why2-chat 2.2.2

Lightweight, fast and secure chat application powered by WHY2 encryption.
/*
This is part of WHY2
Copyright (C) 2022-2026 Václav Šmejkal

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program.  If not, see <https://www.gnu.org/licenses/>.
*/

use std::
{
    fs,
    env,
    time::Duration,
    path::Path,
};

use ureq::{ Error, Agent };

use serde_json::Value;

use semver::Version;

use crate::consts;

#[cfg(feature = "client_base")]
use tokio::sync::mpsc::Sender;

#[cfg(feature = "client_base")]
use crate::network::client::ClientEvent;

#[cfg(feature = "chat")]
use std::
{
    fmt::Write,
    path::PathBuf,
};

//PRIVATE
fn get_dir(dir: &str) -> String
{
    dir.replace("{HOME}", dirs::home_dir().expect("Could not determine home directory").to_str().expect("Invalid home directory"))
}

//PUBLIC
pub fn get_version<'a>() -> &'a str //GET COMPILED PACKAGE VERSION
{
    env!("CARGO_PKG_VERSION")
}

pub fn get_identifier() -> String //GET IDENTIFIER OF PACKAGE VERSION [WHY2/VERSION]
{
    format!("WHY2/{}", get_version())
}

pub fn fetch_data(url: &str) -> Result<String, Error> //FETCH DATA USING REQWEST
{
    //CREATE CUSTOM CLIENT (WITH TIMEOUT)
    let agent: Agent = Agent::config_builder()
        .timeout_global(Some(Duration::from_millis(consts::FETCH_TIMEOUT)))
        .build()
        .into();

    //FETCH DATA
    agent.get(url)
        .header("User-Agent", &get_identifier())
        .call()?
        .body_mut()
        .read_to_string()
}

pub async fn check_version(#[cfg(feature = "client_base")] tx: &Sender<ClientEvent>) //CHECK FOR LATEST WHY2 VERSION
{
    //FETCH METADATA (CUSTOM User-Agent, BLOCKING)
    let metadata_raw = match tokio::task::spawn_blocking(|| fetch_data(consts::METADATA_URL)).await
        .expect("Fetching versions panicked")
    {
        Ok(m) => m,
        Err(_) =>
        {
            #[cfg(feature = "client_base")]
            {
                tx.send(ClientEvent::VersionFailed).await.unwrap();
            }

            #[cfg(feature = "server")]
            {
                log::warn!("Fetching versions failed, this release could be unsafe!");
            }

            return;
        }
    };

    //PARSE METADATA TO JSON
    let metadata: Value = serde_json::from_str(&metadata_raw).expect("Parsing versions failed"); //PARSE
    let newest_version = metadata.get("crate") //GET LATEST VERSION
        .and_then(|c| c.get("newest_version"))
        .and_then(|v| v.as_str())
        .unwrap();

    //OUTDATED VERSION, COUNT THE NEWER ONES
    let current_version = get_version();
    if current_version != newest_version
    {
        //GET ARRAY OF VERSIONS
        let versions = metadata.get("versions").and_then(|v| v.as_array()).unwrap();
        let mut newer_versions = 0usize;
        let current_version = Version::parse(current_version).expect("Invalid version");

        //CALCULATE
        for version in versions
        {
            //FOUND NEWER VERSION
            if Version::parse(version.get("num").and_then(|n| n.as_str()).unwrap()).unwrap() > current_version
            {
                //INCREMENT COUNTER
                newer_versions += 1;
            }
        }

        #[cfg(feature = "client_base")]
        {
            tx.send(ClientEvent::UnsafeVersion(newer_versions, current_version, newest_version.to_owned())).await.unwrap();
        }

        #[cfg(feature = "server")]
        {
            log::warn!("This release could be unsafe! You are {newer_versions} versions behind! ({current_version}/{newest_version})");
        }
    }
}

pub fn get_why2_dir() -> String //RETURN PATH TO WHY2 CONFIG DIRECTORY
{
    get_dir(consts::CONFIG_DIR)
}

pub fn check_directory() //CREATE WHY2 CONFIG DIRECTORY
{
    let config = get_why2_dir();

    //CREATE WHY2 CONFIG DIRECTORY
    if !Path::new(&config).is_dir()
    {
        fs::create_dir_all(config).expect("Failed to create WHY2 config directory");
    }
}

//A PNG'S OR GIF'S SIZE, READ OFF ITS HEADER
pub fn image_dimensions(header: &[u8]) -> Option<(u32, u32)>
{
    let be = |at: usize| u32::from_be_bytes(header[at..at + 4].try_into().unwrap());
    let le = |at: usize| u16::from_le_bytes([header[at], header[at + 1]]) as u32;

    if header.len() >= 24 && header.starts_with(b"\x89PNG\r\n\x1a\n") && &header[12..16] == b"IHDR"
    {
        return Some((be(16), be(20)));
    }

    if header.len() >= 10 && (header.starts_with(b"GIF87a") || header.starts_with(b"GIF89a"))
    {
        return Some((le(6), le(8)));
    }

    None
}

//A SQUARE PNG OR GIF NO BIGGER THAN AN AVATAR IS CUT TO
pub fn is_avatar(header: &[u8]) -> bool
{
    matches!(image_dimensions(header), Some((width, height))
        if width == height && width > 0 && width <= consts::AVATAR_DIMENSION)
}

pub fn is_image(header: &[u8]) -> bool //CHECK FOR SUPPORTED IMAGE
{
    const MAGIC: [&[u8]; 10] =
    [
        b"\x89PNG\r\n\x1a\n", //PNG
        b"\xff\xd8\xff",      //JPEG
        b"GIF87a",            //GIF (87)
        b"GIF89a",            //GIF (89)
        b"BM",                //BMP
        b"\x00\x00\x01\x00",  //ICO
        b"qoif",              //QOI
        b"#?",                //RADIANCE HDR
        b"farbfeld",          //FARBFELD
        b"DDS ",              //DDS
    ];

    if MAGIC.iter().any(|magic| header.starts_with(magic)) { return true; }

    //WEBP SITS BEHIND THE RIFF LENGTH
    (header.len() >= 12 && header.starts_with(b"RIFF") && &header[8..12] == b"WEBP") ||
    (header.len() >= 3 && header[0] == b'P' && (b'1'..=b'7').contains(&header[1]) &&
        header[2].is_ascii_whitespace())
}

#[cfg(feature = "chat")]
pub fn is_web_url(url: &str) -> bool //A LINK SAFE TO HAND A SYSTEM OPENER
{
    let scheme = |length: usize, scheme: &str| url.get(..length)
        .is_some_and(|head| head.eq_ignore_ascii_case(scheme));

    scheme(7, "http://") || scheme(8, "https://")
}

#[cfg(feature = "server")]
pub fn get_upload_dir(username: &str) -> PathBuf //GET USER'S TEMP DIR FOR UPLOAD
{
    env::temp_dir().join(consts::UPLOADS_DIR).join(username)
}

#[cfg(feature = "server")]
pub fn get_image_dir() -> PathBuf //DIRECTORY FOR PERSISTENT IMAGES
{
    PathBuf::from(get_why2_dir() + consts::SERVER_IMAGES_DIR)
}

#[cfg(feature = "chat")]
pub fn unhex(text: &str) -> Option<[u8; 32]> //A 32-BYTE HASH BACK OUT OF HEX
{
    if text.len() != 64 { return None; }

    let mut bytes = [0u8; 32];

    for (byte, pair) in bytes.iter_mut().zip(text.as_bytes().chunks(2))
    {
        *byte = u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok()?;
    }

    Some(bytes)
}

#[cfg(feature = "chat")]
pub fn hex(bytes: &[u8]) -> String //BYTES AS LOWERCASE HEX
{
    let mut string = String::with_capacity(bytes.len() * 2);

    for byte in bytes
    {
        write!(string, "{byte:02x}").expect("Hex encoding failed");
    }

    string
}

#[cfg(feature = "client_base")]
pub fn avatar_temp(hash: &[u8; 32], extension: &str) -> PathBuf //WHERE A CUT AVATAR WAITS FOR ITS UPLOAD
{
    env::temp_dir().join(format!("{}{}.{extension}", consts::AVATAR_TEMP_PREFIX, hex(hash)))
}

#[cfg(feature = "client_base")]
pub fn drop_avatar_temp(path: &Path) //REMOVE ONE, IF THAT IS WHAT path IS
{
    let ours = path.parent() == Some(env::temp_dir().as_path()) && path.file_name()
        .and_then(|name| name.to_str()).is_some_and(|name| name.starts_with(consts::AVATAR_TEMP_PREFIX));

    if ours { let _ = fs::remove_file(path); }
}

#[cfg(feature = "client_base")]
pub fn get_image_cache_dir(fingerprint: &str) -> PathBuf //DIRECTORY FOR ONE SERVER'S CACHED IMAGES
{
    PathBuf::from(get_why2_dir() + consts::CLIENT_IMAGES_DIR).join(fingerprint)
}

#[cfg(feature = "server")]
pub fn restart() -> !
{
    let executable = match env::current_exe()
    {
        Ok(path) => path,
        Err(error) =>
        {
            log::error!("Restart failed (executable not found): {error}");
            std::process::exit(1);
        }
    };

    let arguments: Vec<String> = env::args().skip(1).collect();

    //REPLACE THE PROCESS IMAGE, KEEPING THE PID
    #[cfg(unix)]
    {
        use std::os::unix::process::CommandExt;

        //ONLY EVER RETURNS ON FAILURE
        let error = std::process::Command::new(&executable).args(&arguments).exec();

        log::error!("Restart failed: {error}");
    }

    //START THE REPLACEMENT BESIDE US AND STAND DOWN
    #[cfg(not(unix))]
    match std::process::Command::new(&executable).args(&arguments).spawn()
    {
        Ok(_) => std::process::exit(0),
        Err(error) => log::error!("Restart failed: {error}"),
    }

    std::process::exit(1);
}