feat: add klog

This commit is contained in:
2026-04-29 17:57:41 +09:00
commit f9f009fcd2
18 changed files with 3923 additions and 0 deletions

19
cli/Cargo.toml Normal file
View File

@@ -0,0 +1,19 @@
[package]
name = "klog"
version = "0.1.0"
edition = "2024"
[dependencies]
klog-types = { path = "../types" }
clap = { version = "4.6.1", features = ["derive", "env"] }
tokio = { version = "1.52.1", features = ["full"] }
reqwest = { version = "0.13.3", features = ["form", "json", "multipart"] }
serde_json = "1.0.149"
sha2 = "0.11.0"
hex = "0.4.3"
zip = "8.6.0"
walkdir = "2.5.0"
anyhow = "1.0.102"
rand = "0.10.1"
base64 = "0.22.1"
serde = { version = "1.0.228", features = ["derive"] }

79
cli/src/client.rs Normal file
View File

@@ -0,0 +1,79 @@
use anyhow::{Context, Result};
use klog_types::{BatchFilesRequest, FilesMetaResponse};
use crate::config::Config;
pub struct KlogClient {
base_url: String,
token: String,
client: reqwest::Client,
}
impl KlogClient {
pub fn new(config: &Config) -> Self {
Self {
base_url: config.url.trim_end_matches('/').to_string(),
token: config.token.clone(),
client: reqwest::Client::new(),
}
}
pub async fn list_files(&self) -> Result<FilesMetaResponse> {
Ok(self
.client
.get(format!("{}/admin/files", self.base_url))
.bearer_auth(&self.token)
.send()
.await?
.error_for_status()?
.json::<FilesMetaResponse>()
.await?)
}
pub async fn get_files(&self, req: &BatchFilesRequest) -> Result<Vec<u8>> {
Ok(self
.client
.post(format!("{}/admin/files/get", self.base_url))
.bearer_auth(&self.token)
.json(req)
.send()
.await?
.error_for_status()?
.bytes()
.await?
.to_vec())
}
pub async fn upload_file(&self, path: &std::path::Path) -> Result<serde_json::Value> {
let filename = path
.file_name()
.context("Invalid path: no filename")?
.to_string_lossy()
.to_string();
let data = tokio::fs::read(path).await?;
let part = reqwest::multipart::Part::bytes(data).file_name(filename);
let form = reqwest::multipart::Form::new().part("file", part);
Ok(self
.client
.post(format!("{}/files", self.base_url))
.bearer_auth(&self.token)
.multipart(form)
.send()
.await?
.error_for_status()?
.json::<serde_json::Value>()
.await?)
}
pub async fn delete_files(&self, req: &BatchFilesRequest) -> Result<serde_json::Value> {
Ok(self
.client
.delete(format!("{}/admin/files", self.base_url))
.bearer_auth(&self.token)
.json(req)
.send()
.await?
.error_for_status()?
.json::<serde_json::Value>()
.await?)
}
}

21
cli/src/config.rs Normal file
View File

@@ -0,0 +1,21 @@
use anyhow::Result;
use crate::login;
pub struct Config {
pub url: String,
pub token: String,
}
impl Config {
pub fn from_env() -> Result<Self> {
let url = match std::env::var("KLOG_URL") {
Ok(u) => u,
Err(_) => login::load_url()?,
};
let token = match std::env::var("KLOG_TOKEN") {
Ok(t) => t,
Err(_) => login::load_token()?,
};
Ok(Self { url, token })
}
}

180
cli/src/login.rs Normal file
View File

@@ -0,0 +1,180 @@
use anyhow::{Context, Result};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use rand::Rng;
use sha2::{Digest, Sha256};
use std::path::PathBuf;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
};
pub const DEFAULT_GITEA_URL: &str = "https://git.walruslab.org";
const CLIENT_ID: &str = "b1b43f28-66b0-4577-b622-897f57decc26";
const REDIRECT_URI: &str = "http://localhost:20130/callback";
pub async fn login(gitea_url: &str) -> Result<()> {
let verifier = code_verifier();
let challenge = code_challenge(&verifier);
let state = random_state();
let mut auth_url = reqwest::Url::parse(&format!("{gitea_url}/login/oauth/authorize"))
.context("Invalid Gitea URL")?;
auth_url
.query_pairs_mut()
.append_pair("client_id", CLIENT_ID)
.append_pair("redirect_uri", REDIRECT_URI)
.append_pair("response_type", "code")
.append_pair("state", &state)
.append_pair("code_challenge", &challenge)
.append_pair("code_challenge_method", "S256");
println!("Open this URL in your browser to authenticate:\n");
println!(" {auth_url}\n");
println!("Waiting for callback on {REDIRECT_URI}...");
let code = wait_for_callback(&state).await?;
let token = exchange_code(gitea_url, &code, &verifier).await?;
save_token(&token)?;
println!("Login successful. Token saved to {}", config_path()?.display());
Ok(())
}
async fn wait_for_callback(expected_state: &str) -> Result<String> {
let listener = TcpListener::bind("127.0.0.1:20130")
.await
.context("Cannot bind to port 20130 — is another process using it?")?;
let (mut stream, _) = listener.accept().await?;
let mut buf = vec![0u8; 4096];
let n = stream.read(&mut buf).await?;
let request = String::from_utf8_lossy(&buf[..n]);
// First line: GET /callback?code=...&state=... HTTP/1.1
let first_line = request.lines().next().unwrap_or_default();
let path = first_line.split_whitespace().nth(1).unwrap_or_default();
let query = path.split_once('?').map(|(_, q)| q).unwrap_or_default();
let mut code = None;
let mut got_state = None;
for pair in query.split('&') {
if let Some((k, v)) = pair.split_once('=') {
match k {
"code" => code = Some(v.to_string()),
"state" => got_state = Some(v.to_string()),
_ => {}
}
}
}
let _ = stream
.write_all(
b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n\
<h2>Login successful!</h2><p>You can close this tab.</p>",
)
.await;
let code = code.context("No code in OAuth callback")?;
let got_state = got_state.context("No state in OAuth callback")?;
if got_state != expected_state {
anyhow::bail!("State mismatch — possible CSRF attack");
}
Ok(code)
}
async fn exchange_code(gitea_url: &str, code: &str, verifier: &str) -> Result<String> {
let resp: serde_json::Value = reqwest::Client::new()
.post(format!("{gitea_url}/login/oauth/access_token"))
.header("Accept", "application/json")
.form(&[
("client_id", CLIENT_ID),
("code", code),
("code_verifier", verifier),
("grant_type", "authorization_code"),
("redirect_uri", REDIRECT_URI),
])
.send()
.await?
.error_for_status()?
.json()
.await?;
resp["access_token"]
.as_str()
.with_context(|| format!("No access_token in response: {resp}"))
.map(str::to_string)
}
pub fn config_path() -> Result<PathBuf> {
let home = std::env::var("HOME").context("HOME not set")?;
Ok(PathBuf::from(home).join(".config").join("klog.json"))
}
fn read_config_json() -> serde_json::Value {
config_path()
.ok()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn write_config_json(json: &serde_json::Value) -> Result<()> {
let path = config_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, serde_json::to_string_pretty(json)?)?;
Ok(())
}
pub fn save_token(token: &str) -> Result<()> {
let mut json = read_config_json();
json["token"] = serde_json::Value::String(token.to_string());
write_config_json(&json)
}
pub fn save_url(url: &str) -> Result<()> {
let mut json = read_config_json();
json["url"] = serde_json::Value::String(url.to_string());
write_config_json(&json)
}
pub fn load_token() -> Result<String> {
let json = read_config_json();
json["token"]
.as_str()
.with_context(|| format!(
"token missing from {}. Run `klog login` first.",
config_path().map(|p| p.display().to_string()).unwrap_or_default()
))
.map(str::to_string)
}
pub fn load_url() -> Result<String> {
let json = read_config_json();
json["url"]
.as_str()
.with_context(|| format!(
"url missing from {}. Run `klog set-url <url>` first.",
config_path().map(|p| p.display().to_string()).unwrap_or_default()
))
.map(str::to_string)
}
fn code_verifier() -> String {
let mut bytes = [0u8; 32];
rand::rng().fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
fn code_challenge(verifier: &str) -> String {
URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))
}
fn random_state() -> String {
let mut bytes = [0u8; 16];
rand::rng().fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}

104
cli/src/main.rs Normal file
View File

@@ -0,0 +1,104 @@
mod client;
mod config;
mod login;
mod sync;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use std::path::PathBuf;
use klog_types::{BatchFilesRequest, UserFiles};
#[derive(Parser)]
#[command(name = "klog", version, about = "klog file sync CLI")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Authenticate via Gitea OAuth2 and save token to ~/.config/klog.json
Login {
/// Gitea instance URL
#[arg(long, default_value = login::DEFAULT_GITEA_URL)]
gitea_url: String,
},
/// Save the klog server URL to ~/.config/klog.json
SetUrl {
url: String,
},
/// Upload a file to klog
Upload {
/// Path to the file to upload
file: PathBuf,
},
/// Sync remote files to local log dir
Sync {
#[arg(short = 'l', long, default_value = "./klogs")]
log_dir: PathBuf,
},
/// List remote files
Ls {
/// Filter by username (default: all users)
username: Option<String>,
},
/// Remove a remote file (format: username/filename)
Rm {
target: String,
},
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Login { gitea_url } => {
login::login(&gitea_url).await?;
}
Commands::SetUrl { url } => {
login::save_url(&url)?;
println!("URL saved to {}", login::config_path()?.display());
}
Commands::Upload { file } => {
let client = client::KlogClient::new(&config::Config::from_env()?);
let result = client.upload_file(&file).await?;
println!("Uploaded: {:?}", result["uploaded"]);
}
Commands::Sync { log_dir } => {
let client = client::KlogClient::new(&config::Config::from_env()?);
sync::sync(&client, &log_dir).await?;
}
Commands::Ls { username } => {
let client = client::KlogClient::new(&config::Config::from_env()?);
let meta = client.list_files().await?;
let rows: Vec<_> = meta
.files
.iter()
.filter(|f| username.as_ref().map_or(true, |u| &f.username == u))
.collect();
println!("{:<20} {:<40} SHA256", "USERNAME", "FILENAME");
for f in rows {
println!("{:<20} {:<40} {}", f.username, f.filename, &f.sha256[..8]);
}
}
Commands::Rm { target } => {
let client = client::KlogClient::new(&config::Config::from_env()?);
let (username, filename) = target
.split_once('/')
.context("Expected format: username/filename")?;
let req = BatchFilesRequest {
users: vec![UserFiles {
username: username.to_string(),
files: vec![filename.to_string()],
}],
};
let result = client.delete_files(&req).await?;
println!("Deleted: {}", result["deleted"]);
}
}
Ok(())
}

95
cli/src/sync.rs Normal file
View File

@@ -0,0 +1,95 @@
use anyhow::Result;
use sha2::{Digest, Sha256};
use std::{collections::HashMap, io, path::Path};
use klog_types::{BatchFilesRequest, UserFiles};
use crate::client::KlogClient;
pub async fn sync(client: &KlogClient, log_dir: &Path) -> Result<()> {
let meta = client.list_files().await?;
let local = scan_local(log_dir)?;
let mut to_fetch: HashMap<String, Vec<String>> = HashMap::new();
for file in &meta.files {
let local_hash = local.get(&(file.username.clone(), file.filename.clone()));
if local_hash.map(|h| h.as_str()) != Some(file.sha256.as_str()) {
to_fetch
.entry(file.username.clone())
.or_default()
.push(file.filename.clone());
}
}
if to_fetch.is_empty() {
println!("Already up to date.");
return Ok(());
}
let total: usize = to_fetch.values().map(|v| v.len()).sum();
println!("Fetching {total} file(s)...");
let req = BatchFilesRequest {
users: to_fetch
.into_iter()
.map(|(username, files)| UserFiles { username, files })
.collect(),
};
let zip_bytes = client.get_files(&req).await?;
extract_zip(&zip_bytes, log_dir)?;
println!("Sync complete.");
Ok(())
}
fn scan_local(log_dir: &Path) -> Result<HashMap<(String, String), String>> {
let mut map = HashMap::new();
if !log_dir.exists() {
return Ok(map);
}
for user_entry in std::fs::read_dir(log_dir)? {
let user_entry = user_entry?;
if !user_entry.file_type()?.is_dir() {
continue;
}
let username = user_entry.file_name().to_string_lossy().to_string();
let user_dir = log_dir.join(&username);
for file_entry in std::fs::read_dir(&user_dir)? {
let file_entry = file_entry?;
if !file_entry.file_type()?.is_file() {
continue;
}
let filename = file_entry.file_name().to_string_lossy().to_string();
let data = std::fs::read(file_entry.path())?;
let mut hasher = Sha256::new();
hasher.update(&data);
map.insert((username.clone(), filename), hex::encode(hasher.finalize()));
}
}
Ok(map)
}
fn extract_zip(zip_bytes: &[u8], log_dir: &Path) -> io::Result<()> {
let cursor = std::io::Cursor::new(zip_bytes);
let mut archive = zip::ZipArchive::new(cursor)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
for i in 0..archive.len() {
let mut file = archive
.by_index(i)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let out_path = log_dir.join(file.name());
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut out = std::fs::File::create(&out_path)?;
std::io::copy(&mut file, &mut out)?;
}
Ok(())
}