feat: message unboxing on the cli
This commit is contained in:
parent
9ed30871c1
commit
fd51454e86
7 changed files with 281 additions and 21 deletions
3
Cargo.lock
generated
3
Cargo.lock
generated
|
|
@ -243,6 +243,8 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"message-tools",
|
"message-tools",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -827,6 +829,7 @@ dependencies = [
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
"message-tools",
|
"message-tools",
|
||||||
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
|
|
|
||||||
|
|
@ -8,4 +8,6 @@ default-run = "cli"
|
||||||
clap = { version = "4.5.52", features = ["derive"] }
|
clap = { version = "4.5.52", features = ["derive"] }
|
||||||
|
|
||||||
# local
|
# local
|
||||||
message-tools = { path = "../message-tools" }
|
message-tools = { path = "../message-tools" }
|
||||||
|
serde = "1.0.228"
|
||||||
|
serde_json = "1.0.145"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use std::{os::unix::process::ExitStatusExt, process::{Command, ExitStatus}};
|
use message_tools::BoxedMessage;
|
||||||
|
use std::{
|
||||||
|
fs::OpenOptions,
|
||||||
|
io::{BufRead, Write},
|
||||||
|
os::unix::process::ExitStatusExt,
|
||||||
|
process::{Command, ExitStatus},
|
||||||
|
};
|
||||||
|
|
||||||
const WEB_BUILD_DIR: &str = "web-build";
|
const WEB_BUILD_DIR: &str = "web-build";
|
||||||
|
|
||||||
|
|
@ -43,6 +49,10 @@ enum Commands {
|
||||||
#[arg(short, long, default_value = ".local/messages")]
|
#[arg(short, long, default_value = ".local/messages")]
|
||||||
source: String,
|
source: String,
|
||||||
|
|
||||||
|
/// Where to find the secret keys for decrypting boxed messages
|
||||||
|
#[arg(long, default_value = ".local/secrets.json")]
|
||||||
|
sk_dir: String,
|
||||||
|
|
||||||
/// Optional output file path for decoded messages
|
/// Optional output file path for decoded messages
|
||||||
/// Default: stdout
|
/// Default: stdout
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
|
|
@ -87,13 +97,143 @@ fn main() {
|
||||||
message_tools::gen_keys::generate_keys_to_file(pk_out_dir, sk_out_dir, *number)
|
message_tools::gen_keys::generate_keys_to_file(pk_out_dir, sk_out_dir, *number)
|
||||||
.expect("write key files");
|
.expect("write key files");
|
||||||
}
|
}
|
||||||
Commands::ReadMessages { .. } => {
|
Commands::ReadMessages {
|
||||||
|
source,
|
||||||
|
sk_dir,
|
||||||
|
out_dir,
|
||||||
|
} => {
|
||||||
println!("Executing read-messages command...");
|
println!("Executing read-messages command...");
|
||||||
// Add your message reading logic here
|
let kps = load_kps(sk_dir);
|
||||||
|
let msgs = {
|
||||||
|
let ms = fetch_all_messages_from_dir(source).expect("failed to read messages");
|
||||||
|
|
||||||
|
let mut unboxed: Vec<UnboxedMessage> = vec![];
|
||||||
|
// for loop go brrr, its less than 1000 keys
|
||||||
|
for m in ms {
|
||||||
|
if let Some(sk) =
|
||||||
|
fetch_sk(&kps, &m.remote_pk().expect("failed to parse pk bytes"))
|
||||||
|
{
|
||||||
|
let who = m.tag();
|
||||||
|
let message =
|
||||||
|
message_tools::unbox_message(m, sk).expect("failed to unbox message");
|
||||||
|
unboxed.push(UnboxedMessage { who, message });
|
||||||
|
} else {
|
||||||
|
eprintln!("No secret key found for message {m:?}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unboxed
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(dir) = out_dir {
|
||||||
|
let mut f = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.write(true)
|
||||||
|
.append(true)
|
||||||
|
.open(dir)
|
||||||
|
.expect("failed to create message output file");
|
||||||
|
|
||||||
|
for msg in msgs {
|
||||||
|
writeln!(f, "{:?}", msg).expect("failed to write message");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("{msgs:?}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn fetch_sk(
|
||||||
|
kps: &Vec<KeyPair>,
|
||||||
|
pk: &message_tools::PublicKey,
|
||||||
|
) -> Option<message_tools::StaticSecret> {
|
||||||
|
for kp in kps {
|
||||||
|
if &kp.pk == pk {
|
||||||
|
return Some(kp.sk.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)] // struct for easy display using debug
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct UnboxedMessage {
|
||||||
|
who: String,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct KeyPair {
|
||||||
|
pub pk: message_tools::PublicKey,
|
||||||
|
pub sk: message_tools::StaticSecret,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_kps(path: impl AsRef<std::path::Path>) -> Vec<KeyPair> {
|
||||||
|
let sks_bytes: Vec<Vec<u8>> = process_json_file(path).expect("failed to parse sk file");
|
||||||
|
|
||||||
|
sks_bytes
|
||||||
|
.into_iter()
|
||||||
|
.map(|sk| {
|
||||||
|
let arr: [u8; 32] = sk.try_into().expect("invalid secret");
|
||||||
|
message_tools::StaticSecret::from(arr)
|
||||||
|
})
|
||||||
|
.map(|sk| KeyPair {
|
||||||
|
pk: message_tools::PublicKey::from(&sk),
|
||||||
|
sk,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_all_messages_from_dir(
|
||||||
|
dir_path: impl AsRef<std::path::Path>,
|
||||||
|
) -> std::io::Result<Vec<BoxedMessage>> {
|
||||||
|
let mut all_messages: Vec<BoxedMessage> = Vec::new();
|
||||||
|
|
||||||
|
for entry in std::fs::read_dir(dir_path)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
|
||||||
|
if path.is_file() && path.extension().unwrap_or_default() == "json" {
|
||||||
|
let file_messages = process_json_file(&path)?;
|
||||||
|
all_messages.extend(file_messages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(all_messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_json_file<D: serde::de::DeserializeOwned>(
|
||||||
|
file_path: impl AsRef<std::path::Path>,
|
||||||
|
) -> std::io::Result<Vec<D>> {
|
||||||
|
let f = std::fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(false)
|
||||||
|
.open(file_path.as_ref())
|
||||||
|
.expect(&format!("failed to open file at {:?}", file_path.as_ref()));
|
||||||
|
|
||||||
|
let reader = std::io::BufReader::new(f);
|
||||||
|
let mut ds: Vec<D> = vec![];
|
||||||
|
|
||||||
|
for line in reader.lines() {
|
||||||
|
let line = line.expect("failed to read line");
|
||||||
|
let line = line.trim();
|
||||||
|
|
||||||
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let d: D = serde_json::from_str::<D>(line).expect(&format!(
|
||||||
|
"failed to parse message from file {:?}",
|
||||||
|
file_path.as_ref()
|
||||||
|
));
|
||||||
|
|
||||||
|
ds.push(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ds)
|
||||||
|
}
|
||||||
|
|
||||||
// A helper function to process the Result<ExitStatus, io::Error>
|
// A helper function to process the Result<ExitStatus, io::Error>
|
||||||
fn handle_command_status(status_result: std::io::Result<ExitStatus>, command_name: &str) {
|
fn handle_command_status(status_result: std::io::Result<ExitStatus>, command_name: &str) {
|
||||||
match status_result {
|
match status_result {
|
||||||
|
|
@ -147,12 +287,13 @@ fn build() {
|
||||||
println!("Copying files...");
|
println!("Copying files...");
|
||||||
// copy relevant files
|
// copy relevant files
|
||||||
let status = Command::new("cp")
|
let status = Command::new("cp")
|
||||||
.args(["-R", "routes"])
|
.args(["-R", "-f", "routes"])
|
||||||
.arg(format!("{WEB_BUILD_DIR}/routes"))
|
.arg(format!("{WEB_BUILD_DIR}/routes"))
|
||||||
.status();
|
.status();
|
||||||
handle_command_status(status, "cp routes");
|
handle_command_status(status, "cp routes");
|
||||||
|
|
||||||
let status = Command::new("cp")
|
let status = Command::new("cp")
|
||||||
|
.arg("-f")
|
||||||
.arg(server_bin)
|
.arg(server_bin)
|
||||||
.arg(format!("{WEB_BUILD_DIR}"))
|
.arg(format!("{WEB_BUILD_DIR}"))
|
||||||
.status();
|
.status();
|
||||||
|
|
@ -170,6 +311,7 @@ fn build() {
|
||||||
handle_command_status(status, "mkdir web-build");
|
handle_command_status(status, "mkdir web-build");
|
||||||
|
|
||||||
let status = Command::new("cp")
|
let status = Command::new("cp")
|
||||||
|
.arg("-f")
|
||||||
.arg("target/public_keys.json")
|
.arg("target/public_keys.json")
|
||||||
.arg(format!("{WEB_BUILD_DIR}/pubkeys/"))
|
.arg(format!("{WEB_BUILD_DIR}/pubkeys/"))
|
||||||
.status();
|
.status();
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,9 @@ use chacha20poly1305::{
|
||||||
aead::{Aead, OsRng},
|
aead::{Aead, OsRng},
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use x25519_dalek::StaticSecret;
|
|
||||||
|
|
||||||
|
pub use x25519_dalek::{PublicKey, StaticSecret};
|
||||||
pub mod gen_keys;
|
pub mod gen_keys;
|
||||||
pub use x25519_dalek::PublicKey;
|
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
pub mod wasm;
|
pub mod wasm;
|
||||||
|
|
@ -36,6 +35,35 @@ pub struct BoxedMessage {
|
||||||
ciphertext: Vec<u8>,
|
ciphertext: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct MyBytes(Vec<u8>);
|
||||||
|
|
||||||
|
impl std::fmt::LowerHex for MyBytes {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
// Example: iterate and format each byte
|
||||||
|
for byte in &self.0 {
|
||||||
|
write!(f, "{:02x}", byte)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BoxedMessage {
|
||||||
|
pub fn tag(&self) -> String {
|
||||||
|
let truncated = {
|
||||||
|
let mut s = format!("{:x}", MyBytes(self.user_pk.clone()));
|
||||||
|
s.truncate(16);
|
||||||
|
s
|
||||||
|
};
|
||||||
|
format!("{}-{}", self.user, truncated)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remote_pk(&self) -> Result<PublicKey> {
|
||||||
|
let bytes: [u8; 32] = self.remote_pk.clone().try_into().map_err(to_error)?;
|
||||||
|
|
||||||
|
Ok(PublicKey::from(bytes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Debug, Default)]
|
#[derive(Deserialize, Serialize, Debug, Default)]
|
||||||
pub struct MakeKeyBytesArgs {
|
pub struct MakeKeyBytesArgs {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|
|
||||||
|
|
@ -16,3 +16,4 @@ serde_json = "1.0.145"
|
||||||
message-tools = { path = "../message-tools" }
|
message-tools = { path = "../message-tools" }
|
||||||
getrandom = "0.3.4"
|
getrandom = "0.3.4"
|
||||||
lazy_static = "1.5.0"
|
lazy_static = "1.5.0"
|
||||||
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,22 @@
|
||||||
use axum::{Router, response::Html, routing::get};
|
use axum::{
|
||||||
use std::{fs::OpenOptions, io::{BufRead, Read}, path::Path};
|
Json, Router,
|
||||||
|
http::StatusCode,
|
||||||
|
response::Html,
|
||||||
|
routing::{get, post},
|
||||||
|
};
|
||||||
|
use message_tools::BoxedMessage;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::{
|
||||||
|
fs::OpenOptions,
|
||||||
|
io::{BufRead, Read},
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
use tower_http::services::ServeDir;
|
use tower_http::services::ServeDir;
|
||||||
|
|
||||||
const PUBKEY_PATH: &str = "./pubkeys/public_keys.json";
|
const PUBKEY_PATH: &str = "./pubkeys/public_keys.json";
|
||||||
|
const MESSAGE_STORAGE: &str = "./messages";
|
||||||
|
|
||||||
lazy_static::lazy_static! {
|
lazy_static::lazy_static! {
|
||||||
static ref PUBLIC_KEYS: Vec<Vec<u8>> = load_public_keys(PUBKEY_PATH).expect("static path");
|
static ref PUBLIC_KEYS: Vec<Vec<u8>> = load_public_keys(PUBKEY_PATH).expect("static path");
|
||||||
}
|
}
|
||||||
|
|
@ -12,10 +26,7 @@ type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
fn load_public_keys(path: impl AsRef<Path>) -> std::io::Result<Vec<Vec<u8>>> {
|
fn load_public_keys(path: impl AsRef<Path>) -> std::io::Result<Vec<Vec<u8>>> {
|
||||||
// Open the file
|
// Open the file
|
||||||
let f = OpenOptions::new()
|
let f = OpenOptions::new().read(true).write(false).open(path)?;
|
||||||
.read(true)
|
|
||||||
.write(false)
|
|
||||||
.open(path)?;
|
|
||||||
|
|
||||||
// Wrap it in a BufReader for efficient line-by-line reading
|
// Wrap it in a BufReader for efficient line-by-line reading
|
||||||
let reader = std::io::BufReader::new(f);
|
let reader = std::io::BufReader::new(f);
|
||||||
|
|
@ -54,6 +65,7 @@ async fn main() -> Result<()> {
|
||||||
.route("/who", get(serve_path("./routes/who/index.html")?))
|
.route("/who", get(serve_path("./routes/who/index.html")?))
|
||||||
.route("/api/pubkey", get(async || select_key(&PUBLIC_KEYS)))
|
.route("/api/pubkey", get(async || select_key(&PUBLIC_KEYS)))
|
||||||
.route("/contact", get(serve_path("./routes/contact/index.html")?))
|
.route("/contact", get(serve_path("./routes/contact/index.html")?))
|
||||||
|
.route("/api/publish", post(publish_message))
|
||||||
.nest_service("/routes", ServeDir::new("./routes"));
|
.nest_service("/routes", ServeDir::new("./routes"));
|
||||||
|
|
||||||
// run our app with hyper, listening globally on port 3000
|
// run our app with hyper, listening globally on port 3000
|
||||||
|
|
@ -63,6 +75,56 @@ async fn main() -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Define a struct for the response
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct UserCreated {
|
||||||
|
tag: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME: sanitation of boxed messages
|
||||||
|
async fn publish_message(Json(msg): Json<BoxedMessage>) -> (StatusCode, Json<UserCreated>) {
|
||||||
|
let tag = msg.tag(); // Capture tag early
|
||||||
|
|
||||||
|
// 1. Define the full *file* path: e.g., "MESSAGE_STORAGE/some_tag/.json"
|
||||||
|
let file_path = PathBuf::from(MESSAGE_STORAGE)
|
||||||
|
.join(format!("{tag}.json")); // The file inside that directory
|
||||||
|
|
||||||
|
// 3. Create all necessary parent directories recursively (async operation)
|
||||||
|
if let Err(e) = tokio::fs::create_dir_all(&MESSAGE_STORAGE).await {
|
||||||
|
eprintln!(
|
||||||
|
"Failed to create directory {}: {}",
|
||||||
|
MESSAGE_STORAGE,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, Json(UserCreated { tag }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Open the file asynchronously
|
||||||
|
let mut f = match tokio::fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.write(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&file_path)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(file) => file,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to open file {}: {}", file_path.display(), e);
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, Json(UserCreated { tag }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 5. Serialize and write the data asynchronously
|
||||||
|
// We append a newline character for standard text file formatting
|
||||||
|
let s = serde_json::to_string(&msg).expect("failed to serialize boxed message");
|
||||||
|
if let Err(e) = f.write_all(format!("{}\n", s).as_bytes()).await {
|
||||||
|
eprintln!("Failed to write message to file: {}", e);
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, Json(UserCreated { tag }));
|
||||||
|
}
|
||||||
|
|
||||||
|
(StatusCode::CREATED, Json(UserCreated { tag }))
|
||||||
|
}
|
||||||
|
|
||||||
fn select_key(pks: &Vec<Vec<u8>>) -> String {
|
fn select_key(pks: &Vec<Vec<u8>>) -> String {
|
||||||
let idx = getrandom::u64().expect("random integer") as usize % pks.len();
|
let idx = getrandom::u64().expect("random integer") as usize % pks.len();
|
||||||
serde_json::to_string(&pks[idx]).expect("serialize vec of bytes")
|
serde_json::to_string(&pks[idx]).expect("serialize vec of bytes")
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@
|
||||||
await wasm_bindgen();
|
await wasm_bindgen();
|
||||||
await fetchPublicKey();
|
await fetchPublicKey();
|
||||||
|
|
||||||
console.log(`Server pk: ${serverPublicKey}`)
|
console.log(`Server pk: ${serverPublicKey}`);
|
||||||
|
|
||||||
const form = document.getElementById("message-form");
|
const form = document.getElementById("message-form");
|
||||||
form.addEventListener("submit", (event) => {
|
form.addEventListener("submit", (event) => {
|
||||||
|
|
@ -112,9 +112,9 @@
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pubkeyJSON = await response.text();
|
const pubkeyJSON = await response.text();
|
||||||
serverPublicKey = JSON.parse(pubkeyJSON)
|
serverPublicKey = JSON.parse(pubkeyJSON);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching public key:", error);
|
console.error("Error fetching public key:", error);
|
||||||
document.getElementById("status").textContent =
|
document.getElementById("status").textContent =
|
||||||
|
|
@ -122,7 +122,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function submit() {
|
async function submit() {
|
||||||
console.log("begin");
|
console.log("begin");
|
||||||
const user = document.getElementById("name").value;
|
const user = document.getElementById("name").value;
|
||||||
const password = document.getElementById("password").value;
|
const password = document.getElementById("password").value;
|
||||||
|
|
@ -146,13 +146,35 @@
|
||||||
remote_pk: [...serverPublicKey],
|
remote_pk: [...serverPublicKey],
|
||||||
plaintext: String(message),
|
plaintext: String(message),
|
||||||
};
|
};
|
||||||
console.log("Boxed preflight: ", JSON.stringify(boxMessageArgs))
|
console.log("Boxed preflight: ", JSON.stringify(boxMessageArgs));
|
||||||
const boxed = box_message(JSON.stringify(boxMessageArgs));
|
const boxed = box_message(JSON.stringify(boxMessageArgs));
|
||||||
console.log(JSON.parse(boxed));
|
const boxedObj = JSON.parse(boxed); // Parse the boxed string into a JavaScript object
|
||||||
|
console.log(boxedObj);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/publish", {
|
||||||
|
method: "POST", // Specify the method as POST
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json", // Set the Content-Type header for JSON payload
|
||||||
|
},
|
||||||
|
body: JSON.stringify(boxedObj), // Send the boxed object as a JSON string in the body
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Handle HTTP errors (status codes outside the 2xx range)
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json(); // Parse the response body as JSON
|
||||||
|
console.log("Success:", result);
|
||||||
|
alert("Sent!");
|
||||||
|
location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error:", error);
|
||||||
|
alert("Failed to send message: " + error.message);
|
||||||
|
}
|
||||||
|
|
||||||
console.log("End");
|
console.log("End");
|
||||||
alert("Sent!");
|
|
||||||
location.reload();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
run();
|
run();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue