diff --git a/README.md b/README.md index 36a06ba..720f5c9 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ now with xssbook you can run as much stallman disapprovement as you want - all inputs on the site are unfiltered - api calls dont care what you send them as long as they are valid strings - /console page to see everyones amazing api calls +- /admin page for adnim things **installation** @@ -17,6 +18,8 @@ The project is written in rust, so you can build it by running Next, make sure where you are runing the binary from, that you copy the sources public folder to the same directory. The public folder is needed to server html, css, js, and font files. +Next, the /admin page is protected by a set secret. By default this is set to admin, but you should change it by setting the `SECRET` environment variable. + Finally, the site runs on port `8080`, so its recommended you put it behind a reverse proxy, or you could use a docker container and remap the outsite port (see below). **docker** diff --git a/deployments/docker/docker-compose.yml b/deployments/docker/docker-compose.yml index e58c9f6..09415e4 100644 --- a/deployments/docker/docker-compose.yml +++ b/deployments/docker/docker-compose.yml @@ -4,6 +4,8 @@ services: ritlug-discord-bot: container_name: xssbook image: xssbook + environment: + - SECRET="admin" ports: - 8080:8080 volumes: diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..fe8e38b --- /dev/null +++ b/public/admin.html @@ -0,0 +1,32 @@ + + + + + + + + XSSBook - Admin Panel + + + + + + + + \ No newline at end of file diff --git a/public/css/admin.css b/public/css/admin.css new file mode 100644 index 0000000..1b6e2ac --- /dev/null +++ b/public/css/admin.css @@ -0,0 +1,133 @@ +body { + margin: 0; + padding: 0; + background-color: #181818; +} + +#header { + background-color: #242424; +} + +#login { + display: flex; + justify-content: center; + align-items: center; + width: 100vw; + height: 100vh; + flex-direction: column; +} + +#error .logo { + font-size: 100px; +} + +.desc { + font-size: 40px; +} + +input { + flex: 0; + background-color: #242424; + color: white; + border: 1px solid #606770; +} + +input:focus { + outline: none; +} + +#admin { + margin: 1.75em; + margin-top: 5em; + width: calc(100vw - 1.75em * 2); + height: calc(100vh - 5em - 1.75em); + display: flex; + flex-direction: column; +} + +#queryinput { + display: flexbox; + width: 100%; +} + +#queryinput #query { + width: 50em; + margin: 0; +} + +form { + width: 100%; + display: flex; + justify-content: center; + align-content: center; +} + +#queryinput .submit, .view { + all: unset; + font-family: sfpro; + margin: 0; + padding: 10px 30px; + background-color: #3bd16f; + border-radius: 5px; + font-size: 18px; + margin-left: 2em; + cursor: pointer; + border: 1px solid #606770; +} + +#queryinput .submit:active { + background-color: #30ab5a; +} + +#queryinput .view { + background-color: #242424; + color: #707882; + border: 1px solid #606770; +} + +#queryinput .view:active { + background-color: #181818; +} + +table { + margin-top: 3em; + border-collapse: separate; + border-spacing: 15px; +} + +th, td { + font-family: sfpro; + color: white; + padding: 20px; + border-radius: 10px; + background-color: #242424; + border-radius: 10px; +} + +th { + font-family: sfprobold; +} + +.value { + color: white; +} + +.bool { + color: aqua; +} + +.null { + color: blue; +} + +.number { + color: yellow; +} + +.string { + color: #4ae04a +} + +.key .string { + color: white; +} \ No newline at end of file diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..e023946 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/js/admin.js b/public/js/admin.js new file mode 100644 index 0000000..e4364ec --- /dev/null +++ b/public/js/admin.js @@ -0,0 +1,59 @@ +async function auth(event) { + event.preventDefault(); + const text = event.target.elements.adminpassword.value; + const response = await adminauth(text); + if (response.status !== 200) { + alert(response.msg) + } else { + document.getElementById("admin").classList.remove("hidden") + document.getElementById("login").classList.add("hidden") + } + return false; +} + +async function submit() { + let text = document.getElementById("query").value + let response = await adminquery(text) + alert(response.msg) +} + +async function posts() { + let response = await adminposts(); + if (response.status !== 200) { + alert(response.msg) + return + } + let table = document.getElementById("table") + table.innerHTML = response.msg +} + +async function users() { + let response = await adminusers(); + if (response.status !== 200) { + alert(response.msg) + return + } + let table = document.getElementById("table") + table.innerHTML = response.msg +} + +async function sessions() { + let response = await adminsessions(); + if (response.status !== 200) { + alert(response.msg) + return + } + let table = document.getElementById("table") + table.innerHTML = response.msg +} + +async function load() { + let check = await admincheck(); + if (check.msg === "true") { + document.getElementById("admin").classList.remove("hidden") + } else { + document.getElementById("login").classList.remove("hidden") + } +} + +load() \ No newline at end of file diff --git a/public/js/api.js b/public/js/api.js index 77adff7..9845be5 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -64,4 +64,28 @@ const postlike = async (post_id, state) => { const createpost = async (content) => { return await request('/posts/create', {content}) +} + +const adminauth = async (secret) => { + return await request('/admin/auth', {secret}) +} + +const admincheck = async () => { + return await request('/admin/check', {}) +} + +const adminquery = async (query) => { + return await request('/admin/query', {query}) +} + +const adminposts = async () => { + return await request('/admin/posts', {}) +} + +const adminusers = async () => { + return await request('/admin/users', {}) +} + +const adminsessions = async () => { + return await request('/admin/sessions', {}) } \ No newline at end of file diff --git a/public/login.html b/public/login.html index 97398f9..e0428b9 100644 --- a/public/login.html +++ b/public/login.html @@ -164,7 +164,7 @@ \ No newline at end of file diff --git a/src/admin.rs b/src/admin.rs new file mode 100644 index 0000000..dec6b7d --- /dev/null +++ b/src/admin.rs @@ -0,0 +1,125 @@ +use axum::response::Response; +use lazy_static::lazy_static; +use rand::{distributions::Alphanumeric, Rng}; +use tokio::sync::Mutex; + +use crate::{types::{user::User, http::ResponseCode, post::Post, session::Session}, console::{self, sanatize}}; + +lazy_static! { + static ref SECRET: Mutex = Mutex::new(String::new()); +} + +pub fn new_secret() -> String { + rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(32) + .map(char::from) + .collect() +} + +pub async fn get_secret() -> String { + let mut secret = SECRET.lock().await; + if secret.is_empty() { + *secret = new_secret(); + } + return secret.clone(); +} + +pub async fn regen_secret() -> String { + let mut secret = SECRET.lock().await; + *secret = new_secret(); + return secret.clone(); +} + +pub fn generate_users() -> Response { + + let users = match User::reterieve_all() { + Ok(users) => users, + Err(err) => return err, + }; + + let mut html = r#" + + User ID + First Name + Last Name + Email + Password + Gender + Date + Day + Month + Year + + "#.to_string(); + + for user in users { + html.push_str( + &format!("{}{}{}{}{}{}{}{}{}{}", + user.user_id, sanatize(user.firstname), sanatize(user.lastname), sanatize(user.email), sanatize(user.password), + sanatize(user.gender), user.date, user.day, user.month, user.year + ) + ); + } + + ResponseCode::Success.text(&html) +} + +pub fn generate_posts() -> Response { + + let posts = match Post::reterieve_all() { + Ok(posts) => posts, + Err(err) => return err, + }; + + let mut html = r#" + + Post ID + User ID + Content + Likes + Comments + Date + + "#.to_string(); + + for post in posts { + + let Ok(likes) = serde_json::to_string(&post.likes) else { continue }; + let Ok(comments) = serde_json::to_string(&post.comments) else { continue }; + + html.push_str( + &format!("{}{}{}{}{}{}", + post.post_id, post.user_id, sanatize(post.content), console::beautify(likes), + console::beautify(comments), post.date + ) + ); + } + + ResponseCode::Success.text(&html) +} + +pub fn generate_sessions() -> Response { + + let sessions = match Session::reterieve_all() { + Ok(sessions) => sessions, + Err(err) => return err, + }; + + let mut html = r#" + + User ID + Token + + "#.to_string(); + + for session in sessions { + html.push_str( + &format!("{}{}", + session.user_id, session.token + ) + ); + } + + ResponseCode::Success.text(&html) +} \ No newline at end of file diff --git a/src/api/admin.rs b/src/api/admin.rs new file mode 100644 index 0000000..e654628 --- /dev/null +++ b/src/api/admin.rs @@ -0,0 +1,83 @@ +use std::env; + +use axum::{response::Response, Router, routing::post}; +use serde::Deserialize; +use tower_cookies::{Cookies, Cookie}; + +use crate::{types::{extract::{Check, CheckResult, Json, AdminUser, Log}, http::ResponseCode}, admin, database}; + +#[derive(Deserialize)] +struct AdminAuthRequest { + secret: String, +} + +impl Check for AdminAuthRequest { + fn check(&self) -> CheckResult { + Ok(()) + } +} + +async fn auth(cookies: Cookies, Json(body) : Json) -> Response { + + let check = env::var("SECRET").unwrap_or("admin".to_string()); + if check != body.secret { + return ResponseCode::BadRequest.text("Invalid admin secret") + } + + let mut cookie = Cookie::new("admin", admin::regen_secret().await); + cookie.set_secure(false); + cookie.set_http_only(false); + cookie.set_path("/"); + + cookies.add(cookie); + + ResponseCode::Success.text("Successfully logged in") +} + +#[derive(Deserialize)] +struct QueryRequest { + query: String, +} + +impl Check for QueryRequest { + fn check(&self) -> CheckResult { + Ok(()) + } +} + +async fn query(_: AdminUser, Json(body) : Json) -> Response { + match database::query(body.query) { + Ok(changes) => ResponseCode::Success.text(&format!("Query executed successfully. {} lines changed.", changes)), + Err(err) => ResponseCode::InternalServerError.text(&format!("{}", err)) + } +} + +async fn posts(_: AdminUser, _: Log) -> Response { + admin::generate_posts() +} + +async fn users(_: AdminUser, _: Log) -> Response { + admin::generate_users() +} + +async fn sessions(_: AdminUser, _: Log) -> Response { + admin::generate_sessions() +} + +async fn check(check: Option, _: Log) -> Response { + if check.is_none() { + ResponseCode::Success.text("false") + } else { + ResponseCode::Success.text("true") + } +} + +pub fn router() -> Router { + Router::new() + .route("/auth", post(auth)) + .route("/query", post(query)) + .route("/posts", post(posts)) + .route("/users", post(users)) + .route("/sessions", post(sessions)) + .route("/check", post(check)) +} diff --git a/src/api/mod.rs b/src/api/mod.rs index a2083fe..ab857b1 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -2,3 +2,4 @@ pub mod auth; pub mod pages; pub mod posts; pub mod users; +pub mod admin; \ No newline at end of file diff --git a/src/api/pages.rs b/src/api/pages.rs index 9149744..87d0b8d 100644 --- a/src/api/pages.rs +++ b/src/api/pages.rs @@ -53,6 +53,10 @@ async fn console() -> Response { console::generate().await } +async fn admin() -> Response { + ResponseCode::Success.file("/admin.html").await +} + async fn wordpress(_: Log) -> Response { ResponseCode::ImATeapot.text("Hello i am a teapot owo") } @@ -66,4 +70,5 @@ pub fn router() -> Router { .route("/profile", get(profile)) .route("/console", get(console)) .route("/wp-admin", get(wordpress)) + .route("/admin", get(admin)) } diff --git a/src/console.rs b/src/console.rs index eb78c6a..6e2649f 100644 --- a/src/console.rs +++ b/src/console.rs @@ -36,7 +36,8 @@ impl ToString for LogMessage { Method::OPTIONS => "#423fe0", _ => "white", }; - format!("
{} {} {}{} {}
", ip, color, self.method, self.path, self.uri, self.body) + format!("
{} {} {}{} {}
", + ip, color, self.method, self.path, sanatize(self.uri.to_string()), self.body) } } @@ -200,7 +201,14 @@ impl Formatter for HtmlFormatter { } } -fn beautify(body: String) -> String { +pub fn sanatize(input: String) -> String { + input.replace("&", "&").replace("<", "<").replace(">", ">") +} + +pub fn beautify(body: String) -> String { + + let body = sanatize(body); + if body.is_empty() { return String::new(); } diff --git a/src/database/mod.rs b/src/database/mod.rs index d48f352..b24c1e1 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,3 +1,5 @@ +use tracing::instrument; + pub mod posts; pub mod sessions; pub mod users; @@ -12,3 +14,10 @@ pub fn init() -> Result<(), rusqlite::Error> { sessions::init()?; Ok(()) } + +#[instrument()] +pub fn query(query: String) -> Result { + tracing::trace!("Running custom query"); + let conn = connect()?; + conn.execute(&query, []) +} \ No newline at end of file diff --git a/src/database/posts.rs b/src/database/posts.rs index 58470f0..3f2fc58 100644 --- a/src/database/posts.rs +++ b/src/database/posts.rs @@ -77,6 +77,18 @@ pub fn get_post_page(page: u64) -> Result, rusqlite::Error> { Ok(row.into_iter().flatten().collect()) } +#[instrument()] +pub fn get_all_posts() -> Result, rusqlite::Error> { + tracing::trace!("Retrieving posts page"); + let conn = database::connect()?; + let mut stmt = conn.prepare("SELECT * FROM posts ORDER BY post_id")?; + let row = stmt.query_map([], |row| { + let row = post_from_row(row)?; + Ok(row) + })?; + Ok(row.into_iter().flatten().collect()) +} + #[instrument()] pub fn get_users_posts(user_id: u64) -> Result, rusqlite::Error> { tracing::trace!("Retrieving users posts"); diff --git a/src/database/sessions.rs b/src/database/sessions.rs index 8d4ca73..9adccd4 100644 --- a/src/database/sessions.rs +++ b/src/database/sessions.rs @@ -32,6 +32,20 @@ pub fn get_session(token: &str) -> Result, rusqlite::Error> { Ok(row) } +#[instrument()] +pub fn get_all_sessions() -> Result, rusqlite::Error> { + tracing::trace!("Retrieving session"); + let conn = database::connect()?; + let mut stmt = conn.prepare("SELECT * FROM sessions")?; + let row = stmt.query_map([], |row| { + Ok(Session { + user_id: row.get(0)?, + token: row.get(1)?, + }) + })?; + Ok(row.into_iter().flatten().collect()) +} + #[instrument()] pub fn set_session(user_id: u64, token: &str) -> Result<(), Box> { tracing::trace!("Setting new session"); diff --git a/src/database/users.rs b/src/database/users.rs index 05a3a57..7f8e407 100644 --- a/src/database/users.rs +++ b/src/database/users.rs @@ -117,6 +117,18 @@ pub fn get_user_page(page: u64, hide_password: bool) -> Result, rusqli Ok(row.into_iter().flatten().collect()) } +#[instrument()] +pub fn get_all_users() -> Result, rusqlite::Error> { + tracing::trace!("Retrieving user page"); + let conn = database::connect()?; + let mut stmt = conn.prepare("SELECT * FROM users ORDER BY user_id")?; + let row = stmt.query_map([], |row| { + let row = user_from_row(row, false)?; + Ok(row) + })?; + Ok(row.into_iter().flatten().collect()) +} + #[instrument()] pub fn add_user(request: RegistrationRequet) -> Result { tracing::trace!("Adding new user"); diff --git a/src/main.rs b/src/main.rs index 20627d7..b3f5cd2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,6 +23,7 @@ mod api; mod console; mod database; mod types; +mod admin; async fn serve(req: Request, next: Next) -> Response where @@ -76,6 +77,10 @@ async fn main() { .layer(middleware::from_fn(log)) .layer(middleware::from_fn(serve)) .nest("/", pages::router()) + .nest( + "/api/admin", + api::admin::router().layer(Extension(RouterURI("/admin"))), + ) .nest( "/api/auth", auth::router().layer(Extension(RouterURI("/api/auth"))), diff --git a/src/types/extract.rs b/src/types/extract.rs index 4d92a3b..64a3e73 100644 --- a/src/types/extract.rs +++ b/src/types/extract.rs @@ -19,7 +19,7 @@ use crate::{ http::{ResponseCode, Result}, session::Session, user::User, - }, + }, admin, }; pub struct AuthorizedUser(pub User); @@ -53,6 +53,36 @@ where } } +pub struct AdminUser; + +#[async_trait] +impl FromRequestParts for AdminUser +where + S: Send + Sync, +{ + type Rejection = Response; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let Ok(Some(cookies)) = Option::>::from_request_parts(parts, state).await else { + return Err(ResponseCode::Forbidden.text("No cookies provided")) + }; + + let Some(secret) = cookies.get("admin") else { + return Err(ResponseCode::Forbidden.text("No admin secret provided")) + }; + + println!("{}", secret); + + let check = admin::get_secret().await; + + if check != secret { + return Err(ResponseCode::Unauthorized.text("Auth token invalid")) + } + + Ok(Self) + } +} + pub struct Log; #[async_trait] diff --git a/src/types/post.rs b/src/types/post.rs index 90eada2..7397009 100644 --- a/src/types/post.rs +++ b/src/types/post.rs @@ -50,6 +50,14 @@ impl Post { Ok(posts) } + #[instrument()] + pub fn reterieve_all() -> Result> { + let Ok(posts) = database::posts::get_all_posts() else { + return Err(ResponseCode::InternalServerError.text("Failed to fetch posts")) + }; + Ok(posts) + } + #[instrument()] pub fn new(user_id: u64, content: String) -> Result { let Ok(post) = database::posts::add_post(user_id, &content) else { diff --git a/src/types/session.rs b/src/types/session.rs index e704ac7..a9073aa 100644 --- a/src/types/session.rs +++ b/src/types/session.rs @@ -21,6 +21,14 @@ impl Session { Ok(session) } + #[instrument()] + pub fn reterieve_all() -> Result> { + let Ok(sessions) = database::sessions::get_all_sessions() else { + return Err(ResponseCode::InternalServerError.text("Failed to fetch sessions")) + }; + Ok(sessions) + } + #[instrument()] pub fn new(user_id: u64) -> Result { let token: String = rand::thread_rng() diff --git a/src/types/user.rs b/src/types/user.rs index fcfbe91..2bffa52 100644 --- a/src/types/user.rs +++ b/src/types/user.rs @@ -68,6 +68,14 @@ impl User { Ok(user) } + #[instrument()] + pub fn reterieve_all() -> Result> { + let Ok(users) = database::users::get_all_users() else { + return Err(ResponseCode::InternalServerError.text("Failed to fetch users")) + }; + Ok(users) + } + #[instrument()] pub fn new(request: RegistrationRequet) -> Result { if Self::from_email(&request.email).is_ok() {