2 Commits

Author SHA1 Message Date
Ryan Chen
14edcf4bf6 Add wiki page editing functionality with authentication and git integration
🎉 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-14 21:03:34 -04:00
ryan
9fbeb0afa5 Merge pull request 'markdown tables support' (#1) from markdown-tables into master
Reviewed-on: #1
2025-08-14 20:31:38 -04:00
4 changed files with 410 additions and 5 deletions

View File

@@ -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/<path>` 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

View File

@@ -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::<String, _>("id")).unwrap_or_else(|_| Uuid::new_v4()),
username: row.get("username"),
email: row.get("email"),
password_hash: row.get("password_hash"),

View File

@@ -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<String>,
headers: HeaderMap,
State(state): State<AppState>,
) -> Response<Body> {
// 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<String>,
headers: HeaderMap,
State(state): State<AppState>,
Json(req): Json<SavePageRequest>,
) -> 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 {
<input type="text" id="search" placeholder="Search wiki...">
<div id="auth-section">
<a href="/auth/login" id="auth-link">Login</a>
<button id="edit-button" style="display: none; margin-top: 5px; padding: 5px 10px; border: 1px solid #ccc; background: white; cursor: pointer; font-family: serif;">Edit</button>
</div>
</nav>
</header>
@@ -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<User> {
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 {
</html>"#
.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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;");
format!(
r#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Edit: {}</title>
<style>
body {{
margin: 0;
font-family: serif;
}}
nav {{
position: fixed;
top: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 10px;
padding: 15px;
border: 2px solid #ddd;
z-index: 1000;
}}
nav a {{
text-decoration: none;
color: #333;
padding: 5px 0;
font-family: serif;
}}
main {{
margin-right: 220px;
padding: 20px;
}}
#editor {{
width: 100%;
min-height: 400px;
padding: 10px;
border: 1px solid #ccc;
font-family: monospace;
font-size: 14px;
resize: vertical;
}}
.button-group {{
margin-top: 10px;
display: flex;
gap: 10px;
}}
.button-group button {{
padding: 8px 16px;
border: 1px solid #ccc;
background: white;
cursor: pointer;
font-family: serif;
}}
.button-group button:hover {{
background: #f0f0f0;
}}
.save-btn {{
background: #28a745 !important;
color: white !important;
}}
.cancel-btn {{
background: #dc3545 !important;
color: white !important;
}}
</style>
</head>
<body>
<header>
<nav>
<a href="/">Home</a>
<a href="/wiki/{}">← Back to Page</a>
</nav>
</header>
<main>
<h1>Edit: {}</h1>
<textarea id="editor" placeholder="Enter your markdown content here...">{}</textarea>
<div class="button-group">
<button class="save-btn" onclick="savePage()">Save</button>
<button class="cancel-btn" onclick="cancelEdit()">Cancel</button>
</div>
</main>
<script>
function savePage() {{
const content = document.getElementById('editor').value;
const path = '{}';
// Get token from localStorage or cookies
const token = localStorage.getItem('obswiki_token') || getCookie('auth_token');
if (!token) {{
alert('You must be logged in to save pages');
window.location.href = '/auth/login';
return;
}}
fetch('/api/wiki/' + path, {{
method: 'PUT',
headers: {{
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
}},
body: JSON.stringify({{ content: content }})
}})
.then(response => {{
if (response.ok) {{
window.location.href = '/wiki/' + path;
}} else if (response.status === 401) {{
alert('Your session has expired. Please log in again.');
window.location.href = '/auth/login';
}} else {{
alert('Failed to save page. Please try again.');
}}
}})
.catch(error => {{
alert('Error saving page: ' + error.message);
}});
}}
function cancelEdit() {{
const path = '{}';
window.location.href = '/wiki/' + path;
}}
function getCookie(name) {{
const value = '; ' + document.cookie;
const parts = value.split('; ' + name + '=');
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}}
// Auto-focus the editor
document.getElementById('editor').focus();
</script>
</body>
</html>"#,
path, path, path, html_escaped_content, path, path
)
}

View File

@@ -250,7 +250,7 @@ impl WikiService {
Ok(())
}
async fn can_edit_page(&self, path: &str, user: &User) -> Result<bool> {
pub async fn can_edit_page(&self, path: &str, user: &User) -> Result<bool> {
// Check both role-based permissions and path-specific access rules
if !matches!(user.role, UserRole::Admin | UserRole::Editor) {
return Ok(false);