use std::
{
fs,
collections::{ HashMap, HashSet },
sync::{ LazyLock, Mutex },
};
use wincode::{ SchemaWrite, SchemaRead };
use why2::consts as why2_consts;
use crate::
{
misc,
crypto,
consts::{ self, SharedKeys },
network::codes::
{
MessageColors,
StoredMessage,
},
};
#[derive(SchemaWrite, SchemaRead, Clone)]
struct Record {
id: u64,
username: String,
text: String,
image: Option<[u8; 32]>,
}
#[derive(SchemaRead)]
struct LegacyRecord {
username: String,
text: String,
image: Option<[u8; 32]>,
}
struct History {
next: u64, records: Vec<Record>,
}
pub struct Page
{
pub messages: Vec<StoredMessage>,
pub start: u64, pub more: bool, pub kept: u64, }
static HISTORY: LazyLock<Mutex<History>> = LazyLock::new(|| Mutex::new(History::new())); static KEYS: LazyLock<SharedKeys> = LazyLock::new(crypto::history_keys);
impl History
{
fn new() -> Self {
let records = load();
let next = records.last().map_or(0, |message| message.id + 1);
Self { next, records }
}
fn take_id(&mut self) -> u64 {
let id = self.next;
self.next += 1;
id
}
fn position(&self, id: u64) -> usize {
self.records.partition_point(|message| message.id < id)
}
fn save(&self) {
let bytes = wincode::config::serialize(&self.records, consts::PACKET_CONFIG).expect("Encoding message history failed");
let sealed = crypto::encrypt_packet::<{ why2_consts::DEFAULT_GRID_WIDTH }, { why2_consts::DEFAULT_GRID_HEIGHT }>(&bytes, &KEYS);
fs::write(path(), sealed).expect("Saving message history failed");
}
fn orphans(&self, dropped: Vec<[u8; 32]>) -> Vec<[u8; 32]> {
dropped.into_iter()
.filter(|hash| !self.records.iter().any(|message| message.image.as_ref() == Some(hash)))
.filter(|hash| !super::users::names_avatar(hash))
.collect()
}
}
fn path() -> String {
super::config_path(consts::SERVER_MESSAGES_FILE)
}
fn load() -> Vec<Record> {
let Ok(bytes) = fs::read(path()) else
{
log::info!("No message history on disk, starting empty");
return Vec::new();
};
let Some(plaintext) = crypto::decrypt_packet::
<{ why2_consts::DEFAULT_GRID_WIDTH }, { why2_consts::DEFAULT_GRID_HEIGHT }>(bytes, &KEYS)
else
{
log::error!("Message history failed verification, it is being ignored");
return Vec::new();
};
match wincode::config::deserialize::<Vec<Record>, _>(&plaintext, consts::PACKET_CONFIG)
{
Ok(history) =>
{
log::info!("Loaded {} stored messages", history.len());
history
},
Err(_) => migrate(&plaintext), }
}
fn migrate(plaintext: &[u8]) -> Vec<Record> {
let Ok(history) = wincode::config::deserialize::<Vec<LegacyRecord>, _>(plaintext, consts::PACKET_CONFIG) else
{
log::error!("Message history is of an older format, it is being ignored");
return Vec::new();
};
log::info!("Migrated {} stored messages, ids assigned", history.len());
history.into_iter().zip(0..).map(|(message, id)| Record
{
id,
username: message.username,
text: message.text,
image: message.image,
}).collect()
}
pub fn next_id() -> u64 {
HISTORY.lock().unwrap().take_id()
}
pub fn store(username: &str, text: &str) -> u64 {
push(username, text, None)
}
pub fn store_image(username: &str, filename: &str, hash: &[u8; 32]) -> u64
{
push(username, filename, Some(*hash))
}
fn push(username: &str, text: &str, image: Option<[u8; 32]>) -> u64 {
let limit: usize = super::read_config("max_persistent_messages");
let mut guard = HISTORY.lock().unwrap();
let id = guard.take_id();
if limit == 0 { return id; }
guard.records.push(Record { id, username: username.to_string(), text: text.to_string(), image });
let over = guard.records.len().saturating_sub(limit);
let dropped: Vec<[u8; 32]> = guard.records.drain(..over).filter_map(|message| message.image).collect();
let orphans = guard.orphans(dropped);
guard.save();
drop(guard);
remove_images(orphans);
id
}
fn remove_images(orphans: Vec<[u8; 32]>) {
if !orphans.is_empty() { log::info!("Dropping {} stored images with no history entry left", orphans.len()); }
for hash in orphans { let _ = fs::remove_file(misc::get_image_dir().join(misc::hex(&hash))); }
}
pub fn author(id: u64) -> Option<String> {
let history = HISTORY.lock().unwrap();
history.records.get(history.position(id)).filter(|message| message.id == id).map(|message| message.username.clone())
}
pub fn delete(id: u64) -> bool {
let mut guard = HISTORY.lock().unwrap();
let index = guard.position(id);
if guard.records.get(index).is_none_or(|message| message.id != id) { return false; }
let dropped: Vec<[u8; 32]> = guard.records.remove(index).image.into_iter().collect();
let orphans = guard.orphans(dropped);
guard.save();
drop(guard);
remove_images(orphans);
true
}
pub fn has_image(hash: &[u8; 32]) -> bool {
HISTORY.lock().unwrap().records.iter().any(|message| message.image.as_ref() == Some(hash))
}
pub fn stored(hash: &[u8; 32]) -> bool {
has_image(hash) || super::users::names_avatar(hash)
}
pub fn sweep_images()
{
let Ok(directory) = fs::read_dir(misc::get_image_dir()) else { return };
let files: Vec<_> = directory.flatten().map(|entry| entry.path()).collect();
if files.is_empty() { return; }
let mut kept: HashSet<String> = HISTORY.lock().unwrap().records.iter()
.filter_map(|message| message.image.as_ref().map(|hash| misc::hex(hash)))
.collect();
kept.extend(super::users::avatars().iter().map(|hash| misc::hex(hash)));
let mut swept = 0;
for file in files
{
let named = file.file_name().and_then(|name| name.to_str())
.map(|name| kept.contains(name)).unwrap_or(false);
if !named && fs::remove_file(&file).is_ok() { swept += 1; }
}
if swept > 0 { log::info!("Swept {swept} stored images nothing names any more"); }
}
pub fn page(before: Option<u64>, count: usize, budget: usize) -> Page
{
let (records, start, more, kept) =
{
let history = HISTORY.lock().unwrap();
let to = before.map_or(history.records.len(), |id| history.position(id));
let mut size = 0;
let mut from = to;
while from > 0 && to - from < count
{
let record = &history.records[from - 1];
size += record.username.len() + record.text.len();
if size > budget && from != to { break; }
from -= 1;
}
let start = history.records.get(from).map_or(history.next, |message| message.id);
(history.records[from..to].to_vec(), start, from > 0, history.records.len() as u64)
};
let mut looked_up: HashMap<String, MessageColors> = HashMap::new();
let messages = records.into_iter().map(|message|
{
let stored = looked_up.entry(message.username.clone())
.or_insert_with(|| super::users::colors(&message.username));
StoredMessage
{
message_id: message.id,
username: message.username,
text: message.text,
colors: match message.image.is_some()
{
true => MessageColors { username_color: stored.username_color, message_color: None },
false => stored.clone(),
},
image: message.image,
}
}).collect();
Page { messages, start, more, kept }
}