Merge pull request 'Replace Rocket and Tera with Rouille and Maud' (#88) from rouille_maud into main

Reviewed-on: #88
This commit is contained in:
glyph 2022-03-25 08:07:15 +00:00
commit fded48908d
115 changed files with 4470 additions and 6658 deletions

1591
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -5,19 +5,19 @@ authors = ["Andrew Reid <glyph@mycelial.technology>"]
edition = "2018"
[dependencies]
async-std = "1.10.0"
chrono = "0.4.19"
async-std = "1.10"
chrono = "0.4"
dirs = "4.0"
fslock="0.1.6"
golgi = { git = "https://git.coopcloud.tech/golgi-ssb/golgi.git" }
fslock="0.1"
golgi = { git = "https://git.coopcloud.tech/golgi-ssb/golgi" }
jsonrpc-client-core = "0.5"
jsonrpc-client-http = "0.5"
jsonrpc-core = "8.0.1"
jsonrpc-core = "8.0"
log = "0.4"
nanorand = "0.6.1"
nanorand = "0.6"
regex = "1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_yaml = "0.8"
toml = "0.5.8"
sha3 = "0.10.0"
toml = "0.5"
sha3 = "0.10"

View File

@ -86,7 +86,7 @@ pub fn send_password_reset() -> Result<(), PeachError> {
"Your new temporary password is: {}
If you are on the same WiFi network as your PeachCloud device you can reset your password \
using this link: http://peach.local/reset_password",
using this link: http://peach.local/auth/reset",
temporary_password
);
// if there is an external domain, then include remote link in message
@ -95,7 +95,7 @@ using this link: http://peach.local/reset_password",
Some(domain) => {
format!(
"\n\nOr if you are on a different WiFi network, you can reset your password \
using the the following link: {}/reset_password",
using the the following link: {}/auth/reset",
domain
)
}

View File

@ -1,8 +1,5 @@
*.bak
static/icons/optimized/*
api_docs.md
js_docs.md
hashmap_notes
notes
target
**/*.rs.bk
leftovers

View File

@ -1,6 +1,6 @@
[package]
name = "peach-web"
version = "0.5.0"
version = "0.6.0"
authors = ["Andrew Reid <gnomad@cryptolab.net>"]
edition = "2018"
description = "peach-web is a web application which provides a web interface for monitoring and interacting with the PeachCloud device. This allows administration of the single-board computer (ie. Raspberry Pi) running PeachCloud, as well as the ssb-server and related plugins."
@ -21,8 +21,6 @@ maintainer-scripts="debian"
systemd-units = { unit-name = "peach-web" }
assets = [
["target/release/peach-web", "/usr/bin/", "755"],
["Rocket.toml", "/usr/share/peach-web/Rocket.toml", "644"],
["templates/**/*", "/usr/share/peach-web/templates/", "644"],
["static/*", "/usr/share/peach-web/static/", "644"],
["static/css/*", "/usr/share/peach-web/static/css/", "644"],
["static/icons/*", "/usr/share/peach-web/static/icons/", "644"],
@ -35,23 +33,20 @@ travis-ci = { repository = "peachcloud/peach-web", branch = "master" }
maintenance = { status = "actively-developed" }
[dependencies]
base64 = "0.13.0"
dirs = "4.0.0"
async-std = "1.10"
base64 = "0.13"
chrono = "0.4"
dirs = "4.0"
env_logger = "0.8"
futures = "0.3"
golgi = { git = "https://git.coopcloud.tech/golgi-ssb/golgi.git" }
lazy_static = "1.4.0"
lazy_static = "1.4"
log = "0.4"
nest = "1.0.0"
maud = "0.23"
peach-lib = { path = "../peach-lib" }
peach-network = { path = "../peach-network", features = ["serde_support"] }
peach-stats = { path = "../peach-stats", features = ["serde_support"] }
rocket = { version = "0.5.0-rc.1", features = ["json", "secrets"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
temporary = "0.6.4"
tera = { version = "1.12.1", features = ["builtins"] }
xdg = "2.2.0"
[dependencies.rocket_dyn_templates]
version = "0.1.0-rc.1"
features = ["tera"]
# these will be reintroduced when the full peachcloud mode is added
#peach-network = { path = "../peach-network" }
#peach-stats = { path = "../peach-stats" }
rouille = { version = "3.5", default-features = false }
temporary = "0.6"
xdg = "2.2"

View File

@ -1,6 +1,6 @@
# peach-web
![Generic badge](https://img.shields.io/badge/version-0.5.0-<COLOR>.svg)
![Generic badge](https://img.shields.io/badge/version-0.6.0-<COLOR>.svg)
## Web Interface for PeachCloud
@ -17,7 +17,7 @@ The web interface is primarily designed as a means of managing a Scuttlebutt pub
Additional features are focused on administration of the device itself. This includes networking functionality and device statistics.
The peach-web stack currently consists of [Rocket](https://rocket.rs/) (Rust web framework), [Tera](http://tera.netlify.com/) (Rust template engine), HTML and CSS. Scuttlebutt functionality is provided by [golgi](http://golgi.mycelial.technology).
The peach-web stack currently consists of [Rouille](https://crates.io/crates/rouille) (Rust web framework), [Maud](https://maud.lambda.xyz/) (Rust template engine), HTML and CSS. Scuttlebutt functionality is provided by [golgi](http://golgi.mycelial.technology).
_Note: This is a work-in-progress._
@ -32,39 +32,21 @@ Move into the repo and compile:
`cd peach-workspace/peach-web`
`cargo build --release`
Run the tests:
`ROCKET_DISABLE_AUTH=true ROCKET_STANDALONE_MODE=false cargo test`
Move back to the `peach-workspace` directory:
`cd ..`
Run the binary:
`./target/release/peach-web`
`../target/release/peach-web`
## Environment
### Deployment Profile
The web application deployment profile can be configured with the `ROCKET_ENV` environment variable:
`export ROCKET_ENV=stage`
Default configuration parameters are defined in `Rocket.toml`. This file defines a set of default parameters, some of which are overwritten when running in `debug` mode (ie. `cargo run` or `cargo build`) or `release` mode (ie. `cargo run --release` or `cargo build --release`).
Read the [Rocket Environment Configurations docs](https://rocket.rs/v0.5-rc/guide/configuration/#environment-variables) for further information.
### Configuration Mode
The web application can be run with a minimal set of routes and functionality (PeachPub - a simple sbot manager) or with the full-suite of capabilities, including network management and access to device statistics (PeachCloud). The mode is enabled by default (as defined in `Rocket.toml`) but can be overwritten using the `ROCKET_STANDALONE_MODE` environment variable: `true` or `false`. If the variable is unset or the value is incorrectly set, the application defaults to standalone mode.
The web application can be run with a minimal set of routes and functionality (PeachPub - a simple sbot manager) or with the full-suite of capabilities, including network management and access to device statistics (PeachCloud). The mode is enabled by default (as defined in `Rocket.toml`) but can be overwritten using the `STANDALONE_MODE` environment variable: `true` or `false`. If the variable is unset or the value is incorrectly set, the application defaults to standalone mode.
### Authentication
Authentication is disabled in `debug` mode and enabled by default when running the application in `release` mode. It can be disabled by setting the `ROCKET_DISABLE_AUTH` environment variable to `true`:
Authentication is enabled by default when running the application. It can be disabled by setting the `DISABLE_AUTH` environment variable to `true`:
`export ROCKET_DISABLE_AUTH=true`
`export DISABLE_AUTH=true`
### Logging
@ -106,10 +88,6 @@ Remove configuration files (not removed with `apt-get remove`):
`sudo apt-get purge peach-web`
## Design
`peach-web` is built on the Rocket webserver and Tera templating engine. It presents a web interface for interacting with the device. HTML is rendered server-side. Request handlers call `peach-` libraries and serve HTML and assets. Each Tera template is passed a context object. In the case of Rust, this object is a `struct` and must implement `Serialize`. The fields of the context object are available in the context of the template to be rendered.
## Configuration
Configuration variables are stored in /var/lib/peachcloud/config.yml.
@ -124,6 +102,10 @@ If the config dyn_use_custom_server=true, then a value must also be set for dyn_
This value is the URL of the instance of peach-dyndns-server that requests will be sent to for domain registration.
Using a custom value can here can be useful for testing.
## Design
`peach-web` has been designed with simplicity and resource minimalism in mind. Both the dependencies used by the project, as well as the code itself, reflect these design priorities. The Rouille micro-web-framework and Maud templating engine have been used to present a web interface for interacting with the device. HTML is rendered server-side and request handlers call `peach-` libraries and serve HTML and assets. The optimised binary for `peach-web` can be compiled on a RPi 3 B+ in approximately 30 minutes.
## Licensing
AGPL-3.0

View File

@ -1,11 +0,0 @@
[default]
secret_key = "VYVUDivXvu8g6llxeJd9F92pMfocml5xl/Jjv5Sk4yw="
disable_auth = false
standalone_mode = true
[debug]
template_dir = "templates/"
disable_auth = true
[release]
template_dir = "templates/"

53
peach-web/src/config.rs Normal file
View File

@ -0,0 +1,53 @@
//! Define the configuration parameters for the web application.
//!
//! Sets default values and updates them if the corresponding environment
//! variables have been set.
use std::env;
// environment variable keys to check for
const ENV_VARS: [&str; 4] = ["STANDALONE_MODE", "DISABLE_AUTH", "ADDR", "PORT"];
pub struct Config {
pub standalone_mode: bool,
pub disable_auth: bool,
pub addr: String,
pub port: String,
}
impl Default for Config {
fn default() -> Self {
Self {
standalone_mode: true,
disable_auth: false,
addr: "127.0.0.1".to_string(),
port: "8000".to_string(),
}
}
}
impl Config {
pub fn new() -> Config {
// define default config values
let mut config = Config::default();
// check for the environment variables in our config
for key in ENV_VARS {
// if a variable (key) has been set, check the value
if let Ok(val) = env::var(key) {
// if the value is of the correct type, update the config value
match key {
"STANDALONE_MODE" if val.as_str() == "true" => config.standalone_mode = true,
"STANDALONE_MODE" if val.as_str() == "false" => config.standalone_mode = false,
"DISABLE_AUTH" if val.as_str() == "true" => config.disable_auth = true,
"DISABLE_AUTH" if val.as_str() == "false" => config.disable_auth = false,
"ADDR" => config.addr = val,
"PORT" => config.port = val,
_ => (),
}
}
}
config
}
}

View File

@ -1,38 +0,0 @@
use peach_lib::{config_manager, dyndns_client};
use rocket::serde::Serialize;
#[derive(Debug, Serialize)]
pub struct ConfigureDNSContext {
pub external_domain: String,
pub dyndns_subdomain: String,
pub enable_dyndns: bool,
pub is_dyndns_online: bool,
pub back: Option<String>,
pub title: Option<String>,
pub flash_name: Option<String>,
pub flash_msg: Option<String>,
pub theme: Option<String>,
}
impl ConfigureDNSContext {
pub fn build() -> ConfigureDNSContext {
// TODO: replace `unwrap` with resilient error handling
let peach_config = config_manager::load_peach_config().unwrap();
let dyndns_fulldomain = peach_config.dyn_domain;
let is_dyndns_online = dyndns_client::is_dns_updater_online().unwrap();
let dyndns_subdomain =
dyndns_client::get_dyndns_subdomain(&dyndns_fulldomain).unwrap_or(dyndns_fulldomain);
ConfigureDNSContext {
external_domain: peach_config.external_domain,
dyndns_subdomain,
enable_dyndns: peach_config.dyn_enabled,
is_dyndns_online,
back: None,
title: None,
flash_name: None,
flash_msg: None,
theme: None,
}
}
}

View File

@ -1,3 +0,0 @@
pub mod dns;
pub mod network;
pub mod scuttlebutt;

View File

@ -1,394 +0,0 @@
//! Data retrieval for the purpose of serving routes and hydrating
//! network-related HTML templates.
use std::collections::HashMap;
use rocket::{form::FromForm, serde::Serialize, UriDisplayQuery};
use peach_network::{
network,
network::{Scan, Status, Traffic},
};
use crate::{
utils::{
monitor,
monitor::{Alert, Data, Threshold},
},
AP_IFACE, WLAN_IFACE,
};
#[derive(Debug, Serialize)]
pub struct AccessPoint {
pub detail: Option<Scan>,
pub signal: Option<i32>,
pub state: String,
}
pub fn ap_state() -> String {
match network::state(&*AP_IFACE) {
Ok(Some(state)) => state,
_ => "Interface unavailable".to_string(),
}
}
#[derive(Debug, FromForm, UriDisplayQuery)]
pub struct Ssid {
pub ssid: String,
}
#[derive(Debug, FromForm)]
pub struct WiFi {
pub ssid: String,
pub pass: String,
}
fn convert_traffic(traffic: Traffic) -> Option<IfaceTraffic> {
// modify traffic values & assign measurement unit
// based on received and transmitted values
let (rx, rx_unit) = if traffic.received > 1_047_527_424 {
// convert to GB
(traffic.received / 1_073_741_824, "GB".to_string())
} else if traffic.received > 0 {
// otherwise, convert it to MB
((traffic.received / 1024) / 1024, "MB".to_string())
} else {
(0, "MB".to_string())
};
let (tx, tx_unit) = if traffic.transmitted > 1_047_527_424 {
// convert to GB
(traffic.transmitted / 1_073_741_824, "GB".to_string())
} else if traffic.transmitted > 0 {
((traffic.transmitted / 1024) / 1024, "MB".to_string())
} else {
(0, "MB".to_string())
};
Some(IfaceTraffic {
rx,
rx_unit,
tx,
tx_unit,
})
}
#[derive(Debug, Serialize)]
pub struct IfaceTraffic {
pub rx: u64,
pub rx_unit: String,
pub tx: u64,
pub tx_unit: String,
}
#[derive(Debug, Serialize)]
pub struct NetworkAlertContext {
pub alert: Alert,
pub back: Option<String>,
pub data_total: Option<Data>, // combined stored and current wifi traffic in bytes
pub flash_name: Option<String>,
pub flash_msg: Option<String>,
pub threshold: Threshold,
pub title: Option<String>,
pub traffic: Option<IfaceTraffic>, // current wifi traffic in bytes (since boot)
}
impl NetworkAlertContext {
pub fn build() -> NetworkAlertContext {
let alert = monitor::get_alerts().unwrap();
// stored wifi data values as bytes
let stored_traffic = monitor::get_data().unwrap();
let threshold = monitor::get_thresholds().unwrap();
let (traffic, data_total) = match network::traffic(&*WLAN_IFACE) {
// convert bytes to mb or gb and add appropriate units
Ok(Some(t)) => {
let current_traffic = t.received + t.transmitted;
let traffic = convert_traffic(t);
let total = stored_traffic.total + current_traffic;
let data_total = Data { total };
(traffic, Some(data_total))
}
_ => (None, None),
};
NetworkAlertContext {
alert,
back: None,
data_total,
flash_name: None,
flash_msg: None,
threshold,
title: None,
traffic,
}
}
}
#[derive(Debug, Serialize)]
pub struct NetworkDetailContext {
pub back: Option<String>,
pub flash_name: Option<String>,
pub flash_msg: Option<String>,
pub selected: Option<String>,
pub title: Option<String>,
pub saved_aps: Vec<String>,
pub wlan_ip: String,
pub wlan_networks: HashMap<String, AccessPoint>,
pub wlan_rssi: Option<String>,
pub wlan_ssid: String,
pub wlan_state: String,
pub wlan_status: Option<Status>,
pub wlan_traffic: Option<IfaceTraffic>,
}
impl NetworkDetailContext {
pub fn build() -> NetworkDetailContext {
let wlan_ip = match network::ip(&*WLAN_IFACE) {
Ok(Some(ip)) => ip,
_ => "x.x.x.x".to_string(),
};
// list of networks saved in wpa_supplicant.conf
let wlan_list = match network::saved_networks() {
Ok(Some(ssids)) => ssids,
_ => Vec::new(),
};
// list of networks saved in wpa_supplicant.conf
let saved_aps = wlan_list.clone();
let wlan_rssi = match network::rssi_percent(&*WLAN_IFACE) {
Ok(rssi) => rssi,
Err(_) => None,
};
// list of networks currently in range (online & accessible)
let wlan_scan = match network::available_networks(&*WLAN_IFACE) {
Ok(Some(networks)) => networks,
_ => Vec::new(),
};
let wlan_ssid = match network::ssid(&*WLAN_IFACE) {
Ok(Some(ssid)) => ssid,
_ => "Not connected".to_string(),
};
let wlan_state = match network::state(&*WLAN_IFACE) {
Ok(Some(state)) => state,
_ => "Interface unavailable".to_string(),
};
let wlan_status = match network::status(&*WLAN_IFACE) {
Ok(status) => status,
// interface unavailable
_ => None,
};
let wlan_traffic = match network::traffic(&*WLAN_IFACE) {
// convert bytes to mb or gb and add appropriate units
Ok(Some(traffic)) => convert_traffic(traffic),
_ => None,
};
// create a hashmap to combine wlan_list & wlan_scan without repetition
let mut wlan_networks = HashMap::new();
for ap in wlan_scan {
let ssid = ap.ssid.clone();
let rssi = ap.signal_level.clone();
// parse the string to a signed integer (for math)
let rssi_parsed = rssi.parse::<i32>().unwrap();
// perform rssi (dBm) to quality (%) conversion
let quality_percent = 2 * (rssi_parsed + 100);
let ap_detail = AccessPoint {
detail: Some(ap),
state: "Available".to_string(),
signal: Some(quality_percent),
};
wlan_networks.insert(ssid, ap_detail);
}
for network in wlan_list {
// avoid repetition by checking that ssid is not already in list
if !wlan_networks.contains_key(&network) {
let ssid = network.clone();
let net_detail = AccessPoint {
detail: None,
state: "Not in range".to_string(),
signal: None,
};
wlan_networks.insert(ssid, net_detail);
}
}
NetworkDetailContext {
back: None,
flash_name: None,
flash_msg: None,
selected: None,
title: None,
saved_aps,
wlan_ip,
wlan_networks,
wlan_rssi,
wlan_ssid,
wlan_state,
wlan_status,
wlan_traffic,
}
}
}
#[derive(Debug, Serialize)]
pub struct NetworkListContext {
pub ap_state: String,
pub back: Option<String>,
pub flash_name: Option<String>,
pub flash_msg: Option<String>,
pub title: Option<String>,
pub wlan_networks: HashMap<String, String>,
pub wlan_ssid: String,
}
impl NetworkListContext {
pub fn build() -> NetworkListContext {
// list of networks saved in wpa_supplicant.conf
let wlan_list = match network::saved_networks() {
Ok(Some(ssids)) => ssids,
_ => Vec::new(),
};
// list of networks currently in range (online & accessible)
let wlan_scan = match network::available_networks(&*WLAN_IFACE) {
Ok(Some(networks)) => networks,
_ => Vec::new(),
};
let wlan_ssid = match network::ssid(&*WLAN_IFACE) {
Ok(Some(ssid)) => ssid,
_ => "Not connected".to_string(),
};
// create a hashmap to combine wlan_list & wlan_scan without repetition
let mut wlan_networks = HashMap::new();
for ap in wlan_scan {
wlan_networks.insert(ap.ssid, "Available".to_string());
}
for network in wlan_list {
// insert ssid (with state) only if it doesn't already exist
wlan_networks
.entry(network)
.or_insert_with(|| "Not in range".to_string());
}
let ap_state = match network::state(&*AP_IFACE) {
Ok(Some(state)) => state,
_ => "Interface unavailable".to_string(),
};
NetworkListContext {
ap_state,
back: None,
flash_msg: None,
flash_name: None,
title: None,
wlan_networks,
wlan_ssid,
}
}
}
#[derive(Debug, Serialize)]
pub struct NetworkStatusContext {
pub ap_ip: String,
pub ap_ssid: String,
pub ap_state: String,
pub ap_traffic: Option<IfaceTraffic>,
pub wlan_ip: String,
pub wlan_rssi: Option<String>,
pub wlan_ssid: String,
pub wlan_state: String,
pub wlan_status: Option<Status>,
pub wlan_traffic: Option<IfaceTraffic>,
pub flash_name: Option<String>,
pub flash_msg: Option<String>,
// passing in the ssid of a chosen access point
pub selected: Option<String>,
pub title: Option<String>,
pub back: Option<String>,
}
impl NetworkStatusContext {
pub fn build() -> Self {
let ap_ip = match network::ip(&*AP_IFACE) {
Ok(Some(ip)) => ip,
_ => "x.x.x.x".to_string(),
};
let ap_ssid = match network::ssid(&*AP_IFACE) {
Ok(Some(ssid)) => ssid,
_ => "Not currently activated".to_string(),
};
let ap_state = match network::state(&*AP_IFACE) {
Ok(Some(state)) => state,
_ => "Interface unavailable".to_string(),
};
let ap_traffic = match network::traffic(&*AP_IFACE) {
// convert bytes to mb or gb and add appropriate units
Ok(Some(traffic)) => convert_traffic(traffic),
_ => None,
};
let wlan_ip = match network::ip(&*WLAN_IFACE) {
Ok(Some(ip)) => ip,
_ => "x.x.x.x".to_string(),
};
let wlan_rssi = match network::rssi_percent(&*WLAN_IFACE) {
Ok(rssi) => rssi,
_ => None,
};
let wlan_ssid = match network::ssid(&*WLAN_IFACE) {
Ok(Some(ssid)) => ssid,
_ => "Not connected".to_string(),
};
let wlan_state = match network::state(&*WLAN_IFACE) {
Ok(Some(state)) => state,
_ => "Interface unavailable".to_string(),
};
let wlan_status = match network::status(&*WLAN_IFACE) {
Ok(status) => status,
_ => None,
};
let wlan_traffic = match network::traffic(&*WLAN_IFACE) {
// convert bytes to mb or gb and add appropriate units
Ok(Some(traffic)) => convert_traffic(traffic),
_ => None,
};
NetworkStatusContext {
ap_ip,
ap_ssid,
ap_state,
ap_traffic,
wlan_ip,
wlan_rssi,
wlan_ssid,
wlan_state,
wlan_status,
wlan_traffic,
flash_name: None,
flash_msg: None,
selected: None,
title: None,
back: None,
}
}
}

View File

@ -1,570 +0,0 @@
use std::collections::HashMap;
use golgi::{api::friends::RelationshipQuery, blobs, messages::SsbMessageValue, Sbot};
use peach_lib::sbot::{SbotConfig, SbotStatus};
use rocket::{futures::TryStreamExt, serde::Serialize};
use crate::{error::PeachWebError, utils};
// HELPER FUNCTIONS
pub async fn init_sbot_with_config(
sbot_config: &Option<SbotConfig>,
) -> Result<Sbot, PeachWebError> {
// initialise sbot connection with ip:port and shscap from config file
let sbot_client = match sbot_config {
// TODO: panics if we pass `Some(conf.shscap)` as second arg
Some(conf) => {
let ip_port = conf.lis.clone();
Sbot::init(Some(ip_port), None).await?
}
None => Sbot::init(None, None).await?,
};
Ok(sbot_client)
}
// CONTEXT STRUCTS AND BUILDERS
#[derive(Debug, Serialize)]
pub struct StatusContext {
pub back: Option<String>,
pub flash_name: Option<String>,
pub flash_msg: Option<String>,
pub title: Option<String>,
pub theme: Option<String>,
pub sbot_config: Option<SbotConfig>,
pub sbot_status: Option<SbotStatus>,
// latest sequence number for the local log
pub latest_seq: Option<u64>,
}
impl StatusContext {
pub fn default() -> Self {
StatusContext {
back: Some("/".to_string()),
flash_name: None,
flash_msg: None,
title: Some("Scuttlebutt Status".to_string()),
theme: None,
sbot_config: None,
sbot_status: None,
latest_seq: None,
}
}
pub async fn build() -> Result<Self, PeachWebError> {
let mut context = Self::default();
// retrieve current ui theme
context.theme = Some(utils::get_theme());
// retrieve go-sbot systemd process status
let sbot_status = SbotStatus::read()?;
// retrieve latest go-sbot configuration parameters
let sbot_config = SbotConfig::read().ok();
// we only want to try and interact with the sbot if it's active
if sbot_status.state == Some("active".to_string()) {
let mut sbot_client = init_sbot_with_config(&sbot_config).await?;
// retrieve the local id
let id = sbot_client.whoami().await?;
let history_stream = sbot_client.create_history_stream(id).await?;
let mut msgs: Vec<SsbMessageValue> = history_stream.try_collect().await?;
// reverse the list of messages so we can easily reference the latest one
msgs.reverse();
// assign the sequence number of the latest msg
context.latest_seq = Some(msgs[0].sequence);
} else {
// the sbot is not currently active; return a helpful message
context.flash_name = Some("warning".to_string());
context.flash_msg = Some("The Sbot is currently inactive. As a result, status data cannot be retrieved. Visit the Scuttlebutt settings menu to start the Sbot and then try again".to_string());
}
context.sbot_config = sbot_config;
context.sbot_status = Some(sbot_status);
Ok(context)
}
}
// peers who are blocked by the local account
#[derive(Debug, Serialize)]
pub struct BlocksContext {
pub back: Option<String>,
pub flash_name: Option<String>,
pub flash_msg: Option<String>,
pub title: Option<String>,
pub theme: Option<String>,
pub sbot_config: Option<SbotConfig>,
pub sbot_status: Option<SbotStatus>,
pub peers: Option<Vec<HashMap<String, String>>>,
}
impl BlocksContext {
pub fn default() -> Self {
BlocksContext {
back: Some("/scuttlebutt/peers".to_string()),
flash_name: None,
flash_msg: None,
title: Some("Blocks".to_string()),
theme: None,
sbot_config: None,
sbot_status: None,
peers: None,
}
}
pub async fn build() -> Result<Self, PeachWebError> {
let mut context = Self::default();
// retrieve current ui theme
context.theme = Some(utils::get_theme());
// retrieve go-sbot systemd process status
let sbot_status = SbotStatus::read()?;
// we only want to try and interact with the sbot if it's active
if sbot_status.state == Some("active".to_string()) {
// retrieve latest go-sbot configuration parameters
let sbot_config = SbotConfig::read().ok();
let mut sbot_client = init_sbot_with_config(&sbot_config).await?;
let blocks = sbot_client.get_blocks().await?;
// we'll use this to store the profile info for each peer who follows us
let mut peer_info = Vec::new();
if !blocks.is_empty() {
for peer in blocks.iter() {
// trim whitespace (including newline characters) and
// remove the inverted-commas around the id
let key = peer.trim().replace('"', "");
// retrieve the profile info for the given peer
let mut info = sbot_client.get_profile_info(&key).await?;
// insert the public key of the peer into the info hashmap
info.insert("id".to_string(), key.to_string());
// we do not even attempt to find the blob for a blocked peer,
// since it may be vulgar to cause distress to the local peer.
info.insert("blob_exists".to_string(), "false".to_string());
// push profile info to peer_list vec
peer_info.push(info)
}
context.peers = Some(peer_info)
}
} else {
// the sbot is not currently active; return a helpful message
context.flash_name = Some("warning".to_string());
context.flash_msg = Some("The Sbot is currently inactive. As a result, peer data cannot be retrieved. Visit the Scuttlebutt settings menu to start the Sbot and then try again".to_string());
}
context.sbot_status = Some(sbot_status);
Ok(context)
}
}
// peers who are followed by the local account
#[derive(Debug, Serialize)]
pub struct FollowsContext {
pub back: Option<String>,
pub flash_name: Option<String>,
pub flash_msg: Option<String>,
pub title: Option<String>,
pub theme: Option<String>,
pub sbot_config: Option<SbotConfig>,
pub sbot_status: Option<SbotStatus>,
pub peers: Option<Vec<HashMap<String, String>>>,
}
impl FollowsContext {
pub fn default() -> Self {
FollowsContext {
back: Some("/scuttlebutt/peers".to_string()),
flash_name: None,
flash_msg: None,
title: Some("Follows".to_string()),
theme: None,
sbot_config: None,
sbot_status: None,
peers: None,
}
}
pub async fn build() -> Result<Self, PeachWebError> {
let mut context = Self::default();
// retrieve current ui theme
context.theme = Some(utils::get_theme());
// retrieve go-sbot systemd process status
let sbot_status = SbotStatus::read()?;
// we only want to try and interact with the sbot if it's active
if sbot_status.state == Some("active".to_string()) {
// retrieve latest go-sbot configuration parameters
let sbot_config = SbotConfig::read().ok();
let mut sbot_client = init_sbot_with_config(&sbot_config).await?;
let follows = sbot_client.get_follows().await?;
// we'll use this to store the profile info for each peer who follows us
let mut peer_info = Vec::new();
if !follows.is_empty() {
for peer in follows.iter() {
// trim whitespace (including newline characters) and
// remove the inverted-commas around the id
let key = peer.trim().replace('"', "");
// retrieve the profile info for the given peer
let mut info = sbot_client.get_profile_info(&key).await?;
// insert the public key of the peer into the info hashmap
info.insert("id".to_string(), key.to_string());
// retrieve the profile image blob id for the given peer
if let Some(blob_id) = info.get("image") {
// look-up the path for the image blob
if let Ok(blob_path) = blobs::get_blob_path(&blob_id) {
// insert the image blob path of the peer into the info hashmap
info.insert("blob_path".to_string(), blob_path.to_string());
// check if the blob is in the blobstore
// set a flag in the info hashmap
match utils::blob_is_stored_locally(&blob_path).await {
Ok(exists) if exists == true => {
info.insert("blob_exists".to_string(), "true".to_string())
}
_ => info.insert("blob_exists".to_string(), "false".to_string()),
};
}
}
// push profile info to peer_list vec
peer_info.push(info)
}
context.peers = Some(peer_info)
}
} else {
// the sbot is not currently active; return a helpful message
context.flash_name = Some("warning".to_string());
context.flash_msg = Some("The Sbot is currently inactive. As a result, peer data cannot be retrieved. Visit the Scuttlebutt settings menu to start the Sbot and then try again".to_string());
}
context.sbot_status = Some(sbot_status);
Ok(context)
}
}
// peers who follow and are followed by the local account (friends)
#[derive(Debug, Serialize)]
pub struct FriendsContext {
pub back: Option<String>,
pub flash_name: Option<String>,
pub flash_msg: Option<String>,
pub title: Option<String>,
pub theme: Option<String>,
pub sbot_config: Option<SbotConfig>,
pub sbot_status: Option<SbotStatus>,
pub peers: Option<Vec<HashMap<String, String>>>,
}
impl FriendsContext {
pub fn default() -> Self {
FriendsContext {
back: Some("/scuttlebutt/peers".to_string()),
flash_name: None,
flash_msg: None,
title: Some("Friends".to_string()),
theme: None,
sbot_config: None,
sbot_status: None,
peers: None,
}
}
pub async fn build() -> Result<Self, PeachWebError> {
let mut context = Self::default();
// retrieve current ui theme
context.theme = Some(utils::get_theme());
// retrieve go-sbot systemd process status
let sbot_status = SbotStatus::read()?;
// we only want to try and interact with the sbot if it's active
if sbot_status.state == Some("active".to_string()) {
// retrieve latest go-sbot configuration parameters
let sbot_config = SbotConfig::read().ok();
let mut sbot_client = init_sbot_with_config(&sbot_config).await?;
let local_id = sbot_client.whoami().await?;
let follows = sbot_client.get_follows().await?;
// we'll use this to store the profile info for each peer who follows us
let mut peer_info = Vec::new();
if !follows.is_empty() {
for peer in follows.iter() {
// trim whitespace (including newline characters) and
// remove the inverted-commas around the id
let peer_id = peer.trim().replace('"', "");
// retrieve the profile info for the given peer
let mut info = sbot_client.get_profile_info(&peer_id).await?;
// insert the public key of the peer into the info hashmap
info.insert("id".to_string(), peer_id.to_string());
// retrieve the profile image blob id for the given peer
if let Some(blob_id) = info.get("image") {
// look-up the path for the image blob
if let Ok(blob_path) = blobs::get_blob_path(&blob_id) {
// insert the image blob path of the peer into the info hashmap
info.insert("blob_path".to_string(), blob_path.to_string());
// check if the blob is in the blobstore
// set a flag in the info hashmap
match utils::blob_is_stored_locally(&blob_path).await {
Ok(exists) if exists == true => {
info.insert("blob_exists".to_string(), "true".to_string())
}
_ => info.insert("blob_exists".to_string(), "false".to_string()),
};
}
}
// check if the peer follows us (making us friends)
let follow_query = RelationshipQuery {
source: peer_id.to_string(),
dest: local_id.clone(),
};
// query follow state
match sbot_client.friends_is_following(follow_query).await {
Ok(following) if following == "true" => {
// only push profile info to peer_list vec if they follow us
peer_info.push(info)
}
_ => (),
};
}
context.peers = Some(peer_info)
}
} else {
// the sbot is not currently active; return a helpful message
context.flash_name = Some("warning".to_string());
context.flash_msg = Some("The Sbot is currently inactive. As a result, peer data cannot be retrieved. Visit the Scuttlebutt settings menu to start the Sbot and then try again".to_string());
}
context.sbot_status = Some(sbot_status);
Ok(context)
}
}
#[derive(Debug, Serialize)]
pub struct ProfileContext {
pub back: Option<String>,
pub flash_name: Option<String>,
pub flash_msg: Option<String>,
pub title: Option<String>,
pub theme: Option<String>,
pub sbot_config: Option<SbotConfig>,
pub sbot_status: Option<SbotStatus>,
// is this the local profile or the profile of a peer?
pub is_local_profile: bool,
// an ssb_id which may or may not be the local public key
pub id: Option<String>,
pub name: Option<String>,
pub description: Option<String>,
pub image: Option<String>,
// the path to the blob defined in the `image` field (aka the profile picture)
pub blob_path: Option<String>,
// whether or not the blob exists in the blobstore (ie. is saved on disk)
pub blob_exists: bool,
// relationship state (if the profile being viewed is not for the local public key)
pub following: Option<bool>,
pub blocking: Option<bool>,
}
impl ProfileContext {
pub fn default() -> Self {
ProfileContext {
back: Some("/".to_string()),
flash_name: None,
flash_msg: None,
title: Some("Profile".to_string()),
theme: None,
sbot_config: None,
sbot_status: None,
is_local_profile: true,
id: None,
name: None,
description: None,
image: None,
blob_path: None,
blob_exists: false,
following: None,
blocking: None,
}
}
pub async fn build(ssb_id: Option<String>) -> Result<Self, PeachWebError> {
let mut context = Self::default();
// retrieve current ui theme
context.theme = Some(utils::get_theme());
// retrieve go-sbot systemd process status
let sbot_status = SbotStatus::read()?;
// retrieve latest go-sbot configuration parameters
let sbot_config = SbotConfig::read().ok();
let mut sbot_client = init_sbot_with_config(&sbot_config).await?;
let local_id = sbot_client.whoami().await?;
// if an ssb_id has been provided to the context builder, we assume that
// the profile info being retrieved is for a peer (ie. not for our local
// profile)
let id = if ssb_id.is_some() {
// we are not dealing with the local profile
context.is_local_profile = false;
// we're safe to unwrap here because we know it's `Some(id)`
let peer_id = ssb_id.unwrap();
// determine relationship between peer and local id
let follow_query = RelationshipQuery {
source: local_id.clone(),
dest: peer_id.clone(),
};
// query follow state
context.following = match sbot_client.friends_is_following(follow_query).await {
Ok(following) if following == "true" => Some(true),
Ok(following) if following == "false" => Some(false),
_ => None,
};
// TODO: i don't like that we have to instantiate the same query object
// twice. see if we can streamline this in golgi
let block_query = RelationshipQuery {
source: local_id.clone(),
dest: peer_id.clone(),
};
// query block state
context.blocking = match sbot_client.friends_is_blocking(block_query).await {
Ok(blocking) if blocking == "true" => Some(true),
Ok(blocking) if blocking == "false" => Some(false),
_ => None,
};
peer_id
} else {
// if an ssb_id has not been provided, retrieve the local id using whoami
context.is_local_profile = true;
local_id
};
// retrieve the profile info for the given id
let info = sbot_client.get_profile_info(&id).await?;
// set each context field accordingly
for (key, val) in info {
match key.as_str() {
"name" => context.name = Some(val),
"description" => context.description = Some(val),
"image" => context.image = Some(val),
_ => (),
}
}
// assign the ssb public key to the context
// (could be for the local profile or a peer)
context.id = Some(id);
// determine the path to the blob defined by the value of `context.image`
if let Some(ref blob_id) = context.image {
context.blob_path = match blobs::get_blob_path(&blob_id) {
Ok(path) => {
// if we get the path, check if the blob is in the blobstore.
// this allows us to default to a placeholder image in the template
if let Ok(exists) = utils::blob_is_stored_locally(&path).await {
context.blob_exists = exists
};
Some(path)
}
Err(_) => None,
}
}
context.sbot_status = Some(sbot_status);
Ok(context)
}
}
#[derive(Debug, Serialize)]
pub struct PrivateContext {
pub back: Option<String>,
pub flash_name: Option<String>,
pub flash_msg: Option<String>,
pub title: Option<String>,
pub theme: Option<String>,
pub sbot_config: Option<SbotConfig>,
pub sbot_status: Option<SbotStatus>,
// local peer id (whoami)
pub id: Option<String>,
// id of the peer being messaged
pub recipient_id: Option<String>,
}
impl PrivateContext {
pub fn default() -> Self {
PrivateContext {
back: Some("/".to_string()),
flash_name: None,
flash_msg: None,
title: Some("Private Messages".to_string()),
theme: None,
sbot_config: None,
sbot_status: None,
id: None,
recipient_id: None,
}
}
pub async fn build(recipient_id: Option<String>) -> Result<Self, PeachWebError> {
let mut context = Self::default();
// retrieve current ui theme
context.theme = Some(utils::get_theme());
// retrieve go-sbot systemd process status
let sbot_status = SbotStatus::read()?;
// retrieve latest go-sbot configuration parameters
let sbot_config = SbotConfig::read().ok();
let mut sbot_client = init_sbot_with_config(&sbot_config).await?;
context.recipient_id = recipient_id;
let local_id = sbot_client.whoami().await?;
context.id = Some(local_id);
context.sbot_status = Some(sbot_status);
Ok(context)
}
}

View File

@ -8,108 +8,122 @@
//! ## Design
//!
//! `peach-web` is written primarily in Rust and presents a web interface for
//! interacting with the device. The stack currently consists of Rocket (Rust
//! web framework), Tera (Rust template engine inspired by Jinja2 and the Django
//! template language), HTML, CSS and JavaScript. Additional functionality is
//! provided by JSON-RPC clients for the `peach-network` and `peach-stats`
//! microservices.
//!
//! HTML is rendered server-side. Request handlers call JSON-RPC microservices
//! and serve HTML and assets. A JSON API is exposed for remote calls and
//! dynamic client-side content updates via vanilla JavaScript following
//! unobstructive design principles. Each Tera template is passed a context
//! object. In the case of Rust, this object is a `struct` and must implement
//! `Serialize`. The fields of the context object are available in the context
//! of the template to be rendered.
//! interacting with the device. The stack currently consists of Rouille (Rust
//! micro-web-framework), Maud (an HTML template engine for Rust), HTML and
//! CSS.
mod context;
mod config;
pub mod error;
mod router;
pub mod routes;
#[cfg(test)]
mod tests;
mod private_router;
mod public_router;
mod routes;
mod templates;
pub mod utils;
use std::{process, sync::RwLock};
use std::{
collections::HashMap,
sync::{Mutex, RwLock},
};
use lazy_static::lazy_static;
use log::{debug, error, info};
use log::{debug, info};
use peach_lib::{config_manager, config_manager::YAML_PATH as PEACH_CONFIG};
use rocket::{fairing::AdHoc, serde::Deserialize, Build, Rocket};
use utils::Theme;
pub type BoxError = Box<dyn std::error::Error>;
/// Application configuration parameters.
/// These values are extracted from Rocket's default configuration provider:
/// `Config::figment()`. As such, the values are drawn from `Rocket.toml` or
/// the TOML file path in the `ROCKET_CONFIG` environment variable. The TOML
/// file parameters are automatically overruled by any `ROCKET_` variables
/// which might be set.
#[derive(Debug, Deserialize)]
pub struct RocketConfig {
disable_auth: bool,
standalone_mode: bool,
}
// crate-local dependencies
use config::Config;
use utils::theme::Theme;
// load the application configuration and create the theme switcher
lazy_static! {
static ref CONFIG: Config = Config::new();
static ref THEME: RwLock<Theme> = RwLock::new(Theme::Light);
}
static WLAN_IFACE: &str = "wlan0";
static AP_IFACE: &str = "ap0";
pub fn init_rocket() -> Rocket<Build> {
info!("Initializing Rocket");
// build a basic rocket instance
let rocket = rocket::build();
// return the default provider figment used by `rocket::build()`
let figment = rocket.figment();
// deserialize configuration parameters into our `RocketConfig` struct (defined above)
// since we're in the intialisation phase, panic if the extraction fails
let config: RocketConfig = figment.extract().expect("configuration extraction failed");
debug!("{:?}", config);
info!("Mounting Rocket routes");
let mounted_rocket = if config.standalone_mode {
router::mount_peachpub_routes(rocket)
} else {
router::mount_peachcloud_routes(rocket)
};
info!("Attaching application configuration to managed state");
mounted_rocket.attach(AdHoc::config::<RocketConfig>())
/// Session data for each authenticated client.
#[derive(Debug, Clone)]
pub struct SessionData {
_login: String,
}
/// Launch the peach-web rocket server.
#[rocket::main]
async fn main() {
/// Launch the peach-web server.
fn main() {
// initialize logger
env_logger::init();
// check if /var/lib/peachcloud/config.yml exists
if !std::path::Path::new(PEACH_CONFIG).exists() {
info!("PeachCloud configuration file not found; loading default values");
debug!("PeachCloud configuration file not found; loading default values");
// since we're in the intialisation phase, panic if the loading fails
let config =
config_manager::load_peach_config().expect("peachcloud configuration loading failed");
info!("Saving default PeachCloud configuration values to file");
debug!("Saving default PeachCloud configuration values to file");
// this ensures a config file is created if it does not already exist
config_manager::save_peach_config(config).expect("peachcloud configuration saving failed");
}
// initialize rocket
let rocket = init_rocket();
// set ip address / hostname and port for the webserver
// defaults to "127.0.0.1:8000"
let addr_and_port = format!("{}:{}", CONFIG.addr, CONFIG.port);
// launch rocket
info!("Launching Rocket");
if let Err(e) = rocket.launch().await {
error!("Error in Rocket application: {}", e);
process::exit(1);
}
// store the session data for each session and a hashmap that associates
// each session id with the data
// note: we are storing this data in memory. all sessions are erased when
// the program is restarted.
let sessions_storage: Mutex<HashMap<String, SessionData>> = Mutex::new(HashMap::new());
info!("Launching web server on {}", addr_and_port);
// the `start_server` starts listening forever on the given address
rouille::start_server(addr_and_port, move |request| {
// assign a unique id to each client (appends a cookie to the response
// with a name of "SID" and a duration of one hour (3600 seconds)
rouille::session::session(request, "SID", 3600, |session| {
// if the "DISABLE_AUTH" env var is true, authenticate the session
let mut session_data = if CONFIG.disable_auth {
Some(SessionData {
_login: "success".to_string(),
})
// if the client already has an identifier from a previous request,
// try to load the existing session data. if successful, make a
// copy of the data in order to avoid locking the session for too
// long
} else if session.client_has_sid() {
sessions_storage.lock().unwrap().get(session.id()).cloned()
} else {
None
};
// pass the request to the public router
//
// the public router includes authentication-related routes which
// do not require the user to be authenticated (ie. login and reset
// password)
//
// if the user is already authenticated, their request will be
// passed to the private router by public_router::handle_route()
//
// we pass a mutable reference to the `Option<SessionData>` so that
// the function is free to modify it
let response = public_router::handle_route(request, &mut session_data);
// since the function call to `handle_route` can modify the session
// data, we have to store it back in the `sessions_storage` after
// the request has been handled
if let Some(data) = session_data {
sessions_storage
.lock()
.unwrap()
.insert(session.id().to_owned(), data);
} else if session.client_has_sid() {
// if the content of the `Option` was erased (ie. due to
// deauthentication on logout), remove the session from the
// storage. this is only done if the client already has an
// identifier, otherwise calling `session.id()` will assign one
sessions_storage.lock().unwrap().remove(session.id());
}
response
})
});
}

View File

@ -0,0 +1,197 @@
use rouille::{router, Request, Response};
use crate::{routes, templates, utils::flash::FlashResponse, SessionData};
// TODO: add mount_peachcloud_routes()
// https://github.com/tomaka/rouille/issues/232#issuecomment-919225104
/// Define the PeachPub router.
///
/// Takes an incoming request and matches on the defined routes,
/// returning either a template or a redirect.
///
/// All of these routes require the user to be authenticated. See the
/// `public_router` for publically-accessible, authentication-related routes.
///
/// Excludes settings and status routes related to networking and the device
/// (memory, hard disk, CPU etc.).
pub fn mount_peachpub_routes(
request: &Request,
session_data: &mut Option<SessionData>,
) -> Response {
router!(request,
(GET) (/) => {
Response::html(routes::home::build_template())
},
(GET) (/auth/change) => {
// build the html template
Response::html(routes::authentication::change::build_template(request))
// reset the flash msg cookies in the response object
.reset_flash()
},
(POST) (/auth/change) => {
routes::authentication::change::handle_form(request)
},
(GET) (/auth/logout) => {
routes::authentication::logout::deauthenticate(session_data)
},
(GET) (/guide) => {
Response::html(routes::guide::build_template())
},
(POST) (/scuttlebutt/block) => {
routes::scuttlebutt::block::handle_form(request)
},
(GET) (/scuttlebutt/blocks) => {
Response::html(routes::scuttlebutt::blocks::build_template())
},
(POST) (/scuttlebutt/follow) => {
routes::scuttlebutt::follow::handle_form(request)
},
(GET) (/scuttlebutt/follows) => {
Response::html(routes::scuttlebutt::follows::build_template())
},
(GET) (/scuttlebutt/friends) => {
Response::html(routes::scuttlebutt::friends::build_template())
},
(GET) (/scuttlebutt/invites) => {
Response::html(routes::scuttlebutt::invites::build_template(request))
.reset_flash()
},
(POST) (/scuttlebutt/invites) => {
routes::scuttlebutt::invites::handle_form(request)
},
(GET) (/scuttlebutt/peers) => {
Response::html(routes::scuttlebutt::peers::build_template())
},
(GET) (/scuttlebutt/private) => {
Response::html(routes::scuttlebutt::private::build_template(request, None))
},
(POST) (/scuttlebutt/private) => {
routes::scuttlebutt::private::handle_form(request)
},
(GET) (/scuttlebutt/private/{ssb_id: String}) => {
Response::html(routes::scuttlebutt::private::build_template(request, Some(ssb_id)))
},
(GET) (/scuttlebutt/profile) => {
Response::html(routes::scuttlebutt::profile::build_template(request, None))
.reset_flash()
},
(GET) (/scuttlebutt/profile/update) => {
Response::html(routes::scuttlebutt::profile_update::build_template(request))
.reset_flash()
},
(POST) (/scuttlebutt/profile/update) => {
routes::scuttlebutt::profile_update::handle_form(request)
},
(GET) (/scuttlebutt/profile/{ssb_id: String}) => {
Response::html(routes::scuttlebutt::profile::build_template(request, Some(ssb_id)))
},
(POST) (/scuttlebutt/publish) => {
routes::scuttlebutt::publish::handle_form(request)
},
(GET) (/scuttlebutt/search) => {
Response::html(routes::scuttlebutt::search::build_template(request))
.reset_flash()
},
(POST) (/scuttlebutt/search) => {
routes::scuttlebutt::search::handle_form(request)
},
(POST) (/scuttlebutt/unblock) => {
routes::scuttlebutt::unblock::handle_form(request)
},
(POST) (/scuttlebutt/unfollow) => {
routes::scuttlebutt::unfollow::handle_form(request)
},
(GET) (/settings) => {
Response::html(routes::settings::menu::build_template())
},
(GET) (/settings/admin) => {
Response::html(routes::settings::admin::menu::build_template())
},
(POST) (/settings/admin/add) => {
routes::settings::admin::add::handle_form(request)
},
(GET) (/settings/admin/configure) => {
Response::html(routes::settings::admin::configure::build_template(request))
.reset_flash()
},
(POST) (/settings/admin/delete) => {
routes::settings::admin::delete::handle_form(request)
},
(GET) (/settings/scuttlebutt) => {
Response::html(routes::settings::scuttlebutt::menu::build_template(request))
.reset_flash()
},
(GET) (/settings/scuttlebutt/restart) => {
routes::settings::scuttlebutt::restart::restart_sbot()
},
(GET) (/settings/scuttlebutt/start) => {
routes::settings::scuttlebutt::start::start_sbot()
},
(GET) (/settings/scuttlebutt/stop) => {
routes::settings::scuttlebutt::stop::stop_sbot()
},
(GET) (/settings/scuttlebutt/configure) => {
Response::html(routes::settings::scuttlebutt::configure::build_template(request))
.reset_flash()
},
(POST) (/settings/scuttlebutt/configure) => {
routes::settings::scuttlebutt::configure::handle_form(request, false)
},
(POST) (/settings/scuttlebutt/configure/restart) => {
routes::settings::scuttlebutt::configure::handle_form(request, true)
},
(GET) (/settings/scuttlebutt/configure/default) => {
routes::settings::scuttlebutt::default::write_config()
},
(GET) (/settings/theme/{theme: String}) => {
routes::settings::theme::set_theme(theme)
},
(GET) (/status/scuttlebutt) => {
Response::html(routes::status::scuttlebutt::build_template())
},
// render the not_found template and set a 404 status code if none of
// the other blocks matches the request
_ => Response::html(templates::not_found::build_template()).with_status_code(404)
)
}

View File

@ -0,0 +1,103 @@
use log::{error, info};
use rouille::{router, Request, Response};
use crate::{
private_router, routes,
utils::{flash::FlashResponse, sbot},
SessionData,
};
/// Request handler.
///
/// Mount the fileservers for static assets and define the
/// publically-accessible routes (including per-route handlers). Includes
/// logging of all incoming requests.
///
/// If the request is for a private route (ie. a route requiring successful
/// authentication to view), check the authentication status of the user
/// by querying the `session_data`. If the user is authenticated, pass their
/// request to the private router. Otherwise, redirect them to the login page.
pub fn handle_route(request: &Request, session_data: &mut Option<SessionData>) -> Response {
// static file server
// matches on assets in the `static` directory
let static_response = rouille::match_assets(request, "static");
if static_response.is_success() {
return static_response;
}
// set the `.ssb-go` path in order to mount the blob fileserver
let ssb_path = sbot::get_go_ssb_path().expect("define ssb-go dir path");
let blobstore = format!("{}/blobs/sha256", ssb_path);
// blobstore file server
// removes the /blob url prefix and serves blobs from blobstore
// matches on assets in the `static` directory
if let Some(request) = request.remove_prefix("/blob") {
return rouille::match_assets(&request, &blobstore);
}
// get the current time (for logging purposes)
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S%.6f");
// define the success logger for incoming requests
let log_ok = |req: &Request, _resp: &Response, _elap: std::time::Duration| {
info!("{} {} {}", now, req.method(), req.raw_url());
};
// define the error logger for incoming requests
let log_err = |req: &Request, _elap: std::time::Duration| {
error!(
"{} Handler panicked: {} {}",
now,
req.method(),
req.raw_url()
);
};
// instantiate request logging
rouille::log_custom(request, log_ok, log_err, || {
// handle the routes which are always accessible (ie. whether logged-in
// or not)
router!(request,
(GET) (/auth/forgot) => {
Response::html(routes::authentication::forgot::build_template(request))
.reset_flash()
},
(GET) (/auth/login) => {
Response::html(routes::authentication::login::build_template(request))
.reset_flash()
},
(POST) (/auth/login) => {
routes::authentication::login::handle_form(request, session_data)
},
(GET) (/auth/reset) => {
Response::html(routes::authentication::reset::build_template(request))
.reset_flash()
},
(POST) (/auth/reset) => {
routes::authentication::reset::handle_form(request)
},
(POST) (/auth/temporary) => {
routes::authentication::temporary::handle_form()
},
_ => {
// now that we handled all the routes that are accessible in all
// circumstances, we check that the user is logged in before proceeding
if let Some(_session) = session_data.as_ref() {
// logged in:
// mount the routes which require authentication to view
private_router::mount_peachpub_routes(request, session_data)
} else {
// not logged in:
Response::redirect_303("/auth/login")
}
}
)
})
}

View File

@ -1,125 +0,0 @@
use rocket::{catchers, fs::FileServer, routes, Build, Rocket};
use rocket_dyn_templates::Template;
use crate::{
routes::{
authentication::*,
catchers::*,
index::*,
scuttlebutt::*,
settings::{admin::*, dns::*, menu::*, network::*, scuttlebutt::*, theme::*},
status::{device::*, network::*, scuttlebutt::*},
},
utils,
};
/// Create a Rocket instance and mount PeachPub routes, fileserver and
/// catchers. This gives us everything we need to run PeachPub and excludes
/// settings and status routes related to networking and the device (memory,
/// hard disk, CPU etc.).
pub fn mount_peachpub_routes(rocket: Rocket<Build>) -> Rocket<Build> {
// set the `.ssb-go` path in order to mount the blob fileserver
let ssb_path = utils::get_go_ssb_path().expect("define ssb-go dir path");
let blobstore = format!("{}/blobs/sha256", ssb_path);
rocket
.mount(
"/",
routes![
guide,
home,
login,
login_post,
logout,
settings_menu,
set_theme,
],
)
.mount(
"/settings/admin",
routes![
admin_menu,
configure_admin,
add_admin_post,
delete_admin_post,
change_password,
change_password_post,
reset_password,
reset_password_post,
forgot_password_page,
send_password_reset_post,
],
)
.mount(
"/settings/scuttlebutt",
routes![
ssb_settings_menu,
configure_sbot,
configure_sbot_default,
configure_sbot_post,
restart_sbot,
start_sbot,
stop_sbot
],
)
.mount(
"/scuttlebutt",
routes![
invites,
create_invite,
peers,
search,
search_post,
friends,
follows,
blocks,
profile,
update_profile,
update_profile_post,
private,
private_post,
follow,
unfollow,
block,
unblock,
publish,
],
)
.mount("/status", routes![scuttlebutt_status])
.mount("/", FileServer::from("static"))
.mount("/blob", FileServer::from(blobstore).rank(-1))
.register("/", catchers![not_found, internal_error, forbidden])
.attach(Template::fairing())
}
/// Create a Rocket instance with PeachPub routes, fileserver and catchers by
/// calling `mount_peachpub_routes()` and then mount all additional routes
/// required to run a complete PeachCloud build.
pub fn mount_peachcloud_routes(rocket: Rocket<Build>) -> Rocket<Build> {
mount_peachpub_routes(rocket)
.mount("/", routes![reboot_cmd, shutdown_cmd, power_menu,])
.mount(
"/settings/network",
routes![
add_credentials,
connect_wifi,
configure_dns,
configure_dns_post,
disconnect_wifi,
deploy_ap,
deploy_client,
forget_wifi,
network_home,
add_ssid,
add_wifi,
network_detail,
wifi_list,
wifi_password,
wifi_set_password,
wifi_usage,
wifi_usage_alerts,
wifi_usage_reset,
],
)
.mount("/status", routes![device_status, network_status])
}

View File

@ -1,333 +0,0 @@
use log::info;
use rocket::{
form::{Form, FromForm},
get,
http::{Cookie, CookieJar, Status},
post,
request::{self, FlashMessage, FromRequest, Request},
response::{Flash, Redirect},
};
use rocket_dyn_templates::{tera::Context, Template};
use peach_lib::{error::PeachError, password_utils};
use crate::error::PeachWebError;
use crate::utils;
use crate::utils::TemplateOrRedirect;
use crate::RocketConfig;
// HELPERS AND STRUCTS FOR AUTHENTICATION WITH COOKIES
pub const AUTH_COOKIE_KEY: &str = "peachweb_auth";
pub const ADMIN_USERNAME: &str = "admin";
/// Note: Currently we use an empty struct for the Authenticated request guard
/// because there is only one user to be authenticated, and no data needs to be stored here.
/// In a multi-user authentication scheme, we would store the user_id in this struct,
/// and retrieve the correct user via the user_id stored in the cookie.
pub struct Authenticated;
#[derive(Debug)]
pub enum LoginError {
UserNotLoggedIn,
}
/// Request guard which returns an empty Authenticated struct from the request
/// if and only if the user has a cookie which proves they are authenticated with peach-web.
///
/// Note that cookies.get_private uses encryption, which means that this private cookie
/// cannot be inspected, tampered with, or manufactured by clients.
#[rocket::async_trait]
impl<'r> FromRequest<'r> for Authenticated {
type Error = LoginError;
async fn from_request(req: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
// retrieve auth state from managed state (returns `Option<bool>`).
// this value is read from the Rocket.toml config file on start-up
let authentication_is_disabled: bool = *req
.rocket()
.state::<RocketConfig>()
.map(|config| (&config.disable_auth))
.unwrap_or(&false);
if authentication_is_disabled {
let auth = Authenticated {};
request::Outcome::Success(auth)
} else {
let authenticated = req
.cookies()
.get_private(AUTH_COOKIE_KEY)
.and_then(|cookie| cookie.value().parse().ok())
.map(|_value: String| Authenticated {});
match authenticated {
Some(auth) => request::Outcome::Success(auth),
None => request::Outcome::Failure((Status::Forbidden, LoginError::UserNotLoggedIn)),
}
}
}
}
// HELPERS AND ROUTES FOR /login
#[get("/login")]
pub fn login(flash: Option<FlashMessage>) -> Template {
// retrieve current ui theme
let theme = utils::get_theme();
let mut context = Context::new();
context.insert("theme", &theme);
context.insert("back", &Some("/".to_string()));
context.insert("title", &Some("Login".to_string()));
// check to see if there is a flash message to display
if let Some(flash) = flash {
context.insert("flash_name", &Some(flash.kind().to_string()));
context.insert("flash_msg", &Some(flash.message().to_string()));
};
Template::render("login", &context.into_json())
}
#[derive(Debug, FromForm)]
pub struct LoginForm {
pub password: String,
}
/// Takes in a LoginForm and returns Ok(()) if the password is correct.
///
/// Note: there is currently only one user, therefore we don't need a username.
pub fn verify_login_form(login_form: LoginForm) -> Result<(), PeachError> {
password_utils::verify_password(&login_form.password)
}
#[post("/login", data = "<login_form>")]
pub fn login_post(login_form: Form<LoginForm>, cookies: &CookieJar<'_>) -> TemplateOrRedirect {
match verify_login_form(login_form.into_inner()) {
Ok(_) => {
// if successful login, add a cookie indicating the user is authenticated
// and redirect to home page
// NOTE: since we currently have just one user, the value of the cookie
// is just admin (this is arbitrary).
// If we had multiple users, we could put the user_id here.
cookies.add_private(Cookie::new(AUTH_COOKIE_KEY, ADMIN_USERNAME));
TemplateOrRedirect::Redirect(Redirect::to("/"))
}
Err(e) => {
let err_msg = format!("Invalid password: {}", e);
// if unsuccessful login, render /login page again
let mut context = Context::new();
context.insert("back", &Some("/".to_string()));
context.insert("title", &Some("Login".to_string()));
context.insert("flash_name", &("error".to_string()));
context.insert("flash_msg", &(err_msg));
TemplateOrRedirect::Template(Template::render("login", &context.into_json()))
}
}
}
// HELPERS AND ROUTES FOR /logout
#[get("/logout")]
pub fn logout(cookies: &CookieJar<'_>) -> Flash<Redirect> {
// logout authenticated user
info!("Attempting deauthentication of user.");
cookies.remove_private(Cookie::named(AUTH_COOKIE_KEY));
Flash::success(Redirect::to("/login"), "Logged out")
}
// HELPERS AND ROUTES FOR /reset_password
#[derive(Debug, FromForm)]
pub struct ResetPasswordForm {
pub temporary_password: String,
pub new_password1: String,
pub new_password2: String,
}
/// Verify, validate and save the submitted password. This function is publicly exposed for users who have forgotten their password.
pub fn save_reset_password_form(password_form: ResetPasswordForm) -> Result<(), PeachWebError> {
info!(
"reset password!: {} {} {}",
password_form.temporary_password, password_form.new_password1, password_form.new_password2
);
password_utils::verify_temporary_password(&password_form.temporary_password)?;
// if the previous line did not throw an error, then the secret_link is correct
password_utils::validate_new_passwords(
&password_form.new_password1,
&password_form.new_password2,
)?;
// if the previous line did not throw an error, then the new password is valid
password_utils::set_new_password(&password_form.new_password1)?;
Ok(())
}
/// Password reset request handler. This route is used by a user who is not logged in
/// and is specifically for users who have forgotten their password.
#[get("/reset_password")]
pub fn reset_password(flash: Option<FlashMessage>) -> Template {
// retrieve current ui theme
let theme = utils::get_theme();
let mut context = Context::new();
context.insert("theme", &theme);
context.insert("back", &Some("/".to_string()));
context.insert("title", &Some("Reset Password".to_string()));
// check to see if there is a flash message to display
if let Some(flash) = flash {
context.insert("flash_name", &Some(flash.kind().to_string()));
context.insert("flash_msg", &Some(flash.message().to_string()));
};
Template::render("settings/admin/reset_password", &context.into_json())
}
/// Password reset form request handler. This route is used by a user who is not logged in
/// and is specifically for users who have forgotten their password.
#[post("/reset_password", data = "<reset_password_form>")]
pub fn reset_password_post(reset_password_form: Form<ResetPasswordForm>) -> Template {
let mut context = Context::new();
context.insert("back", &Some("/".to_string()));
context.insert("title", &Some("Reset Password".to_string()));
let (flash_name, flash_msg) = match save_reset_password_form(reset_password_form.into_inner()) {
Ok(_) => (
"success".to_string(),
"New password has been saved. Return home to login".to_string(),
),
Err(err) => (
"error".to_string(),
format!("Failed to reset password: {}", err),
),
};
context.insert("flash_name", &Some(flash_name));
context.insert("flash_msg", &Some(flash_msg));
Template::render("settings/admin/reset_password", &context.into_json())
}
// HELPERS AND ROUTES FOR /send_password_reset
/// Page for users who have forgotten their password.
/// This route is used by a user who is not logged in
/// to initiate the sending of a new password reset.
#[get("/forgot_password")]
pub fn forgot_password_page(flash: Option<FlashMessage>) -> Template {
// retrieve current ui theme
let theme = utils::get_theme();
let mut context = Context::new();
context.insert("theme", &theme);
context.insert("back", &Some("/".to_string()));
context.insert("title", &Some("Send Password Reset".to_string()));
// check to see if there is a flash message to display
if let Some(flash) = flash {
// add flash message contents to the context object
context.insert("flash_name", &Some(flash.kind().to_string()));
context.insert("flash_msg", &Some(flash.message().to_string()));
};
Template::render("settings/admin/forgot_password", &context.into_json())
}
/// Send password reset request handler. This route is used by a user who is not logged in
/// and is specifically for users who have forgotten their password. A successful request results
/// in a Scuttlebutt private message being sent to the account of the device admin.
#[post("/send_password_reset")]
pub fn send_password_reset_post() -> Template {
info!("++ send password reset post");
let mut context = Context::new();
context.insert("back", &Some("/".to_string()));
context.insert("title", &Some("Send Password Reset".to_string()));
let (flash_name, flash_msg) = match password_utils::send_password_reset() {
Ok(_) => (
"success".to_string(),
"A password reset link has been sent to the admin of this device".to_string(),
),
Err(err) => (
"error".to_string(),
format!("Failed to send password reset link: {}", err),
),
};
context.insert("flash_name", &Some(flash_name));
context.insert("flash_msg", &Some(flash_msg));
Template::render("settings/admin/forgot_password", &context.into_json())
}
// HELPERS AND ROUTES FOR /settings/change_password
#[derive(Debug, FromForm)]
pub struct PasswordForm {
pub current_password: String,
pub new_password1: String,
pub new_password2: String,
}
/// Password save form request handler. This function is for use by a user who is already logged in to change their password.
pub fn save_password_form(password_form: PasswordForm) -> Result<(), PeachWebError> {
info!(
"change password!: {} {} {}",
password_form.current_password, password_form.new_password1, password_form.new_password2
);
password_utils::verify_password(&password_form.current_password)?;
// if the previous line did not throw an error, then the old password is correct
password_utils::validate_new_passwords(
&password_form.new_password1,
&password_form.new_password2,
)?;
// if the previous line did not throw an error, then the new password is valid
password_utils::set_new_password(&password_form.new_password1)?;
Ok(())
}
/// Change password request handler. This is used by a user who is already logged in.
#[get("/change_password")]
pub fn change_password(flash: Option<FlashMessage>, _auth: Authenticated) -> Template {
// retrieve current ui theme
let theme = utils::get_theme();
let mut context = Context::new();
context.insert("theme", &theme);
context.insert("back", &Some("/settings/admin".to_string()));
context.insert("title", &Some("Change Password".to_string()));
// check to see if there is a flash message to display
if let Some(flash) = flash {
// add flash message contents to the context object
context.insert("flash_name", &Some(flash.kind().to_string()));
context.insert("flash_msg", &Some(flash.message().to_string()));
};
Template::render("settings/admin/change_password", &context.into_json())
}
/// Change password form request handler. This route is used by a user who is already logged in.
#[post("/change_password", data = "<password_form>")]
pub fn change_password_post(password_form: Form<PasswordForm>, _auth: Authenticated) -> Template {
let mut context = Context::new();
context.insert("back", &Some("/settings/admin".to_string()));
context.insert("title", &Some("Change Password".to_string()));
let (flash_name, flash_msg) = match save_password_form(password_form.into_inner()) {
Ok(_) => (
"success".to_string(),
"New password has been saved".to_string(),
),
Err(err) => (
"error".to_string(),
format!("Failed to save new password: {}", err),
),
};
context.insert("flash_name", &Some(flash_name));
context.insert("flash_msg", &Some(flash_msg));
Template::render("settings/admin/change_password", &context.into_json())
}

View File

@ -0,0 +1,116 @@
use log::info;
use maud::{html, PreEscaped};
use peach_lib::password_utils;
use rouille::{post_input, try_or_400, Request, Response};
use crate::{
error::PeachWebError,
templates,
utils::{
flash::{FlashRequest, FlashResponse},
theme,
},
};
// HELPER AND ROUTES FOR /auth/change (GET and POST)
/// Password change form template builder.
pub fn build_template(request: &Request) -> PreEscaped<String> {
// check for flash cookies; will be (None, None) if no flash cookies are found
let (flash_name, flash_msg) = request.retrieve_flash();
let form_template = html! {
(PreEscaped("<!-- CHANGE PASSWORD FORM -->"))
div class="card center" {
form id="changePassword" class="center" action="/auth/change" method="post" {
div style="display: flex; flex-direction: column; margin-bottom: 1rem;" {
(PreEscaped("<!-- input for current password -->"))
label for="currentPassword" class="center label-small font-gray" style="width: 80%;" { "CURRENT PASSWORD" }
input id="currentPassword" class="center input" name="current_password" type="password" title="Current password" autofocus;
(PreEscaped("<!-- input for new password -->"))
label for="newPassword" class="center label-small font-gray" style="width: 80%;" { "NEW PASSWORD" }
input id="newPassword" class="center input" name="new_password1" type="password" title="New password";
(PreEscaped("<!-- input for duplicate new password -->"))
label for="newPasswordDuplicate" class="center label-small font-gray" style="width: 80%;" { "RE-ENTER NEW PASSWORD" }
input id="newPasswordDuplicate" class="center input" name="new_password2" type="password" title="New password duplicate";
(PreEscaped("<!-- save (form submission) button -->"))
input id="savePassword" class="button button-primary center" title="Add" type="submit" value="Save";
a class="button button-secondary center" href="/settings/admin" title="Cancel"{ "Cancel" }
}
}
// render flash message if cookies were found in the request
@if let (Some(name), Some(msg)) = (flash_name, flash_msg) {
(PreEscaped("<!-- FLASH MESSAGE -->"))
(templates::flash::build_template(name, msg))
}
}
};
// wrap the nav bars around the settings menu template content
// parameters are template, title and back url
let body =
templates::nav::build_template(form_template, "Change Password", Some("/settings/admin"));
// query the current theme so we can pass it into the base template builder
let theme = theme::get_theme();
// render the base template with the provided body
templates::base::build_template(body, theme)
}
/// Verify, validate and set a new password, overwriting the current password.
pub fn save_password(
current_password: &str,
new_password1: &str,
new_password2: &str,
) -> Result<(), PeachWebError> {
info!(
"Attempting password change: {} {} {}",
current_password, new_password1, new_password2
);
// check that the supplied value matches the actual current password
password_utils::verify_password(current_password)?;
// ensure that both new_password values match
password_utils::validate_new_passwords(new_password1, new_password2)?;
// hash the password and save the hash to file
password_utils::set_new_password(new_password1)?;
Ok(())
}
/// Parse current and new passwords from the submitted form, save the new
/// password hash to file (`/var/lib/peachcloud/config.yml`) and redirect
/// to the change password form URL.
pub fn handle_form(request: &Request) -> Response {
// query the request body for form data
// return a 400 error if the admin_id field is missing
let data = try_or_400!(post_input!(request, {
current_password: String,
new_password1: String,
new_password2: String,
}));
// save submitted admin id to file
// match on the result and set flash name and msg accordingly
let (flash_name, flash_msg) = match save_password(
&data.current_password,
&data.new_password1,
&data.new_password2,
) {
Ok(_) => (
// <cookie-name>=<cookie-value>
"flash_name=success".to_string(),
"flash_msg=New password has been saved".to_string(),
),
Err(err) => (
"flash_name=error".to_string(),
format!("flash_msg=Failed to save new password: {}", err),
),
};
// set the flash cookie headers and redirect to the change password page
Response::redirect_303("/auth/change").add_flash(flash_name, flash_msg)
}

View File

@ -0,0 +1,56 @@
use maud::{html, PreEscaped};
use rouille::Request;
use crate::{
templates,
utils::{flash::FlashRequest, theme},
};
// ROUTE: /auth/forgot
/// Forgot password template builder.
pub fn build_template(request: &Request) -> PreEscaped<String> {
// check for flash cookies; will be (None, None) if no flash cookies are found
let (flash_name, flash_msg) = request.retrieve_flash();
let password_reset_template = html! {
(PreEscaped("<!-- PASSWORD RESET REQUEST CARD -->"))
div class="card center" {
div class="capsule capsule-container border-info" {
p class="card-text" {
"Click the 'Send Temporary Password' button to send a new temporary password which can be used to change your device password."
}
p class="card-text" style="margin-top: 1rem;" {
"The temporary password will be sent in an SSB private message to the admin of this device."
}
p class="card-text" style="margin-top: 1rem;" {
"Once you have the temporary password, click the 'Set New Password' button to reach the password reset page."
}
}
form id="sendPasswordReset" action="/auth/temporary" method="post" {
div id="buttonDiv" {
input class="button button-primary center" style="margin-top: 1rem;" type="submit" value="Send Temporary Password" title="Send temporary password to Scuttlebutt admin(s)";
a href="/auth/reset_password" class="button button-primary center" title="Set a new password using the temporary password" {
"Set New Password"
}
}
}
// render flash message if cookies were found in the request
@if let (Some(name), Some(msg)) = (flash_name, flash_msg) {
(PreEscaped("<!-- FLASH MESSAGE -->"))
(templates::flash::build_template(name, msg))
}
}
};
// wrap the nav bars around the settings menu template content
// parameters are template, title and back url
let body =
templates::nav::build_template(password_reset_template, "Send Password Reset", Some("/"));
// query the current theme so we can pass it into the base template builder
let theme = theme::get_theme();
// render the base template with the provided body
templates::base::build_template(body, theme)
}

View File

@ -0,0 +1,87 @@
use log::debug;
use maud::{html, PreEscaped};
use peach_lib::password_utils;
use rouille::{post_input, try_or_400, Request, Response};
use crate::{
templates,
utils::{
flash::{FlashRequest, FlashResponse},
theme,
},
SessionData,
};
// HELPER AND ROUTES FOR /auth/login (GET and POST)
/// Login form template builder.
pub fn build_template(request: &Request) -> PreEscaped<String> {
// check for flash cookies; will be (None, None) if no flash cookies are found
let (flash_name, flash_msg) = request.retrieve_flash();
let form_template = html! {
(PreEscaped("<!-- LOGIN FORM -->"))
div class="card center" {
form id="login_form" class="center" action="/auth/login" method="post" {
div style="display: flex; flex-direction: column; margin-bottom: 1rem;" {
(PreEscaped("<!-- input for password -->"))
label for="password" class="center label-small font-gray" style="width: 80%;" { "PASSWORD" }
input id="password" name="password" class="center input" type="password" title="Password for given username" autofocus;
(PreEscaped("<!-- login (form submission) button -->"))
input id="loginUser" class="button button-primary center" title="Login" type="submit" value="Login";
div class="center-text" style="margin-top: 1rem;" {
a href="/auth/forgot" class="label-small link font-gray" { "Forgot Password?" }
}
}
}
// render flash message if cookies were found in the request
@if let (Some(name), Some(msg)) = (flash_name, flash_msg) {
(PreEscaped("<!-- FLASH MESSAGE -->"))
(templates::flash::build_template(name, msg))
}
}
};
// wrap the nav bars around the settings menu template content
// parameters are template, title and back url
let body = templates::nav::build_template(form_template, "Login", Some("/"));
// query the current theme so we can pass it into the base template builder
let theme = theme::get_theme();
// render the base template with the provided body
templates::base::build_template(body, theme)
}
/// Parse and verify the submitted password. If verification succeeds, set the
/// auth session cookie and redirect to the home page. If not, set a flash
/// message and redirect to the login page.
pub fn handle_form(request: &Request, session_data: &mut Option<SessionData>) -> Response {
// query the request body for form data
// return a 400 error if the admin_id field is missing
let data = try_or_400!(post_input!(request, { password: String }));
match password_utils::verify_password(&data.password) {
Ok(_) => {
debug!("Successful login attempt");
// if password verification is successful, write to `session_data`
// to authenticate the user
*session_data = Some(SessionData {
_login: "success".to_string(),
});
Response::redirect_303("/")
}
Err(err) => {
debug!("Unsuccessful login attempt");
let err_msg = format!("Invalid password: {}", err);
let (flash_name, flash_msg) = (
"flash_name=error".to_string(),
format!("flash_msg={}", err_msg),
);
// if unsuccessful login, render /login page again
Response::redirect_303("/auth/login").add_flash(flash_name, flash_msg)
}
}
}

View File

@ -0,0 +1,23 @@
use log::info;
use rouille::Response;
use crate::{utils::flash::FlashResponse, SessionData};
// ROUTE: /auth/logout (GET)
/// Deauthenticate the logged-in user by erasing the session data.
/// Redirect to the login page.
pub fn deauthenticate(session_data: &mut Option<SessionData>) -> Response {
info!("Attempting deauthentication of user.");
// erase the content of `session_data` to deauthenticate the user
*session_data = None;
let (flash_name, flash_msg) = (
"flash_name=success".to_string(),
"flash_msg=Logged out".to_string(),
);
// set the flash cookie headers and redirect to the login page
Response::redirect_303("/auth/login".to_string()).add_flash(flash_name, flash_msg)
}

View File

@ -0,0 +1,6 @@
pub mod change;
pub mod forgot;
pub mod login;
pub mod logout;
pub mod reset;
pub mod temporary;

View File

@ -0,0 +1,114 @@
use log::info;
use maud::{html, PreEscaped};
use peach_lib::password_utils;
use rouille::{post_input, try_or_400, Request, Response};
use crate::{
error::PeachWebError,
templates,
utils::{
flash::{FlashRequest, FlashResponse},
theme,
},
};
// HELPER AND ROUTES FOR /auth/reset (GET and POST)
/// Password reset form template builder.
pub fn build_template(request: &Request) -> PreEscaped<String> {
// check for flash cookies; will be (None, None) if no flash cookies are found
let (flash_name, flash_msg) = request.retrieve_flash();
let form_template = html! {
(PreEscaped("<!-- RESET PASSWORD PAGE -->"))
div class="card center" {
form id="resetPassword" class="center" action="/auth/reset" method="post" {
div style="display: flex; flex-direction: column; margin-bottom: 1rem;" {
(PreEscaped("<!-- input for temporary password -->"))
label for="temporaryPassword" class="center label-small font-gray" style="width: 80%;" { "TEMPORARY PASSWORD" }
input id="temporaryPassword" class="center input" name="temporary_password" type="password" title="Temporary password" autofocus;
(PreEscaped("<!-- input for new password1 -->"))
label for="newPassword" class="center label-small font-gray" style="width: 80%;" { "NEW PASSWORD" }
input id="newPassword" class="center input" name="new_password1" type="password" title="New password";
(PreEscaped("<!-- input for duplicate new password -->"))
label for="newPasswordDuplicate" class="center label-small font-gray" style="width: 80%;" { "RE-ENTER NEW PASSWORD" }
input id="newPasswordDuplicate" class="center input" name="new_password2" type="password" title="New password duplicate";
(PreEscaped("<!-- save (form submission) button -->"))
input id="savePassword" class="button button-primary center" title="Add" type="submit" value="Save";
a class="button button-secondary center" href="/settings/admin" title="Cancel"{ "Cancel" }
}
}
// render flash message if cookies were found in the request
@if let (Some(name), Some(msg)) = (flash_name, flash_msg) {
(PreEscaped("<!-- FLASH MESSAGE -->"))
(templates::flash::build_template(name, msg))
}
}
};
// wrap the nav bars around the settings menu template content
// parameters are template, title and back url
let body =
templates::nav::build_template(form_template, "Reset Password", Some("/settings/admin"));
// query the current theme so we can pass it into the base template builder
let theme = theme::get_theme();
// render the base template with the provided body
templates::base::build_template(body, theme)
}
/// Verify, validate and set a new password, overwriting the current password.
pub fn save_password(
temporary_password: &str,
new_password1: &str,
new_password2: &str,
) -> Result<(), PeachWebError> {
info!(
"Attempting password reset: {} {} {}",
temporary_password, new_password1, new_password2
);
// check that the supplied value matches the actual temporary password
password_utils::verify_temporary_password(temporary_password)?;
// ensure that both new_password values match
password_utils::validate_new_passwords(new_password1, new_password2)?;
// hash the password and save the hash to file
password_utils::set_new_password(new_password1)?;
Ok(())
}
/// Parse temporary and new passwords from the submitted form, save the new
/// password hash to file (`/var/lib/peachcloud/config.yml`) and redirect
/// to the reset password form URL.
pub fn handle_form(request: &Request) -> Response {
// query the request body for form data
// return a 400 error if the admin_id field is missing
let data = try_or_400!(post_input!(request, {
temporary_password: String,
new_password1: String,
new_password2: String,
}));
// save submitted admin id to file
let (flash_name, flash_msg) = match save_password(
&data.temporary_password,
&data.new_password1,
&data.new_password2,
) {
Ok(_) => (
"flash_name=success".to_string(),
"flash_msg=New password has been saved. Return home to login".to_string(),
),
Err(err) => (
"flash_name=error".to_string(),
format!("flash_msg=Failed to reset password: {}", err),
),
};
// redirect to the configure admin page
Response::redirect_303("/auth/reset").add_flash(flash_name, flash_msg)
}

View File

@ -0,0 +1,42 @@
use log::debug;
use peach_lib::password_utils;
use rouille::Response;
use crate::utils::flash::FlashResponse;
// ROUTE: /auth/temporary (POST)
/// Send a temporary password as a Scuttlebutt private message to the admin(s).
///
/// This route is used by a user who is not logged in and is specifically for
/// users who have forgotten their password. A successful request results
/// in a Scuttlebutt private message being sent to the account of the device
/// admin.
///
/// Redirects to the Send Password Reset page a flash message describing the
/// outcome of the action (may be successful or unsuccessful).
pub fn handle_form() -> Response {
// save submitted admin id to file
let (flash_name, flash_msg) = match password_utils::send_password_reset() {
Ok(_) => {
debug!("Sent temporary password to device admin(s)");
(
"flash_name=success".to_string(),
"flash_msg=A temporary password has been sent to the admin(s) of this device"
.to_string(),
)
}
Err(err) => {
debug!(
"Received an error while trying to send temporary password to device admin(s): {}",
err
);
(
"error".to_string(),
format!("Failed to send temporary password: {}", err),
)
}
};
Response::redirect_303("/auth/forgot").add_flash(flash_name, flash_msg)
}

View File

@ -1,60 +0,0 @@
use log::debug;
use rocket::catch;
use rocket::response::Redirect;
use rocket_dyn_templates::Template;
use serde::Serialize;
// HELPERS AND ROUTES FOR 404 ERROR
#[derive(Debug, Serialize)]
pub struct ErrorContext {
pub back: Option<String>,
pub flash_name: Option<String>,
pub flash_msg: Option<String>,
pub title: Option<String>,
}
impl ErrorContext {
pub fn build() -> ErrorContext {
ErrorContext {
back: None,
flash_name: None,
flash_msg: None,
title: None,
}
}
}
#[catch(404)]
pub fn not_found() -> Template {
debug!("404 Page Not Found");
let mut context = ErrorContext::build();
context.back = Some("/".to_string());
context.title = Some("404: Page Not Found".to_string());
context.flash_name = Some("error".to_string());
context.flash_msg = Some("No resource found for given URL".to_string());
Template::render("catchers/not_found", context)
}
// HELPERS AND ROUTES FOR 500 ERROR
#[catch(500)]
pub fn internal_error() -> Template {
debug!("500 Internal Server Error");
let mut context = ErrorContext::build();
context.back = Some("/".to_string());
context.title = Some("500: Internal Server Error".to_string());
context.flash_name = Some("error".to_string());
context.flash_msg = Some("Internal server error".to_string());
Template::render("catchers/internal_error", context)
}
// HELPERS AND ROUTES FOR 403 FORBIDDEN
#[catch(403)]
pub fn forbidden() -> Redirect {
debug!("403 Forbidden");
Redirect::to("/login")
}

View File

@ -0,0 +1,106 @@
use maud::{html, PreEscaped};
use crate::{templates, utils::theme};
/// Guide template builder.
pub fn build_template() -> PreEscaped<String> {
// render the guide template html
let guide_template = html! {
(PreEscaped("<!-- GUIDE -->"))
div class="card card-wide center" {
div class="capsule capsule-container border-info" {
(PreEscaped("<!-- GETTING STARTED -->"))
details {
summary class="card-text link" { "Getting started" }
p class="card-text" style="margin-top: 1rem; margin-bottom: 1rem;" {
"The Scuttlebutt server (sbot) will be inactive when you first run PeachCloud. This is to allow configuration parameters to be set before it is activated for the first time. Navigate to the "
strong {
a href="/settings/scuttlebutt/configure" class="link font-gray" {
"Sbot Configuration"
}
}
" page to configure your system. The default configuration will be fine for most usecases."
}
p class="card-text" style="margin-top: 1rem; margin-bottom: 1rem;" {
"Once the configuration is set, navigate to the "
strong {
a href="/settings/scuttlebutt" class="link font-gray" {
"Scuttlebutt settings menu"
}
}
" to start the sbot. If the server starts successfully, you will see a green smiley face on the home page. If the face is orange and sleeping, that means the sbot is still inactive (ie. the process is not running). If the face is red and dead, that means the sbot failed to start - indicated an error. For now, the best way to gain insight into the problem is to check the systemd log. Open a terminal and enter: "
code { "systemctl --user status go-sbot.service" }
". The log output may give some clues about the source of the error."
}
}
(PreEscaped("<!-- BUG REPORTS -->"))
details {
summary class="card-text link" { "Submit a bug report" }
p class="card-text" style="margin-top: 1rem; margin-bottom: 1rem;" {
"Bug reports can be submitted by "
strong {
a href="https://git.coopcloud.tech/PeachCloud/peach-workspace/issues/new?template=BUG_TEMPLATE.md" class="link font-gray" {
"filing an issue"
}
}
" on the peach-workspace git repo. Before filing a report, first check to see if an issue already exists for the bug you've encountered. If not, you're invited to submit a new report; the template will guide you through several questions."
}
}
(PreEscaped("<!-- REQUEST SUPPORT -->"))
details {
summary class="card-text link" { "Share feedback & request support" }
p class="card-text" style="margin-top: 1rem; margin-bottom: 1rem;" {
"You're invited to share your thoughts and experiences of PeachCloud in the #peachcloud channel on Scuttlebutt. The channel is also a good place to ask for help."
}
p class="card-text" style="margin-top: 1rem; margin-bottom: 1rem;" {
"Alternatively, we have a "
strong {
a href="https://matrix.to/#/#peachcloud:matrix.org" class="link font-gray" {
"Matrix channel"
}
}
" for discussion about PeachCloud and you can also reach out to @glyph "
strong {
a href="mailto:glyph@mycelial.technology" class="link font-gray" {
"via email"
}
}
"."
}
}
(PreEscaped("<!-- CONTRIBUTE -->"))
details {
summary class="card-text link" { "Contribute to PeachCloud" }
p class="card-text" style="margin-top: 1rem; margin-bottom: 1rem;" {
"PeachCloud is free, open-source software and relies on donations and grants to fund develop. Donations can be made on our "
strong {
a href="https://opencollective.com/peachcloud" class="link font-gray" {
"Open Collective"
}
}
" page."
}
p class="card-text" style="margin-top: 1rem; margin-bottom: 1rem;" {
"Programmers, designers, artists and writers are also welcome to contribute to the project. Please visit the "
strong {
a href="https://git.coopcloud.tech/PeachCloud/peach-workspace" class="link font-gray" {
"main PeachCloud git repository"
}
}
" to find out more details or contact the team via Scuttlebutt, Matrix or email."
}
}
}
}
};
// wrap the nav bars around the home template content
// title is "" and back button link is `None` because this is the homepage
let body = templates::nav::build_template(guide_template, "Guide", Some("/"));
// query the current theme so we can pass it into the base template builder
let theme = theme::get_theme();
// render the base template with the provided body
templates::base::build_template(body, theme)
}

View File

@ -0,0 +1,105 @@
use maud::{html, PreEscaped};
use peach_lib::sbot::SbotStatus;
use crate::{templates, utils::theme};
/// Read the state of the go-sbot process and define status-related
/// elements accordingly.
fn render_status_elements<'a>() -> (&'a str, &'a str, &'a str) {
// retrieve go-sbot systemd process status
let sbot_status = SbotStatus::read();
// conditionally render the center circle class, center circle text and
// status circle class color based on the go-sbot process state
if let Ok(status) = sbot_status {
if status.state == Some("active".to_string()) {
("circle-success", "^_^", "border-success")
} else if status.state == Some("inactive".to_string()) {
("circle-warning", "z_z", "border-warning")
} else {
("circle-error", "x_x", "border-danger")
}
} else {
("circle-error", "x_x", "border-danger")
}
}
/// Home template builder.
pub fn build_template() -> PreEscaped<String> {
let (circle_color, center_circle_text, circle_border) = render_status_elements();
// render the home template html
let home_template = html! {
(PreEscaped("<!-- RADIAL MENU -->"))
div class="grid" {
(PreEscaped("<!-- top-left -->"))
(PreEscaped("<!-- PEERS LINK AND ICON -->"))
a class="top-left" href="/scuttlebutt/peers" title="Scuttlebutt Peers" {
div class="circle circle-small border-circle-small border-ssb" {
img class="icon-medium" src="/icons/users.svg";
}
}
(PreEscaped("<!-- top-middle -->"))
(PreEscaped("<!-- CURRENT USER LINK AND ICON -->"))
a class="top-middle" href="/scuttlebutt/profile" title="Profile" {
div class="circle circle-small border-circle-small border-ssb" {
img class="icon-medium" src="/icons/user.svg";
}
}
(PreEscaped("<!-- top-right -->"))
(PreEscaped("<!-- MESSAGES LINK AND ICON -->"))
a class="top-right" href="/scuttlebutt/private" title="Private Messages" {
div class="circle circle-small border-circle-small border-ssb" {
img class="icon-medium" src="/icons/envelope.svg";
}
}
(PreEscaped("<!-- middle -->"))
a class="middle" {
div class={ "circle circle-large " (circle_color) } {
p style="font-size: 4rem; color: var(--near-black);" {
(center_circle_text)
}
}
}
(PreEscaped("<!-- bottom-left -->"))
(PreEscaped("<!-- SYSTEM STATUS LINK AND ICON -->"))
a class="bottom-left" href="/status/scuttlebutt" title="Status" {
div class={ "circle circle-small border-circle-small " (circle_border) } {
img class="icon-medium" src="/icons/heart-pulse.svg";
}
}
/*
TODO: render the path of the status circle button based on the mode
{%- if standalone_mode == true -%}
<a class="bottom-left" href="/status/scuttlebutt" title="Status">
{% else -%}
<a class="bottom-left" href="/status" title="Status">
{%- endif -%}
*/
(PreEscaped("<!-- bottom-middle -->"))
(PreEscaped("<!-- PEACHCLOUD GUIDEBOOK LINK AND ICON -->"))
a class="bottom-middle" href="/guide" title="Guide" {
div class="circle circle-small border-circle-small border-info" {
img class="icon-medium" src="/icons/book.svg";
}
}
(PreEscaped("<!-- bottom-right -->"))
(PreEscaped("<!-- SYSTEM SETTINGS LINK AND ICON -->"))
a class="bottom-right" href="/settings" title="Settings" {
div class="circle circle-small border-circle-small border-settings" {
img class="icon-medium" src="/icons/cog.svg";
}
}
}
};
// wrap the nav bars around the home template content
// title is "" and back button link is `None` because this is the homepage
let body = templates::nav::build_template(home_template, "", None);
// query the current theme so we can pass it into the base template builder
let theme = theme::get_theme();
// render the base template with the provided body
templates::base::build_template(body, theme)
}

View File

@ -1,51 +0,0 @@
use peach_lib::sbot::SbotStatus;
use rocket::{get, request::FlashMessage, State};
use rocket_dyn_templates::{tera::Context, Template};
use crate::routes::authentication::Authenticated;
use crate::utils;
use crate::RocketConfig;
// HELPERS AND ROUTES FOR / (HOME PAGE)
#[get("/")]
pub fn home(_auth: Authenticated, config: &State<RocketConfig>) -> Template {
// retrieve current ui theme
let theme = utils::get_theme();
// retrieve go-sbot systemd process status
let sbot_status = SbotStatus::read().ok();
let mut context = Context::new();
context.insert("theme", &theme);
context.insert("sbot_status", &sbot_status);
context.insert("flash_name", &None::<()>);
context.insert("flash_msg", &None::<()>);
context.insert("title", &None::<()>);
// pass in mode from managed state so we can define appropriate urls in template
context.insert("standalone_mode", &config.standalone_mode);
Template::render("home", &context.into_json())
}
// HELPERS AND ROUTES FOR /guide
#[get("/guide")]
pub fn guide(flash: Option<FlashMessage>) -> Template {
// retrieve current ui theme
let theme = utils::get_theme();
let mut context = Context::new();
context.insert("theme", &theme);
context.insert("back", &Some("/".to_string()));
context.insert("title", &Some("Guide".to_string()));
// check to see if there is a flash message to display
if let Some(flash) = flash {
context.insert("flash_name", &Some(flash.kind().to_string()));
context.insert("flash_msg", &Some(flash.message().to_string()));
};
Template::render("guide", &context.into_json())
}

View File

@ -1,6 +1,8 @@
pub mod authentication;
pub mod catchers;
pub mod index;
//pub mod catchers;
//pub mod index;
pub mod guide;
pub mod home;
pub mod scuttlebutt;
pub mod settings;
pub mod status;

View File

@ -1,946 +0,0 @@
//! Routes for Scuttlebutt related functionality.
use log::debug;
use peach_lib::sbot::{SbotConfig, SbotStatus};
use rocket::{
form::{Form, FromForm},
fs::TempFile,
get, post,
request::FlashMessage,
response::{Flash, Redirect},
uri,
};
use rocket_dyn_templates::{tera::Context, Template};
use crate::{
context::{
scuttlebutt,
scuttlebutt::{
BlocksContext, FollowsContext, FriendsContext, PrivateContext, ProfileContext,
},
},
routes::authentication::Authenticated,
utils,
};
// HELPER FUNCTIONS
/// Check to see if the go-sbot.service process is currently active.
/// Return an error in the form of a `String` if the process
/// check command fails. Otherwise, return the state of the process.
fn is_sbot_active() -> Result<bool, String> {
// retrieve go-sbot systemd process status
let sbot_status = match SbotStatus::read() {
Ok(status) => status,
Err(e) => return Err(format!("Failed to read sbot status: {}", e)),
};
if sbot_status.state == Some("active".to_string()) {
Ok(true)
} else {
Ok(false)
}
}
/// Ensure that the given public key is a valid ed25519 key.
fn validate_public_key(public_key: &str, redirect_url: String) -> Result<(), Flash<Redirect>> {
// ensure the id starts with the correct sigil link
if !public_key.starts_with('@') {
return Err(Flash::error(
Redirect::to(redirect_url),
"Invalid key: expected '@' sigil as first character",
));
}
// find the dot index denoting the start of the algorithm definition tag
let dot_index = match public_key.rfind('.') {
Some(index) => index,
None => {
return Err(Flash::error(
Redirect::to(redirect_url),
"Invalid key: no dot index was found",
))
}
};
// check hashing algorithm (must end with ".ed25519")
if !&public_key.ends_with(".ed25519") {
return Err(Flash::error(
Redirect::to(redirect_url),
"Invalid key: hashing algorithm must be ed25519",
));
}
// obtain the base64 portion (substring) of the public key
let base64_str = &public_key[1..dot_index];
// length of a base64 encoded ed25519 public key
if base64_str.len() != 44 {
return Err(Flash::error(
Redirect::to(redirect_url),
"Invalid key: base64 data length is incorrect",
));
}
Ok(())
}
// HELPERS AND ROUTES FOR INVITES
#[get("/invites")]
pub fn invites(flash: Option<FlashMessage>, _auth: Authenticated) -> Template {
// retrieve current ui theme
let theme = utils::get_theme();
let mut context = Context::new();
context.insert("theme", &theme);
context.insert("back", &Some("/scuttlebutt/peers".to_string()));
context.insert("title", &Some("Invites".to_string()));
// check to see if there is a flash message to display
if let Some(flash) = flash {
match flash.kind() {
// we've been passed a freshly-generated invite code (redirect from post)
"code" => {
context.insert("invite_code", &Some(flash.message().to_string()));
context.insert("flash_name", &Some("success".to_string()));
context.insert("flash_msg", &Some("Generated invite code".to_string()));
}
_ => {
// add flash message contents to the context object
context.insert("flash_name", &Some(flash.kind().to_string()));
context.insert("flash_msg", &Some(flash.message().to_string()));
}
}
};
Template::render("scuttlebutt/invites", &context.into_json())
}
#[derive(Debug, FromForm)]
pub struct Invite {
pub uses: u16,
}
#[post("/invites", data = "<invite>")]
pub async fn create_invite(invite: Form<Invite>, _auth: Authenticated) -> Flash<Redirect> {
let uses = invite.uses;
let url = uri!("/scuttlebutt", invites);
// we only want to try and interact with the sbot if it's active
match is_sbot_active() {
Ok(true) => {
// retrieve latest go-sbot configuration parameters
let sbot_config = SbotConfig::read().ok();
// initialise sbot connection with ip:port and shscap from config file
match scuttlebutt::init_sbot_with_config(&sbot_config).await {
Ok(mut sbot_client) => {
debug!("Generating Scuttlebutt invite code");
match sbot_client.invite_create(uses).await {
// construct a custom flash msg to pass along the invite code
Ok(code) => Flash::new(Redirect::to(url), "code", code),
Err(e) => Flash::error(
Redirect::to(url),
format!("Failed to create invite code: {}", e),
),
}
}
Err(e) => Flash::error(
Redirect::to(url),
format!("Failed to initialise sbot: {}", e),
),
}
}
Ok(false) => {
return Flash::warning(
Redirect::to(url),
"The Sbot is inactive. New invite codes cannot be generated. Visit the Scuttlebutt settings menu to start the Sbot and then try again",
)
}
// failed to retrieve go-sbot systemd process status
Err(e) => return Flash::error(Redirect::to(url), e)
}
}
// HELPERS AND ROUTES FOR /private
/// A private message composition and publication page.
#[get("/private?<public_key>")]
pub async fn private(
mut public_key: Option<String>,
flash: Option<FlashMessage<'_>>,
_auth: Authenticated,
) -> Template {
// display a helpful message if the sbot is inactive
if let Ok(false) = is_sbot_active() {
// retrieve current ui theme
let theme = utils::get_theme();
let mut context = Context::new();
context.insert("theme", &theme);
context.insert("back", &Some("/".to_string()));
context.insert("title", &Some("Private Messages".to_string()));
context.insert(
"unavailable_msg",
&Some("Private messages cannot be published.".to_string()),
);
// render the "sbot is inactive" template
return Template::render("scuttlebutt/inactive", &context.into_json());
// otherwise, build the full context and render the private message template
} else {
if let Some(ref key) = public_key {
// `url_decode` replaces '+' with ' ', so we need to revert that
public_key = Some(key.replace(' ', "+"));
}
// build the private context object
let context = PrivateContext::build(public_key).await;
match context {
// we were able to build the context without errors
Ok(mut context) => {
// check to see if there is a flash message to display
if let Some(flash) = flash {
// add flash message contents to the context object
context.flash_name = Some(flash.kind().to_string());
context.flash_msg = Some(flash.message().to_string());
};
Template::render("scuttlebutt/private", &context)
}
// an error occurred while building the context
Err(e) => {
// build the default context and pass along the error message
let mut context = PrivateContext::default();
context.flash_name = Some("error".to_string());
context.flash_msg = Some(e.to_string());
Template::render("scuttlebutt/private", &context)
}
}
}
}
#[derive(Debug, FromForm)]
pub struct Private {
pub id: String,
pub text: String,
pub recipient: String,
}
/// Publish a private message.
#[post("/private", data = "<private>")]
pub async fn private_post(private: Form<Private>, _auth: Authenticated) -> Flash<Redirect> {
let url = uri!("/scuttlebutt", private(None::<String>));
// we only want to try and interact with the sbot if it's active
match is_sbot_active() {
Ok(true) => {
// retrieve latest go-sbot configuration parameters
let sbot_config = SbotConfig::read().ok();
let id = &private.id;
let text = &private.text;
let recipient = &private.recipient;
// now we need to add the local id to the recipients vector,
// otherwise the local id will not be able to read the message.
let recipients = vec![id.to_string(), recipient.to_string()];
// initialise sbot connection with ip:port and shscap from config file
match scuttlebutt::init_sbot_with_config(&sbot_config).await {
Ok(mut sbot_client) => {
debug!("Publishing a new Scuttlebutt private message");
match sbot_client
.publish_private(text.to_string(), recipients)
.await
{
Ok(_) => {
Flash::success(Redirect::to(url), format!("Published private message"))
}
Err(e) => Flash::error(
Redirect::to(url),
format!("Failed to publish private message: {}", e),
),
}
}
Err(e) => Flash::error(
Redirect::to(url),
format!("Failed to initialise sbot: {}", e),
),
}
}
Ok(false) => {
return Flash::warning(
Redirect::to(url),
"The Sbot is inactive. New private message cannot be published. Visit the Scuttlebutt settings menu to start the Sbot and then try again",
);
}
// failed to retrieve go-sbot systemd process status
Err(e) => return Flash::error(Redirect::to(url), e),
}
}
// HELPERS AND ROUTES FOR /search
/// Search for a peer.
#[get("/search")]
pub fn search(flash: Option<FlashMessage>, _auth: Authenticated) -> Template {
// retrieve current ui theme
let theme = utils::get_theme();
// retrieve go-sbot systemd process status
let sbot_status = SbotStatus::read().ok();
let mut context = Context::new();
context.insert("theme", &theme);
context.insert("sbot_status", &sbot_status);
context.insert("title", &Some("Search"));
context.insert("back", &Some("/scuttlebutt/peers"));
// check to see if there is a flash message to display
if let Some(flash) = flash {
// add flash message contents to the context object
context.insert("flash_name", &Some(flash.kind().to_string()));
context.insert("flash_msg", &Some(flash.message().to_string()));
};
Template::render("scuttlebutt/search", &context.into_json())
}
#[derive(Debug, FromForm)]
pub struct Peer {
// public key
pub public_key: String,
}
/// Accept the peer search form and redirect to the profile for that peer.
#[post("/search", data = "<peer>")]
pub async fn search_post(peer: Form<Peer>, _auth: Authenticated) -> Flash<Redirect> {
let public_key = &peer.public_key;
let search_url = "/scuttlebutt/search".to_string();
// validate the key before redirecting to profile url
if let Err(flash) = validate_public_key(&public_key, search_url) {
// redirect with error message
return flash;
}
// key has not been validated and we can redirect to the profile page
let profile_url = uri!("/scuttlebutt", profile(Some(public_key)));
Flash::new(
Redirect::to(profile_url),
// this flash msg will not be displayed in the receiving template
"ignore",
"Public key validated for profile lookup",
)
}
// HELPERS AND ROUTES FOR /peers
/// A peer menu which allows navigating to lists of friends, follows, followers
/// and blocks, as well as accessing the invite creation form.
#[get("/peers")]
pub fn peers(flash: Option<FlashMessage>, _auth: Authenticated) -> Template {
// retrieve current ui theme
let theme = utils::get_theme();
let mut context = Context::new();
context.insert("theme", &theme);
context.insert("title", &Some("Scuttlebutt Peers"));
context.insert("back", &Some("/"));
// display a helpful message if the sbot is inactive
if let Ok(false) = is_sbot_active() {
context.insert(
"unavailable_msg",
&Some("Social lists and interactions are unavailable.".to_string()),
);
// render the "sbot is inactive" template
return Template::render("scuttlebutt/inactive", &context.into_json());
} else {
context.insert("sbot_state", &Some("active".to_string()));
// check to see if there is a flash message to display
if let Some(flash) = flash {
// add flash message contents to the context object
context.insert("flash_name", &Some(flash.kind().to_string()));
context.insert("flash_msg", &Some(flash.message().to_string()));
};
}
Template::render("scuttlebutt/peers", &context.into_json())
}
// HELPERS AND ROUTES FOR /post/publish
#[derive(Debug, FromForm)]
pub struct Post {
pub text: String,
}
/// Publish a public Scuttlebutt post.
/// Redirects to profile page of the PeachCloud local identity with a flash
/// message describing the outcome of the action (may be successful or
/// unsuccessful).
#[post("/publish", data = "<post>")]
pub async fn publish(post: Form<Post>, _auth: Authenticated) -> Flash<Redirect> {
let post_text = &post.text;
let url = uri!("/scuttlebutt", profile(None::<String>));
// we only want to try and interact with the sbot if it's active
match is_sbot_active() {
Ok(true) => {
// retrieve latest go-sbot configuration parameters
let sbot_config = SbotConfig::read().ok();
// initialise sbot connection with ip:port and shscap from config file
match scuttlebutt::init_sbot_with_config(&sbot_config).await {
Ok(mut sbot_client) => {
debug!("Publishing new Scuttlebutt public post");
match sbot_client.publish_post(post_text).await {
Ok(_) => Flash::success(Redirect::to(url), format!("Published post")),
Err(e) => Flash::error(
Redirect::to(url),
format!("Failed to publish post: {}", e),
),
}
}
Err(e) => Flash::error(
Redirect::to(url),
format!("Failed to initialise sbot: {}", e),
),
}
}
Ok(false) => {
return Flash::warning(
Redirect::to(url),
"The Sbot is inactive. New posts cannot be published. Visit the Scuttlebutt settings menu to start the Sbot and then try again",
);
}
Err(e) => return Flash::error(Redirect::to(url), e),
}
}
// HELPERS AND ROUTES FOR /follow
/// Follow a Scuttlebutt profile specified by the given public key.
/// Redirects to the appropriate profile page with a flash message describing
/// the outcome of the action (may be successful or unsuccessful).
#[post("/follow", data = "<peer>")]
pub async fn follow(peer: Form<Peer>, _auth: Authenticated) -> Flash<Redirect> {
let public_key = &peer.public_key;
let url = uri!("/scuttlebutt", profile(Some(public_key)));
// we only want to try and interact with the sbot if it's active
match is_sbot_active() {
Ok(true) => {
// retrieve latest go-sbot configuration parameters
let sbot_config = SbotConfig::read().ok();
// initialise sbot connection with ip:port and shscap from config file
match scuttlebutt::init_sbot_with_config(&sbot_config).await {
Ok(mut sbot_client) => {
debug!("Following Scuttlebutt peer");
match sbot_client.follow(public_key).await {
Ok(_) => Flash::success(Redirect::to(url), format!("Followed peer")),
Err(e) => {
Flash::error(Redirect::to(url), format!("Failed to follow peer: {}", e))
}
}
}
Err(e) => Flash::error(
Redirect::to(url),
format!("Failed to initialise sbot: {}", e),
),
}
}
Ok(false) => {
return Flash::warning(
Redirect::to(url),
"The Sbot is inactive. Follow messages cannot be published. Visit the Scuttlebutt settings menu to start the Sbot and then try again",
);
}
Err(e) => return Flash::error(Redirect::to(url), e),
}
}
// HELPERS AND ROUTES FOR /unfollow
/// Unfollow a Scuttlebutt profile specified by the given public key.
/// Redirects to the appropriate profile page with a flash message describing
/// the outcome of the action (may be successful or unsuccessful).
#[post("/unfollow", data = "<peer>")]
pub async fn unfollow(peer: Form<Peer>, _auth: Authenticated) -> Flash<Redirect> {
let public_key = &peer.public_key;
let url = uri!("/scuttlebutt", profile(Some(public_key)));
// we only want to try and interact with the sbot if it's active
match is_sbot_active() {
Ok(true) => {
// retrieve latest go-sbot configuration parameters
let sbot_config = SbotConfig::read().ok();
// initialise sbot connection with ip:port and shscap from config file
match scuttlebutt::init_sbot_with_config(&sbot_config).await {
Ok(mut sbot_client) => {
debug!("Unfollowing Scuttlebutt peer");
match sbot_client.unfollow(public_key).await {
Ok(_) => Flash::success(Redirect::to(url), format!("Unfollowed peer")),
Err(e) => Flash::error(
Redirect::to(url),
format!("Failed to unfollow peer: {}", e),
),
}
}
Err(e) => Flash::error(
Redirect::to(url),
format!("Failed to initialise sbot: {}", e),
),
}
}
Ok(false) => {
return Flash::warning(
Redirect::to(url),
"The Sbot is inactive. Follow messages cannot be published. Visit the Scuttlebutt settings menu to start the Sbot and then try again",
);
}
Err(e) => return Flash::error(Redirect::to(url), e),
}
}
// HELPERS AND ROUTES FOR /block
/// Block a Scuttlebutt profile specified by the given public key.
/// Redirects to the appropriate profile page with a flash message describing
/// the outcome of the action (may be successful or unsuccessful).
#[post("/block", data = "<peer>")]
pub async fn block(peer: Form<Peer>, _auth: Authenticated) -> Flash<Redirect> {
let public_key = &peer.public_key;
let url = uri!("/scuttlebutt", profile(Some(public_key)));
// we only want to try and interact with the sbot if it's active
match is_sbot_active() {
Ok(true) => {
// retrieve latest go-sbot configuration parameters
let sbot_config = SbotConfig::read().ok();
// initialise sbot connection with ip:port and shscap from config file
match scuttlebutt::init_sbot_with_config(&sbot_config).await {
Ok(mut sbot_client) => {
debug!("Blocking Scuttlebutt peer");
match sbot_client.block(public_key).await {
Ok(_) => Flash::success(Redirect::to(url), format!("Blocked peer")),
Err(e) => {
Flash::error(Redirect::to(url), format!("Failed to block peer: {}", e))
}
}
}
Err(e) => Flash::error(
Redirect::to(url),
format!("Failed to initialise sbot: {}", e),
),
}
}
Ok(false) => {
return Flash::warning(
Redirect::to(url),
"The Sbot is inactive. Follow messages cannot be published. Visit the Scuttlebutt settings menu to start the Sbot and then try again",
);
}
Err(e) => return Flash::error(Redirect::to(url), e),
}
}
// HELPERS AND ROUTES FOR /unblock
/// Unblock a Scuttlebutt profile specified by the given public key.
/// Redirects to the appropriate profile page with a flash message describing
/// the outcome of the action (may be successful or unsuccessful).
#[post("/unblock", data = "<peer>")]
pub async fn unblock(peer: Form<Peer>, _auth: Authenticated) -> Flash<Redirect> {
let public_key = &peer.public_key;
let url = uri!("/scuttlebutt", profile(Some(public_key)));
// we only want to try and interact with the sbot if it's active
match is_sbot_active() {
Ok(true) => {
// retrieve latest go-sbot configuration parameters
let sbot_config = SbotConfig::read().ok();
// initialise sbot connection with ip:port and shscap from config file
match scuttlebutt::init_sbot_with_config(&sbot_config).await {
Ok(mut sbot_client) => {
debug!("Unblocking Scuttlebutt peer");
match sbot_client.unblock(public_key).await {
Ok(_) => Flash::success(Redirect::to(url), format!("Unblocked peer")),
Err(e) => Flash::error(
Redirect::to(url),
format!("Failed to unblock peer: {}", e),
),
}
}
Err(e) => Flash::error(
Redirect::to(url),
format!("Failed to initialise sbot: {}", e),
),
}
}
Ok(false) => {
return Flash::warning(
Redirect::to(url),
"The Sbot is inactive. Follow messages cannot be published. Visit the Scuttlebutt settings menu to start the Sbot and then try again",
);
}
Err(e) => return Flash::error(Redirect::to(url), e),
}
}
// ROUTES FOR /profile
/// A Scuttlebutt profile, specified by a public key. It may be our own profile
/// or the profile of a peer. If the public key query parameter is not provided,
/// the local profile is displayed (ie. the profile of the public key associated
/// with the local PeachCloud device).
#[get("/profile?<public_key>")]
pub async fn profile(
mut public_key: Option<String>,
flash: Option<FlashMessage<'_>>,
_auth: Authenticated,
) -> Template {
// display a helpful message if the sbot is inactive
if let Ok(false) = is_sbot_active() {
// retrieve current ui theme
let theme = utils::get_theme();
let mut context = Context::new();
context.insert("theme", &theme);
context.insert("back", &Some("/".to_string()));
context.insert("title", &Some("Profile".to_string()));
context.insert(
"unavailable_msg",
&Some("Profile data cannot be retrieved.".to_string()),
);
// render the "sbot is inactive" template
return Template::render("scuttlebutt/inactive", &context.into_json());
} else {
if let Some(ref key) = public_key {
// `url_decode` replaces '+' with ' ', so we need to revert that
public_key = Some(key.replace(' ', "+"));
}
// build the profile context object
let context = ProfileContext::build(public_key).await;
match context {
// we were able to build the context without errors
Ok(mut context) => {
// check to see if there is a flash message to display
if let Some(flash) = flash {
// add flash message contents to the context object
context.flash_name = Some(flash.kind().to_string());
context.flash_msg = Some(flash.message().to_string());
};
Template::render("scuttlebutt/profile", &context)
}
// an error occurred while building the context
Err(e) => {
// build the default context and pass along the error message
let mut context = ProfileContext::default();
// flash name and msg will be `Some` if the sbot is inactive (in
// that case, they are set by the context builder).
// otherwise, we need to assign the name and returned error msg
// to the flash.
if context.flash_name.is_none() || context.flash_msg.is_none() {
context.flash_name = Some("error".to_string());
context.flash_msg = Some(e.to_string());
}
Template::render("scuttlebutt/profile", &context)
}
}
}
}
// HELPERS AND ROUTES FOR /profile/update
/// Serve a form for the purpose of updating the name, description and picture
/// for the local Scuttlebutt profile.
#[get("/profile/update")]
pub async fn update_profile(flash: Option<FlashMessage<'_>>, _auth: Authenticated) -> Template {
// display a helpful message if the sbot is inactive
if let Ok(false) = is_sbot_active() {
// retrieve current ui theme
let theme = utils::get_theme();
let mut context = Context::new();
context.insert("theme", &theme);
context.insert("back", &Some("/".to_string()));
context.insert("title", &Some("Profile".to_string()));
context.insert(
"unavailable_msg",
&Some("Profile data cannot be retrieved.".to_string()),
);
// render the "sbot is inactive" template
return Template::render("scuttlebutt/inactive", &context.into_json());
} else {
// build the profile context object
let context = ProfileContext::build(None).await;
match context {
// we were able to build the context without errors
Ok(mut context) => {
context.back = Some("/scuttlebutt/profile".to_string());
// check to see if there is a flash message to display
if let Some(flash) = flash {
// add flash message contents to the context object
context.flash_name = Some(flash.kind().to_string());
context.flash_msg = Some(flash.message().to_string());
};
Template::render("scuttlebutt/update_profile", &context)
}
// an error occurred while building the context
Err(e) => {
// build the default context and pass along the error message
let mut context = ProfileContext::default();
context.flash_name = Some("error".to_string());
context.flash_msg = Some(e.to_string());
Template::render("scuttlebutt/update_profile", &context)
}
}
}
}
#[derive(Debug, FromForm)]
pub struct Profile<'f> {
pub id: String,
pub current_name: String,
pub current_description: String,
pub new_name: String,
pub new_description: String,
pub image: TempFile<'f>,
}
/// Update the name, description and picture for the local Scuttlebutt profile.
/// Redirects to profile page of the PeachCloud local identity with a flash
/// message describing the outcome of the action (may be successful or
/// unsuccessful).
#[post("/profile/update", data = "<profile>")]
pub async fn update_profile_post(
mut profile: Form<Profile<'_>>,
_auth: Authenticated,
) -> Flash<Redirect> {
let url = uri!("/scuttlebutt", update_profile);
// we only want to try and interact with the sbot if it's active
match is_sbot_active() {
Ok(true) => {
// retrieve latest go-sbot configuration parameters
let sbot_config = SbotConfig::read().ok();
// initialise sbot connection with ip:port and shscap from config file
match scuttlebutt::init_sbot_with_config(&sbot_config).await {
Ok(mut sbot_client) => {
// track whether the name, description or image have been updated
let mut name_updated: bool = false;
let mut description_updated: bool = false;
let image_updated: bool;
// only update the name if it has changed
if profile.new_name != profile.current_name {
debug!("Publishing new Scuttlebutt profile name");
let publish_name_res = sbot_client.publish_name(&profile.new_name).await;
if publish_name_res.is_err() {
return Flash::error(
Redirect::to(url),
format!("Failed to update name: {}", publish_name_res.unwrap_err()),
);
} else {
name_updated = true
}
}
// only update the description if it has changed
if profile.new_description != profile.current_description {
debug!("Publishing new Scuttlebutt profile description");
let publish_description_res = sbot_client
.publish_description(&profile.new_description)
.await;
if publish_description_res.is_err() {
return Flash::error(
Redirect::to(url),
format!(
"Failed to update description: {}",
publish_description_res.unwrap_err()
),
);
} else {
description_updated = true
}
}
// only update the image if a file was uploaded
if profile.image.name().is_some() {
match utils::write_blob_to_store(&mut profile.image).await {
Ok(blob_id) => {
// if the file was successfully added to the blobstore,
// publish an about image message with the blob id
let publish_image_res = sbot_client.publish_image(&blob_id).await;
if publish_image_res.is_err() {
return Flash::error(
Redirect::to(url),
format!(
"Failed to update image: {}",
publish_image_res.unwrap_err()
),
);
} else {
image_updated = true
}
}
Err(e) => {
return Flash::error(
Redirect::to(url),
format!("Failed to add image to blobstore: {}", e),
)
}
}
} else {
image_updated = false
}
if name_updated || description_updated || image_updated {
return Flash::success(Redirect::to(url), "Profile updated");
} else {
// no updates were made but no errors were encountered either
return Flash::success(Redirect::to(url), "Profile info unchanged");
}
}
Err(e) => Flash::error(
Redirect::to(url),
format!("Failed to initialise sbot: {}", e),
),
}
}
Ok(false) => {
return Flash::warning(
Redirect::to(url),
"The Sbot is inactive. Profile data cannot be updated. Visit the Scuttlebutt settings menu to start the Sbot and then try again",
);
}
Err(e) => return Flash::error(Redirect::to(url), e),
}
}
// HELPERS AND ROUTES FOR /friends
/// A list of friends (mutual follows), with each list item displaying the
/// name, image and public key of the peer.
#[get("/friends")]
pub async fn friends(flash: Option<FlashMessage<'_>>, _auth: Authenticated) -> Template {
// build the friends context object
let context = FriendsContext::build().await;
match context {
// we were able to build the context without errors
Ok(mut context) => {
// check to see if there is a flash message to display
if let Some(flash) = flash {
// add flash message contents to the context object
context.flash_name = Some(flash.kind().to_string());
context.flash_msg = Some(flash.message().to_string());
};
Template::render("scuttlebutt/peers_list", &context)
}
// an error occurred while building the context
Err(e) => {
// build the default context and pass along the error message
let mut context = FriendsContext::default();
context.flash_name = Some("error".to_string());
context.flash_msg = Some(e.to_string());
Template::render("scuttlebutt/peers_list", &context)
}
}
}
// HELPERS AND ROUTES FOR /follows
/// A list of follows (peers we follow who do not follow us), with each list item displaying the name, image and public
/// key of the peer.
#[get("/follows")]
pub async fn follows(flash: Option<FlashMessage<'_>>, _auth: Authenticated) -> Template {
// build the follows context object
let context = FollowsContext::build().await;
match context {
// we were able to build the context without errors
Ok(mut context) => {
// check to see if there is a flash message to display
if let Some(flash) = flash {
// add flash message contents to the context object
context.flash_name = Some(flash.kind().to_string());
context.flash_msg = Some(flash.message().to_string());
};
Template::render("scuttlebutt/peers_list", &context)
}
// an error occurred while building the context
Err(e) => {
// build the default context and pass along the error message
let mut context = FollowsContext::default();
context.flash_name = Some("error".to_string());
context.flash_msg = Some(e.to_string());
Template::render("scuttlebutt/peers_list", &context)
}
}
}
// HELPERS AND ROUTES FOR /blocks
/// A list of blocks (peers we've blocked previously), with each list item
/// displaying the name, image and public key of the peer.
#[get("/blocks")]
pub async fn blocks(flash: Option<FlashMessage<'_>>, _auth: Authenticated) -> Template {
// build the blocks context object
let context = BlocksContext::build().await;
match context {
// we were able to build the context without errors
Ok(mut context) => {
// check to see if there is a flash message to display
if let Some(flash) = flash {
// add flash message contents to the context object
context.flash_name = Some(flash.kind().to_string());
context.flash_msg = Some(flash.message().to_string());
};
Template::render("scuttlebutt/peers_list", &context)
}
// an error occurred while building the context
Err(e) => {
// build the default context and pass along the error message
let mut context = BlocksContext::default();
context.flash_name = Some("error".to_string());
context.flash_msg = Some(e.to_string());
Template::render("scuttlebutt/peers_list", &context)
}
}
}

View File

@ -0,0 +1,42 @@
use peach_lib::sbot::SbotStatus;
use rouille::{post_input, try_or_400, Request, Response};
use crate::utils::{flash::FlashResponse, sbot};
// ROUTE: /scuttlebutt/block
/// Block a Scuttlebutt profile specified by the given public key.
///
/// Parse the public key from the submitted form and publish a contact message.
/// Redirect to the appropriate profile page with a flash message describing
/// the outcome of the action (may be successful or unsuccessful).
pub fn handle_form(request: &Request) -> Response {
// query the request body for form data
// return a 400 error if the admin_id field is missing
let data = try_or_400!(post_input!(request, {
public_key: String,
}));
let (flash_name, flash_msg) = match SbotStatus::read() {
Ok(status) if status.state == Some("active".to_string()) => {
match sbot::block_peer(&data.public_key) {
Ok(success_msg) => (
"flash_name=success".to_string(),
format!("flash_msg={}", success_msg),
),
Err(error_msg) => (
"flash_name=error".to_string(),
format!("flash_msg={}", error_msg),
),
}
}
_ => (
"flash_name=warning".to_string(),
"Social interactions are unavailable.".to_string(),
),
};
let url = format!("/scuttlebutt/profile/{}", data.public_key);
Response::redirect_303(url).add_flash(flash_name, flash_msg)
}

View File

@ -0,0 +1,29 @@
use maud::PreEscaped;
use crate::{
templates,
utils::{sbot, theme},
};
// ROUTE: /scuttlebutt/blocks
/// Scuttlebutt blocks list template builder.
pub fn build_template() -> PreEscaped<String> {
// retrieve the list of blocked peers
match sbot::get_blocks_list() {
// populate the peers_list template with blocks and render it
Ok(blocks) => templates::peers_list::build_template(blocks, "Blocks"),
Err(e) => {
// render the sbot error template with the error message
let error_template = templates::error::build_template(e.to_string());
// wrap the nav bars around the error template content
let body = templates::nav::build_template(error_template, "Blocks", Some("/"));
// query the current theme so we can pass it into the base template builder
let theme = theme::get_theme();
// render the base template with the provided body
templates::base::build_template(body, theme)
}
}
}

View File

@ -0,0 +1,42 @@
use peach_lib::sbot::SbotStatus;
use rouille::{post_input, try_or_400, Request, Response};
use crate::utils::{flash::FlashResponse, sbot};
// ROUTE: /scuttlebutt/follow
/// Follow a Scuttlebutt profile specified by the given public key.
///
/// Parse the public key from the submitted form and publish a contact message.
/// Redirect to the appropriate profile page with a flash message describing
/// the outcome of the action (may be successful or unsuccessful).
pub fn handle_form(request: &Request) -> Response {
// query the request body for form data
// return a 400 error if the admin_id field is missing
let data = try_or_400!(post_input!(request, {
public_key: String,
}));
let (flash_name, flash_msg) = match SbotStatus::read() {
Ok(status) if status.state == Some("active".to_string()) => {
match sbot::follow_peer(&data.public_key) {
Ok(success_msg) => (
"flash_name=success".to_string(),
format!("flash_msg={}", success_msg),
),
Err(error_msg) => (
"flash_name=error".to_string(),
format!("flash_msg={}", error_msg),
),
}
}
_ => (
"flash_name=warning".to_string(),
"Social interactions are unavailable.".to_string(),
),
};
let url = format!("/scuttlebutt/profile/{}", data.public_key);
Response::redirect_303(url).add_flash(flash_name, flash_msg)
}

View File

@ -0,0 +1,29 @@
use maud::PreEscaped;
use crate::{
templates,
utils::{sbot, theme},
};
// ROUTE: /scuttlebutt/follows
/// Scuttlebutt follows list template builder.
pub fn build_template() -> PreEscaped<String> {
// retrieve the list of follows
match sbot::get_follows_list() {
// populate the peers_list template with follows
Ok(follows) => templates::peers_list::build_template(follows, "Follows"),
Err(e) => {
// render the sbot error template with the error message
let error_template = templates::error::build_template(e.to_string());
// wrap the nav bars around the error template content
let body = templates::nav::build_template(error_template, "Follows", Some("/"));
// query the current theme so we can pass it into the base template builder
let theme = theme::get_theme();
// render the base template with the provided body
templates::base::build_template(body, theme)
}
}
}

View File

@ -0,0 +1,29 @@
use maud::PreEscaped;
use crate::{
templates,
utils::{sbot, theme},
};
// ROUTE: /scuttlebutt/friends
/// Scuttlebutt friends list template builder.
pub fn build_template() -> PreEscaped<String> {
// retrieve the list of friends
match sbot::get_friends_list() {
// populate the peers_list template with friends and render it
Ok(friends) => templates::peers_list::build_template(friends, "Friends"),
Err(e) => {
// render the sbot error template with the error message
let error_template = templates::error::build_template(e.to_string());
// wrap the nav bars around the error template content
let body = templates::nav::build_template(error_template, "Friends", Some("/"));
// query the current theme so we can pass it into the base template builder
let theme = theme::get_theme();
// render the base template with the provided body
templates::base::build_template(body, theme)
}
}
}

View File

@ -0,0 +1,97 @@
use maud::{html, Markup, PreEscaped};
use peach_lib::sbot::SbotStatus;
use rouille::{post_input, try_or_400, Request, Response};
use crate::{
templates,
utils::{
flash::{FlashRequest, FlashResponse},
sbot, theme,
},
};
// ROUTE: /scuttlebutt/invites
/// Render the invite form template.
fn invite_form_template(
flash_name: Option<&str>,
flash_msg: Option<&str>,
invite_code: Option<&str>,
) -> Markup {
html! {
(PreEscaped("<!-- SCUTTLEBUTT INVITE FORM -->"))
div class="card center" {
form id="invites" class="center" action="/scuttlebutt/invites" method="post" {
div class="center" style="width: 80%;" {
label for="inviteUses" class="label-small font-gray" title="Number of times the invite code can be reused" { "USES" }
input type="number" id="inviteUses" name="uses" min="1" max="150" size="3" value="1";
@if let Some(code) = invite_code {
p class="card-text" style="margin-top: 1rem; user-select: all;" title="Invite code" {
(code)
}
}
}
(PreEscaped("<!-- BUTTONS -->"))
input id="createInvite" class="button button-primary center" style="margin-top: 1rem;" type="submit" title="Create a new invite code" value="Create";
a id="cancel" class="button button-secondary center" href="/scuttlebutt/peers" title="Cancel" { "Cancel" }
}
// render flash message if cookies were found in the request
@if let (Some(name), Some(msg)) = (flash_name, flash_msg) {
// avoid displaying the invite code-containing flash msg
@if name != "code" {
(PreEscaped("<!-- FLASH MESSAGE -->"))
(templates::flash::build_template(name, msg))
}
}
}
}
}
/// Scuttlebutt invite template builder.
pub fn build_template(request: &Request) -> PreEscaped<String> {
// check for flash cookies; will be (None, None) if no flash cookies are found
let (flash_name, flash_msg) = request.retrieve_flash();
// if flash_name is "code" then flash_msg will be an invite code
let invite_code = if flash_name == Some("code") {
flash_msg
} else {
None
};
let invite_form_template = match SbotStatus::read() {
// only render the invite form template if the sbot is active
Ok(status) if status.state == Some("active".to_string()) => {
html! { (invite_form_template(flash_name, flash_msg, invite_code)) }
}
_ => {
// the sbot is not active; render a message instead of the invite form
templates::inactive::build_template("Invite creation is unavailable.")
}
};
let body =
templates::nav::build_template(invite_form_template, "Invites", Some("/scuttlebutt/peers"));
// query the current theme so we can pass it into the base template builder
let theme = theme::get_theme();
templates::base::build_template(body, theme)
}
/// Parse the invite uses data and attempt to generate an invite code.
pub fn handle_form(request: &Request) -> Response {
// query the request body for form data
// return a 400 error if the admin_id field is missing
let data = try_or_400!(post_input!(request, {
// the number of times the invite code can be used
uses: u16,
}));
let (flash_name, flash_msg) = match sbot::create_invite(data.uses) {
Ok(code) => ("flash_name=code".to_string(), format!("flash_msg={}", code)),
Err(e) => ("flash_name=error".to_string(), format!("flash_msg={}", e)),
};
Response::redirect_303("/scuttlebutt/invites").add_flash(flash_name, flash_msg)
}

View File

@ -0,0 +1,14 @@
pub mod block;
pub mod blocks;
pub mod follow;
pub mod follows;
pub mod friends;
pub mod invites;
pub mod peers;
pub mod private;
pub mod profile;
pub mod profile_update;
pub mod publish;
pub mod search;
pub mod unblock;
pub mod unfollow;

View File

@ -0,0 +1,45 @@
use maud::{html, PreEscaped};
use peach_lib::sbot::SbotStatus;
use crate::{templates, utils::theme};
/// Scuttlebutt peer menu template builder.
///
/// A peer menu which allows navigating to lists of friends, follows, followers
/// and blocks, as well as accessing the invite creation form.
pub fn build_template() -> PreEscaped<String> {
let menu_template = match SbotStatus::read() {
Ok(status) if status.state == Some("active".to_string()) => {
// render the scuttlebutt peers menu
html! {
(PreEscaped("<!-- SCUTTLEBUTT PEERS -->"))
div class="card center" {
div class="card-container" {
(PreEscaped("<!-- BUTTONS -->"))
div id="buttons" {
a id="search" class="button button-primary center" href="/scuttlebutt/search" title="Search for a peer" { "Search" }
a id="friends" class="button button-primary center" href="/scuttlebutt/friends" title="List friends" { "Friends" }
a id="follows" class="button button-primary center" href="/scuttlebutt/follows" title="List follows" { "Follows" }
a id="blocks" class="button button-primary center" href="/scuttlebutt/blocks" title="List blocks" { "Blocks" }
a id="invites" class="button button-primary center" href="/scuttlebutt/invites" title="Create invites" { "Invites" }
}
}
}
}
}
_ => {
// the sbot is not active; render a message instead of the menu
templates::inactive::build_template("Social lists and interactions are unavailable.")
}
};
// wrap the nav bars around the settings menu template content
// parameters are template, title and back url
let body = templates::nav::build_template(menu_template, "Scuttlebutt Peers", Some("/"));
// query the current theme so we can pass it into the base template builder
let theme = theme::get_theme();
// render the base template with the provided body
templates::base::build_template(body, theme)
}

View File

@ -0,0 +1,131 @@
use maud::{html, Markup, PreEscaped};
use peach_lib::sbot::SbotStatus;
use rouille::{post_input, try_or_400, Request, Response};
use crate::{
templates,
utils::{
flash::{FlashRequest, FlashResponse},
sbot, theme,
},
};
// ROUTE: /scuttlebutt/private
fn public_key_input_template(ssb_id: &Option<String>) -> Markup {
match ssb_id {
Some(id) => {
html! { input type="text" id="publicKey" name="recipient" placeholder="@xYz...=.ed25519" value=(id); }
}
// render the input with autofocus if no ssb_id has been provided
None => {
html! { input type="text" id="publicKey" name="recipient" placeholder="@xYz...=.ed25519" autofocus; }
}
}
}
fn private_message_textarea_template(ssb_id: &Option<String>) -> Markup {
match ssb_id {
Some(_) => {
html! { textarea id="privatePost" class="center input message-input" name="text" title="Compose a private message" placeholder="Write a private message..." autofocus { "" } }
}
// render the textarea with autofocus if an ssb_id has been provided
None => {
html! { textarea id="privatePost" class="center input message-input" name="text" title="Compose a private message" placeholder="Write a private message..." { "" } }
}
}
}
/// Scuttlebutt private message template builder.
///
/// Render a form for publishing a provate message. The recipient input field
/// is populated with the provided ssb_id. If no recipient is provided, the
/// template autofocuses on the recipient input field.
pub fn build_template(request: &Request, ssb_id: Option<String>) -> PreEscaped<String> {
// check for flash cookies; will be (None, None) if no flash cookies are found
let (flash_name, flash_msg) = request.retrieve_flash();
let profile_template = match SbotStatus::read() {
// only render the private message elements if the sbot is active
Ok(status) if status.state == Some("active".to_string()) => {
// retrieve the local public key (set to blank if an error is returned)
let local_id = match sbot::get_local_id() {
Ok(id) => id,
Err(_) => "".to_string(),
};
html! {
(PreEscaped("<!-- SCUTTLEBUTT PRIVATE MESSAGE FORM -->"))
div class="card card-wide center" {
form id="sbotConfig" class="center" action="/scuttlebutt/private" method="post" {
div class="center" style="display: flex; flex-direction: column; margin-bottom: 1rem;" title="Public key (ID) of the peer being written to" {
label for="publicKey" class="label-small font-gray" {
"PUBLIC KEY"
}
(public_key_input_template(&ssb_id))
}
(PreEscaped("<!-- input for message contents -->"))
(private_message_textarea_template(&ssb_id))
(PreEscaped("<!-- hidden input field to pass the public key of the local peer -->"))
input type="hidden" id="localId" name="id" value=(local_id);
(PreEscaped("<!-- BUTTONS -->"))
input id="publish" class="button button-primary center" type="submit" style="margin-top: 1rem;" title="Publish private message to peer" value="Publish";
}
// render flash message if cookies were found in the request
@if let (Some(name), Some(msg)) = (flash_name, flash_msg) {
(PreEscaped("<!-- FLASH MESSAGE -->"))
(templates::flash::build_template(name, msg))
}
}
}
}
_ => templates::inactive::build_template("Private messaging is unavailable."),
};
let body = templates::nav::build_template(profile_template, "Profile", Some("/"));
// query the current theme so we can pass it into the base template builder
let theme = theme::get_theme();
templates::base::build_template(body, theme)
}
/// Publish a private message.
///
/// Parse the public key and private message text from the submitted form
/// and publish the message. Set a flash message communicating the outcome
/// of the publishing attempt and redirect to the private message page.
pub fn handle_form(request: &Request) -> Response {
// query the request body for form data
// return a 400 error if the admin_id field is missing
let data = try_or_400!(post_input!(request, {
id: String,
text: String,
recipient: String
}));
// now we need to add the local id to the recipients vector,
// otherwise the local id will not be able to read the message.
let recipients = vec![data.id, data.recipient];
let (flash_name, flash_msg) = match SbotStatus::read() {
Ok(status) if status.state == Some("active".to_string()) => {
match sbot::publish_private_msg(data.text, recipients) {
Ok(success_msg) => (
"flash_name=success".to_string(),
format!("flash_msg={}", success_msg),
),
Err(error_msg) => (
"flash_name=error".to_string(),
format!("flash_msg={}", error_msg),
),
}
}
_ => (
"flash_name=warning".to_string(),
"Private messaging is unavailable.".to_string(),
),
};
Response::redirect_303("/scuttlebutt/private").add_flash(flash_name, flash_msg)
}

View File

@ -0,0 +1,178 @@
use maud::{html, Markup, PreEscaped};
use peach_lib::sbot::SbotStatus;
use rouille::Request;
use crate::{
templates,
utils::{flash::FlashRequest, sbot, sbot::Profile, theme},
};
// ROUTE: /scuttlebutt/profile
fn public_post_form_template() -> Markup {
html! {
(PreEscaped("<!-- PUBLIC POST FORM -->"))
form id="postForm" class="center" action="/scuttlebutt/publish" method="post" {
(PreEscaped("<!-- input for message contents -->"))
textarea id="publicPost" class="center input message-input" name="text" title="Compose Public Post" placeholder="Write a public post..." { }
input id="publishPost" class="button button-primary center" title="Publish" type="submit" value="Publish";
}
}
}
fn profile_info_box_template(profile: &Profile) -> Markup {
html! {
(PreEscaped("<!-- PROFILE INFO BOX -->"))
div class="capsule capsule-profile border-ssb" title="Scuttlebutt account profile information" {
@if profile.is_local_profile {
(PreEscaped("<!-- edit profile button -->"))
a class="nav-icon-right" href="/scuttlebutt/profile/update" title="Edit your profile" {
img id="editProfile" class="icon-small icon-active" src="/icons/pencil.svg" alt="Edit";
}
}
// render the profile bio: picture, id, name, image & description
(profile_bio_template(profile))
}
}
}
fn profile_bio_template(profile: &Profile) -> Markup {
html! {
(PreEscaped("<!-- PROFILE BIO -->"))
(PreEscaped("<!-- profile picture -->"))
// only try to render profile pic if we have the blob
@match &profile.blob_path {
Some(blob_path) if profile.blob_exists => {
img id="profilePicture" class="icon-large" src={ "/blob/" (blob_path) } title="Profile picture" alt="Profile picture";
},
_ => {
// use a placeholder image if we don't have the blob
img id="peerImage" class="icon icon-active list-icon" src="/icons/user.svg" alt="Placeholder profile image";
}
}
(PreEscaped("<!-- name, public key & description -->"))
p id="profileName" class="card-text" title="Name" {
@if let Some(name) = &profile.name {
(name)
} @else {
i { "Name is unavailable or has not been set" }
}
}
label class="label-small label-ellipsis font-gray" style="user-select: all;" for="profileName" title="Public Key" {
@if let Some(id) = &profile.id {
(id)
} @else {
"Public key unavailable"
}
}
p id="profileDescription" style="margin-top: 1rem" class="card-text" title="Description" {
@if let Some(description) = &profile.description {
(description)
} @else {
i { "Description is unavailable or has not been set" }
}
}
}
}
fn social_interaction_buttons_template(profile: &Profile) -> Markup {
html! {
(PreEscaped("<!-- BUTTONS -->"))
div id="buttons" style="margin-top: 2rem;" {
@match (profile.following, &profile.id) {
(Some(false), Some(ssb_id)) => {
form id="followForm" class="center" action="/scuttlebutt/follow" method="post" {
input type="hidden" id="publicKey" name="public_key" value=(ssb_id);
input id="followPeer" class="button button-primary center" type="submit" title="Follow Peer" value="Follow";
}
},
(Some(true), Some(ssb_id)) => {
form id="unfollowForm" class="center" action="/scuttlebutt/unfollow" method="post" {
input type="hidden" id="publicKey" name="public_key" value=(ssb_id);
input id="unfollowPeer" class="button button-primary center" type="submit" title="Unfollow Peer" value="Unfollow";
}
},
_ => p { "Unable to determine follow state" }
}
@match (profile.blocking, &profile.id) {
(Some(false), Some(ssb_id)) => {
form id="blockForm" class="center" action="/scuttlebutt/block" method="post" {
input type="hidden" id="publicKey" name="public_key" value=(ssb_id);
input id="blockPeer" class="button button-primary center" type="submit" title="Block Peer" value="Block";
}
},
(Some(true), Some(ssb_id)) => {
form id="unblockForm" class="center" action="/scuttlebutt/unblock" method="post" {
input type="hidden" id="publicKey" name="public_key" value=(ssb_id);
input id="unblockPeer" class="button button-primary center" type="submit" title="Unblock Peer" value="Unblock";
}
},
_ => p { "Unable to determine block state" }
}
@if let Some(ssb_id) = &profile.id {
form class="center" {
a id="privateMessage" class="button button-primary center" href={ "/scuttlebutt/private/" (ssb_id) } title="Private Message" {
"Send Private Message"
}
}
}
}
}
}
/// Scuttlebutt profile template builder.
///
/// Render a Scuttlebutt profile, either for the local profile or for a peer
/// specified by a public key. If the public key query parameter is not
/// provided, the local profile is displayed (ie. the profile of the public key
/// associated with the local PeachCloud device).
pub fn build_template(request: &Request, ssb_id: Option<String>) -> PreEscaped<String> {
// check for flash cookies; will be (None, None) if no flash cookies are found
let (flash_name, flash_msg) = request.retrieve_flash();
let profile_template = match SbotStatus::read() {
Ok(status) if status.state == Some("active".to_string()) => {
// TODO: validate ssb_id and return error template
// retrieve the profile info
match sbot::get_profile_info(ssb_id) {
Ok(profile) => {
// render the profile template
html! {
(PreEscaped("<!-- SSB PROFILE -->"))
div class="card card-wide center" {
// render profile info box
(profile_info_box_template(&profile))
@if profile.is_local_profile {
// render the public post form template
(public_post_form_template())
} @else {
// render follow / unfollow, block / unblock and
// private message buttons
(social_interaction_buttons_template(&profile))
}
// render flash message if cookies were found in the request
@if let (Some(name), Some(msg)) = (flash_name, flash_msg) {
(PreEscaped("<!-- FLASH MESSAGE -->"))
(templates::flash::build_template(name, msg))
}
}
}
}
Err(e) => {
// render the sbot error template with the error message
templates::error::build_template(e.to_string())
}
}
}
_ => templates::inactive::build_template("Profile is unavailable."),
};
let body = templates::nav::build_template(profile_template, "Profile", Some("/"));
// query the current theme so we can pass it into the base template builder
let theme = theme::get_theme();
templates::base::build_template(body, theme)
}

View File

@ -0,0 +1,173 @@
use maud::{html, PreEscaped};
use peach_lib::sbot::SbotStatus;
use rouille::{input::post::BufferedFile, post_input, try_or_400, Request, Response};
use crate::{
templates,
utils::{
flash::{FlashRequest, FlashResponse},
sbot,
sbot::Profile,
theme,
},
};
// ROUTE: /scuttlebutt/profile/update
fn parse_profile_info(profile: Profile) -> (String, String, String) {
let id = match profile.id {
Some(id) => id,
_ => "Public key unavailable".to_string(),
};
let name = match profile.name {
Some(name) => name,
_ => "Name unavailable".to_string(),
};
let description = match profile.description {
Some(description) => description,
_ => "Description unavailable".to_string(),
};
(id, name, description)
}
/// Scuttlebutt profile update template builder.
///
/// Serve a form for the purpose of updating the name, description and picture
/// for the local Scuttlebutt profile.
pub fn build_template(request: &Request) -> PreEscaped<String> {
// check for flash cookies; will be (None, None) if no flash cookies are found
let (flash_name, flash_msg) = request.retrieve_flash();
let profile_update_template = match SbotStatus::read() {
Ok(status) if status.state == Some("active".to_string()) => {
// retrieve the local profile info
match sbot::get_profile_info(None) {
Ok(profile) => {
let (id, name, description) = parse_profile_info(profile);
// render the scuttlebutt profile update form
html! {
(PreEscaped("<!-- SSB PROFILE UPDATE FORM -->"))
div class="card card-wide center" {
form id="profileInfo" class="center" enctype="multipart/form-data" action="/scuttlebutt/profile/update" method="post" {
div style="display: flex; flex-direction: column" {
label for="name" class="label-small font-gray" {
"NAME"
}
input style="margin-bottom: 1rem;" type="text" id="name" name="new_name" placeholder="Choose a name for your profile..." value=(name);
label for="description" class="label-small font-gray" {
"DESCRIPTION"
}
textarea id="description" class="message-input" style="margin-bottom: 1rem;" name="new_description" placeholder="Write a description for your profile..." {
(description)
}
label for="image" class="label-small font-gray" {
"IMAGE"
}
input type="file" id="fileInput" class="font-normal" name="image";
}
input type="hidden" name="id" value=(id);
input type="hidden" name="current_name" value=(name);
input type="hidden" name="current_description" value=(description);
div id="buttonDiv" style="margin-top: 2rem;" {
input id="updateProfile" class="button button-primary center" title="Publish" type="submit" value="Publish";
a class="button button-secondary center" href="/scuttlebutt/profile" title="Cancel" { "Cancel" }
}
}
// render flash message if cookies were found in the request
@if let (Some(name), Some(msg)) = (flash_name, flash_msg) {
(PreEscaped("<!-- FLASH MESSAGE -->"))
(templates::flash::build_template(name, msg))
}
}
}
}
Err(e) => {
// render the sbot error template with the error message
templates::error::build_template(e.to_string())
}
}
}
_ => {
// the sbot is not active; render a message instead of the form
templates::inactive::build_template("Profile is unavailable.")
}
};
let body = templates::nav::build_template(
profile_update_template,
"Profile",
Some("/scuttlebutt/profile"),
);
// query the current theme so we can pass it into the base template builder
let theme = theme::get_theme();
templates::base::build_template(body, theme)
}
/// Update the name, description and picture for the local Scuttlebutt profile.
///
/// Redirects to profile page of the PeachCloud local identity with a flash
/// message describing the outcome of the action (may be successful or
/// unsuccessful).
pub fn handle_form(request: &Request) -> Response {
// query the request body for form data
// return a 400 error if the admin_id field is missing
let data = try_or_400!(post_input!(request, {
id: String,
current_name: String,
current_description: String,
new_name: Option<String>,
new_description: Option<String>,
image: Option<BufferedFile>,
}));
let (flash_name, flash_msg) = match SbotStatus::read() {
Ok(status) if status.state == Some("active".to_string()) => {
// we can't pass `data` into the function (due to macro creation)
// so we pass in each individual value instead
match sbot::update_profile_info(
data.current_name,
data.current_description,
data.new_name,
data.new_description,
data.image,
) {
Ok(success_msg) => (
"flash_name=success".to_string(),
format!("flash_msg={}", success_msg),
),
Err(error_msg) => (
"flash_name=error".to_string(),
format!("flash_msg={}", error_msg),
),
}
}
_ => (
"flash_name=warning".to_string(),
"Profile is unavailable.".to_string(),
),
};
Response::redirect_303("/scuttlebutt/profile/update").add_flash(flash_name, flash_msg)
}
/*
match sbot::validate_public_key(&data.public_key) {
Ok(_) => {
let url = format!("/scuttlebutt/profile?={}", &data.public_key);
Response::redirect_303(url)
}
Err(err) => {
let (flash_name, flash_msg) =
("flash_name=error".to_string(), format!("flash_msg={}", err));
Response::redirect_303("/scuttlebutt/search").add_flash(flash_name, flash_msg)
}
}
}
*/

View File

@ -0,0 +1,41 @@
use peach_lib::sbot::SbotStatus;
use rouille::{post_input, try_or_400, Request, Response};
use crate::utils::{flash::FlashResponse, sbot};
// ROUTE: /scuttlebutt/publish
/// Publish a public Scuttlebutt post.
///
/// Parse the post text from the submitted form and publish the message.
/// Redirect to the profile page of the PeachCloud local identity with a flash
/// message describing the outcome of the action (may be successful or
/// unsuccessful).
pub fn handle_form(request: &Request) -> Response {
// query the request body for form data
// return a 400 error if the admin_id field is missing
let data = try_or_400!(post_input!(request, {
text: String,
}));
let (flash_name, flash_msg) = match SbotStatus::read() {
Ok(status) if status.state == Some("active".to_string()) => {
match sbot::publish_public_post(data.text) {
Ok(success_msg) => (
"flash_name=success".to_string(),
format!("flash_msg={}", success_msg),
),
Err(error_msg) => (
"flash_name=error".to_string(),
format!("flash_msg={}", error_msg),
),
}
}
_ => (
"flash_name=warning".to_string(),
"Public posting is unavailable.".to_string(),
),
};
Response::redirect_303("/scuttlebutt/profile").add_flash(flash_name, flash_msg)
}

View File

@ -0,0 +1,69 @@
use maud::{html, PreEscaped};
use rouille::{post_input, try_or_400, Request, Response};
use crate::{
templates,
utils::{
flash::{FlashRequest, FlashResponse},
sbot, theme,
},
};
// ROUTE: /scuttlebutt/search
/// Scuttlebutt peer search template builder.
pub fn build_template(request: &Request) -> PreEscaped<String> {
// check for flash cookies; will be (None, None) if no flash cookies are found
let (flash_name, flash_msg) = request.retrieve_flash();
let search_template = html! {
(PreEscaped("<!-- PEER SEARCH FORM -->"))
div class="card center" {
form id="sbotConfig" class="center" action="/scuttlebutt/search" method="post" {
div class="center" style="display: flex; flex-direction: column; margin-bottom: 2rem;" title="Public key (ID) of a peer" {
label for="publicKey" class="label-small font-gray" { "PUBLIC KEY" }
input type="text" id="publicKey" name="public_key" placeholder="@xYz...=.ed25519" autofocus;
}
(PreEscaped("<!-- BUTTONS -->"))
input id="search" class="button button-primary center" type="submit" title="Search for peer" value="Search";
// render flash message if cookies were found in the request
@if let (Some(name), Some(msg)) = (flash_name, flash_msg) {
(PreEscaped("<!-- FLASH MESSAGE -->"))
(templates::flash::build_template(name, msg))
}
}
}
};
let body =
templates::nav::build_template(search_template, "Search", Some("/scuttlebutt/peers"));
// query the current theme so we can pass it into the base template builder
let theme = theme::get_theme();
templates::base::build_template(body, theme)
}
/// Parse the public key, verify that it's valid and then redirect to the
/// profile of the given key.
///
/// If the public key is invalid, set an error flash message and redirect.
pub fn handle_form(request: &Request) -> Response {
// query the request body for form data
// return a 400 error if the admin_id field is missing
let data = try_or_400!(post_input!(request, {
public_key: String,
}));
match sbot::validate_public_key(&data.public_key) {
Ok(_) => {
let url = format!("/scuttlebutt/profile/{}", &data.public_key);
Response::redirect_303(url)
}
Err(err) => {
let (flash_name, flash_msg) =
("flash_name=error".to_string(), format!("flash_msg={}", err));
Response::redirect_303("/scuttlebutt/search").add_flash(flash_name, flash_msg)
}
}
}

View File

@ -0,0 +1,42 @@
use peach_lib::sbot::SbotStatus;
use rouille::{post_input, try_or_400, Request, Response};
use crate::utils::{flash::FlashResponse, sbot};
// ROUTE: /scuttlebutt/unblock
/// Unblock a Scuttlebutt profile specified by the given public key.
///
/// Parse the public key from the submitted form and publish a contact message.
/// Redirect to the appropriate profile page with a flash message describing
/// the outcome of the action (may be successful or unsuccessful).
pub fn handle_form(request: &Request) -> Response {
// query the request body for form data
// return a 400 error if the admin_id field is missing
let data = try_or_400!(post_input!(request, {
public_key: String,
}));
let (flash_name, flash_msg) = match SbotStatus::read() {
Ok(status) if status.state == Some("active".to_string()) => {
match sbot::unblock_peer(&data.public_key) {
Ok(success_msg) => (
"flash_name=success".to_string(),
format!("flash_msg={}", success_msg),
),
Err(error_msg) => (
"flash_name=error".to_string(),
format!("flash_msg={}", error_msg),
),
}
}
_ => (
"flash_name=warning".to_string(),
"Social interactions are unavailable.".to_string(),
),
};
let url = format!("/scuttlebutt/profile/{}", data.public_key);
Response::redirect_303(url).add_flash(flash_name, flash_msg)
}

View File

@ -0,0 +1,42 @@
use peach_lib::sbot::SbotStatus;
use rouille::{post_input, try_or_400, Request, Response};
use crate::utils::{flash::FlashResponse, sbot};
// ROUTE: /scuttlebutt/unfollow
/// Unfollow a Scuttlebutt profile specified by the given public key.
///
/// Parse the public key from the submitted form and publish a contact message.
/// Redirect to the appropriate profile page with a flash message describing
/// the outcome of the action (may be successful or unsuccessful).
pub fn handle_form(request: &Request) -> Response {
// query the request body for form data
// return a 400 error if the admin_id field is missing
let data = try_or_400!(post_input!(request, {
public_key: String,
}));
let (flash_name, flash_msg) = match SbotStatus::read() {
Ok(status) if status.state == Some("active".to_string()) => {
match sbot::unfollow_peer(&data.public_key) {
Ok(success_msg) => (
"flash_name=success".to_string(),
format!("flash_msg={}", success_msg),
),
Err(error_msg) => (
"flash_name=error".to_string(),
format!("flash_msg={}", error_msg),
),
}
}
_ => (
"flash_name=warning".to_string(),
"Social interactions are unavailable.".to_string(),
),
};
let url = format!("/scuttlebutt/profile/{}", data.public_key);
Response::redirect_303(url).add_flash(flash_name, flash_msg)
}

View File

@ -1,122 +0,0 @@
use rocket::{
form::{Form, FromForm},
get, post,
request::FlashMessage,
response::{Flash, Redirect},
uri,
};
use rocket_dyn_templates::{tera::Context, Template};
use peach_lib::config_manager;
use crate::error::PeachWebError;
use crate::routes::authentication::Authenticated;
use crate::utils;
// HELPERS AND ROUTES FOR /settings/admin
/// Administrator settings menu.
#[get("/")]
pub fn admin_menu(flash: Option<FlashMessage>, _auth: Authenticated) -> Template {
// retrieve current ui theme
let theme = utils::get_theme();
let mut context = Context::new();
context.insert("theme", &theme);
context.insert("back", &Some("/settings".to_string()));
context.insert("title", &Some("Administrator Settings".to_string()));
// check to see if there is a flash message to display
if let Some(flash) = flash {
context.insert("flash_name", &Some(flash.kind().to_string()));
context.insert("flash_msg", &Some(flash.message().to_string()));
};
Template::render("settings/admin/menu", &context.into_json())
}
// HELPERS AND ROUTES FOR /settings/admin/configure
/// View and delete currently configured admin.
#[get("/configure")]
pub fn configure_admin(flash: Option<FlashMessage>, _auth: Authenticated) -> Template {
// retrieve current ui theme
let theme = utils::get_theme();
let mut context = Context::new();
context.insert("theme", &theme);
context.insert("back", &Some("/settings/admin".to_string()));
context.insert("title", &Some("Configure Admin".to_string()));
// check to see if there is a flash message to display
if let Some(flash) = flash {
context.insert("flash_name", &Some(flash.kind().to_string()));
context.insert("flash_msg", &Some(flash.message().to_string()));
};
// load the peach configuration vector
match config_manager::load_peach_config() {
Ok(config) => {
// retrieve the vector of ssb admin ids
let ssb_admin_ids = config.ssb_admin_ids;
context.insert("ssb_admin_ids", &ssb_admin_ids);
}
// if load fails, overwrite the flash_name and flash_msg
Err(e) => {
context.insert("flash_name", &Some("error".to_string()));
context.insert(
"flash_msg",
&Some(format!("Failed to load Peach config: {}", e)),
);
}
}
Template::render("settings/admin/configure_admin", &context.into_json())
}
// HELPERS AND ROUTES FOR /settings/admin/add
#[derive(Debug, FromForm)]
pub struct AddAdminForm {
pub ssb_id: String,
}
pub fn save_add_admin_form(admin_form: AddAdminForm) -> Result<(), PeachWebError> {
let _result = config_manager::add_ssb_admin_id(&admin_form.ssb_id)?;
// if the previous line didn't throw an error then it was a success
Ok(())
}
#[post("/add", data = "<add_admin_form>")]
pub fn add_admin_post(add_admin_form: Form<AddAdminForm>, _auth: Authenticated) -> Flash<Redirect> {
let result = save_add_admin_form(add_admin_form.into_inner());
let url = uri!("/settings/admin/configure");
match result {
Ok(_) => Flash::success(Redirect::to(url), "Added SSB administrator"),
Err(e) => Flash::error(Redirect::to(url), format!("Failed to add new admin: {}", e)),
}
}
// HELPERS AND ROUTES FOR /settings/admin/delete
#[derive(Debug, FromForm)]
pub struct DeleteAdminForm {
pub ssb_id: String,
}
#[post("/delete", data = "<delete_admin_form>")]
pub fn delete_admin_post(
delete_admin_form: Form<DeleteAdminForm>,
_auth: Authenticated,
) -> Flash<Redirect> {
let result = config_manager::delete_ssb_admin_id(&delete_admin_form.ssb_id);
let url = uri!("/settings/admin", configure_admin);
match result {
Ok(_) => Flash::success(Redirect::to(url), "Removed SSB administrator"),
Err(e) => Flash::error(
Redirect::to(url),
format!("Failed to remove admin id: {}", e),
),
}
}

View File

@ -0,0 +1,35 @@
use peach_lib::config_manager;
use rouille::{post_input, try_or_400, Request, Response};
use crate::utils::flash::FlashResponse;
// HELPER AND ROUTES FOR /settings/admin/add
/// Parse an `admin_id` from the submitted form, save it to file
/// (`/var/lib/peachcloud/config.yml`) and redirect to the administrator
/// configuration URL.
pub fn handle_form(request: &Request) -> Response {
// query the request body for form data
// return a 400 error if the admin_id field is missing
let data = try_or_400!(post_input!(request, {
// the public key of a desired administrator
ssb_id: String,
}));
// TODO: verify that the given ssb_id is valid
// save submitted admin id to file
let (flash_name, flash_msg) = match config_manager::add_ssb_admin_id(&data.ssb_id) {
Ok(_) => (
"flash_name=success".to_string(),
"flash_msg=Added SSB administrator".to_string(),
),
Err(err) => (
"flash_name=error".to_string(),
format!("flash_msg=Failed to add new administrator: {}", err),
),
};
// redirect to the configure admin page
Response::redirect_303("/settings/admin/configure").add_flash(flash_name, flash_msg)
}

View File

@ -0,0 +1,79 @@
use maud::{html, PreEscaped};
use peach_lib::config_manager;
use rouille::Request;
use crate::{
templates,
utils::{flash::FlashRequest, theme},
};
/// Administrator settings menu template builder.
pub fn build_template(request: &Request) -> PreEscaped<String> {
// check for flash cookies; will be (None, None) if no flash cookies are found
let (mut flash_name, mut flash_msg) = request.retrieve_flash();
// attempt to load peachcloud config file
let ssb_admins = match config_manager::load_peach_config() {
Ok(config) => Some(config.ssb_admin_ids),
// note: this will overwrite any received flash cookie values
// TODO: find a way to include the `err` in the flash_msg
// currently produces an error because we end up with Some(String)
// instead of Some(str)
Err(_err) => {
flash_name = Some("flash_name=error");
flash_msg = Some("flash_msg=Failed to read PeachCloud configuration file");
None
}
};
let menu_template = html! {
(PreEscaped("<!-- CONFIGURE ADMIN PAGE -->"))
div class="card center" {
div class="capsule capsule-profile center-text font-normal border-info" style="font-family: var(--sans-serif); font-size: var(--font-size-6); margin-bottom: 1.5rem;" {
"Administrators are identified and added by their Scuttlebutt public keys. These accounts will be sent private messages on Scuttlebutt when a password reset is requested."
}
@if let Some(ref ssb_admin_ids) = ssb_admins {
@for admin in ssb_admin_ids {
form class="center" action="/settings/admin/delete" method="post" {
div class="center" style="display: flex; justify-content: space-between;" {
input type="hidden" name="ssb_id" value=(admin);
p class="label-small label-ellipsis font-gray" style="user-select: all;" { (admin) }
input style="width: 30%;" type="submit" class="button button-warning" value="Delete" title="Delete SSB administrator";
}
}
}
} @else {
div class="card-text" {
"There are no currently configured admins."
}
}
form id="addAdmin" class="center" style="margin-top: 2rem;" action="/settings/admin/add" method="post" {
div class="center" style="display: flex; flex-direction: column; margin-bottom: 2rem;" title="Public key (ID) of a desired administrator" {
label for="publicKey" class="label-small font-gray" { "PUBLIC KEY" }
input type="text" id="publicKey" name="ssb_id" placeholder="@xYz...=.ed25519" autofocus;
}
(PreEscaped("<!-- BUTTONS -->"))
input class="button button-primary center" type="submit" title="Add SSB administrator" value="Add Admin";
}
// render flash message if cookies were found in the request
@if let (Some(name), Some(msg)) = (flash_name, flash_msg) {
(PreEscaped("<!-- FLASH MESSAGE -->"))
(templates::flash::build_template(name, msg))
}
}
};
// wrap the nav bars around the settings menu template content
// parameters are template, title and back url
let body = templates::nav::build_template(
menu_template,
"Configure Administrators",
Some("/settings/admin"),
);
// query the current theme so we can pass it into the base template builder
let theme = theme::get_theme();
// render the base template with the provided body
templates::base::build_template(body, theme)
}

View File

@ -0,0 +1,35 @@
use peach_lib::config_manager;
use rouille::{post_input, try_or_400, Request, Response};
use crate::utils::flash::FlashResponse;
// HELPERS AND ROUTES FOR /settings/admin/delete
/// Parse an `admin_id` from the submitted form, delete it from file
/// (`/var/lib/peachcloud/config.yml`) and redirect to the administrator
/// configuration URL.
pub fn handle_form(request: &Request) -> Response {
// query the request body for form data
// return a 400 error if the admin_id field is missing
let data = try_or_400!(post_input!(request, {
// the public key of a desired administrator
ssb_id: String,
}));
// remove submitted admin id from file
// match on the result and set flash name and msg accordingly
let (flash_name, flash_msg) = match config_manager::delete_ssb_admin_id(&data.ssb_id) {
Ok(_) => (
// <cookie-name>=<cookie-value>
"flash_name=success".to_string(),
"flash_msg=Removed SSB administrator".to_string(),
),
Err(err) => (
"flash_name=error".to_string(),
format!("flash_msg=Failed to remove administrator: {}", err),
),
};
// set the flash cookie headers and redirect to the configure admin page
Response::redirect_303("/settings/admin/configure").add_flash(flash_name, flash_msg)
}

View File

@ -0,0 +1,30 @@
use maud::{html, PreEscaped};
use crate::{templates, utils::theme};
/// Administrator settings menu template builder.
pub fn build_template() -> PreEscaped<String> {
let menu_template = html! {
(PreEscaped("<!-- ADMIN SETTINGS MENU -->"))
div class="card center" {
(PreEscaped("<!-- BUTTONS -->"))
div id="settingsButtons" {
a id="configure" class="button button-primary center" href="/settings/admin/configure" title="Configure Admin" { "Configure Admin" }
a id="change" class="button button-primary center" href="/auth/change" title="Change Password" { "Change Password" }
a id="reset" class="button button-primary center" href="/auth/reset" title="Reset Password" { "Reset Password" }
}
}
};
// wrap the nav bars around the settings menu template content
// parameters are template, title and back url
let body =
templates::nav::build_template(menu_template, "Administrator Settings", Some("/settings"));
// query the current theme so we can pass it into the base template builder
let theme = theme::get_theme();
// render the base template with the provided body
templates::base::build_template(body, theme)
}

View File

@ -0,0 +1,4 @@
pub mod add;
pub mod configure;
pub mod delete;
pub mod menu;

View File

@ -1,35 +1,33 @@
use rocket::{get, request::FlashMessage, State};
use rocket_dyn_templates::{tera::Context, Template};
use maud::{html, PreEscaped};
use crate::routes::authentication::Authenticated;
use crate::utils;
use crate::RocketConfig;
use crate::{templates, utils::theme, CONFIG};
// HELPERS AND ROUTES FOR /settings
// ROUTE: /settings
/// View and delete currently configured admin.
#[get("/settings")]
pub fn settings_menu(
_auth: Authenticated,
flash: Option<FlashMessage>,
config: &State<RocketConfig>,
) -> Template {
// retrieve current ui theme
let theme = utils::get_theme();
let mut context = Context::new();
context.insert("theme", &theme);
context.insert("back", &Some("/".to_string()));
context.insert("title", &Some("Settings".to_string()));
// pass in mode from managed state so we can conditionally render html elements
context.insert("standalone_mode", &config.standalone_mode);
// check to see if there is a flash message to display
if let Some(flash) = flash {
context.insert("flash_name", &Some(flash.kind().to_string()));
context.insert("flash_msg", &Some(flash.message().to_string()));
/// Settings menu template builder.
pub fn build_template() -> PreEscaped<String> {
let menu_template = html! {
(PreEscaped("<!-- SETTINGS MENU -->"))
div class="card center" {
(PreEscaped("<!-- BUTTONS -->"))
div id="settingsButtons" {
// render the network settings button if we're not in standalone mode
@if !CONFIG.standalone_mode {
a id="network" class="button button-primary center" href="/settings/network" title="Network Settings" { "Network" }
}
a id="scuttlebutt" class="button button-primary center" href="/settings/scuttlebutt" title="Scuttlebutt Settings" { "Scuttlebutt" }
a id="admin" class="button button-primary center" href="/settings/admin" title="Administrator Settings" { "Administration" }
}
}
};
Template::render("settings/menu", &context.into_json())
// wrap the nav bars around the settings menu template content
// parameters are template, title and back url
let body = templates::nav::build_template(menu_template, "Settings", Some("/"));
// query the current theme so we can pass it into the base template builder
let theme = theme::get_theme();
// render the base template with the provided body
templates::base::build_template(body, theme)
}

View File

@ -1,6 +1,6 @@
pub mod admin;
pub mod dns;
//pub mod dns;
pub mod menu;
pub mod network;
//pub mod network;
pub mod scuttlebutt;
pub mod theme;

View File

@ -1,285 +0,0 @@
use std::{
io,
process::{Command, Output},
};
use log::{info, warn};
use peach_lib::sbot::{SbotConfig, SbotStatus};
use rocket::{
form::{Form, FromForm},
get, post,
request::FlashMessage,
response::{Flash, Redirect},
};
use rocket_dyn_templates::{tera::Context, Template};
use crate::routes::authentication::Authenticated;
use crate::utils;
#[derive(Debug, FromForm)]
pub struct SbotConfigForm {
/// Directory path for the log and indexes.
repo: String,
/// Directory path for writing debug output.
debugdir: String,
/// Secret-handshake app-key (aka. network key).
shscap: String,
/// HMAC hash used to sign messages.
hmac: String,
/// Replication hops (1: friends, 2: friends of friends).
hops: u8,
/// IP address to listen on.
lis_ip: String,
/// Port to listen on.
lis_port: String,
/// Address to listen on for WebSocket connections.
wslis: String,
/// Address to for metrics and pprof HTTP server.
debuglis: String,
/// Enable sending local UDP broadcasts.
localadv: bool,
/// Enable listening for UDP broadcasts and connecting.
localdiscov: bool,
/// Enable syncing by using epidemic-broadcast-trees (EBT).
enable_ebt: bool,
/// Bypass graph auth and fetch remote's feed (useful for pubs that are restoring their data
/// from peer; user beware - caveats about).
promisc: bool,
/// Disable the UNIX socket RPC interface.
nounixsock: bool,
/// Run the go-sbot on system start-up (systemd service enabled).
startup: bool,
/// Attempt to repair the filesystem before starting.
repair: bool,
}
// HELPERS AND ROUTES FOR /settings/scuttlebutt
/// Scuttlebutt settings menu.
#[get("/")]
pub fn ssb_settings_menu(flash: Option<FlashMessage>, _auth: Authenticated) -> Template {
// retrieve current ui theme
let theme = utils::get_theme();
// retrieve go-sbot systemd process status
let sbot_status = SbotStatus::read().ok();
let mut context = Context::new();
context.insert("theme", &theme);
context.insert("sbot_status", &sbot_status);
context.insert("back", &Some("/settings".to_string()));
context.insert("title", &Some("Scuttlebutt Settings".to_string()));
if let Some(flash) = flash {
context.insert("flash_name", &Some(flash.kind().to_string()));
context.insert("flash_msg", &Some(flash.message().to_string()));
};
Template::render("settings/scuttlebutt/menu", &context.into_json())
}
/// Sbot configuration page (includes form for updating configuration parameters).
#[get("/configure")]
pub fn configure_sbot(flash: Option<FlashMessage>, _auth: Authenticated) -> Template {
// retrieve current ui theme
let theme = utils::get_theme();
// retrieve go-sbot systemd process status
let sbot_status = SbotStatus::read().ok();
let run_on_startup = sbot_status.map(|status| status.boot_state);
// retrieve sbot config parameters
let sbot_config = SbotConfig::read().ok();
let mut context = Context::new();
context.insert("theme", &theme);
context.insert("back", &Some("/settings/scuttlebutt".to_string()));
context.insert("title", &Some("Sbot Configuration".to_string()));
context.insert("sbot_config", &sbot_config);
context.insert("run_on_startup", &Some(run_on_startup));
if let Some(flash) = flash {
context.insert("flash_name", &Some(flash.kind().to_string()));
context.insert("flash_msg", &Some(flash.message().to_string()));
};
Template::render("settings/scuttlebutt/configure_sbot", &context.into_json())
}
// TODO: consider using `Contextual` here to collect all submitted form
// fields to re-render forms with submitted values on error
/// Receive the sbot configuration form data and save it to file.
#[post("/configure?<restart>", data = "<config>")]
pub fn configure_sbot_post(
restart: bool,
config: Form<SbotConfigForm>,
_auth: Authenticated,
) -> Flash<Redirect> {
// call `into_inner()` to take ownership of the `config` data
let owned_config = config.into_inner();
// concat the ip and port for listen address
let lis = format!("{}:{}", owned_config.lis_ip, owned_config.lis_port);
// instantiate `SbotConfig` from form data
let config = SbotConfig {
lis,
hops: owned_config.hops,
repo: owned_config.repo,
debugdir: owned_config.debugdir,
shscap: owned_config.shscap,
localadv: owned_config.localadv,
localdiscov: owned_config.localdiscov,
hmac: owned_config.hmac,
wslis: owned_config.wslis,
debuglis: owned_config.debuglis,
enable_ebt: owned_config.enable_ebt,
promisc: owned_config.promisc,
nounixsock: owned_config.nounixsock,
repair: owned_config.repair,
};
match owned_config.startup {
true => {
info!("Enabling go-sbot.service");
if let Err(e) = systemctl_sbot_cmd("enable") {
warn!("Failed to enable go-sbot.service: {}", e)
}
}
false => {
info!("Disabling go-sbot.service");
if let Err(e) = systemctl_sbot_cmd("disable") {
warn!("Failed to disable go-sbot.service: {}", e)
}
}
};
// write config to file
match SbotConfig::write(config) {
Ok(_) => {
// if `restart` query parameter is `true`, attempt sbot process (re)start
if restart {
restart_sbot_process(
// redirect url
"/settings/scuttlebutt/configure",
// success flash msg
"Updated configuration and restarted the sbot process",
// first failed flash msg
"Updated configuration but failed to start the sbot process",
// second failed flash msg
"Updated configuration but failed to stop the sbot process",
)
} else {
Flash::success(
Redirect::to("/settings/scuttlebutt/configure"),
"Updated configuration",
)
}
}
Err(e) => Flash::error(
Redirect::to("/settings/scuttlebutt/configure"),
format!("Failed to update configuration: {}", e),
),
}
}
/// Set default configuration parameters for the go-sbot and save them to file.
#[get("/configure/default")]
pub fn configure_sbot_default(_auth: Authenticated) -> Flash<Redirect> {
let default_config = SbotConfig::default();
// write default config to file
match SbotConfig::write(default_config) {
Ok(_) => Flash::success(
Redirect::to("/settings/scuttlebutt/configure"),
"Restored default configuration",
),
Err(e) => Flash::error(
Redirect::to("/settings/scuttlebutt/configure"),
format!("Failed to restore default configuration: {}", e),
),
}
}
/// Attempt to start the go-sbot.service process.
/// Redirect to the Scuttlebutt settings menu and communicate the outcome of
/// the attempt via a flash message.
#[get("/start")]
pub fn start_sbot(_auth: Authenticated) -> Flash<Redirect> {
info!("Starting go-sbot.service");
match systemctl_sbot_cmd("start") {
Ok(_) => Flash::success(
Redirect::to("/settings/scuttlebutt"),
"Sbot process has been started",
),
Err(_) => Flash::error(
Redirect::to("/settings/scuttlebutt"),
"Failed to start the sbot process",
),
}
}
/// Attempt to stop the go-sbot.service process.
/// Redirect to the Scuttlebutt settings menu and communicate the outcome of
/// the attempt via a flash message.
#[get("/stop")]
pub fn stop_sbot(_auth: Authenticated) -> Flash<Redirect> {
info!("Stopping go-sbot.service");
match systemctl_sbot_cmd("stop") {
Ok(_) => Flash::success(
Redirect::to("/settings/scuttlebutt"),
"Sbot process has been stopped",
),
Err(_) => Flash::error(
Redirect::to("/settings/scuttlebutt"),
"Failed to stop the sbot process",
),
}
}
/// Attempt to restart the go-sbot.service process.
/// Redirect to the Scuttlebutt settings menu and communicate the outcome of
/// the attempt via a flash message.
#[get("/restart")]
pub fn restart_sbot(_auth: Authenticated) -> Flash<Redirect> {
restart_sbot_process(
"/settings/scuttlebutt",
"Sbot process has been restarted",
"Failed to start the sbot process",
"Failed to stop the sbot process",
)
}
// HELPER FUNCTIONS
/// Executes a systemctl command for the go-sbot.service process.
pub fn systemctl_sbot_cmd(cmd: &str) -> io::Result<Output> {
Command::new("systemctl")
.arg("--user")
.arg(cmd)
.arg("go-sbot.service")
.output()
}
/// Executes a systemctl stop command followed by start command.
/// Returns a redirect with a flash message stating the output of the restart attempt.
fn restart_sbot_process(
redirect_url: &str,
success_msg: &str,
start_failed_msg: &str,
stop_failed_msg: &str,
) -> Flash<Redirect> {
let url = redirect_url.to_string();
info!("Restarting go-sbot.service");
match systemctl_sbot_cmd("stop") {
// if stop was successful, try to start the process
Ok(_) => match systemctl_sbot_cmd("start") {
Ok(_) => Flash::success(Redirect::to(url), success_msg),
Err(e) => Flash::error(Redirect::to(url), format!("{}: {}", start_failed_msg, e)),
},
Err(e) => Flash::error(Redirect::to(url), format!("{}: {}", stop_failed_msg, e)),
}
}

View File

@ -0,0 +1,267 @@
use log::{debug, warn};
use maud::{html, PreEscaped};
use peach_lib::sbot::{SbotConfig, SbotStatus};
use rouille::{post_input, try_or_400, Request, Response};
use crate::{
templates,
utils::{
flash::{FlashRequest, FlashResponse},
sbot, theme,
},
};
/// Read the status and configuration of the sbot.
/// Define fallback values if an error is returned from either read function.
fn read_status_and_config() -> (String, SbotConfig, String, String) {
// retrieve go-sbot systemd process status
let run_on_startup = if let Ok(status) = SbotStatus::read() {
// if the read is ok, return the value or "disabled" if no value is set
match status.boot_state {
Some(state) => state,
None => "disabled".to_string(),
}
} else {
// if the read returns an error, set the value to "disabled"
"disabled".to_string()
};
// retrieve sbot config parameters
let sbot_config = match SbotConfig::read() {
Ok(config) => config,
// build default config if an error is returned from the read attempt
Err(_) => SbotConfig::default(),
};
// split the listen address into ip and port
let (ip, port) = match sbot_config.lis.find(':') {
Some(index) => {
let (ip, port) = sbot_config.lis.split_at(index);
// remove the : from the port
(ip.to_string(), port.replace(':', ""))
}
// if no ':' separator is found, assume an ip has been configured (without port)
None => (sbot_config.lis.to_string(), String::new()),
};
(run_on_startup, sbot_config, ip, port)
}
/// Scuttlebutt settings menu template builder.
pub fn build_template(request: &Request) -> PreEscaped<String> {
// check for flash cookies; will be (None, None) if no flash cookies are found
let (flash_name, flash_msg) = request.retrieve_flash();
let (run_on_startup, sbot_config, ip, port) = read_status_and_config();
let menu_template = html! {
(PreEscaped("<!-- SBOT CONFIGURATION FORM -->"))
div class="card center" {
form id="sbotConfig" class="center" action="/settings/scuttlebutt/configure" method="post" {
div class="center" style="display: flex; flex-direction: column; margin-bottom: 2rem;" title="Number of hops to replicate" {
label for="hops" class="label-small font-gray" { "HOPS" }
div id="hops" style="display: flex; justify-content: space-evenly;" {
div {
@if sbot_config.hops == 0 {
input type="radio" id="hops_0" name="hops" value="0" checked;
} @else {
input type="radio" id="hops_0" name="hops" value="0";
}
label class="font-normal" for="hops_0" { "0" }
}
div {
@if sbot_config.hops == 1 {
input type="radio" id="hops_1" name="hops" value="1" checked;
} @else {
input type="radio" id="hops_1" name="hops" value="1";
}
label class="font-normal" for="hops_1" { "1" }
}
div {
@if sbot_config.hops == 2 {
input type="radio" id="hops_2" name="hops" value="2" checked;
} @else {
input type="radio" id="hops_2" name="hops" value="2";
}
label class="font-normal" for="hops_2" { "2" }
}
div {
@if sbot_config.hops == 3 {
input type="radio" id="hops_3" name="hops" value="3" checked;
} @else {
input type="radio" id="hops_3" name="hops" value="3";
}
label class="font-normal" for="hops_3" { "3" }
}
div {
@if sbot_config.hops == 4 {
input type="radio" id="hops_4" name="hops" value="4" checked;
} @else {
input type="radio" id="hops_4" name="hops" value="4";
}
label class="font-normal" for="hops_4" { "4" }
}
}
}
div class="center" style="display: flex; justify-content: space-between;" {
div style="display: flex; flex-direction: column; width: 60%; margin-bottom: 2rem;" title="IP address on which the sbot runs" {
label for="ip" class="label-small font-gray" { "IP ADDRESS" }
input type="text" id="ip" name="lis_ip" value=(ip);
}
div style="display: flex; flex-direction: column; width: 20%; margin-bottom: 2rem;" title="Port on which the sbot runs" {
label for="port" class="label-small font-gray" { "PORT" }
input type="text" id="port" name="lis_port" value=(port);
}
}
div class="center" style="display: flex; flex-direction: column; margin-bottom: 2rem;" title="Network key (aka 'caps key') to define the Scuttleverse in which the sbot operates in" {
label for="network_key" class="label-small font-gray" { "NETWORK KEY" }
input type="text" id="network_key" name="shscap" value=(sbot_config.shscap);
}
div class="center" style="display: flex; flex-direction: column; margin-bottom: 2rem;" title="Directory in which the sbot database is saved" {
label for="database_dir" class="label-small font-gray" { "DATABASE DIRECTORY" }
input type="text" id="database_dir" name="repo" value=(sbot_config.repo);
}
div class="center" {
@if sbot_config.localadv {
input type="checkbox" id="lanBroadcast" style="margin-bottom: 1rem;" name="localadv" checked;
} @else {
input type="checkbox" id="lanBroadcast" style="margin-bottom: 1rem;" name="localadv";
}
label class="font-normal" for="lanBroadcast" title="Broadcast the IP and port of this sbot instance so that local peers can discovery it and attempt to connect" {
"Enable LAN Broadcasting"
}
br;
@if sbot_config.localdiscov {
input type="checkbox" id="lanDiscovery" style="margin-bottom: 1rem;" name="localdiscov" checked;
} @else {
input type="checkbox" id="lanDiscovery" style="margin-bottom: 1rem;" name="localdiscov";
}
label class="font-normal" for="lanDiscovery" title="Listen for the presence of local peers and attempt to connect if found" { "Enable LAN Discovery" }
br;
@if run_on_startup == "enabled" {
input type="checkbox" id="startup" style="margin-bottom: 1rem;" name="startup" checked;
} @else {
input type="checkbox" id="startup" style="margin-bottom: 1rem;" name="startup";
}
label class="font-normal" for="startup" title="Run the pub automatically on system startup" { "Run pub when computer starts" }
br;
@if sbot_config.repair {
input type="checkbox" id="repair" name="repair" checked;
} @else {
input type="checkbox" id="repair" name="repair";
}
label class="font-normal" for="repair" title="Attempt to repair the filesystem when starting the pub" { "Attempt filesystem repair when pub starts" }
}
(PreEscaped("<!-- hidden input elements for all other config variables -->"))
input type="hidden" id="debugdir" name="debugdir" value=(sbot_config.debugdir);
input type="hidden" id="hmac" name="hmac" value=(sbot_config.hmac);
input type="hidden" id="wslis" name="wslis" value=(sbot_config.wslis);
input type="hidden" id="debuglis" name="debuglis" value=(sbot_config.debuglis);
input type="hidden" id="enable_ebt" name="enable_ebt" value=(sbot_config.enable_ebt);
input type="hidden" id="promisc" name="promisc" value=(sbot_config.promisc);
input type="hidden" id="nounixsock" name="nounixsock" value=(sbot_config.nounixsock);
(PreEscaped("<!-- BUTTONS -->"))
input id="saveConfig" class="button button-primary center" style="margin-top: 2rem;" type="submit" title="Save configuration parameters to file" value="Save";
input id="saveRestartConfig" class="button button-primary center" type="submit" title="Save configuration parameters to file and then (re)start the pub" value="Save & Restart" formaction="/settings/scuttlebutt/configure/restart";
a id="restoreDefaults" class="button button-warning center" href="/settings/scuttlebutt/configure/default" title="Restore default configuration parameters and save them to file" { "Restore Defaults" }
}
// render flash message if cookies were found in the request
@if let (Some(name), Some(msg)) = (flash_name, flash_msg) {
(PreEscaped("<!-- FLASH MESSAGE -->"))
(templates::flash::build_template(name, msg))
}
}
};
// wrap the nav bars around the settings menu template content
// parameters are template, title and back url
let body =
templates::nav::build_template(menu_template, "Scuttlebutt Settings", Some("/settings"));
// query the current theme so we can pass it into the base template builder
let theme = theme::get_theme();
// render the base template with the provided body
templates::base::build_template(body, theme)
}
/// Parse the sbot configuration values and write to file.
pub fn handle_form(request: &Request, restart: bool) -> Response {
// query the request body for form data
// return a 400 error if the admin_id field is missing
let data = try_or_400!(post_input!(request, {
repo: String,
debugdir: String,
shscap: String,
hmac: String,
hops: u8,
lis_ip: String,
lis_port: String,
wslis: String,
debuglis: String,
localadv: bool,
localdiscov: bool,
enable_ebt: bool,
promisc: bool,
nounixsock: bool,
startup: bool,
repair: bool,
}));
// concat the ip and port for listen address
let lis = format!("{}:{}", data.lis_ip, data.lis_port);
// instantiate `SbotConfig` from form data
let config = SbotConfig {
lis,
hops: data.hops,
repo: data.repo,
debugdir: data.debugdir,
shscap: data.shscap,
localadv: data.localadv,
localdiscov: data.localdiscov,
hmac: data.hmac,
wslis: data.wslis,
debuglis: data.debuglis,
enable_ebt: data.enable_ebt,
promisc: data.promisc,
nounixsock: data.nounixsock,
repair: data.repair,
};
match data.startup {
true => {
debug!("Enabling go-sbot.service");
if let Err(e) = sbot::systemctl_sbot_cmd("enable") {
warn!("Failed to enable go-sbot.service: {}", e)
}
}
false => {
debug!("Disabling go-sbot.service");
if let Err(e) = sbot::systemctl_sbot_cmd("disable") {
warn!("Failed to disable go-sbot.service: {}", e)
}
}
};
// write config to file
let (name, msg) = match SbotConfig::write(config) {
Ok(_) => {
// if `restart` query parameter is `true`, attempt sbot process (re)start
if restart {
// returns a tuple of (name, msg) based on the outcome (success or error)
sbot::restart_sbot_process()
} else {
("success".to_string(), "Updated configuration".to_string())
}
}
Err(err) => (
"error".to_string(),
format!("Failed to update configuration: {}", err),
),
};
let (flash_name, flash_msg) = (format!("flash_name={}", name), format!("flash_msg={}", msg));
Response::redirect_303("/settings/scuttlebutt/configure").add_flash(flash_name, flash_msg)
}

View File

@ -0,0 +1,21 @@
use peach_lib::sbot::SbotConfig;
use rouille::Response;
use crate::utils::flash::FlashResponse;
/// Set default configuration parameters for the go-sbot and save them to file.
pub fn write_config() -> Response {
let default_config = SbotConfig::default();
// write default config to file
let (name, msg) = match SbotConfig::write(default_config) {
Ok(_) => ("success", "Restored default configuration".to_string()),
Err(e) => (
"error",
format!("Failed to restore default configuration: {}", e),
),
};
let (flash_name, flash_msg) = (format!("flash_name={}", name), format!("flash_msg={}", msg));
Response::redirect_303("/settings/scuttlebutt/configure").add_flash(flash_name, flash_msg)
}

View File

@ -0,0 +1,66 @@
use maud::{html, PreEscaped};
use peach_lib::sbot::SbotStatus;
use rouille::Request;
use crate::{
templates,
utils::{flash::FlashRequest, theme},
};
/// Read the status of the go-sbot service and render buttons accordingly.
fn render_process_buttons() -> PreEscaped<String> {
// retrieve go-sbot systemd process status
let sbot_status = SbotStatus::read();
html! {
// render the stop and restart buttons if sbot process is currently active
@if let Ok(status) = sbot_status {
@if status.state == Some("active".to_string()) {
a id="stop" class="button button-primary center" href="/settings/scuttlebutt/stop" title="Stop Sbot" { "Stop Sbot" }
a id="restart" class="button button-primary center" href="/settings/scuttlebutt/restart" title="Restart Sbot" { "Restart Sbot" }
// render the start button if sbot process is currently inactive
} @else {
a id="start" class="button button-primary center" href="/settings/scuttlebutt/start" title="Start Sbot" { "Start Sbot" }
}
// render the start button if an error was returned by the status query
} @else {
a id="start" class="button button-primary center" href="/settings/scuttlebutt/start" title="Start Sbot" { "Start Sbot" }
}
}
}
// ROUTE: /settings/scuttlebutt
/// Scuttlebutt settings menu template builder.
pub fn build_template(request: &Request) -> PreEscaped<String> {
// check for flash cookies; will be (None, None) if no flash cookies are found
let (flash_name, flash_msg) = request.retrieve_flash();
let menu_template = html! {
(PreEscaped("<!-- SCUTTLEBUTT SETTINGS MENU -->"))
div class="card center" {
(PreEscaped("<!-- BUTTONS -->"))
div id="settingsButtons" {
a id="configureSbot" class="button button-primary center" href="/settings/scuttlebutt/configure" title="Configure Sbot" { "Configure Sbot" }
// conditionally render the start / stop / restart buttons
(render_process_buttons())
}
// render flash message if cookies were found in the request
@if let (Some(name), Some(msg)) = (flash_name, flash_msg) {
(PreEscaped("<!-- FLASH MESSAGE -->"))
(templates::flash::build_template(name, msg))
}
}
};
// wrap the nav bars around the settings menu template content
// parameters are template, title and back url
let body =
templates::nav::build_template(menu_template, "Scuttlebutt Settings", Some("/settings"));
// query the current theme so we can pass it into the base template builder
let theme = theme::get_theme();
// render the base template with the provided body
templates::base::build_template(body, theme)
}

View File

@ -0,0 +1,6 @@
pub mod configure;
pub mod default;
pub mod menu;
pub mod restart;
pub mod start;
pub mod stop;

View File

@ -0,0 +1,33 @@
use log::info;
use rouille::Response;
use crate::utils::{flash::FlashResponse, sbot::systemctl_sbot_cmd};
// ROUTE: /settings/scuttlebutt/restart
/// Attempt to restart the go-sbot.service process.
/// Redirect to the Scuttlebutt settings menu and communicate the outcome of
/// the attempt via a flash message.
pub fn restart_sbot() -> Response {
info!("Restarting go-sbot.service");
let (flash_name, flash_msg) = match systemctl_sbot_cmd("stop") {
// if stop was successful, try to start the process
Ok(_) => match systemctl_sbot_cmd("start") {
Ok(_) => (
"flash_name=success".to_string(),
"flash_msg=Sbot process has been restarted".to_string(),
),
Err(e) => (
"flash_name=error".to_string(),
format!("flash_msg=Failed to start the sbot process: {}", e),
),
},
Err(e) => (
"flash_name=error".to_string(),
format!("flash_msg=Failed to stop the sbot process: {}", e),
),
};
// redirect to the scuttlebutt settings menu
Response::redirect_303("/settings/scuttlebutt").add_flash(flash_name, flash_msg)
}

View File

@ -0,0 +1,26 @@
use log::info;
use rouille::Response;
use crate::utils::{flash::FlashResponse, sbot::systemctl_sbot_cmd};
// ROUTE: /settings/scuttlebutt/start
/// Attempt to start the go-sbot.service process.
/// Redirect to the Scuttlebutt settings menu and communicate the outcome of
/// the attempt via a flash message.
pub fn start_sbot() -> Response {
info!("Starting go-sbot.service");
let (flash_name, flash_msg) = match systemctl_sbot_cmd("start") {
Ok(_) => (
"flash_name=success".to_string(),
"flash_msg=Sbot process has been started".to_string(),
),
Err(_) => (
"flash_name=error".to_string(),
"flash_msg=Failed to start the sbot process".to_string(),
),
};
// redirect to the scuttlebutt settings menu
Response::redirect_303("/settings/scuttlebutt").add_flash(flash_name, flash_msg)
}

View File

@ -0,0 +1,26 @@
use log::info;
use rouille::Response;
use crate::utils::{flash::FlashResponse, sbot::systemctl_sbot_cmd};
// ROUTE: /settings/scuttlebutt/stop
/// Attempt to stop the go-sbot.service process.
/// Redirect to the Scuttlebutt settings menu and communicate the outcome of
/// the attempt via a flash message.
pub fn stop_sbot() -> Response {
info!("Stopping go-sbot.service");
let (flash_name, flash_msg) = match systemctl_sbot_cmd("stop") {
Ok(_) => (
"flash_name=success".to_string(),
"flash_msg=Sbot process has been stopped".to_string(),
),
Err(_) => (
"flash_name=error".to_string(),
"flash_msg=Failed to stop the sbot process".to_string(),
),
};
// redirect to the scuttlebutt settings menu
Response::redirect_303("/settings/scuttlebutt").add_flash(flash_name, flash_msg)
}

View File

@ -1,16 +1,16 @@
use rocket::{get, response::Redirect};
use rouille::Response;
use crate::routes::authentication::Authenticated;
use crate::{utils, utils::Theme};
use crate::utils::{theme, theme::Theme};
// ROUTE: /settings/theme/{theme}
/// Set the user-interface theme according to the query parameter value.
#[get("/theme?<theme>")]
pub fn set_theme(_auth: Authenticated, theme: &str) -> Redirect {
match theme {
"light" => utils::set_theme(Theme::Light),
"dark" => utils::set_theme(Theme::Dark),
pub fn set_theme(theme: String) -> Response {
match theme.as_str() {
"light" => theme::set_theme(Theme::Light),
"dark" => theme::set_theme(Theme::Dark),
_ => (),
}
Redirect::to("/")
Response::redirect_303("/")
}

View File

@ -1,3 +1,3 @@
pub mod device;
pub mod network;
//pub mod device;
//pub mod network;
pub mod scuttlebutt;

View File

@ -1,37 +1,284 @@
use rocket::{get, State};
use rocket_dyn_templates::Template;
use maud::{html, Markup, PreEscaped};
use peach_lib::sbot::{SbotConfig, SbotStatus};
use crate::routes::authentication::Authenticated;
use crate::{context::scuttlebutt::StatusContext, RocketConfig};
use crate::{
templates,
utils::{sbot, theme},
};
// HELPERS AND ROUTES FOR /status/scuttlebutt
// HTML RENDERING FOR ELEMENTS
#[get("/scuttlebutt")]
pub async fn scuttlebutt_status(_auth: Authenticated, config: &State<RocketConfig>) -> Template {
let context = StatusContext::build().await;
// TODO: refactor this to make better use of splices
// https://maud.lambda.xyz/splices-toggles.html
let back = if config.standalone_mode {
// return to home page
Some("/".to_string())
} else {
// return to status menu
Some("/status".to_string())
};
match context {
Ok(mut context) => {
// define back arrow url based on mode
context.back = back;
Template::render("status/scuttlebutt", &context)
fn downtime_element(downtime: &Option<String>) -> Markup {
match downtime {
Some(time) => {
html! {
label class="label-small font-gray" for="sbotDowntime" title="go-sbot downtime" style="margin-top: 0.5rem;" { "DOWNTIME" }
p id="sbotDowntime" class="card-text" title="Downtime" { (time) }
}
}
Err(_) => {
let mut context = StatusContext::default();
_ => html! { (PreEscaped("")) },
}
}
// define back arrow url based on mode
context.back = back;
fn uptime_element(uptime: &Option<String>) -> Markup {
match uptime {
Some(time) => {
html! {
label class="label-small font-gray" for="sbotUptime" title="go-sbot uptime" style="margin-top: 0.5rem;" { "UPTIME" }
p id="sbotUptime" class="card-text" title="Uptime" { (time) }
}
}
_ => html! { (PreEscaped("")) },
}
}
Template::render("status/scuttlebutt", &context)
fn run_on_startup_element(boot_state: &Option<String>) -> Markup {
match boot_state {
Some(state) if state == "enabled" => {
html! {
p id="runOnStartup" class="card-text" title="Enabled" { "Enabled" }
}
}
_ => {
html! {
p id="runOnStartup" class="card-text" title="Disabled" { "Disabled" }
}
}
}
}
fn database_element(state: &str) -> Markup {
// retrieve the sequence number of the latest message in the sbot database
let sequence_num = sbot::latest_sequence_number();
match (state, sequence_num) {
// if the state is "active" and latest_sequence_number() was successful
("active", Ok(number)) => {
html! {
label class="card-text" style="margin-right: 5px;" { (number) }
label class="label-small font-gray" { "MESSAGES IN LOCAL DATABASE" }
}
}
(_, _) => html! { label class="label-small font-gray" { "DATABASE UNAVAILABLE" } },
}
}
fn memory_element(memory: Option<u32>) -> Markup {
let (memory, img_class, medium_label_class, small_label_class) = match memory {
Some(mem) => {
// convert memory to mb representation
let memory_rounded = mem / 1024 / 1024;
(
memory_rounded.to_string(),
"icon icon-active",
"label-medium font-normal",
"label-small font-gray",
)
}
_ => (
0.to_string(),
"icon icon-inactive",
"label-medium font-gray",
"label-small font-gray",
),
};
html! {
div class="stack" {
img class=(img_class) title="Memory" src="/icons/ram.png";
div class="flex-grid" style="padding-top: 0.5rem;" {
label class=(medium_label_class) style="padding-right: 3px;" title="Memory usage of the go-sbot process in MB" { (memory) }
label class=(small_label_class) { "MB" }
}
label class=(small_label_class) { "MEMORY" }
}
}
}
fn hops_element() -> Markup {
// retrieve go-sbot systemd process status
let hops = match SbotConfig::read() {
Ok(conf) => conf.hops,
_ => 0,
};
html! {
div class="stack" {
img class="icon icon-active" title="Hops" src="/icons/orbits.png";
div class="flex-grid" style="padding-top: 0.5rem;" {
label class="label-medium font-normal" style="padding-right: 3px;" title="Replication hops" {
(hops)
}
}
label class="label-small font-gray" { "HOPS" }
}
}
}
fn blobs_element(blobstore: Option<u64>) -> Markup {
let blobstore_size = match blobstore {
// convert blobstore size to mb representation
Some(blobs) => blobs / 1024 / 1024,
None => 0,
};
html! {
div class="stack" {
img class="icon icon-active" title="Blobs" src="/icons/image-file.png";
div class="flex-grid" style="padding-top: 0.5rem;" {
label class="label-medium font-normal" style="padding-right: 3px;" title="Blobstore size in MB" { (blobstore_size) }
label class="label-small font-normal" { "MB" }
}
label class="label-small font-gray" { "BLOBSTORE" }
}
}
}
/// Read the state of the go-sbot process and define status-related
/// elements accordingly.
fn render_status_elements<'a>() -> (
String,
&'a str,
&'a str,
Markup,
Markup,
Markup,
Markup,
Markup,
) {
// retrieve go-sbot systemd process status
match SbotStatus::read() {
Ok(status) if status.state == Some("active".to_string()) => (
"ACTIVE".to_string(),
"capsule capsule-container border-success",
"center icon icon-active",
uptime_element(&status.uptime),
run_on_startup_element(&status.boot_state),
database_element("active"),
memory_element(status.memory),
blobs_element(status.blobstore),
),
Ok(status) if status.state == Some("inactive".to_string()) => (
"INACTIVE".to_string(),
"capsule capsule-container border-warning",
"center icon icon-inactive",
downtime_element(&status.downtime),
run_on_startup_element(&status.boot_state),
database_element("inactive"),
memory_element(None),
blobs_element(status.blobstore),
),
// state is neither active nor inactive (might be "failed")
Ok(status) if status.state.is_some() => (
status.state.unwrap().to_uppercase(),
"capsule capsule-container border-danger",
"center icon icon-inactive",
downtime_element(&None),
run_on_startup_element(&status.boot_state),
database_element("failed"),
memory_element(None),
blobs_element(status.blobstore),
),
Ok(status) if status.state.is_none() => (
"UNAVAILABLE".to_string(),
"capsule capsule-container border-danger",
"center icon icon-inactive",
downtime_element(&None),
run_on_startup_element(&status.boot_state),
database_element("unavailable"),
memory_element(None),
blobs_element(status.blobstore),
),
_ => (
"PROCESS QUERY FAILED".to_string(),
"capsule capsule-container border-danger",
"center icon icon-inactive",
downtime_element(&None),
run_on_startup_element(&None),
database_element("error"),
memory_element(None),
blobs_element(None),
),
}
}
/// Scuttlebutt status template builder.
pub fn build_template() -> PreEscaped<String> {
let (
sbot_state,
capsule_class,
sbot_icon_class,
uptime_downtime_element,
run_on_startup_element,
database_element,
memory_element,
blobs_element,
) = render_status_elements();
let hops_element = hops_element();
let status_template = html! {
(PreEscaped("<!-- SCUTTLEBUTT STATUS -->"))
div class="card center" {
(PreEscaped("<!-- SBOT INFO BOX -->"))
div class=(capsule_class) {
(PreEscaped("<!-- SBOT STATUS GRID -->"))
div class="two-grid" title="go-sbot process state" {
(PreEscaped("<!-- top-right config icon -->"))
a class="link two-grid-top-right" href="/settings/scuttlebutt" title="Configure Scuttlebutt settings" {
img id="configureNetworking" class="icon-small icon-active" src="/icons/cog.svg" alt="Configure";
}
(PreEscaped("<!-- left column -->"))
(PreEscaped("<!-- go-sbot state icon with label -->"))
div class="grid-column-1" {
img id="sbotStateIcon" class=(sbot_icon_class) src="/icons/hermies.svg" alt="Hermies";
label id="sbotStateLabel" for="sbotStateIcon" class="center label-small font-gray" style="margin-top: 0.5rem;" title="Sbot state" {
(sbot_state)
}
}
(PreEscaped("<!-- right column -->"))
(PreEscaped("<!-- go-sbot version and uptime / downtime with labels -->"))
div class="grid-column-2" {
label class="label-small font-gray" for="sbotVersion" title="go-sbot version" {
"VERSION"
}
p id="sbotVersion" class="card-text" title="Version" {
"1.1.0-alpha"
}
(uptime_downtime_element)
label class="label-small font-gray" for="sbotBootState" title="go-sbot boot state" style="margin-top: 0.5rem;" { "RUN ON STARTUP" }
(run_on_startup_element)
}
}
hr style="color: var(--light-gray);";
div id="middleSection" style="margin-top: 1rem;" {
div id="sbotInfo" class="center" style="display: flex; justify-content: space-between; width: 90%;" {
div class="center" style="display: flex; align-items: last baseline;" {
(database_element)
}
}
}
hr style="color: var(--light-gray);";
(PreEscaped("<!-- THREE-ACROSS STACK -->"))
div class="three-grid card-container" style="margin-top: 1rem;" {
(hops_element)
(blobs_element)
(memory_element)
}
}
}
};
// wrap the nav bars around the settings menu template content
// parameters are template, title and back url
let body = templates::nav::build_template(status_template, "Settings", Some("/"));
// query the current theme so we can pass it into the base template builder
let theme = theme::get_theme();
// render the base template with the provided body
templates::base::build_template(body, theme)
}

View File

@ -0,0 +1,23 @@
use maud::{html, PreEscaped, DOCTYPE};
/// Base template builder.
///
/// Takes an HTML body as input and splices it into the base template.
pub fn build_template(body: PreEscaped<String>, theme: String) -> PreEscaped<String> {
html! {
(DOCTYPE)
html lang="en" data-theme=(theme);
head {
meta charset="utf-8";
meta name="description" content="PeachCloud web interface";
meta name="author" content="glyph and notplants";
meta name="viewport" content="width=devide-width, initial-scale=1.0";
link rel="stylesheet" href="/css/peachcloud.css";
link rel="stylesheet" href="/css/_variables.css";
title { "PeachCloud" }
}
body {
(body)
}
}
}

View File

@ -0,0 +1,27 @@
use maud::{html, Markup, PreEscaped};
/// Sbot error template builder.
///
/// Display a message to the operator when an sbot command returns an error.
pub fn build_template(error_msg: String) -> Markup {
html! {
(PreEscaped("<!-- SBOT ERROR -->"))
div class="card center" {
div class="capsule capsule-container border-danger center-text" {
p class="card-text" style="font-size: var(--font-size-4);" {
"Sbot Error"
}
p class="card-text" { (error_msg) }
p class="card-text" {
"Visit the "
strong {
a href="/settings/scuttlebutt" class="link" {
"Scuttlebutt settings menu"
}
}
" to start the Sbot and then try again."
}
}
}
}
}

View File

@ -0,0 +1,22 @@
use maud::{html, Markup};
/// Flash message template builder.
///
/// Render a flash elements based on the given flash name and message.
pub fn build_template(flash_name: &str, flash_msg: &str) -> Markup {
let common_classes = "capsule center center-text flash-message font-normal ";
let border_class = match flash_name {
"success" => "border-success",
"info" => "border-info",
"warning" => "border-warning",
"error" => "border-danger",
_ => "",
};
html! {
div class={ (common_classes) (border_class) } {
(flash_msg)
}
}
}

View File

@ -0,0 +1,28 @@
use maud::{html, Markup, PreEscaped};
/// Sbot inactive template builder.
///
/// Display a message to the operator when the sbot is inactive and
/// therefore some functionality is not available.
pub fn build_template(unavailable_msg: &str) -> Markup {
html! {
(PreEscaped("<!-- SBOT INACTIVE -->"))
div class="card center" {
div class="capsule capsule-container border-warning center-text" {
p class="card-text" style="font-size: var(--font-size-4);" {
"Sbot Inactive"
}
p class="card-text" { (unavailable_msg) }
p class="card-text" {
"Visit the "
strong {
a href="/settings/scuttlebutt" class="link" {
"Scuttlebutt settings menu"
}
}
" to start the Sbot and then try again."
}
}
}
}
}

View File

@ -0,0 +1,7 @@
pub mod base;
pub mod error;
pub mod flash;
pub mod inactive;
pub mod nav;
pub mod not_found;
pub mod peers_list;

View File

@ -0,0 +1,63 @@
use maud::{html, PreEscaped};
use crate::utils::theme;
/// Navigation template builder.
///
/// Takes the main HTML content as input and splices it into the navigation template.
pub fn build_template(
main: PreEscaped<String>,
title: &str,
back: Option<&str>,
) -> PreEscaped<String> {
// retrieve the current theme value
let theme = theme::get_theme();
// conditionally render the hermies icon and theme-switcher icon with correct link
let (hermies, switcher) = match theme.as_str() {
// if we're using the dark theme, render light icons and "light" query param
"dark" => (
"/icons/hermies_hex_light.svg",
html! {
a class="nav-item" href="/settings/theme/light" {
img class="icon-medium nav-icon-right icon-active" title="Toggle theme" src="/icons/sun.png" alt="Sun";
}
},
),
// otherwise, assume we're using light mode
_ => (
"/icons/hermies_hex.svg",
html! {
a class="nav-item" href="/settings/theme/dark" {
img class="icon-medium nav-icon-right icon-active" title="Toggle theme" src="/icons/moon.png" alt="Moon";
}
},
),
};
html! {
(PreEscaped("<!-- Top navigation bar -->"))
nav class="nav-bar" {
a class="nav-item" href=[back] title="Back" {
img class="icon-medium nav-icon-left icon-active" src="/icons/back.svg" alt="Back";
}
h1 class="nav-title" { (title) }
a class="nav-item" id="logoutButton" href="/auth/logout" title="Logout" {
img class="icon-medium nav-icon-right icon-active" src="/icons/enter.svg" alt="Logout";
}
}
(PreEscaped("<!-- Main content container -->"))
main { (main) }
(PreEscaped("<!-- Bottom navigation bar -->"))
nav class="nav-bar" {
a class="nav-item" href="https://scuttlebutt.nz/" {
img class="icon-medium nav-icon-left" title="Scuttlebutt Website" src=(hermies) alt="Secure Scuttlebutt";
}
a class="nav-item" href="/" {
img class="icon nav-icon-left" src="/icons/peach-icon.png" alt="PeachCloud" title="Home";
}
// render the pre-defined theme-switcher icon
(switcher)
}
}
}

View File

@ -0,0 +1,34 @@
use maud::{html, PreEscaped};
use crate::{templates, utils::theme};
// 404 ROUTE NOT FOUND CATCHER
/// 404 template builder.
pub fn build_template() -> PreEscaped<String> {
let not_found_template = html! {
div class="card center" {
div class="capsule-container" {
div class="capsule info-border" {
p {
"No PeachCloud resource exists for this URL. Please ensure that the URL in the address bar is correct."
}
p {
"Click the back arrow in the top-left or the PeachCloud logo at the bottom of your screen to return Home."
}
}
}
}
};
// wrap the nav bars around the settings menu template content
// parameters are template, title and back url
let body =
templates::nav::build_template(not_found_template, "404: Route Not Found", Some("/"));
// query the current theme so we can pass it into the base template builder
let theme = theme::get_theme();
// render the base template with the provided body
templates::base::build_template(body, theme)
}

View File

@ -0,0 +1,79 @@
use std::collections::HashMap;
use maud::{html, Markup, PreEscaped};
use peach_lib::sbot::SbotStatus;
use crate::{templates, utils::theme};
/// Render an unordered list of peers with one list element for each peer.
fn peers_template(peers: Vec<HashMap<String, String>>) -> Markup {
html! {
ul class="center list" {
@for peer in peers {
@let (name, name_alt) = match peer.get("name") {
Some(name) => (
name.to_owned(),
format!("{}'s profile image", name)
),
None => (
// set a fall-back value for name in case the data is unavailable
"Name unavailable".to_string(),
"Profile image".to_string()
)
};
@let url_safe_peer_id = peer["id"].replace('/', "%2F");
li {
a class="list-item link" href={ "/scuttlebutt/profile/" (url_safe_peer_id) } {
@if peer.get("blob_path").is_some() && peer["blob_exists"] == "true" {
img id="peerImage" class="icon list-icon" src={ "/blob/" (peer["blob_path"]) } alt=(name_alt);
} @else {
// use a placeholder image if we don't have the blob
img id="peerImage" class="icon icon-active list-icon" src="/icons/user.svg" alt="Placeholder profile image";
}
p id="peerName" class="font-normal list-text " { (name) };
label class="label-small label-ellipsis list-label font-gray" for="peerName" title={ (name) "'s public key" } {
(peer["id"])
}
}
}
}
}
}
}
/// Scuttlebutt peers list template builder.
///
/// A list of peers. Currently used to render lists of friends, follows and
/// blocks. The title of the page is set according to the provided parameter.
pub fn build_template(peers: Vec<HashMap<String, String>>, title: &str) -> PreEscaped<String> {
let peer_list_template = match SbotStatus::read() {
// only render the complete peers list if the sbot is active
Ok(status) if status.state == Some("active".to_string()) => {
html! {
div class="card center" {
@if !peers.is_empty() {
// render the peers list template
(peers_template(peers))
} @else {
p class="center-text font-normal" { "None found" }
}
}
}
}
_ => {
// the sbot is not active; render a message instead of the menu
templates::inactive::build_template("Social lists and interactions are unavailable.")
}
};
// wrap the nav bars around the settings menu template content
// parameters are template, title and back url
let body =
templates::nav::build_template(peer_list_template, title, Some("/scuttlebutt/peers"));
// query the current theme so we can pass it into the base template builder
let theme = theme::get_theme();
// render the base template with the provided body
templates::base::build_template(body, theme)
}

View File

@ -1,562 +0,0 @@
use std::fs::File;
use std::io::Read;
use rocket::http::{ContentType, Status};
use rocket::local::blocking::Client;
use rocket::{Build, Config, Rocket};
use super::init_rocket;
// define authentication mode
const DISABLE_AUTH: bool = true;
/// Wrapper around `init_rocket()` to simplify the process of invoking the application with the desired authentication status. This is particularly useful for testing purposes.
fn init_test_rocket(disable_auth: bool) -> Rocket<Build> {
// set authentication based on provided `disable_auth` value
Config::figment().merge(("disable_auth", disable_auth));
init_rocket()
}
// helper function to test correct retrieval and content of a file
fn test_query_file<T>(path: &str, file: T, status: Status)
where
T: Into<Option<&'static str>>,
{
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).unwrap();
let response = client.get(path).dispatch();
assert_eq!(response.status(), status);
let body_data = response.into_bytes();
if let Some(filename) = file.into() {
let expected_data = read_file_content(filename);
assert!(body_data.map_or(false, |s| s == expected_data));
}
}
// helper function to return the content of a file, given a path
fn read_file_content(path: &str) -> Vec<u8> {
let mut fp = File::open(&path).expect(&format!("Can't open {}", path));
let mut file_content = vec![];
fp.read_to_end(&mut file_content)
.expect(&format!("Reading {} failed.", path));
file_content
}
// WEB PAGE ROUTES
#[test]
fn index_html() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client.get("/").dispatch();
assert_eq!(response.status(), Status::Ok);
assert_eq!(response.content_type(), Some(ContentType::HTML));
let body = response.into_string().unwrap();
assert!(body.contains("/peers"));
assert!(body.contains("/profile"));
assert!(body.contains("/private"));
assert!(body.contains("/status"));
assert!(body.contains("/help"));
assert!(body.contains("/settings"));
}
#[test]
fn help_html() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client.get("/help").dispatch();
assert_eq!(response.status(), Status::Ok);
assert_eq!(response.content_type(), Some(ContentType::HTML));
let body = response.into_string().unwrap();
assert!(body.contains("Help"));
}
#[test]
fn login_html() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client.get("/login").dispatch();
assert_eq!(response.status(), Status::Ok);
assert_eq!(response.content_type(), Some(ContentType::HTML));
let body = response.into_string().unwrap();
assert!(body.contains("Login"));
}
#[test]
fn logout_html() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client.get("/logout").dispatch();
// check for 303 status (redirect to "/login")
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(response.content_type(), None);
}
#[test]
fn power_html() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client.get("/power").dispatch();
assert_eq!(response.status(), Status::Ok);
assert_eq!(response.content_type(), Some(ContentType::HTML));
let body = response.into_string().unwrap();
assert!(body.contains("Shutdown Device"));
}
/*
NOTE: these tests are comment-out for the moment, due to the fact that they invoke system commands (resulting in a `sudo` password request during test execution). see if we can find a way to test the results without triggering the shutdown or restart.
#[test]
fn reboot() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client.get("/power/reboot").dispatch();
// check for redirect
assert_eq!(response.status(), Status::SeeOther);
}
#[test]
fn shutdown() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client.get("/power/shutdown").dispatch();
// check for redirect
assert_eq!(response.status(), Status::SeeOther);
}
*/
// SCUTTLEBUTT ROUTES
#[test]
fn block() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client
.post("/scuttlebutt/block")
.header(ContentType::Form)
.body("key=HEqy940T6uB+T+d9Jaa58aNfRzLx9eRWqkZljBmnkmk=.ed25519")
.dispatch();
assert_eq!(response.status(), Status::SeeOther);
}
#[test]
fn blocks_html() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client.get("/scuttlebutt/blocks").dispatch();
assert_eq!(response.status(), Status::Ok);
assert_eq!(response.content_type(), Some(ContentType::HTML));
let body = response.into_string().unwrap();
assert!(body.contains("Blocks"));
}
#[test]
fn follow() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client
.post("/scuttlebutt/follow")
.header(ContentType::Form)
.body("key=@HEqy940T6uB+T+d9Jaa58aNfRzLx9eRWqkZljBmnkmk=.ed25519")
.dispatch();
// ensure we redirect (303)
assert_eq!(response.status(), Status::SeeOther);
}
#[test]
fn follows_html() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client.get("/scuttlebutt/follows").dispatch();
assert_eq!(response.status(), Status::Ok);
assert_eq!(response.content_type(), Some(ContentType::HTML));
let body = response.into_string().unwrap();
assert!(body.contains("Follows"));
}
#[test]
fn followers_html() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client.get("/scuttlebutt/followers").dispatch();
assert_eq!(response.status(), Status::Ok);
assert_eq!(response.content_type(), Some(ContentType::HTML));
let body = response.into_string().unwrap();
assert!(body.contains("Followers"));
}
#[test]
fn friends_html() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client.get("/scuttlebutt/friends").dispatch();
assert_eq!(response.status(), Status::Ok);
assert_eq!(response.content_type(), Some(ContentType::HTML));
let body = response.into_string().unwrap();
assert!(body.contains("Friends"));
}
#[test]
fn peers_html() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client.get("/scuttlebutt/peers").dispatch();
assert_eq!(response.status(), Status::Ok);
assert_eq!(response.content_type(), Some(ContentType::HTML));
let body = response.into_string().unwrap();
assert!(body.contains("Scuttlebutt Peers"));
}
#[test]
fn private_html() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client.get("/scuttlebutt/private").dispatch();
assert_eq!(response.status(), Status::Ok);
assert_eq!(response.content_type(), Some(ContentType::HTML));
let body = response.into_string().unwrap();
assert!(body.contains("Private Messages"));
}
#[test]
fn profile_html() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client.get("/scuttlebutt/profile").dispatch();
assert_eq!(response.status(), Status::Ok);
assert_eq!(response.content_type(), Some(ContentType::HTML));
let body = response.into_string().unwrap();
assert!(body.contains("Profile"));
}
#[test]
fn publish_post() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client
.post("/scuttlebutt/publish")
.header(ContentType::Form)
.body("text='golden ripples in the meshwork'")
.dispatch();
assert_eq!(response.status(), Status::SeeOther);
}
#[test]
fn unfollow() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client
.post("/scuttlebutt/unfollow")
.header(ContentType::Form)
.body("key=@HEqy940T6uB+T+d9Jaa58aNfRzLx9eRWqkZljBmnkmk=.ed25519")
.dispatch();
assert_eq!(response.status(), Status::SeeOther);
}
// ADMIN SETTINGS ROUTES
#[test]
fn admin_settings_menu_html() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client.get("/settings/admin").dispatch();
assert_eq!(response.status(), Status::Ok);
assert_eq!(response.content_type(), Some(ContentType::HTML));
let body = response.into_string().unwrap();
assert!(body.contains("Administrator Settings"));
assert!(body.contains("Change Password"));
assert!(body.contains("Configure Admin"));
}
#[test]
fn add_admin_html() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client.get("/settings/admin/add").dispatch();
assert_eq!(response.status(), Status::Ok);
assert_eq!(response.content_type(), Some(ContentType::HTML));
let body = response.into_string().unwrap();
assert!(body.contains("Add Admin"));
assert!(body.contains("SSB ID"));
assert!(body.contains("Add"));
assert!(body.contains("Cancel"));
}
#[test]
fn add_admin() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client
.post("/settings/admin/add")
.header(ContentType::Form)
.body("ssb_id=@HEqy940T6uB+T+d9Jaa58aNfRzLx9eRWqkZljBmnkmk=.ed25519")
.dispatch();
// check for redirect
assert_eq!(response.status(), Status::SeeOther);
}
#[test]
fn change_password_html() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client.get("/settings/admin/change_password").dispatch();
assert_eq!(response.status(), Status::Ok);
assert_eq!(response.content_type(), Some(ContentType::HTML));
let body = response.into_string().unwrap();
assert!(body.contains("Change Password"));
assert!(body.contains("Current password"));
assert!(body.contains("New password"));
assert!(body.contains("New password duplicate"));
assert!(body.contains("Save"));
}
#[test]
fn configure_admin_html() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client.get("/settings/admin/configure").dispatch();
assert_eq!(response.status(), Status::Ok);
assert_eq!(response.content_type(), Some(ContentType::HTML));
let body = response.into_string().unwrap();
assert!(body.contains("Configure Admin"));
assert!(body.contains("Current Admins"));
assert!(body.contains("Add Admin"));
}
#[test]
fn forgot_password_html() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client.get("/settings/admin/forgot_password").dispatch();
assert_eq!(response.status(), Status::Ok);
assert_eq!(response.content_type(), Some(ContentType::HTML));
let body = response.into_string().unwrap();
assert!(body.contains("Send Password Reset"));
}
// NETWORK SETTINGS ROUTES
#[test]
fn network_settings_menu_html() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client.get("/settings/network").dispatch();
assert_eq!(response.status(), Status::Ok);
assert_eq!(response.content_type(), Some(ContentType::HTML));
let body = response.into_string().unwrap();
assert!(body.contains("Network Configuration"));
}
/*
NOTE: these tests are commented-out for the moment, due to the fact that they
invoke system commands (resulting in a `sudo` password request during
test execution). see if we can find a way to test the results without
triggering the `systemctl` call.
#[test]
fn deploy_ap() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client.get("/settings/network/ap/activate").dispatch();
// check for 303 status (redirect)
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(response.content_type(), None);
}
#[test]
fn deploy_client() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client.get("/settings/network/wifi/activate").dispatch();
// check for 303 status (redirect)
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(response.content_type(), None);
}
*/
#[test]
fn dns_settings_html() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client.get("/settings/network/dns").dispatch();
assert_eq!(response.status(), Status::Ok);
assert_eq!(response.content_type(), Some(ContentType::HTML));
let body = response.into_string().unwrap();
assert!(body.contains("Configure DNS"));
assert!(body.contains("External Domain (optional)"));
assert!(body.contains("Enable Dynamic DNS"));
assert!(body.contains("Dynamic DNS Domain"));
assert!(body.contains("Save"));
}
#[test]
fn list_aps_html() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client.get("/settings/network/wifi").dispatch();
assert_eq!(response.status(), Status::Ok);
assert_eq!(response.content_type(), Some(ContentType::HTML));
let body = response.into_string().unwrap();
assert!(body.contains("WiFi Networks"));
assert!(body.contains("No saved or available networks found."));
}
// TODO: needs further testing once template has been refactored
#[test]
fn ap_details_html() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client.get("/settings/network/wifi?ssid=Home").dispatch();
assert_eq!(response.status(), Status::Ok);
assert_eq!(response.content_type(), Some(ContentType::HTML));
//let body = response.into_string().unwrap();
//assert!(body.contains("Network not found"));
}
#[test]
fn add_ap_html() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client.get("/settings/network/wifi/add").dispatch();
assert_eq!(response.status(), Status::Ok);
assert_eq!(response.content_type(), Some(ContentType::HTML));
let body = response.into_string().unwrap();
assert!(body.contains("Add WiFi Network"));
assert!(body.contains("SSID"));
assert!(body.contains("Password"));
assert!(body.contains("Add"));
assert!(body.contains("Cancel"));
}
#[test]
fn add_ap_ssid_html() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client
.get("/settings/network/wifi/add?ssid=Home")
.dispatch();
assert_eq!(response.status(), Status::Ok);
assert_eq!(response.content_type(), Some(ContentType::HTML));
let body = response.into_string().unwrap();
assert!(body.contains("Add WiFi Network"));
assert!(body.contains("Home"));
assert!(body.contains("Password"));
assert!(body.contains("Add"));
assert!(body.contains("Cancel"));
}
#[test]
fn add_credentials() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client
.post("/settings/network/wifi/add")
.header(ContentType::Form)
.body("ssid=Home&pass=Password")
.dispatch();
assert_eq!(response.status(), Status::Ok);
assert_eq!(response.content_type(), Some(ContentType::HTML));
}
#[test]
fn forget_wifi() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client
.post("/settings/network/wifi/forget")
.header(ContentType::Form)
.body("ssid=Home")
.dispatch();
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(response.content_type(), None);
}
#[test]
fn modify_password() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client
.post("/settings/network/wifi/modify")
.header(ContentType::Form)
.body("ssid=Home&pass=Password")
.dispatch();
assert_eq!(response.status(), Status::SeeOther);
assert_eq!(response.content_type(), None);
}
#[test]
fn data_usage_html() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client.get("/settings/network/wifi/usage").dispatch();
assert_eq!(response.status(), Status::Ok);
assert_eq!(response.content_type(), Some(ContentType::HTML));
let body = response.into_string().unwrap();
assert!(body.contains("Network Data Usage"));
assert!(body.contains("WARNING THRESHOLD"));
assert!(body.contains("Update"));
assert!(body.contains("Cancel"));
}
// SCUTTLEBUTT SETTINGS HTML ROUTES
#[test]
fn scuttlebutt_settings_menu_html() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client.get("/settings/scuttlebutt").dispatch();
assert_eq!(response.status(), Status::Ok);
assert_eq!(response.content_type(), Some(ContentType::HTML));
let body = response.into_string().unwrap();
assert!(body.contains("Scuttlebutt Settings"));
assert!(body.contains("Configure Sbot"));
}
// STATUS HTML ROUTES
#[test]
fn status_html() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client.get("/status").dispatch();
assert_eq!(response.status(), Status::Ok);
assert_eq!(response.content_type(), Some(ContentType::HTML));
let body = response.into_string().unwrap();
assert!(body.contains("Device Status"));
assert!(body.contains("Networking"));
assert!(body.contains("Display"));
assert!(body.contains("Statistics"));
}
#[test]
fn network_status_html() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).expect("valid rocket instance");
let response = client.get("/status/network").dispatch();
assert_eq!(response.status(), Status::Ok);
assert_eq!(response.content_type(), Some(ContentType::HTML));
let body = response.into_string().unwrap();
assert!(body.contains("Network Status"));
assert!(body.contains("Mode"));
assert!(body.contains("SSID"));
assert!(body.contains("IP"));
assert!(body.contains("DOWNLOAD"));
assert!(body.contains("UPLOAD"));
}
// FILE TESTS
#[test]
fn nested_file() {
test_query_file(
"/images/placeholder.txt",
"static/images/placeholder.txt",
Status::Ok,
);
test_query_file(
"/images/placeholder.txt?v=1",
"static/images/placeholder.txt",
Status::Ok,
);
test_query_file(
"/images/placeholder.txt?v=1&a=b",
"static/images/placeholder.txt",
Status::Ok,
);
}
#[test]
fn icon_file() {
test_query_file(
"/icons/peach-icon.png",
"static/icons/peach-icon.png",
Status::Ok,
);
}
#[test]
fn invalid_path() {
test_query_file("/thou_shalt_not_exist", None, Status::NotFound);
test_query_file("/thou_shalt_not_exist", None, Status::NotFound);
test_query_file("/thou/shalt/not/exist?a=b&c=d", None, Status::NotFound);
}
#[test]
fn invalid_get_request() {
let client = Client::tracked(init_test_rocket(DISABLE_AUTH)).unwrap();
// try to get a path that doesn't exist
let res = client
.get("/message/99")
.header(ContentType::JSON)
.dispatch();
assert_eq!(res.status(), Status::NotFound);
let body = res.into_string().unwrap();
assert!(body.contains("404: Page Not Found"));
assert!(body.contains("No PeachCloud resource exists for this URL."));
}

View File

@ -1,124 +0,0 @@
pub mod monitor;
use std::io::prelude::*;
use std::{fs, fs::File, path::Path};
use dirs;
use golgi::blobs;
use log::info;
use peach_lib::sbot::SbotConfig;
use rocket::{
fs::TempFile,
response::{Redirect, Responder},
serde::Serialize,
};
use rocket_dyn_templates::Template;
use temporary::Directory;
use crate::{error::PeachWebError, THEME};
// FILEPATH FUNCTIONS
// return the path of the ssb-go directory
pub fn get_go_ssb_path() -> Result<String, PeachWebError> {
let go_ssb_path = match SbotConfig::read() {
Ok(conf) => conf.repo,
// return the default path if unable to read `config.toml`
Err(_) => {
// determine the home directory
let mut home_path = dirs::home_dir().ok_or_else(|| PeachWebError::HomeDir)?;
// add the go-ssb subdirectory
home_path.push(".ssb-go");
// convert the PathBuf to a String
home_path
.into_os_string()
.into_string()
.map_err(|_| PeachWebError::OsString)?
}
};
Ok(go_ssb_path)
}
// check whether a blob is in the blobstore
pub async fn blob_is_stored_locally(blob_path: &str) -> Result<bool, PeachWebError> {
let go_ssb_path = get_go_ssb_path()?;
let complete_path = format!("{}/blobs/sha256/{}", go_ssb_path, blob_path);
let blob_exists_locally = Path::new(&complete_path).exists();
Ok(blob_exists_locally)
}
// take the path to a file, add it to the blobstore and return the blob id
pub async fn write_blob_to_store(file: &mut TempFile<'_>) -> Result<String, PeachWebError> {
// create temporary directory and path
let temp_dir = Directory::new("blob")?;
// we performed a `file.name().is_some()` check before calling `write_blob_to_store`
// so it should be safe to do a simple unwrap here
let filename = file.name().expect("retrieving filename from uploaded file");
let temp_path = temp_dir.join(filename);
// write file to temporary path
file.persist_to(&temp_path).await?;
// open the file and read it into a buffer
let mut file = File::open(&temp_path)?;
let mut buffer = Vec::new();
file.read_to_end(&mut buffer)?;
// hash the bytes representing the file
let (hex_hash, blob_id) = blobs::hash_blob(&buffer)?;
// define the blobstore path and blob filename
let (blob_dir, blob_filename) = hex_hash.split_at(2);
let go_ssb_path = get_go_ssb_path()?;
let blobstore_sub_dir = format!("{}/blobs/sha256/{}", go_ssb_path, blob_dir);
// create the blobstore sub-directory
fs::create_dir_all(&blobstore_sub_dir)?;
// copy the file to the blobstore
let blob_path = format!("{}/{}", blobstore_sub_dir, blob_filename);
fs::copy(temp_path, blob_path)?;
Ok(blob_id)
}
// THEME FUNCTIONS
#[derive(Debug, Copy, Clone)]
pub enum Theme {
Light,
Dark,
}
pub fn get_theme() -> String {
let current_theme = THEME.read().unwrap();
match *current_theme {
Theme::Dark => "dark".to_string(),
_ => "light".to_string(),
}
}
pub fn set_theme(theme: Theme) {
info!("set ui theme to: {:?}", theme);
let mut writable_theme = THEME.write().unwrap();
*writable_theme = theme;
}
// HELPER FUNCTIONS
#[derive(Debug, Serialize)]
pub struct FlashContext {
pub flash_name: Option<String>,
pub flash_msg: Option<String>,
}
/// A helper enum which allows routes to either return a Template or a Redirect
/// from: https://github.com/SergioBenitez/Rocket/issues/253#issuecomment-532356066
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Responder)]
pub enum TemplateOrRedirect {
Template(Template),
Redirect(Redirect),
}

View File

@ -0,0 +1,50 @@
use rouille::{input, Request, Response};
/// Flash message trait for `Request`.
pub trait FlashRequest {
/// Retrieve the flash message cookie values from a `Request`.
fn retrieve_flash(&self) -> (Option<&str>, Option<&str>);
}
impl FlashRequest for Request {
fn retrieve_flash(&self) -> (Option<&str>, Option<&str>) {
// check for flash cookies
let flash_name = input::cookies(self)
.find(|&(n, _)| n == "flash_name")
// return the value of the cookie (key is already known)
.map(|key_val| key_val.1);
let flash_msg = input::cookies(self)
.find(|&(n, _)| n == "flash_msg")
.map(|key_val| key_val.1);
(flash_name, flash_msg)
}
}
/// Flash message trait for `Response`.
pub trait FlashResponse {
/// Add flash message cookies to a `Response`.
fn add_flash(self, flash_name: String, flash_msg: String) -> Response;
/// Reset flash message cookie values for a `Response`.
fn reset_flash(self) -> Response;
}
impl FlashResponse for Response {
fn add_flash(self, flash_name: String, flash_msg: String) -> Response {
// set the flash cookie headers
self.with_additional_header("Set-Cookie", format!("{}; Max-Age=1", flash_name))
.with_additional_header("Set-Cookie", format!("{}; Max-Age=1", flash_msg))
}
fn reset_flash(self) -> Response {
// set blank cookies to clear the flash msg from the previous request
self.with_additional_header(
"Set-Cookie",
"flash_name=; Max-Age=0; Expires=Fri, 21 Aug 1987 12:00:00 UTC",
)
.with_additional_header(
"Set-Cookie",
"flash_msg=; Max-Age=0; Expires=Fri, 21 Aug 1987 12:00:00 UTC",
)
}
}

View File

@ -0,0 +1,3 @@
pub mod flash;
pub mod sbot;
pub mod theme;

View File

@ -1,198 +0,0 @@
// Monitor data transmission totals, set thresholds and check alert flags
use std::convert::TryInto;
use nest::{Error, Store, Value};
use rocket::form::FromForm;
use rocket::serde::{Deserialize, Serialize};
use serde_json::json;
/// Network traffic data total
#[derive(Debug, Serialize)]
pub struct Data {
pub total: u64, // total traffic in bytes
}
impl Data {
/// Retrieve network traffic data values from the store
fn get(store: &Store) -> Data {
// retrieve previous network traffic statistics
let data_stored = match store.get(&["net", "traffic", "total"]) {
Ok(total) => total,
// return 0 if no value exists
Err(_) => Value::Uint(u64::MIN),
};
let mut data = Vec::new();
// retrieve u64 from Value type
if let Value::Uint(total) = data_stored {
data.push(total);
};
Data { total: data[0] }
}
}
/// Network traffic notification thresholds and flags (user-defined)
#[derive(Debug, Deserialize, Serialize, FromForm)]
pub struct Threshold {
warn: u64, // traffic warning threshold
cut: u64, // traffic cutoff threshold
warn_flag: bool, // traffic warning notification flag
cut_flag: bool, // traffic cutoff notification flag
}
impl Threshold {
/// Retrieve notification thresholds and flags from the store
fn get(store: &Store) -> Threshold {
let mut threshold = Vec::new();
let warn_val = store
.get(&["net", "notify", "warn"])
.unwrap_or(Value::Uint(0));
if let Value::Uint(val) = warn_val {
threshold.push(val);
};
let cut_val = store
.get(&["net", "notify", "cut"])
.unwrap_or(Value::Uint(0));
if let Value::Uint(val) = cut_val {
threshold.push(val);
};
let mut flag = Vec::new();
let warn_flag = store
.get(&["net", "notify", "warn_flag"])
.unwrap_or(Value::Bool(false));
if let Value::Bool(state) = warn_flag {
flag.push(state);
}
let cut_flag = store
.get(&["net", "notify", "cut_flag"])
.unwrap_or(Value::Bool(false));
if let Value::Bool(state) = cut_flag {
flag.push(state);
}
Threshold {
warn: threshold[0],
cut: threshold[1],
warn_flag: flag[0],
cut_flag: flag[1],
}
}
/// Store notification flags from user data
fn set(self, store: &Store) {
store
.set(&["net", "notify", "warn"], &Value::Uint(self.warn))
.unwrap();
store
.set(&["net", "notify", "cut"], &Value::Uint(self.cut))
.unwrap();
store
.set(
&["net", "notify", "warn_flag"],
&Value::Bool(self.warn_flag),
)
.unwrap();
store
.set(&["net", "notify", "cut_flag"], &Value::Bool(self.cut_flag))
.unwrap();
}
}
/// Warning and cutoff network traffic alert flags (programatically-defined)
#[derive(Debug, Serialize)]
pub struct Alert {
warn: bool,
cut: bool,
}
impl Alert {
/// Retrieve latest alert flags from the store
fn get(store: &Store) -> Alert {
let mut alert = Vec::new();
let warn_flag = store
.get(&["net", "alert", "warn"])
.unwrap_or(Value::Bool(false));
if let Value::Bool(flag) = warn_flag {
alert.push(flag);
}
let cut_flag = store
.get(&["net", "alert", "cut"])
.unwrap_or(Value::Bool(false));
if let Value::Bool(flag) = cut_flag {
alert.push(flag);
}
Alert {
warn: alert[0],
cut: alert[1],
}
}
}
fn create_store() -> std::result::Result<Store, Error> {
// define the path
let path = xdg::BaseDirectories::new()
.unwrap()
.create_data_directory("peachcloud")
.unwrap();
// define the schema
let schema = json!({
"net": {
"traffic": "json",
"alert": "json",
"notify": "json",
}
})
.try_into()?;
// create the data store
let store = Store::new(path, schema);
Ok(store)
}
pub fn get_alerts() -> std::result::Result<Alert, Error> {
let store = create_store()?;
let alerts = Alert::get(&store);
Ok(alerts)
}
pub fn get_data() -> std::result::Result<Data, Error> {
let store = create_store()?;
let data = Data::get(&store);
Ok(data)
}
pub fn get_thresholds() -> std::result::Result<Threshold, Error> {
let store = create_store()?;
let thresholds = Threshold::get(&store);
Ok(thresholds)
}
// set stored traffic total to 0
pub fn reset_data() -> std::result::Result<(), Error> {
let store = create_store()?;
store.set(&["net", "traffic", "total"], &Value::Uint(0))?;
Ok(())
}
pub fn update_store(threshold: Threshold) -> std::result::Result<(), Error> {
let store = create_store()?;
Threshold::set(threshold, &store);
Ok(())
}

698
peach-web/src/utils/sbot.rs Normal file
View File

@ -0,0 +1,698 @@
use std::{
collections::HashMap,
error::Error,
fs,
fs::File,
io,
io::prelude::*,
path::Path,
process::{Command, Output},
};
use async_std::task;
use dirs;
use futures::stream::TryStreamExt;
use golgi::{api::friends::RelationshipQuery, blobs, messages::SsbMessageValue, Sbot};
use log::debug;
use peach_lib::sbot::SbotConfig;
use rouille::input::post::BufferedFile;
use temporary::Directory;
use crate::{error::PeachWebError, utils::sbot};
// SBOT HELPER FUNCTIONS
/// Executes a systemctl command for the go-sbot.service process.
pub fn systemctl_sbot_cmd(cmd: &str) -> io::Result<Output> {
Command::new("systemctl")
.arg("--user")
.arg(cmd)
.arg("go-sbot.service")
.output()
}
/// Executes a systemctl stop command followed by start command.
/// Returns a redirect with a flash message stating the output of the restart attempt.
pub fn restart_sbot_process() -> (String, String) {
debug!("Restarting go-sbot.service");
match systemctl_sbot_cmd("stop") {
// if stop was successful, try to start the process
Ok(_) => match systemctl_sbot_cmd("start") {
Ok(_) => (
"success".to_string(),
"Updated configuration and restarted the sbot process".to_string(),
),
Err(err) => (
"error".to_string(),
format!(
"Updated configuration but failed to start the sbot process: {}",
err
),
),
},
Err(err) => (
"error".to_string(),
format!(
"Updated configuration but failed to stop the sbot process: {}",
err
),
),
}
}
/// Initialise an sbot client with the given configuration parameters.
pub async fn init_sbot_with_config(
sbot_config: &Option<SbotConfig>,
) -> Result<Sbot, PeachWebError> {
debug!("Initialising an sbot client with configuration parameters");
// initialise sbot connection with ip:port and shscap from config file
let sbot_client = match sbot_config {
// TODO: panics if we pass `Some(conf.shscap)` as second arg
Some(conf) => {
let ip_port = conf.lis.clone();
Sbot::init(Some(ip_port), None).await?
}
None => Sbot::init(None, None).await?,
};
Ok(sbot_client)
}
// SCUTTLEBUTT FUNCTIONS
/// Ensure that the given public key is a valid ed25519 key.
///
/// Return an error string if the key is invalid.
pub fn validate_public_key(public_key: &str) -> Result<(), String> {
// ensure the id starts with the correct sigil link
if !public_key.starts_with('@') {
return Err("Invalid key: expected '@' sigil as first character".to_string());
}
// find the dot index denoting the start of the algorithm definition tag
let dot_index = match public_key.rfind('.') {
Some(index) => index,
None => return Err("Invalid key: no dot index was found".to_string()),
};
// check hashing algorithm (must end with ".ed25519")
if !&public_key.ends_with(".ed25519") {
return Err("Invalid key: hashing algorithm must be ed25519".to_string());
}
// obtain the base64 portion (substring) of the public key
let base64_str = &public_key[1..dot_index];
// length of a base64 encoded ed25519 public key
if base64_str.len() != 44 {
return Err("Invalid key: base64 data length is incorrect".to_string());
}
Ok(())
}
/// Calculate the latest sequence number for the local profile.
///
/// Retrieves a list of all messages authored by the local public key,
/// reverses the list and reads the sequence number of the most recently
/// authored message. This gives us the size of the database in terms of
/// the total number of locally-authored messages.
pub fn latest_sequence_number() -> Result<u64, Box<dyn Error>> {
// retrieve latest go-sbot configuration parameters
let sbot_config = SbotConfig::read().ok();
task::block_on(async {
let mut sbot_client = init_sbot_with_config(&sbot_config).await?;
// retrieve the local id
let id = sbot_client.whoami().await?;
let history_stream = sbot_client.create_history_stream(id).await?;
let mut msgs: Vec<SsbMessageValue> = history_stream.try_collect().await?;
// there will be zero messages when the sbot is run for the first time
if msgs.is_empty() {
Ok(0)
} else {
// reverse the list of messages so we can easily reference the latest one
msgs.reverse();
// return the sequence number of the latest msg
Ok(msgs[0].sequence)
}
})
}
pub fn create_invite(uses: u16) -> Result<String, Box<dyn Error>> {
// retrieve latest go-sbot configuration parameters
let sbot_config = SbotConfig::read().ok();
task::block_on(async {
let mut sbot_client = init_sbot_with_config(&sbot_config).await?;
debug!("Generating Scuttlebutt invite code");
let invite_code = sbot_client.invite_create(uses).await?;
Ok(invite_code)
})
}
#[derive(Debug)]
pub struct Profile {
// is this the local profile or the profile of a peer?
pub is_local_profile: bool,
// an ssb_id which may or may not be the local public key
pub id: Option<String>,
pub name: Option<String>,
pub description: Option<String>,
pub image: Option<String>,
// the path to the blob defined in the `image` field (aka the profile picture)
pub blob_path: Option<String>,
// whether or not the blob exists in the blobstore (ie. is saved on disk)
pub blob_exists: bool,
// relationship state (if the profile being viewed is not for the local public key)
pub following: Option<bool>,
pub blocking: Option<bool>,
}
impl Profile {
pub fn default() -> Self {
Profile {
is_local_profile: true,
id: None,
name: None,
description: None,
image: None,
blob_path: None,
blob_exists: false,
following: None,
blocking: None,
}
}
}
/// Retrieve the profile info for the given public key.
pub fn get_profile_info(ssb_id: Option<String>) -> Result<Profile, Box<dyn Error>> {
// retrieve latest go-sbot configuration parameters
let sbot_config = SbotConfig::read().ok();
task::block_on(async {
let mut sbot_client = init_sbot_with_config(&sbot_config).await?;
let local_id = sbot_client.whoami().await?;
let mut profile = Profile::default();
// if an ssb_id has been provided, we assume that the profile info
// being retrieved is for a peer (ie. not for our local profile)
let id = if let Some(peer_id) = ssb_id {
// we are not dealing with the local profile
profile.is_local_profile = false;
// determine relationship between peer and local id
let follow_query = RelationshipQuery {
source: local_id.clone(),
dest: peer_id.clone(),
};
// query follow state
profile.following = match sbot_client.friends_is_following(follow_query).await {
Ok(following) if following == "true" => Some(true),
Ok(following) if following == "false" => Some(false),
_ => None,
};
// TODO: i don't like that we have to instantiate the same query object
// twice. see if we can streamline this in golgi
let block_query = RelationshipQuery {
source: local_id.clone(),
dest: peer_id.clone(),
};
// query block state
profile.blocking = match sbot_client.friends_is_blocking(block_query).await {
Ok(blocking) if blocking == "true" => Some(true),
Ok(blocking) if blocking == "false" => Some(false),
_ => None,
};
peer_id
} else {
// if an ssb_id has not been provided, retrieve the local id using whoami
profile.is_local_profile = true;
local_id
};
// retrieve the profile info for the given id
let info = sbot_client.get_profile_info(&id).await?;
// set each profile field accordingly
for (key, val) in info {
match key.as_str() {
"name" => profile.name = Some(val),
"description" => profile.description = Some(val),
"image" => profile.image = Some(val),
_ => (),
}
}
// assign the ssb public key
// (could be for the local profile or a peer)
profile.id = Some(id);
// determine the path to the blob defined by the value of `profile.image`
if let Some(ref blob_id) = profile.image {
profile.blob_path = match blobs::get_blob_path(blob_id) {
Ok(path) => {
// if we get the path, check if the blob is in the blobstore.
// this allows us to default to a placeholder image in the template
if let Ok(exists) = blob_is_stored_locally(&path).await {
profile.blob_exists = exists
};
Some(path)
}
Err(_) => None,
}
}
Ok(profile)
})
}
/// Update the profile info for the local public key.
///
/// Profile info includes name, description and image.
pub fn update_profile_info(
current_name: String,
current_description: String,
new_name: Option<String>,
new_description: Option<String>,
image: Option<BufferedFile>,
) -> Result<String, String> {
// retrieve latest go-sbot configuration parameters
let sbot_config = SbotConfig::read().ok();
task::block_on(async {
let mut sbot_client = init_sbot_with_config(&sbot_config)
.await
.map_err(|e| e.to_string())?;
// track whether the name, description or image have been updated
let mut name_updated: bool = false;
let mut description_updated: bool = false;
let mut image_updated: bool = false;
// check if a new_name value has been submitted in the form
if let Some(name) = new_name {
// only update the name if it has changed
if name != current_name {
debug!("Publishing a new Scuttlebutt profile name");
if let Err(e) = sbot_client.publish_name(&name).await {
return Err(format!("Failed to update name: {}", e));
} else {
name_updated = true
}
}
}
if let Some(description) = new_description {
// only update the description if it has changed
if description != current_description {
debug!("Publishing a new Scuttlebutt profile description");
if let Err(e) = sbot_client.publish_description(&description).await {
return Err(format!("Failed to update description: {}", e));
} else {
description_updated = true
}
}
}
// only update the image if a file was uploaded
if let Some(img) = image {
// only write the blob if it has a filename and data > 0 bytes
if img.filename.is_some() && !img.data.is_empty() {
match write_blob_to_store(img).await {
Ok(blob_id) => {
// if the file was successfully added to the blobstore,
// publish an about image message with the blob id
if let Err(e) = sbot_client.publish_image(&blob_id).await {
return Err(format!("Failed to update image: {}", e));
} else {
image_updated = true
}
}
Err(e) => return Err(format!("Failed to add image to blobstore: {}", e)),
}
} else {
image_updated = false
}
}
if name_updated || description_updated || image_updated {
Ok("Profile updated".to_string())
} else {
// no updates were made but no errors were encountered either
Ok("Profile info unchanged".to_string())
}
})
}
/// Follow a peer.
pub fn follow_peer(public_key: &str) -> Result<String, String> {
// retrieve latest go-sbot configuration parameters
let sbot_config = SbotConfig::read().ok();
task::block_on(async {
let mut sbot_client = init_sbot_with_config(&sbot_config)
.await
.map_err(|e| e.to_string())?;
debug!("Following a Scuttlebutt peer");
match sbot_client.follow(public_key).await {
Ok(_) => Ok("Followed peer".to_string()),
Err(e) => Err(format!("Failed to follow peer: {}", e)),
}
})
}
/// Unfollow a peer.
pub fn unfollow_peer(public_key: &str) -> Result<String, String> {
// retrieve latest go-sbot configuration parameters
let sbot_config = SbotConfig::read().ok();
task::block_on(async {
let mut sbot_client = init_sbot_with_config(&sbot_config)
.await
.map_err(|e| e.to_string())?;
debug!("Unfollowing a Scuttlebutt peer");
match sbot_client.unfollow(public_key).await {
Ok(_) => Ok("Unfollowed peer".to_string()),
Err(e) => Err(format!("Failed to unfollow peer: {}", e)),
}
})
}
/// Block a peer.
pub fn block_peer(public_key: &str) -> Result<String, String> {
// retrieve latest go-sbot configuration parameters
let sbot_config = SbotConfig::read().ok();
task::block_on(async {
let mut sbot_client = init_sbot_with_config(&sbot_config)
.await
.map_err(|e| e.to_string())?;
debug!("Blocking a Scuttlebutt peer");
match sbot_client.block(public_key).await {
Ok(_) => Ok("Blocked peer".to_string()),
Err(e) => Err(format!("Failed to block peer: {}", e)),
}
})
}
/// Unblock a peer.
pub fn unblock_peer(public_key: &str) -> Result<String, String> {
// retrieve latest go-sbot configuration parameters
let sbot_config = SbotConfig::read().ok();
task::block_on(async {
let mut sbot_client = init_sbot_with_config(&sbot_config)
.await
.map_err(|e| e.to_string())?;
debug!("Unblocking a Scuttlebutt peer");
match sbot_client.unblock(public_key).await {
Ok(_) => Ok("Unblocked peer".to_string()),
Err(e) => Err(format!("Failed to unblock peer: {}", e)),
}
})
}
/// Retrieve a list of peers blocked by the local public key.
pub fn get_blocks_list() -> Result<Vec<HashMap<String, String>>, Box<dyn Error>> {
// retrieve latest go-sbot configuration parameters
let sbot_config = SbotConfig::read().ok();
task::block_on(async {
let mut sbot_client = init_sbot_with_config(&sbot_config).await?;
let blocks = sbot_client.get_blocks().await?;
// we'll use this to store the profile info for each peer whom we block
let mut peer_list = Vec::new();
if !blocks.is_empty() {
for peer in blocks.iter() {
// trim whitespace (including newline characters) and
// remove the inverted-commas around the id
let key = peer.trim().replace('"', "");
// retrieve the profile info for the given peer
let mut peer_info = sbot_client.get_profile_info(&key).await?;
// insert the public key of the peer into the info hashmap
peer_info.insert("id".to_string(), key.to_string());
// we do not even attempt to find the blob for a blocked peer,
// since it may be vulgar to cause distress to the local peer.
peer_info.insert("blob_exists".to_string(), "false".to_string());
// push profile info to peer_list vec
peer_list.push(peer_info)
}
}
// return the list of blocked peers
Ok(peer_list)
})
}
/// Retrieve a list of peers followed by the local public key.
pub fn get_follows_list() -> Result<Vec<HashMap<String, String>>, Box<dyn Error>> {
// retrieve latest go-sbot configuration parameters
let sbot_config = SbotConfig::read().ok();
task::block_on(async {
let mut sbot_client = init_sbot_with_config(&sbot_config).await?;
let follows = sbot_client.get_follows().await?;
// we'll use this to store the profile info for each peer who follows us
let mut peer_list = Vec::new();
if !follows.is_empty() {
for peer in follows.iter() {
// trim whitespace (including newline characters) and
// remove the inverted-commas around the id
let key = peer.trim().replace('"', "");
// retrieve the profile info for the given peer
let mut peer_info = sbot_client.get_profile_info(&key).await?;
// insert the public key of the peer into the info hashmap
peer_info.insert("id".to_string(), key.to_string());
// retrieve the profile image blob id for the given peer
if let Some(blob_id) = peer_info.get("image") {
// look-up the path for the image blob
if let Ok(blob_path) = blobs::get_blob_path(blob_id) {
// insert the image blob path of the peer into the info hashmap
peer_info.insert("blob_path".to_string(), blob_path.to_string());
// check if the blob is in the blobstore
// set a flag in the info hashmap
match blob_is_stored_locally(&blob_path).await {
Ok(exists) if exists => {
peer_info.insert("blob_exists".to_string(), "true".to_string())
}
_ => peer_info.insert("blob_exists".to_string(), "false".to_string()),
};
}
}
// push profile info to peer_list vec
peer_list.push(peer_info)
}
}
// return the list of peers
Ok(peer_list)
})
}
/// Retrieve a list of peers friended by the local public key.
pub fn get_friends_list() -> Result<Vec<HashMap<String, String>>, Box<dyn Error>> {
// retrieve latest go-sbot configuration parameters
let sbot_config = SbotConfig::read().ok();
task::block_on(async {
let mut sbot_client = init_sbot_with_config(&sbot_config).await?;
let local_id = sbot_client.whoami().await?;
let follows = sbot_client.get_follows().await?;
// we'll use this to store the profile info for each friend
let mut peer_list = Vec::new();
if !follows.is_empty() {
for peer in follows.iter() {
// trim whitespace (including newline characters) and
// remove the inverted-commas around the id
let peer_id = peer.trim().replace('"', "");
// retrieve the profile info for the given peer
let mut peer_info = sbot_client.get_profile_info(&peer_id).await?;
// insert the public key of the peer into the info hashmap
peer_info.insert("id".to_string(), peer_id.to_string());
// retrieve the profile image blob id for the given peer
if let Some(blob_id) = peer_info.get("image") {
// look-up the path for the image blob
if let Ok(blob_path) = blobs::get_blob_path(blob_id) {
// insert the image blob path of the peer into the info hashmap
peer_info.insert("blob_path".to_string(), blob_path.to_string());
// check if the blob is in the blobstore
// set a flag in the info hashmap
match sbot::blob_is_stored_locally(&blob_path).await {
Ok(exists) if exists => {
peer_info.insert("blob_exists".to_string(), "true".to_string())
}
_ => peer_info.insert("blob_exists".to_string(), "false".to_string()),
};
}
}
// check if the peer follows us (making us friends)
let follow_query = RelationshipQuery {
source: peer_id.to_string(),
dest: local_id.clone(),
};
// query follow state
match sbot_client.friends_is_following(follow_query).await {
Ok(following) if following == "true" => {
// only push profile info to peer_list vec if they follow us
peer_list.push(peer_info)
}
_ => (),
};
}
}
// return the list of peers
Ok(peer_list)
})
}
/// Retrieve the local public key (id).
pub fn get_local_id() -> Result<String, Box<dyn Error>> {
// retrieve latest go-sbot configuration parameters
let sbot_config = SbotConfig::read().ok();
task::block_on(async {
let mut sbot_client = init_sbot_with_config(&sbot_config).await?;
let local_id = sbot_client.whoami().await?;
Ok(local_id)
})
}
/// Publish a public post.
pub fn publish_public_post(text: String) -> Result<String, String> {
// retrieve latest go-sbot configuration parameters
let sbot_config = SbotConfig::read().ok();
task::block_on(async {
let mut sbot_client = init_sbot_with_config(&sbot_config)
.await
.map_err(|e| e.to_string())?;
debug!("Publishing a new Scuttlebutt public post");
match sbot_client.publish_post(&text).await {
Ok(_) => Ok("Published post".to_string()),
Err(e) => Err(format!("Failed to publish post: {}", e)),
}
})
}
/// Publish a private message.
pub fn publish_private_msg(text: String, recipients: Vec<String>) -> Result<String, String> {
// retrieve latest go-sbot configuration parameters
let sbot_config = SbotConfig::read().ok();
task::block_on(async {
let mut sbot_client = init_sbot_with_config(&sbot_config)
.await
.map_err(|e| e.to_string())?;
debug!("Publishing a new Scuttlebutt private message");
match sbot_client
.publish_private(text.to_string(), recipients)
.await
{
Ok(_) => Ok("Published private message".to_string()),
Err(e) => Err(format!("Failed to publish private message: {}", e)),
}
})
}
// FILEPATH FUNCTIONS
/// Return the path of the ssb-go directory.
pub fn get_go_ssb_path() -> Result<String, PeachWebError> {
let go_ssb_path = match SbotConfig::read() {
Ok(conf) => conf.repo,
// return the default path if unable to read `config.toml`
Err(_) => {
// determine the home directory
let mut home_path = dirs::home_dir().ok_or(PeachWebError::HomeDir)?;
// add the go-ssb subdirectory
home_path.push(".ssb-go");
// convert the PathBuf to a String
home_path
.into_os_string()
.into_string()
.map_err(|_| PeachWebError::OsString)?
}
};
Ok(go_ssb_path)
}
/// Check whether a blob is in the blobstore.
pub async fn blob_is_stored_locally(blob_path: &str) -> Result<bool, PeachWebError> {
let go_ssb_path = get_go_ssb_path()?;
let complete_path = format!("{}/blobs/sha256/{}", go_ssb_path, blob_path);
let blob_exists_locally = Path::new(&complete_path).exists();
Ok(blob_exists_locally)
}
// take the path to a file, add it to the blobstore and return the blob id
pub async fn write_blob_to_store(image: BufferedFile) -> Result<String, PeachWebError> {
// we performed a `image.filename.is_some()` check before calling `write_blob_to_store`
// so it should be safe to do a simple unwrap here
let filename = image
.filename
.expect("retrieving filename from uploaded file");
// create temporary directory and path
let temp_dir = Directory::new("blob")?;
let temp_path = temp_dir.join(filename);
// write file to temporary path
fs::write(&temp_path, &image.data)?;
// open the file and read it into a buffer
let mut file = File::open(&temp_path)?;
let mut buffer = Vec::new();
file.read_to_end(&mut buffer)?;
// hash the bytes representing the file
let (hex_hash, blob_id) = blobs::hash_blob(&buffer)?;
// define the blobstore path and blob filename
let (blob_dir, blob_filename) = hex_hash.split_at(2);
let go_ssb_path = get_go_ssb_path()?;
let blobstore_sub_dir = format!("{}/blobs/sha256/{}", go_ssb_path, blob_dir);
// create the blobstore sub-directory
fs::create_dir_all(&blobstore_sub_dir)?;
// copy the file to the blobstore
let blob_path = format!("{}/{}", blobstore_sub_dir, blob_filename);
fs::copy(temp_path, blob_path)?;
Ok(blob_id)
}

View File

@ -0,0 +1,25 @@
use log::info;
use crate::THEME;
// THEME FUNCTIONS
#[derive(Debug, Copy, Clone)]
pub enum Theme {
Light,
Dark,
}
pub fn get_theme() -> String {
let current_theme = THEME.read().unwrap();
match *current_theme {
Theme::Dark => "dark".to_string(),
_ => "light".to_string(),
}
}
pub fn set_theme(theme: Theme) {
info!("set ui theme to: {:?}", theme);
let mut writable_theme = THEME.write().unwrap();
*writable_theme = theme;
}

View File

@ -648,9 +648,10 @@ html[data-theme='dark'] {
.flash-message {
font-family: var(--sans-serif);
font-size: var(--font-size-6);
margin-left: 2rem;
margin-right: 2rem;
/*margin-left: 2rem;*/
/*margin-right: 2rem;*/
margin-top: 1rem;
width: 14rem;
}
/*
@ -794,7 +795,7 @@ form {
.label-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
width: 10rem;
/*width: 10rem;*/
}
.input-label {

View File

@ -1,16 +0,0 @@
<!doctype html>
<html lang="en"{% if theme %} data-theme="{{ theme }}"{% endif %}>
<head>
<meta charset="utf-8">
<title>PeachCloud</title>
<meta name="description" content="PeachCloud Network">
<meta name="author" content="glyph">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/css/peachcloud.css">
<style>@import url("/css/_variables.css");</style>
</head>
<body>
{% block nav %}{% endblock nav %}
</body>
</html>

View File

@ -1,9 +0,0 @@
{%- extends "nav" -%}
{%- block card %}
<div class="card center">
<div class="card-container capsule info-border">
<p>PeachCloud has encountered an internal error. This may indicate that one or several software components are misconfigured or malfunctioning. Please try to repeat your desired actions. If the problem persists, a system reset is recommended - either via the <a href="/shutdown">Shutdown menu</a> or the OLED menu on the physical device.</p>
<p>Click the back arrow in the top-left or the PeachCloud logo at the bottom of your screen to return Home.</p>
</div>
</div>
{%- endblock card -%}

View File

@ -1,11 +0,0 @@
{%- extends "nav" -%}
{%- block card %}
<div class="card center">
<div class="capsule-container">
<div class="capsule info-border">
<p>No PeachCloud resource exists for this URL. Please ensure that the URL in the address bar is correct.</p>
<p>Click the back arrow in the top-left or the PeachCloud logo at the bottom of your screen to return Home.</p>
</div>
</div>
</div>
{%- endblock card -%}

View File

@ -1,34 +0,0 @@
{%- extends "nav" -%}
{%- block card %}
<!-- GUIDE -->
<div class="card card-wide center">
<div class="capsule capsule-container border-info">
<!-- GETTING STARTED -->
<details>
<summary class="card-text link">Getting started</summary>
<p class="card-text" style="margin-top: 1rem; margin-bottom: 1rem;">The Scuttlebutt server (sbot) will be inactive when you first run PeachCloud. This is to allow configuration parameters to be set before it is activated for the first time. Navigate to the <strong><a href="/settings/scuttlebutt/configure" class="link font-gray">Sbot Configuration</a></strong> page to configure your system. The default configuration will be fine for most usecases.</p>
<p class="card-text" style="margin-top: 1rem; margin-bottom: 1rem;">Once the configuration is set, navigate to the <strong><a href="/settings/scuttlebutt" class="link font-gray">Scuttlebutt Settings menu</a></strong> to start the sbot. If the server starts successfully, you will see a green smiley face on the home page. If the face is orange and sleeping, that means the sbot is still inactive (ie. the process is not running). If the face is red and dead, that means the sbot failed to start - indicating an error. For now, the best way to gain insight into the problem is to check the systemd log. Open a terminal and enter: <code>systemctl --user status go-sbot.service</code>. The log output may give some clues about the source of the error.</p>
</details>
<!-- BUG REPORTS -->
<details>
<summary class="card-text link">Submit a bug report</summary>
<p class="card-text" style="margin-top: 1rem; margin-bottom: 1rem;">Bug reports can be submitted by filing an issue on the peach-workspace git repo. Before filing a report, first check to see if an issue already exists for the bug you've encountered. If not, you're invited to <strong><a href="https://git.coopcloud.tech/PeachCloud/peach-workspace/issues/new?template=BUG_TEMPLATE.md" class="link font-gray">submit a new report</a></strong>; the template will guide you through several questions.</p>
</details>
<!-- REQUEST SUPPORT -->
<details>
<summary class="card-text link">Share feedback & request support</summary>
<p class="card-text" style="margin-top: 1rem; margin-bottom: 1rem;">You're invited to share your thoughts and experiences of PeachCloud in the #peachcloud channel on Scuttlebutt. The channel is also a good place to ask for help.</p>
<p class="card-text" style="margin-top: 1rem; margin-bottom: 1rem;">Alternatively, we have a <strong><a href="https://matrix.to/#/#peachcloud:matrix.org" class="link font-gray">Matrix channel</a></strong> for discussion about PeachCloud and you can also reach out to @glyph <strong><a href="mailto:glyph@mycelial.technology" class="link font-gray">via email</a></strong>.</p>
<p class="card=text" style="margin-top: 1rem; margin-bottom: 1rem;">If you'd like to suggest a feature, you're welcome to <strong><a href="https://git.coopcloud.tech/PeachCloud/peach-workspace/issues/new?template=FEATURE_SUGGESTION.md" class="link font-gray" style="margin-top: 1rem; margin-bottom: 1rem;">submit an issue</a></strong> to the peach-workspace git repo.</p>
</details>
<!-- CONTRIBUTE -->
<details>
<summary class="card-text link">Contribute to PeachCloud</summary>
<p class="card-text" style="margin-top: 1rem; margin-bottom: 1rem;">PeachCloud is free, open-source software and relies on donations and grants to fund develop. Donations can be made on our <strong><a href="https://opencollective.com/peachcloud" class="link font-gray">Open Collective</a></strong> page.</p>
<p class="card-text" style="margin-top: 1rem; margin-bottom: 1rem;">Programmers, designers, artists and writers are also welcome to contribute to the project. Please visit the <strong><a href="https://git.coopcloud.tech/PeachCloud/peach-workspace" class="link font-gray">main PeachCloud git repository</a></strong> to find out more details or contact the team via Scuttlebutt, Matrix or email.</p>
</details>
</div>
<!-- FLASH MESSAGE -->
{% include "snippets/flash_message" %}
</div>
{%- endblock card -%}

View File

@ -1,68 +0,0 @@
{%- extends "nav" -%}
{%- block card %}
<!-- RADIAL MENU -->
<div class="grid">
<!-- top-left -->
<!-- PEERS LINK AND ICON -->
<a class="top-left" href="/scuttlebutt/peers" title="Scuttlebutt Peers">
<div class="circle circle-small border-circle-small border-ssb">
<img class="icon-medium" src="/icons/users.svg">
</div>
</a>
<!-- top-middle -->
<!-- CURRENT USER LINK AND ICON -->
<a class="top-middle" href="/scuttlebutt/profile" title="Profile">
<div class="circle circle-small border-circle-small border-ssb">
<img class="icon-medium" src="/icons/user.svg">
</div>
</a>
<!-- top-right -->
<!-- MESSAGES LINK AND ICON -->
<a class="top-right" href="/scuttlebutt/private" title="Private Messages">
<div class="circle circle-small border-circle-small border-ssb">
<img class="icon-medium" src="/icons/envelope.svg">
</div>
</a>
<!-- middle -->
<a class="middle">
{% if sbot_status.state == "active" %}
<div class="circle circle-large circle-success">
<p style="font-size: 4rem; color: var(--near-black);">^_^</p>
</div>
{% elif sbot_status.state == "inactive" %}
<div class="circle circle-large circle-warning">
<p style="font-size: 4rem; color: var(--near-black);">z_z</p>
</div>
{% else %}
<div class="circle circle-large circle-error">
<p style="font-size: 4rem; color: var(--near-black);">x_x</p>
</div>
{% endif %}
</a>
<!-- bottom-left -->
<!-- SYSTEM STATUS LINK AND ICON -->
{%- if standalone_mode == true -%}
<a class="bottom-left" href="/status/scuttlebutt" title="Status">
{% else -%}
<a class="bottom-left" href="/status" title="Status">
{%- endif -%}
<div class="circle circle-small border-circle-small {% if sbot_status.state == "active" %}border-success{% elif sbot_status.state == "inactive" %}border-warning{% else %}border-danger{% endif %}">
<img class="icon-medium" src="/icons/heart-pulse.svg">
</div>
</a>
<!-- bottom-middle -->
<!-- PEACHCLOUD GUIDEBOOK LINK AND ICON -->
<a class="bottom-middle" href="/guide" title="Guide">
<div class="circle circle-small border-circle-small border-info">
<img class="icon-medium" src="/icons/book.svg">
</div>
</a>
<!-- bottom-right -->
<!-- SYSTEM SETTINGS LINK AND ICON -->
<a class="bottom-right" href="/settings" title="Settings">
<div class="circle circle-small border-circle-small border-settings">
<img class="icon-medium" src="/icons/cog.svg">
</div>
</a>
</div>
{%- endblock card %}

View File

@ -1,20 +0,0 @@
{%- extends "nav" -%}
{%- block card %}
<!-- LOGIN FORM -->
<div class="card center">
<form id="login_form" class="center" action="/login" method="post">
<div style="display: flex; flex-direction: column; margin-bottom: 1rem;">
<!-- input for password -->
<label for="password" class="center label-small font-gray" style="width: 80%;">PASSWORD</label>
<input id="password" name="password" class="center input" type="password" title="Password for given username"/>
<!-- login (form submission) button -->
<input id="loginUser" class="button button-primary center" title="Login" type="submit" value="Login">
<div class="center-text" style="margin-top: 1rem;">
<a href="/settings/admin/forgot_password" class="label-small link font-gray">Forgot Password?</a>
</div>
</div>
<!-- FLASH MESSAGE -->
{% include "snippets/flash_message" %}
</form>
</div>
{%- endblock card -%}

View File

@ -1,41 +0,0 @@
{%- extends "base" -%}
{%- block nav -%}
<!-- Top nav bar -->
<nav class="nav-bar">
<a class="nav-item" href="{% if back %}{{ back }}{% endif %}" title="Back">
<img class="icon-medium nav-icon-left icon-active" src="/icons/back.svg" alt="Back">
</a>
<h1 class="nav-title">{{ title }}</h1>
<a class="nav-item" id="logoutButton" href="/logout" title="Logout">
<img class="icon-medium nav-icon-right icon-active" src="/icons/enter.svg" alt="Enter">
</a>
</nav>
<!-- Main content container -->
<main>
{%- block card -%}{%- endblock card %}
</main>
<!-- Bottom nav bar -->
<nav class="nav-bar">
<a class="nav-item" href="https://scuttlebutt.nz/">
<img class="icon-medium nav-icon-left" title="Scuttlebutt Website" src="/icons/hermies_hex{% if theme and theme == "dark" %}_light{% endif %}.svg" alt="Secure Scuttlebutt">
</a>
<a class="nav-item" href="/">
<img class="icon nav-icon-left" src="/icons/peach-icon.png" alt="PeachCloud" title="Home">
</a>
{# only render a theme-switcher icon if the `theme` variable has been set #}
{% if theme and theme == "light" %}
<a class="nav-item" href="/theme?theme=dark">
<img class="icon-medium nav-icon-right icon-active" title="Toggle theme" src="/icons/moon.png" alt="Moon">
</a>
{% elif theme and theme == "dark" %}
<a class="nav-item" href="/theme?theme=light">
<img class="icon-medium nav-icon-right icon-active" title="Toggle theme" src="/icons/sun.png" alt="Sun">
</a>
{% else %}
{# render hidden element to maintain correctly alignment of other bottom nav icons #}
<a class="nav-item" style="visibility: hidden;" href="/theme?theme=light">
<img class="icon-medium nav-icon-right icon-active" title="Toggle theme" src="/icons/sun.png" alt="Sun">
</a>
{% endif %}
</nav>
{%- endblock nav -%}

View File

@ -1,15 +0,0 @@
{%- extends "nav" -%}
{%- block card %}
<!-- SHUTDOWN / REBOOT MENU -->
<div class="card center">
<div class="card-container">
<!-- BUTTONS -->
<div id="buttonDiv">
<a id="rebootBtn" class="button button-primary center" href="/power/reboot" title="Reboot Device">Reboot</a>
<a id="shutdownBtn" class="button button-warning center" href="/power/shutdown" title="Shutdown Device">Shutdown</a>
</div>
<!-- FLASH MESSAGE -->
{% include "snippets/flash_message" %}
</div>
</div>
{%- endblock card -%}

View File

@ -1,11 +0,0 @@
{%- extends "nav" -%}
{%- block card %}
<!-- SBOT INACTIVE -->
<div class="card center">
<div class="capsule capsule-container border-warning center-text">
<p class="card-text" style="font-size: var(--font-size-4);">Sbot Inactive</p>
<p class="card-text">{{ unavailable_msg }}</p>
<p class="card-text">Visit the <strong><a href="/settings/scuttlebutt" class="link">Scuttlebutt settings menu</a></strong> to start the Sbot and then try again.</p>
</div>
</div>
{%- endblock card -%}

View File

@ -1,20 +0,0 @@
{%- extends "nav" -%}
{%- block card %}
<!-- SCUTTLEBUTT INVITE FORM -->
<div class="card center">
<form id="invites" class="center" action="/scuttlebutt/invites" method="post">
<div class="center" style="width: 80%;">
<label for="inviteUses" class="label-small font-gray" title="Number of times the invite code can be reused">USES</label>
<input type="number" id="inviteUses" name="uses" min="1" max="150" size="3" value="1">
{% if invite_code %}
<p class="card-text" style="margin-top: 1rem; user-select: all;" title="Invite code">{{ invite_code }}</p>
{% endif %}
</div>
<!-- BUTTONS -->
<input id="createInvite" class="button button-primary center" style="margin-top: 1rem;" type="submit" title="Create a new invite code" value="Create">
<a id="cancel" class="button button-secondary center" href="/scuttlebutt/peers" title="Cancel">Cancel</a>
</form>
<!-- FLASH MESSAGE -->
{% include "snippets/flash_message" %}
</div>
{%- endblock card -%}

View File

@ -1,21 +0,0 @@
{%- extends "nav" -%}
{%- block card %}
<!-- SCUTTLEBUTT PEERS -->
<div class="card center">
{# only render the peer menu elements if the sbot is active #}
{%- if sbot_state == "active" %}
<div class="card-container">
<!-- BUTTONS -->
<div id="buttons">
<a id="search" class="button button-primary center" href="/scuttlebutt/search" title="Search for a peer">Search</a>
<a id="friends" class="button button-primary center" href="/scuttlebutt/friends" title="List friends">Friends</a>
<a id="follows" class="button button-primary center" href="/scuttlebutt/follows" title="List follows">Follows</a>
<a id="blocks" class="button button-primary center" href="/scuttlebutt/blocks" title="List blocks">Blocks</a>
<a id="invites" class="button button-primary center" href="/scuttlebutt/invites" title="Create invites">Invites</a>
</div>
</div>
{%- endif %}
<!-- FLASH MESSAGE -->
{% include "snippets/flash_message" %}
</div>
{%- endblock card -%}

View File

@ -1,30 +0,0 @@
{%- extends "nav" -%}
{%- block card %}
<div class="card center">
{%- if peers %}
<ul class="center list">
{%- for peer in peers %}
{# set a fall-back value for name in case the data is unavailable #}
{%- if not peer['name'] %}
{%- set name = "name unavailable" %}
{%- else %}
{%- set name = peer['name'] %}
{%- endif %}
<li>
<a class="list-item link" href="/scuttlebutt/profile?public_key={{ peer['id'] }}">
{%- if peer['blob_path'] and peer['blob_exists'] == "true" %}
<img id="peerImage" class="icon list-icon" src="/blob/{{ peer['blob_path'] }}" alt="{{ name }}'s profile image">
{%- else %}
<img id="peerImage" class="icon icon-active list-icon" src="/icons/user.svg" alt="Placeholder profile image">
{%- endif %}
<p id="peerName" class="font-normal list-text">{{ name }}</p>
<label class="label-small label-ellipsis list-label font-gray" for="peerName" title="{{ name }}'s Public Key">{{ peer['id'] }}</label>
</a>
</li>
{%- endfor %}
</ul>
{%- else %}
<p>No follows found</p>
{%- endif %}
</div>
{%- endblock card -%}

View File

@ -1,23 +0,0 @@
{%- extends "nav" -%}
{%- block card %}
<!-- SCUTTLEBUTT PRIVATE MESSAGE FORM -->
<div class="card card-wide center">
{# only render the private message elements if the sbot is active #}
{%- if sbot_status and sbot_status.state == "active" %}
<form id="sbotConfig" class="center" action="/scuttlebutt/private" method="post">
<div class="center" style="display: flex; flex-direction: column; margin-bottom: 1rem;" title="Public key (ID) of the peer being written to">
<label for="publicKey" class="label-small font-gray">PUBLIC KEY</label>
<input type="text" id="publicKey" name="recipient" placeholder="@xYz...=.ed25519" {% if recipient_id %}value="{{ recipient_id }}"{% else %}autofocus{% endif %}>
</div>
<!-- input for message contents -->
<textarea id="privatePost" class="center input message-input" name="text" title="Compose a private message" placeholder="Write a private message..."{% if recipient_id %} autofocus{% endif %}></textarea>
<!-- hidden input field to pass the public key of the local peer -->
<input type="hidden" id="localId" name="id" value="{{ id }}">
<!-- BUTTONS -->
<input id="publish" class="button button-primary center" type="submit" style="margin-top: 1rem;" title="Publish private message to peer" value="Publish">
</form>
{%- endif %}
<!-- FLASH MESSAGE -->
{% include "snippets/flash_message" %}
</div>
{%- endblock card -%}

View File

@ -1,75 +0,0 @@
{%- extends "nav" -%}
{%- block card %}
<!-- USER PROFILE -->
<div class="card card-wide center">
{# only render the profile info elements if the sbot is active #}
{%- if sbot_status and sbot_status.state == "active" %}
<!-- PROFILE INFO BOX -->
<div class="capsule capsule-profile border-ssb" title="Scuttlebutt account profile information">
{% if is_local_profile %}
<!-- edit profile button -->
<a class="nav-icon-right" href="/scuttlebutt/profile/update" title="Edit your profile">
<img id="editProfile" class="icon-small icon-active" src="/icons/pencil.svg" alt="Edit">
</a>
{% endif %}
<!-- PROFILE BIO -->
<!-- profile picture -->
{# only try to render profile pic if we have the blob #}
{%- if blob_path and blob_exists == true %}
<img id="profilePicture" class="icon-large" src="/blob/{{ blob_path }}" title="Profile picture" alt="Profile picture">
{% else %}
{# render a placeholder profile picture (icon) #}
<img id="profilePicture" class="icon icon-active" src="/icons/user.svg" title="Profile picture" alt="Profile picture">
{% endif %}
<!-- name, public key & description -->
<p id="profileName" class="card-text" title="Name">{{ name }}</p>
<label class="label-small label-ellipsis font-gray" style="user-select: all;" for="profileName" title="Public Key">{{ id }}</label>
<p id="profileDescription" style="margin-top: 1rem" class="card-text" title="Description">{{ description }}</p>
</div>
{% if is_local_profile %}
<!-- PUBLIC POST FORM -->
<form id="postForm" class="center" action="/scuttlebutt/publish" method="post">
<!-- input for message contents -->
<textarea id="publicPost" class="center input message-input" name="text" title="Compose Public Post" placeholder="Write a public post..."></textarea>
<input id="publishPost" class="button button-primary center" title="Publish" type="submit" value="Publish">
</form>
{% else %}
<!-- BUTTONS -->
<!-- TODO: each of these buttons needs to be a form with a public key -->
<div id="buttons" style="margin-top: 2rem;">
{% if following == false %}
<form id="followForm" class="center" action="/scuttlebutt/follow" method="post">
<input type="hidden" id="publicKey" name="public_key" value="{{ id }}">
<input id="followPeer" class="button button-primary center" type="submit" title="Follow Peer" value="Follow">
</form>
{% elif following == true %}
<form id="unfollowForm" class="center" action="/scuttlebutt/unfollow" method="post">
<input type="hidden" id="publicKey" name="public_key" value="{{ id }}">
<input id="unfollowPeer" class="button button-primary center" type="submit" title="Unfollow Peer" value="Unfollow">
</form>
{% else %}
<p>Unable to determine follow state</p>
{% endif %}
{% if blocking == false %}
<form id="blockForm" class="center" action="/scuttlebutt/block" method="post">
<input type="hidden" id="publicKey" name="public_key" value="{{ id }}">
<input id="blockPeer" class="button button-primary center" type="submit" title="Block Peer" value="Block">
</form>
{% elif blocking == true %}
<form id="unblockForm" class="center" action="/scuttlebutt/unblock" method="post">
<input type="hidden" id="publicKey" name="public_key" value="{{ id }}">
<input id="unblockPeer" class="button button-primary center" type="submit" title="Unblock Peer" value="Unblock">
</form>
{% else %}
<p>Unable to determine block state</p>
{% endif %}
<form class="center">
<a id="privateMessage" class="button button-primary center" href="/scuttlebutt/private?public_key={{ id }}" title="Private Message">Send Private Message</a>
</form>
</div>
{%- endif %}
{%- endif %}
<!-- FLASH MESSAGE -->
{% include "snippets/flash_message" %}
</div>
{%- endblock card -%}

View File

@ -1,16 +0,0 @@
{%- extends "nav" -%}
{%- block card %}
<!-- PEER SEARCH FORM -->
<div class="card center">
<form id="sbotConfig" class="center" action="/scuttlebutt/search" method="post">
<div class="center" style="display: flex; flex-direction: column; margin-bottom: 2rem;" title="Public key (ID) of a peer">
<label for="publicKey" class="label-small font-gray">PUBLIC KEY</label>
<input type="text" id="publicKey" name="public_key" placeholder="@xYz...=.ed25519" autofocus>
</div>
<!-- BUTTONS -->
<input id="search" class="button button-primary center" type="submit" title="Search for peer" value="Search">
<!-- FLASH MESSAGE -->
{% include "snippets/flash_message" %}
</form>
</div>
{%- endblock card -%}

View File

@ -1,27 +0,0 @@
{%- extends "nav" -%}
{%- block card %}
{# ASSIGN VARIABLES #}
{# ---------------- #}
<!-- SSB PROFILE UPDATE FORM -->
<div class="card card-wide center">
<form id="profileInfo" class="center" enctype="multipart/form-data" action="/scuttlebutt/profile/update" method="post">
<div style="display: flex; flex-direction: column">
<label for="name" class="label-small font-gray">NAME</label>
<input style="margin-bottom: 1rem;" type="text" id="name" name="new_name" placeholder="Choose a name for your profile..." value="{{ name }}">
<label for="description" class="label-small font-gray">DESCRIPTION</label>
<textarea id="description" class="message-input" style="margin-bottom: 1rem;" name="new_description" placeholder="Write a description for your profile...">{{ description }}</textarea>
<label for="image" class="label-small font-gray">IMAGE</label>
<input type="file" id="fileInput" class="font-normal" name="image">
</div>
<input type="hidden" name="id" value="{{ id }}">
<input type="hidden" name="current_name" value="{{ name }}">
<input type="hidden" name="current_description" value="{{ description }}">
<div id="buttonDiv" style="margin-top: 2rem;">
<input id="updateProfile" class="button button-primary center" title="Publish" type="submit" value="Publish">
<a class="button button-secondary center" href="/scuttlebutt/profile" title="Cancel">Cancel</a>
</div>
</form>
<!-- FLASH MESSAGE -->
{% include "snippets/flash_message" %}
</div>
{%- endblock card -%}

View File

@ -1,24 +0,0 @@
{%- extends "nav" -%}
{%- block card %}
<!-- CHANGE PASSWORD FORM -->
<div class="card center">
<form id="changePassword" class="center" action="/settings/admin/change_password" method="post">
<div style="display: flex; flex-direction: column; margin-bottom: 1rem;">
<!-- input for current password -->
<label for="currentPassword" class="center label-small font-gray" style="width: 80%;">CURRENT PASSWORD</label>
<input id="currentPassword" class="center input" name="current_password" type="password" title="Current password" autofocus>
<!-- input for new password -->
<label for="newPassword" class="center label-small font-gray" style="width: 80%;">NEW PASSWORD</label>
<input id="newPassword" class="center input" name="new_password1" type="password" title="New password">
<!-- input for duplicate new password -->
<label for="newPasswordDuplicate" class="center label-small font-gray" style="width: 80%;">RE-ENTER NEW PASSWORD</label>
<input id="newPasswordDuplicate" class="center input" name="new_password2" type="password" title="New password duplicate">
<!-- save (form submission) button -->
<input id="savePassword" class="button button-primary center" title="Add" type="submit" value="Save">
<a class="button button-secondary center" href="/settings/admin" title="Cancel">Cancel</a>
</div>
</form>
<!-- FLASH MESSAGE -->
{% include "snippets/flash_message" %}
</div>
{%- endblock card -%}

View File

@ -1,32 +0,0 @@
{%- extends "nav" -%}
{%- block card %}
<!-- CONFIGURE ADMIN PAGE -->
<div class="card center">
<div class="capsule capsule-profile center-text font-normal border-info" style="font-family: var(--sans-serif); font-size: var(--font-size-6); margin-bottom: 1.5rem;">Administrators are identified and added by their Scuttlebutt public keys. These accounts will be sent private messages on Scuttlebutt when a password reset is requested.</div>
{% if not ssb_admin_ids %}
<div class="card-text">
There are no currently configured admins.
</div>
{% else %}
{% for admin in ssb_admin_ids %}
<form class="center" action="/settings/admin/delete" method="post">
<div class="center" style="display: flex; justify-content: space-between;">
<input type="hidden" name="ssb_id" value="{{ admin }}"/>
<p class="label-small label-ellipsis font-gray" style="user-select: all;">{{ admin }}</p>
<input style="width: 30%;" type="submit" class="button button-warning" value="Delete" title="Delete SSB administrator"/>
</div>
</form>
{% endfor %}
{% endif %}
<form id="addAdmin" class="center" style="margin-top: 2rem;" action="/settings/admin/add" method="post">
<div class="center" style="display: flex; flex-direction: column; margin-bottom: 2rem;" title="Public key (ID) of a desired administrator">
<label for="publicKey" class="label-small font-gray">PUBLIC KEY</label>
<input type="text" id="publicKey" name="ssb_id" placeholder="@xYz...=.ed25519" autofocus>
</div>
<!-- BUTTONS -->
<input class="button button-primary center" type="submit" title="Add SSB administrator" value="Add Admin">
<!-- FLASH MESSAGE -->
{% include "snippets/flash_message" %}
</form>
</div>
{%- endblock card -%}

View File

@ -1,19 +0,0 @@
{%- extends "nav" -%}
{%- block card %}
<!-- PASSWORD RESET REQUEST CARD -->
<div class="card center">
<div class="capsule capsule-container border-info">
<p class="card-text">Click the 'Send Temporary Password' button to send a new temporary password which can be used to change your device password.</p>
<p class="card-text" style="margin-top: 1rem;">The temporary password will be sent in an SSB private message to the admin of this device.</p>
<p class="card-text" style="margin-top: 1rem;">Once you have the temporary password, click the 'Set New Password' button to reach the password reset page.</p>
</div>
<form id="sendPasswordReset" action="/settings/admin/send_password_reset" method="post">
<div id="buttonDiv">
<input class="button button-primary center" style="margin-top: 1rem;" type="submit" value="Send Temporary Password" title="Send temporary password to Scuttlebutt admin"/>
<a href="/settings/admin/reset_password" class="button button-primary center" title="Set a new password using the temporary password">Set New Password</a>
</div>
</form>
<!-- FLASH MESSAGE -->
{% include "snippets/flash_message" %}
</div>
{%- endblock card -%}

View File

@ -1,12 +0,0 @@
{%- extends "nav" -%}
{%- block card %}
<!-- ADMIN SETTINGS MENU -->
<div class="card center">
<!-- BUTTONS -->
<div id="settingsButtons">
<a id="configure" class="button button-primary center" href="/settings/admin/configure" title="Configure Admin">Configure Admin</a>
<a id="change" class="button button-primary center" href="/settings/admin/change_password" title="Change Password">Change Password</a>
<a id="reset" class="button button-primary center" href="/settings/admin/forgot_password" title="Reset Password">Reset Password</a>
</div>
</div>
{%- endblock card -%}

Some files were not shown because too many files have changed in this diff Show More