yeet
This commit is contained in:
976
src/server/mod.rs
Normal file
976
src/server/mod.rs
Normal file
@@ -0,0 +1,976 @@
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav>
|
||||
<a href="/">Home</a>
|
||||
<div>
|
||||
<input type="text" id="search" placeholder="Search wiki...">
|
||||
</div>
|
||||
<div id="auth-section">
|
||||
<a href="/auth/login" id="auth-link">Login</a>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<article>
|
||||
<h1>{}</h1>
|
||||
<div>
|
||||
<h3>Files in this folder</h3>
|
||||
<div id="filetree">
|
||||
Loading...
|
||||
</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();
|
||||
}};
|
||||
}}
|
||||
// Load filetree for current folder
|
||||
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;
|
||||
}}
|
||||
</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>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav>
|
||||
<a href="/">Home</a>
|
||||
<div>
|
||||
<input type="text" id="search" placeholder="Search wiki...">
|
||||
</div>
|
||||
<div id="auth-section">
|
||||
<a href="/auth/login" id="auth-link">Login</a>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<article>
|
||||
<h1>{}</h1>
|
||||
<div>
|
||||
{}
|
||||
</div>
|
||||
<div>
|
||||
<h3>Files in this folder</h3>
|
||||
<div id="filetree">
|
||||
Loading...
|
||||
</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();
|
||||
}};
|
||||
}}
|
||||
// Load filetree for current folder
|
||||
loadCurrentFolderFiles();
|
||||
}});
|
||||
|
||||
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;
|
||||
}}
|
||||
</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>
|
||||
<div>
|
||||
<input type="text" id="search" placeholder="Search wiki...">
|
||||
</div>
|
||||
<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()
|
||||
}
|
||||
Reference in New Issue
Block a user