This commit is contained in:
2025-08-10 10:31:10 -04:00
commit db767dcabc
26 changed files with 8170 additions and 0 deletions

976
src/server/mod.rs Normal file
View 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(&current_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()
}