From 14edcf4bf6dd7b1480a27e782852f3589e3ecf14 Mon Sep 17 00:00:00 2001 From: Ryan Chen Date: Thu, 14 Aug 2025 21:03:34 -0400 Subject: [PATCH] Add wiki page editing functionality with authentication and git integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🎉 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- TASK_LOG.md | 40 ++++++ src/auth/mod.rs | 13 +- src/server/mod.rs | 360 +++++++++++++++++++++++++++++++++++++++++++++- src/wiki/mod.rs | 2 +- 4 files changed, 410 insertions(+), 5 deletions(-) diff --git a/TASK_LOG.md b/TASK_LOG.md index f127a90..3153bac 100644 --- a/TASK_LOG.md +++ b/TASK_LOG.md @@ -521,3 +521,43 @@ 4. JavaScript API calls use Authorization header via `authenticatedFetch()` - **Result:** Working JWT authentication with unstyled HTML forms, supporting both browser navigation and API calls +--- + +### 25. Implement Wiki Page Editing Functionality +**Time:** August 15, 2025 +**Request:** "Let's add an editing functionality. Make a new branch for this or a small batch of kittens will die!" +**Actions:** +- Created new branch `feature/wiki-editing` for development +- **1. Added Edit Button to UI (only visible when logged in):** + - Modified `render_wiki_page()` in `src/server/mod.rs` to include edit button in navigation + - Added JavaScript to show/hide edit button based on JWT authentication status + - Button redirects to `/edit/` when clicked +- **2. Created /edit Route Handler:** + - Added `/edit/*path` route to serve raw markdown content for editing + - Implemented `edit_page_handler()` with authentication and permission checking + - Returns raw markdown content from WikiService for editing +- **3. Built Multi-line Text Editor Page:** + - Created `render_edit_page()` function with full HTML template + - Features no-frills textarea editor preserving all whitespace + - Added Save and Cancel buttons with proper styling + - Auto-focus on editor for immediate typing +- **4. Implemented PUT API Endpoint:** + - Added PUT handler to `/api/wiki/*path` route for saving edited content + - Implemented `save_wiki_handler()` with authentication and permission validation + - Uses existing `WikiService.save_page()` method for file operations +- **5. Added Git Commit Functionality:** + - Created `git_commit_changes()` helper function + - Automatically stages and commits edited files if git repository exists + - Includes commit message with editor username: "Update {path}\n\nEdited by: {username}" + - Fails silently if no git repository present (as requested) +- **6. Helper Functions:** + - Added `get_user_from_headers()` to extract user from JWT tokens + - Made `WikiService.can_edit_page()` method public for permission checking +- **Technical Implementation:** + - Uses existing authentication system (JWT tokens) + - Respects user role permissions (Admin/Editor can edit, Viewer cannot) + - Handles both Bearer token headers and auth_token cookies + - Proper URL encoding/decoding for nested paths + - JavaScript fetch API with proper error handling +- **Result:** Complete editing workflow allowing authenticated users to edit any wiki page with automatic git versioning + diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 402f919..8e42bad 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -269,18 +269,25 @@ impl AuthService { &Validation::default(), )?; + tracing::debug!("JWT contains user ID: {}", token_data.claims.sub); + // Get user from database to ensure they're still active + let user_id_str = &token_data.claims.sub; + tracing::debug!("Querying with user ID string: {}", user_id_str); + let user_row = sqlx::query( "SELECT id, username, email, password_hash, role, provider, provider_id, created_at, last_login, is_active - FROM users WHERE id = ? AND is_active = true" + FROM users WHERE id = ? AND is_active = 1" ) - .bind(Uuid::parse_str(&token_data.claims.sub)?) + .bind(user_id_str) .fetch_optional(&self.db) .await?; + + tracing::debug!("Query returned {} rows", if user_row.is_some() { 1 } else { 0 }); if let Some(row) = user_row { let user = User { - id: row.get("id"), + id: Uuid::parse_str(&row.get::("id")).unwrap_or_else(|_| Uuid::new_v4()), username: row.get("username"), email: row.get("email"), password_hash: row.get("password_hash"), diff --git a/src/server/mod.rs b/src/server/mod.rs index 09ff556..5d7650c 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -71,7 +71,8 @@ async fn create_app(state: AppState) -> Router { let app = Router::new() .route("/", get(index_handler)) .route("/wiki/*path", get(wiki_page_handler)) - .route("/api/wiki/*path", get(api_wiki_handler)) + .route("/edit/*path", get(edit_page_handler)) + .route("/api/wiki/*path", get(api_wiki_handler).put(save_wiki_handler)) .route("/api/search", get(search_handler)) .route("/api/folder-files", get(folder_files_handler)) .route("/auth/login", get(login_form_handler).post(login_handler)) @@ -320,6 +321,117 @@ async fn github_callback_handler( } } +async fn edit_page_handler( + Path(path): Path, + headers: HeaderMap, + State(state): State, +) -> Response { + // Remove leading slash from captured path + let clean_path = path.strip_prefix('/').unwrap_or(&path); + let decoded_path = urlencoding::decode(clean_path).unwrap_or_else(|_| clean_path.into()); + + // Check if user is authenticated + tracing::info!("Edit page request for path: {}", decoded_path); + if !is_user_authenticated(&headers, &state).await { + tracing::warn!("User not authenticated for edit page: {}", decoded_path); + return Html(render_login_required_page()).into_response(); + } + tracing::info!("User authenticated for edit page: {}", decoded_path); + + // Get user from token for permission checking (simplified approach) + let user = if let Some(cookie_header) = headers.get("Cookie") { + if let Ok(cookie_str) = cookie_header.to_str() { + tracing::info!("Found cookies: {}", cookie_str); + let mut found_user = None; + for cookie in cookie_str.split(';') { + let cookie = cookie.trim(); + if let Some(token) = cookie.strip_prefix("auth_token=") { + tracing::debug!("Found auth_token cookie, verifying..."); + match state.auth.verify_token(token).await { + Ok(Some(user)) => { + tracing::info!("Cookie token verified successfully for user: {}", user.username); + found_user = Some(user); + break; + }, + Ok(None) => { + tracing::warn!("Cookie token is valid JWT but user not found in database"); + }, + Err(e) => { + tracing::error!("Cookie token verification failed: {}", e); + } + } + } + } + found_user + } else { + tracing::error!("Could not parse cookie header"); + None + } + } else { + tracing::error!("No Cookie header found in request"); + None + }; + + let user = match user { + Some(user) => { + tracing::info!("Successfully got user from cookie: {}", user.username); + user + }, + None => { + tracing::error!("Failed to get user from cookie despite authentication check passing"); + return Html(render_login_required_page()).into_response(); + }, + }; + + // Check if user can edit this page + match state.wiki.can_edit_page(&decoded_path, &user).await { + Ok(true) => {}, + Ok(false) => return (StatusCode::FORBIDDEN, "Permission denied").into_response(), + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Error checking permissions").into_response(), + } + + // Get raw markdown content + let raw_content = match state.wiki.get_page(&decoded_path).await { + Ok(Some(page)) => page.content, + Ok(None) => String::new(), // New page + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Error loading page").into_response(), + }; + + Html(render_edit_page(&decoded_path, &raw_content)).into_response() +} + +#[derive(Deserialize)] +struct SavePageRequest { + content: String, +} + +async fn save_wiki_handler( + Path(path): Path, + headers: HeaderMap, + State(state): State, + Json(req): Json, +) -> impl IntoResponse { + // Remove leading slash from captured path + let clean_path = path.strip_prefix('/').unwrap_or(&path); + let decoded_path = urlencoding::decode(clean_path).unwrap_or_else(|_| clean_path.into()); + + // Check if user is authenticated + let user = match get_user_from_headers(&headers, &state).await { + Some(user) => user, + None => return StatusCode::UNAUTHORIZED.into_response(), + }; + + // Save the page + match state.wiki.save_page(&decoded_path, &req.content, &user).await { + Ok(_) => { + // Try to commit to git if possible + let _ = git_commit_changes(&state, &decoded_path, &user).await; + StatusCode::OK.into_response() + } + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} + async fn render_folder_page(folder_path: &str, state: &AppState) -> String { let folder_name = if folder_path.is_empty() { "Wiki Root" @@ -536,6 +648,7 @@ async fn render_wiki_page(page: &WikiPage, state: &AppState) -> String {
Login +
@@ -566,6 +679,7 @@ async fn render_wiki_page(page: &WikiPage, state: &AppState) -> String { const token = localStorage.getItem('obswiki_token'); const cookieToken = getCookie('auth_token'); const authLink = document.getElementById('auth-link'); + const editButton = document.getElementById('edit-button'); if (token || cookieToken) {{ authLink.textContent = 'Logout'; @@ -577,6 +691,13 @@ async fn render_wiki_page(page: &WikiPage, state: &AppState) -> String { document.cookie = 'auth_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT'; window.location.reload(); }}; + + // Show edit button for logged in users + editButton.style.display = 'block'; + editButton.onclick = function() {{ + const currentPath = window.location.pathname.replace('/wiki/', '') || 'index'; + window.location.href = '/edit/' + currentPath; + }}; }} // Don't load filetree initially - load on first toggle }}); @@ -830,6 +951,96 @@ async fn is_user_authenticated(headers: &HeaderMap, state: &AppState) -> bool { false } +async fn get_user_from_headers(headers: &HeaderMap, state: &AppState) -> Option { + tracing::debug!("Getting user from headers"); + + // Check Authorization header first + if let Some(auth_header) = headers.get("Authorization") { + if let Ok(auth_str) = auth_header.to_str() { + tracing::debug!("Found Authorization header: {}", auth_str); + if let Some(token) = auth_str.strip_prefix("Bearer ") { + tracing::debug!("Extracted Bearer token, verifying..."); + match state.auth.verify_token(token).await { + Ok(Some(user)) => { + tracing::info!("Bearer token verified successfully for user: {}", user.username); + return Some(user); + }, + Ok(None) => { + tracing::warn!("Bearer token verification returned None"); + }, + Err(e) => { + tracing::error!("Bearer token verification failed: {}", e); + } + } + } + } + } else { + tracing::debug!("No Authorization header found"); + } + + // Also check cookies for token + if let Some(cookie_header) = headers.get("Cookie") { + if let Ok(cookie_str) = cookie_header.to_str() { + tracing::debug!("Found Cookie header: {}", cookie_str); + for cookie in cookie_str.split(';') { + let cookie = cookie.trim(); + if let Some(token) = cookie.strip_prefix("auth_token=") { + tracing::debug!("Found auth_token cookie, verifying..."); + match state.auth.verify_token(token).await { + Ok(Some(user)) => { + tracing::info!("Cookie token verified successfully for user: {}", user.username); + return Some(user); + }, + Ok(None) => { + tracing::warn!("Cookie token verification returned None"); + }, + Err(e) => { + tracing::error!("Cookie token verification failed: {}", e); + } + } + } + } + } + } else { + tracing::debug!("No Cookie header found"); + } + + tracing::error!("Failed to get user from any authentication method"); + None +} + +async fn git_commit_changes(state: &AppState, path: &str, user: &User) -> Result<()> { + use tokio::process::Command; + + let wiki_path = state.wiki.get_wiki_path(); + + // Check if this is a git repository + let git_dir = wiki_path.join(".git"); + if !git_dir.exists() { + // Fail silently as requested + return Ok(()); + } + + // Stage the changed file + let file_path = format!("{}.md", path); + let _output = Command::new("git") + .current_dir(wiki_path) + .args(&["add", &file_path]) + .output() + .await?; + + // Create commit message + let commit_message = format!("Update {}\n\nEdited by: {}", path, user.username); + + // Commit the changes + let _output = Command::new("git") + .current_dir(wiki_path) + .args(&["commit", "-m", &commit_message]) + .output() + .await?; + + Ok(()) +} #[derive(Serialize)] struct FolderFile { @@ -1077,3 +1288,150 @@ fn render_login_page() -> String { "# .to_string() } + +fn render_edit_page(path: &str, content: &str) -> String { + // Escape content for HTML textarea (only need to escape < > & and ") + let html_escaped_content = content + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """); + + format!( + r#" + + + + Edit: {} + + + +
+ +
+
+

Edit: {}

+ +
+ + +
+
+ + +"#, + path, path, path, html_escaped_content, path, path + ) +} diff --git a/src/wiki/mod.rs b/src/wiki/mod.rs index cde93d3..2c316c0 100644 --- a/src/wiki/mod.rs +++ b/src/wiki/mod.rs @@ -250,7 +250,7 @@ impl WikiService { Ok(()) } - async fn can_edit_page(&self, path: &str, user: &User) -> Result { + pub async fn can_edit_page(&self, path: &str, user: &User) -> Result { // Check both role-based permissions and path-specific access rules if !matches!(user.role, UserRole::Admin | UserRole::Editor) { return Ok(false); -- 2.49.1