correction + regexp
This commit is contained in:
70
rklipd/src/config.rs
Normal file
70
rklipd/src/config.rs
Normal file
@ -0,0 +1,70 @@
|
||||
/// rklipd --max-entries 500 --max-entry-size-kb 512 --expiry-days 30
|
||||
pub struct Config {
|
||||
pub max_entries: usize,
|
||||
pub max_entry_size_kb: usize,
|
||||
pub expiry_days: Option<u64>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_entries: 500,
|
||||
max_entry_size_kb: 512,
|
||||
expiry_days: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_args() -> Self {
|
||||
let mut cfg = Self::default();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let mut i = 0;
|
||||
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--max-entries" => {
|
||||
if let Some(v) = args.get(i + 1).and_then(|s| s.parse().ok()) {
|
||||
cfg.max_entries = v;
|
||||
i += 1;
|
||||
} else {
|
||||
eprintln!("--max-entries requiert une valeur entière positive");
|
||||
}
|
||||
}
|
||||
"--max-entry-size-kb" => {
|
||||
if let Some(v) = args.get(i + 1).and_then(|s| s.parse().ok()) {
|
||||
cfg.max_entry_size_kb = v;
|
||||
i += 1;
|
||||
} else {
|
||||
eprintln!("--max-entry-size-kb requiert une valeur entière positive");
|
||||
}
|
||||
}
|
||||
"--expiry-days" => {
|
||||
if let Some(v) = args.get(i + 1).and_then(|s| s.parse::<u64>().ok()) {
|
||||
cfg.expiry_days = Some(v);
|
||||
i += 1;
|
||||
} else {
|
||||
eprintln!("--expiry-days requiert une valeur entière positive");
|
||||
}
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
println!(
|
||||
r#"Usage: rklipd [OPTIONS]
|
||||
|
||||
Options:
|
||||
--max-entries <N> Nombre max d'entrées (défaut: 500)
|
||||
--max-entry-size-kb <N> Taille max d'une entrée en Ko (défaut: 512)
|
||||
--expiry-days <N> Supprime les entrées > N jours (défaut: désactivé)
|
||||
--help Affiche cette aide"#
|
||||
);
|
||||
std::process::exit(0);
|
||||
}
|
||||
unknown => {
|
||||
eprintln!("Argument inconnu : {unknown}. Utilisez --help.");
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
cfg
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,9 @@ use std::error::Error;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
const ENC_PREFIX: &str = "enc:";
|
||||
const ENC2_PREFIX: &str = "enc2:";
|
||||
|
||||
pub struct Crypto {
|
||||
key: [u8; 32],
|
||||
}
|
||||
@ -45,13 +48,13 @@ impl Crypto {
|
||||
.map_err(|e| format!("Erreur de chiffrement : {e}"))?;
|
||||
let mut combined = nonce.to_vec();
|
||||
combined.extend_from_slice(&ciphertext);
|
||||
Ok(format!("enc:{}", BASE64.encode(combined)))
|
||||
Ok(format!("{}{}", ENC_PREFIX, BASE64.encode(combined)))
|
||||
}
|
||||
|
||||
pub fn decrypt(&self, encrypted: &str) -> Result<String, Box<dyn Error>> {
|
||||
let encoded = encrypted
|
||||
.strip_prefix("enc:")
|
||||
.ok_or("Pas une entrée chiffrée")?;
|
||||
.strip_prefix(ENC_PREFIX)
|
||||
.ok_or("Pas une entrée chiffrée (enc:)")?;
|
||||
let combined = BASE64.decode(encoded)?;
|
||||
if combined.len() < 12 {
|
||||
return Err("Données chiffrées trop courtes".into());
|
||||
@ -66,7 +69,15 @@ impl Crypto {
|
||||
Ok(String::from_utf8(plaintext)?)
|
||||
}
|
||||
|
||||
pub fn is_encrypted(content: &str) -> bool {
|
||||
content.starts_with("enc:")
|
||||
pub fn is_legacy_encrypted(content: &str) -> bool {
|
||||
content.starts_with(ENC_PREFIX) && !content.starts_with(ENC2_PREFIX)
|
||||
}
|
||||
|
||||
pub fn is_password_encrypted(content: &str) -> bool {
|
||||
content.starts_with(ENC2_PREFIX)
|
||||
}
|
||||
|
||||
pub fn is_any_encrypted(content: &str) -> bool {
|
||||
content.starts_with(ENC_PREFIX) || content.starts_with(ENC2_PREFIX)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
use crate::config::Config;
|
||||
use crate::models::{ClipboardData, ClipboardEntry, Image};
|
||||
use image::codecs::jpeg::JpegEncoder;
|
||||
use image::{ExtendedColorType, ImageEncoder};
|
||||
@ -5,46 +6,55 @@ use rusqlite::Connection;
|
||||
use std::error::Error;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct Database {
|
||||
conn: Connection,
|
||||
dir_path: String,
|
||||
max_entries: usize,
|
||||
max_entry_size_bytes: usize,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub fn init(dir_path: &str) -> Result<Self, Box<dyn Error>> {
|
||||
pub fn init(dir_path: &str, config: &Config) -> Result<Self, Box<dyn Error>> {
|
||||
let base_path = Path::new(dir_path);
|
||||
fs::create_dir_all(base_path.join("images"))?;
|
||||
|
||||
let conn = Connection::open(base_path.join("clipboard.db"))?;
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL);
|
||||
INSERT OR IGNORE INTO schema_version (version) SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM schema_version);",
|
||||
)?;
|
||||
"CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL);
|
||||
INSERT OR IGNORE INTO schema_version (version)
|
||||
SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM schema_version);",
|
||||
)?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL
|
||||
)",
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_history_content ON history(content);
|
||||
CREATE INDEX IF NOT EXISTS idx_history_timestamp ON history(timestamp DESC);",
|
||||
)?;
|
||||
|
||||
conn.execute_batch(
|
||||
"DELETE FROM history WHERE id NOT IN (
|
||||
SELECT MAX(id) FROM history GROUP BY content
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_history_content ON history(content);",
|
||||
SELECT MAX(id) FROM history GROUP BY content
|
||||
);",
|
||||
)?;
|
||||
|
||||
Ok(Self {
|
||||
conn,
|
||||
dir_path: dir_path.to_string(),
|
||||
max_entries: config.max_entries,
|
||||
max_entry_size_bytes: config.max_entry_size_kb * 1024,
|
||||
})
|
||||
}
|
||||
|
||||
@ -56,10 +66,16 @@ impl Database {
|
||||
if t.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if t.len() > self.max_entry_size_bytes {
|
||||
return Ok(());
|
||||
}
|
||||
("text", t.clone())
|
||||
}
|
||||
ClipboardData::Image(img) => {
|
||||
if let Some(px) = &img.raw_pixels {
|
||||
if px.len() > self.max_entry_size_bytes * 4 {
|
||||
return Ok(());
|
||||
}
|
||||
let path = img.file_path(&self.dir_path);
|
||||
let file = fs::File::create(&path)?;
|
||||
let rgb: Vec<u8> = px
|
||||
@ -81,6 +97,45 @@ impl Database {
|
||||
"INSERT OR REPLACE INTO history (type, content, timestamp) VALUES (?1, ?2, ?3)",
|
||||
(kind, &content, ts),
|
||||
)?;
|
||||
|
||||
self.trim_to_max()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn trim_to_max(&self) -> Result<(), Box<dyn Error>> {
|
||||
if self.max_entries == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT content FROM history
|
||||
WHERE type = 'image'
|
||||
AND id NOT IN (
|
||||
SELECT id FROM history ORDER BY timestamp DESC LIMIT ?1
|
||||
)",
|
||||
)?;
|
||||
let to_delete: Vec<String> = stmt
|
||||
.query_map([self.max_entries as i64], |row| row.get(0))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
self.conn.execute(
|
||||
"DELETE FROM history WHERE id NOT IN (
|
||||
SELECT id FROM history ORDER BY timestamp DESC LIMIT ?1
|
||||
)",
|
||||
[self.max_entries as i64],
|
||||
)?;
|
||||
|
||||
for filename in to_delete {
|
||||
let path = Path::new(&self.dir_path).join("images").join(&filename);
|
||||
if path.exists() {
|
||||
if let Err(e) = fs::remove_file(&path) {
|
||||
eprintln!("Impossible de supprimer l'image {filename} : {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -137,14 +192,40 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_entries_older_than(&self, days: u64) -> Result<usize, Box<dyn Error>> {
|
||||
let cutoff_ms = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() as i64
|
||||
- (days as i64 * 86_400_000);
|
||||
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT content FROM history WHERE type = 'image' AND timestamp < ?1")?;
|
||||
let image_files: Vec<String> = stmt
|
||||
.query_map([cutoff_ms], |row| row.get(0))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
let count = self
|
||||
.conn
|
||||
.execute("DELETE FROM history WHERE timestamp < ?1", [cutoff_ms])?;
|
||||
|
||||
for filename in image_files {
|
||||
let path = Path::new(&self.dir_path).join("images").join(&filename);
|
||||
if path.exists() {
|
||||
if let Err(e) = fs::remove_file(&path) {
|
||||
eprintln!("Impossible de supprimer l'image expirée {filename} : {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub fn clear_history(&self) -> Result<(), Box<dyn Error>> {
|
||||
let images_dir = Path::new(&self.dir_path).join("images");
|
||||
|
||||
if images_dir.exists() {
|
||||
fs::remove_dir_all(&images_dir)?;
|
||||
}
|
||||
fs::create_dir_all(&images_dir)?;
|
||||
|
||||
self.conn.execute("DELETE FROM history", [])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -119,12 +119,23 @@ pub fn start_server(db: Arc<Mutex<Database>>, crypto: Arc<Crypto>, socket_path:
|
||||
}
|
||||
|
||||
IpcRequest::SetClipboard { content } => {
|
||||
let actual =
|
||||
if content.starts_with("enc:") || content.starts_with("enc2:") {
|
||||
crypto_clone.decrypt(&content).unwrap_or(content)
|
||||
} else {
|
||||
content
|
||||
};
|
||||
let actual = if Crypto::is_legacy_encrypted(&content) {
|
||||
crypto_clone.decrypt(&content).unwrap_or_else(|e| {
|
||||
eprintln!("Impossible de déchiffrer l'entrée enc: : {e}");
|
||||
content.clone()
|
||||
})
|
||||
} else if Crypto::is_password_encrypted(&content) {
|
||||
reply(
|
||||
&mut stream,
|
||||
IpcResponse::Error(
|
||||
"Entrée chiffrée par mot de passe : déchiffrez-la côté client avant de coller"
|
||||
.to_string(),
|
||||
),
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
content
|
||||
};
|
||||
|
||||
match arboard::Clipboard::new() {
|
||||
Ok(mut cb) => {
|
||||
@ -142,6 +153,14 @@ pub fn start_server(db: Arc<Mutex<Database>>, crypto: Arc<Crypto>, socket_path:
|
||||
height: h,
|
||||
bytes: std::borrow::Cow::Owned(rgba.into_raw()),
|
||||
});
|
||||
} else {
|
||||
reply(
|
||||
&mut stream,
|
||||
IpcResponse::Error(format!(
|
||||
"Image introuvable : {actual}"
|
||||
)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
mod config;
|
||||
mod crypto;
|
||||
mod database;
|
||||
mod ipc;
|
||||
@ -5,13 +6,27 @@ mod models;
|
||||
mod monitor;
|
||||
mod ws;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::crypto::Crypto;
|
||||
use crate::database::Database;
|
||||
use arboard::Clipboard;
|
||||
use directories::ProjectDirs;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let config = Config::from_args();
|
||||
|
||||
println!(
|
||||
"rklipd démarrage — max_entries={}, max_entry_size_kb={}, expiry_days={}",
|
||||
config.max_entries,
|
||||
config.max_entry_size_kb,
|
||||
config
|
||||
.expiry_days
|
||||
.map(|d| d.to_string())
|
||||
.unwrap_or_else(|| "désactivé".to_string())
|
||||
);
|
||||
|
||||
let clipboard = Clipboard::new()?;
|
||||
|
||||
let proj_dirs =
|
||||
@ -19,7 +34,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let dir_path = proj_dirs.data_dir();
|
||||
let dir_path_str = dir_path.to_str().expect("Chemin invalide").to_string();
|
||||
|
||||
let db = Arc::new(Mutex::new(Database::init(&dir_path_str)?));
|
||||
let db = Arc::new(Mutex::new(Database::init(&dir_path_str, &config)?));
|
||||
let crypto = Arc::new(Crypto::load_or_create(dir_path)?);
|
||||
|
||||
let socket_path = dir_path.join("rklip.sock");
|
||||
@ -29,8 +44,21 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
crate::ipc::start_server(db_for_ipc, crypto_for_ipc, &socket_path);
|
||||
});
|
||||
|
||||
println!("rklipd démarrage...");
|
||||
monitor::start(db, clipboard)?;
|
||||
if let Some(days) = config.expiry_days {
|
||||
let db_for_expiry = Arc::clone(&db);
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_secs(3600));
|
||||
let lock = db_for_expiry.lock().unwrap();
|
||||
match lock.delete_entries_older_than(days) {
|
||||
Ok(0) => {}
|
||||
Ok(n) => println!("Expiration : {n} entrée(s) supprimée(s) (> {days} jours)"),
|
||||
Err(e) => eprintln!("Erreur expiration : {e}"),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
monitor::start(db, clipboard)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user