├── .gitignore ├── .rustfmt.toml ├── .taplo.toml ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── Laplace.toml ├── Makefile.toml ├── README.md ├── doc ├── laplace_arch.jpg └── web_apps_arch.jpg ├── examples ├── chat │ ├── client │ │ ├── Cargo.toml │ │ └── src │ │ │ ├── addresses.rs │ │ │ └── main.rs │ ├── common │ │ ├── Cargo.toml │ │ └── src │ │ │ └── lib.rs │ ├── config.toml │ ├── server │ │ ├── Cargo.toml │ │ └── src │ │ │ └── lib.rs │ └── static │ │ ├── index.html │ │ └── main.css ├── echo │ ├── client │ │ ├── Cargo.toml │ │ └── src │ │ │ └── main.rs │ ├── config.toml │ ├── server │ │ ├── Cargo.toml │ │ └── src │ │ │ └── lib.rs │ └── static │ │ └── index.html ├── notes │ ├── client │ │ ├── Cargo.toml │ │ └── src │ │ │ └── main.rs │ ├── common │ │ ├── Cargo.toml │ │ └── src │ │ │ └── lib.rs │ ├── config.toml │ ├── server │ │ ├── Cargo.toml │ │ └── src │ │ │ └── main.rs │ └── static │ │ ├── index.html │ │ └── main.css └── todo │ ├── client │ ├── Cargo.toml │ └── src │ │ └── main.rs │ ├── common │ ├── Cargo.toml │ └── src │ │ └── lib.rs │ ├── config.toml │ ├── server │ ├── Cargo.toml │ └── src │ │ └── lib.rs │ └── static │ └── index.html ├── laplace_client ├── Cargo.toml └── src │ ├── i18n.rs │ └── main.rs ├── laplace_common ├── Cargo.toml └── src │ ├── api.rs │ ├── api │ ├── p2p.rs │ └── update.rs │ ├── lapp.rs │ ├── lapp │ ├── access.rs │ └── settings.rs │ └── lib.rs ├── laplace_mobile ├── Cargo.lock ├── Cargo.toml ├── README.md └── src │ ├── assets.rs │ ├── lib.rs │ ├── main.rs │ └── panic.rs ├── laplace_server ├── Cargo.toml └── src │ ├── auth.rs │ ├── auth │ └── middleware.rs │ ├── cli.rs │ ├── convert.rs │ ├── error.rs │ ├── lapps.rs │ ├── lapps │ ├── instance.rs │ ├── lapp.rs │ ├── manager.rs │ ├── provider.rs │ ├── settings.rs │ ├── wasm_interop.rs │ └── wasm_interop │ │ ├── database.rs │ │ ├── http.rs │ │ └── sleep.rs │ ├── lib.rs │ ├── main.rs │ ├── service.rs │ ├── service │ ├── gossipsub.rs │ ├── gossipsub │ │ └── error.rs │ ├── lapp.rs │ └── websocket.rs │ ├── settings.rs │ ├── web_api.rs │ └── web_api │ ├── laplace.rs │ ├── laplace │ └── handler.rs │ ├── lapp.rs │ └── lapp │ └── handler.rs ├── laplace_wasm ├── Cargo.toml └── src │ ├── database.rs │ ├── http.rs │ ├── http │ ├── request.rs │ └── response.rs │ ├── lib.rs │ ├── route.rs │ ├── route │ ├── gossipsub.rs │ ├── http.rs │ └── websocket.rs │ ├── sleep.rs │ └── slice.rs ├── laplace_wasm_macro ├── Cargo.toml └── src │ ├── lib.rs │ └── process.rs ├── laplace_yew ├── Cargo.toml └── src │ ├── error.rs │ ├── error │ └── mdc.rs │ ├── html.rs │ └── lib.rs ├── static ├── favicon.ico ├── favicon_io │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ └── favicon-32x32.png ├── index.html ├── laplace.webmanifest ├── main.css └── mdc │ ├── fonts │ ├── materialicons.css │ ├── materialicons │ │ └── v139 │ │ │ └── flUhRq6tzZclQEJ-Vdg-IuiaDsNc.woff2 │ ├── roboto.css │ └── roboto │ │ └── v30 │ │ ├── KFOlCnqEu92Fr1MmEU9fABc4EsA.woff2 │ │ ├── KFOlCnqEu92Fr1MmEU9fBBc4.woff2 │ │ ├── KFOlCnqEu92Fr1MmEU9fBxc4EsA.woff2 │ │ ├── KFOlCnqEu92Fr1MmEU9fCBc4EsA.woff2 │ │ ├── KFOlCnqEu92Fr1MmEU9fCRc4EsA.woff2 │ │ ├── KFOlCnqEu92Fr1MmEU9fChc4EsA.woff2 │ │ ├── KFOlCnqEu92Fr1MmEU9fCxc4EsA.woff2 │ │ ├── KFOlCnqEu92Fr1MmSU5fABc4EsA.woff2 │ │ ├── KFOlCnqEu92Fr1MmSU5fBBc4.woff2 │ │ ├── KFOlCnqEu92Fr1MmSU5fBxc4EsA.woff2 │ │ ├── KFOlCnqEu92Fr1MmSU5fCBc4EsA.woff2 │ │ ├── KFOlCnqEu92Fr1MmSU5fCRc4EsA.woff2 │ │ ├── KFOlCnqEu92Fr1MmSU5fChc4EsA.woff2 │ │ ├── KFOlCnqEu92Fr1MmSU5fCxc4EsA.woff2 │ │ ├── KFOlCnqEu92Fr1MmWUlfABc4EsA.woff2 │ │ ├── KFOlCnqEu92Fr1MmWUlfBBc4.woff2 │ │ ├── KFOlCnqEu92Fr1MmWUlfBxc4EsA.woff2 │ │ ├── KFOlCnqEu92Fr1MmWUlfCBc4EsA.woff2 │ │ ├── KFOlCnqEu92Fr1MmWUlfCRc4EsA.woff2 │ │ ├── KFOlCnqEu92Fr1MmWUlfChc4EsA.woff2 │ │ ├── KFOlCnqEu92Fr1MmWUlfCxc4EsA.woff2 │ │ ├── KFOmCnqEu92Fr1Mu4WxKOzY.woff2 │ │ ├── KFOmCnqEu92Fr1Mu4mxK.woff2 │ │ ├── KFOmCnqEu92Fr1Mu5mxKOzY.woff2 │ │ ├── KFOmCnqEu92Fr1Mu72xKOzY.woff2 │ │ ├── KFOmCnqEu92Fr1Mu7GxKOzY.woff2 │ │ ├── KFOmCnqEu92Fr1Mu7WxKOzY.woff2 │ │ └── KFOmCnqEu92Fr1Mu7mxKOzY.woff2 │ └── v14.0.0 │ ├── material-components-manual-fix.js │ ├── material-components-web.min.css │ ├── material-components-web.min.css.map │ └── material-components-web.min.js └── tests ├── Cargo.toml ├── config └── config.toml ├── src ├── laplace_client.rs ├── laplace_service.rs ├── lib.rs └── port.rs └── tests └── main_access.rs /.gitignore: -------------------------------------------------------------------------------- 1 | **/target/ 2 | /lapps/ 3 | /cert/ 4 | 5 | **/*.rs.bk 6 | 7 | TODO.* 8 | NOTES.md 9 | 10 | **/.idea 11 | **/*.iml 12 | **/.vscode 13 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | # Configuring Rustfmt: https://github.com/rust-lang-nursery/rustfmt/blob/master/Configurations.md 2 | unstable_features = true 3 | max_width = 120 4 | comment_width = 120 5 | edition = "2021" 6 | condense_wildcard_suffixes = true 7 | match_block_trailing_comma = true 8 | format_macro_matchers = true 9 | imports_granularity = "Module" 10 | group_imports = "StdExternalCrate" 11 | newline_style = "Unix" 12 | overflow_delimited_expr = true 13 | use_field_init_shorthand = true 14 | wrap_comments = true 15 | -------------------------------------------------------------------------------- /.taplo.toml: -------------------------------------------------------------------------------- 1 | include = ["**/Cargo.toml", "**/Makefile.toml", ".taplo.toml"] 2 | 3 | [formatting] 4 | # Formatter options: https://taplo.tamasfe.dev/configuration/formatter-options.html 5 | align_comments = false 6 | array_auto_collapse = false 7 | column_width = 120 8 | indent_string = " " 9 | indent_tables = true 10 | reorder_keys = false 11 | 12 | [[rule]] 13 | keys = ["workspace.dependencies"] 14 | 15 | [rule.formatting] 16 | indent_tables = false 17 | 18 | [[rule]] 19 | keys = ["dependencies", "dev-dependencies", "build-dependencies", "workspace.dependencies", "formatting"] 20 | 21 | [rule.formatting] 22 | reorder_keys = true 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] - YYYY-MM-DD 9 | 10 | ### Added 11 | 12 | - Response routing to wasm from services, split wasm messages into In/Out 13 | - Lapp setting `application.autoload` to configure the lapp to load at Laplace startup or in lazy mode on request from lapp client part 14 | - Lapp setting `application.data_dir` to configure data dir of lapp, "data" by default (the relative path will be inside the lapp directory) 15 | - Display of errors in the client UI 16 | - Make commands for checking and testing 17 | - This changelog file 18 | 19 | ### Fixed 20 | 21 | - It became possible to restart gossipsub service for lapp 22 | - Fix WS close send error 23 | - Improve services communication 24 | 25 | ### Changed 26 | 27 | - Replace wasmer to wasmtime 28 | - Use separated threads for server side wasm 29 | - Lapp loading is now lazy by default (use `application.autoload` setting for change this) 30 | - Update dependencies: borsh 1.1.0, yew 0.21.0, libp2p 0.52.4, wasmtime, etc. 31 | 32 | ### Removed 33 | 34 | - Unneсessary locks of the lapp manager when handling lapp requests 35 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = [ 4 | "examples/chat/client", 5 | "examples/chat/common", 6 | "examples/chat/server", 7 | "examples/echo/client", 8 | "examples/echo/server", 9 | "examples/notes/client", 10 | "examples/notes/common", 11 | "examples/notes/server", 12 | "examples/todo/client", 13 | "examples/todo/common", 14 | "examples/todo/server", 15 | 16 | "laplace_client", 17 | "laplace_common", 18 | "laplace_server", 19 | "laplace_wasm", 20 | "laplace_wasm_macro", 21 | "laplace_yew", 22 | "tests", 23 | ] 24 | exclude = [ 25 | "laplace_mobile", 26 | ] 27 | 28 | [profile.release] 29 | lto = true 30 | 31 | [workspace.dependencies] 32 | borsh = { version = "1.5", features = ["derive"] } 33 | derive_more = { version = "1.0", features = ["from", "display", "deref", "deref_mut"] } 34 | strum = { version = "0.26", features = ["derive"] } 35 | tokio = { version = "1.41", features = ["full"] } 36 | yew = { version = "0.21", features = ["csr"] } 37 | yew-mdc-widgets = { git = "https://github.com/noogen-projects/yew-mdc-widgets" } 38 | thiserror = "2.0" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Noogen & Alexander Mescheryakov 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. 22 | -------------------------------------------------------------------------------- /Laplace.toml: -------------------------------------------------------------------------------- 1 | [http] 2 | host = "127.0.0.1" 3 | port = 8080 4 | access_token = "24tpHRcbGKGYFGMYq66G3hfH8GQEYGTysXqiJyaCy9eR" 5 | upload_file_limit = 2147483648 # 2 GB 6 | print_url = true 7 | 8 | [ssl] 9 | enabled = true 10 | private_key_path = "cert/key.pem" 11 | certificate_path = "cert/cert.pem" 12 | 13 | [p2p] 14 | mdns_discovery_enabled = true 15 | 16 | [log] 17 | spec = "info,hyper=info,rustls=info,regalloc=warn,cranelift_codegen=info,h2=info,netlink_proto=info" 18 | 19 | [lapps] 20 | path = "lapps" 21 | #allowed = ["echo", "notes"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laplace 2 | 3 | The local-first web-application platform for the decentralized web. 4 | 5 | Look at the traditional centralized web-applications architecture vs. the local-first web-applications architecture: 6 | ![traditional and local-first web-applications architecture](doc/web_apps_arch.jpg) 7 | 8 | The Laplace high-level architecture is shown in the diagram below: 9 | ![laplace architecture](doc/laplace_arch.jpg) 10 | 11 | ## Build 12 | 13 | Building Laplace requires the latest `stable` and `nightly` Rust toolchains, the `wasm32` targets and `cargo-make` and 14 | `wasm-bindgen` build tools. 15 | 16 | To install Rust and its toolchains/targets via [rustup](https://rustup.rs/), if it is not already installed, run: 17 | 18 | ```shell 19 | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh 20 | source $HOME/.cargo/env 21 | 22 | rustup toolchain install stable nightly 23 | rustup target add wasm32-unknown-unknown --toolchain stable 24 | rustup target add wasm32-wasi --toolchain nightly 25 | ``` 26 | 27 | To install [cargo-make](https://github.com/sagiegurari/cargo-make) and 28 | [wasm-bindgen](https://github.com/rustwasm/wasm-bindgen), run: 29 | 30 | ```shell 31 | cargo install --force cargo-make wasm-bindgen-cli 32 | ``` 33 | 34 | To build Laplace and all examples, run: 35 | 36 | ```shell 37 | cargo make all 38 | ``` 39 | 40 | Or for a debug build, use the following command: 41 | 42 | ```shell 43 | cargo make -p debug all 44 | ``` 45 | 46 | ## Run examples 47 | 48 | Run the Laplace server: 49 | 50 | ```shell 51 | cargo make run 52 | ``` 53 | 54 | Or for a debug build: 55 | 56 | ```shell 57 | cargo make -p debug run 58 | ``` 59 | 60 | Then visit [http://localhost:8080](http://localhost:8080). If you are running for the first time or the previous link 61 | did not work, then visit [http://localhost:8080/?access_token=24tpHRcbGKGYFGMYq66G3hfH8GQEYGTysXqiJyaCy9eR](http://localhost:8080/?access_token=24tpHRcbGKGYFGMYq66G3hfH8GQEYGTysXqiJyaCy9eR). 62 | You can change the default port, access token and other settings by editing `Laplace.toml` config file. 63 | 64 | ## Development notes 65 | 66 | To check the project, use the following command: 67 | 68 | ```shell script 69 | cargo make check 70 | ``` 71 | 72 | To run all tests, use the following command: 73 | 74 | ```shell script 75 | cargo make test 76 | ``` 77 | 78 | To check and perform formatting, use the following commands: 79 | 80 | ```shell script 81 | cargo make checkfmt 82 | cargo make fmt 83 | ``` 84 | 85 | To run clippy, use the following command: 86 | 87 | ```shell script 88 | cargo make clippy 89 | ``` 90 | -------------------------------------------------------------------------------- /doc/laplace_arch.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/doc/laplace_arch.jpg -------------------------------------------------------------------------------- /doc/web_apps_arch.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/doc/web_apps_arch.jpg -------------------------------------------------------------------------------- /examples/chat/client/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "chat_client" 3 | version = "0.1.0" 4 | authors = [ 5 | "Alexander Mescheryakov ", 6 | "Noogen Team ", 7 | ] 8 | edition = "2021" 9 | license = "MIT" 10 | readme = "README.md" 11 | repository = "https://github.com/noogen-projects/laplace" 12 | description = "The Chat lapp example" 13 | 14 | [dependencies] 15 | anyhow = "1.0" 16 | bs58 = "0.5" 17 | chat_common = { path = "../common" } 18 | getrandom = { version = "0.2", features = ["js"] } 19 | gloo-timers = "0.3" 20 | laplace_yew = { path = "../../../laplace_yew" } 21 | libp2p-identity = { version = "0.2", features = ["ed25519", "rand", "peerid"] } 22 | pulldown-cmark = "0.10" 23 | serde_json = "1.0" 24 | wasm-web-helpers = "0.2" 25 | web-sys = { version = "0.3", features = [ 26 | "Window", 27 | "Document", 28 | "HtmlElement", 29 | "HtmlInputElement", 30 | "HtmlTextAreaElement", 31 | "CssStyleDeclaration", 32 | "Range", 33 | "Selection", 34 | "HtmlCollection", 35 | ] } 36 | yew = { workspace = true } 37 | yew-mdc-widgets = { workspace = true } 38 | -------------------------------------------------------------------------------- /examples/chat/client/src/addresses.rs: -------------------------------------------------------------------------------- 1 | use gloo_timers::callback::Timeout; 2 | use web_sys::HtmlInputElement; 3 | use yew::html::Scope; 4 | use yew::{html, Component, Context, Html, MouseEvent, Properties}; 5 | use yew_mdc_widgets::dom::{self, JsCast}; 6 | use yew_mdc_widgets::{console, Button, Dialog, Element, IconButton, List, ListItem, MdcWidget, TextField}; 7 | 8 | use super::{Msg as RootMsg, Root}; 9 | 10 | pub(super) struct Addresses { 11 | root_link: Scope, 12 | list: Vec, 13 | } 14 | 15 | pub(super) enum Msg { 16 | Add(String), 17 | Remove(usize), 18 | FinishRemove(String), 19 | } 20 | 21 | #[derive(Properties, Clone)] 22 | pub(super) struct Props { 23 | pub(super) root: Scope, 24 | pub(super) list: Vec, 25 | } 26 | 27 | impl PartialEq for Props { 28 | fn eq(&self, other: &Self) -> bool { 29 | self.list.eq(&other.list) 30 | } 31 | } 32 | 33 | impl Component for Addresses { 34 | type Message = Msg; 35 | type Properties = Props; 36 | 37 | fn create(ctx: &Context) -> Self { 38 | ctx.props() 39 | .root 40 | .send_message(RootMsg::LinkAddresses(ctx.link().clone())); 41 | Self { 42 | root_link: ctx.props().root.clone(), 43 | list: ctx.props().list.clone(), 44 | } 45 | } 46 | 47 | fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { 48 | match msg { 49 | Msg::Add(address) => { 50 | if !address.is_empty() && !self.list.contains(&address) { 51 | self.root_link.send_message(RootMsg::AddAddress(address.clone())); 52 | self.list.push(address); 53 | true 54 | } else { 55 | false 56 | } 57 | }, 58 | Msg::Remove(index) => { 59 | let address = self.list.remove(index); 60 | Timeout::new(200, { 61 | let callback = ctx.link().callback(move |_| Msg::FinishRemove(address.clone())); 62 | move || callback.emit(()) 63 | }) 64 | .forget(); 65 | false 66 | }, 67 | Msg::FinishRemove(address) => { 68 | console::log!(&format!("Remove {address}")); 69 | dom::existing::get_element_by_id::(&address).remove(); 70 | false 71 | }, 72 | } 73 | } 74 | 75 | fn view(&self, ctx: &Context) -> Html { 76 | let address_items = self.list.iter().enumerate().map(|(index, address)| { 77 | ListItem::new() 78 | .id(address) 79 | .text(html! { { address } }) 80 | .tile(IconButton::new().icon("close").on_click(ctx.link().callback({ 81 | let address = address.clone(); 82 | move |_| { 83 | let item = dom::existing::get_element_by_id::(&address); 84 | item.set_class_name(&format!("{} exited", item.class_name())); 85 | Msg::Remove(index) 86 | } 87 | }))) 88 | .on_click(|event: MouseEvent| { 89 | if let Ok(range) = dom::existing::document().create_range() { 90 | if let Some(element) = event 91 | .target() 92 | .and_then(|target| JsCast::dyn_into::(target).ok()) 93 | { 94 | let node = element.children().get_with_index(1).unwrap_or(element); 95 | 96 | if (node.tag_name() == "STRONG" || node.class_name() == ListItem::TEXT_ITEM_CLASS) 97 | && range.select_node_contents(&node).is_ok() 98 | { 99 | if let Ok(Some(selection)) = dom::existing::window().get_selection() { 100 | selection.remove_all_ranges().ok(); 101 | selection.add_range(&range).ok(); 102 | } 103 | } 104 | } 105 | } 106 | }) 107 | }); 108 | 109 | Dialog::new() 110 | .id("addresses-dialog") 111 | .title(html! {

{ "Addresses" }

}) 112 | .content(List::ul().id("addresses-list").items(address_items)) 113 | .action( 114 | TextField::outlined() 115 | .id("new-address") 116 | .class("address-textfield") 117 | .label("New address"), 118 | ) 119 | .action( 120 | Button::new() 121 | .label("Add") 122 | .class(Dialog::BUTTON_CLASS) 123 | .on_click(ctx.link().callback(move |_| { 124 | let address = dom::existing::select_element::("#new-address > input").value(); 125 | Msg::Add(address) 126 | })), 127 | ) 128 | .into() 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /examples/chat/common/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "chat_common" 3 | version = "0.1.0" 4 | authors = [ 5 | "Alexander Mescheryakov ", 6 | "Noogen Team ", 7 | ] 8 | edition = "2021" 9 | license = "MIT" 10 | readme = "README.md" 11 | repository = "https://github.com/noogen-projects/laplace" 12 | description = "The Chat lapp example" 13 | 14 | [dependencies] 15 | serde = { version = "1.0", features = ["derive"] } 16 | -------------------------------------------------------------------------------- /examples/chat/common/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Debug, Deserialize, Serialize)] 6 | pub struct Peer { 7 | pub peer_id: Vec, 8 | pub keypair: Vec, 9 | } 10 | 11 | #[derive(Debug, Deserialize, Serialize)] 12 | pub struct ChatWsMessage { 13 | pub peer_id: String, 14 | pub msg: String, 15 | } 16 | 17 | #[derive(Debug, Deserialize, Serialize)] 18 | pub enum ChatWsRequest { 19 | AddPeer(String), 20 | AddAddress(String), 21 | UpdateName(String), 22 | SendMessage(ChatWsMessage), 23 | } 24 | 25 | #[derive(Debug, Deserialize, Serialize)] 26 | pub enum ChatWsResponse { 27 | AddPeerResult(String, Result<(), String>), 28 | AddAddressResult(String, Result<(), String>), 29 | SendMessageResult(String, Result<(), String>), 30 | ReceiveMessage(ChatWsMessage), 31 | InternalError(String), 32 | } 33 | 34 | impl ChatWsResponse { 35 | pub fn make_error_json_string(err: E) -> String { 36 | format!(r#"{{"InternalError":"{err:?}"}}"#) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /examples/chat/config.toml: -------------------------------------------------------------------------------- 1 | [application] 2 | title = "Chat" 3 | enabled = true 4 | description = "The Chat local-first web app example" 5 | tags = ["example", "chat"] 6 | access_token = "JDQmIY3pxSI28Y3U5x6BpTmnx0ojFp6Emc29zrEOrmA0bWJhZue97dJ4YNjxyERX" 7 | 8 | [permissions] 9 | required = ["client_http", "websocket", "tcp", "lapps_outgoing"] 10 | allowed = ["client_http", "websocket", "tcp", "lapps_outgoing"] 11 | 12 | [network.gossipsub] 13 | addr = "/ip4/0.0.0.0/tcp/36598" 14 | dial_ports = [36598, 36599] 15 | 16 | [[lapp_requests]] 17 | lapp_name = "sowa" 18 | 19 | [[lapp_requests.outgoing]] 20 | methods = ["get"] 21 | request = "account/.*" 22 | 23 | [[lapp_requests.outgoing]] 24 | methods = ["post"] 25 | request = "transfer/.*" 26 | -------------------------------------------------------------------------------- /examples/chat/server/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "chat_server" 3 | version = "0.1.0" 4 | authors = [ 5 | "Alexander Mescheryakov ", 6 | "Noogen Team ", 7 | ] 8 | edition = "2021" 9 | license = "MIT" 10 | readme = "README.md" 11 | repository = "https://github.com/noogen-projects/laplace" 12 | description = "The Chat lapp example" 13 | 14 | [lib] 15 | crate-type = ["cdylib"] 16 | 17 | [dependencies] 18 | borsh = { workspace = true } 19 | chat_common = { path = "../common" } 20 | laplace_wasm = { path = "../../../laplace_wasm" } 21 | serde_json = "1.0" 22 | -------------------------------------------------------------------------------- /examples/chat/server/src/lib.rs: -------------------------------------------------------------------------------- 1 | use borsh::BorshDeserialize; 2 | use chat_common::{ChatWsMessage, ChatWsRequest, ChatWsResponse}; 3 | use laplace_wasm::route::{gossipsub, websocket}; 4 | pub use laplace_wasm::{alloc, dealloc}; 5 | use laplace_wasm::{Route, WasmSlice}; 6 | 7 | #[no_mangle] 8 | pub extern "C" fn route_ws(msg: WasmSlice) -> WasmSlice { 9 | let routes = match do_ws(unsafe { msg.into_vec_in_wasm() }) { 10 | DoWsResult::Empty => vec![], 11 | DoWsResult::Close => vec![Route::Gossipsub(gossipsub::MessageOut { 12 | id: "close".into(), 13 | msg: gossipsub::Message::Close, 14 | })], 15 | DoWsResult::AddPeer(peer_id) => vec![Route::Gossipsub(gossipsub::MessageOut { 16 | id: format!("add_peer:{peer_id}"), 17 | msg: gossipsub::Message::Dial(peer_id), 18 | })], 19 | DoWsResult::AddAddress(address) => vec![Route::Gossipsub(gossipsub::MessageOut { 20 | id: format!("add_address:{address}"), 21 | msg: gossipsub::Message::AddAddress(address), 22 | })], 23 | DoWsResult::Msg(ChatWsMessage { peer_id, msg }) => vec![Route::Gossipsub(gossipsub::MessageOut { 24 | id: format!("send_message:{peer_id}"), 25 | msg: gossipsub::Message::Text { peer_id, msg }, 26 | })], 27 | DoWsResult::Response(response) => vec![route_ws_message_out("", &response)], 28 | }; 29 | WasmSlice::from(borsh::to_vec(&routes).expect("Routes should be serializable")) 30 | } 31 | 32 | #[no_mangle] 33 | pub extern "C" fn route_gossipsub(msg: WasmSlice) -> WasmSlice { 34 | let response = do_gossipsub(unsafe { msg.into_vec_in_wasm() }); 35 | let routes = vec![route_ws_message_out("", &response)]; 36 | 37 | WasmSlice::from(borsh::to_vec(&routes).expect("Routes should be serializable")) 38 | } 39 | 40 | fn route_ws_message_out(id: impl Into, response: &ChatWsResponse) -> Route { 41 | let message = serde_json::to_string(response).unwrap_or_else(ChatWsResponse::make_error_json_string); 42 | Route::WebSocket(websocket::MessageOut { 43 | id: id.into(), 44 | msg: websocket::Message::Text(message), 45 | }) 46 | } 47 | 48 | enum DoWsResult { 49 | Empty, 50 | Close, 51 | AddPeer(String), 52 | AddAddress(String), 53 | Msg(ChatWsMessage), 54 | Response(ChatWsResponse), 55 | } 56 | 57 | impl From for DoWsResult { 58 | fn from(response: ChatWsResponse) -> Self { 59 | Self::Response(response) 60 | } 61 | } 62 | 63 | fn do_ws(msg: Vec) -> DoWsResult { 64 | let msg: websocket::MessageIn = match BorshDeserialize::deserialize(&mut msg.as_slice()) { 65 | Ok(msg) => msg, 66 | Err(_err) => return DoWsResult::Close, 67 | }; 68 | match msg { 69 | websocket::MessageIn::Message(websocket::Message::Text(text)) => { 70 | let request: ChatWsRequest = match serde_json::from_str(&text) { 71 | Ok(request) => request, 72 | Err(err) => return ChatWsResponse::InternalError(err.to_string()).into(), 73 | }; 74 | match request { 75 | ChatWsRequest::AddPeer(peer_id) => DoWsResult::AddPeer(peer_id), 76 | ChatWsRequest::AddAddress(address) => DoWsResult::AddAddress(address), 77 | ChatWsRequest::SendMessage(msg) => DoWsResult::Msg(msg), 78 | request => ChatWsResponse::InternalError(format!("Unexpected request {request:?}")).into(), 79 | } 80 | }, 81 | websocket::MessageIn::Message(websocket::Message::Binary(data)) => { 82 | ChatWsResponse::InternalError(format!("Wrong message data: {data:?}")).into() 83 | }, 84 | websocket::MessageIn::Response { id: _, result } if result.is_ok() => DoWsResult::Empty, 85 | _ => DoWsResult::Close, 86 | } 87 | } 88 | 89 | fn do_gossipsub(msg: Vec) -> ChatWsResponse { 90 | let msg: gossipsub::MessageIn = match BorshDeserialize::deserialize(&mut msg.as_slice()) { 91 | Ok(msg) => msg, 92 | Err(err) => return ChatWsResponse::InternalError(err.to_string()), 93 | }; 94 | match msg { 95 | gossipsub::MessageIn::Text { peer_id, msg } => ChatWsResponse::ReceiveMessage(ChatWsMessage { peer_id, msg }), 96 | gossipsub::MessageIn::Response { id, result } => { 97 | let result = result.map_err(|err| err.message); 98 | if let Some(peer_id) = id.strip_prefix("add_peer:") { 99 | ChatWsResponse::AddPeerResult(peer_id.into(), result) 100 | } else if let Some(address) = id.strip_prefix("add_address:") { 101 | ChatWsResponse::AddAddressResult(address.into(), result) 102 | } else if let Some(peer_id) = id.strip_prefix("send_message:") { 103 | ChatWsResponse::SendMessageResult(peer_id.into(), result) 104 | } else { 105 | ChatWsResponse::InternalError(format!("Unknown operation result. id: {id}, result: {result:?}")) 106 | } 107 | }, 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /examples/chat/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Chat lapp 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 23 | 24 | -------------------------------------------------------------------------------- /examples/chat/static/main.css: -------------------------------------------------------------------------------- 1 | #root { 2 | margin-bottom: 0; 3 | } 4 | 5 | .sig-in-field { 6 | padding: 10px; 7 | } 8 | 9 | .expand { 10 | width: 100%; 11 | } 12 | 13 | .keys-form { 14 | width: 470px; 15 | margin: auto; 16 | } 17 | 18 | .keys-form .mdc-list-item { 19 | cursor: default; 20 | padding: 10px; 21 | } 22 | 23 | .address-textfield { 24 | width: 300px; 25 | margin: auto; 26 | } 27 | 28 | .exited { 29 | transform: scale(0); 30 | opacity: 0; 31 | transition: opacity 15ms linear 150ms,transform 180ms 0ms cubic-bezier(0.4, 0, 1, 1); 32 | } 33 | 34 | .sign-in-actions { 35 | position: fixed; 36 | right: 0; 37 | } 38 | 39 | .sign-in-actions > .mdc-button { 40 | margin-left: 10px; 41 | } 42 | 43 | .content-container { 44 | margin: 0; 45 | max-width: 100%; 46 | display: flex; 47 | } 48 | 49 | .scrollable-content { 50 | overflow-y: auto; 51 | } 52 | 53 | .chat-screen { 54 | display: flex; 55 | width: 100%; 56 | } 57 | 58 | .chat-sidebar { 59 | position: relative; 60 | flex: 0 0 auto; 61 | width: 400px; 62 | } 63 | 64 | .chat-flex-container { 65 | width: inherit; 66 | display: flex; 67 | flex-direction: column; 68 | height: calc(100vh - 64px); 69 | } 70 | 71 | .chat-sidebar-split-handle { 72 | flex: 0 0 auto; 73 | position: relative; 74 | box-sizing: border-box; 75 | width: 4px; 76 | height: 100%; 77 | border-left-width: 1px; 78 | border-left-style: solid; 79 | border-left-color: #f7f7f7; 80 | border-right-width: 1px; 81 | border-right-style: solid; 82 | border-right-color: #f7f7f7; 83 | -webkit-touch-callout: none; 84 | user-select: none; 85 | } 86 | 87 | .chat-editor-split-handle { 88 | flex: 0 0 auto; 89 | position: relative; 90 | box-sizing: border-box; 91 | width: 100%; 92 | height: 4px; 93 | border-top-width: 1px; 94 | border-top-style: solid; 95 | border-top-color: #f7f7f7; 96 | border-bottom-width: 1px; 97 | border-bottom-style: solid; 98 | border-bottom-color: #f7f7f7; 99 | -webkit-touch-callout: none; 100 | user-select: none; 101 | } 102 | 103 | .resize-hor-cursor { 104 | cursor: col-resize; 105 | } 106 | 107 | .resize-ver-cursor { 108 | cursor: row-resize; 109 | } 110 | 111 | .centered-hor { 112 | margin-left: auto; 113 | margin-right: auto; 114 | } 115 | 116 | .chat-main { 117 | position: relative; 118 | width: 100%; 119 | } 120 | 121 | .chat-messages { 122 | background-color: #f7f7f7; 123 | padding: 0 20px 20px; 124 | overflow: auto; 125 | height: 100%; 126 | flex: auto; 127 | } 128 | 129 | .chat-editor { 130 | flex: auto; 131 | padding-top: 10px; 132 | padding-bottom: 10px; 133 | } 134 | 135 | .chat-editor .mdc-text-field--textarea { 136 | width: 100%; 137 | } 138 | 139 | .mine-message { 140 | font-style: italic; 141 | } -------------------------------------------------------------------------------- /examples/echo/client/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "echo_client" 3 | version = "0.1.0" 4 | authors = [ 5 | "Alexander Mescheryakov ", 6 | "Noogen Team ", 7 | ] 8 | edition = "2021" 9 | license = "MIT" 10 | readme = "README.md" 11 | repository = "https://github.com/noogen-projects/laplace" 12 | description = "The Echo lapp example" 13 | 14 | [dependencies] 15 | laplace_yew = { path = "../../../laplace_yew" } 16 | wasm-web-helpers = "0.2" 17 | web-sys = { version = "0.3", features = ["Window", "Document", "HtmlInputElement"] } 18 | yew = { workspace = true } 19 | yew-mdc-widgets = { workspace = true } 20 | -------------------------------------------------------------------------------- /examples/echo/client/src/main.rs: -------------------------------------------------------------------------------- 1 | #![recursion_limit = "256"] 2 | 3 | use laplace_yew::error::{Errors, ErrorsMsg}; 4 | use wasm_web_helpers::error::Result; 5 | use wasm_web_helpers::fetch::{fetch_success_text, Request, Response}; 6 | use wasm_web_helpers::spawn_local; 7 | use web_sys::HtmlInputElement; 8 | use yew::html::Scope; 9 | use yew::{html, Component, Context, Html}; 10 | use yew_mdc_widgets::{auto_init, console, dom, Button, List, ListItem, MdcWidget, TextField, TopAppBar}; 11 | 12 | type ErrorsLink = Scope>; 13 | 14 | struct Root { 15 | responses: Vec, 16 | errors_link: Option, 17 | } 18 | 19 | enum Msg { 20 | Submit, 21 | Fetch(String), 22 | Error(String), 23 | SetErrorsLink(ErrorsLink), 24 | } 25 | 26 | impl From for Msg { 27 | fn from(link: ErrorsLink) -> Self { 28 | Self::SetErrorsLink(link) 29 | } 30 | } 31 | 32 | impl Component for Root { 33 | type Message = Msg; 34 | type Properties = (); 35 | 36 | fn create(_ctx: &Context) -> Self { 37 | Self { 38 | responses: Vec::new(), 39 | errors_link: None, 40 | } 41 | } 42 | 43 | fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { 44 | match msg { 45 | Msg::Submit => { 46 | let uri = dom::existing::select_element::("#uri > input").value(); 47 | if !uri.is_empty() { 48 | let request = Request::get(&format!("/echo/api/{uri}")); 49 | let callback = ctx.link().callback(|result: Result<(Response, Result)>| { 50 | match result.and_then(|(_, body)| body) { 51 | Ok(body) => Msg::Fetch(body), 52 | Err(err) => Msg::Error(format!("Fetch error: {err:?}")), 53 | } 54 | }); 55 | spawn_local(async move { 56 | callback.emit(fetch_success_text(request).await); 57 | }); 58 | } 59 | false 60 | }, 61 | Msg::Fetch(data) => { 62 | self.responses.push(data); 63 | true 64 | }, 65 | Msg::Error(error) => { 66 | console::error!(&error); 67 | if let Some(link) = self.errors_link.as_ref() { 68 | link.callback(move |_| ErrorsMsg::Spawn(error.clone())).emit(()); 69 | } 70 | false 71 | }, 72 | Msg::SetErrorsLink(link) => { 73 | self.errors_link = Some(link); 74 | false 75 | }, 76 | } 77 | } 78 | 79 | fn view(&self, ctx: &Context) -> Html { 80 | let top_app_bar = TopAppBar::new() 81 | .id("top-app-bar") 82 | .title("Echo lapp") 83 | .enable_shadow_when_scroll_window(); 84 | 85 | let mut list = List::ul().divider(); 86 | for uri in self.responses.iter().rev() { 87 | list = list.item(ListItem::new().text(uri)).divider(); 88 | } 89 | 90 | html! { 91 | <> 92 |
93 | { top_app_bar } 94 |
95 |
96 |

{ "Echo" }

97 |
98 |
99 |
100 | { TextField::filled().id("uri").class("expand").label("URI") } 101 |
102 |
103 | { Button::raised().label("submit").on_click(ctx.link().callback(|_| Msg::Submit)) } 104 |
105 |
106 |
107 | { list } 108 |
109 |
110 | /> 111 |
112 | 113 | } 114 | } 115 | 116 | fn rendered(&mut self, _ctx: &Context, _first_render: bool) { 117 | auto_init(); 118 | } 119 | } 120 | 121 | fn main() { 122 | let root = dom::existing::get_element_by_id("root"); 123 | yew::Renderer::::with_root(root).render(); 124 | } 125 | -------------------------------------------------------------------------------- /examples/echo/config.toml: -------------------------------------------------------------------------------- 1 | [application] 2 | title = "Echo" 3 | enabled = true 4 | description = "The Echo local-first web app example" 5 | tags = ["example", "echo"] 6 | access_token = "HWAJn0ty4WIIqURJXNA66Jfk5piM7Prsod7iSVcWpEkvI64oCenJRL5PYcjaqLOe" 7 | autoload = true 8 | 9 | [permissions] 10 | required = ["client_http"] 11 | allowed = ["client_http"] 12 | -------------------------------------------------------------------------------- /examples/echo/server/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "echo_server" 3 | version = "0.1.0" 4 | authors = [ 5 | "Alexander Mescheryakov ", 6 | "Noogen Team ", 7 | ] 8 | edition = "2021" 9 | license = "MIT" 10 | readme = "README.md" 11 | repository = "https://github.com/noogen-projects/laplace" 12 | description = "The Echo lapp example" 13 | 14 | [lib] 15 | crate-type = ["cdylib"] 16 | 17 | [dependencies] 18 | laplace_wasm = { path = "../../../laplace_wasm" } 19 | -------------------------------------------------------------------------------- /examples/echo/server/src/lib.rs: -------------------------------------------------------------------------------- 1 | use laplace_wasm::http; 2 | 3 | #[http::process] 4 | fn http(request: http::Request) -> http::Response { 5 | let mut body = String::from("Echo "); 6 | body.push_str(&request.uri.to_string()); 7 | 8 | http::Response::new(body.into_bytes()) 9 | } 10 | -------------------------------------------------------------------------------- /examples/echo/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Echo lapp 5 | 6 | 7 | 8 | 9 | 10 | 11 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 27 | 28 | -------------------------------------------------------------------------------- /examples/notes/client/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "notes_client" 3 | version = "0.1.0" 4 | authors = [ 5 | "Alexander Mescheryakov ", 6 | "Noogen Team ", 7 | ] 8 | edition = "2021" 9 | license = "MIT" 10 | readme = "README.md" 11 | repository = "https://github.com/noogen-projects/laplace" 12 | description = "The Notes lapp example" 13 | 14 | [dependencies] 15 | anyhow = "1.0" 16 | laplace_yew = { path = "../../../laplace_yew" } 17 | lew = { git = "https://github.com/noogen-projects/lew" } 18 | notes_common = { path = "../common" } 19 | pulldown-cmark = "0.10" 20 | wasm-web-helpers = "0.2" 21 | web-sys = { version = "0.3", features = [ 22 | "Window", 23 | "Document", 24 | "HtmlInputElement", 25 | "HtmlElement", 26 | "HtmlTextAreaElement", 27 | "DomStringMap", 28 | ] } 29 | yew = { workspace = true } 30 | yew-mdc-widgets = { workspace = true } 31 | -------------------------------------------------------------------------------- /examples/notes/common/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "notes_common" 3 | version = "0.1.0" 4 | authors = [ 5 | "Alexander Mescheryakov ", 6 | "Noogen Team ", 7 | ] 8 | edition = "2021" 9 | license = "MIT" 10 | readme = "README.md" 11 | repository = "https://github.com/noogen-projects/laplace" 12 | description = "The Notes lapp example" 13 | 14 | [dependencies] 15 | serde = { version = "1.0", features = ["derive"] } 16 | -------------------------------------------------------------------------------- /examples/notes/common/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt, io, iter}; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Debug, Deserialize, Serialize)] 6 | pub struct Note { 7 | pub name: String, 8 | pub content: NoteContent, 9 | } 10 | 11 | #[derive(Debug, Deserialize, Serialize)] 12 | pub enum NoteContent { 13 | Preview(String), 14 | FullBody(String), 15 | } 16 | 17 | impl NoteContent { 18 | pub const PREVIEW_LIMIT: usize = 300; 19 | 20 | pub fn content(&self) -> Option<&str> { 21 | match self { 22 | Self::Preview(_) => None, 23 | Self::FullBody(content) => Some(content.as_str()), 24 | } 25 | } 26 | 27 | pub fn preview(&self) -> Option<&str> { 28 | match self { 29 | Self::Preview(preview) => Some(preview.as_str()), 30 | Self::FullBody(_) => None, 31 | } 32 | } 33 | 34 | pub fn make_preview(&self) -> String { 35 | match self { 36 | Self::Preview(preview) => preview.clone(), 37 | Self::FullBody(content) => { 38 | make_preview(content.lines().map(|line| Ok(line.to_string()))).expect("Lines should be always Ok") 39 | }, 40 | } 41 | } 42 | } 43 | 44 | pub fn make_preview(lines: impl Iterator>) -> io::Result { 45 | let mut preview = String::new(); 46 | let mut preview_chars = 0; 47 | 48 | let mut prev_line = String::new(); 49 | 'lines: for line in lines { 50 | let line = line?; 51 | if line.starts_with("---") && prev_line.is_empty() { 52 | break 'lines; 53 | } 54 | 55 | for ch in line.chars().chain(iter::once('\n')) { 56 | preview.push(ch); 57 | preview_chars += 1; 58 | if preview_chars >= NoteContent::PREVIEW_LIMIT { 59 | break 'lines; 60 | } 61 | } 62 | prev_line = line; 63 | } 64 | 65 | if preview.ends_with("\n\n") { 66 | preview.remove(preview.len() - 1); 67 | } 68 | 69 | Ok(preview) 70 | } 71 | 72 | #[derive(Debug, Deserialize, Serialize)] 73 | pub enum Response { 74 | Notes(Vec), 75 | Note(Note), 76 | Error(String), 77 | } 78 | 79 | impl Response { 80 | pub fn json_error_from(err: E) -> String { 81 | format!(r#"{{"Error":"{err:?}"}}"#) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /examples/notes/config.toml: -------------------------------------------------------------------------------- 1 | [application] 2 | title = "Notes" 3 | enabled = true 4 | description = "The Notes local-first web app example" 5 | tags = ["example", "notes"] 6 | access_token = "RwUWYSsd0pq5lwLxd2jOl06Mh2hkGLwZ9DoCUh7VzzUfU2oxrhpr72boZ4lkd8Fy" 7 | additional_static_dirs = ["data"] 8 | data_dir = "data" 9 | 10 | [permissions] 11 | required = ["file_read", "file_write", "client_http"] 12 | allowed = ["file_read", "file_write", "client_http"] 13 | -------------------------------------------------------------------------------- /examples/notes/server/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "notes_server" 3 | version = "0.1.0" 4 | authors = [ 5 | "Alexander Mescheryakov ", 6 | "Noogen Team ", 7 | ] 8 | edition = "2021" 9 | license = "MIT" 10 | readme = "README.md" 11 | repository = "https://github.com/noogen-projects/laplace" 12 | description = "The Notes lapp example" 13 | 14 | [dependencies] 15 | laplace_wasm = { path = "../../../laplace_wasm" } 16 | notes_common = { path = "../common" } 17 | serde_json = "1.0" 18 | thiserror = { workspace = true } 19 | -------------------------------------------------------------------------------- /examples/notes/server/src/main.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | 3 | use std::fs::{self, DirEntry, File}; 4 | use std::io::{self, BufRead, BufReader}; 5 | use std::path::Path; 6 | 7 | use laplace_wasm::http::{self, Method, Uri}; 8 | use notes_common::{make_preview, Note, NoteContent, Response}; 9 | use thiserror::Error; 10 | 11 | #[http::process] 12 | fn http(request: http::Request) -> http::Response { 13 | let http::Request { method, uri, body, .. } = request; 14 | let response = match method { 15 | Method::GET => NotesRequest::parse(uri, None) 16 | .map(|request| request.process()) 17 | .unwrap_or_else(Response::Error), 18 | Method::POST => NotesRequest::parse(uri, Some(body)) 19 | .map(|request| request.process()) 20 | .unwrap_or_else(Response::Error), 21 | method => Response::Error(format!("Unsupported HTTP method {}", method)), 22 | }; 23 | 24 | let response = serde_json::to_string(&response).unwrap_or_else(Response::json_error_from); 25 | http::Response::new(response.into_bytes()) 26 | } 27 | 28 | #[derive(Debug, Error)] 29 | enum NoteError { 30 | #[error("IO error: {0}")] 31 | Io(#[from] io::Error), 32 | 33 | #[error("File name is not valid utf-8 string")] 34 | WrongFileName, 35 | } 36 | 37 | impl From for Response { 38 | fn from(err: NoteError) -> Self { 39 | Response::Error(format!("{}", err)) 40 | } 41 | } 42 | 43 | enum NotesRequest { 44 | GetNotes, 45 | GetNote(String), 46 | UpdateNote(String, String), 47 | RenameNote(String, String), 48 | DeleteNote(String), 49 | } 50 | 51 | impl NotesRequest { 52 | fn parse(uri: Uri, body: Option>) -> Result { 53 | let path = uri.path(); 54 | let chunks: Vec<_> = path.split('/').collect(); 55 | 56 | match &chunks[..] { 57 | [.., "list"] => Ok(Self::GetNotes), 58 | [.., "note", name] => { 59 | if let Some(body) = body { 60 | let content = String::from_utf8(body).map_err(|err| err.to_string())?; 61 | Ok(Self::UpdateNote(name.to_string(), content)) 62 | } else { 63 | Ok(Self::GetNote(name.to_string())) 64 | } 65 | }, 66 | [.., "rename", name] => { 67 | if let Some(body) = body { 68 | let content = String::from_utf8(body).map_err(|err| err.to_string())?; 69 | Ok(Self::RenameNote(name.to_string(), content.trim().to_string())) 70 | } else { 71 | Err(format!("New name for '{}' not specified", name)) 72 | } 73 | }, 74 | [.., "delete", name] => Ok(Self::DeleteNote(name.to_string())), 75 | _ => Err(format!("Cannot parse uri path {}, {:?}", path, chunks)), 76 | } 77 | } 78 | 79 | fn process(self) -> Response { 80 | match self { 81 | Self::GetNotes => process_notes().map(Response::Notes), 82 | Self::GetNote(name) => process_note(name.as_str()).map(Response::Note), 83 | Self::UpdateNote(name, content) => process_update(name.as_str(), content).map(Response::Note), 84 | Self::RenameNote(name, new_name) => process_rename(name.as_str(), new_name.as_str()).map(Response::Notes), 85 | Self::DeleteNote(name) => process_delete(name.as_str()).map(Response::Notes), 86 | } 87 | .unwrap_or_else(Response::from) 88 | } 89 | } 90 | 91 | fn process_notes() -> Result, NoteError> { 92 | let mut notes = vec![]; 93 | 94 | for entry in dir_entries()? { 95 | if let Ok(file_type) = entry.file_type() { 96 | if file_type.is_file() { 97 | let name = entry 98 | .file_name() 99 | .into_string() 100 | .map_err(|_| NoteError::WrongFileName)? 101 | .trim_end_matches(".md") 102 | .to_string(); 103 | 104 | let file = File::open(entry.path())?; 105 | let reader = BufReader::new(file); 106 | let preview = make_preview(reader.lines())?; 107 | 108 | notes.push(Note { 109 | name, 110 | content: NoteContent::Preview(preview), 111 | }); 112 | } 113 | } 114 | } 115 | Ok(notes) 116 | } 117 | 118 | fn process_note(name: &str) -> Result { 119 | let path = Path::new("/").join(format!("{}.md", name)); 120 | let content = fs::read_to_string(path)?; 121 | Ok(Note { 122 | name: name.to_string(), 123 | content: NoteContent::FullBody(content), 124 | }) 125 | } 126 | 127 | fn process_update(name: &str, content: String) -> Result { 128 | let path = Path::new("/").join(format!("{}.md", name)); 129 | 130 | fs::write(path, content)?; 131 | process_note(name) 132 | } 133 | 134 | fn process_delete(name: &str) -> Result, NoteError> { 135 | let path = Path::new("/").join(format!("{}.md", name)); 136 | 137 | fs::remove_file(path)?; 138 | process_notes() 139 | } 140 | 141 | fn process_rename(name: &str, new_name: &str) -> Result, NoteError> { 142 | let from_path = Path::new("/").join(format!("{}.md", name)); 143 | let to_path = Path::new("/").join(format!("{}.md", new_name)); 144 | 145 | fs::rename(from_path, to_path)?; 146 | process_notes() 147 | } 148 | 149 | fn dir_entries() -> io::Result> { 150 | fs::read_dir("/")?.collect() 151 | } 152 | -------------------------------------------------------------------------------- /examples/notes/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Notes lapp example 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 23 | 24 | -------------------------------------------------------------------------------- /examples/notes/static/main.css: -------------------------------------------------------------------------------- 1 | .expand { 2 | width: 100%; 3 | } 4 | 5 | .content-container { 6 | padding-bottom: 5em; 7 | } 8 | 9 | .hidden { 10 | display: none; 11 | } 12 | 13 | .notes { 14 | background-color: #fcfcfd; 15 | } 16 | 17 | .note-card__content { 18 | padding: 1rem; 19 | color: rgba(0, 0, 0, .64); 20 | } 21 | 22 | #edit_mode { 23 | float: right; 24 | top: -10px; 25 | left: 10px; 26 | } 27 | 28 | #add-note-button { 29 | float: right; 30 | } 31 | 32 | .lew-simple { 33 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif; 34 | } 35 | 36 | .lew-simple__textarea { 37 | position: relative; 38 | z-index: 0; 39 | overflow: auto; 40 | box-sizing: border-box; 41 | margin: 0; 42 | padding: 10px 10px 0; 43 | border: 1px solid #e0e0e0; 44 | background: #fff; 45 | color: #000; 46 | vertical-align: top; 47 | font: 16px/1.8 Verdana,Geneva,sans-serif; 48 | resize: vertical; 49 | } 50 | 51 | .lew-simple__toolbar { 52 | background-color: transparent; 53 | display: flex; 54 | align-items: center; 55 | box-sizing: border-box; 56 | margin: 0; 57 | padding: 5px 0; 58 | list-style: none; 59 | font-size: 16px; 60 | } 61 | 62 | .lew-simple__toolbar_item { 63 | fill: #505357; 64 | color: #505357; 65 | padding: 0 5px; 66 | } 67 | 68 | .lew-simple__toolbar_item:hover { 69 | fill: #0560d5; 70 | color: #0560d5; 71 | } 72 | 73 | .lew-simple__tool_button { 74 | vertical-align: middle; 75 | white-space: nowrap; 76 | line-height: normal; 77 | border: 0; 78 | background-color: transparent; 79 | box-shadow: none; 80 | padding: 4px; 81 | cursor: pointer; 82 | user-select: none; 83 | touch-action: manipulation; 84 | display: inline-flex; 85 | align-items: center; 86 | justify-content: center; 87 | } 88 | -------------------------------------------------------------------------------- /examples/todo/client/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "todo_client" 3 | version = "0.1.0" 4 | authors = [ 5 | "Alexander Mescheryakov ", 6 | "Noogen Team ", 7 | ] 8 | edition = "2021" 9 | license = "MIT" 10 | readme = "README.md" 11 | repository = "https://github.com/noogen-projects/laplace" 12 | description = "The Todo lapp example" 13 | 14 | [dependencies] 15 | anyhow = "1.0" 16 | gloo-console = "0.3" 17 | laplace_yew = { path = "../../../laplace_yew" } 18 | strum = { workspace = true } 19 | todo_common = { path = "../common" } 20 | wasm-dom = "1.0" 21 | wasm-web-helpers = "0.2" 22 | web-sys = { version = "0.3", features = ["HtmlInputElement"] } 23 | yew = { workspace = true } 24 | -------------------------------------------------------------------------------- /examples/todo/common/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "todo_common" 3 | version = "0.1.0" 4 | authors = [ 5 | "Alexander Mescheryakov ", 6 | "Noogen Team ", 7 | ] 8 | edition = "2021" 9 | license = "MIT" 10 | readme = "README.md" 11 | repository = "https://github.com/noogen-projects/laplace" 12 | description = "The Todo lapp example" 13 | 14 | [dependencies] 15 | serde = { version = "1.0", features = ["derive"] } 16 | -------------------------------------------------------------------------------- /examples/todo/common/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Debug, Default, Clone, Deserialize, Serialize)] 6 | pub struct Task { 7 | pub description: String, 8 | pub completed: bool, 9 | } 10 | 11 | #[derive(Debug, Deserialize, Serialize)] 12 | pub enum Response { 13 | List(Vec), 14 | Task(Task), 15 | Empty, 16 | Error(String), 17 | } 18 | 19 | impl Response { 20 | pub fn json_error_from(err: E) -> String { 21 | format!(r#"{{"Error":"{err:?}"}}"#) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/todo/config.toml: -------------------------------------------------------------------------------- 1 | [application] 2 | title = "Todo" 3 | enabled = true 4 | description = "The Todo local-first web app example" 5 | tags = ["example", "todo"] 6 | access_token = "OTpFVytjgoRuUpoBACruBcBmxXmREVnmjFMhqAwfJdfQ8amoqkJxJncWZMVo9mGe" 7 | 8 | [permissions] 9 | required = ["database", "client_http"] 10 | allowed = ["database", "client_http"] 11 | 12 | [database] 13 | path = "todos.db" 14 | -------------------------------------------------------------------------------- /examples/todo/server/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "todo_server" 3 | version = "0.1.0" 4 | authors = [ 5 | "Alexander Mescheryakov ", 6 | "Noogen Team ", 7 | ] 8 | edition = "2021" 9 | license = "MIT" 10 | readme = "README.md" 11 | repository = "https://github.com/noogen-projects/laplace" 12 | description = "The Todo lapp example" 13 | 14 | [lib] 15 | crate-type = ["cdylib"] 16 | 17 | [dependencies] 18 | anyhow = "1.0" 19 | borsh = { workspace = true } 20 | laplace_wasm = { path = "../../../laplace_wasm" } 21 | serde_json = "1.0" 22 | sql-builder = "3.1" 23 | thiserror = { workspace = true } 24 | todo_common = { path = "../common" } 25 | -------------------------------------------------------------------------------- /examples/todo/server/src/lib.rs: -------------------------------------------------------------------------------- 1 | use laplace_wasm::database::{execute, query, Value}; 2 | use laplace_wasm::http::{self, Method, Uri}; 3 | use laplace_wasm::WasmSlice; 4 | pub use laplace_wasm::{alloc, dealloc}; 5 | use sql_builder::{quote, SqlBuilder, SqlBuilderError}; 6 | use thiserror::Error; 7 | use todo_common::{Response, Task}; 8 | 9 | const TASKS_TABLE_NAME: &str = "Tasks"; 10 | 11 | #[no_mangle] 12 | pub extern "C" fn init() -> WasmSlice { 13 | let result = execute(format!( 14 | r"CREATE TABLE IF NOT EXISTS {table}( 15 | description TEXT NOT NULL, 16 | completed INTEGER NOT NULL DEFAULT 0 CHECK(completed IN (0,1)) 17 | );", 18 | table = TASKS_TABLE_NAME 19 | )); 20 | 21 | let data = borsh::to_vec(&result.map(drop)).expect("Init result should be serializable"); 22 | WasmSlice::from(data) 23 | } 24 | 25 | #[http::process] 26 | fn http(request: http::Request) -> http::Response { 27 | let http::Request { method, uri, body, .. } = request; 28 | let response = match method { 29 | Method::GET => TodoRequest::parse(uri, None) 30 | .map(|request| request.process()) 31 | .unwrap_or_else(Response::Error), 32 | Method::POST => TodoRequest::parse(uri, Some(body)) 33 | .map(|request| request.process()) 34 | .unwrap_or_else(Response::Error), 35 | method => Response::Error(format!("Unsupported HTTP method {}", method)), 36 | }; 37 | 38 | let response = serde_json::to_string(&response).unwrap_or_else(Response::json_error_from); 39 | http::Response::new(response.into_bytes()) 40 | } 41 | 42 | #[derive(Debug, Error)] 43 | enum TaskError { 44 | #[error("Invalid SQL query: {0}")] 45 | Sql(#[from] SqlBuilderError), 46 | 47 | #[error("Error: {0}")] 48 | AnyhowError(#[from] anyhow::Error), 49 | 50 | #[error("Error message: {0}")] 51 | ErrorMessage(String), 52 | } 53 | 54 | impl From for TaskError { 55 | fn from(message: String) -> Self { 56 | Self::ErrorMessage(message) 57 | } 58 | } 59 | 60 | impl From for Response { 61 | fn from(err: TaskError) -> Self { 62 | Response::Error(format!("{}", err)) 63 | } 64 | } 65 | 66 | enum TodoRequest { 67 | List, 68 | Add(Task), 69 | Update(u32, Task), 70 | Delete(u32), 71 | ClearCompleted, 72 | } 73 | 74 | impl TodoRequest { 75 | fn parse(uri: Uri, body: Option>) -> Result { 76 | let path = uri.path(); 77 | let chunks: Vec<_> = path.split('/').collect(); 78 | 79 | match &chunks[..] { 80 | [.., "list"] => Ok(Self::List), 81 | [.., "add"] => { 82 | let body = String::from_utf8(body.ok_or_else(|| "Task not specified".to_string())?) 83 | .map_err(|err| err.to_string())?; 84 | parse_task(&body).map(Self::Add) 85 | }, 86 | [.., "update", idx] => { 87 | let idx = parse_idx(idx)?; 88 | let body = String::from_utf8(body.ok_or_else(|| "Task not specified".to_string())?) 89 | .map_err(|err| err.to_string())?; 90 | parse_task(&body).map(|task| Self::Update(idx, task)) 91 | }, 92 | [.., "delete", idx] => parse_idx(idx).map(Self::Delete), 93 | [.., "clear_completed"] => Ok(Self::ClearCompleted), 94 | _ => Err(format!("Cannot parse uri path {}, {:?}", path, chunks)), 95 | } 96 | } 97 | 98 | fn process(self) -> Response { 99 | match self { 100 | Self::List => process_list().map(Response::List), 101 | Self::Add(task) => process_add(task).map(Response::List), 102 | Self::Update(idx, task) => process_update(idx, task).map(|_| Response::Empty), 103 | Self::Delete(idx) => process_delete(idx).map(Response::List), 104 | Self::ClearCompleted => process_clear_completed().map(Response::List), 105 | } 106 | .unwrap_or_else(Response::from) 107 | } 108 | } 109 | 110 | fn parse_idx(source: &str) -> Result { 111 | source 112 | .parse() 113 | .map_err(|err| format!("Parse task index error: {:?}", err)) 114 | } 115 | 116 | fn parse_task(source: &str) -> Result { 117 | serde_json::from_str(source).map_err(|err| format!("Parse task error: {:?}", err)) 118 | } 119 | 120 | fn process_list() -> Result, TaskError> { 121 | let sql = SqlBuilder::select_from(TASKS_TABLE_NAME).sql()?; 122 | let rows = query(sql)?; 123 | 124 | let mut tasks = Vec::with_capacity(rows.len()); 125 | for row in rows { 126 | tasks.push(task_from(row.into_values())?); 127 | } 128 | Ok(tasks) 129 | } 130 | 131 | fn process_add(task: Task) -> Result, TaskError> { 132 | let sql = SqlBuilder::insert_into(TASKS_TABLE_NAME) 133 | .fields(&["description", "completed"]) 134 | .values(&[quote(task.description), if task.completed { 1 } else { 0 }.to_string()]) 135 | .sql()?; 136 | execute(sql)?; 137 | process_list() 138 | } 139 | 140 | fn process_update(idx: u32, update: Task) -> Result<(), TaskError> { 141 | let sql = SqlBuilder::update_table(TASKS_TABLE_NAME) 142 | .set("description", quote(update.description)) 143 | .set("completed", update.completed) 144 | .and_where_eq("rowid", idx) 145 | .sql()?; 146 | execute(sql)?; 147 | execute("VACUUM")?; 148 | Ok(()) 149 | } 150 | 151 | fn process_delete(idx: u32) -> Result, TaskError> { 152 | let sql = SqlBuilder::delete_from(TASKS_TABLE_NAME) 153 | .and_where_eq("rowid", idx) 154 | .sql()?; 155 | execute(sql)?; 156 | execute("VACUUM")?; 157 | process_list() 158 | } 159 | 160 | fn process_clear_completed() -> Result, TaskError> { 161 | let sql = SqlBuilder::delete_from(TASKS_TABLE_NAME) 162 | .and_where_ne("completed", 0) 163 | .sql()?; 164 | execute(sql)?; 165 | execute("VACUUM")?; 166 | process_list() 167 | } 168 | 169 | fn task_from(values: Vec) -> Result { 170 | let mut task = Task::default(); 171 | let mut iter = values.into_iter(); 172 | 173 | match iter.next() { 174 | Some(Value::Text(description)) => task.description = description, 175 | Some(value) => Err(format!("Incorrect task description value: {:?}", value))?, 176 | None => Err("Task description value does not exist".to_string())?, 177 | } 178 | 179 | match iter.next() { 180 | Some(Value::Integer(completed)) => task.completed = completed != 0, 181 | Some(value) => Err(format!("Incorrect task completed value: {:?}", value))?, 182 | None => Err("Task completed value does not exist".to_string())?, 183 | } 184 | 185 | Ok(task) 186 | } 187 | -------------------------------------------------------------------------------- /examples/todo/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Todo lapp example 5 | 6 | 7 | 8 | 9 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /laplace_client/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "laplace_client" 3 | version = "0.1.0" 4 | authors = [ 5 | "Alexander Mescheryakov ", 6 | "Noogen Team ", 7 | ] 8 | edition = "2021" 9 | license = "MIT" 10 | repository = "https://github.com/noogen-projects/laplace" 11 | description = "The WASM client of the local-firs web-application platform" 12 | 13 | [dependencies] 14 | anyhow = "1.0" 15 | arc-swap = "1.6" 16 | laplace_common = { path = "../laplace_common" } 17 | laplace_yew = { path = "../laplace_yew" } 18 | lazy_static = "1.4" 19 | serde = { version = "1.0", features = ["derive"] } 20 | serde-wasm-bindgen = "0.6" 21 | serde_json = "1.0" 22 | wasm-web-helpers = "0.2" 23 | web-sys = { version = "0.3", features = ["HtmlInputElement", "FormData"] } 24 | yew = { workspace = true } 25 | yew-mdc-widgets = { workspace = true } 26 | -------------------------------------------------------------------------------- /laplace_client/src/i18n.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::sync::Arc; 3 | 4 | use arc_swap::{ArcSwap, Guard}; 5 | use lazy_static::lazy_static; 6 | 7 | pub type TextMap = HashMap; 8 | 9 | pub const DEFAULT_LANG: &str = "en"; 10 | 11 | lazy_static! { 12 | static ref CURRENT_LANG: ArcSwap = ArcSwap::from_pointee(DEFAULT_LANG.to_string()); 13 | static ref TRANSLATIONS: ArcSwap> = ArcSwap::from_pointee(default_translations()); 14 | } 15 | 16 | pub mod label { 17 | pub const SETTINGS: &str = "Settings"; 18 | pub const APPLICATIONS: &str = "Applications"; 19 | pub const ADD_LAPP: &str = "Add lapp"; 20 | } 21 | 22 | pub fn default_translations() -> HashMap { 23 | [( 24 | DEFAULT_LANG.into(), 25 | [ 26 | (label::SETTINGS.into(), "Settings".into()), 27 | (label::APPLICATIONS.into(), "Applications".into()), 28 | (label::ADD_LAPP.into(), "Add lapp".into()), 29 | ] 30 | .into(), 31 | )] 32 | .into() 33 | } 34 | 35 | #[inline] 36 | pub fn load() -> I18n { 37 | I18n { 38 | current_lang: CURRENT_LANG.load(), 39 | translations: TRANSLATIONS.load(), 40 | } 41 | } 42 | 43 | #[inline] 44 | pub fn switch_lang(lang: impl Into) -> bool { 45 | let lang = lang.into(); 46 | 47 | if TRANSLATIONS.load().contains_key(&lang) { 48 | CURRENT_LANG.swap(Arc::new(lang)); 49 | true 50 | } else { 51 | false 52 | } 53 | } 54 | 55 | pub fn add_translations(translations: Vec<(String, TextMap)>) { 56 | TRANSLATIONS.rcu(|old_translations| { 57 | let mut new_translations = HashMap::clone(old_translations); 58 | for (lang, text_map) in &translations { 59 | new_translations.insert(lang.clone(), text_map.clone()); 60 | } 61 | new_translations 62 | }); 63 | } 64 | 65 | pub struct I18n { 66 | pub current_lang: Guard>, 67 | pub translations: Guard>>, 68 | } 69 | 70 | impl I18n { 71 | pub fn text<'a>(&'a self, label: &'a str) -> &'a str { 72 | self.translate(label).unwrap_or(label) 73 | } 74 | 75 | fn translate(&self, label: &str) -> Option<&str> { 76 | let translations = if let Some(translations) = self.translations.get(self.current_lang.as_str()) { 77 | translations 78 | } else { 79 | self.translations.get(DEFAULT_LANG)? 80 | }; 81 | translations.get(label).map(String::as_str) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /laplace_common/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "laplace_common" 3 | version = "0.1.0" 4 | authors = [ 5 | "Alexander Mescheryakov ", 6 | "Noogen Team ", 7 | ] 8 | edition = "2021" 9 | license = "MIT" 10 | repository = "https://github.com/noogen-projects/laplace" 11 | description = "The common lib of the local-firs web-application platform" 12 | 13 | [dependencies] 14 | serde = { version = "1.0", features = ["derive"] } 15 | serde_with = "3.3" 16 | strum = { workspace = true } 17 | 18 | [dev-dependencies] 19 | serde_json = "1.0" 20 | -------------------------------------------------------------------------------- /laplace_common/src/api.rs: -------------------------------------------------------------------------------- 1 | pub use self::p2p::*; 2 | pub use self::update::*; 3 | 4 | pub mod p2p; 5 | pub mod update; 6 | -------------------------------------------------------------------------------- /laplace_common/src/api/p2p.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] 4 | pub struct Peer { 5 | pub peer_id: Vec, 6 | pub keypair: Vec, 7 | } 8 | -------------------------------------------------------------------------------- /laplace_common/src/api/update.rs: -------------------------------------------------------------------------------- 1 | use std::marker::PhantomData; 2 | use std::ops::Deref; 3 | 4 | use serde::{Deserialize, Serialize}; 5 | use serde_with::skip_serializing_none; 6 | 7 | use crate::lapp::{LappSettings, Permission}; 8 | 9 | #[skip_serializing_none] 10 | #[derive(Debug, Default, Deserialize, Serialize, PartialEq, Eq)] 11 | pub struct UpdateQuery { 12 | pub lapp_name: String, 13 | pub enabled: Option, 14 | pub autoload: Option, 15 | pub allow_permission: Option, 16 | pub deny_permission: Option, 17 | } 18 | 19 | impl UpdateQuery { 20 | pub fn new(lapp_name: impl Into) -> Self { 21 | Self { 22 | lapp_name: lapp_name.into(), 23 | ..Default::default() 24 | } 25 | } 26 | 27 | pub fn is_applied(&self) -> bool { 28 | let Self { 29 | lapp_name: _, 30 | enabled, 31 | autoload, 32 | allow_permission, 33 | deny_permission, 34 | } = self; 35 | enabled.is_some() || autoload.is_some() || allow_permission.is_some() || deny_permission.is_some() 36 | } 37 | 38 | pub fn enabled(mut self, enabled: impl Into>) -> Self { 39 | self.enabled = enabled.into(); 40 | self 41 | } 42 | 43 | pub fn autoload(mut self, autoload: impl Into>) -> Self { 44 | self.autoload = autoload.into(); 45 | self 46 | } 47 | 48 | pub fn allow_permission(mut self, allow_permission: impl Into>) -> Self { 49 | self.allow_permission = allow_permission.into(); 50 | self 51 | } 52 | 53 | pub fn deny_permission(mut self, deny_permission: impl Into>) -> Self { 54 | self.deny_permission = deny_permission.into(); 55 | self 56 | } 57 | 58 | pub fn update_permission(self, permission: impl Into, allow: bool) -> Self { 59 | if allow { 60 | self.allow_permission(permission.into()) 61 | } else { 62 | self.deny_permission(permission.into()) 63 | } 64 | } 65 | 66 | pub fn into_request(self) -> UpdateRequest { 67 | self.into() 68 | } 69 | 70 | pub fn into_response<'a, LS: Deref>(self) -> Response<'a, LS> { 71 | self.into() 72 | } 73 | } 74 | 75 | impl From for UpdateQuery { 76 | fn from(request: UpdateRequest) -> Self { 77 | request.update 78 | } 79 | } 80 | 81 | #[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] 82 | pub struct UpdateRequest { 83 | pub update: UpdateQuery, 84 | } 85 | 86 | impl UpdateRequest { 87 | pub fn into_query(self) -> UpdateQuery { 88 | self.into() 89 | } 90 | } 91 | 92 | impl From for UpdateRequest { 93 | fn from(update: UpdateQuery) -> Self { 94 | Self { update } 95 | } 96 | } 97 | 98 | #[derive(Debug, Deserialize, Serialize)] 99 | #[serde(untagged)] 100 | pub enum Response<'a, LS: Deref + 'a> { 101 | Lapps { 102 | lapps: Vec, 103 | 104 | #[serde(skip)] 105 | _marker: PhantomData<&'a LappSettings>, 106 | }, 107 | 108 | Updated { 109 | updated: UpdateQuery, 110 | }, 111 | } 112 | 113 | impl<'a, LS: Deref + 'a> Response<'a, LS> { 114 | pub fn lapps(lapps: impl Into>) -> Self { 115 | Self::Lapps { 116 | lapps: lapps.into(), 117 | _marker: Default::default(), 118 | } 119 | } 120 | } 121 | 122 | impl<'a, LS: Deref + 'a> From> for Response<'a, LS> { 123 | fn from(lapps: Vec) -> Self { 124 | Self::Lapps { 125 | lapps, 126 | _marker: Default::default(), 127 | } 128 | } 129 | } 130 | 131 | impl<'a, LS: Deref + 'a> From for Response<'a, LS> { 132 | fn from(updated: UpdateQuery) -> Self { 133 | Self::Updated { updated } 134 | } 135 | } 136 | 137 | #[cfg(test)] 138 | mod tests { 139 | use super::*; 140 | 141 | #[test] 142 | fn serialize_request() { 143 | let request = UpdateQuery::new("test").into_request(); 144 | let json = serde_json::to_string(&request).unwrap(); 145 | assert_eq!(json, r#"{"update":{"lapp_name":"test"}}"#); 146 | 147 | let request = UpdateQuery::new("test").enabled(true).into_request(); 148 | let json = serde_json::to_string(&request).unwrap(); 149 | assert_eq!(json, r#"{"update":{"lapp_name":"test","enabled":true}}"#); 150 | 151 | let request = UpdateQuery::new("test") 152 | .enabled(true) 153 | .autoload(true) 154 | .allow_permission(Permission::Http) 155 | .deny_permission(Permission::Tcp) 156 | .into_request(); 157 | let json = serde_json::to_string(&request).unwrap(); 158 | assert_eq!( 159 | json, 160 | r#"{"update":{"lapp_name":"test","enabled":true,"autoload":true,"allow_permission":"http","deny_permission":"tcp"}}"# 161 | ); 162 | } 163 | 164 | #[test] 165 | fn deserialize_request() { 166 | let json = r#"{"update":{"lapp_name":"test"}}"#; 167 | let request: UpdateRequest = serde_json::from_str(json).unwrap(); 168 | assert_eq!(request, UpdateRequest { 169 | update: UpdateQuery { 170 | lapp_name: "test".to_string(), 171 | ..Default::default() 172 | } 173 | }); 174 | } 175 | 176 | #[test] 177 | fn serialize_lapps_response() { 178 | let response = Response::<'_, &LappSettings>::from(vec![]); 179 | let json = serde_json::to_string(&response).unwrap(); 180 | assert_eq!(json, r#"{"lapps":[]}"#); 181 | } 182 | 183 | #[test] 184 | fn serialize_updated_response() { 185 | let response = Response::Updated::<'_, &LappSettings> { 186 | updated: UpdateQuery::new("test"), 187 | }; 188 | let json = serde_json::to_string(&response).unwrap(); 189 | assert_eq!(json, r#"{"updated":{"lapp_name":"test"}}"#); 190 | 191 | let response = Response::Updated::<'_, &LappSettings> { 192 | updated: UpdateQuery::new("test").enabled(true), 193 | }; 194 | let json = serde_json::to_string(&response).unwrap(); 195 | assert_eq!(json, r#"{"updated":{"lapp_name":"test","enabled":true}}"#); 196 | 197 | let response = Response::Updated::<'_, &LappSettings> { 198 | updated: UpdateQuery::new("test") 199 | .enabled(true) 200 | .autoload(true) 201 | .allow_permission(Permission::Http) 202 | .deny_permission(Permission::Tcp), 203 | }; 204 | let json = serde_json::to_string(&response).unwrap(); 205 | assert_eq!( 206 | json, 207 | r#"{"updated":{"lapp_name":"test","enabled":true,"autoload":true,"allow_permission":"http","deny_permission":"tcp"}}"# 208 | ); 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /laplace_common/src/lapp.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | pub use self::access::*; 6 | pub use self::settings::*; 7 | 8 | pub mod access; 9 | pub mod settings; 10 | 11 | #[derive(Debug, Clone, Deserialize, Serialize)] 12 | pub struct Lapp { 13 | name: String, 14 | root_dir: PathT, 15 | settings: LappSettings, 16 | } 17 | 18 | impl Lapp { 19 | #[inline] 20 | pub fn new(name: impl Into, root_dir: impl Into, settings: LappSettings) -> Self { 21 | Self { 22 | name: name.into(), 23 | root_dir: root_dir.into(), 24 | settings, 25 | } 26 | } 27 | 28 | pub const fn static_dir_name() -> &'static str { 29 | "static" 30 | } 31 | 32 | pub const fn index_file_name() -> &'static str { 33 | "index.html" 34 | } 35 | 36 | pub const fn main_name() -> &'static str { 37 | "laplace" 38 | } 39 | 40 | pub fn main_static_uri() -> String { 41 | format!("/{}", Self::static_dir_name()) 42 | } 43 | 44 | pub fn main_uri(tail: impl AsRef) -> String { 45 | format!("/{}/{}", Self::main_name(), tail.as_ref()) 46 | } 47 | 48 | pub fn main_uri2(first: impl AsRef, second: impl AsRef) -> String { 49 | format!("/{}/{}/{}", Self::main_name(), first.as_ref(), second.as_ref()) 50 | } 51 | 52 | pub fn is_main(name: impl AsRef) -> bool { 53 | Self::main_name() == name.as_ref() 54 | } 55 | 56 | #[inline] 57 | pub fn name(&self) -> &str { 58 | &self.name 59 | } 60 | 61 | #[inline] 62 | pub fn root_dir(&self) -> &PathT { 63 | &self.root_dir 64 | } 65 | 66 | #[inline] 67 | pub fn data_dir(&self) -> &Path { 68 | &self.settings.application.data_dir 69 | } 70 | 71 | #[inline] 72 | pub fn settings(&self) -> &LappSettings { 73 | &self.settings 74 | } 75 | 76 | #[inline] 77 | pub fn set_settings(&mut self, settings: LappSettings) { 78 | self.settings = settings; 79 | } 80 | 81 | pub fn root_uri(&self) -> String { 82 | format!("/{}", self.name()) 83 | } 84 | 85 | pub fn static_uri(&self) -> String { 86 | format!("{}/{}", self.root_uri(), Self::static_dir_name()) 87 | } 88 | 89 | pub fn uri(&self, tail: impl AsRef) -> String { 90 | format!("/{}/{}", self.name(), tail.as_ref()) 91 | } 92 | 93 | pub fn uri2(&self, first: impl AsRef, second: impl AsRef) -> String { 94 | format!("/{}/{}/{}", self.name(), first.as_ref(), second.as_ref()) 95 | } 96 | 97 | pub fn is_allowed_permission(&self, permission: Permission) -> bool { 98 | self.settings.permissions.is_allowed(permission) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /laplace_common/src/lapp/access.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use strum::{AsRefStr, EnumString, IntoStaticStr}; 3 | 4 | #[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq, AsRefStr, IntoStaticStr, EnumString)] 5 | #[serde(rename_all = "snake_case")] 6 | #[strum(serialize_all = "snake_case")] 7 | pub enum Permission { 8 | FileRead, 9 | FileWrite, 10 | ClientHttp, 11 | Http, 12 | Websocket, 13 | Tcp, 14 | Database, 15 | Sleep, 16 | LappsIncoming, 17 | LappsOutgoing, 18 | } 19 | 20 | impl Permission { 21 | pub fn as_str(&self) -> &'static str { 22 | self.into() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /laplace_common/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod api; 2 | pub mod lapp; 3 | -------------------------------------------------------------------------------- /laplace_mobile/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "laplace_mobile" 3 | version = "0.1.1" 4 | authors = [ 5 | "Alexander Mescheryakov ", 6 | "Noogen Team ", 7 | ] 8 | edition = "2021" 9 | license = "MIT" 10 | repository = "https://github.com/noogen-projects/laplace_mobile" 11 | description = "The mobile server of the local-firs web-application platform" 12 | 13 | [lib] 14 | crate-type = ["lib", "cdylib"] 15 | 16 | [dependencies] 17 | jni = "0.21" 18 | laplace_server = { path = "../laplace_server" } 19 | log = "0.4" 20 | ndk = "0.7" 21 | ndk-context = "0.1" 22 | ndk-glue = "0.7" 23 | tokio = { version = "1.32", features = ["full"] } 24 | toml = "0.8" 25 | 26 | [profile.release] 27 | lto = true 28 | 29 | [package.metadata.android.sdk] 30 | min_sdk_version = 16 31 | target_sdk_version = 23 32 | build_targets = ["aarch64-linux-android", "i686-linux-android"] 33 | 34 | [package.metadata.android] 35 | apk_name = "laplace" 36 | assets = "../target/mobile/assets" 37 | 38 | [[package.metadata.android.uses_permission]] 39 | name = "android.permission.WRITE_EXTERNAL_STORAGE" 40 | 41 | [[package.metadata.android.uses_permission]] 42 | name = "android.permission.INTERNET" 43 | 44 | [[package.metadata.android.uses_permission]] 45 | name = "android.permission.CHANGE_NETWORK_STATE" 46 | 47 | [[package.metadata.android.uses_permission]] 48 | name = "android.permission.ACCESS_NETWORK_STATE" 49 | -------------------------------------------------------------------------------- /laplace_mobile/README.md: -------------------------------------------------------------------------------- 1 | # Laplace mobile application 2 | 3 | ## Install the Android NDK and SDK 4 | 5 | 1. Install Java Runtime Environment, for example `openjdk-jre`. 6 | 2. Download [Android SDK Command line tools](https://developer.android.com/studio#command-tools) for your platform. 7 | 3. Unpack `cmdline-tools` and put it to the `android-sdk` directory. WARNING! Make location to the content of the tools 8 | archive as `android-sdk/cmdline-tools/latest/`. 9 | 4. Setup `ANDROID_SDK_ROOT` env variable to the `android-sdk` location and add the `android-sdk/cmdline-tools/latest/bin/` 10 | to the `PATH` env variable. 11 | 5. Install SDK components: 12 | ```shell 13 | $ sdkmanager "ndk;23.1.7779620" "platforms;android-23" "platform-tools" "build-tools;32.0.0" 14 | ``` 15 | 6. Setup `ANDROID_NDK_ROOT` env variable to the `$ANDROID_SDK_ROOT/ndk/23.1.7779620/` location. 16 | 7. Add necessary build targets: 17 | ```shell 18 | $ rustup target add --toolchain stable aarch64-linux-android 19 | $ rustup target add --toolchain stable i686-linux-android 20 | ``` 21 | 8. Install `cargo-apk` tool and build project: 22 | ```shell 23 | $ cargo install cargo-apk 24 | $ cargo +stable apk build --lib --release 25 | ``` 26 | WARNING! If you get an error like this: 27 | ```shell 28 | = note: ld: error: unable to find library -lgcc 29 | clang-12: error: linker command failed with exit code 1 (use -v to see invocation) 30 | ``` 31 | Then [patch](https://github.com/rust-windowing/android-ndk-rs/issues/265) your toolchain libraries with: 32 | ```shell 33 | $ echo 'INPUT(-lunwind)' > ~/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/aarch64-linux-android/lib/libgcc.a 34 | ``` 35 | 36 | ## Run on the emulator 37 | 38 | ```shell 39 | $ sdkmanager "system-images;android-24;default;arm64-v8a" "emulator" 40 | $ echo no | avdmanager create avd --force --name default_arm --abi default/arm64-v8a --package "system-images;android-24;default;arm64-v8a" 41 | $ $ANDROID_SDK_ROOT/emulator/emulator -avd default_arm 42 | $ cargo +stable apk run --lib --release 43 | ``` -------------------------------------------------------------------------------- /laplace_mobile/src/assets.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | use std::ffi::{CStr, CString}; 3 | use std::path::{Path, PathBuf}; 4 | use std::{fs, io}; 5 | 6 | use jni::objects::{JObject, JObjectArray, JString}; 7 | use jni::{JNIEnv, JavaVM}; 8 | use ndk::asset::Asset; 9 | 10 | pub type CopyResult = Result>; 11 | 12 | pub fn copy(asset_dirs: impl IntoIterator>, destination: impl AsRef) -> CopyResult<()> { 13 | // Create a VM for executing Java calls 14 | let ctx = ndk_context::android_context(); 15 | let vm = unsafe { JavaVM::from_raw(ctx.vm().cast()) }?; 16 | let mut env = vm.attach_current_thread()?; 17 | 18 | // Query the Asset Manager 19 | let asset_manager = env 20 | .call_method( 21 | unsafe { JObject::from_raw(ctx.context().cast()) }, 22 | "getAssets", 23 | "()Landroid/content/res/AssetManager;", 24 | &[], 25 | )? 26 | .l()?; 27 | 28 | // Copy assets 29 | for asset_dir in asset_dirs { 30 | copy_recursively( 31 | &mut *env, 32 | &asset_manager, 33 | asset_dir.as_ref().to_path_buf(), 34 | destination.as_ref().join(asset_dir), 35 | )?; 36 | } 37 | 38 | Ok(()) 39 | } 40 | 41 | fn copy_recursively( 42 | env: &mut JNIEnv, 43 | asset_manager: &JObject, 44 | asset_dir: PathBuf, 45 | destination_dir: PathBuf, 46 | ) -> CopyResult<()> { 47 | for asset_filename in list(env, asset_manager, &asset_dir)? { 48 | let asset_path = asset_dir.join(&asset_filename); 49 | if let Some(asset) = open_asset(&CString::new(asset_path.to_string_lossy().as_ref())?) { 50 | copy_asset(asset, asset_filename, &destination_dir)?; 51 | } else { 52 | copy_recursively(env, asset_manager, asset_path, destination_dir.join(asset_filename))?; 53 | } 54 | } 55 | Ok(()) 56 | } 57 | 58 | fn list(env: &mut JNIEnv, asset_manager: &JObject, asset_dir: &Path) -> CopyResult> { 59 | let asset_array = JObjectArray::from(env 60 | .call_method(asset_manager, "list", "(Ljava/lang/String;)[Ljava/lang/String;", &[ 61 | (&env.new_string(asset_dir.to_string_lossy())?).into(), 62 | ])? 63 | .l()?); 64 | 65 | let mut assets = Vec::new(); 66 | for index in 0..env.get_array_length(&asset_array)? { 67 | let asset_string = JString::from(env.get_object_array_element(&asset_array, index)?); 68 | let asset: String = env 69 | .get_string(&asset_string)? 70 | .into(); 71 | assets.push(asset); 72 | } 73 | 74 | Ok(assets) 75 | } 76 | 77 | fn open_asset(asset_path: &CStr) -> Option { 78 | #[allow(deprecated)] 79 | ndk_glue::native_activity().asset_manager().open(asset_path) 80 | } 81 | 82 | fn copy_asset(mut asset: Asset, filename: impl AsRef, destination_dir: impl AsRef) -> CopyResult<()> { 83 | fs::create_dir_all(destination_dir.as_ref())?; 84 | let mut file = fs::File::options() 85 | .create(true) 86 | .write(true) 87 | .open(destination_dir.as_ref().join(filename))?; 88 | 89 | io::copy(&mut asset, &mut file)?; 90 | Ok(()) 91 | } 92 | -------------------------------------------------------------------------------- /laplace_mobile/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use std::path::PathBuf; 3 | 4 | use laplace_server::auth::generate_token; 5 | use laplace_server::settings::Settings; 6 | use log::info; 7 | 8 | mod assets; 9 | mod panic; 10 | 11 | fn get_data_path() -> &'static str { 12 | #[allow(deprecated)] 13 | ndk_glue::native_activity() 14 | .external_data_path() 15 | .to_str() 16 | .expect("Wrong external data path") 17 | } 18 | 19 | #[cfg_attr(target_os = "android", ndk_glue::main(backtrace = "on"))] 20 | pub fn main() { 21 | let data_path = PathBuf::from(get_data_path()); 22 | let settings_path = data_path.join("config.toml"); 23 | let settings = if let Ok(settings) = Settings::new(&settings_path) { 24 | settings 25 | } else { 26 | let mut settings = Settings::default(); 27 | settings.http.web_root = data_path.join("web_root"); 28 | settings.http.access_token = generate_token().ok(); 29 | settings.lapps.path = settings.http.web_root.join("lapps"); 30 | settings.log.path = Some(data_path.join("log").join("laplace.log")); 31 | settings.log.spec = "info,regalloc=warn,wasmer_compiler_cranelift=warn,cranelift_codegen=warn".into(); 32 | settings.ssl.enabled = false; 33 | settings.ssl.private_key_path = data_path.join("cert").join("key.pem"); 34 | settings.ssl.certificate_path = data_path.join("cert").join("cert.pem"); 35 | 36 | let serialized_settings = toml::to_string(&settings).expect("Cannot serialize settings"); 37 | fs::write(settings_path, serialized_settings).expect("Cannot write settings"); 38 | 39 | settings 40 | }; 41 | 42 | laplace_server::init_logger(&settings.log).expect("Logger should be configured"); 43 | panic::set_logger_hook(); 44 | 45 | if !settings.lapps.path.exists() 46 | || (settings.lapps.path.is_dir() 47 | && settings 48 | .lapps 49 | .path 50 | .read_dir() 51 | .map(|mut dir| dir.next().is_none()) 52 | .unwrap_or(false)) 53 | { 54 | info!("Copy assets"); 55 | assets::copy(["lapps", "static"], &settings.http.web_root).expect("Copy assets error"); 56 | } 57 | 58 | info!("Create tokio runtime"); 59 | tokio::runtime::Builder::new_multi_thread() 60 | .enable_all() 61 | .build() 62 | .expect("Cannot build tokio runtime") 63 | .block_on(async move { laplace_server::run(settings).await }) 64 | .expect("Laplace run error"); 65 | } 66 | -------------------------------------------------------------------------------- /laplace_mobile/src/main.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | laplace_mobile::main(); 3 | } 4 | -------------------------------------------------------------------------------- /laplace_mobile/src/panic.rs: -------------------------------------------------------------------------------- 1 | //! Slightly changed copy of the https://github.com/sfackler/rust-log-panics/blob/master/src/lib.rs 2 | use std::{ 3 | panic::{self, PanicInfo}, 4 | thread, 5 | }; 6 | 7 | use log::error; 8 | 9 | fn panic_handler(info: &PanicInfo<'_>) { 10 | let thread = thread::current(); 11 | let thread = thread.name().unwrap_or("unnamed"); 12 | 13 | let msg = match info.payload().downcast_ref::<&'static str>() { 14 | Some(s) => *s, 15 | None => match info.payload().downcast_ref::() { 16 | Some(s) => &**s, 17 | None => "Box", 18 | }, 19 | }; 20 | 21 | match info.location() { 22 | Some(location) => { 23 | error!( 24 | target: "panic", "thread '{}' panicked at '{}': {}:{}", 25 | thread, 26 | msg, 27 | location.file(), 28 | location.line() 29 | ); 30 | }, 31 | None => error!( 32 | target: "panic", 33 | "thread '{}' panicked at '{}'", 34 | thread, 35 | msg 36 | ), 37 | } 38 | } 39 | 40 | /// Initializes the panic hook. 41 | /// 42 | /// After this method is called, all panics will be logged rather than printed 43 | /// to standard error. 44 | pub fn set_logger_hook() { 45 | let next = panic::take_hook(); 46 | panic::set_hook(Box::new(move |info| { 47 | panic_handler(info); 48 | next(info); 49 | })); 50 | } 51 | -------------------------------------------------------------------------------- /laplace_server/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "laplace_server" 3 | version = "0.1.9" 4 | authors = [ 5 | "Alexander Mescheryakov ", 6 | "Noogen Team ", 7 | ] 8 | edition = "2021" 9 | license = "MIT" 10 | repository = "https://github.com/noogen-projects/laplace" 11 | description = "The server of the local-first web-application platform" 12 | 13 | [dependencies] 14 | anyhow = "1.0" 15 | axum = { version = "0.7", features = ["ws", "multipart"] } 16 | axum-server = { version = "0.7", features = ["tls-rustls"] } 17 | axum_typed_multipart = "0.13" 18 | borsh = { workspace = true } 19 | bs58 = "0.5" 20 | cap-std = "3.1" 21 | clap = { version = "4.5", features = ["derive"] } 22 | config = "0.14" 23 | const_format = "0.2" 24 | cookie = "0.18" 25 | derive_more = { workspace = true } 26 | flexi_logger = "0.29" 27 | futures = "0.3" 28 | http-body-util = "0.1" 29 | humantime-serde = "1.1" 30 | hyper = "1.3" 31 | laplace_common = { path = "../laplace_common" } 32 | laplace_wasm = { path = "../laplace_wasm" } 33 | lazy_static = "1.5" 34 | libp2p = { version = "0.54", features = [ 35 | "dns", 36 | "gossipsub", 37 | "macros", 38 | "mdns", 39 | "noise", 40 | "tcp", 41 | "tokio", 42 | "websocket", 43 | "yamux", 44 | ] } 45 | log = "0.4" 46 | rcgen = "0.13" 47 | reqwest = { version = "0.12", default-features = false, features = [ 48 | "blocking", 49 | "rustls-tls", 50 | ] } 51 | ring = "0.17" 52 | rusqlite = { version = "0.32", features = ["bundled"] } 53 | rustls = "0.23" # depend on axum-server 54 | rustls-pemfile = "2.1" 55 | serde = { version = "1.0", features = ["derive"] } 56 | serde_json = "1.0" 57 | tempfile = "3.10" 58 | thiserror = { workspace = true } 59 | tokio = { workspace = true } 60 | toml = "0.8" 61 | tower = { version = "0.5", features = ["util"] } 62 | tower-http = { version = "0.5", features = ["fs", "set-header", "normalize-path", "compression-gzip"] } 63 | truba = "0.1" 64 | wasi-common = "26.0" 65 | wasmtime = "26.0" 66 | wasmtime-wasi = "26.0" 67 | zip = "2.1" 68 | -------------------------------------------------------------------------------- /laplace_server/src/auth.rs: -------------------------------------------------------------------------------- 1 | use std::fs; 2 | use std::io::{BufReader, Write}; 3 | use std::path::Path; 4 | 5 | use rcgen::{CertificateParams, CertifiedKey, DistinguishedName, DnType, KeyPair}; 6 | use ring::rand; 7 | use rustls::pki_types::{CertificateDer, PrivateKeyDer}; 8 | use rustls_pemfile::{certs, pkcs8_private_keys}; 9 | 10 | use crate::error::{AppError, AppResult}; 11 | 12 | pub mod middleware; 13 | 14 | pub fn prepare_access_token(maybe_access_token: Option) -> AppResult<&'static str> { 15 | let access_token = if let Some(access_token) = maybe_access_token { 16 | access_token 17 | } else { 18 | generate_token()? 19 | }; 20 | 21 | Ok(access_token.leak()) 22 | } 23 | 24 | pub fn generate_token() -> AppResult { 25 | let buf: [u8; 32] = rand::generate(&rand::SystemRandom::new()) 26 | .map_err(|_| AppError::TokenGenerationFail)? 27 | .expose(); 28 | Ok(bs58::encode(&buf).into_string()) 29 | } 30 | 31 | pub fn prepare_certificates( 32 | certificate_path: &Path, 33 | private_key_path: &Path, 34 | host: impl Into, 35 | ) -> AppResult<(Vec>, PrivateKeyDer<'static>)> { 36 | if !certificate_path.exists() && !private_key_path.exists() { 37 | log::info!("Generate SSL certificate"); 38 | let CertifiedKey { cert, key_pair } = generate_self_signed_certificate(vec![host.into()])?; 39 | 40 | if let Some(parent) = private_key_path.parent() { 41 | fs::create_dir_all(parent)?; 42 | } 43 | if let Some(parent) = certificate_path.parent() { 44 | fs::create_dir_all(parent)?; 45 | } 46 | 47 | fs::File::create(private_key_path)?.write_all(key_pair.serialize_pem().as_bytes())?; 48 | fs::File::create(certificate_path)?.write_all(cert.pem().as_bytes())?; 49 | } 50 | 51 | log::info!("Bind SSL"); 52 | let certificates = certs(&mut BufReader::new(fs::File::open(certificate_path)?)).collect::, _>>()?; 53 | 54 | let private_key = pkcs8_private_keys(&mut BufReader::new(fs::File::open(private_key_path)?)) 55 | .next() 56 | .ok_or(AppError::MissingPrivateKey)??; 57 | 58 | Ok((certificates, PrivateKeyDer::Pkcs8(private_key))) 59 | } 60 | 61 | pub fn generate_self_signed_certificate( 62 | subject_alt_names: impl Into>, 63 | ) -> Result { 64 | let mut distinguished_name = DistinguishedName::new(); 65 | distinguished_name.push(DnType::CommonName, "Laplace self signed cert"); 66 | distinguished_name.push(DnType::OrganizationName, "Laplace community"); 67 | 68 | let mut params = CertificateParams::new(subject_alt_names)?; 69 | params.distinguished_name = distinguished_name; 70 | 71 | let key_pair = KeyPair::generate()?; 72 | let cert = params.self_signed(&key_pair)?; 73 | 74 | Ok(CertifiedKey { cert, key_pair }) 75 | } 76 | -------------------------------------------------------------------------------- /laplace_server/src/auth/middleware.rs: -------------------------------------------------------------------------------- 1 | use axum::body::Body; 2 | use axum::extract::State; 3 | use axum::http::{header, Request, StatusCode}; 4 | use axum::middleware::Next; 5 | use axum::response::{IntoResponse, Redirect, Response}; 6 | use cookie::time::Duration; 7 | use cookie::Cookie; 8 | 9 | use crate::lapps::{Lapp, LappsProvider}; 10 | use crate::web_api::{err_into_json_response, ResultResponse}; 11 | 12 | pub async fn check_access( 13 | State((lapps_provider, laplace_access_token)): State<(LappsProvider, &'static str)>, 14 | request: Request, 15 | next: Next, 16 | ) -> ResultResponse { 17 | let request = match query_access_token_redirect(request) { 18 | Ok(response) => return Ok(response), 19 | Err(request) => request, 20 | }; 21 | 22 | let lapp_name = request 23 | .uri() 24 | .path() 25 | .split('/') 26 | .find(|chunk| !chunk.is_empty()) 27 | .unwrap_or_default() 28 | .to_string(); 29 | 30 | if lapp_name.is_empty() || lapp_name == "static" || lapp_name == "favicon.ico" { 31 | Ok(next.run(request).await) 32 | } else { 33 | let access_token = request 34 | .headers() 35 | .get_all(header::COOKIE) 36 | .into_iter() 37 | .filter_map(|cookie_value| Cookie::parse(cookie_value.to_str().ok()?).ok()) 38 | .find(|cookie| cookie.name() == "access_token") 39 | .map(|cookie| cookie.value().to_string()) 40 | .unwrap_or_default(); 41 | 42 | if lapp_name == Lapp::main_name() { 43 | if access_token == laplace_access_token { 44 | Ok(next.run(request).await) 45 | } else { 46 | let mut response = Response::default(); 47 | *response.status_mut() = StatusCode::FORBIDDEN; 48 | Ok(response) 49 | } 50 | } else { 51 | match lapps_provider.read_manager().await.lapp_settings(&lapp_name) { 52 | Ok(lapp_settings) => { 53 | if access_token == lapp_settings.application.access_token.as_deref().unwrap_or_default() { 54 | Ok(next.run(request).await) 55 | } else { 56 | log::debug!("{request:?}"); 57 | log::warn!("Access denied for lapp \"{lapp_name}\" with access token \"{access_token}\""); 58 | 59 | let mut response = Response::default(); 60 | *response.status_mut() = StatusCode::FORBIDDEN; 61 | Ok(response) 62 | } 63 | }, 64 | Err(err) => Err(err_into_json_response(err)), 65 | } 66 | } 67 | } 68 | } 69 | 70 | pub fn query_access_token_redirect(request: Request) -> Result> { 71 | let uri = request.uri().clone(); 72 | let query = uri.query().unwrap_or_default(); 73 | 74 | if query.starts_with("access_token=") || query.contains("&access_token=") { 75 | let mut access_token = ""; 76 | let mut new_query = String::new(); 77 | 78 | for param in query.split('&') { 79 | let pair: Vec<_> = param.split('=').collect(); 80 | if pair[0] == "access_token" { 81 | access_token = pair[1]; 82 | } else { 83 | new_query.push(if new_query.is_empty() { '?' } else { '&' }); 84 | new_query.push_str(param) 85 | } 86 | } 87 | 88 | let lapp_name = uri 89 | .path() 90 | .split('/') 91 | .find(|chunk| !chunk.is_empty()) 92 | .unwrap_or(Lapp::main_name()); 93 | 94 | let access_token_cookie = Cookie::build(("access_token", access_token)) 95 | .domain(uri.host().unwrap_or("")) 96 | .path(format!("/{}", lapp_name)) 97 | .http_only(true) 98 | .max_age(Duration::days(365 * 10)) // 10 years 99 | .build(); 100 | 101 | let mut response = Redirect::to(&format!("{}{}", uri.path(), new_query)).into_response(); 102 | response.headers_mut().insert( 103 | header::SET_COOKIE, 104 | access_token_cookie.to_string().try_into().map_err(|_| request)?, 105 | ); 106 | 107 | Ok(response) 108 | } else { 109 | Err(request) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /laplace_server/src/cli.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | #[derive(clap::Parser)] 4 | pub struct Opts { 5 | #[clap(short, long, default_value = "Laplace.toml")] 6 | pub config: PathBuf, 7 | } 8 | -------------------------------------------------------------------------------- /laplace_server/src/convert.rs: -------------------------------------------------------------------------------- 1 | use axum::body::Body; 2 | use axum::http::Request; 3 | use http_body_util::BodyExt; 4 | use laplace_wasm::http; 5 | 6 | use crate::error::ServerResult; 7 | 8 | pub async fn to_wasm_http_request(request: Request) -> ServerResult { 9 | let (parts, body) = request.into_parts(); 10 | let body = BodyExt::collect(body).await?.to_bytes(); 11 | 12 | Ok(http::Request { 13 | method: parts.method, 14 | uri: parts.uri, 15 | version: parts.version, 16 | headers: parts.headers, 17 | body: body.into(), 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /laplace_server/src/error.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | use std::net::AddrParseError; 3 | 4 | use flexi_logger::FlexiLoggerError; 5 | use laplace_common::lapp::Permission; 6 | use rusqlite::Error as SqlError; 7 | use thiserror::Error; 8 | 9 | use crate::lapps::{LappInstanceError, LappSettingsError}; 10 | use crate::service::gossipsub; 11 | 12 | pub type AppResult = Result; 13 | 14 | #[derive(Debug, Error)] 15 | pub enum AppError { 16 | #[error("Web error: {0}")] 17 | WebError(#[from] hyper::Error), 18 | 19 | #[error("IO error: {0}")] 20 | IoError(#[from] io::Error), 21 | 22 | #[error("Parse addr error: {0}")] 23 | AddrParseError(#[from] AddrParseError), 24 | 25 | #[error("Logger error: {0}")] 26 | LoggerError(#[from] FlexiLoggerError), 27 | 28 | #[error("TLS error: {0:?}")] 29 | TlsError(#[from] rustls::Error), 30 | 31 | #[error("Certificate generation error: {0:?}")] 32 | RcgenError(#[from] rcgen::Error), 33 | 34 | #[error("Missing private key")] 35 | MissingPrivateKey, 36 | 37 | #[error("Error while generate token")] 38 | TokenGenerationFail, 39 | } 40 | 41 | pub type ServerResult = Result; 42 | 43 | #[derive(Debug, Error)] 44 | pub enum ServerError { 45 | #[error("Lapp wasm error: {0}")] 46 | LappWasm(#[from] anyhow::Error), 47 | 48 | #[error("Web error: {0}")] 49 | WebError(#[from] hyper::Error), 50 | 51 | #[error("Web server error: {0}")] 52 | WebServerError(#[from] axum::Error), 53 | 54 | #[error("Http error: {0}")] 55 | HttpError(#[from] axum::http::Error), 56 | 57 | #[error("P2p error: {0}")] 58 | P2pError(#[from] gossipsub::Error), 59 | 60 | #[error("Wrong parse JSON: {0}")] 61 | ParseJsonError(#[from] serde_json::Error), 62 | 63 | #[error("Zip error: {0}")] 64 | ZipError(#[from] zip::result::ZipError), 65 | 66 | #[error("Lapps manager poisoned lock: another task failed inside")] 67 | LappsManagerNotLock, 68 | 69 | #[error("Lapps poisoned lock")] 70 | LappNotLock, 71 | 72 | #[error("Lapp '{0}' does not exist")] 73 | LappNotFound(String), 74 | 75 | #[error("Lapp '{0}' is not enabled")] 76 | LappNotEnabled(String), 77 | 78 | #[error("Lapp '{0}' is not loaded")] 79 | LappNotLoaded(String), 80 | 81 | #[error("Lapp '{0}' already exists")] 82 | LappAlreadyExists(String), 83 | 84 | #[error("Path '{0}' is not lapp directory")] 85 | WrongLappDirectory(String), 86 | 87 | #[error("Unknown lapp name")] 88 | UnknownLappName, 89 | 90 | #[error("Permission '{perm}' denied for lapp '{0}'", perm = .1.as_str())] 91 | LappPermissionDenied(String, Permission), 92 | 93 | #[error("Lapp config operation error: {0}")] 94 | LappSettingsFail(#[from] LappSettingsError), 95 | 96 | #[error("Lapp file operation error: {0}")] 97 | LappIoError(#[from] io::Error), 98 | 99 | #[error("Wasm result value has wrong data length")] 100 | WrongResultLength, 101 | 102 | #[error("Wasm result value cannot be parsed")] 103 | ResultNotParsed, 104 | 105 | #[error("Lapp instance operation error: {0}")] 106 | LappInstanceFail(#[from] LappInstanceError), 107 | 108 | #[error("Lapp database operation error: {0:?}")] 109 | LappDatabaseError(#[from] SqlError), 110 | 111 | #[error("Lapp initialization error: {0:?}")] 112 | LappInitError(String), 113 | 114 | #[error("Fail to send lapp service for lapp '{0}'")] 115 | LappServiceSendError(String), 116 | } 117 | -------------------------------------------------------------------------------- /laplace_server/src/lapps.rs: -------------------------------------------------------------------------------- 1 | pub use self::instance::*; 2 | pub use self::lapp::*; 3 | pub use self::manager::*; 4 | pub use self::provider::*; 5 | pub use self::settings::*; 6 | 7 | mod instance; 8 | mod lapp; 9 | mod manager; 10 | mod provider; 11 | mod settings; 12 | mod wasm_interop; 13 | -------------------------------------------------------------------------------- /laplace_server/src/lapps/instance.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | use std::ops::Deref; 3 | use std::string::FromUtf8Error; 4 | 5 | use borsh::BorshDeserialize; 6 | use laplace_wasm::route::{gossipsub, websocket, Route}; 7 | use laplace_wasm::{http, WasmSlice}; 8 | use thiserror::Error; 9 | use wasmtime::component::ResourceTable; 10 | use wasmtime::{Instance, Store}; 11 | use wasmtime_wasi::preview1::WasiP1Ctx; 12 | use wasmtime_wasi::{WasiCtx, WasiView}; 13 | 14 | use crate::lapps::wasm_interop::database::DatabaseCtx; 15 | use crate::lapps::wasm_interop::http::HttpCtx; 16 | use crate::lapps::wasm_interop::{MemoryManagementError, MemoryManagementHostData}; 17 | 18 | #[derive(Debug, Error)] 19 | pub enum LappInstanceError { 20 | #[error("Wasm function does not found: {0}")] 21 | WasmFunctionNotFound(String), 22 | 23 | #[error("Wasm error: {0}")] 24 | WasmError(#[from] anyhow::Error), 25 | 26 | #[error("Can't deserialize string: {0:?}")] 27 | DeserializeStringError(#[from] FromUtf8Error), 28 | 29 | #[error("IO error: {0}")] 30 | IoError(#[from] io::Error), 31 | 32 | #[error("Wrong memory operation: {0}")] 33 | MemoryManagementError(#[from] MemoryManagementError), 34 | } 35 | 36 | pub type LappInstanceResult = Result; 37 | 38 | pub struct LappInstance { 39 | pub instance: Instance, 40 | pub memory_management: MemoryManagementHostData, 41 | pub store: Store, 42 | } 43 | 44 | impl LappInstance { 45 | pub async fn process_http(&mut self, request: http::Request) -> LappInstanceResult { 46 | let process_http_fn = self 47 | .instance 48 | .get_typed_func::(&mut self.store, "process_http")?; 49 | 50 | let bytes = borsh::to_vec(&request)?; 51 | let arg = self.bytes_to_wasm_slice(&bytes).await?; 52 | 53 | let slice = process_http_fn.call_async(&mut self.store, arg.into()).await?; 54 | let bytes = self.wasm_slice_to_vec(slice).await?; 55 | 56 | Ok(BorshDeserialize::deserialize(&mut bytes.as_slice())?) 57 | } 58 | 59 | pub async fn route_ws(&mut self, msg: &websocket::MessageIn) -> LappInstanceResult> { 60 | let route_ws_fn = self.instance.get_typed_func::(&mut self.store, "route_ws")?; 61 | let arg = self.bytes_to_wasm_slice(&borsh::to_vec(&msg)?).await?; 62 | 63 | let response_slice = route_ws_fn.call_async(&mut self.store, arg.into()).await?; 64 | let bytes = self.wasm_slice_to_vec(response_slice).await?; 65 | 66 | Ok(BorshDeserialize::try_from_slice(&bytes)?) 67 | } 68 | 69 | pub async fn route_gossipsub(&mut self, msg: &gossipsub::MessageIn) -> LappInstanceResult> { 70 | let route_gossipsub = self 71 | .instance 72 | .get_typed_func::(&mut self.store, "route_gossipsub")?; 73 | let arg = self.bytes_to_wasm_slice(&borsh::to_vec(&msg)?).await?; 74 | 75 | let response_slice = route_gossipsub.call_async(&mut self.store, arg.into()).await?; 76 | let bytes = self.wasm_slice_to_vec(response_slice).await?; 77 | 78 | Ok(BorshDeserialize::try_from_slice(&bytes)?) 79 | } 80 | 81 | pub async fn copy_to_memory(&mut self, src_bytes: &[u8]) -> LappInstanceResult { 82 | Ok(self 83 | .memory_management 84 | .to_manager(&mut self.store) 85 | .copy_to_memory(src_bytes) 86 | .await?) 87 | } 88 | 89 | pub async fn move_from_memory(&mut self, offset: usize, size: usize) -> LappInstanceResult> { 90 | Ok(self 91 | .memory_management 92 | .to_manager(&mut self.store) 93 | .move_from_memory(offset, size) 94 | .await?) 95 | } 96 | 97 | pub async fn wasm_slice_to_vec(&mut self, slice: impl Into) -> LappInstanceResult> { 98 | Ok(self 99 | .memory_management 100 | .to_manager(&mut self.store) 101 | .wasm_slice_to_vec(slice) 102 | .await?) 103 | } 104 | 105 | pub async fn wasm_slice_to_string(&mut self, slice: impl Into) -> LappInstanceResult { 106 | Ok(self 107 | .memory_management 108 | .to_manager(&mut self.store) 109 | .wasm_slice_to_string(slice) 110 | .await?) 111 | } 112 | 113 | pub async fn bytes_to_wasm_slice(&mut self, bytes: impl AsRef<[u8]>) -> LappInstanceResult { 114 | Ok(self 115 | .memory_management 116 | .to_manager(&mut self.store) 117 | .bytes_to_wasm_slice(bytes) 118 | .await?) 119 | } 120 | } 121 | 122 | impl Deref for LappInstance { 123 | type Target = Instance; 124 | 125 | fn deref(&self) -> &Self::Target { 126 | &self.instance 127 | } 128 | } 129 | 130 | pub struct Ctx { 131 | pub wasi: WasiP1Ctx, 132 | pub table: ResourceTable, 133 | pub memory_data: Option, 134 | pub database: Option, 135 | pub http: Option, 136 | } 137 | 138 | impl Ctx { 139 | pub fn new(wasi: WasiP1Ctx, table: ResourceTable) -> Self { 140 | Self { 141 | wasi, 142 | table, 143 | memory_data: None, 144 | database: None, 145 | http: None, 146 | } 147 | } 148 | 149 | pub fn memory_data(&self) -> &MemoryManagementHostData { 150 | self.memory_data.as_ref().expect("Memory data is empty") 151 | } 152 | } 153 | 154 | impl WasiView for Ctx { 155 | fn table(&mut self) -> &mut ResourceTable { 156 | &mut self.table 157 | } 158 | 159 | fn ctx(&mut self) -> &mut WasiCtx { 160 | self.wasi.ctx() 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /laplace_server/src/lapps/provider.rs: -------------------------------------------------------------------------------- 1 | use std::future::Future; 2 | use std::io; 3 | use std::sync::Arc; 4 | 5 | use axum::response::IntoResponse; 6 | use derive_more::Deref; 7 | use laplace_common::lapp::Permission; 8 | use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}; 9 | use truba::Context; 10 | 11 | use crate::error::ServerResult; 12 | use crate::lapps::LappsManager; 13 | use crate::service::Addr; 14 | use crate::settings::LappsSettings; 15 | use crate::web_api::{err_into_json_response, ResultResponse}; 16 | 17 | #[derive(Clone, Deref)] 18 | #[deref(forward)] 19 | pub struct LappsProvider(Arc>); 20 | 21 | impl LappsProvider { 22 | pub async fn new(settings: &LappsSettings, ctx: Context) -> io::Result { 23 | let manager = LappsManager::new(settings, ctx).await?; 24 | 25 | Ok(Self(Arc::new(RwLock::new(manager)))) 26 | } 27 | 28 | pub async fn read_manager(&self) -> RwLockReadGuard { 29 | self.0.read().await 30 | } 31 | 32 | pub async fn write_manager(&self) -> RwLockWriteGuard { 33 | self.0.write().await 34 | } 35 | 36 | pub async fn handle(self, handler: impl FnOnce(Self) -> Fut) -> ResultResponse 37 | where 38 | Fut: Future>, 39 | Res: IntoResponse, 40 | { 41 | handler(self).await.map_err(err_into_json_response) 42 | } 43 | 44 | pub async fn handle_allowed( 45 | self, 46 | permissions: &[Permission], 47 | lapp_name: String, 48 | handler: impl FnOnce(Self, String) -> Fut, 49 | ) -> ResultResponse 50 | where 51 | Fut: Future>, 52 | Res: IntoResponse, 53 | { 54 | self.handle(move |lapps_provider| async move { 55 | lapps_provider 56 | .read_manager() 57 | .await 58 | .check_enabled_and_allow_permissions(&lapp_name, permissions)?; 59 | 60 | handler(lapps_provider, lapp_name).await 61 | }) 62 | .await 63 | } 64 | 65 | pub async fn handle_client_http( 66 | self, 67 | lapp_name: String, 68 | handler: impl FnOnce(Self, String) -> Fut, 69 | ) -> ResultResponse 70 | where 71 | Fut: Future>, 72 | Res: IntoResponse, 73 | { 74 | self.handle_allowed(&[Permission::ClientHttp], lapp_name, handler).await 75 | } 76 | 77 | pub async fn handle_ws( 78 | self, 79 | lapp_name: String, 80 | handler: impl FnOnce(Self, String) -> Fut, 81 | ) -> ResultResponse 82 | where 83 | Fut: Future>, 84 | Res: IntoResponse, 85 | { 86 | self.handle_allowed(&[Permission::ClientHttp, Permission::Websocket], lapp_name, handler) 87 | .await 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /laplace_server/src/lapps/settings.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | use std::{fs, io}; 3 | 4 | use laplace_common::api::UpdateQuery; 5 | pub use laplace_common::lapp::{ApplicationSettings, LappSettings, PermissionsSettings}; 6 | use thiserror::Error; 7 | 8 | #[derive(Debug, Error)] 9 | pub enum LappSettingsError { 10 | #[error("Settings file operation error: {0}")] 11 | Io(#[from] io::Error), 12 | 13 | #[error("Settings deserialization error: {0}")] 14 | Deserialize(#[from] toml::de::Error), 15 | 16 | #[error("Settings serialization error: {0}")] 17 | Serialize(#[from] toml::ser::Error), 18 | } 19 | 20 | pub type LappSettingsResult = Result; 21 | 22 | pub trait FileSettings { 23 | type Settings; 24 | 25 | fn load(lapp_name: impl Into, path: impl AsRef) -> LappSettingsResult; 26 | fn save(&self, path: impl AsRef) -> LappSettingsResult<()>; 27 | fn update(&mut self, query: UpdateQuery, path: impl AsRef) -> LappSettingsResult; 28 | } 29 | 30 | impl FileSettings for LappSettings { 31 | type Settings = Self; 32 | 33 | fn load(lapp_name: impl Into, path: impl AsRef) -> LappSettingsResult { 34 | let content = fs::read_to_string(path)?; 35 | let mut settings: LappSettings = toml::from_str(&content)?; 36 | settings.lapp_name = lapp_name.into(); 37 | 38 | Ok(settings) 39 | } 40 | 41 | fn save(&self, path: impl AsRef) -> LappSettingsResult<()> { 42 | log::debug!("Save settings to file {}\n{:#?}", path.as_ref().display(), self); 43 | 44 | let settings = toml::to_string(self)?; 45 | fs::write(path, settings).map_err(Into::into) 46 | } 47 | 48 | fn update(&mut self, mut query: UpdateQuery, path: impl AsRef) -> LappSettingsResult { 49 | if let Some(enabled) = query.enabled { 50 | if self.enabled() != enabled { 51 | self.set_enabled(enabled); 52 | } else { 53 | query.enabled = None; 54 | } 55 | } 56 | 57 | if let Some(autoload) = query.autoload { 58 | if self.autoload() != autoload { 59 | self.set_autoload(autoload); 60 | } else { 61 | query.autoload = None; 62 | } 63 | } 64 | 65 | if let Some(permission) = query.allow_permission { 66 | if !self.permissions.allow(permission) { 67 | query.allow_permission = None; 68 | } 69 | } 70 | 71 | if let Some(permission) = query.deny_permission { 72 | if !self.permissions.deny(permission) { 73 | query.deny_permission = None; 74 | } 75 | } 76 | 77 | self.save(path)?; 78 | Ok(query) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /laplace_server/src/lapps/wasm_interop.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | use std::future::Future; 3 | use std::ptr::copy_nonoverlapping; 4 | use std::string::FromUtf8Error; 5 | 6 | use anyhow::anyhow; 7 | use laplace_wasm::WasmSlice; 8 | use thiserror::Error; 9 | use wasmtime::{AsContextMut, Instance, Memory, TypedFunc}; 10 | 11 | pub mod database; 12 | pub mod http; 13 | pub mod sleep; 14 | 15 | pub type BoxedSendFuture<'a, T> = Box + Send + 'a>; 16 | 17 | #[derive(Debug, Error)] 18 | pub enum MemoryManagementError { 19 | #[error("Wasm error: {0}")] 20 | Wasmtime(#[from] anyhow::Error), 21 | 22 | #[error("Wasm memory has a wrong size")] 23 | WrongMemorySize, 24 | 25 | #[error("Wrong string data: {0}")] 26 | IntoStringError(#[from] FromUtf8Error), 27 | } 28 | 29 | pub type MemoryManagementResult = Result; 30 | 31 | #[derive(Clone)] 32 | pub struct MemoryManagementHostData { 33 | memory: Memory, 34 | alloc_fn: TypedFunc, 35 | dealloc_fn: TypedFunc<(u32, u32), ()>, 36 | } 37 | 38 | impl MemoryManagementHostData { 39 | pub fn new(memory: Memory, alloc_fn: TypedFunc, dealloc_fn: TypedFunc<(u32, u32), ()>) -> Self { 40 | Self { 41 | memory, 42 | alloc_fn, 43 | dealloc_fn, 44 | } 45 | } 46 | 47 | pub fn from_instance(instance: &Instance, mut store: impl AsContextMut) -> anyhow::Result { 48 | let memory = instance 49 | .get_memory(&mut store, "memory") 50 | .ok_or_else(|| anyhow!("Memory is empty"))?; 51 | let alloc_fn = instance.get_typed_func(&mut store, "alloc")?; 52 | let dealloc_fn = instance.get_typed_func(store, "dealloc")?; 53 | 54 | Ok(Self::new(memory, alloc_fn, dealloc_fn)) 55 | } 56 | 57 | pub fn memory(&self) -> &Memory { 58 | &self.memory 59 | } 60 | 61 | pub fn to_manager<'a, S: AsContextMut>(&'a self, store: &'a mut S) -> MemoryManager<'a, S> { 62 | MemoryManager { 63 | host_data: Cow::Borrowed(self), 64 | store, 65 | } 66 | } 67 | 68 | pub fn into_manager(self, store: &mut S) -> MemoryManager { 69 | MemoryManager { 70 | host_data: Cow::Owned(self), 71 | store, 72 | } 73 | } 74 | } 75 | 76 | pub struct MemoryManager<'a, S> { 77 | host_data: Cow<'a, MemoryManagementHostData>, 78 | store: &'a mut S, 79 | } 80 | 81 | impl<'a, S> MemoryManager<'a, S> 82 | where 83 | S: AsContextMut, 84 | S::Data: Send, 85 | { 86 | pub fn memory(&self) -> &Memory { 87 | &self.host_data.memory 88 | } 89 | 90 | pub async fn memory_grow(&mut self, pages: u64) -> anyhow::Result { 91 | self.host_data.memory.grow_async(&mut self.store, pages).await 92 | } 93 | 94 | pub fn is_memory_enough(&self, offset: usize, size: usize) -> bool { 95 | size <= self.memory().data_size(&self.store) - offset 96 | } 97 | 98 | pub async fn grow_memory_if_needed(&mut self, offset: usize, size: usize) -> anyhow::Result<()> { 99 | while !self.is_memory_enough(offset, size) { 100 | log::trace!( 101 | "Destination offset = {} and buffer len = {}, but memory data size = {}", 102 | offset, 103 | size, 104 | self.memory().data_size(&self.store) 105 | ); 106 | self.memory_grow(1).await?; 107 | } 108 | Ok(()) 109 | } 110 | 111 | pub async fn copy_to_memory(&mut self, src_bytes: &[u8]) -> MemoryManagementResult { 112 | let size = src_bytes.len(); 113 | let offset = self.alloc(size as _).await?; 114 | self.grow_memory_if_needed(offset as _, size).await?; 115 | 116 | // SAFETY: in this point memory has a required space 117 | unsafe { 118 | copy_nonoverlapping( 119 | src_bytes.as_ptr(), 120 | self.memory().data_ptr(&self.store).offset(offset as _), 121 | size, 122 | ); 123 | } 124 | 125 | Ok(offset) 126 | } 127 | 128 | pub async fn move_from_memory(&mut self, offset: usize, size: usize) -> MemoryManagementResult> { 129 | let memory = self.memory(); 130 | log::trace!( 131 | "Move from memory: data_ptr = {}, data_size = {}, offset = {}, size = {}", 132 | memory.data_ptr(&self.store) as usize, 133 | memory.data_size(&self.store), 134 | offset, 135 | size 136 | ); 137 | 138 | let data = memory.data(&self.store)[offset..(offset + size)].to_vec(); 139 | unsafe { self.dealloc(offset as _, size as _).await? }; 140 | 141 | Ok(data) 142 | } 143 | 144 | pub async fn wasm_slice_to_vec(&mut self, slice: impl Into) -> MemoryManagementResult> { 145 | let slice = slice.into(); 146 | let ptr = slice.ptr() as _; 147 | let len = slice.len() as _; 148 | 149 | if self.is_memory_enough(ptr, len) { 150 | self.move_from_memory(ptr, len).await 151 | } else { 152 | log::error!( 153 | "WASM slice ptr = {ptr}, len = {len}, but memory data size = {}", 154 | self.memory().data_size(&self.store) 155 | ); 156 | Err(MemoryManagementError::WrongMemorySize) 157 | } 158 | } 159 | 160 | pub async fn wasm_slice_to_string(&mut self, slice: impl Into) -> MemoryManagementResult { 161 | let data = self.wasm_slice_to_vec(slice).await?; 162 | String::from_utf8(data).map_err(Into::into) 163 | } 164 | 165 | pub async fn bytes_to_wasm_slice(&mut self, bytes: impl AsRef<[u8]>) -> MemoryManagementResult { 166 | let bytes = bytes.as_ref(); 167 | let offset = self.copy_to_memory(bytes).await?; 168 | Ok(WasmSlice::from((offset, bytes.len() as _))) 169 | } 170 | 171 | async fn alloc(&mut self, size: u32) -> anyhow::Result { 172 | self.host_data.alloc_fn.call_async(&mut self.store, size).await 173 | } 174 | 175 | async unsafe fn dealloc(&mut self, offset: u32, size: u32) -> anyhow::Result<()> { 176 | log::trace!("Dealloc: offset = {offset}, size = {size}"); 177 | self.host_data 178 | .dealloc_fn 179 | .call_async(&mut self.store, (offset, size)) 180 | .await 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /laplace_server/src/lapps/wasm_interop/database.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use borsh::BorshSerialize; 4 | use laplace_wasm::database::{Row, Value}; 5 | use rusqlite::types::ValueRef; 6 | use rusqlite::{Connection, OptionalExtension}; 7 | use tokio::sync::Mutex; 8 | use wasmtime::Caller; 9 | 10 | use crate::lapps::wasm_interop::BoxedSendFuture; 11 | use crate::lapps::Ctx; 12 | 13 | pub struct DatabaseCtx { 14 | pub connection: Arc>, 15 | } 16 | 17 | impl DatabaseCtx { 18 | pub fn new(connection: Connection) -> Self { 19 | Self { 20 | connection: Arc::new(Mutex::new(connection)), 21 | } 22 | } 23 | } 24 | 25 | pub fn execute(caller: Caller, (sql_query_slice,): (u64,)) -> BoxedSendFuture { 26 | Box::new(run(caller, sql_query_slice, do_execute)) 27 | } 28 | 29 | pub fn query(caller: Caller, (sql_query_slice,): (u64,)) -> BoxedSendFuture { 30 | Box::new(run(caller, sql_query_slice, do_query)) 31 | } 32 | 33 | pub fn query_row(caller: Caller, (sql_query_slice,): (u64,)) -> BoxedSendFuture { 34 | Box::new(run(caller, sql_query_slice, do_query_row)) 35 | } 36 | 37 | pub fn do_execute(connection: &Connection, sql: String) -> Result { 38 | let updated_rows = connection.execute(&sql, []).map_err(|err| format!("{}", err))?; 39 | Ok(updated_rows as _) 40 | } 41 | 42 | pub fn do_query(connection: &Connection, sql: String) -> Result, String> { 43 | connection 44 | .prepare(&sql) 45 | .and_then(|mut stmt| { 46 | let mut rows = Vec::new(); 47 | let mut provider = stmt.query([])?; 48 | while let Some(row) = provider.next()? { 49 | rows.push(to_row(row)?); 50 | } 51 | Ok(rows) 52 | }) 53 | .map_err(|err| format!("{:?}", err)) 54 | } 55 | 56 | pub fn do_query_row(connection: &Connection, sql: String) -> Result, String> { 57 | connection 58 | .query_row(&sql, [], to_row) 59 | .optional() 60 | .map_err(|err| format!("{:?}", err)) 61 | } 62 | 63 | async fn run( 64 | mut caller: Caller<'_, Ctx>, 65 | sql_query_slice: u64, 66 | fun: impl Fn(&Connection, String) -> Result, 67 | ) -> u64 { 68 | let memory_data = caller.data().memory_data().clone(); 69 | 70 | let sql = memory_data 71 | .to_manager(&mut caller) 72 | .wasm_slice_to_string(sql_query_slice) 73 | .await 74 | .expect("SQL query should be converted to string"); 75 | 76 | let result = match caller.data().database.as_ref() { 77 | Some(database_ctx) => { 78 | let connection = database_ctx.connection.lock().await; 79 | fun(&connection, sql) 80 | }, 81 | None => Err("Database context not found".to_string()), 82 | }; 83 | 84 | let serialized = borsh::to_vec(&result).expect("Result should be serializable"); 85 | memory_data 86 | .to_manager(&mut caller) 87 | .bytes_to_wasm_slice(&serialized) 88 | .await 89 | .expect("Result should be to move to WASM") 90 | .into() 91 | } 92 | 93 | fn to_row(source: &rusqlite::Row<'_>) -> rusqlite::Result { 94 | (0..source.as_ref().column_count()) 95 | .map(|idx| source.get_ref(idx).map(to_value)) 96 | .collect::>() 97 | .map(Row::new) 98 | } 99 | 100 | fn to_value(source: ValueRef<'_>) -> Value { 101 | match source { 102 | ValueRef::Null => Value::Null, 103 | ValueRef::Integer(val) => Value::Integer(val), 104 | ValueRef::Real(val) => Value::Real(val), 105 | ValueRef::Text(val) => Value::Text(String::from_utf8_lossy(val).into()), 106 | ValueRef::Blob(val) => Value::Blob(val.into()), 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /laplace_server/src/lapps/wasm_interop/http.rs: -------------------------------------------------------------------------------- 1 | use std::iter::FromIterator; 2 | use std::time::Duration; 3 | 4 | use borsh::BorshDeserialize; 5 | use laplace_common::lapp::{HttpHosts, HttpMethod, HttpMethods, HttpSettings}; 6 | use laplace_wasm::http; 7 | use reqwest::Client; 8 | use wasmtime::Caller; 9 | 10 | use crate::lapps::wasm_interop::BoxedSendFuture; 11 | use crate::lapps::Ctx; 12 | 13 | #[derive(Clone)] 14 | pub struct HttpCtx { 15 | pub client: Client, 16 | pub settings: HttpSettings, 17 | } 18 | 19 | impl HttpCtx { 20 | pub fn new(client: Client, settings: HttpSettings) -> Self { 21 | Self { client, settings } 22 | } 23 | } 24 | 25 | pub fn invoke_http(caller: Caller, (request_slice,): (u64,)) -> BoxedSendFuture { 26 | Box::new(invoke_http_async(caller, request_slice)) 27 | } 28 | 29 | pub async fn invoke_http_async(mut caller: Caller<'_, Ctx>, request_slice: u64) -> u64 { 30 | let memory_data = caller.data().memory_data().clone(); 31 | 32 | let request_bytes = memory_data 33 | .to_manager(&mut caller) 34 | .wasm_slice_to_vec(request_slice) 35 | .await 36 | .map_err(|_| http::InvokeError::CanNotReadWasmData); 37 | 38 | let result = match caller.data().http.as_ref() { 39 | Some(http_ctx) => match request_bytes.and_then(|bytes| { 40 | BorshDeserialize::try_from_slice(&bytes).map_err(|_| http::InvokeError::FailDeserializeRequest) 41 | }) { 42 | Ok(request) => do_invoke_http(http_ctx, request).await, 43 | Err(err) => Err(err), 44 | }, 45 | None => Err(http::InvokeError::EmptyContext), 46 | }; 47 | 48 | let serialized = borsh::to_vec(&result).expect("Result should be serializable"); 49 | memory_data 50 | .to_manager(&mut caller) 51 | .bytes_to_wasm_slice(&serialized) 52 | .await 53 | .expect("Result should be to move to WASM") 54 | .into() 55 | } 56 | 57 | pub async fn do_invoke_http(ctx: &HttpCtx, request: http::Request) -> http::InvokeResult { 58 | log::trace!("Invoke HTTP: {request:#?},\n{:#?}", ctx.settings); 59 | let http::Request { 60 | method, 61 | uri, 62 | version, 63 | headers, 64 | body, 65 | } = request; 66 | 67 | log::trace!("Invoke HTTP body: {}", String::from_utf8_lossy(&body)); 68 | 69 | if !is_method_allowed(&method, &ctx.settings.methods) { 70 | return Err(http::InvokeError::ForbiddenMethod(method.to_string())); 71 | } 72 | 73 | if !is_host_allowed(uri.host().unwrap_or(""), &ctx.settings.hosts) { 74 | return Err(http::InvokeError::ForbiddenHost(uri.host().unwrap_or("").into())); 75 | } 76 | 77 | match ctx 78 | .client 79 | .request(method, uri.to_string()) 80 | .version(version) 81 | .body(body) 82 | .headers(headers) 83 | .timeout(Duration::from_millis(ctx.settings.timeout_ms)) 84 | .send() 85 | .await 86 | { 87 | Ok(response) => { 88 | log::trace!("Invoke HTTP response: {response:#?}"); 89 | 90 | Ok(http::Response { 91 | status: response.status(), 92 | version: response.version(), 93 | headers: http::HeaderMap::from_iter( 94 | response 95 | .headers() 96 | .iter() 97 | .map(|(name, value)| (name.clone(), value.clone())), 98 | ), 99 | body: { 100 | let body = response.bytes().await.map(|bytes| bytes.to_vec()).unwrap_or_default(); 101 | log::trace!("Invoke HTTP response body: {}", String::from_utf8_lossy(&body)); 102 | body 103 | }, 104 | }) 105 | }, 106 | Err(err) => Err(http::InvokeError::FailRequest( 107 | err.status().map(|status| status.as_u16()), 108 | format!("{}", err), 109 | )), 110 | } 111 | } 112 | 113 | fn is_method_allowed(method: &http::Method, methods: &HttpMethods) -> bool { 114 | match methods { 115 | HttpMethods::All => true, 116 | HttpMethods::List(list) => list.iter().any(|item| match item { 117 | HttpMethod::Get => method == http::Method::GET, 118 | HttpMethod::Post => method == http::Method::POST, 119 | }), 120 | } 121 | } 122 | 123 | fn is_host_allowed(host: &str, hosts: &HttpHosts) -> bool { 124 | match hosts { 125 | HttpHosts::All => true, 126 | HttpHosts::List(list) => list.iter().any(|item| item.as_str() == host), 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /laplace_server/src/lapps/wasm_interop/sleep.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use wasmtime::Caller; 4 | 5 | use crate::lapps::wasm_interop::BoxedSendFuture; 6 | use crate::lapps::Ctx; 7 | 8 | pub fn invoke_sleep(_caller: Caller, (millis,): (u64,)) -> BoxedSendFuture<()> { 9 | Box::new(tokio::time::sleep(Duration::from_millis(millis))) 10 | } 11 | -------------------------------------------------------------------------------- /laplace_server/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | use std::net::{IpAddr, SocketAddr}; 3 | use std::str::FromStr; 4 | use std::sync::Arc; 5 | 6 | use axum::extract::{DefaultBodyLimit, Request}; 7 | use axum::http::{HeaderName, HeaderValue}; 8 | use axum::response::Redirect; 9 | use axum::routing::get; 10 | use axum::{middleware, Router, ServiceExt}; 11 | use axum_server::tls_rustls::RustlsConfig; 12 | use const_format::concatcp; 13 | use flexi_logger::{style, Age, Cleanup, Criterion, DeferredNow, Duplicate, FileSpec, Logger, LoggerHandle, Naming}; 14 | use log::Record; 15 | use rustls::ServerConfig; 16 | use tower::{Layer, ServiceBuilder}; 17 | use tower_http::compression::CompressionLayer; 18 | use tower_http::normalize_path::NormalizePathLayer; 19 | use tower_http::services::{ServeDir, ServeFile}; 20 | use tower_http::set_header::SetResponseHeaderLayer; 21 | use truba::Context; 22 | 23 | use crate::error::AppResult; 24 | use crate::lapps::{Lapp, LappsProvider}; 25 | use crate::service::Addr; 26 | use crate::settings::{LoggerSettings, Settings}; 27 | 28 | pub mod auth; 29 | pub mod convert; 30 | pub mod error; 31 | pub mod lapps; 32 | pub mod service; 33 | pub mod settings; 34 | pub mod web_api; 35 | 36 | pub const VERSION: &str = env!("CARGO_PKG_VERSION"); 37 | 38 | pub fn init_logger(settings: &LoggerSettings) -> AppResult { 39 | let mut logger = Logger::try_with_env_or_str(&settings.spec)?; 40 | if let Some(path) = &settings.path { 41 | logger = logger 42 | .log_to_file(FileSpec::try_from(path)?.suppress_timestamp()) 43 | .rotate( 44 | Criterion::Age(Age::Day), 45 | Naming::Timestamps, 46 | Cleanup::KeepLogFiles(settings.keep_log_for_days), 47 | ) 48 | .append() 49 | } 50 | let handle = logger 51 | .duplicate_to_stdout(if settings.duplicate_to_stdout { 52 | Duplicate::All 53 | } else { 54 | Duplicate::None 55 | }) 56 | .use_utc() 57 | .format(custom_colored_detailed_format) 58 | .start()?; 59 | 60 | Ok(handle) 61 | } 62 | 63 | fn custom_colored_detailed_format( 64 | writer: &mut dyn io::Write, 65 | now: &mut DeferredNow, 66 | record: &Record, 67 | ) -> Result<(), io::Error> { 68 | let level = record.level(); 69 | let start_idx = record 70 | .file() 71 | .and_then(|path| { 72 | path.rfind("/src") 73 | .and_then(|src_idx| path[..src_idx].rfind('/')) 74 | .map(|start_idx| start_idx + 1) 75 | }) 76 | .unwrap_or(0); 77 | let path = record.file().map(|path| &path[start_idx..]).unwrap_or(""); 78 | 79 | write!( 80 | writer, 81 | "[{}] {} [{}] {}:{}: {}", 82 | style(level).paint(now.format("%Y-%m-%d %H:%M:%S%.6f").to_string()), 83 | style(level).paint(level.as_str()), 84 | record.module_path().unwrap_or(""), 85 | path, 86 | record.line().unwrap_or(0), 87 | style(level).paint(record.args().to_string()), 88 | ) 89 | } 90 | 91 | pub async fn run(settings: Settings) -> AppResult<()> { 92 | let web_root = settings.http.web_root.clone(); 93 | let laplace_access_token = auth::prepare_access_token(settings.http.access_token.clone())?; 94 | let upload_file_limit = settings.http.upload_file_limit; 95 | let ctx = Context::::default(); 96 | let lapps_provider = LappsProvider::new(&settings.lapps, ctx.clone()) 97 | .await 98 | .unwrap_or_else(|err| { 99 | panic!( 100 | "Lapps provider should be constructed from settings {:?}: {err}", 101 | settings.lapps 102 | ) 103 | }); 104 | 105 | let root_url = format!( 106 | "{schema}://{host}:{port}", 107 | schema = if settings.ssl.enabled { "https" } else { "http" }, 108 | host = settings.http.host, 109 | port = settings.http.port, 110 | ); 111 | if settings.http.print_url { 112 | let access_query = (!laplace_access_token.is_empty()) 113 | .then(|| format!("?access_token={laplace_access_token}")) 114 | .unwrap_or_default(); 115 | 116 | log::info!("Laplace URL: {root_url}/{access_query}",); 117 | } 118 | 119 | log::info!("Load lapps"); 120 | lapps_provider.read_manager().await.autoload_lapps().await; 121 | 122 | if settings.http.print_url { 123 | for (lapp_name, lapp_settings) in lapps_provider.read_manager().await.lapp_settings_iter() { 124 | if lapp_settings.is_lapp_startup_active() { 125 | let access_query = lapp_settings 126 | .application 127 | .access_token 128 | .as_ref() 129 | .map(|access_token| format!("?access_token={access_token}")) 130 | .unwrap_or_default(); 131 | log::info!("Lapp '{lapp_name}' URL: {root_url}/{lapp_name}{access_query}"); 132 | } 133 | } 134 | } 135 | 136 | log::info!("Create HTTP server"); 137 | let static_dir = web_root.join(Lapp::static_dir_name()); 138 | let laplace_uri = concatcp!("/", Lapp::main_name()); 139 | 140 | let router = Router::new() 141 | .route("/", get(|| async { Redirect::to(laplace_uri) })) 142 | .route_service("/favicon.ico", ServeFile::new(static_dir.join("favicon.ico"))) 143 | .nest_service(&Lapp::main_static_uri(), ServeDir::new(&static_dir)) 144 | .fallback_service(ServeFile::new(Lapp::index_file_name())) 145 | .merge(web_api::laplace::router(laplace_uri, &static_dir, &settings.lapps.path)) 146 | .merge(web_api::lapp::router()) 147 | .route_layer(middleware::from_fn_with_state( 148 | (lapps_provider.clone(), laplace_access_token), 149 | auth::middleware::check_access, 150 | )) 151 | .layer( 152 | ServiceBuilder::new() 153 | .layer(DefaultBodyLimit::max(upload_file_limit)) 154 | .layer(CompressionLayer::new()) 155 | .layer(SetResponseHeaderLayer::if_not_present( 156 | HeaderName::from_static("x-version"), 157 | HeaderValue::from_static(VERSION), 158 | )), 159 | ) 160 | .with_state(lapps_provider); 161 | let service = ServiceExt::::into_make_service(NormalizePathLayer::trim_trailing_slash().layer(router)); 162 | 163 | log::info!("Run HTTP server"); 164 | let http_server_addr = SocketAddr::new(IpAddr::from_str(&settings.http.host)?, settings.http.port); 165 | if settings.ssl.enabled { 166 | let (certificates, private_key) = auth::prepare_certificates( 167 | &settings.ssl.certificate_path, 168 | &settings.ssl.private_key_path, 169 | &settings.http.host, 170 | )?; 171 | 172 | rustls::crypto::ring::default_provider() 173 | .install_default() 174 | .expect("Failed to install default provider"); 175 | let config = ServerConfig::builder() 176 | .with_no_client_auth() 177 | .with_single_cert(certificates, private_key)?; 178 | 179 | axum_server::bind_rustls(http_server_addr, RustlsConfig::from_config(Arc::new(config))) 180 | .serve(service) 181 | .await? 182 | } else { 183 | axum_server::Server::bind(http_server_addr).serve(service).await? 184 | }; 185 | 186 | log::info!("Shutdown the context"); 187 | ctx.shutdown().await; 188 | 189 | Ok(()) 190 | } 191 | -------------------------------------------------------------------------------- /laplace_server/src/main.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use laplace_server::settings::Settings; 3 | 4 | mod cli; 5 | 6 | #[tokio::main] 7 | async fn main() { 8 | let opts: cli::Opts = cli::Opts::parse(); 9 | let settings = Settings::new(&opts.config).expect("Settings should be configured"); 10 | 11 | laplace_server::init_logger(&settings.log).expect("Logger should be configured"); 12 | laplace_server::run(settings).await.expect("Laplace running error") 13 | } 14 | -------------------------------------------------------------------------------- /laplace_server/src/service.rs: -------------------------------------------------------------------------------- 1 | use derive_more::Display; 2 | 3 | pub use self::gossipsub::GossipsubService; 4 | pub use self::lapp::LappService; 5 | pub use self::websocket::WebSocketService; 6 | 7 | pub mod gossipsub; 8 | pub mod lapp; 9 | pub mod websocket; 10 | 11 | #[derive(Debug, Hash, Clone, Eq, PartialEq, Display)] 12 | pub enum Addr { 13 | #[display("Lapp({})", _0)] 14 | Lapp(String), 15 | } 16 | 17 | impl Addr { 18 | pub fn as_lapp_name(&self) -> &str { 19 | match self { 20 | Addr::Lapp(name) => name.as_str(), 21 | } 22 | } 23 | 24 | pub fn into_lapp_name(self) -> String { 25 | self.into() 26 | } 27 | } 28 | 29 | impl From for String { 30 | fn from(addr: Addr) -> Self { 31 | match addr { 32 | Addr::Lapp(value) => value, 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /laplace_server/src/service/gossipsub/error.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | 3 | use laplace_wasm::route::gossipsub::{Error as WasmError, ErrorKind}; 4 | use thiserror::Error; 5 | 6 | pub type GossipsubResult = Result; 7 | 8 | #[derive(Error, Debug)] 9 | pub enum Error { 10 | #[error("Noise error: {0}")] 11 | NoiseError(#[from] libp2p::noise::Error), 12 | 13 | #[error("Hash error: {0}")] 14 | HashError(#[from] libp2p::multihash::Error), 15 | 16 | #[error("Fail identity decode: {0}")] 17 | IdentityDecodeError(#[from] libp2p::identity::DecodingError), 18 | 19 | #[error("Wrong multiaddr: {0}")] 20 | WrongMultiaddr(#[from] libp2p::multiaddr::Error), 21 | 22 | #[error("Dial error: {0}")] 23 | DialError(#[from] libp2p::swarm::DialError), 24 | 25 | #[error("I/O error: {0}")] 26 | Io(#[from] io::Error), 27 | 28 | #[error("Wrong behaviour: {0}")] 29 | WrongBehaviour(String), 30 | 31 | #[error("Gossipsub uninitialize: {0}")] 32 | GossipsubUninit(String), 33 | 34 | #[error("Gossipsub subscription error: {0:?}")] 35 | GossipsubSubscribtionError(libp2p::gossipsub::SubscriptionError), 36 | 37 | #[error("Gossipsub publish error: {0:?}")] 38 | GossipsubPublishError(libp2p::gossipsub::PublishError), 39 | 40 | #[error("Parse peer ID error: {0}")] 41 | ParsePeerIdError(String), 42 | 43 | #[error("Transport error: {0}")] 44 | TransportError(#[from] libp2p::TransportError), 45 | } 46 | 47 | impl From for WasmError { 48 | fn from(err: Error) -> Self { 49 | let kind = match &err { 50 | Error::GossipsubPublishError(_) => ErrorKind::GossipsubPublishError, 51 | Error::ParsePeerIdError(_) => ErrorKind::ParsePeerIdError, 52 | Error::DialError(_) => ErrorKind::DialError, 53 | Error::WrongMultiaddr(_) => ErrorKind::WrongMultiaddr, 54 | _ => ErrorKind::Other, 55 | }; 56 | 57 | Self { 58 | message: err.to_string(), 59 | kind, 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /laplace_server/src/service/websocket.rs: -------------------------------------------------------------------------------- 1 | use std::ops::ControlFlow; 2 | use std::time::{Duration, Instant}; 3 | 4 | use axum::extract::ws; 5 | use axum::extract::ws::WebSocket; 6 | use futures::stream::{SplitSink, SplitStream}; 7 | use futures::{SinkExt, StreamExt}; 8 | pub use laplace_wasm::route::websocket::{Message, MessageIn, MessageOut}; 9 | use tokio::time; 10 | use truba::{Context, Sender, UnboundedMpscChannel}; 11 | 12 | use crate::service::lapp::LappServiceMessage; 13 | use crate::service::Addr; 14 | 15 | #[derive(Debug)] 16 | pub struct WsServiceMessage(pub MessageOut); 17 | 18 | impl truba::Message for WsServiceMessage { 19 | type Channel = UnboundedMpscChannel; 20 | } 21 | 22 | #[derive(Debug)] 23 | pub struct WebSocketService { 24 | /// Client must send ping at least once per SETTINGS.ws.client_timeout_sec seconds, 25 | /// otherwise we drop connection. 26 | hb: Instant, 27 | 28 | lapp_service_sender: Sender, 29 | ws_sender: SplitSink, 30 | ws_receiver: SplitStream, 31 | } 32 | 33 | impl WebSocketService { 34 | /// How often heartbeat pings are sent 35 | const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); 36 | 37 | /// How long before lack of client response causes a timeout 38 | const CLIENT_TIMEOUT: Duration = Duration::from_secs(10); 39 | 40 | pub fn new(web_socket: WebSocket, lapp_service_sender: Sender) -> Self { 41 | let (ws_sender, ws_receiver) = web_socket.split(); 42 | 43 | Self { 44 | hb: Instant::now(), 45 | lapp_service_sender, 46 | ws_sender, 47 | ws_receiver, 48 | } 49 | } 50 | 51 | pub fn run(mut self, ctx: Context, actor_id: Addr) { 52 | let mut messages_in = ctx.actor_receiver::(actor_id); 53 | let mut hb_interval = time::interval(Self::HEARTBEAT_INTERVAL); 54 | 55 | ctx.clone().spawn(async move { 56 | truba::event_loop!(ctx, { 57 | _ = hb_interval.tick() => { 58 | if self.handle_heartbeat().await.is_break() { 59 | break; 60 | } 61 | } 62 | Some(msg) = self.ws_receiver.next() => { 63 | if self.handle_ws_message(msg).is_break() { 64 | break; 65 | } 66 | }, 67 | Some(WsServiceMessage(msg)) = messages_in.recv() => { 68 | if self.handle_service_message(msg).await.is_break() { 69 | break; 70 | } 71 | }, 72 | }); 73 | self.close().await; 74 | }); 75 | } 76 | 77 | /// helper method that sends ping to client every second. 78 | /// 79 | /// also this method checks heartbeats from client 80 | async fn handle_heartbeat(&mut self) -> ControlFlow<(), ()> { 81 | // check client heartbeats 82 | if Instant::now().duration_since(self.hb) > Self::CLIENT_TIMEOUT { 83 | // heartbeat timed out 84 | log::debug!("Websocket Client heartbeat failed, disconnecting!"); 85 | self.send_to_lapp(MessageIn::Timeout); 86 | 87 | // don't try to send a ping 88 | ControlFlow::Break(()) 89 | } else if !self.send_to_ws(None, ws::Message::Ping(Vec::new())).await { 90 | ControlFlow::Break(()) 91 | } else { 92 | ControlFlow::Continue(()) 93 | } 94 | } 95 | 96 | fn handle_ws_message(&mut self, msg: Result) -> ControlFlow<(), ()> { 97 | let msg = match msg { 98 | Ok(msg) => msg, 99 | Err(err) => { 100 | log::error!("WS error: {err:?}"); 101 | return ControlFlow::Break(()); 102 | }, 103 | }; 104 | 105 | match msg { 106 | ws::Message::Text(text) => { 107 | log::debug!("Receive WS text: {text}"); 108 | self.send_to_lapp(Message::Text(text).into()); 109 | }, 110 | ws::Message::Binary(bin) => { 111 | log::debug!("Receive WS binary: {bin:?}"); 112 | self.send_to_lapp(Message::Binary(bin).into()); 113 | }, 114 | ws::Message::Close(close_frame) => { 115 | log::debug!("Receive WS close: {close_frame:?}"); 116 | self.send_to_lapp(Message::Close.into()); 117 | return ControlFlow::Break(()); 118 | }, 119 | 120 | ws::Message::Pong(_) => { 121 | self.hb = Instant::now(); 122 | }, 123 | // You should never need to manually handle Message::Ping, as axum's websocket library 124 | // will do so for you automagically by replying with Pong and copying the v according to 125 | // spec. But if you need the contents of the pings you can see them here. 126 | ws::Message::Ping(_) => { 127 | self.hb = Instant::now(); 128 | }, 129 | } 130 | ControlFlow::Continue(()) 131 | } 132 | 133 | async fn handle_service_message(&mut self, MessageOut { id, msg }: MessageOut) -> ControlFlow<(), ()> { 134 | let id = Some(id); 135 | let sent = match msg { 136 | Message::Text(text) => self.send_to_ws(id, ws::Message::Text(text)).await, 137 | Message::Binary(text) => self.send_to_ws(id, ws::Message::Binary(text)).await, 138 | Message::Close => self.send_to_ws(id, ws::Message::Close(None)).await, 139 | }; 140 | if !sent { 141 | ControlFlow::Break(()) 142 | } else { 143 | ControlFlow::Continue(()) 144 | } 145 | } 146 | 147 | async fn close(&mut self) { 148 | self.ws_sender.send(ws::Message::Close(None)).await.ok(); 149 | } 150 | 151 | async fn send_to_ws(&mut self, id: Option, msg: ws::Message) -> bool { 152 | let sent; 153 | let result; 154 | 155 | if let Err(err) = self.ws_sender.send(msg).await { 156 | log::error!("WS send error: {err:?}"); 157 | result = Err(err.to_string()); 158 | sent = false; 159 | } else { 160 | result = Ok(()); 161 | sent = true; 162 | } 163 | 164 | if let Some(id) = id { 165 | self.send_to_lapp(MessageIn::Response { id, result }); 166 | } else if let Err(err) = result { 167 | self.send_to_lapp(MessageIn::Error(err.to_string())); 168 | } 169 | sent 170 | } 171 | 172 | fn send_to_lapp(&self, msg: MessageIn) { 173 | if let Err(err) = self.lapp_service_sender.send(LappServiceMessage::WebSocket(msg)) { 174 | log::error!("Error occurs when send to lapp service: {err:?}"); 175 | } 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /laplace_server/src/settings.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | use std::path::{Path, PathBuf}; 3 | 4 | pub use config::ConfigError; 5 | use config::{Config, Environment, File}; 6 | use serde::{Deserialize, Serialize}; 7 | 8 | #[derive(Debug, Deserialize, Serialize)] 9 | #[serde(default)] 10 | pub struct HttpSettings { 11 | pub host: String, 12 | pub port: u16, 13 | pub web_root: PathBuf, 14 | pub access_token: Option, 15 | pub upload_file_limit: usize, 16 | pub print_url: bool, 17 | } 18 | 19 | impl Default for HttpSettings { 20 | fn default() -> Self { 21 | Self { 22 | host: "127.0.0.1".into(), 23 | port: 8080, 24 | web_root: PathBuf::new(), 25 | access_token: None, 26 | upload_file_limit: 2 * 1024 * 1024 * 1024, 27 | print_url: true, 28 | } 29 | } 30 | } 31 | 32 | #[derive(Debug, Deserialize, Serialize)] 33 | pub struct SslSettings { 34 | #[serde(default)] 35 | pub enabled: bool, 36 | 37 | #[serde(default = "private_key_path_default")] 38 | pub private_key_path: PathBuf, 39 | 40 | #[serde(default = "certificate_path_default")] 41 | pub certificate_path: PathBuf, 42 | } 43 | 44 | impl Default for SslSettings { 45 | fn default() -> Self { 46 | Self { 47 | enabled: false, 48 | private_key_path: private_key_path_default(), 49 | certificate_path: certificate_path_default(), 50 | } 51 | } 52 | } 53 | 54 | fn private_key_path_default() -> PathBuf { 55 | PathBuf::from("key.pem") 56 | } 57 | 58 | fn certificate_path_default() -> PathBuf { 59 | PathBuf::from("cert.pem") 60 | } 61 | 62 | #[derive(Debug, Default, Deserialize, Serialize)] 63 | #[serde(default)] 64 | pub struct P2pSettings { 65 | pub mdns_discovery_enabled: bool, 66 | } 67 | 68 | #[derive(Debug, Deserialize, Serialize)] 69 | #[serde(default)] 70 | pub struct LoggerSettings { 71 | #[serde(default = "default_spec")] 72 | pub spec: String, 73 | 74 | pub path: Option, 75 | 76 | pub duplicate_to_stdout: bool, 77 | 78 | #[serde(default = "default_keep_log_for_days")] 79 | pub keep_log_for_days: usize, 80 | } 81 | 82 | impl Default for LoggerSettings { 83 | fn default() -> Self { 84 | Self { 85 | spec: default_spec(), 86 | path: None, 87 | duplicate_to_stdout: false, 88 | keep_log_for_days: default_keep_log_for_days(), 89 | } 90 | } 91 | } 92 | 93 | fn default_spec() -> String { 94 | "info".into() 95 | } 96 | 97 | const fn default_keep_log_for_days() -> usize { 98 | 7 99 | } 100 | 101 | #[derive(Debug, Deserialize, Serialize)] 102 | #[serde(default)] 103 | pub struct LappsSettings { 104 | pub path: PathBuf, 105 | pub allowed: Option>, 106 | } 107 | 108 | impl Default for LappsSettings { 109 | fn default() -> Self { 110 | Self { 111 | path: "lapps".into(), 112 | allowed: None, 113 | } 114 | } 115 | } 116 | 117 | #[derive(Default, Debug, Deserialize, Serialize)] 118 | #[serde(default)] 119 | pub struct Settings { 120 | pub http: HttpSettings, 121 | pub ssl: SslSettings, 122 | pub p2p: P2pSettings, 123 | pub log: LoggerSettings, 124 | pub lapps: LappsSettings, 125 | } 126 | 127 | impl Settings { 128 | pub fn new(path: impl AsRef) -> Result { 129 | let config = Config::builder() 130 | .add_source(File::from(path.as_ref())) 131 | // Add in settings from the environment (with a prefix of LAPLACE) 132 | // Eg.. `LAPLACE__HTTP__PORT=8090 laplace_server` would set the `http.port` key 133 | .add_source( 134 | Environment::with_prefix("LAPLACE") 135 | .separator("__") 136 | .with_list_parse_key("lapps.allowed") 137 | .list_separator(",") 138 | .try_parsing(true), 139 | ) 140 | .build()?; 141 | config.try_deserialize() 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /laplace_server/src/web_api.rs: -------------------------------------------------------------------------------- 1 | use axum::http::StatusCode; 2 | use axum::Json; 3 | use serde_json::{json, Value}; 4 | 5 | use crate::error::{ServerError, ServerResult}; 6 | 7 | pub mod laplace; 8 | pub mod lapp; 9 | 10 | pub type JsonErrResponse = (StatusCode, Json); 11 | pub type ResultResponse = Result; 12 | 13 | pub trait IntoJsonResponse { 14 | type Output; 15 | 16 | fn into_json_response(self) -> ResultResponse>; 17 | } 18 | 19 | impl IntoJsonResponse for ServerResult { 20 | type Output = T; 21 | 22 | fn into_json_response(self) -> ResultResponse> { 23 | self.map(Json).map_err(err_into_json_response) 24 | } 25 | } 26 | 27 | pub fn err_into_json_response(err: ServerError) -> JsonErrResponse { 28 | ( 29 | StatusCode::INTERNAL_SERVER_ERROR, 30 | Json(json!({ "error": err.to_string() })), 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /laplace_server/src/web_api/laplace.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use axum::routing::{get, post}; 4 | use axum::Router; 5 | use tower_http::services::{ServeDir, ServeFile}; 6 | 7 | use crate::lapps::{Lapp, LappsProvider}; 8 | 9 | pub mod handler; 10 | 11 | pub fn router( 12 | laplace_uri: &'static str, 13 | static_dir: impl Into, 14 | lapps_dir: impl Into, 15 | ) -> Router { 16 | let static_dir = static_dir.into(); 17 | let lapps_dir = lapps_dir.into(); 18 | 19 | Router::new() 20 | .route_service(laplace_uri, ServeFile::new(static_dir.join(Lapp::index_file_name()))) 21 | .nest_service( 22 | &format!("{laplace_uri}/{}", Lapp::static_dir_name()), 23 | ServeDir::new(lapps_dir.join(Lapp::main_name()).join(Lapp::static_dir_name())), 24 | ) 25 | .route(&format!("{laplace_uri}/lapps"), get(handler::get_lapps)) 26 | .route(&format!("{laplace_uri}/lapp/add"), post(handler::add_lapp)) 27 | .route(&format!("{laplace_uri}/lapp/update"), post(handler::update_lapp)) 28 | } 29 | -------------------------------------------------------------------------------- /laplace_server/src/web_api/laplace/handler.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | 3 | use axum::extract::State; 4 | use axum::response::{IntoResponse, Response}; 5 | use axum::Json; 6 | use axum_typed_multipart::{FieldData, TryFromMultipart, TypedMultipart}; 7 | use tempfile::NamedTempFile; 8 | use zip::ZipArchive; 9 | 10 | use crate::error::{ServerError, ServerResult}; 11 | use crate::lapps::{CommonLappGuard, CommonLappResponse, Lapp, LappUpdateRequest, LappsProvider}; 12 | use crate::web_api::err_into_json_response; 13 | 14 | pub async fn get_lapps(State(lapps_provider): State) -> impl IntoResponse { 15 | process_get_lapps(lapps_provider).await.map_err(err_into_json_response) 16 | } 17 | 18 | #[derive(TryFromMultipart)] 19 | pub struct LarUpload { 20 | // This field will be limited to the total size of the request body. 21 | #[form_data(limit = "unlimited")] 22 | pub lar: FieldData, 23 | } 24 | 25 | pub async fn add_lapp( 26 | State(lapps_provider): State, 27 | TypedMultipart(form): TypedMultipart, 28 | ) -> impl IntoResponse { 29 | process_add_lapp(lapps_provider, form.lar) 30 | .await 31 | .map_err(err_into_json_response) 32 | } 33 | 34 | pub async fn update_lapp( 35 | State(lapps_provider): State, 36 | Json(update_request): Json, 37 | ) -> impl IntoResponse { 38 | process_update_lapp(lapps_provider, update_request) 39 | .await 40 | .map_err(err_into_json_response) 41 | } 42 | 43 | async fn process_get_lapps(lapps_provider: LappsProvider) -> ServerResult { 44 | let manager = lapps_provider.read_manager().await; 45 | 46 | let mut lapps = Vec::new(); 47 | for (lapp_name, lapp_settings) in manager.lapp_settings_iter() { 48 | if !Lapp::is_main(lapp_name) { 49 | lapps.push(CommonLappGuard(lapp_settings)); 50 | } 51 | } 52 | lapps.sort_unstable_by(|lapp_a, lapp_b| lapp_a.name().cmp(lapp_b.name())); 53 | 54 | Ok(Json(CommonLappResponse::lapps(lapps)).into_response()) 55 | } 56 | 57 | async fn process_add_lapp(lapps_provider: LappsProvider, lar: FieldData) -> ServerResult { 58 | let file_name = lar.metadata.file_name.ok_or(ServerError::UnknownLappName)?; 59 | let lapp_name = file_name 60 | .strip_suffix(".zip") 61 | .unwrap_or_else(|| file_name.strip_suffix(".lar").unwrap_or(&file_name)); 62 | 63 | extract_lar(&lapps_provider, lapp_name, ZipArchive::new(lar.contents.as_file())?).await?; 64 | lapps_provider.write_manager().await.insert_lapp_settings(lapp_name); 65 | 66 | process_get_lapps(lapps_provider).await 67 | } 68 | 69 | async fn extract_lar( 70 | lapps_provider: &LappsProvider, 71 | lapp_name: &str, 72 | mut archive: ZipArchive, 73 | ) -> ServerResult<()> { 74 | let lapp_dir = lapps_provider.read_manager().await.lapp_dir(lapp_name); 75 | 76 | if lapp_dir.exists() { 77 | if !lapp_dir.is_dir() { 78 | return Err(ServerError::WrongLappDirectory(lapp_dir.display().to_string())); 79 | } 80 | 81 | if lapp_dir.read_dir()?.next().is_some() { 82 | return Err(ServerError::LappAlreadyExists(lapp_name.into())); 83 | } 84 | } 85 | 86 | archive.extract(lapp_dir).map_err(Into::into) 87 | } 88 | 89 | async fn process_update_lapp( 90 | lapps_provider: LappsProvider, 91 | update_request: LappUpdateRequest, 92 | ) -> ServerResult { 93 | let update_query = update_request.into_query(); 94 | let updated = lapps_provider 95 | .write_manager() 96 | .await 97 | .update_lapp_settings(update_query) 98 | .await?; 99 | 100 | Ok(Json(CommonLappResponse::Updated { updated }).into_response()) 101 | } 102 | -------------------------------------------------------------------------------- /laplace_server/src/web_api/lapp.rs: -------------------------------------------------------------------------------- 1 | use axum::routing::{any, get, post}; 2 | use axum::Router; 3 | use const_format::concatcp; 4 | 5 | use crate::lapps::{Lapp, LappsProvider}; 6 | 7 | pub mod handler; 8 | 9 | pub fn router() -> Router { 10 | Router::new() 11 | .route("/:lapp_name", get(handler::index_file)) 12 | .route( 13 | concatcp!("/:lapp_name/", Lapp::static_dir_name(), "/*file_path"), 14 | get(handler::static_file), 15 | ) 16 | .route("/:lapp_name/api/ws", get(handler::ws_start)) 17 | .route("/:lapp_name/api/p2p", post(handler::gossipsub_start)) 18 | .route("/:lapp_name/api/*tail", any(handler::http)) 19 | .route("/:lapp_name/*tail", get(handler::index)) 20 | } 21 | -------------------------------------------------------------------------------- /laplace_wasm/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "laplace_wasm" 3 | version = "0.1.0" 4 | authors = [ 5 | "Alexander Mescheryakov ", 6 | "Noogen Team ", 7 | ] 8 | edition = "2021" 9 | license = "MIT" 10 | readme = "README.md" 11 | repository = "https://github.com/noogen-projects/laplace" 12 | description = "The WASM utilities of the local-firs web-application platform" 13 | 14 | [dependencies] 15 | borsh = { workspace = true } 16 | derive_more = { workspace = true } 17 | http = "1.1" 18 | laplace_wasm_macro = { path = "../laplace_wasm_macro" } 19 | thiserror = { workspace = true } 20 | -------------------------------------------------------------------------------- /laplace_wasm/src/database.rs: -------------------------------------------------------------------------------- 1 | use borsh::{BorshDeserialize, BorshSerialize}; 2 | 3 | use crate::WasmSlice; 4 | 5 | extern "C" { 6 | fn db_execute(sql_query: WasmSlice) -> WasmSlice; 7 | fn db_query(sql_query: WasmSlice) -> WasmSlice; 8 | fn db_query_row(sql_query: WasmSlice) -> WasmSlice; 9 | } 10 | 11 | pub fn execute(sql: impl Into) -> Result { 12 | let bytes = unsafe { db_execute(WasmSlice::from(sql.into())).into_vec_in_wasm() }; 13 | BorshDeserialize::try_from_slice(&bytes).expect("Execution result should be deserializable") 14 | } 15 | 16 | pub fn query(sql: impl Into) -> Result, String> { 17 | let bytes = unsafe { db_query(WasmSlice::from(sql.into())).into_vec_in_wasm() }; 18 | BorshDeserialize::try_from_slice(&bytes).expect("Query result should be deserializable") 19 | } 20 | 21 | pub fn query_row(sql: impl Into) -> Result, String> { 22 | let bytes = unsafe { db_query_row(WasmSlice::from(sql.into())).into_vec_in_wasm() }; 23 | BorshDeserialize::try_from_slice(&bytes).expect("Query row result should be deserializable") 24 | } 25 | 26 | #[derive(Debug, Clone, PartialEq, BorshSerialize, BorshDeserialize)] 27 | pub enum Value { 28 | Null, 29 | Integer(i64), 30 | Real(f64), 31 | Text(String), 32 | Blob(Vec), 33 | } 34 | 35 | #[derive(Debug, Clone, PartialEq, BorshSerialize, BorshDeserialize)] 36 | pub struct Column { 37 | name: String, 38 | decl_type: Option, 39 | } 40 | 41 | impl Column { 42 | pub fn new(name: impl Into, decl_type: impl Into>) -> Self { 43 | Self { 44 | name: name.into(), 45 | decl_type: decl_type.into(), 46 | } 47 | } 48 | 49 | /// Returns the name of the column. 50 | pub fn name(&self) -> &str { 51 | &self.name 52 | } 53 | 54 | /// Returns the type of the column (`None` for expression). 55 | pub fn decl_type(&self) -> Option<&str> { 56 | self.decl_type.as_deref() 57 | } 58 | } 59 | 60 | #[derive(Debug, Clone, PartialEq, BorshSerialize, BorshDeserialize)] 61 | pub struct Row { 62 | values: Vec, 63 | } 64 | 65 | impl Row { 66 | pub fn new(values: Vec) -> Self { 67 | Self { values } 68 | } 69 | 70 | pub fn into_values(self) -> Vec { 71 | self.values 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /laplace_wasm/src/http.rs: -------------------------------------------------------------------------------- 1 | use std::io::{self, Read}; 2 | use std::iter::FromIterator; 3 | 4 | use borsh::io::Write; 5 | use borsh::{BorshDeserialize, BorshSerialize}; 6 | pub use http::header::{self, HeaderName}; 7 | pub use http::{self as types, HeaderMap, HeaderValue, Method, StatusCode, Uri, Version}; 8 | pub use laplace_wasm_macro::process_http as process; 9 | use thiserror::Error; 10 | 11 | pub use self::request::*; 12 | pub use self::response::*; 13 | use crate::WasmSlice; 14 | 15 | pub mod request; 16 | pub mod response; 17 | 18 | pub type Result = std::result::Result; 19 | pub type InvokeResult = std::result::Result; 20 | 21 | #[derive(Debug, Error, BorshDeserialize, BorshSerialize)] 22 | pub enum InvokeError { 23 | #[error("HTTP context is empty")] 24 | EmptyContext, 25 | 26 | #[error("Read from WASM error")] 27 | CanNotReadWasmData, 28 | 29 | #[error("HTTP request deserialization error")] 30 | FailDeserializeRequest, 31 | 32 | #[error("HTTP response building error: {0}")] 33 | FailBuildResponse(String), 34 | 35 | #[error("HTTP method \"{0}\" not allowed")] 36 | ForbiddenMethod(String), 37 | 38 | #[error("HTTP host \"{0}\" not allowed")] 39 | ForbiddenHost(String), 40 | 41 | #[error("HTTP request error: {code}, {1}", code = display_code(.0))] 42 | FailRequest(Option, String), 43 | } 44 | 45 | fn display_code(code: &Option) -> String { 46 | if let Some(code) = code { 47 | format!("{code}") 48 | } else { 49 | "None".to_string() 50 | } 51 | } 52 | 53 | #[derive(Debug, Error)] 54 | pub enum Error { 55 | #[error("HTTP request serialization error: {0:?}")] 56 | FailSerializeRequest(io::Error), 57 | 58 | #[error("HTTP request building error: {0}")] 59 | FailBuildRequest(String), 60 | 61 | #[error("HTTP response deserialization error: {0:?}")] 62 | FailDeserializeResponse(io::Error), 63 | 64 | #[error("HTTP response building error: {0}")] 65 | FailBuildResponse(String), 66 | 67 | #[error("HTTP invoke error: {0:?}")] 68 | FailInvoke(InvokeError), 69 | } 70 | 71 | extern "C" { 72 | fn invoke_http(request: WasmSlice) -> WasmSlice; 73 | } 74 | 75 | pub fn invoke(request: Request) -> Result { 76 | let request_bytes = borsh::to_vec(&request).map_err(Error::FailSerializeRequest)?; 77 | let response_bytes = unsafe { invoke_http(WasmSlice::from(request_bytes)).into_vec_in_wasm() }; 78 | let response: InvokeResult = 79 | BorshDeserialize::try_from_slice(&response_bytes).map_err(Error::FailDeserializeResponse)?; 80 | response.map_err(Error::FailInvoke) 81 | } 82 | 83 | fn serialize_version(version: Version, writer: &mut W) -> io::Result<()> { 84 | match version { 85 | Version::HTTP_09 => 9_u8, 86 | Version::HTTP_10 => 10, 87 | Version::HTTP_11 => 11, 88 | Version::HTTP_2 => 20, 89 | Version::HTTP_3 => 30, 90 | _ => return Err(io::Error::from(io::ErrorKind::Unsupported)), 91 | } 92 | .serialize(writer) 93 | } 94 | 95 | fn deserialize_version(reader: &mut R) -> io::Result { 96 | Ok(match u8::deserialize_reader(reader)? { 97 | 9 => Version::HTTP_09, 98 | 10 => Version::HTTP_10, 99 | 11 => Version::HTTP_11, 100 | 20 => Version::HTTP_2, 101 | 30 => Version::HTTP_3, 102 | _ => return Err(io::Error::from(io::ErrorKind::Unsupported)), 103 | }) 104 | } 105 | 106 | fn serialize_headers(headers: &HeaderMap, writer: &mut W) -> io::Result<()> { 107 | let headers: Vec<_> = headers 108 | .into_iter() 109 | .map(|(key, value)| (key.as_str().as_bytes(), value.as_bytes())) 110 | .collect(); 111 | headers.serialize(writer) 112 | } 113 | 114 | fn deserialize_headers(reader: &mut R) -> io::Result { 115 | let mut headers = Vec::new(); 116 | for (name, value) in Vec::<(Vec, Vec)>::deserialize_reader(reader)?.into_iter() { 117 | headers.push(( 118 | HeaderName::from_bytes(&name).map_err(|_| io::Error::from(io::ErrorKind::InvalidData))?, 119 | HeaderValue::from_bytes(&value).map_err(|_| io::Error::from(io::ErrorKind::InvalidData))?, 120 | )); 121 | } 122 | Ok(HeaderMap::from_iter(headers)) 123 | } 124 | -------------------------------------------------------------------------------- /laplace_wasm/src/http/request.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | use std::io::{self, Read}; 3 | use std::str::FromStr; 4 | 5 | use borsh::io::Write; 6 | use borsh::{BorshDeserialize, BorshSerialize}; 7 | use http; 8 | 9 | use super::{ 10 | deserialize_headers, deserialize_version, serialize_headers, serialize_version, HeaderMap, HeaderValue, Method, 11 | Uri, Version, 12 | }; 13 | 14 | pub type RequestBuilder = http::request::Builder; 15 | 16 | #[derive(Default)] 17 | pub struct Request { 18 | pub method: Method, 19 | pub uri: Uri, 20 | pub version: Version, 21 | pub headers: HeaderMap, 22 | pub body: Vec, 23 | } 24 | 25 | impl Request { 26 | #[inline] 27 | pub fn new(body: impl Into>) -> Self { 28 | Self { 29 | body: body.into(), 30 | ..Default::default() 31 | } 32 | } 33 | } 34 | 35 | impl From for http::Request> { 36 | fn from(request: Request) -> Self { 37 | let Request { 38 | method, 39 | uri, 40 | version, 41 | headers, 42 | body, 43 | } = request; 44 | let (mut parts, body) = http::Request::new(body).into_parts(); 45 | parts.method = method; 46 | parts.uri = uri; 47 | parts.version = version; 48 | parts.headers = headers; 49 | http::Request::from_parts(parts, body) 50 | } 51 | } 52 | 53 | impl From>> for Request { 54 | fn from(request: http::Request>) -> Self { 55 | let (parts, body) = request.into_parts(); 56 | Self { 57 | method: parts.method, 58 | uri: parts.uri, 59 | version: parts.version, 60 | headers: parts.headers, 61 | body, 62 | } 63 | } 64 | } 65 | 66 | impl BorshSerialize for Request { 67 | fn serialize(&self, writer: &mut W) -> io::Result<()> { 68 | let Self { 69 | method, 70 | uri, 71 | version, 72 | headers, 73 | body, 74 | } = self; 75 | method.as_str().serialize(writer)?; 76 | uri.to_string().serialize(writer)?; 77 | serialize_version(*version, writer)?; 78 | serialize_headers(headers, writer)?; 79 | body.serialize(writer) 80 | } 81 | } 82 | 83 | impl BorshDeserialize for Request { 84 | fn deserialize_reader(reader: &mut R) -> io::Result { 85 | let method = Method::from_str(&String::deserialize_reader(reader)?) 86 | .map_err(|_| io::Error::from(io::ErrorKind::InvalidData))?; 87 | let uri = Uri::from_str(&String::deserialize_reader(reader)?) 88 | .map_err(|_| io::Error::from(io::ErrorKind::InvalidData))?; 89 | let version = deserialize_version(reader)?; 90 | let headers = deserialize_headers(reader)?; 91 | let body = Vec::::deserialize_reader(reader)?; 92 | 93 | Ok(Self { 94 | method, 95 | uri, 96 | version, 97 | headers, 98 | body, 99 | }) 100 | } 101 | } 102 | 103 | impl fmt::Debug for Request { 104 | fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 105 | formatter 106 | .debug_struct("Request") 107 | .field("method", &self.method) 108 | .field("uri", &self.uri) 109 | .field("version", &self.version) 110 | .field("headers", &self.headers) 111 | .field("body", &self.body) 112 | .finish() 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /laplace_wasm/src/http/response.rs: -------------------------------------------------------------------------------- 1 | use std::io::Read; 2 | use std::{fmt, io}; 3 | 4 | use borsh::io::Write; 5 | use borsh::{BorshDeserialize, BorshSerialize}; 6 | use http; 7 | 8 | use super::{ 9 | deserialize_headers, deserialize_version, serialize_headers, serialize_version, HeaderMap, HeaderValue, StatusCode, 10 | Version, 11 | }; 12 | 13 | pub type ResponseBuilder = http::response::Builder; 14 | 15 | #[derive(Default)] 16 | pub struct Response { 17 | pub status: StatusCode, 18 | pub version: Version, 19 | pub headers: HeaderMap, 20 | pub body: Vec, 21 | } 22 | 23 | impl Response { 24 | #[inline] 25 | pub fn new(body: impl Into>) -> Self { 26 | Self { 27 | body: body.into(), 28 | ..Default::default() 29 | } 30 | } 31 | } 32 | 33 | impl From for http::Response> { 34 | fn from(response: Response) -> Self { 35 | let Response { 36 | status, 37 | version, 38 | headers, 39 | body, 40 | } = response; 41 | let (mut parts, body) = http::Response::new(body).into_parts(); 42 | parts.status = status; 43 | parts.version = version; 44 | parts.headers = headers; 45 | http::Response::from_parts(parts, body) 46 | } 47 | } 48 | 49 | impl From>> for Response { 50 | fn from(response: http::Response>) -> Self { 51 | let (parts, body) = response.into_parts(); 52 | Self { 53 | status: parts.status, 54 | version: parts.version, 55 | headers: parts.headers, 56 | body, 57 | } 58 | } 59 | } 60 | 61 | impl BorshSerialize for Response { 62 | fn serialize(&self, writer: &mut W) -> io::Result<()> { 63 | let Self { 64 | status, 65 | version, 66 | headers, 67 | body, 68 | } = self; 69 | status.as_u16().serialize(writer)?; 70 | serialize_version(*version, writer)?; 71 | serialize_headers(headers, writer)?; 72 | body.serialize(writer) 73 | } 74 | } 75 | 76 | impl BorshDeserialize for Response { 77 | fn deserialize_reader(reader: &mut R) -> io::Result { 78 | let status = StatusCode::from_u16(u16::deserialize_reader(reader)?) 79 | .map_err(|_| io::Error::from(io::ErrorKind::InvalidData))?; 80 | let version = deserialize_version(reader)?; 81 | let headers = deserialize_headers(reader)?; 82 | let body = Vec::::deserialize_reader(reader)?; 83 | 84 | Ok(Self { 85 | status, 86 | version, 87 | headers, 88 | body, 89 | }) 90 | } 91 | } 92 | 93 | impl fmt::Debug for Response { 94 | fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 95 | formatter 96 | .debug_struct("Response") 97 | .field("status", &self.status) 98 | .field("version", &self.version) 99 | .field("headers", &self.headers) 100 | .field("body", &self.body) 101 | .finish() 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /laplace_wasm/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub extern crate borsh; 2 | 3 | pub use self::route::Route; 4 | pub use self::slice::*; 5 | 6 | pub mod database; 7 | pub mod http; 8 | pub mod route; 9 | pub mod sleep; 10 | pub mod slice; 11 | 12 | #[no_mangle] 13 | pub unsafe fn alloc(size: u32) -> u32 { 14 | std::alloc::alloc(std::alloc::Layout::from_size_align_unchecked(size as usize, 1)) as u32 15 | } 16 | 17 | #[no_mangle] 18 | pub unsafe fn dealloc(ptr: u32, size: u32) { 19 | std::alloc::dealloc( 20 | ptr as *mut _, 21 | std::alloc::Layout::from_size_align_unchecked(size as usize, 1), 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /laplace_wasm/src/route.rs: -------------------------------------------------------------------------------- 1 | use borsh::{BorshDeserialize, BorshSerialize}; 2 | use derive_more::From; 3 | 4 | pub mod gossipsub; 5 | pub mod http; 6 | pub mod websocket; 7 | 8 | #[derive(Debug, From, BorshSerialize, BorshDeserialize)] 9 | pub enum Route { 10 | Http(http::Message), 11 | WebSocket(websocket::MessageOut), 12 | Gossipsub(gossipsub::MessageOut), 13 | } 14 | -------------------------------------------------------------------------------- /laplace_wasm/src/route/gossipsub.rs: -------------------------------------------------------------------------------- 1 | use borsh::{BorshDeserialize, BorshSerialize}; 2 | 3 | #[derive(Debug, BorshSerialize, BorshDeserialize)] 4 | pub enum MessageIn { 5 | Text { peer_id: String, msg: String }, 6 | Response { id: String, result: Result<(), Error> }, 7 | } 8 | 9 | #[derive(Debug, BorshSerialize, BorshDeserialize)] 10 | pub struct MessageOut { 11 | pub id: String, 12 | pub msg: Message, 13 | } 14 | 15 | #[derive(Debug, BorshSerialize, BorshDeserialize)] 16 | pub enum Message { 17 | Dial(String), 18 | AddAddress(String), 19 | Text { peer_id: String, msg: String }, 20 | Close, 21 | } 22 | 23 | #[derive(Debug, BorshSerialize, BorshDeserialize)] 24 | pub struct Error { 25 | pub message: String, 26 | pub kind: ErrorKind, 27 | } 28 | 29 | #[derive(Debug, BorshSerialize, BorshDeserialize)] 30 | pub enum ErrorKind { 31 | GossipsubPublishError, 32 | ParsePeerIdError, 33 | DialError, 34 | WrongMultiaddr, 35 | Other, 36 | } 37 | -------------------------------------------------------------------------------- /laplace_wasm/src/route/http.rs: -------------------------------------------------------------------------------- 1 | use borsh::{BorshDeserialize, BorshSerialize}; 2 | 3 | #[derive(Debug, BorshSerialize, BorshDeserialize)] 4 | pub struct Message { 5 | pub body: String, 6 | } 7 | 8 | impl Message { 9 | pub fn new(body: impl Into) -> Self { 10 | Self { body: body.into() } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /laplace_wasm/src/route/websocket.rs: -------------------------------------------------------------------------------- 1 | use borsh::{BorshDeserialize, BorshSerialize}; 2 | use derive_more::From; 3 | 4 | #[derive(Debug, BorshSerialize, BorshDeserialize, From)] 5 | pub enum MessageIn { 6 | #[from] 7 | Message(Message), 8 | Response { 9 | id: String, 10 | result: Result<(), String>, 11 | }, 12 | Timeout, 13 | Error(String), 14 | } 15 | 16 | #[derive(Debug, BorshSerialize, BorshDeserialize)] 17 | pub struct MessageOut { 18 | pub id: String, 19 | pub msg: Message, 20 | } 21 | 22 | #[derive(Debug, BorshSerialize, BorshDeserialize)] 23 | pub enum Message { 24 | Text(String), 25 | Binary(Vec), 26 | Close, 27 | } 28 | 29 | impl Message { 30 | pub fn new_text(msg: impl Into) -> Self { 31 | Self::Text(msg.into()) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /laplace_wasm/src/sleep.rs: -------------------------------------------------------------------------------- 1 | extern "C" { 2 | fn invoke_sleep(millis: u64); 3 | } 4 | 5 | pub fn invoke(millis: u64) { 6 | unsafe { invoke_sleep(millis) } 7 | } 8 | -------------------------------------------------------------------------------- /laplace_wasm/src/slice.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, Clone, Copy)] 2 | #[repr(transparent)] 3 | pub struct WasmSlice(u64); 4 | 5 | impl WasmSlice { 6 | pub fn ptr(&self) -> u32 { 7 | (self.0 >> 32) as _ 8 | } 9 | 10 | pub fn len(&self) -> u32 { 11 | (self.0 & 0x00000000ffffffff) as _ 12 | } 13 | 14 | pub fn is_empty(&self) -> bool { 15 | self.len() == 0 16 | } 17 | 18 | pub unsafe fn into_string_in_wasm(self) -> String { 19 | String::from_raw_parts(self.ptr() as *mut _, self.len() as usize, self.len() as usize) 20 | } 21 | 22 | pub unsafe fn into_vec_in_wasm(self) -> Vec { 23 | Vec::from_raw_parts(self.ptr() as *mut _, self.len() as usize, self.len() as usize) 24 | } 25 | } 26 | 27 | impl From for u64 { 28 | fn from(slice: WasmSlice) -> Self { 29 | slice.0 30 | } 31 | } 32 | 33 | impl From for WasmSlice { 34 | fn from(value: u64) -> Self { 35 | Self(value) 36 | } 37 | } 38 | 39 | impl From<(u32, u32)> for WasmSlice { 40 | fn from((ptr, len): (u32, u32)) -> Self { 41 | Self(((ptr as u64) << 32) | len as u64) 42 | } 43 | } 44 | 45 | impl From<&[T]> for WasmSlice { 46 | fn from(slice: &[T]) -> Self { 47 | Self::from((slice.as_ptr() as u32, slice.len() as u32)) 48 | } 49 | } 50 | 51 | impl From<&str> for WasmSlice { 52 | fn from(string: &str) -> Self { 53 | let ptr = string.as_ptr() as u32; 54 | let len = string.len() as u32; 55 | Self::from((ptr, len)) 56 | } 57 | } 58 | 59 | impl From for WasmSlice { 60 | fn from(string: String) -> Self { 61 | let len = string.len() as u32; 62 | let ptr = Box::into_raw(string.into_boxed_str()) as *const u8 as u32; 63 | Self::from((ptr, len)) 64 | } 65 | } 66 | 67 | impl From> for WasmSlice { 68 | fn from(bytes: Vec) -> Self { 69 | let len = bytes.len() as u32; 70 | let ptr = Box::into_raw(bytes.into_boxed_slice()) as *const u8 as u32; 71 | Self::from((ptr, len)) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /laplace_wasm_macro/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "laplace_wasm_macro" 3 | version = "0.1.0" 4 | authors = [ 5 | "Alexander Mescheryakov ", 6 | "Noogen Team ", 7 | ] 8 | edition = "2021" 9 | license = "MIT" 10 | readme = "README.md" 11 | repository = "https://github.com/noogen-projects/laplace" 12 | description = "The WASM macros of the local-firs web-application platform" 13 | 14 | [lib] 15 | proc-macro = true 16 | 17 | [dependencies] 18 | proc-macro2 = "1.0" 19 | quote = "1.0" 20 | syn = { version = "2.0", features = ["full", "extra-traits"] } 21 | -------------------------------------------------------------------------------- /laplace_wasm_macro/src/lib.rs: -------------------------------------------------------------------------------- 1 | use proc_macro::TokenStream; 2 | 3 | mod process; 4 | 5 | #[proc_macro_attribute] 6 | pub fn process_http(attrs: TokenStream, input: TokenStream) -> TokenStream { 7 | process::http(attrs, input) 8 | } 9 | -------------------------------------------------------------------------------- /laplace_wasm_macro/src/process.rs: -------------------------------------------------------------------------------- 1 | use proc_macro::TokenStream; 2 | use quote::quote; 3 | use syn::{parse_macro_input, ItemFn}; 4 | 5 | pub fn http(attrs: TokenStream, input: TokenStream) -> TokenStream { 6 | let function = parse_macro_input!(input as ItemFn); 7 | let function_name = function.sig.ident.clone(); 8 | let attrs = proc_macro2::TokenStream::from(attrs); 9 | 10 | let expanded = quote! { 11 | #[no_mangle] 12 | pub unsafe extern "C" fn process_http(request: ::laplace_wasm::WasmSlice) -> ::laplace_wasm::WasmSlice { 13 | use ::laplace_wasm::borsh::{BorshDeserialize, to_vec}; 14 | use ::laplace_wasm::http; 15 | 16 | let mut request = request.into_vec_in_wasm(); 17 | let request: http::Request = BorshDeserialize::deserialize(&mut request.as_slice()) 18 | .expect("HTTP request should be deserializable"); 19 | let response: http::Response = #function_name(request); 20 | ::laplace_wasm::WasmSlice::from( 21 | to_vec(&response).expect("HTTP response should be serializable") 22 | ) 23 | } 24 | 25 | #attrs 26 | #function 27 | }; 28 | 29 | TokenStream::from(expanded) 30 | } 31 | -------------------------------------------------------------------------------- /laplace_yew/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "laplace_yew" 3 | version = "0.1.0" 4 | authors = [ 5 | "Alexander Mescheryakov ", 6 | "Noogen Team ", 7 | ] 8 | edition = "2021" 9 | license = "MIT" 10 | readme = "README.md" 11 | repository = "https://github.com/noogen-projects/laplace" 12 | description = "The WASM client yew helpers of the local-firs web-application platform" 13 | 14 | [features] 15 | mdc = ["dep:yew-mdc-widgets"] 16 | default = ["mdc"] 17 | 18 | [dependencies] 19 | anyhow = "1.0" 20 | wasm-dom = "1.0" 21 | web-sys = { version = "0.3", features = ["Window", "Document"] } 22 | yew = { workspace = true } 23 | yew-mdc-widgets = { workspace = true, optional = true } 24 | -------------------------------------------------------------------------------- /laplace_yew/src/error.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Error, Result}; 2 | use yew::html::Scope; 3 | use yew::Component; 4 | 5 | #[cfg(feature = "mdc")] 6 | pub use self::mdc::*; 7 | 8 | #[cfg(feature = "mdc")] 9 | pub mod mdc; 10 | 11 | pub trait MsgError { 12 | type Map; 13 | 14 | fn msg_error(self, link: &Scope) 15 | where 16 | Comp: Component, 17 | Comp::Message: From; 18 | 19 | fn msg_error_map(self, link: &Scope) -> Self::Map 20 | where 21 | Comp: Component, 22 | Comp::Message: From; 23 | } 24 | 25 | impl MsgError for Result { 26 | type Map = std::result::Result; 27 | 28 | fn msg_error(self, link: &Scope) 29 | where 30 | Comp: Component, 31 | Comp::Message: From, 32 | { 33 | if let Err(err) = self { 34 | link.send_message(Comp::Message::from(err)) 35 | } 36 | } 37 | 38 | fn msg_error_map(self, link: &Scope) -> Self::Map 39 | where 40 | Comp: Component, 41 | ::Message: From, 42 | { 43 | self.map_err(|err| link.send_message(Comp::Message::from(err))) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /laplace_yew/src/error/mdc.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::marker::PhantomData; 3 | 4 | use yew::html::Scope; 5 | use yew::{html, Component, Context, Html, Properties}; 6 | use yew_mdc_widgets::{IconButton, MdcWidget, Snackbar}; 7 | 8 | pub const DEFAULT_ERRORS_ID: &str = "errors-snackbar"; 9 | 10 | #[derive(Debug)] 11 | pub struct Errors { 12 | id: String, 13 | errors: HashMap, 14 | timeout_ms: i32, 15 | _phantom: PhantomData, 16 | } 17 | 18 | pub enum ErrorsMsg { 19 | Open, 20 | Close, 21 | Add(String), 22 | Spawn(String), 23 | } 24 | 25 | #[derive(Properties, PartialEq)] 26 | pub struct ErrorsProps { 27 | #[prop_or(String::from(DEFAULT_ERRORS_ID))] 28 | pub id: String, 29 | 30 | #[prop_or(-1)] 31 | pub timeout_ms: i32, 32 | 33 | #[prop_or_default] 34 | pub errors: HashMap, 35 | } 36 | 37 | impl Component for Errors 38 | where 39 | ParentT: Component, 40 | ParentT::Message: From>, 41 | { 42 | type Message = ErrorsMsg; 43 | type Properties = ErrorsProps; 44 | 45 | fn create(ctx: &Context) -> Self { 46 | if let Some(parent_link) = ctx.link().get_parent() { 47 | parent_link.downcast::().send_message(ctx.link().clone()); 48 | } 49 | 50 | Self { 51 | id: ctx.props().id.clone(), 52 | timeout_ms: ctx.props().timeout_ms, 53 | errors: ctx.props().errors.clone(), 54 | _phantom: PhantomData, 55 | } 56 | } 57 | 58 | fn update(&mut self, _ctx: &yew::Context, msg: Self::Message) -> bool { 59 | match msg { 60 | ErrorsMsg::Open => self.open(), 61 | ErrorsMsg::Close => self.close(), 62 | ErrorsMsg::Add(error) => self.add(error), 63 | ErrorsMsg::Spawn(error) => { 64 | self.add(error); 65 | self.open(); 66 | }, 67 | } 68 | true 69 | } 70 | 71 | fn view(&self, ctx: &Context) -> Html { 72 | let messages = self 73 | .errors 74 | .iter() 75 | .map(|(error, &count)| { 76 | let message = if count > 1 { 77 | format!("({count}) {error}") 78 | } else { 79 | error.clone() 80 | }; 81 | html! {
{ message }
} 82 | }) 83 | .collect::(); 84 | 85 | Snackbar::new() 86 | .id(&self.id) 87 | .label(messages) 88 | .dismiss( 89 | IconButton::new() 90 | .item(html! { 91 | 92 | 93 | 94 | }) 95 | .on_click(ctx.link().callback(move |_| ErrorsMsg::Close)), 96 | ) 97 | .into() 98 | } 99 | } 100 | 101 | impl Errors { 102 | fn open(&self) { 103 | Snackbar::set_timeout_ms(&self.id, self.timeout_ms); 104 | Snackbar::open_existing(&self.id); 105 | } 106 | 107 | fn close(&mut self) { 108 | self.errors.clear(); 109 | Snackbar::close_existing(&self.id); 110 | } 111 | 112 | fn add(&mut self, error: impl Into) { 113 | *self.errors.entry(error.into()).or_default() += 1; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /laplace_yew/src/html.rs: -------------------------------------------------------------------------------- 1 | use wasm_dom::UnwrapThrowExt; 2 | use web_sys::Node; 3 | use yew::virtual_dom::VNode; 4 | use yew::{Component, Context, Html, Properties}; 5 | 6 | #[derive(Debug, Clone, Eq, PartialEq, Properties)] 7 | pub struct RawHtmlProps { 8 | pub inner_html: String, 9 | 10 | #[prop_or_default] 11 | pub styles: Option, 12 | } 13 | 14 | pub struct RawHtml { 15 | props: RawHtmlProps, 16 | } 17 | 18 | impl Component for RawHtml { 19 | type Message = (); 20 | type Properties = RawHtmlProps; 21 | 22 | fn create(ctx: &Context) -> Self { 23 | Self { 24 | props: ctx.props().clone(), 25 | } 26 | } 27 | 28 | fn changed(&mut self, ctx: &Context, _old_props: &Self::Properties) -> bool { 29 | if self.props != *ctx.props() { 30 | self.props = ctx.props().clone(); 31 | true 32 | } else { 33 | false 34 | } 35 | } 36 | 37 | fn view(&self, _ctx: &Context) -> Html { 38 | let div = wasm_dom::existing::document() 39 | .create_element("div") 40 | .expect_throw("Div should be created"); 41 | div.set_inner_html(self.props.inner_html.as_str()); 42 | if let Some(styles) = self.props.styles.as_deref() { 43 | div.set_attribute("style", styles) 44 | .expect_throw("Attribute style should be set"); 45 | } 46 | 47 | VNode::VRef(Node::from(div)) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /laplace_yew/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub use self::error::*; 2 | pub use self::html::*; 3 | 4 | pub mod error; 5 | pub mod html; 6 | -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/favicon.ico -------------------------------------------------------------------------------- /static/favicon_io/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/favicon_io/android-chrome-192x192.png -------------------------------------------------------------------------------- /static/favicon_io/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/favicon_io/android-chrome-512x512.png -------------------------------------------------------------------------------- /static/favicon_io/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/favicon_io/apple-touch-icon.png -------------------------------------------------------------------------------- /static/favicon_io/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/favicon_io/favicon-16x16.png -------------------------------------------------------------------------------- /static/favicon_io/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/favicon_io/favicon-32x32.png -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Laplace 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 28 | 29 | -------------------------------------------------------------------------------- /static/laplace.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"Lapps","short_name":"Lp","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} 2 | -------------------------------------------------------------------------------- /static/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif; 3 | margin: 0; 4 | } 5 | 6 | #root { 7 | width: 100%; 8 | overflow: auto; 9 | display: flex; 10 | margin-bottom: 32px; 11 | } 12 | 13 | .mdc-drawer { 14 | position: fixed; 15 | } 16 | 17 | .app-content { 18 | flex: auto; 19 | overflow: auto; 20 | position: relative; 21 | } 22 | 23 | .content-container { 24 | width: 100%; 25 | max-width: 1200px; 26 | margin: auto; 27 | } 28 | 29 | .title { 30 | margin-top: 32px; 31 | border-bottom: 1px solid rgba(0,0,0,.87); 32 | } 33 | 34 | .lapps-table { 35 | display: table; 36 | width: auto; 37 | } 38 | 39 | .lapps-table-row { 40 | display: table-row; 41 | width: auto; 42 | height: auto; 43 | clear: both; 44 | margin-top: 10px; 45 | margin-bottom: 10px; 46 | } 47 | 48 | .lapps-table-col { 49 | float: left; /* fix for buggy browsers */ 50 | display: table-column; 51 | vertical-align: middle; 52 | line-height: 30px; 53 | padding: 5px; 54 | margin-left: 10px; 55 | margin-right: 10px; 56 | } 57 | 58 | .list-item { 59 | display: inline-block; 60 | margin: 8px 16px; 61 | } 62 | 63 | .list, .bordered-list { 64 | display: inline-block; 65 | } 66 | 67 | .list > ul, .bordered-list > ul, .group-list { 68 | border: 1px solid rgba(0, 0, 0, .1); 69 | } 70 | 71 | .list .mdc-list-item__meta { 72 | margin-left: 16px; 73 | } 74 | 75 | .mdc-list--avatar-list .mdc-list-item__graphic { 76 | background-color: rgba(0, 0, 0, .3); 77 | color: #fff; 78 | } 79 | 80 | .rounded-button, .rounded-button .mdc-button__ripple { 81 | border-radius: 18px; 82 | } 83 | 84 | .card { 85 | width: 350px; 86 | } 87 | 88 | .card__primary { 89 | padding: 1rem; 90 | } 91 | 92 | .card__secondary { 93 | padding: 1rem 1rem 8px; 94 | color: rgba(0, 0, 0, .54); 95 | } 96 | 97 | .regular .card__secondary { 98 | padding-top: 0; 99 | } 100 | 101 | .card__title, .card__subtitle { 102 | margin: 0; 103 | } 104 | 105 | .card__secondary, .card__subtitle { 106 | color: rgba(0, 0, 0, .54); 107 | } 108 | 109 | .over-media .mdc-card__media-content { 110 | display: flex; 111 | align-items: flex-end; 112 | } 113 | 114 | .over-media .card__title, #card-over-media .card__subtitle { 115 | color: #fff; 116 | } 117 | 118 | .primary-action-horizontal { 119 | display: flex; 120 | flex-direction: row; 121 | } 122 | 123 | .primary-action-horizontal .mdc-card__media--square { 124 | width: 110px; 125 | } -------------------------------------------------------------------------------- /static/mdc/fonts/materialicons.css: -------------------------------------------------------------------------------- 1 | /* https://material.io/resources/icons */ 2 | /* https://google.github.io/material-design-icons/ */ 3 | /* https://fonts.googleapis.com/icon?family=Material+Icons */ 4 | 5 | /* fallback */ 6 | @font-face { 7 | font-family: 'Material Icons'; 8 | font-style: normal; 9 | font-weight: 400; 10 | src: url(materialicons/v139/flUhRq6tzZclQEJ-Vdg-IuiaDsNc.woff2) format('woff2'); 11 | } 12 | 13 | .material-icons { 14 | font-family: 'Material Icons'; 15 | font-weight: normal; 16 | font-style: normal; 17 | font-size: 24px; 18 | line-height: 1; 19 | letter-spacing: normal; 20 | text-transform: none; 21 | display: inline-block; 22 | white-space: nowrap; 23 | word-wrap: normal; 24 | direction: ltr; 25 | -moz-font-feature-settings: 'liga'; 26 | -moz-osx-font-smoothing: grayscale; 27 | } 28 | -------------------------------------------------------------------------------- /static/mdc/fonts/materialicons/v139/flUhRq6tzZclQEJ-Vdg-IuiaDsNc.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/mdc/fonts/materialicons/v139/flUhRq6tzZclQEJ-Vdg-IuiaDsNc.woff2 -------------------------------------------------------------------------------- /static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmEU9fABc4EsA.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmEU9fABc4EsA.woff2 -------------------------------------------------------------------------------- /static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmEU9fBBc4.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmEU9fBBc4.woff2 -------------------------------------------------------------------------------- /static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmEU9fBxc4EsA.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmEU9fBxc4EsA.woff2 -------------------------------------------------------------------------------- /static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmEU9fCBc4EsA.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmEU9fCBc4EsA.woff2 -------------------------------------------------------------------------------- /static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmEU9fCRc4EsA.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmEU9fCRc4EsA.woff2 -------------------------------------------------------------------------------- /static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmEU9fChc4EsA.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmEU9fChc4EsA.woff2 -------------------------------------------------------------------------------- /static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmEU9fCxc4EsA.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmEU9fCxc4EsA.woff2 -------------------------------------------------------------------------------- /static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmSU5fABc4EsA.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmSU5fABc4EsA.woff2 -------------------------------------------------------------------------------- /static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmSU5fBBc4.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmSU5fBBc4.woff2 -------------------------------------------------------------------------------- /static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmSU5fBxc4EsA.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmSU5fBxc4EsA.woff2 -------------------------------------------------------------------------------- /static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmSU5fCBc4EsA.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmSU5fCBc4EsA.woff2 -------------------------------------------------------------------------------- /static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmSU5fCRc4EsA.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmSU5fCRc4EsA.woff2 -------------------------------------------------------------------------------- /static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmSU5fChc4EsA.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmSU5fChc4EsA.woff2 -------------------------------------------------------------------------------- /static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmSU5fCxc4EsA.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmSU5fCxc4EsA.woff2 -------------------------------------------------------------------------------- /static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmWUlfABc4EsA.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmWUlfABc4EsA.woff2 -------------------------------------------------------------------------------- /static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmWUlfBBc4.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmWUlfBBc4.woff2 -------------------------------------------------------------------------------- /static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmWUlfBxc4EsA.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmWUlfBxc4EsA.woff2 -------------------------------------------------------------------------------- /static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmWUlfCBc4EsA.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmWUlfCBc4EsA.woff2 -------------------------------------------------------------------------------- /static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmWUlfCRc4EsA.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmWUlfCRc4EsA.woff2 -------------------------------------------------------------------------------- /static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmWUlfChc4EsA.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmWUlfChc4EsA.woff2 -------------------------------------------------------------------------------- /static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmWUlfCxc4EsA.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/mdc/fonts/roboto/v30/KFOlCnqEu92Fr1MmWUlfCxc4EsA.woff2 -------------------------------------------------------------------------------- /static/mdc/fonts/roboto/v30/KFOmCnqEu92Fr1Mu4WxKOzY.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/mdc/fonts/roboto/v30/KFOmCnqEu92Fr1Mu4WxKOzY.woff2 -------------------------------------------------------------------------------- /static/mdc/fonts/roboto/v30/KFOmCnqEu92Fr1Mu4mxK.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/mdc/fonts/roboto/v30/KFOmCnqEu92Fr1Mu4mxK.woff2 -------------------------------------------------------------------------------- /static/mdc/fonts/roboto/v30/KFOmCnqEu92Fr1Mu5mxKOzY.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/mdc/fonts/roboto/v30/KFOmCnqEu92Fr1Mu5mxKOzY.woff2 -------------------------------------------------------------------------------- /static/mdc/fonts/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/mdc/fonts/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2 -------------------------------------------------------------------------------- /static/mdc/fonts/roboto/v30/KFOmCnqEu92Fr1Mu7GxKOzY.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/mdc/fonts/roboto/v30/KFOmCnqEu92Fr1Mu7GxKOzY.woff2 -------------------------------------------------------------------------------- /static/mdc/fonts/roboto/v30/KFOmCnqEu92Fr1Mu7WxKOzY.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/mdc/fonts/roboto/v30/KFOmCnqEu92Fr1Mu7WxKOzY.woff2 -------------------------------------------------------------------------------- /static/mdc/fonts/roboto/v30/KFOmCnqEu92Fr1Mu7mxKOzY.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noogen-projects/laplace/d22ef42676888effd351af6d3405ff48fffd1cce/static/mdc/fonts/roboto/v30/KFOmCnqEu92Fr1Mu7mxKOzY.woff2 -------------------------------------------------------------------------------- /static/mdc/v14.0.0/material-components-manual-fix.js: -------------------------------------------------------------------------------- 1 | // FIXME MANUAL HACK material-components/material-components-web#7618 2 | mdc.list.MDCList.prototype.handleClickEvent = function (evt) { 3 | var index = this.getListItemIndex(evt.target); 4 | var target = evt.target; 5 | // Toggle the checkbox only if it's not the target of the event, or the checkbox will have 2 change events. 6 | var isCheckboxAlreadyUpdatedInAdapter = mdc.dom.ponyfill.matches(target, mdc.list.strings.CHECKBOX_RADIO_SELECTOR); 7 | this.foundation.handleClick(index, isCheckboxAlreadyUpdatedInAdapter, evt); 8 | }; 9 | -------------------------------------------------------------------------------- /tests/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tests" 3 | version = "0.1.0" 4 | authors = [ 5 | "Alexander Mescheryakov ", 6 | "Noogen Team ", 7 | ] 8 | edition = "2021" 9 | license = "MIT" 10 | repository = "https://github.com/noogen-projects/laplace" 11 | description = "The local-first web-application platform test suite" 12 | 13 | [dependencies] 14 | env_logger = "0.11" 15 | function_name = "0.3" 16 | itertools = "0.12" 17 | lazy_static = "1.4" 18 | log = "0.4" 19 | reqwest = { version = "0.12", features = ["rustls-tls-manual-roots"] } 20 | strum = { workspace = true } 21 | subprocess = "0.2" 22 | tokio = { workspace = true } 23 | -------------------------------------------------------------------------------- /tests/config/config.toml: -------------------------------------------------------------------------------- 1 | [http] 2 | host = "127.0.0.1" 3 | port = 8080 4 | access_token = "24tpHRcbGKGYFGMYq66G3hfH8GQEYGTysXqiJyaCy9eR" 5 | upload_file_limit = 2147483648 # 2 GB 6 | print_url = true 7 | web_root = ".." 8 | 9 | [ssl] 10 | enabled = true 11 | private_key_path = "../cert/key.pem" 12 | certificate_path = "../cert/cert.pem" 13 | 14 | [p2p] 15 | mdns_discovery_enabled = true 16 | 17 | [log] 18 | spec = "info,hyper=info,rustls=info,regalloc=warn,wasmer_compiler_cranelift=warn,cranelift_codegen=warn,h2=info,netlink_proto=info" 19 | 20 | [lapps] 21 | path = "../lapps" 22 | allowed = ["echo"] 23 | -------------------------------------------------------------------------------- /tests/src/laplace_client.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | use std::time::{Duration, Instant}; 3 | 4 | use reqwest::{Client, Response}; 5 | use strum::Display; 6 | use tokio::time; 7 | 8 | #[derive(Debug, Display, Clone, Copy)] 9 | #[strum(serialize_all = "snake_case")] 10 | pub enum Scheme { 11 | Http, 12 | Https, 13 | } 14 | 15 | #[derive(Debug)] 16 | pub struct LaplaceClientBuilder { 17 | request_timeout: Option, 18 | scheme: Scheme, 19 | host: String, 20 | port: u16, 21 | } 22 | 23 | impl Default for LaplaceClientBuilder { 24 | fn default() -> Self { 25 | Self { 26 | request_timeout: None, 27 | scheme: Scheme::Http, 28 | host: "127.0.0.1".to_string(), 29 | port: 80, 30 | } 31 | } 32 | } 33 | 34 | impl LaplaceClientBuilder { 35 | pub fn request_timeout(mut self, timeout: Duration) -> Self { 36 | self.request_timeout = Some(timeout); 37 | self 38 | } 39 | 40 | pub fn scheme(mut self, scheme: Scheme) -> Self { 41 | self.scheme = scheme; 42 | self 43 | } 44 | 45 | pub fn host(mut self, host: impl Into) -> Self { 46 | self.host = host.into(); 47 | self 48 | } 49 | 50 | pub fn port(mut self, port: u16) -> Self { 51 | self.port = port; 52 | self 53 | } 54 | 55 | pub fn build(self) -> reqwest::Result { 56 | let mut builder = Client::builder().danger_accept_invalid_certs(true); 57 | if let Some(timeout) = self.request_timeout { 58 | builder = builder.timeout(timeout); 59 | } 60 | let client = builder.build()?; 61 | 62 | Ok(LaplaceClient { client, param: self }) 63 | } 64 | } 65 | 66 | pub struct LaplaceClient { 67 | client: Client, 68 | param: LaplaceClientBuilder, 69 | } 70 | 71 | impl LaplaceClient { 72 | pub fn builder() -> LaplaceClientBuilder { 73 | LaplaceClientBuilder::default() 74 | } 75 | 76 | pub fn http(host: impl Into, port: u16) -> LaplaceClientBuilder { 77 | Self::builder() 78 | .request_timeout(Duration::from_secs(10)) 79 | .scheme(Scheme::Http) 80 | .host(host) 81 | .port(port) 82 | } 83 | 84 | pub fn https(host: impl Into, port: u16) -> LaplaceClientBuilder { 85 | Self::builder() 86 | .request_timeout(Duration::from_secs(10)) 87 | .scheme(Scheme::Https) 88 | .host(host) 89 | .port(port) 90 | } 91 | 92 | pub fn url(&self, path: impl Display) -> String { 93 | format!("{}://{}:{}/{path}", self.param.scheme, self.param.host, self.param.port) 94 | } 95 | 96 | pub async fn wait_to_ready(&self, timeout: Duration) -> reqwest::Result<()> { 97 | let instant = Instant::now(); 98 | while let Err(err) = self.get_index().await { 99 | if !err.is_connect() || instant.elapsed() >= timeout { 100 | return Err(err); 101 | } 102 | time::sleep(timeout / 1000).await; 103 | } 104 | Ok(()) 105 | } 106 | 107 | pub async fn get_index(&self) -> reqwest::Result { 108 | self.client.get(self.url("")).send().await 109 | } 110 | 111 | pub async fn get_laplace(&self) -> reqwest::Result { 112 | self.client.get(self.url("laplace")).send().await 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /tests/src/laplace_service.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{HashMap, HashSet}; 2 | use std::ffi::OsString; 3 | use std::io::BufRead; 4 | use std::time::Duration; 5 | use std::{fs, io, thread}; 6 | 7 | use itertools::Itertools; 8 | use log::{debug, error}; 9 | use subprocess::{make_pipe, Exec, Popen, Redirection, Result as PopenResult}; 10 | 11 | use crate::port::next_free_local_port; 12 | use crate::{target_build_dir, LaplaceClient}; 13 | 14 | pub mod env { 15 | pub const SSL_ENABLED: &str = "LAPLACE__SSL__ENABLED"; 16 | pub const HTTP_HOST: &str = "LAPLACE__HTTP__HOST"; 17 | pub const HTTP_PORT: &str = "LAPLACE__HTTP__PORT"; 18 | pub const LAPPS_ALLOWED: &str = "LAPLACE__LAPPS__ALLOWED"; 19 | } 20 | 21 | pub struct LaplaceService { 22 | test_name: String, 23 | subprocess: Option, 24 | envs: HashMap, 25 | args: Vec, 26 | http_host: String, 27 | http_port: u16, 28 | allowed_lapps: Option>, 29 | } 30 | 31 | impl LaplaceService { 32 | pub fn new(test_name: impl Into) -> Self { 33 | Self { 34 | test_name: test_name.into(), 35 | subprocess: None, 36 | envs: HashMap::new(), 37 | args: Vec::new(), 38 | http_host: "127.0.0.1".to_string(), 39 | http_port: next_free_local_port(), 40 | allowed_lapps: None, 41 | } 42 | } 43 | 44 | pub fn with_arg(mut self, arg: impl ToString) -> Self { 45 | self.args.push(arg.to_string()); 46 | self 47 | } 48 | 49 | pub fn with_var(mut self, key: &str, val: impl Into) -> Self { 50 | self.add_var(key, val); 51 | self 52 | } 53 | 54 | pub fn with_vars(mut self, env: &[(&str, &str)]) -> Self { 55 | self.add_vars(env); 56 | self 57 | } 58 | 59 | pub fn with_host(mut self, host: impl ToString) -> Self { 60 | self.http_host = host.to_string(); 61 | self 62 | } 63 | 64 | pub fn with_port(mut self, port: u16) -> Self { 65 | self.http_port = port; 66 | self 67 | } 68 | 69 | pub fn with_allowed_lapp(mut self, lapp_name: impl Into) -> Self { 70 | if let Some(lapps) = &mut self.allowed_lapps { 71 | lapps.insert(lapp_name.into()); 72 | } else { 73 | self.allowed_lapps = Some(HashSet::from([lapp_name.into()])); 74 | } 75 | self 76 | } 77 | 78 | pub fn add_var(&mut self, key: &str, val: impl Into) { 79 | self.envs.insert(key.into(), val.into()); 80 | } 81 | 82 | pub fn add_vars(&mut self, env: &[(&str, &str)]) { 83 | for (key, val) in env { 84 | self.envs.insert((*key).into(), val.into()); 85 | } 86 | } 87 | 88 | fn run_exec(&mut self) -> PopenResult { 89 | let working_dir = std::env::current_dir().expect("Cannot get working dir"); 90 | let bin_path = target_build_dir().join("laplace_server"); 91 | 92 | debug!("Starting process {:?}", bin_path); 93 | 94 | self.add_var(env::HTTP_HOST, self.http_host.clone()); 95 | self.add_var(env::HTTP_PORT, self.http_port.to_string()); 96 | 97 | if let Some(lapps) = &self.allowed_lapps { 98 | let env_lapps_var = std::env::var(env::LAPPS_ALLOWED).unwrap_or_default(); 99 | let env_lapps = env_lapps_var.split(','); 100 | self.add_var( 101 | env::LAPPS_ALLOWED, 102 | lapps.iter().map(AsRef::as_ref).chain(env_lapps).join(","), 103 | ); 104 | } 105 | 106 | let config_path = working_dir.join("config").join("config.toml"); 107 | let envs: Vec<_> = self.envs.iter().collect(); 108 | let (pipe_read, pipe_write) = make_pipe()?; 109 | 110 | let subprocess = Exec::cmd(bin_path) 111 | .env_extend(&envs) 112 | .arg("--config") 113 | .arg(config_path) 114 | .args(self.args.as_slice()) 115 | .stdout(Redirection::Pipe) 116 | .stderr(Redirection::File(pipe_write)) 117 | .detached() 118 | .popen()?; 119 | 120 | let pid = subprocess.pid().expect("PID must be present"); 121 | debug!("Started process PID {pid} for test {}", self.test_name); 122 | 123 | self.subprocess = Some(subprocess); 124 | Ok(pipe_read) 125 | } 126 | 127 | pub fn start(mut self) -> Self { 128 | let stdout = self.run_exec().expect("Fail to run service"); 129 | 130 | thread::spawn(move || { 131 | let reader = io::BufReader::new(stdout); 132 | 133 | for line in reader.lines() { 134 | let line = line.unwrap_or_else(|err| err.to_string()); 135 | println!("{line}"); 136 | } 137 | }); 138 | 139 | self 140 | } 141 | 142 | pub async fn http_client(&self) -> LaplaceClient { 143 | let client = LaplaceClient::http(&self.http_host, self.http_port) 144 | .build() 145 | .expect("Cannot build laplace client"); 146 | client 147 | .wait_to_ready(Duration::from_secs(60)) 148 | .await 149 | .expect("Connection error"); 150 | client 151 | } 152 | 153 | pub async fn https_client(&self) -> LaplaceClient { 154 | let client = LaplaceClient::https(&self.http_host, self.http_port) 155 | .build() 156 | .expect("Cannot build laplace client"); 157 | client 158 | .wait_to_ready(Duration::from_secs(60)) 159 | .await 160 | .expect("Connection error"); 161 | client 162 | } 163 | } 164 | 165 | impl Drop for LaplaceService { 166 | fn drop(&mut self) { 167 | if let Some(ref mut subprocess) = self.subprocess { 168 | if let Some(pid) = subprocess.pid() { 169 | debug!("Stopping service process for {:?}, PID = {pid}", self.test_name); 170 | 171 | for _ in 0..20 { 172 | debug!("Try terminate subprocess for {:?}, PID = {pid}", self.test_name); 173 | if let Err(err) = subprocess.terminate() { 174 | error!("Fail to terminate subprocess: {err}"); 175 | } 176 | 177 | match subprocess.wait_timeout(Duration::from_secs(1)) { 178 | Err(err) => { 179 | error!("Unable to stop process {pid}: {err:?}"); 180 | }, 181 | Ok(None) => { 182 | continue; 183 | }, 184 | Ok(Some(_)) => { 185 | break; 186 | }, 187 | } 188 | } 189 | 190 | if let Some(exit_status) = subprocess.poll() { 191 | debug!( 192 | "Service process for {:?} stopped with {exit_status:?}, PID = {pid}", 193 | self.test_name 194 | ); 195 | } else { 196 | debug!("Kill the service process for {:?}, PID = {pid}", self.test_name); 197 | subprocess.kill().expect("Cannot kill subprocess"); 198 | panic!("Wait too long for the process to terminate, PID = {}", pid); 199 | } 200 | } 201 | } 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /tests/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | use std::sync::Once; 3 | 4 | pub use laplace_client::*; 5 | pub use laplace_service::*; 6 | 7 | pub mod laplace_client; 8 | pub mod laplace_service; 9 | pub mod port; 10 | 11 | pub fn init_logger() { 12 | static INIT: Once = Once::new(); 13 | 14 | INIT.call_once(|| { 15 | let log_env = env_logger::Env::default().filter_or(env_logger::DEFAULT_FILTER_ENV, "debug,reqwest=info"); 16 | let is_log_force = std::env::var("RUST_LOG_FORCE") 17 | .map(|force| !(force.trim().is_empty() || force.trim() == "0" || force.trim().to_lowercase() == "false")) 18 | .unwrap_or_default(); 19 | 20 | env_logger::Builder::from_env(log_env).is_test(!is_log_force).init(); 21 | }); 22 | } 23 | 24 | pub fn target_build_dir() -> PathBuf { 25 | let mut dir = std::env::current_exe().expect("Cannot get current exe path"); 26 | dir.pop(); 27 | if dir.ends_with("deps") { 28 | dir.pop(); 29 | } 30 | dir 31 | } 32 | -------------------------------------------------------------------------------- /tests/src/port.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | use std::net::{TcpListener, TcpStream}; 3 | use std::sync::Mutex; 4 | use std::thread; 5 | use std::time::Duration; 6 | 7 | use lazy_static::lazy_static; 8 | 9 | lazy_static! { 10 | static ref BUSY_PORTS: Mutex> = Mutex::new(HashSet::new()); 11 | } 12 | 13 | /// Use a free port, provided by system, because 0 is passed 14 | /// to the [`TcpListener::local_addr`] method. 15 | pub fn next_free_local_port() -> u16 { 16 | let mut busy_ports = BUSY_PORTS.lock().expect("Cannot lock busy ports collection"); 17 | loop { 18 | if let Some(port) = TcpListener::bind(("127.0.0.1", 0)) 19 | .and_then(|listener| listener.local_addr()) 20 | .ok() 21 | .map(|address| address.port()) 22 | .filter(|port| !busy_ports.contains(port)) 23 | { 24 | busy_ports.insert(port); 25 | break port; 26 | } 27 | } 28 | } 29 | 30 | #[derive(Debug)] 31 | pub struct PortOpeningError; 32 | 33 | pub fn wait_for_port_opened(host: &str, port: u16, timeout: Duration) -> Result<(), PortOpeningError> { 34 | // wait for TCP port 35 | const PORT_CHECK_INTERVAL: Duration = Duration::from_millis(10); 36 | let checks_num = timeout.as_millis() / PORT_CHECK_INTERVAL.as_millis(); 37 | for _ in 0..checks_num { 38 | if TcpStream::connect(format!("{}:{}", host, port)).is_ok() { 39 | return Ok(()); 40 | } 41 | thread::sleep(PORT_CHECK_INTERVAL); 42 | } 43 | Err(PortOpeningError) 44 | } 45 | 46 | #[derive(Debug)] 47 | pub struct PortClosingError; 48 | 49 | pub fn wait_for_port_closed(host: &str, port: u16) -> Result<(), PortClosingError> { 50 | // wait for TCP port to close 51 | for _ in 0..100 { 52 | if TcpStream::connect(format!("{}:{}", host, port)).is_err() { 53 | return Ok(()); 54 | } 55 | thread::sleep(Duration::from_millis(10)); 56 | } 57 | Err(PortClosingError) 58 | } 59 | -------------------------------------------------------------------------------- /tests/tests/main_access.rs: -------------------------------------------------------------------------------- 1 | use function_name::named; 2 | use reqwest::StatusCode; 3 | use tests::laplace_service::env; 4 | use tests::{init_logger, LaplaceService}; 5 | 6 | #[tokio::test] 7 | #[named] 8 | async fn http_access() { 9 | init_logger(); 10 | 11 | let service = LaplaceService::new(function_name!()) 12 | .with_var(env::SSL_ENABLED, "false") 13 | .start(); 14 | let client = service.http_client().await; 15 | 16 | let response = client.get_index().await.expect("Cannot get index"); 17 | assert_eq!(response.status(), StatusCode::FORBIDDEN); 18 | } 19 | 20 | #[tokio::test] 21 | #[named] 22 | async fn https_access() { 23 | init_logger(); 24 | 25 | let service = LaplaceService::new(function_name!()) 26 | .with_var(env::SSL_ENABLED, "true") 27 | .start(); 28 | let client = service.https_client().await; 29 | 30 | let response = client.get_index().await.expect("Cannot get index"); 31 | assert_eq!(response.status(), StatusCode::FORBIDDEN); 32 | } 33 | 34 | #[tokio::test] 35 | #[named] 36 | async fn unauthorized_access_denied() { 37 | let service = LaplaceService::new(function_name!()).start(); 38 | let client = service.https_client().await; 39 | 40 | let response = client.get_index().await.expect("Fail to get index"); 41 | assert_eq!(response.status(), StatusCode::FORBIDDEN); 42 | 43 | let response = client.get_laplace().await.expect("Fail to get laplace"); 44 | assert_eq!(response.status(), StatusCode::FORBIDDEN); 45 | } 46 | --------------------------------------------------------------------------------