use anyhow::Result; use axum::{ body::Body, extract::{Path, Query, State}, http::{header, HeaderMap, HeaderValue, StatusCode}, response::{Html, IntoResponse, Response}, routing::{get, post}, Json, Router, }; use serde::{Deserialize, Serialize}; use std::path::Path as StdPath; use std::sync::Arc; use tokio::net::TcpListener; use tower::ServiceBuilder; use tower_http::{cors::CorsLayer, services::ServeDir, trace::TraceLayer}; use uuid::Uuid; use crate::{ auth::AuthService, config::Config, models::{User, WikiPage}, wiki::WikiService, }; #[derive(Clone)] pub struct AppState { pub config: Config, pub wiki: Arc, pub auth: Arc, } pub struct Server { app: Router, listener: TcpListener, } impl Server { pub async fn new(config: Config, wiki_path: String, port: u16) -> Result { let auth_service = Arc::new(AuthService::new(&config).await?); // Ensure default admin user exists on startup auth_service.ensure_default_admin().await?; let wiki_service = Arc::new(WikiService::new(wiki_path, auth_service.clone()).await?); let state = AppState { config: config.clone(), wiki: wiki_service, auth: auth_service, }; let app = create_app(state).await; let addr = format!("{}:{}", config.server.host, port); let listener = TcpListener::bind(&addr).await?; tracing::info!("Server listening on {}", addr); Ok(Self { app, listener }) } pub async fn run(self) -> Result<()> { axum::serve(self.listener, self.app).await?; Ok(()) } } async fn create_app(state: AppState) -> Router { // Check static dir before moving state let static_dir = state.config.server.static_dir.clone(); let app = Router::new() .route("/", get(index_handler)) .route("/wiki/*path", get(wiki_page_handler)) .route("/api/wiki/*path", get(api_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)) .route("/auth/logout", post(logout_handler)) .route("/auth/register", post(register_handler)) .route("/auth/github", get(github_oauth_handler)) .route("/auth/github/callback", get(github_callback_handler)) .with_state(state); // Add static file serving if configured let app = if let Some(static_dir) = static_dir { app.nest_service("/static", ServeDir::new(static_dir)) } else { app.nest_service("/static", ServeDir::new("static")) }; app.layer( ServiceBuilder::new() .layer(TraceLayer::new_for_http()) .layer(CorsLayer::permissive()), ) } async fn index_handler(State(state): State) -> Response { match state.wiki.get_page("index").await { Ok(Some(page)) => Html(render_wiki_page(&page, &state).await).into_response(), Ok(None) => Html(render_welcome_page()).into_response(), Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Error loading page").into_response(), } } async fn wiki_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 this is a folder request (ends with /) if decoded_path.ends_with('/') { let folder_path = decoded_path.strip_suffix('/').unwrap_or(&decoded_path); return Html(render_folder_page(folder_path, &state).await).into_response(); } match state.wiki.get_page(&decoded_path).await { Ok(Some(page)) => { // Check if page is public or user is authenticated let is_public = is_page_public(&page); let is_authenticated = is_user_authenticated(&headers, &state).await; tracing::debug!("Page '{}': public={}, authenticated={}", decoded_path, is_public, is_authenticated); if is_public || is_authenticated { Html(render_wiki_page(&page, &state).await).into_response() } else { Html(render_login_required_page()).into_response() } } Ok(None) => { // Check if it's a folder that exists let wiki_path = state.wiki.get_wiki_path(); let folder_full_path = if decoded_path.is_empty() { wiki_path.to_path_buf() } else { wiki_path.join(&*decoded_path) }; if folder_full_path.is_dir() { // Check if user is authenticated for folder access if is_user_authenticated(&headers, &state).await { Html(render_folder_page(&decoded_path, &state).await).into_response() } else { // For non-authenticated users, check if folder contains any public files match build_folder_files(state.wiki.get_wiki_path(), &decoded_path, false, &state.wiki).await { Ok(files) if !files.is_empty() => { Html(render_folder_page(&decoded_path, &state).await).into_response() } _ => Html(render_login_required_page()).into_response() } } } else { Html(render_not_found_page(&decoded_path)).into_response() } } Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Error loading page").into_response(), } } async fn api_wiki_handler( Path(path): Path, headers: HeaderMap, State(state): State, ) -> 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()); match state.wiki.get_page(&decoded_path).await { Ok(Some(page)) => { // Check if page is public or user is authenticated if is_page_public(&page) || is_user_authenticated(&headers, &state).await { Json(page).into_response() } else { StatusCode::UNAUTHORIZED.into_response() } } Ok(None) => StatusCode::NOT_FOUND.into_response(), Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), } } #[derive(Deserialize)] struct SearchQuery { q: String, limit: Option, } async fn search_handler( Query(query): Query, State(state): State, ) -> impl IntoResponse { match state.wiki.search(&query.q, query.limit.unwrap_or(10)).await { Ok(results) => Json(results).into_response(), Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), } } #[derive(Deserialize)] struct FolderQuery { path: Option, } async fn folder_files_handler( Query(query): Query, headers: HeaderMap, State(state): State, ) -> impl IntoResponse { let folder_path = query.path.unwrap_or_else(|| "".to_string()); let is_authenticated = is_user_authenticated(&headers, &state).await; match build_folder_files(state.wiki.get_wiki_path(), &folder_path, is_authenticated, &state.wiki).await { Ok(files) => Json(files).into_response(), Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), } } #[derive(Deserialize)] struct LoginRequest { username: String, password: String, } #[derive(Serialize)] struct AuthResponse { token: String, user: User, } async fn login_form_handler() -> impl IntoResponse { Html(render_login_page()) } async fn login_handler( State(state): State, Json(req): Json, ) -> impl IntoResponse { tracing::info!("Login attempt for user: {}", req.username); match state .auth .authenticate_local(&req.username, &req.password) .await { Ok(Some((user, token))) => { tracing::info!("Login successful for user: {}", user.username); Json(AuthResponse { token, user }).into_response() } Ok(None) => { tracing::warn!( "Login failed for user: {} - invalid credentials", req.username ); StatusCode::UNAUTHORIZED.into_response() } Err(e) => { tracing::error!("Login error for user {}: {}", req.username, e); StatusCode::INTERNAL_SERVER_ERROR.into_response() } } } async fn logout_handler() -> impl IntoResponse { StatusCode::OK } #[derive(Deserialize)] struct RegisterRequest { username: String, email: Option, password: String, } async fn register_handler( State(state): State, Json(req): Json, ) -> impl IntoResponse { match state .auth .register_local(req.username, req.email, req.password) .await { Ok((user, token)) => Json(AuthResponse { token, user }).into_response(), Err(_) => StatusCode::BAD_REQUEST.into_response(), } } async fn github_oauth_handler(State(state): State) -> impl IntoResponse { match state.auth.get_github_auth_url().await { Ok(url) => { let response = Response::builder() .status(StatusCode::FOUND) .header(header::LOCATION, url) .body("".into()) .unwrap(); response } Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), } } #[derive(Deserialize)] struct GitHubCallback { code: String, state: Option, } async fn github_callback_handler( Query(callback): Query, State(state): State, ) -> impl IntoResponse { match state.auth.handle_github_callback(&callback.code).await { Ok((user, token)) => Json(AuthResponse { token, user }).into_response(), Err(_) => StatusCode::UNAUTHORIZED.into_response(), } } async fn render_folder_page(folder_path: &str, state: &AppState) -> String { let folder_name = if folder_path.is_empty() { "Wiki Root" } else { folder_path.split('/').last().unwrap_or("Unknown Folder") }; format!( r#" {}

📁 Filetree

Loading...

{}

"#, folder_name, folder_name, folder_path ) } async fn render_wiki_page(page: &WikiPage, state: &AppState) -> String { let backlinks = state .wiki .get_backlinks(&page.path) .await .unwrap_or_default(); format!( r#" {}

📂 Filetree

{}

{}

Backlinks

{}
"#, page.title, page.title, page.html, backlinks .iter() .map(|link| format!( r#"{}"#, urlencoding::encode(link), link )) .collect::>() .join(" ") ) } fn render_welcome_page() -> String { r#" Welcome to ObsWiki

Welcome to ObsWiki

Your Obsidian-style wiki is ready!

Create an index.md file in your wiki directory to customize this page.

"#.to_string() } fn render_not_found_page(path: &str) -> String { format!( r#" Page Not Found

Page Not Found

The page {} doesn't exist yet.

Create it now

"#, path, urlencoding::encode(path) ) } fn is_page_public(page: &WikiPage) -> bool { // Extract frontmatter and check for obswiki_public: true let lines: Vec<&str> = page.content.lines().collect(); if lines.is_empty() || !lines[0].trim().starts_with("---") { tracing::debug!("Page '{}': no frontmatter found", page.path); return false; } for line in lines.iter().skip(1) { let trimmed = line.trim(); if trimmed == "---" { break; } if trimmed.starts_with("obswiki_public:") { let value = trimmed.split(':').nth(1).unwrap_or("").trim(); let is_public = value == "true"; tracing::debug!("Page '{}': obswiki_public = {}", page.path, is_public); return is_public; } } tracing::debug!("Page '{}': obswiki_public not found in frontmatter", page.path); false } async fn is_user_authenticated(headers: &HeaderMap, state: &AppState) -> bool { // Check Authorization header first if let Some(auth_header) = headers.get("Authorization") { if let Ok(auth_str) = auth_header.to_str() { if let Some(token) = auth_str.strip_prefix("Bearer ") { let is_valid = state.auth.verify_token(token).await.is_ok(); tracing::debug!("Bearer token authentication: {}", is_valid); return is_valid; } } } // Also check cookies for token (for browser navigation) if let Some(cookie_header) = headers.get("Cookie") { if let Ok(cookie_str) = cookie_header.to_str() { for cookie in cookie_str.split(';') { let cookie = cookie.trim(); if let Some(token) = cookie.strip_prefix("auth_token=") { let is_valid = state.auth.verify_token(token).await.is_ok(); tracing::debug!("Cookie token authentication: {}", is_valid); return is_valid; } } } } tracing::debug!("No valid authentication found"); false } #[derive(Serialize)] struct FolderFile { name: String, path: String, #[serde(rename = "type")] file_type: String, // "file" or "folder" } fn build_folder_files<'a>( base_path: &'a StdPath, folder_path: &'a str, is_authenticated: bool, wiki_service: &'a Arc, ) -> std::pin::Pin, std::io::Error>> + Send + 'a>> { Box::pin(async move { use tokio::fs; let current_path = if folder_path.is_empty() { base_path.to_path_buf() } else { base_path.join(folder_path) }; let mut entries = fs::read_dir(¤t_path).await?; let mut files = Vec::new(); while let Some(entry) = entries.next_entry().await? { let entry_path = entry.path(); let file_name = entry.file_name().to_string_lossy().to_string(); // Skip .git folder and other hidden files/folders if file_name.starts_with('.') { continue; } if entry_path.is_dir() { let dir_relative_path = if folder_path.is_empty() { file_name.clone() } else { format!("{}/{}", folder_path, file_name) }; // Check if folder should be visible - if user is authenticated, show all folders // If not authenticated, only show folders that contain public files if is_authenticated { files.push(FolderFile { name: file_name, path: dir_relative_path, file_type: "folder".to_string(), }); } else { // Check if folder contains any public files if let Ok(folder_files) = build_folder_files(base_path, &dir_relative_path, is_authenticated, wiki_service).await { if !folder_files.is_empty() { // Folder contains at least one public file, so show it files.push(FolderFile { name: file_name, path: dir_relative_path, file_type: "folder".to_string(), }); } } // If folder has no accessible files, don't show it } } else if file_name.ends_with(".md") { let page_name = &file_name[..file_name.len() - 3]; // Remove .md let page_path = if folder_path.is_empty() { page_name.to_string() } else { format!("{}/{}", folder_path, page_name) }; // Check if user can access this file (either authenticated or file is public) if is_authenticated { // Authenticated users can see all files files.push(FolderFile { name: page_name.to_string(), path: page_path, file_type: "file".to_string(), }); } else { // Non-authenticated users can only see public files if let Ok(Some(page)) = wiki_service.get_page(&page_path).await { if is_page_public(&page) { files.push(FolderFile { name: page_name.to_string(), path: page_path, file_type: "file".to_string(), }); } } // If page doesn't exist or fails to load, don't include it } } } // Sort: folders first, then files, both alphabetically files.sort_by( |a, b| match (&a.file_type == "folder", &b.file_type == "folder") { (true, false) => std::cmp::Ordering::Less, (false, true) => std::cmp::Ordering::Greater, _ => a.name.cmp(&b.name), }, ); Ok(files) }) } fn render_login_required_page() -> String { r#" Login Required - ObsWiki

Authentication Required

This page is private and requires authentication to view.

Login to Continue

"# .to_string() } fn render_login_page() -> String { r#" Login - ObsWiki

Login

"# .to_string() }