* work * moar work * notes * work * separate to terminal and control channels * stdin working * serve html web client initial * serve static assets loaded with include_dir * merge * enable_web_server config parameter * compile time flag to disable web server capability * rustfmt * add license to all xterm.js assets * mouse working except copy/paste * helpful comment * web client improvements - move script to js file - add favicon - add nerd font - change title TODO: investigate if font license embedded in otf is sufficient * get mouse to work properly * kitty keyboard support initial * fix wrong type in preload link * wip axum websocket handlers - upgrade axum to v0.8.1, enable ws feature - begin setup of websocket handlers - tidy up imports * replace control listener * handle terminal websocket with axum * cleanup Cargo.toml * kitty fixes and bracketed paste * fix(mouse): pane not found crash * initial session switching infra * add `web_client_font` option * session switching, creation and resurrection working through the session manager * move session module to zellij-utils and share logic with web-client * some cleanups * require restart for enable-web-server * use session name from router * write config to disk and watch for config changes * rename session name to ipc path * add basic panic handler, make render_to_client exit on channel close * use while let instead of loop * handle websocket close * add mouse motions * make clipboard work * add weblink handling and webgl rendering * add todo * fix: use session name instead of patch on session switch * use "default" layout for new sessions * ui indication for session being shared * share this session ui * plugin assets * Fix process crash on mac with notify watcher. Use poll watcher instead of recommended as a workaround. * make url session switching and creation work * start welcome screen on root url * scaffold control messages, set font from config * set dimensions on session start * bring back session name from url * send bytes on terminal websocket instead of json - create web client os input and id before websocket connection * draft ui * work * refactor ui * remove otf font, remove margins to avoid scrollbar * version query endpoint for server status * web session info query endpoint * refactor: move stuff around * add web client info to session metadata * make tests pass * populate real data in session list * remove unnecessary endpoint * add web_client node to config, add font option * remove web_client_font * allow disabling the web session through the config - WIP * formalize sharing/not-sharing configuration * fix tests * allow shutting down web server * display error when web clients are forbidden to attach * only show sessions that allow web clients if this is a web client * style(fmt): rustfmt * fix: query web server from Zellij rather than from each plugin * remove log spam * handle some error paths better in the web client * allow controlling the web server through the cli * allow configuring the web server's ip/port * fix tests and format code * use direct WebServerStatus event instead of piggy-backing on SessionInfo * plugin revamp initial * make plugin responsive * adjust plugin title * refactor: share plugin * refactor: share plugin * add cors middleware * some fixes for running without a compiled web server capability * display error when starting the share plugin without web server support * clarify config * add pipelines to compile zellij without web support * display error when unable to start web server * only query web server when share plugin is running * refactor(web-client): connection table * give zellij_server_listener access to the control channel * fixes and clarifications * refactor: consolidate generate_unique_session_name * give proper error when trying to attach to a forbidden session * change browser URL when switching sessions * add keyboard shortcut * enforce https when bound to non-loopback ip * initial authentication token implementation * background color from theme * initial web client theme config * basic token generation ui * refactor set config message creation * also set body background * allow editing scrollback for plugins too * set scrollback to 0 * properly parse colors in config * generate token from plugin * nice login modals * initial token management screen * implement token authentication * refactor(share): token management screen * style(fmt): rustfmt * fix(plugin): some minor bugs * refactor(share): main screen * refactor(share): token screen * refactor(share): main * refactor(share): ui components * fix(responsiveness): properly send usage_width to the render function * fix cli commands and add some verbosity * add support for settings ansi and selection colors * add cursor and cursor accent * basic web client tests * fix tests * refactor: web client * use session tokens for authentication * improve modals * move shutdown to ipc * refactor: ipc logic * serialize theme config for web client * update tests * refactor: move some stuff around to prepare for config hot reload * config live reloading for the web clients * change remember-me UI wording * improve xterm.js link handling * make sure terminal is focused on mousemove * remove deprecated sharing indication from compact-bar * gate deps and functionality behind the web_server_compatibility feature * feat(build): add --no-web flag in all the places * fix some other build flows * add new assets * update CI for no-web (untested) * make more dependencies optional * update axum-extra * add web client configuration options * gracefully close connections on server exit * tests for graceful connection closing * handle client-side reconnect when server is down * fix: make sure ipc bus folder exists before starting * add commands to manage login tokens from the cli * style(fmt): rustfmt * some cleanups * fix(ux): allow alt-right-click on the web client without opening the context menu * fix: prevent attaching to welcome screen * fix: reload config issues * fix long socket path on macos * normalize config conversion and fix color gap in browser * revoke session_token cookie if it is not valid * fix: visual bug with multiple clients in extremely small screen sizes * fix: only include rusqlite for the web server capability builds * update e2e snapshots * refactor(web): client side js * some cleanups * moar cleanups * fix(tests): wait for server instead of using a fixed timeout * debug CI * fix(tests): use spawn_blocking for running the test web server * fix(tests): wait for http rather than tcp port * fix(tests): properly pass config path - hopefully this is the issue... * success! bring back the rest of the tests * attempt to fix the macos CI issue * docs(changelog): add PR --------- Co-authored-by: Thomas Linford <linford.t@gmail.com>
349 lines
10 KiB
Rust
349 lines
10 KiB
Rust
// TODO: GATE THIS WHOLE FILE AND RELEVANT DEPS BEHIND web_server_capability
|
|
use crate::consts::ZELLIJ_PROJ_DIR;
|
|
use rusqlite::Connection;
|
|
use sha2::{Digest, Sha256};
|
|
use std::path::PathBuf;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
use uuid::Uuid;
|
|
|
|
#[derive(Debug)]
|
|
pub struct TokenInfo {
|
|
pub name: String,
|
|
pub created_at: String,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum TokenError {
|
|
Database(rusqlite::Error),
|
|
Io(std::io::Error),
|
|
InvalidPath,
|
|
DuplicateName(String),
|
|
TokenNotFound(String),
|
|
InvalidToken,
|
|
}
|
|
|
|
impl std::fmt::Display for TokenError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
TokenError::Database(e) => write!(f, "Database error: {}", e),
|
|
TokenError::Io(e) => write!(f, "IO error: {}", e),
|
|
TokenError::InvalidPath => write!(f, "Invalid path"),
|
|
TokenError::DuplicateName(name) => write!(f, "Token name '{}' already exists", name),
|
|
TokenError::TokenNotFound(name) => write!(f, "Token '{}' not found", name),
|
|
TokenError::InvalidToken => write!(f, "Invalid token"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for TokenError {}
|
|
|
|
impl From<rusqlite::Error> for TokenError {
|
|
fn from(error: rusqlite::Error) -> Self {
|
|
match error {
|
|
rusqlite::Error::SqliteFailure(ffi_error, _)
|
|
if ffi_error.code == rusqlite::ErrorCode::ConstraintViolation =>
|
|
{
|
|
TokenError::DuplicateName("unknown".to_string())
|
|
},
|
|
_ => TokenError::Database(error),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<std::io::Error> for TokenError {
|
|
fn from(error: std::io::Error) -> Self {
|
|
TokenError::Io(error)
|
|
}
|
|
}
|
|
|
|
type Result<T> = std::result::Result<T, TokenError>;
|
|
|
|
fn get_db_path() -> Result<PathBuf> {
|
|
if cfg!(debug_assertions) {
|
|
// tests db
|
|
let data_dir = ZELLIJ_PROJ_DIR.data_dir();
|
|
std::fs::create_dir_all(&data_dir)?;
|
|
Ok(data_dir.join("tokens_for_dev.db"))
|
|
} else {
|
|
// prod db
|
|
let data_dir = ZELLIJ_PROJ_DIR.data_dir();
|
|
std::fs::create_dir_all(data_dir)?;
|
|
Ok(data_dir.join("tokens.db"))
|
|
}
|
|
}
|
|
|
|
fn init_db(conn: &Connection) -> Result<()> {
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS tokens (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
token_hash TEXT UNIQUE NOT NULL,
|
|
name TEXT UNIQUE NOT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
)",
|
|
[],
|
|
)?;
|
|
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS session_tokens (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_token_hash TEXT UNIQUE NOT NULL,
|
|
auth_token_hash TEXT NOT NULL,
|
|
remember_me BOOLEAN NOT NULL DEFAULT 0,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
expires_at DATETIME NOT NULL,
|
|
FOREIGN KEY (auth_token_hash) REFERENCES tokens(token_hash)
|
|
)",
|
|
[],
|
|
)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn hash_token(token: &str) -> String {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(token.as_bytes());
|
|
format!("{:x}", hasher.finalize())
|
|
}
|
|
|
|
pub fn create_token(name: Option<String>) -> Result<(String, String)> {
|
|
let db_path = get_db_path()?;
|
|
let conn = Connection::open(db_path)?;
|
|
init_db(&conn)?;
|
|
|
|
let token = Uuid::new_v4().to_string();
|
|
let token_hash = hash_token(&token);
|
|
|
|
let token_name = if let Some(n) = name {
|
|
n.to_string()
|
|
} else {
|
|
let count: i64 = conn.query_row("SELECT COUNT(*) FROM tokens", [], |row| row.get(0))?;
|
|
format!("token_{}", count + 1)
|
|
};
|
|
|
|
match conn.execute(
|
|
"INSERT INTO tokens (token_hash, name) VALUES (?1, ?2)",
|
|
[&token_hash, &token_name],
|
|
) {
|
|
Err(rusqlite::Error::SqliteFailure(ffi_error, _))
|
|
if ffi_error.code == rusqlite::ErrorCode::ConstraintViolation =>
|
|
{
|
|
Err(TokenError::DuplicateName(token_name))
|
|
},
|
|
Err(e) => Err(TokenError::Database(e)),
|
|
Ok(_) => Ok((token, token_name)),
|
|
}
|
|
}
|
|
|
|
pub fn create_session_token(auth_token: &str, remember_me: bool) -> Result<String> {
|
|
let db_path = get_db_path()?;
|
|
let conn = Connection::open(db_path)?;
|
|
init_db(&conn)?;
|
|
|
|
cleanup_expired_sessions()?;
|
|
|
|
let auth_token_hash = hash_token(auth_token);
|
|
|
|
let count: i64 = conn.query_row(
|
|
"SELECT COUNT(*) FROM tokens WHERE token_hash = ?1",
|
|
[&auth_token_hash],
|
|
|row| row.get(0),
|
|
)?;
|
|
|
|
if count == 0 {
|
|
return Err(TokenError::InvalidToken);
|
|
}
|
|
|
|
let session_token = Uuid::new_v4().to_string();
|
|
let session_token_hash = hash_token(&session_token);
|
|
|
|
let expires_at = if remember_me {
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_secs();
|
|
let four_weeks = 4 * 7 * 24 * 60 * 60;
|
|
format!("datetime({}, 'unixepoch')", now + four_weeks)
|
|
} else {
|
|
// For session-only: very short expiration (e.g., 5 minutes)
|
|
// The browser will handle the session aspect via cookie expiration
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_secs();
|
|
let short_duration = 5 * 60; // 5 minutes
|
|
format!("datetime({}, 'unixepoch')", now + short_duration)
|
|
};
|
|
|
|
conn.execute(
|
|
&format!("INSERT INTO session_tokens (session_token_hash, auth_token_hash, remember_me, expires_at) VALUES (?1, ?2, ?3, {})", expires_at),
|
|
[&session_token_hash, &auth_token_hash, &(remember_me as i64).to_string()],
|
|
)?;
|
|
|
|
Ok(session_token)
|
|
}
|
|
|
|
pub fn validate_session_token(session_token: &str) -> Result<bool> {
|
|
let db_path = get_db_path()?;
|
|
let conn = Connection::open(db_path)?;
|
|
init_db(&conn)?;
|
|
|
|
let session_token_hash = hash_token(session_token);
|
|
|
|
let count: i64 = conn.query_row(
|
|
"SELECT COUNT(*) FROM session_tokens WHERE session_token_hash = ?1 AND expires_at > datetime('now')",
|
|
[&session_token_hash],
|
|
|row| row.get(0),
|
|
)?;
|
|
|
|
Ok(count > 0)
|
|
}
|
|
|
|
pub fn cleanup_expired_sessions() -> Result<usize> {
|
|
let db_path = get_db_path()?;
|
|
let conn = Connection::open(db_path)?;
|
|
init_db(&conn)?;
|
|
|
|
let rows_affected = conn.execute(
|
|
"DELETE FROM session_tokens WHERE expires_at <= datetime('now')",
|
|
[],
|
|
)?;
|
|
|
|
Ok(rows_affected)
|
|
}
|
|
|
|
pub fn revoke_session_token(session_token: &str) -> Result<bool> {
|
|
let db_path = get_db_path()?;
|
|
let conn = Connection::open(db_path)?;
|
|
init_db(&conn)?;
|
|
|
|
let session_token_hash = hash_token(session_token);
|
|
let rows_affected = conn.execute(
|
|
"DELETE FROM session_tokens WHERE session_token_hash = ?1",
|
|
[&session_token_hash],
|
|
)?;
|
|
|
|
Ok(rows_affected > 0)
|
|
}
|
|
|
|
pub fn revoke_sessions_for_auth_token(auth_token: &str) -> Result<usize> {
|
|
let db_path = get_db_path()?;
|
|
let conn = Connection::open(db_path)?;
|
|
init_db(&conn)?;
|
|
|
|
let auth_token_hash = hash_token(auth_token);
|
|
let rows_affected = conn.execute(
|
|
"DELETE FROM session_tokens WHERE auth_token_hash = ?1",
|
|
[&auth_token_hash],
|
|
)?;
|
|
|
|
Ok(rows_affected)
|
|
}
|
|
|
|
pub fn revoke_token(name: &str) -> Result<bool> {
|
|
let db_path = get_db_path()?;
|
|
let conn = Connection::open(db_path)?;
|
|
init_db(&conn)?;
|
|
|
|
let token_hash = match conn.query_row(
|
|
"SELECT token_hash FROM tokens WHERE name = ?1",
|
|
[&name],
|
|
|row| row.get::<_, String>(0),
|
|
) {
|
|
Ok(hash) => Some(hash),
|
|
Err(rusqlite::Error::QueryReturnedNoRows) => None,
|
|
Err(e) => return Err(TokenError::Database(e)),
|
|
};
|
|
|
|
if let Some(token_hash) = token_hash {
|
|
conn.execute(
|
|
"DELETE FROM session_tokens WHERE auth_token_hash = ?1",
|
|
[&token_hash],
|
|
)?;
|
|
}
|
|
|
|
let rows_affected = conn.execute("DELETE FROM tokens WHERE name = ?1", [&name])?;
|
|
Ok(rows_affected > 0)
|
|
}
|
|
|
|
pub fn revoke_all_tokens() -> Result<usize> {
|
|
let db_path = get_db_path()?;
|
|
let conn = Connection::open(db_path)?;
|
|
init_db(&conn)?;
|
|
|
|
conn.execute("DELETE FROM session_tokens", [])?;
|
|
let rows_affected = conn.execute("DELETE FROM tokens", [])?;
|
|
Ok(rows_affected)
|
|
}
|
|
|
|
pub fn rename_token(old_name: &str, new_name: &str) -> Result<()> {
|
|
let db_path = get_db_path()?;
|
|
let conn = Connection::open(db_path)?;
|
|
init_db(&conn)?;
|
|
|
|
let count: i64 = conn.query_row(
|
|
"SELECT COUNT(*) FROM tokens WHERE name = ?1",
|
|
[&old_name],
|
|
|row| row.get(0),
|
|
)?;
|
|
|
|
if count == 0 {
|
|
return Err(TokenError::TokenNotFound(old_name.to_string()));
|
|
}
|
|
|
|
match conn.execute(
|
|
"UPDATE tokens SET name = ?1 WHERE name = ?2",
|
|
[&new_name, &old_name],
|
|
) {
|
|
Err(rusqlite::Error::SqliteFailure(ffi_error, _))
|
|
if ffi_error.code == rusqlite::ErrorCode::ConstraintViolation =>
|
|
{
|
|
Err(TokenError::DuplicateName(new_name.to_string()))
|
|
},
|
|
Err(e) => Err(TokenError::Database(e)),
|
|
Ok(_) => Ok(()),
|
|
}
|
|
}
|
|
|
|
pub fn list_tokens() -> Result<Vec<TokenInfo>> {
|
|
let db_path = get_db_path()?;
|
|
let conn = Connection::open(db_path)?;
|
|
init_db(&conn)?;
|
|
|
|
let mut stmt = conn.prepare("SELECT name, created_at FROM tokens ORDER BY created_at")?;
|
|
let rows = stmt.query_map([], |row| {
|
|
Ok(TokenInfo {
|
|
name: row.get::<_, String>(0)?,
|
|
created_at: row.get::<_, String>(1)?,
|
|
})
|
|
})?;
|
|
|
|
let mut tokens = Vec::new();
|
|
for token in rows {
|
|
tokens.push(token?);
|
|
}
|
|
Ok(tokens)
|
|
}
|
|
|
|
pub fn delete_db() -> Result<()> {
|
|
let db_path = get_db_path()?;
|
|
if db_path.exists() {
|
|
std::fs::remove_file(db_path)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn validate_token(token: &str) -> Result<bool> {
|
|
let db_path = get_db_path()?;
|
|
let conn = Connection::open(db_path)?;
|
|
init_db(&conn)?;
|
|
|
|
let token_hash = hash_token(token);
|
|
|
|
let count: i64 = conn.query_row(
|
|
"SELECT COUNT(*) FROM tokens WHERE token_hash = ?1",
|
|
[&token_hash],
|
|
|row| row.get(0),
|
|
)?;
|
|
Ok(count > 0)
|
|
}
|