feat: add klog
This commit is contained in:
19
cli/Cargo.toml
Normal file
19
cli/Cargo.toml
Normal 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
79
cli/src/client.rs
Normal 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
21
cli/src/config.rs
Normal 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
180
cli/src/login.rs
Normal 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
104
cli/src/main.rs
Normal 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
95
cli/src/sync.rs
Normal 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user