sculblog 0.1.5

project xanadu revivalism
Documentation
use crate::maps::{HashLookup, MapsCache};
use crate::shared::load_config;
use crate::{dtob_ffi, SNAPSHOT_INTERVAL};
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 (target_offset, snapshot_offset) = if let Some(off) = requested_offset {
                let si = (off / SNAPSHOT_INTERVAL) * SNAPSHOT_INTERVAL;
                (Some(off), cache.lookup_snapshot_offset(xvuid, si))
            } else if let Some((latest_index, latest_snapshot_offset)) = cache.lookup_latest(xvuid)
            {
                (Some(latest_index), Some(latest_snapshot_offset))
            } else {
                (None, None)
            };

            let dif_path = repo_path.join(format!("{}.dif", xvuid));
            if let Some(built) = crate::serve::build_content_from_snapshot_offset(
                &dif_path,
                target_offset,
                snapshot_offset,
                false,
            ) {
                if let Some(stripped_content) = xanadoc::decode_content(&built.current_content) {
                    out.extend_from_slice(stripped_content);
                } else {
                    return Err(format!("failed to decode trailing content for xvuid {}", xvuid));
                }
            } else {
                return Err(format!("failed to serve xvuid {}", xvuid));
            }
        }

        "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 si = (index / SNAPSHOT_INTERVAL) * SNAPSHOT_INTERVAL;
                    let snapshot_offset = cache.lookup_snapshot_offset(&xvuid, si);
                    let dif_path = repo_path.join(format!("{}.dif", xvuid));

                    if let Some(built) = crate::serve::build_content_from_snapshot_offset(
                        &dif_path,
                        Some(index),
                        snapshot_offset,
                        false,
                    ) {
                        if let Some(stripped_content) = xanadoc::decode_content(&built.current_content) {
                            out.extend_from_slice(stripped_content);
                        } else {
                            return Err(format!("failed to decode trailing content for hash {}", hash));
                        }
                    } else {
                        return Err(format!("failed to serve hash {}", hash));
                    }
                }
                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 si = (index / SNAPSHOT_INTERVAL) * SNAPSHOT_INTERVAL;
                    let snapshot_offset = cache.lookup_snapshot_offset(&xvuid, si);
                    let dif_path = repo_path.join(format!("{}.dif", xvuid));

                    if let Some(built) = crate::serve::build_content_from_snapshot_offset(
                        &dif_path,
                        Some(index),
                        snapshot_offset,
                        false,
                    ) {
                        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); }
                    } else {
                        return Err("failed to parse target dif".into());
                    }
                }
                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" => {
            // Generate the full DTOB table into the output buffer
            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" => {
            // List all 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() {
    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); }
        
        let fd = libc::open(b"/dev/null\0".as_ptr() as *const _, libc::O_RDWR);
        if fd >= 0 {
            libc::dup2(fd, libc::STDIN_FILENO);
            libc::dup2(fd, libc::STDOUT_FILENO);
            libc::dup2(fd, libc::STDERR_FILENO);
            if fd > libc::STDERR_FILENO {
                libc::close(fd);
            }
        }
    }

    let repo_path = load_config("REPO_DIR");
    let maps_path = load_config("MAPS_DIR");

    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 SIGHUP reload flag
    unsafe {
        libc::signal(libc::SIGHUP, {
            extern "C" fn handler(_: libc::c_int) {
                RELOAD_REQUESTED.store(true, Ordering::SeqCst);
            }
            handler as libc::sighandler_t
        });
    }

    for stream in listener.incoming() {
        // 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();
                    match handle_command(&line, &cache, &repo_path, &maps_path, &mut response) {
                        Ok(()) => {
                            // Write a 4-byte big-endian length prefix, then the payload
                            let len_bytes = (response.len() as u32).to_be_bytes();
                            let mut writer = stream.try_clone().unwrap_or_else(|_| {
                                // fallback: just use the stream directly
                                panic!("failed to clone stream");
                            });
                            let _ = writer.write_all(&len_bytes);
                            let _ = writer.write_all(&response);
                            let _ = writer.flush();
                        }
                        Err(msg) => {
                            // Write error: length prefix 0, then error message on stderr
                            let err_bytes = format!("ERR {}\n", msg);
                            let len_bytes = 0u32.to_be_bytes();
                            let mut writer = stream.try_clone().unwrap_or_else(|_| {
                                panic!("failed to clone stream");
                            });
                            let _ = writer.write_all(&len_bytes);
                            let _ = writer.write_all(err_bytes.as_bytes());
                            let _ = writer.flush();
                            eprintln!("Command error: {}", msg);
                        }
                    }
                }
            }
            Err(e) => {
                eprintln!("Connection error: {}", e);
            }
        }
    }
}

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