admin page

This commit is contained in:
Tyler Murphy 2023-01-29 19:28:48 -05:00
parent 7805c730e8
commit ac58a612a3
22 changed files with 585 additions and 4 deletions

View file

@ -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**

View file

@ -4,6 +4,8 @@ services:
ritlug-discord-bot:
container_name: xssbook
image: xssbook
environment:
- SECRET="admin"
ports:
- 8080:8080
volumes:

32
public/admin.html Normal file
View file

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/header.css">
<link rel="stylesheet" href="/css/admin.css">
<title>XSSBook - Admin Panel</title>
</head>
<body>
<script src="/js/api.js"></script>
<script src="/js/admin.js"></script>
<div id="header">
<span class="logo"><a href="/">xssbook</a></span>
</div>
<div id="login" class="hidden">
<span class="gtext desc">Admin Login</span>
<form autocomplete="off" onsubmit="auth(event)">
<input autocomplete="new-password" type="password" name="adminpassword" id="adminpassword" placeholder="Login Secret">
</form>
</div>
<div id="admin" class="hidden">
<div id="queryinput">
<input type="text" name="query" id="query" placeholder="SQL Query">
<button class="submit" onclick="submit()">Submit</button>
<button class="view" onclick="posts()">View Posts</button>
<button class="view" onclick="users()">View Users</button>
<button class="view" onclick="sessions()">View Sessions</button>
</div>
<table id="table"></table>
</div>
</body>

133
public/css/admin.css Normal file
View file

@ -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;
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

59
public/js/admin.js Normal file
View file

@ -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()

View file

@ -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', {})
}

View file

@ -164,7 +164,7 @@
</div>
</div>
<footer>
Metashit © 2023 | This website does not care about you
Tyler Murphy © 2023 | tylerm.dev
</footer>
</body>
</html>

125
src/admin.rs Normal file
View file

@ -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<String> = 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#"
<tr>
<th>User ID</th>
<th>First Name</th>
<th>Last Name</th>
<th>Email</th>
<th>Password</th>
<th>Gender</th>
<th>Date</th>
<th>Day</th>
<th>Month</th>
<th>Year</th>
</tr>
"#.to_string();
for user in users {
html.push_str(
&format!("<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
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#"
<tr>
<th>Post ID</th>
<th>User ID</th>
<th>Content</th>
<th>Likes</th>
<th>Comments</th>
<th>Date</th>
</tr>
"#.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!("<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
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#"
<tr>
<th>User ID</th>
<th>Token</th>
</tr>
"#.to_string();
for session in sessions {
html.push_str(
&format!("<tr><td>{}</td><td>{}</td></tr>",
session.user_id, session.token
)
);
}
ResponseCode::Success.text(&html)
}

83
src/api/admin.rs Normal file
View file

@ -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<AdminAuthRequest>) -> 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<QueryRequest>) -> 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<AdminUser>, _: 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))
}

View file

@ -2,3 +2,4 @@ pub mod auth;
pub mod pages;
pub mod posts;
pub mod users;
pub mod admin;

View file

@ -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))
}

View file

@ -36,7 +36,8 @@ impl ToString for LogMessage {
Method::OPTIONS => "#423fe0",
_ => "white",
};
format!("<div><span class='ip'>{}</span> <span class='method' style='color: {};'>{}</span> <span class='path'>{}{}</span> <span class='body'>{}</span></div>", ip, color, self.method, self.path, self.uri, self.body)
format!("<div><span class='ip'>{}</span> <span class='method' style='color: {};'>{}</span> <span class='path'>{}{}</span> <span class='body'>{}</span></div>",
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
}
pub fn beautify(body: String) -> String {
let body = sanatize(body);
if body.is_empty() {
return String::new();
}

View file

@ -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<usize, rusqlite::Error> {
tracing::trace!("Running custom query");
let conn = connect()?;
conn.execute(&query, [])
}

View file

@ -77,6 +77,18 @@ pub fn get_post_page(page: u64) -> Result<Vec<Post>, rusqlite::Error> {
Ok(row.into_iter().flatten().collect())
}
#[instrument()]
pub fn get_all_posts() -> Result<Vec<Post>, 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<Vec<Post>, rusqlite::Error> {
tracing::trace!("Retrieving users posts");

View file

@ -32,6 +32,20 @@ pub fn get_session(token: &str) -> Result<Option<Session>, rusqlite::Error> {
Ok(row)
}
#[instrument()]
pub fn get_all_sessions() -> Result<Vec<Session>, 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<dyn std::error::Error>> {
tracing::trace!("Setting new session");

View file

@ -117,6 +117,18 @@ pub fn get_user_page(page: u64, hide_password: bool) -> Result<Vec<User>, rusqli
Ok(row.into_iter().flatten().collect())
}
#[instrument()]
pub fn get_all_users() -> Result<Vec<User>, 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<User, rusqlite::Error> {
tracing::trace!("Adding new user");

View file

@ -23,6 +23,7 @@ mod api;
mod console;
mod database;
mod types;
mod admin;
async fn serve<B>(req: Request<B>, next: Next<B>) -> 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"))),

View file

@ -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<S> FromRequestParts<S> for AdminUser
where
S: Send + Sync,
{
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self> {
let Ok(Some(cookies)) = Option::<TypedHeader<Cookie>>::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]

View file

@ -50,6 +50,14 @@ impl Post {
Ok(posts)
}
#[instrument()]
pub fn reterieve_all() -> Result<Vec<Self>> {
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<Self> {
let Ok(post) = database::posts::add_post(user_id, &content) else {

View file

@ -21,6 +21,14 @@ impl Session {
Ok(session)
}
#[instrument()]
pub fn reterieve_all() -> Result<Vec<Self>> {
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<Self> {
let token: String = rand::thread_rng()

View file

@ -68,6 +68,14 @@ impl User {
Ok(user)
}
#[instrument()]
pub fn reterieve_all() -> Result<Vec<Self>> {
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<Self> {
if Self::from_email(&request.email).is_ok() {