sculblog 0.1.9

project xanadu revivalism
Documentation
use crate::maps::{HashLookup, MapsCache};
use crate::shared::{load_config, serve_xvuid};
use crate::dtob_ffi;
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixListener;
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};

use std::{fs, process};

/// Handles a single command line and writes the response to `out`.
/// Returns Ok(()) on success, Err(msg) on failure.
fn handle_command(
    cmd_line: &str,
    cache: &MapsCache,
    repo_path: &Path,
    maps_path: &Path,
    out: &mut Vec<u8>,
) -> Result<(), String> {
    let parts: Vec<&str> = cmd_line.trim().split_whitespace().collect();
    if parts.is_empty() {
        return Err("empty command".into());
    }

    let command = parts[0];

    match command {
        "serve:xvuid" => {
            let xvuid = parts.get(1).ok_or("missing xvuid")?;
            let requested_offset: Option<usize> = parts.get(2).and_then(|s| s.parse().ok());

            let built = serve_xvuid(cache, repo_path, xvuid, requested_offset, false)
                .ok_or_else(|| format!("failed to serve xvuid {}", xvuid))?;
            let stripped = xanadoc::decode_content(&built.current_content)
                .ok_or_else(|| format!("failed to decode trailing content for xvuid {}", xvuid))?;
            out.extend_from_slice(stripped);
        }

        "serve:cid" | "serve:ccid" => {
            let hash = parts.get(1).ok_or("missing hash")?;
            let map_name = if command == "serve:cid" { "cid_to_xvuid" } else { "ccid_to_xvuid" };

            match cache.lookup_hash(map_name, hash) {
                HashLookup::Found { xvuid, index } => {
                    let built = serve_xvuid(cache, repo_path, &xvuid, Some(index), false)
                        .ok_or_else(|| format!("failed to serve hash {}", hash))?;
                    let stripped = xanadoc::decode_content(&built.current_content)
                        .ok_or_else(|| format!("failed to decode trailing content for hash {}", hash))?;
                    out.extend_from_slice(stripped);
                }
                HashLookup::Missing => {
                    return Err(format!("hash {} not found", hash));
                }
            }
        }

        "resolve:cid:xvuid" | "resolve:ccid:xvuid" => {
            let hash = parts.get(1).ok_or("missing hash")?;
            let map_name = if command == "resolve:cid:xvuid" {
                "cid_to_xvuid"
            } else {
                "ccid_to_xvuid"
            };
            match cache.lookup_hash(map_name, hash) {
                HashLookup::Found { xvuid, .. } => {
                    out.extend_from_slice(xvuid.as_bytes());
                    out.push(b'\n');
                }
                HashLookup::Missing => {
                    return Err(format!("hash {} not found", hash));
                }
            }
        }

        "resolve:ccid:cid" => {
            let hash = parts.get(1).ok_or("missing ccid")?;
            match cache.lookup_hash("ccid_to_xvuid", hash) {
                HashLookup::Found { xvuid, index } => {
                    let built = serve_xvuid(cache, repo_path, &xvuid, Some(index), false)
                        .ok_or("failed to parse target dif")?;
                    if let Some(ids) = xanadoc::decode_ids(&built.current_content) {
                        let hex_str = hex::encode(ids.cid);
                        out.extend_from_slice(hex_str.as_bytes());
                        out.push(b'\n');
                    }
                    unsafe { dtob_ffi::ffi_dtob_free(built.target_root); }
                }
                HashLookup::Missing => {
                    return Err(format!("ccid {} not found", hash));
                }
            }
        }

        "resolve:xvuid:cid" | "resolve:xvuid:ccid" => {
            let xvuid = parts.get(1).ok_or("missing xvuid")?;
            let field = if command == "resolve:xvuid:cid" { 1 } else { 2 };
            match cache.lookup_meta_hex(xvuid, field) {
                Some(val) => {
                    out.extend_from_slice(val.as_bytes());
                    out.push(b'\n');
                }
                None => {
                    return Err(format!("xvuid {} not found in meta", xvuid));
                }
            }
        }

        "resolve:ccid:title" => {
            let hash = parts.get(1).ok_or("missing ccid")?;
            match cache.lookup_hash("ccid_to_xvuid", hash) {
                HashLookup::Found { xvuid, .. } => match cache.lookup_title(&xvuid) {
                    Some(title) => {
                        out.extend_from_slice(title.as_bytes());
                        out.push(b'\n');
                    }
                    None => {
                        return Err(format!("title not found for xvuid {}", xvuid));
                    }
                },
                HashLookup::Missing => {
                    return Err(format!("ccid {} not found", hash));
                }
            }
        }

        "table" => {
            crate::table::generate_table_to_buf(maps_path, out);
        }

        "table:xvuid" | "table:cid" | "table:ccid" => {
            let hash = parts.get(1).ok_or("missing hash")?;
            let target_xvuid = if command == "table:xvuid" {
                hash.to_string()
            } else {
                let map_name = if command == "table:cid" {
                    "cid_to_xvuid"
                } else {
                    "ccid_to_xvuid"
                };
                match cache.lookup_hash(map_name, hash) {
                    HashLookup::Found { xvuid, .. } => xvuid,
                    HashLookup::Missing => {
                        return Err(format!("hash {} not found", hash));
                    }
                }
            };
            crate::table::generate_table_for_xvuid_to_buf(maps_path, cache, &target_xvuid, out);
        }

        "titles" => {
            if let Some(meta_root) = cache.meta_root() {
                unsafe {
                    let len = dtob_ffi::ffi_dtob_kvset_len(meta_root);
                    for i in 0..len {
                        let key_ptr = dtob_ffi::ffi_dtob_kvset_key(meta_root, i);
                        let val_ptr = dtob_ffi::ffi_dtob_kvset_value_at(meta_root, i);
                        if key_ptr.is_null() || val_ptr.is_null() {
                            continue;
                        }
                        let key_c = std::ffi::CStr::from_ptr(key_ptr);
                        let xvuid_str = key_c.to_string_lossy();
                        let v_title = dtob_ffi::ffi_dtob_array_get(val_ptr, 0);
                        let mut tlen = 0;
                        let tptr = dtob_ffi::ffi_dtob_get_raw(v_title, &mut tlen);
                        let title = if !tptr.is_null() && tlen > 0 {
                            String::from_utf8_lossy(std::slice::from_raw_parts(tptr, tlen))
                                .to_string()
                        } else {
                            "Untitled".to_string()
                        };
                        let line = format!("{}\t{}\n", xvuid_str, title);
                        out.extend_from_slice(line.as_bytes());
                    }
                }
            }
        }

        _ => {
            return Err(format!("unknown command: {}", command));
        }
    }

    Ok(())
}

pub fn run_daemon() {
    let repo_path = load_config("REPO_DIR");
    let maps_path = load_config("MAPS_DIR");
    let log_path = maps_path.join("sculblog-daemon.log");

    unsafe {
        let pid = libc::fork();
        if pid < 0 { std::process::exit(1); }
        if pid > 0 { std::process::exit(0); }

        libc::setsid();

        let pid2 = libc::fork();
        if pid2 < 0 { std::process::exit(1); }
        if pid2 > 0 { std::process::exit(0); }

        // Redirect stdin/stdout to /dev/null, stderr to log file
        let null_fd = libc::open(b"/dev/null\0".as_ptr() as *const _, libc::O_RDWR);
        if null_fd >= 0 {
            libc::dup2(null_fd, libc::STDIN_FILENO);
            libc::dup2(null_fd, libc::STDOUT_FILENO);
            if null_fd > libc::STDERR_FILENO {
                libc::close(null_fd);
            }
        }

        let log_c = std::ffi::CString::new(log_path.to_str().unwrap()).unwrap();
        let log_fd = libc::open(
            log_c.as_ptr(),
            libc::O_WRONLY | libc::O_CREAT | libc::O_APPEND,
            0o644,
        );
        if log_fd >= 0 {
            libc::dup2(log_fd, libc::STDERR_FILENO);
            if log_fd > libc::STDERR_FILENO {
                libc::close(log_fd);
            }
        }
    }

    let socket_path = maps_path.join("sculblog.sock");
    let pid_path = maps_path.join("sculblog-daemon.pid");

    // Clean up stale socket
    let _ = fs::remove_file(&socket_path);

    let listener = UnixListener::bind(&socket_path).unwrap_or_else(|e| {
        eprintln!("Failed to bind {}: {}", socket_path.display(), e);
        process::exit(1);
    });

    // Make socket world-writable so PHP (www-data / nginx) can connect
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let _ = fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o777));
    }

    // Write PID file
    let _ = fs::write(&pid_path, format!("{}", process::id()));

    // Load maps initially
    let mut cache = MapsCache::load(&maps_path);
    eprintln!(
        "sculblog daemon started on {} (PID {})",
        socket_path.display(),
        process::id()
    );

    // Set up signal handlers
    unsafe {
        libc::signal(libc::SIGHUP, {
            extern "C" fn handler(_: libc::c_int) {
                RELOAD_REQUESTED.store(true, Ordering::SeqCst);
            }
            handler as libc::sighandler_t
        });
        libc::signal(libc::SIGTERM, {
            extern "C" fn handler(_: libc::c_int) {
                SHUTDOWN_REQUESTED.store(true, Ordering::SeqCst);
            }
            handler as libc::sighandler_t
        });
    }

    for stream in listener.incoming() {
        if SHUTDOWN_REQUESTED.load(Ordering::SeqCst) {
            eprintln!("SIGTERM received, shutting down.");
            break;
        }

        // Check reload flag
        if RELOAD_REQUESTED.load(Ordering::SeqCst) {
            RELOAD_REQUESTED.store(false, Ordering::SeqCst);
            eprintln!("SIGHUP received, reloading maps...");
            drop(cache);
            cache = MapsCache::load(&maps_path);
            eprintln!("Maps reloaded.");
        }

        match stream {
            Ok(stream) => {
                let mut reader = BufReader::new(&stream);
                let mut line = String::new();
                if reader.read_line(&mut line).is_ok() && !line.is_empty() {
                    let mut response = Vec::new();
                    let mut writer = stream.try_clone().unwrap_or_else(|_| {
                        panic!("failed to clone stream");
                    });
                    match handle_command(&line, &cache, &repo_path, &maps_path, &mut response) {
                        Ok(()) => {
                            let len_bytes = (response.len() as u32).to_be_bytes();
                            let _ = writer.write_all(&len_bytes);
                            let _ = writer.write_all(&response);
                        }
                        Err(msg) => {
                            let err_bytes = format!("ERR {}", msg);
                            let len_bytes = (err_bytes.len() as u32).to_be_bytes();
                            let _ = writer.write_all(&len_bytes);
                            let _ = writer.write_all(err_bytes.as_bytes());
                            eprintln!("Command error: {}", msg);
                        }
                    }
                    let _ = writer.flush();
                }
            }
            Err(e) => {
                eprintln!("Connection error: {}", e);
            }
        }
    }

    // Cleanup
    let _ = fs::remove_file(&socket_path);
    let _ = fs::remove_file(&pid_path);
}

static RELOAD_REQUESTED: AtomicBool = AtomicBool::new(false);
static SHUTDOWN_REQUESTED: AtomicBool = AtomicBool::new(false);