aboutsummaryrefslogtreecommitdiff
path: root/src/server/clipboard_image.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-20 02:51:59 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-20 02:51:59 +0300
commited478be9835e984c42bc1e30fe84b5fb01a8c5d4 (patch)
tree353b6ea5c7368a9f3b477bc8f1bbcd248e377577 /src/server/clipboard_image.rs
parent2b97799b6715fd6c355db9c225029765971915e6 (diff)
fix: bridge remote clipboard image paste
refs #205
Diffstat (limited to 'src/server/clipboard_image.rs')
-rw-r--r--src/server/clipboard_image.rs128
1 files changed, 128 insertions, 0 deletions
diff --git a/src/server/clipboard_image.rs b/src/server/clipboard_image.rs
new file mode 100644
index 00000000..299267d3
--- /dev/null
+++ b/src/server/clipboard_image.rs
@@ -0,0 +1,128 @@
+use std::fs;
+use std::io::{self, Write as _};
+use std::path::{Path, PathBuf};
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
+
+const STAGED_CLIPBOARD_IMAGE_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
+
+pub(crate) struct StagedClipboardImage {
+ pub(crate) path: PathBuf,
+ pub(crate) paste_text: String,
+}
+
+pub(crate) fn stage(
+ client_id: u64,
+ extension: &str,
+ data: &[u8],
+) -> io::Result<StagedClipboardImage> {
+ use std::os::unix::fs::OpenOptionsExt;
+
+ let extension = sanitize_extension(extension);
+ let dir = ensure_staging_dir()?;
+ cleanup_stale(&dir);
+
+ let unique = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|duration| duration.as_nanos())
+ .unwrap_or(0);
+
+ for attempt in 0..100 {
+ let path = dir.join(format!(
+ "client-{client_id}-clipboard-{unique}-{attempt}.{extension}"
+ ));
+ let mut file = match fs::OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .mode(0o600)
+ .open(&path)
+ {
+ Ok(file) => file,
+ Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue,
+ Err(err) => return Err(err),
+ };
+ file.write_all(data)?;
+ return Ok(StagedClipboardImage {
+ paste_text: path.to_string_lossy().into_owned(),
+ path,
+ });
+ }
+
+ Err(io::Error::new(
+ io::ErrorKind::AlreadyExists,
+ "failed to allocate unique clipboard image staging path",
+ ))
+}
+
+pub(crate) fn remove_files(paths: Vec<PathBuf>) {
+ for path in paths {
+ let _ = fs::remove_file(path);
+ }
+}
+
+fn sanitize_extension(extension: &str) -> &'static str {
+ if extension.eq_ignore_ascii_case("png") {
+ "png"
+ } else if extension.eq_ignore_ascii_case("jpg") || extension.eq_ignore_ascii_case("jpeg") {
+ "jpg"
+ } else if extension.eq_ignore_ascii_case("gif") {
+ "gif"
+ } else if extension.eq_ignore_ascii_case("webp") {
+ "webp"
+ } else if extension.eq_ignore_ascii_case("bmp") {
+ "bmp"
+ } else {
+ "png"
+ }
+}
+
+fn staging_dir() -> PathBuf {
+ let user_id = unsafe { libc::geteuid() };
+ std::env::temp_dir().join(format!("herdr-clipboard-images-{user_id}"))
+}
+
+fn ensure_staging_dir() -> io::Result<PathBuf> {
+ use std::os::unix::fs::PermissionsExt;
+
+ let dir = staging_dir();
+ fs::create_dir_all(&dir)?;
+ let metadata = fs::metadata(&dir)?;
+ if !metadata.is_dir() {
+ return Err(io::Error::other(format!(
+ "clipboard image staging path is not a directory: {}",
+ dir.display()
+ )));
+ }
+ fs::set_permissions(&dir, fs::Permissions::from_mode(0o700))?;
+ Ok(dir)
+}
+
+fn cleanup_stale(dir: &Path) {
+ let Ok(entries) = fs::read_dir(dir) else {
+ return;
+ };
+ for entry in entries.flatten() {
+ let path = entry.path();
+ let Ok(metadata) = entry.metadata() else {
+ continue;
+ };
+ let Ok(modified) = metadata.modified() else {
+ continue;
+ };
+ if modified.elapsed().unwrap_or_default() > STAGED_CLIPBOARD_IMAGE_MAX_AGE {
+ let _ = fs::remove_file(path);
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn sanitize_extension_accepts_known_image_extensions() {
+ assert_eq!(sanitize_extension("PNG"), "png");
+ assert_eq!(sanitize_extension("jpeg"), "jpg");
+ assert_eq!(sanitize_extension("webp"), "webp");
+ assert_eq!(sanitize_extension("sh"), "png");
+ }
+}