1080 lines
35 KiB
Rust
1080 lines
35 KiB
Rust
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<WikiService>,
|
|
pub auth: Arc<AuthService>,
|
|
}
|
|
|
|
pub struct Server {
|
|
app: Router,
|
|
listener: TcpListener,
|
|
}
|
|
|
|
impl Server {
|
|
pub async fn new(config: Config, wiki_path: String, port: u16) -> Result<Self> {
|
|
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<AppState>) -> Response<Body> {
|
|
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<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 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<String>,
|
|
headers: HeaderMap,
|
|
State(state): State<AppState>,
|
|
) -> 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<usize>,
|
|
}
|
|
|
|
async fn search_handler(
|
|
Query(query): Query<SearchQuery>,
|
|
State(state): State<AppState>,
|
|
) -> 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<String>,
|
|
}
|
|
|
|
async fn folder_files_handler(
|
|
Query(query): Query<FolderQuery>,
|
|
headers: HeaderMap,
|
|
State(state): State<AppState>,
|
|
) -> 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<AppState>,
|
|
Json(req): Json<LoginRequest>,
|
|
) -> 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<String>,
|
|
password: String,
|
|
}
|
|
|
|
async fn register_handler(
|
|
State(state): State<AppState>,
|
|
Json(req): Json<RegisterRequest>,
|
|
) -> 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<AppState>) -> 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<String>,
|
|
}
|
|
|
|
async fn github_callback_handler(
|
|
Query(callback): Query<GitHubCallback>,
|
|
State(state): State<AppState>,
|
|
) -> 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#"<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>{}</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;
|
|
}}
|
|
nav input {{
|
|
padding: 8px;
|
|
border: 1px solid #ccc;
|
|
width: 150px;
|
|
font-family: serif;
|
|
}}
|
|
#auth-section {{
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 5px;
|
|
}}
|
|
main {{
|
|
margin-right: 220px;
|
|
padding: 20px;
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<nav>
|
|
<a href="/">Home</a>
|
|
<input type="text" id="search" placeholder="Search wiki...">
|
|
<div id="auth-section">
|
|
<a href="/auth/login" id="auth-link">Login</a>
|
|
</div>
|
|
</nav>
|
|
</header>
|
|
<main>
|
|
<div>
|
|
<h3 id="filetree-toggle" style="cursor: pointer; user-select: none;">📁 Filetree</h3>
|
|
<div id="filetree">
|
|
Loading...
|
|
</div>
|
|
</div>
|
|
<article>
|
|
<h1>{}</h1>
|
|
</article>
|
|
</main>
|
|
<script src="/static/js/script.js"></script>
|
|
<script>
|
|
// Update auth link based on login status
|
|
document.addEventListener('DOMContentLoaded', function() {{
|
|
const token = localStorage.getItem('obswiki_token');
|
|
const cookieToken = getCookie('auth_token');
|
|
const authLink = document.getElementById('auth-link');
|
|
|
|
if (token || cookieToken) {{
|
|
authLink.textContent = 'Logout';
|
|
authLink.href = '#';
|
|
authLink.onclick = function(e) {{
|
|
e.preventDefault();
|
|
localStorage.removeItem('obswiki_token');
|
|
localStorage.removeItem('obswiki_user');
|
|
document.cookie = 'auth_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
|
|
window.location.reload();
|
|
}};
|
|
}}
|
|
// Load filetree immediately for folder pages
|
|
loadFolderFiles('{}');
|
|
}});
|
|
|
|
function getCookie(name) {{
|
|
const value = '; ' + document.cookie;
|
|
const parts = value.split('; ' + name + '=');
|
|
if (parts.length === 2) return parts.pop().split(';').shift();
|
|
return null;
|
|
}}
|
|
|
|
async function loadFolderFiles(folderPath) {{
|
|
try {{
|
|
const response = await fetch('/api/folder-files?path=' + encodeURIComponent(folderPath), {{
|
|
credentials: 'same-origin'
|
|
}});
|
|
if (response.ok) {{
|
|
const files = await response.json();
|
|
renderFolderFiles(files);
|
|
}} else {{
|
|
console.error('Folder files API error:', response.status, response.statusText);
|
|
document.getElementById('filetree').textContent = 'Error loading files (status: ' + response.status + ')';
|
|
}}
|
|
}} catch (error) {{
|
|
console.error('Failed to load folder files:', error);
|
|
document.getElementById('filetree').textContent = 'Error loading files: ' + error.message;
|
|
}}
|
|
}}
|
|
|
|
function renderFolderFiles(files) {{
|
|
const container = document.getElementById('filetree');
|
|
if (files.length === 0) {{
|
|
container.textContent = 'No files in this folder';
|
|
return;
|
|
}}
|
|
|
|
const list = files.map(function(file) {{
|
|
const icon = file.type === 'folder' ? '📁' : '📄';
|
|
const href = file.type === 'folder' ? '/wiki/' + file.path + '/' : '/wiki/' + file.path;
|
|
return '<div><a href="' + href + '">' + icon + ' ' + file.name + '</a></div>';
|
|
}}).join('');
|
|
|
|
container.innerHTML = list;
|
|
}}
|
|
|
|
// Add filetree toggle functionality
|
|
const filetreeToggle = document.getElementById('filetree-toggle');
|
|
const filetreeContent = document.getElementById('filetree');
|
|
|
|
filetreeToggle.addEventListener('click', function() {{
|
|
if (filetreeContent.style.display === 'none') {{
|
|
filetreeContent.style.display = 'block';
|
|
filetreeToggle.textContent = '📁 Filetree';
|
|
}} else {{
|
|
filetreeContent.style.display = 'none';
|
|
filetreeToggle.textContent = '📂 Filetree';
|
|
}}
|
|
}});
|
|
</script>
|
|
</body>
|
|
</html>"#,
|
|
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#"<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>{}</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;
|
|
}}
|
|
nav input {{
|
|
padding: 8px;
|
|
border: 1px solid #ccc;
|
|
width: 150px;
|
|
font-family: serif;
|
|
}}
|
|
#auth-section {{
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 5px;
|
|
}}
|
|
main {{
|
|
margin-right: 220px;
|
|
padding: 20px;
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<nav>
|
|
<a href="/">Home</a>
|
|
<input type="text" id="search" placeholder="Search wiki...">
|
|
<div id="auth-section">
|
|
<a href="/auth/login" id="auth-link">Login</a>
|
|
</div>
|
|
</nav>
|
|
</header>
|
|
<main>
|
|
<div>
|
|
<h3 id="filetree-toggle" style="cursor: pointer; user-select: none;">📂 Filetree</h3>
|
|
<div id="filetree" style="display: none;">
|
|
Loading...
|
|
</div>
|
|
</div>
|
|
<article>
|
|
<h1>{}</h1>
|
|
<div>
|
|
{}
|
|
</div>
|
|
<div>
|
|
<h3>Backlinks</h3>
|
|
<div>
|
|
{}
|
|
</div>
|
|
</div>
|
|
</article>
|
|
</main>
|
|
<script src="/static/js/script.js"></script>
|
|
<script>
|
|
// Update auth link based on login status
|
|
document.addEventListener('DOMContentLoaded', function() {{
|
|
const token = localStorage.getItem('obswiki_token');
|
|
const cookieToken = getCookie('auth_token');
|
|
const authLink = document.getElementById('auth-link');
|
|
|
|
if (token || cookieToken) {{
|
|
authLink.textContent = 'Logout';
|
|
authLink.href = '#';
|
|
authLink.onclick = function(e) {{
|
|
e.preventDefault();
|
|
localStorage.removeItem('obswiki_token');
|
|
localStorage.removeItem('obswiki_user');
|
|
document.cookie = 'auth_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
|
|
window.location.reload();
|
|
}};
|
|
}}
|
|
// Don't load filetree initially - load on first toggle
|
|
}});
|
|
|
|
function getCookie(name) {{
|
|
const value = '; ' + document.cookie;
|
|
const parts = value.split('; ' + name + '=');
|
|
if (parts.length === 2) return parts.pop().split(';').shift();
|
|
return null;
|
|
}}
|
|
|
|
async function loadCurrentFolderFiles() {{
|
|
const currentPath = window.location.pathname.replace('/wiki/', '') || '';
|
|
const folderPath = currentPath.includes('/') ? currentPath.substring(0, currentPath.lastIndexOf('/')) : '';
|
|
|
|
try {{
|
|
const response = await fetch('/api/folder-files?path=' + encodeURIComponent(folderPath), {{
|
|
credentials: 'same-origin'
|
|
}});
|
|
if (response.ok) {{
|
|
const files = await response.json();
|
|
renderFolderFiles(files, folderPath);
|
|
}} else {{
|
|
console.error('Folder files API error:', response.status, response.statusText);
|
|
document.getElementById('filetree').textContent = 'Error loading files (status: ' + response.status + ')';
|
|
}}
|
|
}} catch (error) {{
|
|
console.error('Failed to load folder files:', error);
|
|
document.getElementById('filetree').textContent = 'Error loading files: ' + error.message;
|
|
}}
|
|
}}
|
|
|
|
function renderFolderFiles(files, currentFolder) {{
|
|
const container = document.getElementById('filetree');
|
|
if (files.length === 0) {{
|
|
container.textContent = 'No files in this folder';
|
|
return;
|
|
}}
|
|
|
|
const list = files.map(function(file) {{
|
|
const icon = file.type === 'folder' ? '📁' : '📄';
|
|
const href = file.type === 'folder' ? '/wiki/' + file.path + '/' : '/wiki/' + file.path;
|
|
return '<div><a href="' + href + '">' + icon + ' ' + file.name + '</a></div>';
|
|
}}).join('');
|
|
|
|
container.innerHTML = list;
|
|
}}
|
|
|
|
// Add filetree toggle functionality
|
|
const filetreeToggle = document.getElementById('filetree-toggle');
|
|
const filetreeContent = document.getElementById('filetree');
|
|
let filetreeLoaded = false;
|
|
|
|
filetreeToggle.addEventListener('click', function() {{
|
|
if (filetreeContent.style.display === 'none') {{
|
|
filetreeContent.style.display = 'block';
|
|
filetreeToggle.textContent = '📁 Filetree';
|
|
// Load filetree content on first open
|
|
if (!filetreeLoaded) {{
|
|
loadCurrentFolderFiles();
|
|
filetreeLoaded = true;
|
|
}}
|
|
}} else {{
|
|
filetreeContent.style.display = 'none';
|
|
filetreeToggle.textContent = '📂 Filetree';
|
|
}}
|
|
}});
|
|
</script>
|
|
</body>
|
|
</html>"#,
|
|
page.title,
|
|
page.title,
|
|
page.html,
|
|
backlinks
|
|
.iter()
|
|
.map(|link| format!(
|
|
r#"<a href="/wiki/{}">{}</a>"#,
|
|
urlencoding::encode(link),
|
|
link
|
|
))
|
|
.collect::<Vec<_>>()
|
|
.join(" ")
|
|
)
|
|
}
|
|
|
|
fn render_welcome_page() -> String {
|
|
r#"<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Welcome to ObsWiki</title>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<nav>
|
|
<a href="/">Home</a>
|
|
<div id="auth-section">
|
|
<a href="/auth/login" id="auth-link">Login</a>
|
|
</div>
|
|
</nav>
|
|
</header>
|
|
<main>
|
|
<div>
|
|
<h1>Welcome to ObsWiki</h1>
|
|
<p>Your Obsidian-style wiki is ready!</p>
|
|
<p>Create an <code>index.md</code> file in your wiki directory to customize this page.</p>
|
|
</div>
|
|
</main>
|
|
<script>
|
|
// Update auth link based on login status
|
|
document.addEventListener('DOMContentLoaded', function() {{
|
|
const token = localStorage.getItem('obswiki_token');
|
|
const cookieToken = getCookie('auth_token');
|
|
const authLink = document.getElementById('auth-link');
|
|
|
|
if (token || cookieToken) {{
|
|
authLink.textContent = 'Logout';
|
|
authLink.href = '#';
|
|
authLink.onclick = function(e) {{
|
|
e.preventDefault();
|
|
localStorage.removeItem('obswiki_token');
|
|
localStorage.removeItem('obswiki_user');
|
|
document.cookie = 'auth_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
|
|
window.location.reload();
|
|
}};
|
|
}}
|
|
}});
|
|
|
|
function getCookie(name) {{
|
|
const value = '; ' + document.cookie;
|
|
const parts = value.split('; ' + name + '=');
|
|
if (parts.length === 2) return parts.pop().split(';').shift();
|
|
return null;
|
|
}}
|
|
}});
|
|
</script>
|
|
</body>
|
|
</html>"#.to_string()
|
|
}
|
|
|
|
fn render_not_found_page(path: &str) -> String {
|
|
format!(
|
|
r#"<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Page Not Found</title>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<nav>
|
|
<a href="/">Home</a>
|
|
<div id="auth-section">
|
|
<a href="/auth/login" id="auth-link">Login</a>
|
|
</div>
|
|
</nav>
|
|
</header>
|
|
<main>
|
|
<div>
|
|
<h1>Page Not Found</h1>
|
|
<p>The page <strong>{}</strong> doesn't exist yet.</p>
|
|
<p><a href="/wiki/{}?edit=true">Create it now</a></p>
|
|
</div>
|
|
</main>
|
|
<script>
|
|
// Update auth link based on login status
|
|
document.addEventListener('DOMContentLoaded', function() {{
|
|
const token = localStorage.getItem('obswiki_token');
|
|
const cookieToken = getCookie('auth_token');
|
|
const authLink = document.getElementById('auth-link');
|
|
|
|
if (token || cookieToken) {{
|
|
authLink.textContent = 'Logout';
|
|
authLink.href = '#';
|
|
authLink.onclick = function(e) {{
|
|
e.preventDefault();
|
|
localStorage.removeItem('obswiki_token');
|
|
localStorage.removeItem('obswiki_user');
|
|
document.cookie = 'auth_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
|
|
window.location.reload();
|
|
}};
|
|
}}
|
|
}});
|
|
|
|
function getCookie(name) {{
|
|
const value = '; ' + document.cookie;
|
|
const parts = value.split('; ' + name + '=');
|
|
if (parts.length === 2) return parts.pop().split(';').shift();
|
|
return null;
|
|
}}
|
|
}});
|
|
</script>
|
|
</body>
|
|
</html>"#,
|
|
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<WikiService>,
|
|
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<FolderFile>, 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#"<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Login Required - ObsWiki</title>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<nav>
|
|
<a href="/">Home</a>
|
|
<input type="text" id="search" placeholder="Search wiki...">
|
|
<div id="auth-section">
|
|
<a href="/auth/login" id="auth-link">Login</a>
|
|
</div>
|
|
</nav>
|
|
</header>
|
|
<main>
|
|
<div>
|
|
<h1>Authentication Required</h1>
|
|
<p>This page is private and requires authentication to view.</p>
|
|
<p><a href="/auth/login">Login to Continue</a></p>
|
|
</div>
|
|
</main>
|
|
<script>
|
|
// Update auth link based on login status
|
|
document.addEventListener('DOMContentLoaded', function() {{
|
|
const token = localStorage.getItem('obswiki_token');
|
|
const cookieToken = getCookie('auth_token');
|
|
const authLink = document.getElementById('auth-link');
|
|
|
|
if (token || cookieToken) {{
|
|
authLink.textContent = 'Logout';
|
|
authLink.href = '#';
|
|
authLink.onclick = function(e) {{
|
|
e.preventDefault();
|
|
localStorage.removeItem('obswiki_token');
|
|
localStorage.removeItem('obswiki_user');
|
|
document.cookie = 'auth_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
|
|
window.location.reload();
|
|
}};
|
|
}}
|
|
}});
|
|
|
|
function getCookie(name) {{
|
|
const value = '; ' + document.cookie;
|
|
const parts = value.split('; ' + name + '=');
|
|
if (parts.length === 2) return parts.pop().split(';').shift();
|
|
return null;
|
|
}}
|
|
}});
|
|
</script>
|
|
</body>
|
|
</html>"#
|
|
.to_string()
|
|
}
|
|
|
|
fn render_login_page() -> String {
|
|
r#"<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Login - ObsWiki</title>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<nav>
|
|
<a href="/">Home</a>
|
|
</nav>
|
|
</header>
|
|
<main>
|
|
<div>
|
|
<h1>Login</h1>
|
|
<form id="loginForm">
|
|
<div>
|
|
<label for="username">Username:</label>
|
|
<input type="text" id="username" name="username" required>
|
|
</div>
|
|
<div>
|
|
<label for="password">Password:</label>
|
|
<input type="password" id="password" name="password" required>
|
|
</div>
|
|
<button type="submit">Login</button>
|
|
</form>
|
|
<div>
|
|
<a href="/auth/github">Login with GitHub</a>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
<script>
|
|
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const username = document.getElementById('username').value;
|
|
const password = document.getElementById('password').value;
|
|
|
|
try {
|
|
const loginData = {};
|
|
loginData.username = username;
|
|
loginData.password = password;
|
|
|
|
const response = await fetch('/auth/login', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(loginData)
|
|
});
|
|
|
|
if (response.ok) {
|
|
console.log('Login response OK, parsing JSON...');
|
|
const data = await response.json();
|
|
console.log('Login data:', data);
|
|
|
|
if (data.token) {
|
|
console.log('Storing token and redirecting...');
|
|
localStorage.setItem('obswiki_token', data.token);
|
|
localStorage.setItem('obswiki_user', JSON.stringify(data.user));
|
|
// Also set as cookie for browser navigation
|
|
document.cookie = 'auth_token=' + data.token + '; path=/; SameSite=Lax';
|
|
window.location.href = '/';
|
|
} else {
|
|
console.error('No token in response:', data);
|
|
alert('Login failed: No token received');
|
|
}
|
|
} else {
|
|
console.error('Login failed with status:', response.status);
|
|
alert('Invalid username or password');
|
|
}
|
|
} catch (error) {
|
|
alert('Login failed: ' + error.message);
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>"#
|
|
.to_string()
|
|
}
|