Compare commits

...

No commits in common. "oldjsver" and "main" have entirely different histories.

108 changed files with 8821 additions and 3870 deletions

5
.dockerignore Normal file
View file

@ -0,0 +1,5 @@
.git
.gitignore
deployments
target
xssbook.db

6
.gitignore vendored
View file

@ -1,3 +1,3 @@
node_modules
.env
xssbook.db
/target
xssbook.db
/public/image/custom

1672
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

21
Cargo.toml Normal file
View file

@ -0,0 +1,21 @@
[package]
name = "xssbook"
version = "0.0.1"
edition = "2021"
[dependencies]
tokio = { version = "1.25.0", features = ["full"] }
axum = { version = "0.6.12", features = ["headers", "query", "ws"] }
tower-http = { version = "0.3.5", features = ["fs"] }
tower-cookies = "0.8.0"
tower = "0.4.13"
tracing = "0.1.37"
tracing-subscriber = "0.3.16"
bytes = "1.4"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["std"] }
rusqlite = { version = "0.28.0", features = ["bundled"] }
rand = "0.8.5"
time = "0.3.17"
lazy_static = "1.4"
image = "0.24.5"

13
LICENSE Normal file
View file

@ -0,0 +1,13 @@
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document, and changing it is allowed as long
as the name is changed.
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. You just DO WHAT THE FUCK YOU WANT TO.

52
README.md Normal file
View file

@ -0,0 +1,52 @@
# xssbook
**description**
who doesn't want to run non free javascript
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
- /docs for api documentation
**installation**
The project is written in rust, so you can build it by running
`cargo build --release`
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**
If you want to run it in a docker container a premade dockerfile is here for you
`docker build -f deployments/docker/Dockerfile -t xssbook .`
There is also a docker-compose.yml file for your reference in the /deployments/docker folder.
There are two volumes you have to make for the container. First one for the database otherwise all data will be wiped upon container restart. You only should volume the database file so create the vollume with the directory below.
`touch [your directory]/xssbook.db`
`-v [your directory]/xssbook.db:/data/xssbook.db`
You have to create the database file beforehand because otherwise docker will create a folder there instead, and then the program will crash when it tries to load a folder as a database.
Finally, you have to make a volume to store custom user avatars and banners. Without this, this data too will be lost upon contaienr restart. To make the volume simply run this with your container.
`-v [another directory]:/data/public/image/custom`
**reverse proxy**
Finally if you are using docker by itself, a reverse proxy, or both, the ip send to the container likily will not be the correct ip. xssbook looks for headers `x-forwarded-for`, `x-real-ip`, and `forwarded` to check for proxies. So make sure to have those headers set. Or if your running just docker, you could also run the docker container on the host network instead of on the bridge network.
**license**
This amazing project is licensed under the WTFPL

View file

@ -0,0 +1,20 @@
FROM rust:1.67-buster as builder
WORKDIR /usr/src/xssbook
COPY ./Cargo.toml ./Cargo.toml
COPY ./Cargo.lock ./Cargo.lock
COPY ./src ./src
RUN cargo install --path .
FROM debian:buster-slim
COPY --from=builder /usr/local/cargo/bin/xssbook /usr/local/bin/xssbook
RUN mkdir /data
WORKDIR /data
COPY ./public ./public
RUN mkdir ./public/image/custom
VOLUME ./public/image/custom
EXPOSE 8080
CMD ["/usr/local/bin/xssbook"]

View file

@ -0,0 +1,13 @@
version: "3.9"
services:
ritlug-discord-bot:
container_name: xssbook
image: xssbook
environment:
- SECRET = "admin"
ports:
- 8080:8080
volumes:
- ${PWD}/xssbook.db:/data/xssbook.db
- ${PWD}/custom:/data/public/image/custom

135
index.js
View file

@ -1,135 +0,0 @@
const express = require('express')
const app = express()
const cache = require('./src/cache')
const con = require('./src/console')
const auth = require('./src/api/auth')
const pages = require('./src/api/pages')
const posts = require('./src/api/posts')
const users = require('./src/api/users')
app.set('trust proxy', true)
app.use(express.static('public'))
app.use(require('cookie-parser')())
app.use(express.json());
app.use((req, res, next) => {
var ip = req.headers['x-real-ip'] || req.socket.remoteAddress;
if (req.path !== '/console') {
let body = { ...req.body }
if (body.password !== undefined) {
body.password = '********'
}
con.log(
ip,
req.method,
req.path,
body
)
}
next()
})
app.use((req, res, next) => {
if (req.path.startsWith('/api/auth')) {
next()
return
}
const cookies = req.cookies
if (cookies === undefined || cookies.auth === undefined) {
if (req.method !== 'GET' && req.path.startsWith('/api')) {
res.status(401).send({msg: 'Unauthorized'})
return
}
next()
return
}
const user = cache.auth(req.cookies.auth)
if (user !== undefined) {
res.locals.user = user
} else if (req.method !== 'GET' && req.path.startsWith('/api')) {
res.status(401).send({msg: 'Unauthorized'})
return
}
next()
})
app.use('/api/auth', auth)
app.use('/api/posts', posts)
app.use('/api/users', users)
app.use('/', pages)
app.get('/console', (req, res) => {
res.send(con.render())
})
app.use((req, res, next) => {
res.status(404).sendFile('404.html', { root: './public' })
})
app.use((err, req, res, next) => {
if (err instanceof SyntaxError && err.status === 400 && 'body' in err) {
res.status(400).send({ msg: 'Invalid json body' })
return
}
console.error(err)
res.status(500).send({ msg: 'Internal server error' })
})
const cron = require('node-cron').schedule('*/5 * * * *', () => {
con.msg('Writing cache to database')
cache.dump()
})
const port = 8080
const server = app.listen(port, () => {
console.log(`App listening on port http://127.0.0.1:${port}`)
})
const close = () => {
console.log('Writing cache to database')
cache.dump()
console.log('Stopping cron jobs')
cron.stop()
server.close(() => {
console.log('HTTP server closed')
})
}
process.on('SIGINT', close)
process.on('SIGTERM', close)
process.on('SIGQUIT', close)

2094
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,19 +0,0 @@
{
"name": "xssbook",
"version": "1.0.0",
"description": "a terrible facebook clone",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"author": "Tyler Murphy",
"license": "WTFPL",
"dependencies": {
"better-sqlite3": "^8.0.1",
"cheerio": "^1.0.0-rc.12",
"cookie-parser": "^1.4.6",
"express": "^4.18.2",
"express-rate-limit": "^6.7.0",
"node-cron": "^3.0.2"
}
}

View file

@ -2,10 +2,22 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>XSSBook - Not Found</title>
<meta name="author" content="Tyler Murphy">
<meta name="description" content="404 Page">
<meta property="og:title" content="xssbook">
<meta property="og:site_name" content="xssbook.com">
<meta property="og:description" content="404 Page">
<meta itemprop="name" content="xssbook">
<meta itemprop="description" content="404 Page">
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/404.css">
<link rel="stylesheet" href="/css/header.css">
<title>XSSBook - Not Found</title>
</head>
<body>
<div id="header">

48
public/admin.html Normal file
View file

@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>XSSBook - Admin Panel</title>
<meta name="author" content="Tyler Murphy">
<meta name="description" content="Admin Panel">
<meta property="og:title" content="xssbook">
<meta property="og:site_name" content="xssbook.com">
<meta property="og:description" content="Admin Panel">
<meta itemprop="name" content="xssbook">
<meta itemprop="description" content="Admin Panel">
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/header.css">
<link rel="stylesheet" href="/css/admin.css">
<script src="/js/admin.js" type="module"></script>
</head>
<body>
<div id="header">
<span class="logo"><a href="/">xssbook</a></span>
<span class="gtext desc" style="margin-left: 6em; font-size: 2em; color: #606770">Admin Panel</span>
</div>
<div id="login" class="hidden">
<form autocomplete="off" onsubmit="window.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">
<div id="buttons">
<button class="submit" onclick="window.submit()">Submit</button>
<button class="view" onclick="window.posts()">View Posts</button>
<button class="view" onclick="window.users()">View Users</button>
<button class="view" onclick="window.sessions()">View Sessions</button>
<button class="view" onclick="window.comments()">View Comments</button>
<button class="view" onclick="window.likes()">View Likes</button>
</div>
</div>
<table id="table"></table>
</div>
</body>

28
public/chat.html Normal file
View file

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>XSSBook - Home</title>
<meta name="author" content="Tyler Murphy">
<meta name="description" content="Home">
<meta property="og:title" content="xssbook">
<meta property="og:site_name" content="xssbook.com">
<meta property="og:description" content="Home">
<meta itemprop="name" content="xssbook">
<meta itemprop="description" content="Home">
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/header.css">
<link rel="stylesheet" href="/css/people.css">
<link rel="stylesheet" href="/css/chat.css">
<script type="module" src="/js/chat.js"></script>
</head>
<body>
</body>
</html>

View file

@ -1,5 +1,5 @@
body {
background-color: #f0f2f5;
background-color: var(--secondary)
}
.error {

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

@ -0,0 +1,144 @@
body {
margin: 0;
padding: 0;
background-color: #181818;
overflow-x: hidden;
}
#header {
background-color: #242424;
}
#login {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100vh;
flex-direction: column;
margin: 0;
padding: 0;
}
#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(100% - 1.75em * 2);
height: calc(100% - 5em - 1.75em);
display: flex;
flex-direction: column;
}
#queryinput {
display: flex;
width: calc(100% - 2em);
justify-content: space-between;
flex-direction: row;
margin: 1em;
}
#queryinput #query {
width: 50em;
margin: 0;
flex: 10;
}
form {
width: 100%;
display: flex;
justify-content: center;
align-content: center;
}
#buttons .submit, .view {
all: unset;
font-family: sfpro;
margin: 0;
padding: 10px 30px;
background-color: #242424;
border-radius: 5px;
font-size: 18px;
margin-left: 2em;
cursor: pointer;
border: 1px solid #30ab5a;
color: #30ab5a;
}
#buttons .submit:active {
background-color: #181818;
}
#buttons .view {
background-color: #242424;
color: #707882;
border: 1px solid #606770;
}
#buttons .view:active {
background-color: #181818;
}
table {
margin: 1em 0em;
border-collapse: separate;
border-spacing: 15px;
table-layout: fixed;
width: 100%;
}
th, td {
font-family: sfpro;
color: white;
padding: 20px;
border-radius: 10px;
background-color: #242424;
border-radius: 10px;
word-wrap: break-word;
}
th {
font-family: sfprobold;
}
.value {
color: white;
}
.bool {
color: aqua;
}
.null {
color: blue;
}
.number {
color: yellow;
}
.string {
color: #4ae04a
}
.key .string {
color: white;
}

136
public/css/api.css Normal file
View file

@ -0,0 +1,136 @@
body {
margin: 0;
padding: 0;
background-color: #181818;
overflow-x: hidden;
font-family: sfpro;
}
#docs {
margin-top: 5.5em;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
#docs>div {
display: block;
max-width: 100%;
width: 100em;
background-color: #242424;
border-radius: .5em;
padding: 1em;
box-shadow: 0 2px 4px rgba(0, 0, 0, .05), 0 8px 16px rgba(0, 0, 0, .05);
margin-bottom: 2em;
}
.endpoint {
width: 100%;
height: 3em;
display: flex;
align-items: center;
flex-direction: row;
}
.method {
font-family: sfprobold;
font-size: 1em;
color: #e2ded6;
display: flex;
justify-content: center;
align-items: center;
border-radius: 3px;
width: 5em;
height: 2em;
margin-left: .5em;
}
.uri {
margin-left: 1em;
font-size: 1.25em;
display: inline-block;
font-family: sfprobold;
}
.auth {
flex: 1;
text-align: right;
padding-right: 20px;
font-size: 1.25em;
}
.desc {
margin-left: 2em;
}
.info {
width: 100%;
font-family: sfpro;
display: flex;
flex-direction: column;
}
h2 {
border-bottom: 1px solid #e2ded6;
margin-top: 0;
padding: 10px;
font-size: 20px;
}
.info div {
width: calc(100% - 4em);
margin-left: 2em;
padding-bottom: .5em;
}
.ptype {
font-size: 1.25em;
width: 20em;
display: inline-block;
}
.auth span, .ptype span, .pdesc span {
color: orange;
}
.bigger {
width: 100%;
margin-left: 2em;
}
.pdesc {
font-size: 1em;
display: inline-block;
}
.body {
padding: 20px !important;
width: calc(100% - 4em - 40px) !important;
display: block;
background-color: #181818;
}
.post {
background-color: #853fe0ff;
}
.patch {
background-color: #e0773f;
}
.put {
background-color: #bfa354;
}
.get {
background-color: #00cc00;
}
.delete {
background-color: #cc0000;
}
.key {
margin-left: 40px;
}

180
public/css/chat.css Normal file
View file

@ -0,0 +1,180 @@
.spacer {
margin-bottom: 3.5em !important;
}
#cent {
display: flex;
width: 100%;
height: calc(100vh - 3.5em);
flex-direction: row;
}
#sidebar {
height: 100%;
width: 20%;
min-width: 25em;
display: flex;
flex-direction: column;
}
#sidebar>span {
display: block;
width: 100%;
text-align: center;
font-size: 1.5em;
margin: .5em 0;
}
#center {
height: 100%;
width: calc(100vw - 20%);
max-width: calc(100vw - 25em);
}
.room {
width: calc (100% - 1rem);
height: 3rem;
display: flex;
position: relative;
flex-direction: row;
padding: .5rem 1rem;
}
.room:hover, .current {
background-color: var(--hover);
}
.room-icon {
border-radius: 3rem;
height: 3rem;
width: 3rem;
display: flex;
justify-content: center;
align-items: center;
font-weight: 1000;
font-size: 1.5rem;
right: 0;}
.room-name {
display: flex;
height: 3rem;
margin-left: 1rem;
align-items: center;
}
.roomDisplay {
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
position: relative;
}
.roomDisplayCenter {
position: relative;
display: flex;
width: calc(100% - 25em);
height: 100%;
flex-direction: column;
}
.roomDisplayPeople {
width: 25em;
height: 100%;
overflow-y: scroll;
display: flex;
align-items: center;
flex-direction: column;
}
.roomDisplayPeople>span {
display: block;
width: 100%;
text-align: center;
font-size: 1.5em;
margin: .5em 0em;
}
.person {
width: 20em;
}
.person img, .person .profile {
height: 7em;
width: 7em;
}
.person .info {
margin: 5px;
}
.person .ltext {
font-size: 18px;
}
.person .gtext {
font-size: 12px;
}
.roomDisplay .messages {
flex: 1;
margin-left: 1rem;
display: flex;
flex-direction: column-reverse;
overflow-y: scroll;
}
.roomDisplay .messageContent {
flex-grow: 0;
width: auto;
margin-bottom: .5rem;
display: block;
height: fit-content;
overflow: none;
}
.roomDisplay .messageContent[contenteditable]:empty::before {
content: "Send an unencrypted message";
color: gray;
}
.addUser[contenteditable]:empty::before {
content: "Type email to add user";
color: gray;
}
.addRoom[contenteditable]:empty::before {
content: "Type name to create room";
color: gray;
}
.message {
display: flex;
flex-direction: row;
height: fit-content;
padding: .5rem;
}
.message-pfp {
flex-grow: 0;
height: 3rem;
width: 3rem;
margin-right: 1rem;
}
.message-pfp img {
width: 100%;
height: 100%;
border-radius: 3rem;
}
.message-content {
flex-grow: 0;
}
.loadMessages {
position: absolute;
right: 0;
cursor: pointer;
}

View file

@ -2,8 +2,6 @@ body {
margin: 0;
padding: 0;
background-color: #181818;
display: flex;
flex-direction: column-reverse;
}
@font-face {
@ -11,7 +9,7 @@ body {
src: url("../fonts/sfpro.otf") format("opentype");
}
div {
.msg {
background-color: #282828;
font-family: sfpro;
margin: 15px;
@ -21,7 +19,7 @@ div {
width: calc(100% - 50px)
}
span {
.msg span {
display: inline-block;
padding: 0;
margin: 0;
@ -30,31 +28,35 @@ span {
margin-right: 10px;
}
.body span {
margin-right: 0;
}
.json span {
display: inline;
margin: 0;
}
.key {
color: white;
}
.value {
color: white;
color: white !important;
}
.boolean {
color: aqua;
.bool {
color: aqua !important;
}
.null {
color: blue;
color: blue !important;
}
.number {
color: yellow;
color: yellow !important;
}
.string {
color: #4ae04a
color: #4ae04a !important;
}
.key .string {
color: white !important;
}

View file

@ -1,6 +1,6 @@
#header {
height: 3.5em;
background-color: white;
background-color: var(--primary);
position: fixed;
width: 100vw;
box-shadow: 0 2px 4px rgba(0, 0, 0, .05), 0 8px 16px rgba(0, 0, 0, .05);
@ -36,19 +36,19 @@
display: flex;
align-items: center;
justify-content: center;
color: #606770;
color: var(--medium);
}
#header .buttons a:hover {
background-color: #dddfe2;
background-color: var(--hover);
}
.selected {
color: #1778f2 !important;
border-bottom: 3px solid #1778f2;
color: var(--logo) !important;
border-bottom: 3px solid var(--logo);
}
#header .pfp, #header .pfp img {
#header .pfp {
position: absolute;
right: 1em;
top: .5em;

File diff suppressed because one or more lines are too long

View file

@ -1,5 +1,5 @@
.login {
background-color: #f0f2f5;
background-color: var(--secondary);
display: flex;
justify-content: center;
align-content: center;
@ -11,7 +11,7 @@
display: flex;
position: relative;
flex-direction: column;
background-color: white;
background-color: var(--primary);
box-shadow: 0 2px 4px rgba(0, 0, 0, .1), 0 8px 16px rgba(0, 0, 0, .1);
border-radius: 8px;
width: 396px;
@ -44,4 +44,8 @@
flex-direction: column;
align-items: center;
}
.show {
margin-bottom: 2em;
}
}

View file

@ -1,30 +1,54 @@
body {
background-color: white;
width: 100vw;
height: 100vh;
--primary: #ffffff;
--secondary: #f0f2f5;
--hover: #e4e6e8;
--light: #dadde1;
--mild: #dadde1;
--medium: #606770;
--extreme: #1d2129;
--logo: #1778f2;
--error: #f02849;
--success: #30ab5a;
--text: #000000;
--banner: #949494;
--popup: #ffffffcc;
}
textarea {
resize: none
}
body {
background-color: var(--primary);
width: 100%;
height: 100%;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
color: var(--text);
}
@font-face {
font-family: facebook;
src: url("../fonts/facebook.otf") format("opentype");
font-display: swap;
}
@font-face {
font-family: sfpro;
src: url("../fonts/sfpro.otf") format("opentype");
font-display: swap;
}
@font-face {
font-family: sfprobold;
src: url("../fonts/sfprobold.otf") format("opentype");
font-display: swap;
}
.logo {
color: #1778f2;
color: var(--logo);
font-size: 3.5em;
font-family: facebook;
}
@ -39,12 +63,16 @@ body {
.btext {
font-family: sfpro;
color: #1778f2
color: var(--logo)
}
.bltext {
color: var(--logo)
}
.error {
font-family: sfpro;
color: #f02849;
color: var(--error);
padding-top: 10px;
margin-bottom: -10px;
font-size: 15px;
@ -52,12 +80,12 @@ body {
.gtext {
font-family: sfpro;
color: #606770
color: var(--medium)
}
.label {
font-family: sfpro;
color: #606770;
color: var(--medium);
font-size: 15px;
padding-top: 10px;
padding-left: 10px;
@ -103,19 +131,19 @@ span {
footer {
bottom: 0;
height: 400px;
background-color: white;
background-color: var(--primary);
}
input {
input, .input {
flex: 1;
font-family: sfpro;
background-color: white;
background-color: var(--primary);
padding: 10px;
margin: 10px;
margin-bottom: 0;
border-radius: 5px;
border: 1px solid #dddfe2;
color: #1d2129;
border: 1px solid var(--light);
color: var(--extreme);
font-size: 18px;
}
@ -128,12 +156,12 @@ input {
display: inline-block;
position: relative;
font-family: sfpro;
background-color: white;
background-color: var(--primary);
margin: 10px;
margin-bottom: 0;
border-radius: 5px;
border: 1px solid #dddfe2;
color: #1d2129;
border: 1px solid var(--light);
color: var(--extreme);
font-size: 15px;
flex: 1 0 auto;
}
@ -143,7 +171,7 @@ input {
display: block;
box-sizing: border-box;
width: auto;
color: #1d2129;
color: var(--extreme);
}
[type="radio"] {
@ -159,28 +187,28 @@ select {
all: unset;
flex: 1;
font-family: sfpro;
background-color: white;
background-color: var(--primary);
padding: 10px;
margin: 10px;
margin-bottom: 0;
border-radius: 5px;
border: 1px solid #dddfe2;
color: #1d2129;
border: 1px solid var(--light);
color: var(--extreme);
font-size: 15px;
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYAgMAAACdGdVrAAAADFBMVEVMaXEFBQUFBQUFBQXG+MOgAAAAA3RSTlMAn3BcqiM3AAAAOUlEQVQIW53BsQ2AMAwAMJelV6C80qPYw4k9JmskbqA2px5uNIlcoxF7FmbFxuhckA2iwNzgev3wAR4FDUQbc/qhAAAAAElFTkSuQmCC");
background-image: url("/image/arrow.png");
background-position: right 10px center;
background-repeat: no-repeat;
background-size: 15px;
}
input:focus {
border: 1px solid #1778f2;
border: 1px solid var(--logo);
}
.primary {
all: unset;
font-family: sfpro;
background-color: #1778f2;
background-color: var(--logo);
color: white;
padding: 10px;
margin: 20px;
@ -193,7 +221,7 @@ input:focus {
.success {
all: unset;
font-family: sfpro;
background-color: #42b72a;
background-color: var(--success);
color: white;
padding: 10px;
margin-left: 10px;
@ -211,16 +239,20 @@ input:focus {
width: calc(100% - 40px);
margin-left: 20px;
margin-right: 20px;
border-bottom: 1px solid #dadde1;
border-bottom: 1px solid var(--light);
margin-bottom: 10px;
margin-top: 10px;
z-index: 2;
max-width: 100%;
}
.fullline {
width: calc(100%);
border-bottom: 1px solid #dadde1;
border-bottom: 1px solid var(--light);
margin-bottom: 10px;
margin-top: 10px;
z-index: 2;
max-width: 100%;
}
footer {
@ -236,7 +268,7 @@ footer {
position: absolute;
width: 100vw;
height: 100vh;
background-color: rgba(255, 255, 255, .8);
background-color: var(--popup);
margin: 0;
padding: 0;
top: 0;
@ -266,7 +298,7 @@ footer {
cursor: pointer;
background-size: 20px;
background-position: right;
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYAgMAAACdGdVrAAAADFBMVEVgZ3FHcExgZ3FgZ3Fd28LEAAAAA3RSTlOfAHBcPEovAAAASklEQVQIW2MIBQMG7FRmaGgmUG5PaKgdkNovGvIPSPkfDf4IpII/+h8FUiF/7EVB2u3/gE3x/4hMQQShSqAaoNqhhkGNhlqE2y0A1E85Y0JErBoAAAAASUVORK5CYII=');
background-image: url('/image/close.png');
}
.hidden {
@ -280,8 +312,10 @@ footer {
width: 2.5em;
height: 2.5em;
border-radius: 3em;
background-color: #e4e6e8;
background-color: var(--hover);
flex-shrink: 0;
image-rendering: crisp-edges;
object-fit: cover;
}
.nb {
@ -295,13 +329,110 @@ form {
width: 100%;
}
#load {
#load, .cload {
width: 100%;
display: flex;
justify-content: center;
padding-bottom: 20px;
cursor: pointer;
}
#load a:hover {
border-bottom: #606770 1px solid;
}
#load span:hover, .cload span:hover {
border-bottom: var(--medium) 1px solid;
}
@media (prefers-color-scheme: dark) {
body {
--primary: #242424 !important;
--secondary: #181818 !important;
--hover: #1b1b1b !important;
--light: #3e4042 !important;
--mild: #1b1b1b !important;
--medium: #e2ded6 !important;
--extreme: #e2ded6 !important;
--logo: #1778f2 !important;
--error: #f02849 !important;
--success: #30ab5a !important;
--text: #ffffff !important;
--banner: #6b6b6b !important;
--popup: #242424cc !important;
}
body .icons {
filter: invert(100%) !important;
}
body .blue {
filter: invert(39%) sepia(57%) saturate(200%) saturate(200%)
saturate(200%) saturate(200%) saturate(200%) saturate(147.75%)
hue-rotate(202deg) brightness(97%) contrast(96%) !important;
}
body select {
background-color: var(--secondary) I !important;
color: var(--text) !important;
border: 1px solid var(--light) !important;
}
input:focus {
outline: none;
}
.changeavatar {
filter: invert(100%) !important;
background-color: #bbbbbb !important;
}
.changebanner {
filter: invert(100%) !important;
background-color: #bbbbbb !important;
}
}
.fullwidth {
width: 100%;
display: flex;
justify-content: center;
}
.loading {
display: inline-block;
position: relative;
width: 80px;
height: 80px;
}
.loading div {
box-sizing: border-box;
display: block;
position: absolute;
width: 64px;
height: 64px;
margin: 8px;
border: 3px solid var(--text);
border-radius: 50%;
animation: loading 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
border-color: var(--text) transparent transparent transparent;
}
.loading div:nth-child(1) {
animation-delay: -0.45s;
}
.loading div:nth-child(2) {
animation-delay: -0.3s;
}
.loading div:nth-child(3) {
animation-delay: -0.15s;
}
@keyframes loading {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View file

@ -1,5 +1,5 @@
body {
background-color: #f0f2f5;
background-color: var(--secondary);
}
#users {
@ -9,9 +9,10 @@ body {
}
.person {
color: var(--text);
width: 30em;
height: fit-content;
background-color: white;
background-color: var(--primary);
border-radius: 10px;
box-shadow: 0 2px 4px rgba(0, 0, 0, .05);
margin-bottom: 1.5em;
@ -25,7 +26,7 @@ body {
height: 10em;
padding: 0;
display: block;
background-color: #e4e6e8;
background-color: var(--banner);
flex-shrink: 0;
}

View file

@ -1,5 +1,6 @@
body {
background-color: #f0f2f5;
background-color: var(--secondary);
max-width: 100%;
}
.spacer {
@ -7,7 +8,7 @@ body {
}
#top {
background-color: white;
background-color: var(--primary);
display: flex;
flex-direction: column;
align-items: center;
@ -16,37 +17,67 @@ body {
}
#banner {
background-image: linear-gradient(#949494, white, white);
background-image: linear-gradient(var(--banner), var(--primary), var(--primary));
height: 30em;
width: 100%;
display: flex;
justify-content: center;
}
#banner div, #banner img {
#banner .bg, #banner img {
width: 80em;
max-width: 100%;
height: inherit;
background-color: #e4e6e8;
background-color: var(--hover);
border-radius: 0px 0px 20px 20px;
object-fit: cover;
}
#info {
width: 80em;
max-width: 100%;
display: flex;
flex-direction: row;
}
.face {
background-color: #e4e6e8;
background-color: var(--hover);
height: 12em;
width: 12em;
border-radius: 7em;
border: solid 5px white;
border: solid 5px var(--primary);
margin-top: -2em;
margin-left: 2em;
margin-right: 2em;
}
.face img {
height: 12em;
width: 12em;
border-radius: 7em;
}
.changeavatar, .changebanner {
all: unset;
position: absolute;
width: 3em;
height: 3em;
margin-left: -3em;
margin-top: 9em;
border-radius: 3em;
background-color: var(--secondary);
z-index: 10000 !important;
text-align: center;
background-image: url('/image/change.png');
cursor: pointer;
}
.changebanner {
position: relative;
margin-left: -4em;
margin-top: 26em;
}
.infodata {
margin-top: 2em;
display: flex;
@ -59,9 +90,11 @@ body {
.profilebuttons {
width: 80em;
max-width: 100%;
height: 3em;
display: flex;
align-items: center;
justify-content: space-between;
}
.profilebuttons button {
@ -72,35 +105,42 @@ body {
display: flex;
align-items: center;
justify-content: center;
color: #606770;
color: var(--medium);
cursor: pointer;
flex: 0;
}
.profilebuttons button:hover {
background-color: #dddfe2;
background-color: var(--mild);
}
.selected {
color: #1778f2 !important;
border-bottom: 3px solid #1778f2 !important;
color: var(--logo) !important;
border-bottom: 3px solid var(--logo) !important;
}
#about {
#about, #friends, #followers, #following {
margin-top: 2em;
align-self: center;
padding: 0;
display: flex;
flex-direction: column;
}
#about {
flex-direction: row;
}
#posts {
max-width: 100%;
margin-top: 2em;
}
#about .ltext {
border-right: 2px solid #dadde1;
border-right: 2px solid var(--mild);
padding: 10px;
padding-right: 3em;
max-width: 100%;
}
#about .data {
@ -109,6 +149,7 @@ body {
padding: 10px;
padding-left: 20px;
padding-top: 15px;
max-width: 100%;
}
#about .data span {
@ -118,4 +159,41 @@ body {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
max-width: 100%;
}
.logout {
flex: 1;
}
.follow {
display: flex;
justify-content: center;
align-items: center;
height: 40px;
width: 175px;
background-color: var(--secondary);
border-radius: 10px;
cursor: pointer;
}
.follow>span {
color: var(--medium);
}
.friend {
background-color: var(--logo);
border: 1px solid var(#ffffff)
}
.friend>span {
color: #ffffff;
}
.right {
flex: 1;
display: flex;
justify-content: end;
align-items: center;
padding-right: 50px;
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View file

@ -2,16 +2,25 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="/css/header.css">
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/home.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>XSSBook - Home</title>
<meta name="author" content="Tyler Murphy">
<meta name="description" content="Home">
<meta property="og:title" content="xssbook">
<meta property="og:site_name" content="xssbook.com">
<meta property="og:description" content="Home">
<meta itemprop="name" content="xssbook">
<meta itemprop="description" content="Home">
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/header.css">
<link rel="stylesheet" href="/css/home.css">
<script type="module" src="/js/home.js"></script>
</head>
<body>
<script src="/js/main.js"></script>
<script src="/js/header.js"></script>
<script src="/js/api.js"></script>
<script src="/js/home.js"></script>
<script>init()</script>
</body>
</html>

BIN
public/image/arrow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 B

BIN
public/image/change.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

BIN
public/image/close.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 B

BIN
public/image/default/0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

BIN
public/image/default/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

BIN
public/image/default/10.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

BIN
public/image/default/11.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

BIN
public/image/default/12.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

BIN
public/image/default/13.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

BIN
public/image/default/14.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

BIN
public/image/default/15.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

BIN
public/image/default/16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

BIN
public/image/default/17.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
public/image/default/18.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

BIN
public/image/default/19.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

BIN
public/image/default/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

BIN
public/image/default/20.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

BIN
public/image/default/21.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

BIN
public/image/default/22.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

BIN
public/image/default/23.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

BIN
public/image/default/24.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

BIN
public/image/default/3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

BIN
public/image/default/4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

BIN
public/image/default/5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

BIN
public/image/default/6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

BIN
public/image/default/7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

BIN
public/image/default/8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

BIN
public/image/default/9.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

BIN
public/image/icons.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

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

@ -0,0 +1,88 @@
import { adminauth, admincheck, admincomments, adminposts, adminquery, adminsessions, adminusers, adminlikes } from './api.js'
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;
}
window.auth = auth
async function submit() {
let text = document.getElementById("query").value
let response = await adminquery(text)
alert(response.msg)
}
window.submit = submit
async function posts() {
let response = await adminposts();
if (response.status !== 200) {
alert(response.msg)
return
}
let table = document.getElementById("table")
table.innerHTML = response.msg
}
window.posts = posts
async function users() {
let response = await adminusers();
if (response.status !== 200) {
alert(response.msg)
return
}
let table = document.getElementById("table")
table.innerHTML = response.msg
}
window.users = users
async function sessions() {
let response = await adminsessions();
if (response.status !== 200) {
alert(response.msg)
return
}
let table = document.getElementById("table")
table.innerHTML = response.msg
}
window.sessions = sessions
async function comments() {
let response = await admincomments();
if (response.status !== 200) {
alert(response.msg)
return
}
let table = document.getElementById("table")
table.innerHTML = response.msg
}
window.comments = comments
async function likes() {
let response = await adminlikes();
if (response.status !== 200) {
alert(response.msg)
return
}
let table = document.getElementById("table")
table.innerHTML = response.msg
}
window.likes = likes
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

@ -1,6 +1,27 @@
const endpoint = '/api'
const fileRequest = async (url, file, method) => {
if (method === undefined) method = 'POST'
const response = await fetch(endpoint + url, {
method,
body: file,
headers: {}
});
if (response.status == 401) {
location.href = '/login'
}
const contentType = response.headers.get("content-type");
if (contentType && contentType.indexOf("application/json") !== -1) {
const json = await response.json()
return { status: response.status, msg: json.msg, json }
} else {
const msg = await response.text();
return { status: response.status, msg }
}
}
const request = async (url, body, method) => {
if (method === undefined) method = 'POST'
const response = await fetch(endpoint + url, {
method,
@ -10,48 +31,138 @@ const request = async (url, body, method) => {
}
});
if (response.status == 401) {
location.href = 'login'
location.href = '/login'
}
const contentType = response.headers.get("content-type");
if (contentType && contentType.indexOf("application/json") !== -1) {
const json = await response.json()
return { status: response.status, msg: json.msg, json }
} else {
const msg = await response.text();
return { status: response.status, msg }
}
const json = await response.json()
return { status: response.status, msg: json.msg, json }
}
const login = async (email, password) => {
export const login = async (email, password) => {
return await request('/auth/login', {email, password})
}
const register = async (firstname, lastname, email, password, gender, month, day, year) => {
return await request('/auth/register', {firstname, lastname, email, password, gender, month, day, year})
export const register = async (firstname, lastname, email, password, gender, day, month, year) => {
return await request('/auth/register', {firstname, lastname, email, password, gender, day, month, year})
}
const loadposts = async (page) => {
return await request('/posts/load', {page})
export const logout = async () => {
return await request('/auth/logout', {})
}
const loadusersposts = async (id) => {
return await request('/posts/user', {id})
export const loadpostspage = async (page) => {
return await request('/posts/page', {page})
}
const loadusers = async (ids) => {
export const loadcommentspage = async (page, post_id) => {
return await request('/posts/comments', {page, post_id})
}
export const loadusersposts = async (user_id, page) => {
return await request('/posts/user', {user_id, page})
}
export const loadusers = async (ids) => {
return await request('/users/load', {ids})
}
const loaduserspage = async (page) => {
export const loaduserspage = async (page) => {
return await request('/users/page', {page})
}
const loadself = async () => {
export const loadself = async () => {
return await request("/users/self", {})
}
const postcomment = async (id, content) => {
return await request('/posts/comment', {id, content}, 'PUT')
export const follow = async (state, user_id) => {
return await request('/users/follow', {state, user_id}, 'PUT')
}
const postlike = async (id, state) => {
return await request('/posts/like', {id, state}, 'PUT')
export const follow_status = async (user_id) => {
return await request('/users/follow', {user_id})
}
const createpost = async (content) => {
export const friends = async (user_id) => {
return await request('/users/friends', {user_id})
}
export const postcomment = async (post_id, content) => {
return await request('/posts/comment', {post_id, content}, 'PATCH')
}
export const postlike = async (post_id, state) => {
return await request('/posts/like', {post_id, state}, 'PATCH')
}
export const createpost = async (content) => {
return await request('/posts/create', {content})
}
}
export const adminauth = async (secret) => {
return await request('/admin/auth', {secret})
}
export const admincheck = async () => {
return await request('/admin/check', {})
}
export const adminquery = async (query) => {
return await request('/admin/query', {query})
}
export const adminposts = async () => {
return await request('/admin/posts', {})
}
export const adminusers = async () => {
return await request('/admin/users', {})
}
export const adminsessions = async () => {
return await request('/admin/sessions', {})
}
export const admincomments = async () => {
return await request('/admin/comments', {})
}
export const adminlikes = async () => {
return await request('/admin/likes', {})
}
export const updateavatar = async (file) => {
return await fileRequest('/users/avatar', file, 'PUT')
}
export const updatebanner = async (file) => {
return await fileRequest('/users/banner', file, 'PUT')
}
export const chatlist = async () => {
return await request('/chat/list', {})
}
export const chatcreate = async (name) => {
return await request('/chat/create', {name})
}
export const chatadd = async (email, room_id) => {
return await request('/chat/add', {email, room_id}, 'PATCH')
}
export const chatleave = async (room_id) => {
return await request('/chat/leave', {room_id}, 'DELETE')
}
export const chatsend = async (content, room_id) => {
return await request('/chat/send', {content, room_id})
}
export const chatload = async (newest_msg, page, room_id) => {
return await request('/chat/load', {newest_msg, page, room_id})
}

240
public/js/chat.js Normal file
View file

@ -0,0 +1,240 @@
import { body, div, span, p, parse } from './main.js'
import { loadself, chatlist, chatload, loadusers, chatcreate } from './api.js'
import { createRoomDisplay, header, parseMessage, parseRoom, parseUser, createSingleLineInput } from './components.js'
async function getUser(user_id) {
if (data.users[user_id]) {
return data.users[user_id]
} else {
let request = (await loadusers([user_id]))
if (request.status != 200) {
location.href = 'login'
} else {
data.users[user_id] = request.json[0]
return request.json[0]
}
}
}
async function parseMessageImpl(message) {
let user = await getUser(message.user_id)
return parseMessage(message, user)
}
async function onRoomClick(room) {
for (const room of Object.values(data.rooms)) {
room.display.style.display = 'none'
room.button.classList.remove('current')
}
room.display.style.display = ''
room.button.classList.add('current')
}
async function render() {
let new_body =
body({},
...header(false, false, true, data.self.user_id),
div({id: 'cent'},
div({id: 'sidebar'},
span({class: 'ltext'},
parse("Rooms")
),
createSingleLineInput(
{
type: 'text',
name: 'addRoom',
class: 'addRoom input',
style: 'flex-grow: 0; width: 80%; margin-left: auto; margin-right: auto;'
},
async (text) => {
let result = (await chatcreate(text))
if (result.status != 201) {
alert(result.msg)
return false
} else {
return true
}
}
),
),
div({id: 'center'})
)
)
document.body.replaceWith(new_body)
}
const data = {
self: {},
users: [],
rooms: {},
}
async function loadRoomPage(room) {
let request = (await chatload (
room.newest_msg,
room.page,
room.room_id
))
if (request.json == undefined) {
alert(request.msg)
return
}
let messages = room.display.getElementsByClassName('messages')[0]
for (const msg of request.json) {
room.messages.push(msg)
messages.appendChild(await parseMessageImpl(msg))
}
room.page++
return request.json.length > 0
}
async function loadRoom(room_id) {
let room = data.rooms[room_id]
let batch = []
for (const user_id of room.users) {
if (data.users[user_id]) continue
batch.push(user_id)
}
if (batch.length > 1) {
let request = (await loadusers(batch))
if (request.status != 200) {
location.href = '/login'
} else {
for (const user of request.json) {
data.users[user.user_id] = user
}
}
}
room.display = createRoomDisplay(room, loadRoomPage)
let displays = document.getElementById("center")
displays.appendChild(room.display)
let button = parseRoom(room, onRoomClick)
if (displays.children.length > 1) {
room.display.style.display = 'none'
} else {
button.classList.add('current')
}
room.page = 0
room.messages = []
if (room.newest_msg == undefined || room.newest_msg < 0)
room.newest_msg = Number.MAX_SAFE_INTEGER
await loadRoomPage(room)
room.newest_msg = Math.min(
...room.messages.map(m => m.message_id)
)
room.page = 0
let sidebar = document.getElementById("sidebar")
sidebar.appendChild(button)
room.button = button
if (!room.people) room.people = room.people = {}
let people = room.display.getElementsByClassName("roomDisplayPeople")[0]
for (const user_id of room.users) {
if (room.people[user_id]) continue
let user = await getUser(user_id)
let el = parseUser(user)
people.appendChild(el)
room.people[user_id] = el
}
}
async function onMessage(message) {
let event = JSON.parse(message.data)
switch (event.type) {
case "message": {
let room = data.rooms[event.room_id]
if (!room) return
let display = room.display
let messages = display.getElementsByClassName('messages')[0]
messages.prepend(await parseMessageImpl(event))
break;
}
case "add": {
let room = data.rooms[event.room.room_id]
if (!room) {
// we are the user being added
data.rooms[event.room.room_id] = event.room
loadRoom(event.room.room_id)
} else {
let display = room.display
let people = display.getElementsByClassName('roomDisplayPeople')[0]
if (!room.people[event.user_id]) {
let user = await getUser(event.user_id)
let el = parseUser(user)
people.appendChild(el)
room.people[event.user_id] = el
}
}
break;
}
case "leave": {
let room = data.rooms[event.room_id]
if (!room) return
if (room.people[event.user_id]) {
room.people[event.user_id].remove()
delete room.people[event.user_id]
}
if (event.user_id == data.self.user_id) {
room.display.remove()
room.button.remove()
delete data.rooms[event.room_id]
}
break;
}
case "typing": {
break;
}
default: {
console.warn("unhandled event: " + message.data)
break;
}
}
}
async function init() {
let request = (await loadself());
if (request.json == undefined) {
location.href = '/login'
return
}
data.self = request.json
data.users[data.self.user_id] = data.self
render()
let rooms = (await chatlist());
if (rooms.json === undefined) {
alert(rooms.msg)
} else {
for (const room of rooms.json) {
data.rooms[room.room_id] = room
loadRoom(room.room_id)
}
}
let socket = new WebSocket(window.location.protocol.replace("http", "ws") + "//" + location.host + "/api/chat/connect")
socket.addEventListener("message", onMessage);
}
init()

451
public/js/components.js Normal file
View file

@ -0,0 +1,451 @@
import { div, a, pfp, span, i, parse, parseDate, p, form, input, svg, path, parseMonth, g, button } from './main.js'
import { postlike, postcomment, loadcommentspage, chatsend, chatadd, chatleave } from './api.js';
window.parse = parse;
export function header(home, people, chat, user_id) {
return [
div({id: 'header'},
span({class: 'logo'},
a({href: '/', 'aria-label': 'xssbook.com'},
parse('xssbook')
)
),
div({class: 'buttons'},
a({id: 'home', class: home ? 'selected' : '', href: '/home', 'aria-label': 'xssbook home page'},
svg({viewBox: '0 0 28 28', fill: 'currentColor', height: '28', width: '28'},
path({d: "M25.825 12.29C25.824 12.289 25.823 12.288 25.821 12.286L15.027 2.937C14.752 2.675 14.392 2.527 13.989 2.521 13.608 2.527 13.248 2.675 13.001 2.912L2.175 12.29C1.756 12.658 1.629 13.245 1.868 13.759 2.079 14.215 2.567 14.479 3.069 14.479L5 14.479 5 23.729C5 24.695 5.784 25.479 6.75 25.479L11 25.479C11.552 25.479 12 25.031 12 24.479L12 18.309C12 18.126 12.148 17.979 12.33 17.979L15.67 17.979C15.852 17.979 16 18.126 16 18.309L16 24.479C16 25.031 16.448 25.479 17 25.479L21.25 25.479C22.217 25.479 23 24.695 23 23.729L23 14.479 24.931 14.479C25.433 14.479 25.921 14.215 26.132 13.759 26.371 13.245 26.244 12.658 25.825 12.29"})
)
),
a({id: 'people', class: people ? 'selected' : '', href: '/people', 'aria-label': 'xssbook people page'},
svg({viewBox: '0 0 28 28', fill: 'currentColor', height: '28', width: '28'},
path({d: "M10.5 4.5c-2.272 0-2.75 1.768-2.75 3.25C7.75 9.542 8.983 11 10.5 11s2.75-1.458 2.75-3.25c0-1.482-.478-3.25-2.75-3.25zm0 8c-2.344 0-4.25-2.131-4.25-4.75C6.25 4.776 7.839 3 10.5 3s4.25 1.776 4.25 4.75c0 2.619-1.906 4.75-4.25 4.75zm9.5-6c-1.41 0-2.125.841-2.125 2.5 0 1.378.953 2.5 2.125 2.5 1.172 0 2.125-1.122 2.125-2.5 0-1.659-.715-2.5-2.125-2.5zm0 6.5c-1.999 0-3.625-1.794-3.625-4 0-2.467 1.389-4 3.625-4 2.236 0 3.625 1.533 3.625 4 0 2.206-1.626 4-3.625 4zm4.622 8a.887.887 0 00.878-.894c0-2.54-2.043-4.606-4.555-4.606h-1.86c-.643 0-1.265.148-1.844.413a6.226 6.226 0 011.76 4.336V21h5.621zm-7.122.562v-1.313a4.755 4.755 0 00-4.749-4.749H8.25A4.755 4.755 0 003.5 20.249v1.313c0 .518.421.938.937.938h12.125c.517 0 .938-.42.938-.938zM20.945 14C24.285 14 27 16.739 27 20.106a2.388 2.388 0 01-2.378 2.394h-5.81a2.44 2.44 0 01-2.25 1.5H4.437A2.44 2.44 0 012 21.562v-1.313A6.256 6.256 0 018.25 14h4.501a6.2 6.2 0 013.218.902A5.932 5.932 0 0119.084 14h1.861z"})
)
),
a({id: 'chat', class: chat ? 'selected' : '', href: '/chat', 'aria-label': 'xssbook chat page'},
svg({viewBox: '0 0 512 512', fill: 'currentColor', height: '28', width: '28'},
g({transform: "translate(0.000000,512.000000) scale(0.100000,-0.100000)", fill: "#fffffff", stroke: "none"},
path({d: "M1731 4799 c-240 -27 -467 -93 -687 -199 -992 -481 -1340 -1619 -768 -2512 l43 -66 -150 -469 c-82 -257 -149 -481 -149 -496 0 -73 75 -147 150 -147 31 0 215 89 725 350 l230 118 90 -35 c109 -42 279 -87 395 -104 83 -12 86 -14 147 -70 172 -159 313 -256 514 -354 507 -245 1103 -270 1644 -68 l81 30 449 -229 c291 -148 464 -232 491 -235 80 -10 164 63 164 143 0 15 -67 238 -149 496 l-150 469 43 67 c330 511 364 1151 90 1689 -268 524 -818 913 -1421 1003 -43 7 -83 15 -89 18 -7 4 -54 45 -106 92 -143 128 -266 212 -443 299 -215 107 -352 152 -580 191 -139 25 -430 34 -564 19z m407 -300 c123 -13 261 -43 377 -80 100 -33 300 -127 385 -182 l54 -35 -39 -7 c-273 -43 -442 -94 -645 -191 -911 -439 -1295 -1442 -887 -2317 25 -53 41 -97 36 -97 -6 0 -67 22 -136 50 -78 31 -141 50 -166 50 -32 0 -104 -33 -363 -165 -178 -91 -325 -165 -327 -165 -3 0 42 145 100 323 57 177 104 340 105 362 1 47 -6 63 -84 178 -107 157 -180 326 -220 510 -29 135 -31 396 -5 530 119 596 612 1070 1253 1206 186 40 380 50 562 30z m1220 -600 c223 -24 404 -78 607 -179 436 -217 742 -607 832 -1059 24 -119 24 -384 0 -504 -39 -194 -130 -405 -244 -563 -31 -43 -60 -94 -64 -112 -9 -42 -4 -61 114 -429 52 -161 93 -293 91 -293 -2 0 -149 74 -327 165 -263 134 -331 165 -365 165 -26 0 -82 -17 -149 -44 -528 -216 -1130 -170 -1608 124 -163 100 -335 258 -452 417 -115 155 -211 374 -250 570 -24 122 -24 384 0 506 106 530 514 974 1062 1155 239 79 508 108 753 81z"}),
path({d: "M2488 2539 c-43 -22 -78 -81 -78 -129 0 -50 35 -107 80 -130 75 -38 157 -14 198 58 27 49 28 91 2 142 -37 73 -127 99 -202 59z"}),
path({d: "M3088 2539 c-43 -22 -78 -81 -78 -129 0 -50 35 -107 80 -130 75 -38 157 -14 198 58 27 49 28 91 2 142 -37 73 -127 99 -202 59z"}),
path({d: "M3688 2539 c-43 -22 -78 -81 -78 -129 0 -50 35 -107 80 -130 49 -25 90 -25 138 -1 43 22 82 84 82 131 0 47 -39 109 -82 131 -47 24 -93 24 -140 -2z"})
)
)
)
),
a({class: 'pfp', id: 'profile', href: '/profile', 'aria-label': 'your xssbook profile'},
user_id === undefined ? parse('') : pfp(user_id)
)
),
div({class: 'spacer'})
]
}
export function parsePost(post, users, self) {
let content = post.content
let date = post.date
let likes = post.likes
let author = users[post.user_id]
let comments = []
for (const comment of post.comments) {
comments.push(parseComment(comment, users))
}
let liked = post.liked;
var page = 0
return (
div({class: 'post', postid: post.post_id},
div({class: 'postheader'},
a({class: 'pfp', href: `/profile?id=${author.user_id}`, 'aria-label': 'Post author profile like'},
pfp(author.user_id)
),
div({class: 'postname'},
span({class: 'bold'},
parse(author.firstname + ' ' + author.lastname)
),
span({class: 'gtext mtext'},
parse(parseDate(new Date(date)))
)
)
),
p({class: 'mtext', style: "color: var(--text);"},
parse(content.replace(/\n/g,'<br>'))
),
span({class: 'gtext mtext likes'},
parse(`${likes} Likes`)
),
div({class: 'fullline nb'}),
div({class: 'postbuttons'},
span({class: 'likeclicky', onclick: async (event) => {
var post = event.target;
while(post.parentElement) {
post = post.parentElement
if (post.getAttribute('postid')) {
break;
}
}
let likes = post.getElementsByClassName('likes')[0]
let post_id = parseInt(post.getAttribute('postid'))
let like_text = likes.textContent;
let like_count = parseInt(like_text.substring(0, like_text.indexOf(" Likes")))
const response = await postlike(post_id, !liked)
if (response.status !== 200) { return }
liked = !liked
let el = post.getElementsByClassName('liketoggle')
if (liked) {
el[0].classList.add('blue')
el[1].classList.add('blue')
like_count++
} else {
el[0].classList.remove('blue')
el[1].classList.remove('blue')
like_count--
}
likes.textContent = like_count + " Likes"
}},
i({class: `liketoggle icons like ${liked ? 'blue' : ''}`}),
span({class: `liketoggle bold ${liked ? 'blue' : ''}`},
parse('Like')
)
),
span({onclick: (event) => {
var post = event.target;
while(post.parentElement) {
post = post.parentElement
if (post.getAttribute('postid')) {
break;
}
}
post.getElementsByClassName('comments')[0].getElementsByClassName('newcomment')[0].focus()
}},
i({class: 'icons comm'}),
span({class: 'bold'},
parse('Comment')
)
)
),
div({class: 'fullline nb', style: 'margin-top: 0'}),
div({class: 'comments'},
div({class: 'comment commentsubmit', style: 'margin-top: 0'}),
...comments,
comments.length > 0 ?
div({class: 'cload', style: 'justify-content: inherit; margin-left: 3.5em; font-size: .9em; margin-bottom: -.5em;'},
span({class: 'blod gtext', onclick: async (event) => {
page++;
const response = await loadcommentspage(page, post.post_id)
if (response.status != 200) { return };
let comments = response.json
for (const comment of comments) {
event.target.parentElement.parentElement.insertBefore(
parseComment(comment, users),
event.target.parentElement
)
}
if (comments.length < 5) {
event.target.parentElement.remove()
}
}},
parse('Load more comments')
)
)
: parse(''),
div({class: 'comment commentsubmit'},
a({class: 'pfp', href: '/profile', 'aria-label': 'Your profile link'},
pfp(self.user_id)
),
form({onsubmit: async (event) => {
event.preventDefault()
let text = event.target.elements.text.value.trim();
if (text.length < 1) {
return
}
let post = event.target.parentElement.parentElement.parentElement
let post_id = parseInt(post.getAttribute('postid'))
const response = await postcomment(post_id, text)
if (response.status != 200) { return };
let comment = {
user_id: self.user_id,
content: text,
date: Date.now()
}
let comments = post.getElementsByClassName('comments')[0]
let load = comments.getElementsByClassName('cload')[0];
if (load == undefined) {
load = comments.lastChild
}
comments.insertBefore(
parseComment(comment, users),
load
)
event.target.elements.text.value = ''
}},
input({type: 'text', name: 'text', placeholder: 'Write a comment', class: 'newcomment'})
)
)
)
)
)
}
export function parseComment(comment, users) {
let author = users[comment.user_id]
return (
div({class: 'comment'},
a({class: 'pfp', href: `/profile?id=${comment.user_id}`, 'aria-label': 'Comment author profile link'},
pfp(comment.user_id)
),
span({},
span({class: 'bold mtext'},
parse(author.firstname + ' ' + author.lastname),
span({class: 'gtext mtext', style: 'margin-left: 1em'},
parse(parseDate(new Date(comment.date)))
)
),
p({class: 'mtext'},
parse(comment.content)
)
)
)
)
}
export function parseUser(user) {
return (
a({class: 'person', href: `/profile?id=${user.user_id}`, 'aria-label': 'User profile link'},
div({class: 'profile'},
pfp(user.user_id)
),
div({class: 'info'},
span({class: 'bold ltext'},
parse(user.firstname + ' ' + user.lastname)
),
span({class: 'gtext'},
parse('Joined ' + parseDate(new Date(user.date)))
),
span({class: 'gtext'},
parse('Gender:' + user.gender)
),
span({class: 'gtext'},
parse('Birthday: ' + parseMonth(user.month) + ' ' + user.day + ', ' + user.year)
),
span({class: 'gtext', style: 'margin-bottom: -100px'},
parse('User ID: ' + user.user_id)
)
)
)
)
}
const stringToColor = (str) => {
let hash = 0;
str.split('').forEach(char => {
hash = char.charCodeAt(0) + ((hash << 5) - hash)
})
let color = '#'
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xff
color += value.toString(16).padStart(2, '0')
}
return color
}
export function parseRoom(room, callback) {
let dspName = room.name[0].toUpperCase()
let color = stringToColor(room.room_id + room.name + room.room_id)
return (
div({class: 'room', onclick: () => {
callback(room)
}},
div({class: 'room-icon ltext', style: `background-color: ${color}`},
span({}, parse(dspName))
),
div({class: 'room-name ltext'},
span({}, parse(room.name))
),
div({
class: 'close',
onclick: async () => {
let request = (await chatleave(room.room_id))
if (request.status != 200) {
alert(request.msg)
}
}
})
)
)
}
export function parseRooms(rooms, callback) {
let ret = []
for (const room of rooms) {
ret.push(parseRoom(room, callback))
}
return ret
}
export function createMultiLineInput(attributes, onSubmit) {
let area = span({
...attributes,
role: 'textbox',
contenteditable: '',
onkeydown: async (event) => {
if (event.keyCode == 13 && !event.shiftKey) {
event.preventDefault()
let text = area.innerHTML.trim()
.replaceAll("&amp;", '&')
.replaceAll("&lt;", '<')
.replaceAll("&gt;", '>')
.replaceAll("&quot;", '"')
.replaceAll("&#39;", "'")
text = text.replaceAll("\n", "<br>")
if (text.length < 1) return
if (await onSubmit(text)) {
area.textContent = ''
}
}
},
})
return area
}
export function createSingleLineInput(attributes, onSubmit) {
let area = span({
...attributes,
role: 'textbox',
contenteditable: '',
onkeydown: async (event) => {
if (event.keyCode == 13 && !event.shiftKey) {
event.preventDefault()
let text = area.innerHTML.trim()
text = text.replaceAll("\n", "<br>")
if (text.length < 1) return
if (await onSubmit(text)) {
area.textContent = ''
}
} else if (event.keyCode == 13) {
event.preventDefault()
}
},
})
return area
}
export function createRoomDisplay(room, loadMessageCallback) {
let buttonEl = button({
class: 'loadMessages input',
style: 'flex-grow: 0',
onclick: async () => {
if (!await loadMessageCallback(room)) {
buttonEl.remove()
}
}
},
parse('Load Previous')
)
return (
div({class: 'roomDisplay'},
div({class: 'roomDisplayCenter'},
buttonEl,
div({class: 'messages'}),
createMultiLineInput(
{
type: 'text',
name: 'messageContent',
class: 'messageContent input',
},
async (text) => {
let result = (await chatsend(text, room.room_id))
if (result.status != 201) {
alert(result.msg)
return false
} else {
return true
}
}
),
),
div({class: 'roomDisplayPeople'},
span({class: 'ltext'},
parse("People"),
),
createSingleLineInput(
{
type: 'text',
name: 'addUser',
class: 'addUser input',
style: 'flex-grow: 0; width: 80%'
},
async (text) => {
let result = (await chatadd(text, room.room_id))
if (result.status != 200) {
alert(result.msg)
return false
} else {
return true
}
}
)
)
)
)
}
export function parseMessage(message, user) {
return (
div({class: 'message'},
a({class: 'message-pfp', href: `/profile?id=${message.user_id}`},
pfp(message.user_id)
),
div({class: 'message-content'},
span({class: 'message-name ltext'},
parse(user.firstname + ' ' + user.lastname)
),
p({class: 'message-text ltext'},
parse(message.content)
)
)
)
)
}

View file

@ -1,25 +0,0 @@
function header(home, people) {
const html = `
<div id="header">
<span class="logo"><a href="/">xssbook</a></span>
<div class="buttons">
<a id="home" ${home ? 'class="selected"' : ''} href="home">
<svg viewBox="0 0 28 28" fill="currentColor" height="28" width="28">
<path d="M25.825 12.29C25.824 12.289 25.823 12.288 25.821 12.286L15.027 2.937C14.752 2.675 14.392 2.527 13.989 2.521 13.608 2.527 13.248 2.675 13.001 2.912L2.175 12.29C1.756 12.658 1.629 13.245 1.868 13.759 2.079 14.215 2.567 14.479 3.069 14.479L5 14.479 5 23.729C5 24.695 5.784 25.479 6.75 25.479L11 25.479C11.552 25.479 12 25.031 12 24.479L12 18.309C12 18.126 12.148 17.979 12.33 17.979L15.67 17.979C15.852 17.979 16 18.126 16 18.309L16 24.479C16 25.031 16.448 25.479 17 25.479L21.25 25.479C22.217 25.479 23 24.695 23 23.729L23 14.479 24.931 14.479C25.433 14.479 25.921 14.215 26.132 13.759 26.371 13.245 26.244 12.658 25.825 12.29"></path>
</svg>
</a>
<a id="people" ${people ? 'class="selected"' : ''} href="people">
<svg viewBox="0 0 28 28" fill="currentColor" height="28" width="28">
<path d="M10.5 4.5c-2.272 0-2.75 1.768-2.75 3.25C7.75 9.542 8.983 11 10.5 11s2.75-1.458 2.75-3.25c0-1.482-.478-3.25-2.75-3.25zm0 8c-2.344 0-4.25-2.131-4.25-4.75C6.25 4.776 7.839 3 10.5 3s4.25 1.776 4.25 4.75c0 2.619-1.906 4.75-4.25 4.75zm9.5-6c-1.41 0-2.125.841-2.125 2.5 0 1.378.953 2.5 2.125 2.5 1.172 0 2.125-1.122 2.125-2.5 0-1.659-.715-2.5-2.125-2.5zm0 6.5c-1.999 0-3.625-1.794-3.625-4 0-2.467 1.389-4 3.625-4 2.236 0 3.625 1.533 3.625 4 0 2.206-1.626 4-3.625 4zm4.622 8a.887.887 0 00.878-.894c0-2.54-2.043-4.606-4.555-4.606h-1.86c-.643 0-1.265.148-1.844.413a6.226 6.226 0 011.76 4.336V21h5.621zm-7.122.562v-1.313a4.755 4.755 0 00-4.749-4.749H8.25A4.755 4.755 0 003.5 20.249v1.313c0 .518.421.938.937.938h12.125c.517 0 .938-.42.938-.938zM20.945 14C24.285 14 27 16.739 27 20.106a2.388 2.388 0 01-2.378 2.394h-5.81a2.44 2.44 0 01-2.25 1.5H4.437A2.44 2.44 0 012 21.562v-1.313A6.256 6.256 0 018.25 14h4.501a6.2 6.2 0 013.218.902A5.932 5.932 0 0119.084 14h1.861z"></path>
</svg>
</a>
</div>
<a class="pfp" id="profile" hreF="profile">
</a>
</div>
<div class="spacer"></div>
`
add(html, 'header')
}

View file

@ -1,230 +1,155 @@
function parseDate(date) {
var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return months[date.getUTCMonth()] + ' ' + date.getUTCDate() + ', ' + date.getUTCFullYear() + ' ' + date.toLocaleTimeString();
}
function parseComment(comment) {
const author = data.users[comment.user]
if (author === undefined) {
author = {}
}
const html = `
<div class="comment">
<a class="pfp">
</a>
<span>
<span class="bold mtext">${author.first + ' ' + author.last}</span>
<p class="mtext">${comment.content}</p>
</span>
</div>
`
return html
}
function parsePost(post) {
const author = data.users[post.user]
if (author === undefined) {
author = {}
}
const html = `
<div class="post" postid=${post.id}>
<div class="postheader">
<a class="pfp">
</a>
<div class="postname">
<span class="bold">${author.first + ' ' + author.last}</span>
<span class="gtext mtext">${parseDate(new Date(post.date))}</span>
</div>
</div>
<p class="mtext">
${post.content.replace(/\n/g,'<br>')}
</p>
<span class="gtext mtext">
${Object.keys(post.likes).map(k => post.likes[k]).filter(v => v !== false).length} Likes
</span>
<div class="fullline nb"></div>
<div class="postbuttons">
<span onclick="like(this)">
<i class="icons like ${post.likes[data.user.id] ? 'blue' : ''}"></i>
<span class="bold ${post.likes[data.user.id] ? 'blue' : ''}">Like</span>
</span>
<span onclick="this.parentElement.parentElement.getElementsByClassName('newcomment')[0].focus()">
<i class="icons comm"></i>
<span class="bold">Comment</span>
</span>
</div>
<div id="comments">
<div class="fullline" style="margin-top: 0"></div>
${post.comments.map(parseComment).join('')}
<div class="comment">
<a class="pfp" href="profile">
</a>
<form onsubmit="comment(event)">
<input type="text" name="text" placeholder="Write a comment..." id="newcomment" class="newcomment">
</form>
</div>
</div>
</div>
`
return html
}
function getPost(id) {
for (let i = 0; i < data.posts.length; i++) {
if (data.posts[i].id === id) {
return id
}
}
return -1
}
async function like(span) {
const id = parseInt(span.parentElement.parentElement.getAttribute('postid'))
const post = data.posts[getPost(id)]
const current = post.likes[data.user.id]
const response = await postlike(id, !current)
if (response.status != 200) return;
post.likes[data.user.id] = !currentg
render()
}
async function comment(event) {
event.preventDefault();
const text = event.target.elements.text.value.trim();
if (text.length < 1) return;
const id = parseInt(event.target.parentElement.parentElement.parentElement.getAttribute('postid'))
var index = getPost(id);
if (index === -1) return;
const response = await postcomment(id, text)
if (response.status != 200) return;
event.target.elements.text.value = '';
data.posts[index].comments.push({
user: data.user.id,
content: text
})
render()
}
import { div, pfp, p, parse, button, body, a, textarea, span, crawl } from './main.js'
import { loadself, loadpostspage, createpost, loadusers } from './api.js'
import { parsePost, header } from './components.js'
async function post() {
const text = document.getElementById("text").value.trim()
const error = document.getElementsByClassName('error')[0]
const posts_block = document.getElementById("posts")
if (text.length < 1) return;
const response = await createpost(text);
if (response.status != 200) {
if (response.status != 201) {
error.innerHTML = response.msg
return;
}
error.innerHTML = '';
data.posts.unshift({
id: response.msg,
user: data.user.id,
let post = {
post_id: response.json.post_id,
user_id: data.self.user_id,
date: Date.now(),
content: text,
likes: [],
comments: []
})
render()
}
data.posts.unshift(post)
let html = parsePost(post, data.users, data.self)
posts_block.insertBefore(
html,
posts_block.firstChild
)
document.getElementById('popup').classList.add('hidden')
}
function render() {
const html = `
<div id="posts">
<div class="create">
<a class="pfp" href="profile">
</a>
<button class="pfp">
<p class="gtext" onclick="document.getElementById('popup').classList.remove('hidden')">
What's on your mind, ${data.user.first}?
</p>
</button>
</div>
${data.posts.map(p => parsePost(p)).join('')}
</div>
`
let new_body =
body({},
...header(true, false, false, data.self.user_id),
div({id: 'create'},
div({class: 'create'},
a({class: 'pfp', href: '/profile'},
pfp(data.self.user_id)
),
button({class: 'pfp'},
p({class: 'gtext', onclick: () => document.getElementById('popup').classList.remove('hidden')},
parse(`What's on your mind, ${data.self.firstname}`)
)
)
)
),
div({id: 'posts'},
...data.posts.map(p => parsePost(p, data.users, data.self))
),
div({id: 'popup', class: 'hidden'},
div({class: 'createpost'},
div({class: 'close', onclick: () => document.getElementById('popup').classList.add('hidden')}),
span({class: 'ltext ctext bold'},
parse('Create post')
),
div({class: 'fullline'}),
div({class: 'postheader'},
a({class: 'pfp', style: 'cursor: auto', href: '/profile'},
pfp(data.self.user_id)
),
div({class: 'postname'},
span({class: 'bold'},
parse(data.self.firstname + ' ' + data.self.lastname)
),
span({class: 'gtext mtext'},
parse('Now')
)
)
),
textarea({type: 'text', name: 'text', id: 'text', placeholder: `What's on your mind, ${data.self.firstname}?`}),
span({class: 'error ctext', style: 'padding-bottom: 15px; margin-top: -30px'}),
button({class: 'primary', onclick: post},
parse('Post')
)
)
),
div({id: 'load'},
span({class: 'blod gtext', onclick: async () => {
const posts = await load()
data.posts.push(... posts)
const el = document.getElementById("posts")
for (const post of posts) {
el.appendChild(
parsePost(post, data.users, data.self)
)
}
}},
parse('Load more posts')
)
)
)
add(html, 'posts')
document.body.replaceWith(new_body)
const popup = `
<div id="popup" class="hidden">
<div class="createpost">
<div class="close" onclick="document.getElementById('popup').classList.add('hidden')"></div>
<span class="ltext ctext bold">Create post</span>
<div class="fullline"></div>
<div class="postheader">
<a class="pfp" style="cursor: auto">
</a>
<div class="postname">
<span class="bold">${data.user.first + ' ' + data.user.last}</span>
<span class="gtext mtext">Now</span>
</div>
</div>
<textarea type="text" name="text" id="text" placeholder="What's on your mind, ${data.user.first}?"></textarea>
<span class="error ctext" style="padding-bottom: 15px; margin-top: -30px;"></span>
<button class="primary" onclick="post(this)">Post</button>
</div>
</div>
`
add(popup, 'popup')
const load = `
<div id="load">
<a class="bold gtext" onclick="load()">Load more posts</a>
</div>
`
if (page !== -1) {
add(load, 'load')
} else {
remove('load')
}
}
var page = 0
const data = {
user: {},
self: {},
users: {},
posts: []
}
async function load() {
const posts = (await loadposts(page)).json
const posts = (await loadpostspage(page)).json
if (posts.length === 0) {
page = -1
let load = document.getElementById('load')
if (load)
load.remove()
return []
} else {
page++
}
data.posts.push(... posts)
const batch = []
for (const post of posts) {
for(const comment of post.comments) {
if (data.users[comment.user] !== undefined) continue
if (batch.includes(comment.user)) continue
batch.push(comment.user)
const batch = Array.from(new Set(crawl('user_id', posts))).filter(id => data.users[id] == undefined)
if (batch.length != 0) {
const users = (await loadusers(batch)).json
for (const user of users) {
data.users[user.user_id] = user
}
if (data.users[post.user] !== undefined) continue
if (batch.includes(post.user)) continue
batch.push(post.user)
}
const users = (await loadusers(batch)).json
for (const id in users) {
data.users[id] = users[id]
return posts
}
async function init() {
let request = (await loadself());
data.self = request.json
if (request.json == undefined) {
location.href = '/login'
return
}
data.users[data.self.user_id] = data.self
const posts = await load()
data.posts.push(... posts)
render()
}
async function init() {
header(true, false)
data.user = (await loadself()).json
data.users[data.user.id] = data.user
load()
}
init()

View file

@ -1,3 +1,5 @@
import { login, register } from './api.js'
async function onlogin() {
const email = document.getElementById('email').value
const password = document.getElementById('pass').value
@ -10,6 +12,8 @@ async function onlogin() {
}
}
window.onlogin = onlogin
async function onregister() {
const first = document.getElementById('firstname').value
const last = document.getElementById('lastname').value
@ -19,11 +23,13 @@ async function onregister() {
const day = document.getElementById('day').value
const year = document.getElementById('year').value
const gender = document.querySelector('input[name="gender"]:checked').value
const response = await register(first, last, email, pass, gender, month, parseInt(day), parseInt(year))
if (response.status !== 200) {
const response = await register(first, last, email, pass, gender, parseInt(day), parseInt(month), parseInt(year))
if (response.status !== 201) {
const error = document.getElementsByClassName('error')[1]
error.innerHTML = response.msg
} else {
location.href = '/home'
}
}
}
window.onregister = onregister

View file

@ -1,22 +1,145 @@
var range;
function createElement(name, attrs, ...children) {
const el = document.createElement(name);
function add(html, id) {
const old = document.getElementById(id)
if (old !== null) {
old.remove()
for (const attr in attrs) {
if(attr.startsWith("on")) {
el[attr] = attrs[attr];
} else {
el.setAttribute(attr, attrs[attr])
}
}
if (range === undefined) {
var range = document.createRange()
range.setStart(document.body, 0)
for (const child of children) {
if (child == null) {
continue
}
el.appendChild(child)
}
document.body.appendChild(
range.createContextualFragment(html)
)
return el
}
function remove(id) {
const old = document.getElementById(id)
if (old !== null) {
old.remove()
export function createElementNS(name, attrs, ...children) {
var svgns = "http://www.w3.org/2000/svg";
var el = document.createElementNS(svgns, name);
for (const attr in attrs) {
if(attr.startsWith("on")) {
el[attr] = attrs[attr];
} else {
el.setAttribute(attr, attrs[attr])
}
}
}
for (const child of children) {
if (child == null) {
continue
}
el.appendChild(child)
}
return el
}
export function p(attrs, ...children) {
return createElement("p", attrs, ...children)
}
export function span(attrs, ...children) {
return createElement("span", attrs, ...children)
}
export function div(attrs, ...children) {
return createElement("div", attrs, ...children)
}
export function a(attrs, ...children) {
return createElement("a", attrs, ...children)
}
export function i(attrs, ...children) {
return createElement("i", attrs, ...children)
}
export function form(attrs, ...children) {
return createElement("form", attrs, ...children)
}
export function img(alt, attrs, ...children) {
attrs['onerror'] = (event) => event.target.remove()
attrs['alt'] = alt
return createElement("img", attrs, ...children)
}
export function input(attrs, ...children) {
return createElement("input", attrs, ...children)
}
export function button(attrs, ...children) {
return createElement("button", attrs, ...children)
}
export function path(attrs, ...children) {
return createElementNS("path", attrs, ...children)
}
export function g(attrs, ...children) {
return createElementNS("g", attrs, ...children)
}
export function svg(attrs, ...children) {
return createElementNS("svg", attrs, ...children)
}
export function body(attrs, ...children) {
return createElement("body", attrs, ...children)
}
export function textarea(attrs, ...children) {
return createElement("textarea", attrs, ...children)
}
export function parse(html) {
return document.createRange().createContextualFragment(html);
}
export function pfp(id) {
return img('pfp', {src: `/image/avatar?user_id=${id}`})
}
export function banner(id) {
return img('banner', {src: `/image/banner?user_id=${id}`})
}
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const letters = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i',
'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'];
export function parseMonth(month) {
if (month > -1 && month < 26) {
return months[month]
} else {
let first = letters[month%26].toUpperCase()
let middle = letters[month*13%26]
let last = letters[month*50%26]
return first + middle + last
}
}
export function parseDate(date) {
return parseMonth(date.getUTCMonth()) + ' ' + date.getUTCDate() + ', ' + date.getUTCFullYear() + ' ' + date.toLocaleTimeString();
}
export function crawl(key, object) {
let data = []
for (const k in object) {
if (typeof object[k] === 'object') {
data.push(...crawl(key, object[k]))
} else if (k == key) {
data.push(object[k])
}
}
return data
}

View file

@ -1,65 +1,65 @@
function parseDate(date) {
var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return months[date.getUTCMonth()] + ' ' + date.getUTCDate() + ', ' + date.getUTCFullYear() + ' ' + date.toLocaleTimeString();
}
function parseUser(user) {
const html = `
<a class="person" href="/profile?id=${user.id}">
<div class="profile">
</div>
<div class="info">
<span class="bold ltext">${user.first + ' ' + user.last}</span>
<span class="gtext">Joined ${parseDate(new Date(user.date))}</span>
<span class="gtext">Gender: ${user.gender}</span>
<span class="gtext">Birthday: ${user.month + ' ' + user.day + ', ' + user.year}</span>
<span class="gtext" style="margin-bottom: -100px;">User ID: ${user.id}</span>
</div>
</a>
`
return html
}
import { div, body, span, parse } from './main.js'
import { loadself, loaduserspage } from './api.js'
import { header, parseUser } from './components.js'
function render() {
const html = `
<div id="users">
${data.users.map(u => parseUser(u)).join('')}
</div>
`
let new_body =
body({},
...header(false, true, false, data.self.user_id),
div({id: 'users'},
...data.users.map(u => parseUser(u))
),
div({id: 'load'},
span({class: 'bold gtext', onclick: async () => {
let users = await load()
add(html, 'users')
let el = document.getElementById("users")
for (const user of users) {
el.appendChild(parseUser(user))
}
}},
parse("Load more users")
)
)
)
const load = `
<div id="load">
<a class="bold gtext" onclick="load()">Load more users</a>
</div>
`
if (page !== -1) {
add(load, 'load')
} else {
remove('load')
}
document.body.replaceWith(new_body)
}
var page = 0
var data = {
users: []
const data = {
users: [],
self: {}
}
async function load() {
const users = (await loaduserspage(page)).json
if (users.length === 0) {
page = -1
document.getElementById('load').remove()
return []
} else {
page++
}
return users
}
async function init() {
let request = (await loadself());
if (request.json == undefined) {
location.href = '/login'
return
}
data.self = request.json
const users = await load()
data.users.push(... users)
render()
}
header(false, true)
load()
init()

View file

@ -1,88 +1,365 @@
function render() {
const html = `
<div id="top">
<div id="banner">
<div>
</div>
</div>
<div id="info">
<div class="face">
</div>
<div class="infodata">
<span class="bold ltext">${data.user.first + ' ' + data.user.last}</span>
<span class="gtext">Joined ${parseDate(new Date(data.user.date))}</span>
</div>
</div>
<div class="fullline" style="width: 80em; margin-bottom: 0;"></div>
<div class="profilebuttons">
<button class="${posts ? 'selected' : ''}" onclick="posts = true; render()">
Posts
</button>
<button class="${posts ? '' : 'selected'}" onclick="posts = false; render()">
About
</button>
</div>
</div>
`
import { div, pfp, banner, parse, button, body, a, span, crawl, parseDate, parseMonth } from './main.js'
import { loadself, loadusers, loadusersposts, updateavatar, updatebanner, logout, follow, follow_status, friends } from './api.js'
import { parsePost, parseUser, header } from './components.js'
add(html, 'top')
function swap(tab) {
let buttons = []
buttons[0] = document.querySelector("#profilepostbutton");
buttons[1] = document.querySelector("#profileaboutbutton");
buttons[2] = document.querySelector("#profilefriendsbutton");
buttons[3] = document.querySelector("#profilefollowersbutton");
buttons[4] = document.querySelector("#profilefollowingbutton");
const postsh = `
<div id="posts" class="${posts ? '' : 'hidden'}">
${data.posts.map(p => parsePost(p)).join('')}
</div>
`
let sections = []
sections[0] = document.querySelector("#posts");
sections[1] = document.querySelector("#about");
sections[2] = document.querySelector("#friends");
sections[3] = document.querySelector("#followers");
sections[4] = document.querySelector("#following");
add(postsh, 'posts')
let load = document.querySelector(".loadp");
const about = `
<div id="about" class="post ${posts ? 'hidden' : ''}">
<span class="bold ltext">About</span>
<div class="data">
<span class="gtext bold">Name: ${data.user.first + ' ' + data.user.last}</span>
<span class="gtext bold">Email: ${data.user.email}</span>
<span class="gtext bold">Gender: ${data.user.gender}</span>
<span class="gtext bold">Birthday: ${data.user.month + ' ' + data.user.day + ', ' + data.user.year}</span>
<span class="gtext bold">User ID: ${data.user.id}</span>
</div>
</div>
`
for (const i in buttons) {
if (i == tab) {
buttons[i].classList.add("selected")
sections[i].classList.remove("hidden")
} else {
buttons[i].classList.remove("selected")
sections[i].classList.add("hidden")
}
}
add(about, 'about')
if (load) {
if (tab == 0) {
load.classList.remove("hidden")
} else {
load.classList.add("hidden")
}
}
}
var posts = true
function changeimage(fn) {
async function load() {
header(false, false)
var input = document.createElement('input')
input.type = 'file'
input.accept= 'image/png'
input.onchange = async (e) => {
var popup = document.getElementById("popup")
var loader = popup.getElementsByClassName("loading")[0]
var message = popup.getElementsByClassName("message")[0]
loader.classList.add("hidden")
message.innerHTML = '';
popup.classList.remove("hidden")
var file = e.target.files[0];
if (file.type !== 'image/png') {
message.innerHTML = 'Image must be a PNG';
return
}
loader.classList.remove("hidden")
let response = await fn(file);
loader.classList.add("hidden")
message.innerHTML = response.msg
}
input.click();
}
function status_text(status) {
switch (status) {
case 1:
return 'Following ✓'
case 2:
return 'Follow Back'
case 3:
return 'Friends ✓'
default:
return 'Follow'
}
}
async function render() {
let status;
if (!isself) {
let response = await follow_status(data.user.user_id)
if (response.status == 200) {
status = parseInt(response.msg)
} else {
status = 0;
}
}
let response = (await friends(data.user.user_id)).json
if (response == undefined) {
response = []
}
let friends_arr = response[0];
let followers = response[1];
let following = response[2];
let new_body =
body({},
...header(false, false, false, data.self.user_id),
div({id: 'top'},
div({id: 'banner'},
div({class: 'bg'},
banner(data.user.user_id)
),
isself ? div({class: 'changebanner', onclick: () => changeimage(updatebanner)}) : parse(''),
),
div({id: 'info'},
div({class: 'face'},
pfp(data.user.user_id),
isself ? div({class: 'changeavatar', onclick: () => changeimage(updateavatar)}) : parse(''),
),
div({class: 'infodata'},
span({class: 'bold ltext'},
parse(data.user.firstname + ' ' + data.user.lastname)
),
span({class: 'gtext'},
parse('Joined ' + parseDate(new Date(data.user.date)))
)
),
!isself ?
div({class: 'right'},
div({class: `follow ${status == 3 ? 'friend' : ''}`, onclick: async (event) => {
let button = event.target
if (button.tagName == 'SPAN') {
button = button.parentElement
}
let response
if (status % 2 == 0) {
response = await follow(true, data.user.user_id);
} else {
response = await follow(false, data.user.user_id);
}
if (response.status == 200) {
status = parseInt(response.msg)
} else {
return
}
button.firstChild.innerHTML = status_text(status)
if (status == 3) {
button.classList.add('friend')
} else {
button.classList.remove('friend')
}
}},
span({class: 'gtext'},
parse(status_text(status))
)
)
)
: parse('')
),
div({class: 'fullline', style: 'width: 80em; margin-bottom: 0; z-index: 0;'}),
div({class: 'profilebuttons'},
button({id: 'profilepostbutton', class: 'selected', onclick: () => swap(0)},
parse('Posts')
),
button({id: 'profileaboutbutton', onclick: () => swap(1)},
parse('About')
),
button({id: 'profilefriendsbutton', onclick: () => swap(2)},
parse('Friends')
),
button({id: 'profilefollowersbutton', onclick: () => swap(3)},
parse('Followers')
),
button({id: 'profilefollowingbutton', onclick: () => swap(4)},
parse('Following')
),
div({style: 'flex: 20'}),
isself ? button({class: 'logout', onclick: async () => {
const response = await logout()
if (response.status != 200) return;
location.href = '/login'
}},
parse('Logout')
)
: parse('')
)
),
div({id: 'posts'},
...data.posts.map(p => parsePost(p, data.users, data.self))
),
div({id: 'load'},
a({class: 'loadp bold gtext', onclick: async () => {
const posts = await load()
data.posts.push(... posts)
const el = document.getElementById("posts")
for (const post of posts) {
el.appendChild(
parsePost(post, data.users, data.self)
)
}
}},
parse('Load more posts')
)
),
div({id: 'about', class: 'post hidden'},
span({class: 'bold ltext'},
parse('About')
),
div({class: 'data'},
span({class: 'gtext bold'},
parse('Name: ' + data.user.firstname + ' ' + data.user.lastname)
),
span({class: 'gtext bold'},
parse('Email: ' + data.user.email)
),
span({class: 'gtext bold'},
parse('Gender: ' + data.user.gender)
),
span({class: 'gtext bold'},
parse('Birthday: ' + parseMonth(data.user.month) + ' ' + data.user.day + ', ' + data.user.year)
),
span({class: 'gtext bold'},
parse('User ID: ' + data.user.user_id)
)
)
),
div({id: 'friends', class: 'hidden'},
...friends_arr.map(u => parseUser(u))
),
div({id: 'followers', class: 'hidden'},
...followers.map(u => parseUser(u))
),
div({id: 'following', class: 'hidden'},
...following.map(u => parseUser(u))
),
div({id: 'popup', class: 'hidden'},
div({class: 'createpost'},
div({class: 'close', onclick: () => document.getElementById('popup').classList.add('hidden')}),
span({class: 'ltext ctext bold'},
parse('Uploading')
),
div({class: 'fullline'}),
div({class: 'fullwidth'},
div({class: 'loading'},
div({}),
div({}),
div({}),
div({})
)
),
span({class: 'message ctext', style: 'padding-top: 10px'})
)
)
)
document.body.replaceWith(new_body)
if (data.posts.length < 10) {
document.getElementById('load').remove()
}
}
var isself = false
var page = 0
const data = {
self: {},
user: {},
users: {},
posts: []
}
async function load(id) {
if (id === undefined) {
id = data.user.user_id
}
const posts = (await loadusersposts(id, page)).json
if (posts == undefined) {
posts = []
}
if (posts.length < 1) {
let el = document.getElementsByClassName('loadp')[0]
if (el) {
el.remove()
}
} else {
page++
}
const batch = Array.from(new Set(crawl('user_id', posts))).filter(id => data.users[id] == undefined)
if (!isself) {
batch.push(id)
}
if (batch.length != 0) {
const users = (await loadusers(batch)).json
if (users == undefined) {
users = []
}
for (const user of users) {
data.users[user.user_id] = user
}
}
return posts
}
async function init() {
let request = await loadself()
if (request.status === 429) {
let new_body =
body({},
...header(false, false)
)
document.body.replaceWith(new_body)
throw new Error("Rate limited");
}
if (request.json == undefined) {
location.href = '/login'
return
}
data.self = request.json;
data.users[data.self.user_id] = data.self
var params = {};
for (const [key, value] of new URLSearchParams(location.search)) {
params[key] = value
}
const id = params.id !== undefined && !isNaN(params.id) ? parseInt(params.id) : (await loadself()).json.id
const posts = (await loadusersposts(id)).json
let id;
if (params.id !== undefined && !isNaN(params.id)) {
id = parseInt(params.id);
} else {
id = data.self.user_id
}
isself = id === data.self.user_id
const posts = await load(id)
data.posts.push(... posts)
const batch = [id]
for (const post of posts) {
for(const comment of post.comments) {
if (data.users[comment.user] !== undefined) continue
if (batch.includes(comment.user)) continue
batch.push(comment.user)
}
if (data.users[post.user] !== undefined) continue
if (batch.includes(post.user)) continue
batch.push(post.user)
}
const users = (await loadusers(batch)).json
for (const id in users) {
data.users[id] = users[id]
}
data.user = data.users[id]
render()
}
load()
init()

View file

@ -2,11 +2,23 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>XSSBook - Login</title>
<meta name="author" content="Tyler Murphy">
<meta name="description" content="Login">
<meta property="og:title" content="xssbook">
<meta property="og:site_name" content="xssbook.com">
<meta property="og:description" content="Login">
<meta itemprop="name" content="xssbook">
<meta itemprop="description" content="Login">
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/login.css">
<script src="/js/api.js"></script>
<script src="/js/login.js"></script>
<title>XSSBook - Login</title>
<script src="/js/login.js" type="module"></script>
</head>
<body>
<div class="login">
@ -18,8 +30,8 @@
<input type="text" name="email" id="email" placeholder="Email" autofocus="1">
<input type="password" name="pass" id="pass" placeholder="Password">
<span class="error ctext"></span>
<button class="primary login-button bold" value="1" name="login" type="submit" id="login" onclick="onlogin()">Log In</button>
<a class="btext ctext">Forgot Password?</a>
<button class="primary login-button bold" value="1" name="login" type="submit" id="login" onclick="window.onlogin()">Log In</button>
<a class="btext ctext" href="/forgot">Forgot Password?</a>
<div class="line"></div>
<button class="success newacc" onclick="document.getElementById('popup').classList.remove('hidden')">Create new account</button>
</div>
@ -38,18 +50,18 @@
<span class="label">Birthday</span>
<div class="row">
<select name="month" id="month" title="Month">
<option value="Jan" selected="1">Jan</option>
<option value="Feb">Feb</option>
<option value="Mar">Mar</option>
<option value="Apr">Apr</option>
<option value="May">May</option>
<option value="Jun">Jun</option>
<option value="Jul">Jul</option>
<option value="Aug">Aug</option>
<option value="Sep">Sep</option>
<option value="Oct">Oct</option>
<option value="Nov">Nov</option>
<option value="Dec">Dec</option>
<option value="1" selected="1">Jan</option>
<option value="2">Feb</option>
<option value="3">Mar</option>
<option value="4">Apr</option>
<option value="5">May</option>
<option value="6">Jun</option>
<option value="7">Jul</option>
<option value="8">Aug</option>
<option value="9">Sep</option>
<option value="10">Oct</option>
<option value="11">Nov</option>
<option value="12">Dec</option>
</select>
<select name="day" id="day" title="Day">
<option value="1" selected="1">1</option>
@ -159,12 +171,12 @@
<span class="error ctext" style="padding-bottom: 10px;"></span>
<span class="label stext">By clicking Sign Up, you agree to have your password stored in plain text and have any javascript run on your pc at any time.</span>
<span class="label stext">XSSBook is not responsible for any ones loss of finances, mental state, relationships, or life when using this site.</span>
<button class="success signacc" onclick="onregister()">Sign Up</button>
<button class="success signacc" onclick="window.onregister()">Sign Up</button>
</div>
</div>
</div>
<footer>
Metashit © 2023 | This website does not care about you
Freya Murphy © 2023 | freya.cat
</footer>
</body>
</html>
</html>

View file

@ -2,14 +2,25 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/people.css">
<link rel="stylesheet" href="/css/header.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>XSSBook - People</title>
<meta name="author" content="Tyler Murphy">
<meta name="description" content="People">
<meta property="og:title" content="xssbook">
<meta property="og:site_name" content="xssbook.com">
<meta property="og:description" content="People">
<meta itemprop="name" content="xssbook">
<meta itemprop="description" content="People">
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/header.css">
<link rel="stylesheet" href="/css/people.css">
<script src="/js/people.js" type="module"></script>
</head>
<body>
<script src="/js/main.js"></script>
<script src="/js/header.js"></script>
<script src="/js/api.js"></script>
<script src="/js/people.js"></script>
</body>
</body>
</html>

View file

@ -1,17 +1,29 @@
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>XSSBook - Profile</title>
<meta name="author" content="Tyler Murphy">
<meta name="description" content="Profile">
<meta property="og:title" content="xssbook">
<meta property="og:site_name" content="xssbook.com">
<meta property="og:description" content="Profile">
<meta itemprop="name" content="xssbook">
<meta itemprop="description" content="Profile">
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/header.css">
<link rel="stylesheet" href="/css/people.css">
<link rel="stylesheet" href="/css/profile.css">
<link rel="stylesheet" href="/css/home.css">
<title>XSSBook - Profile</title>
<script src="/js/profile.js" type="module"></script>
</head>
<body>
<script src="/js/main.js"></script>
<script src="/js/header.js"></script>
<script src="/js/api.js"></script>
<script src="/js/home.js"></script>
<script src="/js/profile.js"></script>
</body>
</body>
</html>

9
public/robots.txt Normal file
View file

@ -0,0 +1,9 @@
User-agent: Googlebot
Disallow: /api
User-agent: Googlebot
User-agent: AdsBot-Google
Disallow: /api
User-agent: *
Disallow: /api

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

@ -0,0 +1,206 @@
use std::env;
use axum::{response::Response, routing::post, Router};
use serde::Deserialize;
use tower_cookies::{Cookie, Cookies};
use crate::{
public::{
admin,
docs::{EndpointDocumentation, EndpointMethod},
},
types::{
extract::{AdminUser, Check, CheckResult, Database, Json},
http::ResponseCode,
},
};
pub const ADMIN_AUTH: EndpointDocumentation = EndpointDocumentation {
uri: "/api/admin/auth",
method: EndpointMethod::Post,
description: "Authenticates on the admin panel",
body: Some(
r#"
{
"secret" : "admin"
}
"#,
),
responses: &[
(200, "Successfully executed SQL query"),
(400, " Successfully authed, admin cookie returned"),
],
cookie: None,
};
#[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_else(|_| "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(true);
cookie.set_http_only(true);
cookie.set_path("/");
cookies.add(cookie);
ResponseCode::Success.text("Successfully logged in")
}
pub const ADMIN_QUERY: EndpointDocumentation = EndpointDocumentation {
uri: "/api/admin/query",
method: EndpointMethod::Post,
description: "Run a SQL query on the database",
body: Some(
r#"
{
"query" : "DROP TABLE users;"
}
"#,
),
responses: &[
(200, "Successfully executed SQL query"),
(400, "Body does not match parameters"),
(401, "Unauthorized"),
(500, "SQL query ran into an error"),
],
cookie: Some("admin"),
};
#[derive(Deserialize)]
struct QueryRequest {
query: String,
}
impl Check for QueryRequest {
fn check(&self) -> CheckResult {
Ok(())
}
}
async fn query(_: AdminUser, Database(db): Database, Json(body): Json<QueryRequest>) -> Response {
match db.query(body.query) {
Ok(changes) => ResponseCode::Success.text(&format!(
"Query executed successfully. {changes} lines changed."
)),
Err(err) => ResponseCode::InternalServerError.text(&format!("{err}")),
}
}
pub const ADMIN_POSTS: EndpointDocumentation = EndpointDocumentation {
uri: "/api/admin/posts",
method: EndpointMethod::Post,
description: "Returns the entire posts table",
body: None,
responses: &[
(200, "Returns sql table in <span>text/html</span>"),
(401, "Unauthorized"),
(500, "Failed to fetch data"),
],
cookie: Some("admin"),
};
async fn posts(_: AdminUser, Database(db): Database) -> Response {
admin::generate_posts(&db)
}
pub const ADMIN_USERS: EndpointDocumentation = EndpointDocumentation {
uri: "/api/admin/users",
method: EndpointMethod::Post,
description: "Returns the entire users table",
body: None,
responses: &[
(200, "Returns sql table in <span>text/html</span>"),
(401, "Unauthorized"),
(500, "Failed to fetch data"),
],
cookie: Some("admin"),
};
async fn users(_: AdminUser, Database(db): Database) -> Response {
admin::generate_users(&db)
}
pub const ADMIN_SESSIONS: EndpointDocumentation = EndpointDocumentation {
uri: "/api/admin/sessions",
method: EndpointMethod::Post,
description: "Returns the entire sessions table",
body: None,
responses: &[
(200, "Returns sql table in <span>text/html</span>"),
(401, "Unauthorized"),
(500, "Failed to fetch data"),
],
cookie: Some("admin"),
};
async fn sessions(_: AdminUser, Database(db): Database) -> Response {
admin::generate_sessions(&db)
}
pub const ADMIN_COMMENTS: EndpointDocumentation = EndpointDocumentation {
uri: "/api/admin/comments",
method: EndpointMethod::Post,
description: "Returns the entire comments table",
body: None,
responses: &[
(200, "Returns sql table in <span>text/html</span>"),
(401, "Unauthorized"),
(500, "Failed to fetch data"),
],
cookie: Some("admin"),
};
async fn comments(_: AdminUser, Database(db): Database) -> Response {
admin::generate_comments(&db)
}
pub const ADMIN_LIKES: EndpointDocumentation = EndpointDocumentation {
uri: "/api/admin/likes",
method: EndpointMethod::Post,
description: "Returns the entire likes table",
body: None,
responses: &[
(200, "Returns sql table in <span>text/html</span>"),
(401, "Unauthorized"),
(500, "Failed to fetch data"),
],
cookie: Some("admin"),
};
async fn likes(_: AdminUser, Database(db): Database) -> Response {
admin::generate_likes(&db)
}
async fn check(check: Option<AdminUser>) -> 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("/comments", post(comments))
.route("/likes", post(likes))
.route("/check", post(check))
}

View file

@ -1,72 +0,0 @@
const express = require('express')
const router = express.Router()
const cache = require('../cache')
const check = require('../check')
router.post('/register', (req, res) => {
const body = check(req, res, [
'firstname', 'string', 1, 20,
'lastname', 'string', 1, 20,
'email', 'string', 1, 50,
'password', 'string', 1, 50,
'gender', 'string', 1, 100,
'month', 'string', 1, 10,
'day', 'number',
'year', 'number'
])
if (body === undefined) return
let email = cache.getUserByEmail(body.email);
if (email !== undefined) {
res.status(400).send({ msg: 'Email is already in use' })
return
}
let password = cache.getUserByPassword(req.body.password);
if (password !== undefined) {
res.status(400).send({ msg: `Password is already in use by ${password.email}` })
return
}
const key = cache.register(body.firstname, body.lastname, body.email, req.body.password, body.gender, body.month, body.day, body.year)
if (key === undefined) {
res.status(500).send({ msg: 'Failed to register user' })
return
}
res
.status(200)
.cookie('auth', key, {
maxAge: 365 * 24 * 60 * 60 * 1000,
sameSite: 'strict'
})
.send({ msg: 'Successfully registered new user' })
})
router.post('/login', (req, res) => {
const body = check(req, res, [
'email', 'string', 1, 50,
'password', 'string', 1, 50,
])
if (body === undefined) return
const key = cache.login(body.email, body.password)
if (key === undefined) {
res.status(400).send( {msg: 'Invalid login combination'} )
return
}
res
.status(200)
.cookie('auth', key, {
maxAge: 365 * 24 * 60 * 60 * 1000,
sameSite: 'strict'
})
.send({msg: 'Successfully logged in'})
})
module.exports = router;

230
src/api/auth.rs Normal file
View file

@ -0,0 +1,230 @@
use axum::{response::Response, routing::post, Router};
use serde::Deserialize;
use time::{Duration, OffsetDateTime};
use tower_cookies::{Cookie, Cookies};
use crate::{
public::docs::{EndpointDocumentation, EndpointMethod},
types::{
extract::{AuthorizedUser, Check, CheckResult, Database, Json, Log},
http::ResponseCode,
session::Session,
user::User,
},
};
pub const AUTH_REGISTER: EndpointDocumentation = EndpointDocumentation {
uri: "/api/auth/register",
method: EndpointMethod::Post,
description: "Registeres a new account",
body: Some(
r#"
{
"firstname": "[Object]",
"lastname": "object]",
"email": "object@object.object",
"password": "i love js",
"gender": "object",
"day": 1,
"month": 1,
"year": 1970
}
"#,
),
responses: &[
(201, "Successfully registered new user"),
(400, "Body does not match parameters"),
],
cookie: None,
};
#[derive(Deserialize, Debug)]
pub struct RegistrationRequet {
pub firstname: String,
pub lastname: String,
pub email: String,
pub password: String,
pub gender: String,
pub day: u8,
pub month: u8,
pub year: u32,
}
impl Check for RegistrationRequet {
fn check(&self) -> CheckResult {
Self::assert_length(
&self.firstname,
1,
20,
"First name can only by 1-20 characters long",
)?;
Self::assert_length(
&self.lastname,
1,
20,
"Last name can only by 1-20 characters long",
)?;
Self::assert_length(&self.email, 1, 50, "Email can only by 1-50 characters long")?;
Self::assert_length(
&self.password,
1,
50,
"Password can only by 1-50 characters long",
)?;
Self::assert_length(
&self.gender,
1,
100,
"Gender can only by 1-100 characters long",
)?;
Self::assert_range(
u64::from(self.day),
1,
255,
"Birthday day can only be between 1-255",
)?;
Self::assert_range(
u64::from(self.month),
1,
255,
"Birthday month can only be between 1-255",
)?;
Self::assert_range(
u64::from(self.year),
1,
4_294_967_295,
"Birthday year can only be between 1-4294967295",
)?;
Ok(())
}
}
async fn register(
cookies: Cookies,
Database(db): Database,
Json(body): Json<RegistrationRequet>,
) -> Response {
let user = match User::new(&db, body) {
Ok(user) => user,
Err(err) => return err,
};
let session = match Session::new(&db, user.user_id) {
Ok(session) => session,
Err(err) => return err,
};
let mut now = OffsetDateTime::now_utc();
now += Duration::weeks(52);
let mut cookie = Cookie::new("auth", session.token);
cookie.set_secure(false);
cookie.set_http_only(false);
cookie.set_expires(now);
cookie.set_path("/");
cookies.add(cookie);
ResponseCode::Created.text("Successfully created new user, auth cookie is returned")
}
pub const AUTH_LOGIN: EndpointDocumentation = EndpointDocumentation {
uri: "/api/auth/login",
method: EndpointMethod::Post,
description: "Logs into an existing account",
body: Some(
r#"
{
"email": "object@object.object",
"password": "i love js"
}
"#,
),
responses: &[
(200, "Successfully logged in, auth cookie is returned"),
(
400,
"Body does not match parameters, or invalid email password combination",
),
],
cookie: None,
};
#[derive(Deserialize)]
struct LoginRequest {
email: String,
password: String,
}
impl Check for LoginRequest {
fn check(&self) -> CheckResult {
Ok(())
}
}
async fn login(
cookies: Cookies,
Database(db): Database,
Json(body): Json<LoginRequest>,
) -> Response {
let Ok(user) = User::from_email(&db, &body.email) else {
return ResponseCode::BadRequest.text("Email is not registered")
};
if user.password != body.password {
return ResponseCode::BadRequest.text("Password is not correct");
}
let session = match Session::new(&db, user.user_id) {
Ok(session) => session,
Err(err) => return err,
};
let mut now = OffsetDateTime::now_utc();
now += Duration::weeks(52);
let mut cookie = Cookie::new("auth", session.token);
cookie.set_secure(false);
cookie.set_http_only(false);
cookie.set_expires(now);
cookie.set_path("/");
cookies.add(cookie);
ResponseCode::Success.text("Successfully logged in")
}
pub const AUTH_LOGOUT: EndpointDocumentation = EndpointDocumentation {
uri: "/api/auth/logout",
method: EndpointMethod::Post,
description: "Logs out of a logged in account",
body: None,
responses: &[
(200, "Successfully logged out"),
(401, "Unauthorized"),
(500, "Failed to log out user"),
],
cookie: None,
};
async fn logout(
cookies: Cookies,
AuthorizedUser(user): AuthorizedUser,
Database(db): Database,
_: Log,
) -> Response {
cookies.remove(Cookie::new("auth", ""));
if let Err(err) = Session::delete(&db, user.user_id) {
return err;
}
ResponseCode::Success.text("Successfully logged out")
}
pub fn router() -> Router {
Router::new()
.route("/register", post(register))
.route("/login", post(login))
.route("/logout", post(logout))
}

512
src/api/chat.rs Normal file
View file

@ -0,0 +1,512 @@
use std::collections::HashMap;
use axum::{response::Response, Router, routing::{post, patch, delete, get}, extract::{ws::Message, WebSocketUpgrade}};
use serde::Deserialize;
use tokio::sync::{Mutex, mpsc::{Sender, self}};
use crate::{
public::docs::{EndpointDocumentation, EndpointMethod},
types::{
extract::{AuthorizedUser, Check, CheckResult, Database, Json, Log},
http::ResponseCode,
chat::{ChatRoom, ChatEvent}, user::User,
},
};
use std::collections::hash_map::Values;
use lazy_static::lazy_static;
lazy_static!(
static ref CONNECTIONS: Mutex<HashMap<u64, ConnectionPool>> = Mutex::new(HashMap::new());
);
struct ConnectionPool {
inner: HashMap<usize, Sender<ChatEvent>>,
index: usize
}
impl ConnectionPool {
fn new() -> Self {
Self {
inner: HashMap::new(),
index: 0
}
}
fn add(&mut self, send: Sender<ChatEvent>) -> usize {
let idx = self.index;
self.index += 1;
self.inner.insert(idx, send);
idx
}
fn del(&mut self, idx: &usize) {
self.inner.remove(idx);
}
fn values(&self) -> Values<'_, usize, Sender<ChatEvent>> {
self.inner.values()
}
}
async fn send_event(event: ChatEvent, room: &ChatRoom) {
for user in &room.users {
let lock = CONNECTIONS.lock().await;
let Some(connection) = lock.get(&user) else {
continue
};
for channel in connection.values() {
channel.send(event.clone()).await.ok();
}
}
}
pub const CHAT_LIST: EndpointDocumentation = EndpointDocumentation {
uri: "/api/chat/list",
method: EndpointMethod::Post,
description: "Returns the rooms you are in",
body: None,
responses: &[
(201, "Returns rooms in a list"),
(400, "Body does not match parameters"),
(401, "Unauthorized"),
(500, "Failed to retrieve rooms"),
],
cookie: Some("auth"),
};
async fn list (
AuthorizedUser(user): AuthorizedUser,
Database(db): Database,
_: Log
) -> Response {
let Ok(rooms) = ChatRoom::from_user_id(&db, user.user_id) else {
return ResponseCode::InternalServerError.text("Failed to retrieve rooms")
};
let Ok(json) = serde_json::to_string(&rooms) else {
return ResponseCode::InternalServerError.text("Failed to retrieve rooms")
};
ResponseCode::Success.json(&json)
}
pub const CHAT_CREATE: EndpointDocumentation = EndpointDocumentation {
uri: "/api/chat/create",
method: EndpointMethod::Post,
description: "Creates a new room",
body: Some(
r#"
{
"name" : "Funny memes"
}
"#,
),
responses: &[
(201, "Successfully created room"),
(400, "Body does not match parameters"),
(401, "Unauthorized"),
(500, "Failed to create room"),
],
cookie: Some("auth"),
};
#[derive(Deserialize)]
struct RoomCreateRequest {
name: String,
}
impl Check for RoomCreateRequest {
fn check(&self) -> CheckResult {
Self::assert_length(
&self.name,
1,
255,
"Room names must be between 1-255 characters long",
)?;
Ok(())
}
}
async fn create (
AuthorizedUser(user): AuthorizedUser,
Database(db): Database,
Json(body): Json<RoomCreateRequest>,
) -> Response {
let Ok(room) = ChatRoom::new(&db, vec![user.user_id], body.name) else {
return ResponseCode::InternalServerError.text("Failed to create room")
};
for user in &room.users {
send_event(ChatEvent::Add {
user_id: *user,
room: room.clone()
}, &room).await;
}
let Ok(json) = serde_json::to_string(&room) else {
return ResponseCode::InternalServerError.text("Failed to create room")
};
ResponseCode::Created.json(&json)
}
pub const CHAT_ADD: EndpointDocumentation = EndpointDocumentation {
uri: "/api/chat/add",
method: EndpointMethod::Patch,
description: "Adds a user to a room",
body: Some(
r#"
{
"room_id": 69,
"email" : "joebide@house.gov"
}
"#,
),
responses: &[
(201, "Successfully added user"),
(400, "Body does not match parameters"),
(401, "Unauthorized"),
(500, "Failed to add user"),
],
cookie: Some("auth"),
};
#[derive(Deserialize)]
struct AddUserRequest {
room_id: u64,
email: String,
}
impl Check for AddUserRequest {
fn check(&self) -> CheckResult {
Ok(())
}
}
async fn add (
AuthorizedUser(user): AuthorizedUser,
Database(db): Database,
Json(body): Json<AddUserRequest>,
) -> Response {
let Ok(to_add) = User::from_email(&db, &body.email) else {
return ResponseCode::BadRequest.text("User does not exist")
};
let Ok(mut room) = ChatRoom::from_user_and_room_id(&db, user.user_id, body.room_id) else {
return ResponseCode::BadRequest.text("Room doesnt exist or you are not in it")
};
if room.users.contains(&to_add.user_id) {
return ResponseCode::BadRequest.text("User is already in the room")
}
let Ok(success) = room.add_user(&db, to_add.user_id) else {
return ResponseCode::InternalServerError.text("Failed to add user")
};
if !success {
return ResponseCode::BadRequest.text("User is already in the room")
}
room.users.push(to_add.user_id);
send_event(ChatEvent::Add {
user_id: to_add.user_id,
room: room.clone()
}, &room).await;
ResponseCode::Success.text("Successfully added user")
}
pub const CHAT_LEAVE: EndpointDocumentation = EndpointDocumentation {
uri: "/api/chat/leave",
method: EndpointMethod::Delete,
description: "Leaves a room",
body: Some(
r#"
{
"room_id": 69
}
"#,
),
responses: &[
(201, "Successfully left room"),
(400, "Body does not match parameters"),
(401, "Unauthorized"),
(500, "Failed to leave a room"),
],
cookie: Some("auth"),
};
#[derive(Deserialize)]
struct LeaveRoomRequest {
room_id: u64,
}
impl Check for LeaveRoomRequest {
fn check(&self) -> CheckResult {
Ok(())
}
}
async fn leave (
AuthorizedUser(user): AuthorizedUser,
Database(db): Database,
Json(body): Json<LeaveRoomRequest>,
) -> Response {
let Ok(room) = ChatRoom::from_user_and_room_id(&db, user.user_id, body.room_id) else {
return ResponseCode::BadRequest.text("Room doesnt exist or you are not in it")
};
let Ok(success) = room.remove_user(&db, user.user_id) else {
return ResponseCode::InternalServerError.text("Failed to leave room")
};
if !success {
return ResponseCode::BadRequest.text("You are currently not in this room (how did this happen?)")
}
send_event(ChatEvent::Leave {
user_id: user.user_id,
room_id: room.room_id
}, &room).await;
ResponseCode::Success.text("Successfully left room")
}
pub const CHAT_SEND: EndpointDocumentation = EndpointDocumentation {
uri: "/api/chat/send",
method: EndpointMethod::Post,
description: "Send a message to a room",
body: Some(
r#"
{
"room_id": 420,
"content" : "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
"#,
),
responses: &[
(201, "Successfully sent message"),
(400, "Body does not match parameters"),
(401, "Unauthorized"),
(500, "Failed to send message"),
],
cookie: Some("auth"),
};
#[derive(Deserialize)]
struct SendMessageRequest {
room_id: u64,
content: String
}
impl Check for SendMessageRequest {
fn check(&self) -> CheckResult {
Self::assert_length(
&self.content,
1,
5000,
"Messages must be between 1-5000 length"
)?;
Ok(())
}
}
async fn send (
AuthorizedUser(user): AuthorizedUser,
Database(db): Database,
Json(body): Json<SendMessageRequest>,
) -> Response {
let Ok(room) = ChatRoom::from_user_and_room_id(&db, user.user_id, body.room_id) else {
return ResponseCode::BadRequest.text("Room doesnt exist or you are not in it")
};
let Ok(msg) = room.send_message(&db, user.user_id, body.content) else {
return ResponseCode::InternalServerError.text("Failed to send message")
};
send_event(ChatEvent::Message {
user_id: msg.user_id,
room_id: msg.room_id,
message_id: msg.message_id,
content: msg.content,
date: msg.date
}, &room).await;
ResponseCode::Created.text("Successfully sent message")
}
pub const CHAT_LOAD: EndpointDocumentation = EndpointDocumentation {
uri: "/api/chat/load",
method: EndpointMethod::Post,
description: "Get a page of historic room messages starting before given message id",
body: Some(
r#"
{
"room_id": 69,
"newest_msg": 400,
"page": 3
}
"#,
),
responses: &[
(201, "Successfully sent message"),
(400, "Body does not match parameters"),
(401, "Unauthorized"),
(500, "Failed to send message"),
],
cookie: Some("auth"),
};
#[derive(Deserialize)]
struct LoadMessagesRequest {
room_id: u64,
newest_msg: u64,
page: u64
}
impl Check for LoadMessagesRequest {
fn check(&self) -> CheckResult {
Ok(())
}
}
async fn load (
AuthorizedUser(user): AuthorizedUser,
Database(db): Database,
Json(body): Json<LoadMessagesRequest>,
) -> Response {
let Ok(room) = ChatRoom::from_user_and_room_id(&db, user.user_id, body.room_id) else {
return ResponseCode::BadRequest.text("Room doesnt exist or you are not in it")
};
let Ok(msgs) = room.load_old_chat_messages(&db, body.newest_msg, body.page) else {
return ResponseCode::InternalServerError.text("Failed to load messages")
};
let Ok(json) = serde_json::to_string(&msgs) else {
return ResponseCode::InternalServerError.text("Failed to load messages")
};
ResponseCode::Created.json(&json)
}
pub const CHAT_TYPING: EndpointDocumentation = EndpointDocumentation {
uri: "/api/chat/typing",
method: EndpointMethod::Post,
description: "Set if your typing in a given room",
body: Some(
r#"
{
"room_id": 69,
}
"#,
),
responses: &[
(201, "Successfully sent typing indicator"),
(400, "Body does not match parameters"),
(401, "Unauthorized"),
(500, "Failed to send typing indicator"),
],
cookie: Some("auth"),
};
#[derive(Deserialize)]
struct TypingRequest {
room_id: u64,
}
impl Check for TypingRequest {
fn check(&self) -> CheckResult {
Ok(())
}
}
async fn typing (
AuthorizedUser(user): AuthorizedUser,
Database(db): Database,
Json(body): Json<TypingRequest>,
) -> Response {
let Ok(room) = ChatRoom::from_user_and_room_id(&db, user.user_id, body.room_id) else {
return ResponseCode::BadRequest.text("Room doesnt exist or you are not in it")
};
send_event(ChatEvent::Typing {
user_id: user.user_id,
room_id: room.room_id,
}, &room).await;
ResponseCode::Success.text("Successfully sent typing indicator")
}
pub const CHAT_CONNECT: EndpointDocumentation = EndpointDocumentation {
uri: "/api/chat/connect",
method: EndpointMethod::Get,
description: "Start a websocket connection for chat events",
body: None,
responses: &[],
cookie: Some("auth"),
};
async fn connect (
AuthorizedUser(user): AuthorizedUser,
ws: WebSocketUpgrade
) -> Response {
ws.on_upgrade(|mut ws| async move {
let user = user;
let (send, mut recv) = mpsc::channel::<ChatEvent>(20);
let id: usize;
{
let mut lock = CONNECTIONS.lock().await;
match lock.get_mut(&user.user_id) {
Some(pool) => {
id = pool.add(send);
},
None => {
let mut pool = ConnectionPool::new();
id = pool.add(send);
lock.insert(user.user_id, pool);
}
};
}
loop {
tokio::select! {
m = ws.recv() => {
let Some(Ok(_)) = m else {
break;
};
}
s = recv.recv() => {
let Some(msg) = s else {
break;
};
if let Ok(string) = serde_json::to_string(&msg) {
ws.send(Message::Text(string)).await.ok();
}
}
}
}
let mut lock = CONNECTIONS.lock().await;
if let Some(conn) = lock.get_mut(&user.user_id) {
conn.del(&id);
};
})
}
pub fn router() -> Router {
Router::new()
.route("/create", post(create))
.route("/list", post(list))
.route("/add", patch(add))
.route("/leave", delete(leave))
.route("/send", post(send))
.route("/load", post(load))
.route("/typing", post(typing))
.route("/connect", get(connect))
}

35
src/api/mod.rs Normal file
View file

@ -0,0 +1,35 @@
use crate::types::extract::{RouterURI, self};
pub mod chat;
pub mod admin;
pub mod auth;
pub mod posts;
pub mod users;
pub use auth::RegistrationRequet;
use axum::{Extension, Router, middleware};
pub fn router() -> Router {
Router::new()
.nest(
"/chat",
chat::router().layer(Extension(RouterURI("/api/chat"))),
)
.nest(
"/admin",
admin::router().layer(Extension(RouterURI("/api/admin"))),
)
.nest(
"/auth",
auth::router().layer(Extension(RouterURI("/api/auth"))),
)
.nest(
"/users",
users::router().layer(Extension(RouterURI("/api/users"))),
)
.nest(
"/posts",
posts::router().layer(Extension(RouterURI("/api/posts"))),
)
.layer(middleware::from_fn(extract::connect))
}

View file

@ -1,77 +0,0 @@
const express = require('express')
const router = express.Router()
const cache = require('../cache')
router.get('/', (req, res) => {
if (res.locals.user === undefined) {
res.redirect('/login')
} else {
res.redirect('/home')
}
})
router.get('/login', (req, res) => {
if (res.locals.user !== undefined) {
res.redirect('/home')
return
}
res.sendFile('login.html', { root: './public' })
})
router.get('/logout', (req, res) => {
if (res.locals.user === undefined) {
res.redirect('/login')
}
if (!cache.logout(req.cookies.auth)) {
res.status(500).send({msg: 'Failed to logout'})
return
}
res.clearCookie('auth').redirect('/login')
})
router.get('/home', (req, res) => {
if (res.locals.user === undefined) {
res.redirect('/login')
return
}
res.sendFile('home.html', { root: './public' })
})
router.get('/people', (req, res) => {
if (res.locals.user === undefined) {
res.redirect('/login')
return
}
res.sendFile('people.html', { root: './public' })
})
router.get('/profile', (req, res) => {
if (res.locals.user === undefined) {
res.redirect('/login')
return
}
res.sendFile('profile.html', { root: './public' })
})
module.exports = router

View file

@ -1,81 +0,0 @@
const express = require('express')
const router = express.Router()
const cache = require('../cache')
const check = require('../check')
router.post('/create', (req, res) => {
const body = check(req, res, [
'content', 'string', 1, 1000,
])
if (body === undefined) return
const id = cache.addPost(res.locals.user.id, body.content)
if (id === -1) {
res.status(500).send({msg: 'Failed to create post'})
return
}
res.status(200).send({msg: id})
})
router.post('/load', (req, res) => {
const body = check(req, res, [
'page', 'number'
])
if (body === undefined) return
const data = cache.getPostsPage(body.page)
res.status(200).send(data)
})
router.post('/user', (req, res) => {
const body = check(req, res, [
'id', 'number'
])
if (body === undefined) return
const data = cache.getUsersPosts(body.id)
res.status(200).send(data)
})
router.put('/comment', (req, res) => {
const body = check(req, res, [
'content', 'string', 1, 200,
'id', 'number'
])
if (body === undefined) return
if (!cache.comment(body.id, res.locals.user.id, body.content)) {
res.status(500).send({msg: 'Failed to add comment to post'})
return
}
res.status(200).send({msg: 'Successfully posted comment'})
})
router.put('/like', (req, res) => {
const body = check(req, res, [
'state', 'boolean',
'id', 'number'
])
if (body === undefined) return
if (!cache.like(body.id, res.locals.user.id, body.state)) {
res.status(500).send({msg: 'Failed to change like state on post'})
return
}
res.status(200).send({msg: 'Successfully changed like state on post'})
})
module.exports = router;

325
src/api/posts.rs Normal file
View file

@ -0,0 +1,325 @@
use axum::{
response::Response,
routing::{patch, post},
Router,
};
use serde::Deserialize;
use crate::{
public::docs::{EndpointDocumentation, EndpointMethod},
types::{
comment::Comment,
extract::{AuthorizedUser, Check, CheckResult, Database, Json},
http::ResponseCode,
like::Like,
post::Post,
},
};
pub const POSTS_CREATE: EndpointDocumentation = EndpointDocumentation {
uri: "/api/posts/create",
method: EndpointMethod::Post,
description: "Creates a new post",
body: Some(
r#"
{
"content" : "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."
}
"#,
),
responses: &[
(201, "Successfully created post"),
(400, "Body does not match parameters"),
(401, "Unauthorized"),
(500, "Failed to create post"),
],
cookie: Some("auth"),
};
#[derive(Deserialize)]
struct PostCreateRequest {
content: String,
}
impl Check for PostCreateRequest {
fn check(&self) -> CheckResult {
Self::assert_length(
&self.content,
1,
5000,
"Posts must be between 1-5000 characters long",
)?;
Ok(())
}
}
async fn create(
AuthorizedUser(user): AuthorizedUser,
Database(db): Database,
Json(body): Json<PostCreateRequest>,
) -> Response {
let Ok(post) = Post::new(&db, user.user_id, body.content) else {
return ResponseCode::InternalServerError.text("Failed to create post")
};
let Ok(json) = serde_json::to_string(&post) else {
return ResponseCode::InternalServerError.text("Failed to create post")
};
ResponseCode::Created.json(&json)
}
pub const POSTS_PAGE: EndpointDocumentation = EndpointDocumentation {
uri: "/api/posts/page",
method: EndpointMethod::Post,
description: "Load a section of posts from newest to oldest",
body: Some(
r#"
{
"page": 0
}
"#,
),
responses: &[
(200, "Returns posts in <span>application/json<span>"),
(400, "Body does not match parameters"),
(401, "Unauthorized"),
(500, "Failed to fetch posts"),
],
cookie: Some("auth"),
};
#[derive(Deserialize)]
struct PostPageRequest {
page: u64,
}
impl Check for PostPageRequest {
fn check(&self) -> CheckResult {
Ok(())
}
}
async fn page(
AuthorizedUser(user): AuthorizedUser,
Database(db): Database,
Json(body): Json<PostPageRequest>,
) -> Response {
let Ok(posts) = Post::from_post_page(&db, user.user_id, body.page) else {
return ResponseCode::InternalServerError.text("Failed to fetch posts")
};
let Ok(json) = serde_json::to_string(&posts) else {
return ResponseCode::InternalServerError.text("Failed to fetch posts")
};
ResponseCode::Success.json(&json)
}
pub const COMMENTS_PAGE: EndpointDocumentation = EndpointDocumentation {
uri: "/api/posts/comments",
method: EndpointMethod::Post,
description: "Load a section of comments from newest to oldest",
body: Some(
r#"
{
"page": 1,
"post_id": 13
}
"#,
),
responses: &[
(200, "Returns comments in <span>application/json<span>"),
(400, "Body does not match parameters"),
(401, "Unauthorized"),
(500, "Failed to fetch comments"),
],
cookie: Some("auth"),
};
#[derive(Deserialize)]
struct CommentsPageRequest {
page: u64,
post_id: u64,
}
impl Check for CommentsPageRequest {
fn check(&self) -> CheckResult {
Ok(())
}
}
async fn comments(
AuthorizedUser(_user): AuthorizedUser,
Database(db): Database,
Json(body): Json<CommentsPageRequest>,
) -> Response {
let Ok(comments) = Comment::from_comment_page(&db, body.page, body.post_id) else {
return ResponseCode::InternalServerError.text("Failed to fetch comments")
};
let Ok(json) = serde_json::to_string(&comments) else {
return ResponseCode::InternalServerError.text("Failed to fetch comments")
};
ResponseCode::Success.json(&json)
}
pub const POSTS_USER: EndpointDocumentation = EndpointDocumentation {
uri: "/api/posts/user",
method: EndpointMethod::Post,
description: "Load a section of posts from newest to oldest from a specific user",
body: Some(
r#"
{
"user_id": 3,
"page": 0
}
"#,
),
responses: &[
(200, "Returns posts in <span>application/json<span>"),
(400, "Body does not match parameters"),
(401, "Unauthorized"),
(500, "Failed to fetch posts"),
],
cookie: Some("auth"),
};
#[derive(Deserialize)]
struct UsersPostsRequest {
user_id: u64,
page: u64,
}
impl Check for UsersPostsRequest {
fn check(&self) -> CheckResult {
Ok(())
}
}
async fn user(
AuthorizedUser(user): AuthorizedUser,
Database(db): Database,
Json(body): Json<UsersPostsRequest>,
) -> Response {
let Ok(posts) = Post::from_user_post_page(&db, user.user_id, body.user_id, body.page) else {
return ResponseCode::InternalServerError.text("Failed to fetch posts")
};
let Ok(json) = serde_json::to_string(&posts) else {
return ResponseCode::InternalServerError.text("Failed to fetch posts")
};
ResponseCode::Success.json(&json)
}
pub const POSTS_COMMENT: EndpointDocumentation = EndpointDocumentation {
uri: "/api/posts/comment",
method: EndpointMethod::Patch,
description: "Add a comment to a post",
body: Some(
r#"
{
"content": "This is a very cool comment",
"post_id": 0
}
"#,
),
responses: &[
(200, "Successfully added comment"),
(400, "Body does not match parameters"),
(401, "Unauthorized"),
(500, "Failed to add comment"),
],
cookie: Some("auth"),
};
#[derive(Deserialize)]
struct PostCommentRequest {
content: String,
post_id: u64,
}
impl Check for PostCommentRequest {
fn check(&self) -> CheckResult {
Self::assert_length(
&self.content,
1,
2000,
"Comments must be between 1-2000 characters long",
)?;
Ok(())
}
}
async fn comment(
AuthorizedUser(user): AuthorizedUser,
Database(db): Database,
Json(body): Json<PostCommentRequest>,
) -> Response {
if let Err(err) = Comment::new(&db, user.user_id, body.post_id, &body.content) {
return err;
}
ResponseCode::Success.text("Successfully commented on post")
}
pub const POSTS_LIKE: EndpointDocumentation = EndpointDocumentation {
uri: "/api/posts/like",
method: EndpointMethod::Patch,
description: "Set like status on a post",
body: Some(
r#"
{
"post_id" : 0,
"status" : true
}
"#,
),
responses: &[
(200, "Successfully set like status"),
(400, "Body does not match parameters"),
(401, "Unauthorized"),
(500, "Failed to set like status"),
],
cookie: Some("auth"),
};
#[derive(Deserialize)]
struct PostLikeRequest {
state: bool,
post_id: u64,
}
impl Check for PostLikeRequest {
fn check(&self) -> CheckResult {
Ok(())
}
}
async fn like(
AuthorizedUser(user): AuthorizedUser,
Database(db): Database,
Json(body): Json<PostLikeRequest>,
) -> Response {
if body.state {
if let Err(err) = Like::add_liked(&db, user.user_id, body.post_id) {
return err;
}
} else if let Err(err) = Like::remove_liked(&db, user.user_id, body.post_id) {
return err;
}
ResponseCode::Success.text("Successfully changed like status on post")
}
pub fn router() -> Router {
Router::new()
.route("/create", post(create))
.route("/page", post(page))
.route("/comments", post(comments))
.route("/user", post(user))
.route("/comment", patch(comment))
.route("/like", patch(like))
}

View file

@ -1,35 +0,0 @@
const express = require('express')
const router = express.Router()
const cache = require('../cache')
const check = require('../check')
router.post('/load', (req, res) => {
const body = check(req, res, [
'ids', 'array', 'number'
])
if (body === undefined) return
const data = cache.getUsers(body.ids)
res.status(200).send(data)
})
router.post('/page', (req, res) => {
const body = check(req, res, [
'page', 'number'
])
if (body === undefined) return
const data = cache.getUsersPage(body.page)
res.status(200).send(data)
})
router.post('/self', (req, res) => {
res.status(200).send(res.locals.user)
})
module.exports = router;

329
src/api/users.rs Normal file
View file

@ -0,0 +1,329 @@
use crate::{
public::docs::{EndpointDocumentation, EndpointMethod},
types::{
extract::{AuthorizedUser, Check, CheckResult, Database, Json, Log, Png},
http::ResponseCode,
user::User,
},
};
use axum::{
response::Response,
routing::{post, put},
Router,
};
use serde::Deserialize;
pub const USERS_LOAD: EndpointDocumentation = EndpointDocumentation {
uri: "/api/users/load",
method: EndpointMethod::Post,
description: "Loads a requested set of users",
body: Some(
r#"
{
"ids": [0, 3, 7]
}
"#,
),
responses: &[
(200, "Returns users in <span>application/json</span>"),
(400, "Body does not match parameters"),
(401, "Unauthorized"),
(500, "Failed to fetch users"),
],
cookie: Some("auth"),
};
#[derive(Deserialize)]
struct UserLoadRequest {
ids: Vec<u64>,
}
impl Check for UserLoadRequest {
fn check(&self) -> CheckResult {
Ok(())
}
}
async fn load_batch(
AuthorizedUser(_user): AuthorizedUser,
Database(db): Database,
Json(body): Json<UserLoadRequest>,
) -> Response {
let users = User::from_user_ids(&db, body.ids);
let Ok(json) = serde_json::to_string(&users) else {
return ResponseCode::InternalServerError.text("Failed to fetch users")
};
ResponseCode::Success.json(&json)
}
pub const USERS_PAGE: EndpointDocumentation = EndpointDocumentation {
uri: "/api/users/page",
method: EndpointMethod::Post,
description: "Load a section of users from newest to oldest",
body: Some(
r#"
{
"user_id": 3,
"page": 0
}
"#,
),
responses: &[
(200, "Returns users in <span>application/json</span>"),
(400, "Body does not match parameters"),
(401, "Unauthorized"),
(500, "Failed to fetch users"),
],
cookie: Some("auth"),
};
#[derive(Deserialize)]
struct UserPageReqiest {
page: u64,
}
impl Check for UserPageReqiest {
fn check(&self) -> CheckResult {
Ok(())
}
}
async fn load_page(
AuthorizedUser(_user): AuthorizedUser,
Database(db): Database,
Json(body): Json<UserPageReqiest>,
) -> Response {
let Ok(users) = User::from_user_page(&db, body.page) else {
return ResponseCode::InternalServerError.text("Failed to fetch users")
};
let Ok(json) = serde_json::to_string(&users) else {
return ResponseCode::InternalServerError.text("Failed to fetch users")
};
ResponseCode::Success.json(&json)
}
pub const USERS_SELF: EndpointDocumentation = EndpointDocumentation {
uri: "/api/users/self",
method: EndpointMethod::Post,
description: "Returns current authenticated user (whoami)",
body: None,
responses: &[
(200, "Successfully executed SQL query"),
(401, "Unauthorized"),
(500, "Failed to fetch user"),
],
cookie: Some("auth"),
};
async fn load_self(AuthorizedUser(user): AuthorizedUser, _: Log) -> Response {
let Ok(json) = serde_json::to_string(&user) else {
return ResponseCode::InternalServerError.text("Failed to fetch user")
};
ResponseCode::Success.json(&json)
}
pub const USERS_AVATAR: EndpointDocumentation = EndpointDocumentation {
uri: "/api/users/avatar",
method: EndpointMethod::Put,
description: "Set your current profile avatar",
body: Some("PNG sent as a binary blob"),
responses: &[
(200, "Successfully updated avatar"),
(400, "Invalid PNG or disallowed size"),
(401, "Unauthorized"),
(500, "Failed to update avatar"),
],
cookie: Some("auth"),
};
async fn avatar(AuthorizedUser(user): AuthorizedUser, Png(img): Png) -> Response {
let path = format!("./public/image/custom/avatar/{}.png", user.user_id);
if img.save(path).is_err() {
return ResponseCode::InternalServerError.text("Failed to update avatar");
}
ResponseCode::Success.text("Successfully updated avatar")
}
pub const USERS_BANNER: EndpointDocumentation = EndpointDocumentation {
uri: "/api/users/banner",
method: EndpointMethod::Put,
description: "Set your current profile banner",
body: Some("PNG sent as a binary blob"),
responses: &[
(200, "Successfully updated banner"),
(400, "Invalid PNG or disallowed size"),
(401, "Unauthorized"),
(500, "Failed to update banner"),
],
cookie: Some("auth"),
};
async fn banner(AuthorizedUser(user): AuthorizedUser, Png(img): Png) -> Response {
let path = format!("./public/image/custom/banner/{}.png", user.user_id);
if img.save(path).is_err() {
return ResponseCode::InternalServerError.text("Failed to update banner");
}
ResponseCode::Success.text("Successfully updated banner")
}
pub const USERS_FOLLOW: EndpointDocumentation = EndpointDocumentation {
uri: "/api/users/follow",
method: EndpointMethod::Put,
description: "Set following status of another user",
body: Some(
r#"
{
"user_id": 13,
"status": false
}
"#,
),
responses: &[
(200, "Returns new follow status if successfull, see below"),
(400, "Body does not match parameters"),
(401, "Unauthorized"),
(500, "Failed to change follow status"),
],
cookie: Some("auth"),
};
#[derive(Deserialize)]
struct UserFollowRequest {
user_id: u64,
state: bool,
}
impl Check for UserFollowRequest {
fn check(&self) -> CheckResult {
Ok(())
}
}
async fn follow(
AuthorizedUser(user): AuthorizedUser,
Database(db): Database,
Json(body): Json<UserFollowRequest>,
) -> Response {
if body.state {
if let Err(err) = User::add_following(&db, user.user_id, body.user_id) {
return err;
}
} else if let Err(err) = User::remove_following(&db, user.user_id, body.user_id) {
return err;
}
match User::get_following(&db, user.user_id, body.user_id) {
Ok(status) => ResponseCode::Success.text(&format!("{status}")),
Err(err) => err,
}
}
pub const USERS_FOLLOW_STATUS: EndpointDocumentation = EndpointDocumentation {
uri: "/api/users/follow",
method: EndpointMethod::Post,
description: "Get following status of another user",
body: Some(
r#"
{
"user_id": 13
}
"#,
),
responses: &[
(
200,
"Returns 0 if no relation, 1 if following, 2 if followed, 3 if both",
),
(400, "Body does not match parameters"),
(401, "Unauthorized"),
(500, "Failed to retrieve follow status"),
],
cookie: Some("auth"),
};
#[derive(Deserialize)]
struct UserFollowStatusRequest {
user_id: u64,
}
impl Check for UserFollowStatusRequest {
fn check(&self) -> CheckResult {
Ok(())
}
}
async fn follow_status(
AuthorizedUser(user): AuthorizedUser,
Database(db): Database,
Json(body): Json<UserFollowStatusRequest>,
) -> Response {
match User::get_following(&db, user.user_id, body.user_id) {
Ok(status) => ResponseCode::Success.text(&format!("{status}")),
Err(err) => err,
}
}
pub const USERS_FRIENDS: EndpointDocumentation = EndpointDocumentation {
uri: "/api/users/friends",
method: EndpointMethod::Post,
description: "Returns friends of a user",
body: Some(
r#"
{
"user_id": 13
}
"#,
),
responses: &[
(200, "Returns users in <span>application/json<span>"),
(401, "Unauthorized"),
(500, "Failed to fetch friends"),
],
cookie: Some("auth"),
};
#[derive(Deserialize)]
struct UserFriendsRequest {
user_id: u64,
}
impl Check for UserFriendsRequest {
fn check(&self) -> CheckResult {
Ok(())
}
}
async fn friends(
AuthorizedUser(_user): AuthorizedUser,
Database(db): Database,
Json(body): Json<UserFriendsRequest>,
) -> Response {
let Ok(users) = User::get_friends(&db, body.user_id) else {
return ResponseCode::InternalServerError.text("Failed to fetch user")
};
let Ok(json) = serde_json::to_string(&users) else {
return ResponseCode::InternalServerError.text("Failed to fetch user")
};
ResponseCode::Success.json(&json)
}
pub fn router() -> Router {
Router::new()
.route("/load", post(load_batch))
.route("/self", post(load_self))
.route("/page", post(load_page))
.route("/avatar", put(avatar))
.route("/banner", put(banner))
.route("/follow", put(follow).post(follow_status))
.route("/friends", post(friends))
}

View file

@ -1,280 +0,0 @@
const e = require('express')
const database = require('./database.js')
const con = require('./console')
const NO_VALUE = null
const NO_CACHE = undefined
const users = {}
const email_links = {}
const password_links = {}
const session_links = {}
var newest_user = database.getNewestUserId();
const getUserByEmail = (email) => {
const fast = email_links[email]
if (fast === NO_VALUE) {
return undefined
}
if (fast === NO_CACHE) {
const slow = database.getUserByEmail(email)
if (slow === undefined) {
email_links[email] = NO_VALUE
} else {
email_links[email] = slow.id
if (users[slow.id] === NO_CACHE) {
users[slow.id] = slow
}
}
return slow
}
return users[fast]
}
const getUserByPassword = (password) => {
const fast = password_links[password]
if (fast === NO_VALUE) {
return undefined
}
if (fast === NO_CACHE) {
const slow = database.getUserByPassword(password)
if (slow === undefined) {
password_links[password] = NO_VALUE
} else {
password_links[password] = slow.id
if (users[slow.id] === NO_CACHE) {
users[slow.id] = slow
}
}
return slow
}
return users[fast]
}
const getUsers = (ids) => {
const fast = {}
const batch = []
for (const id of ids) {
if (users[id] === NO_CACHE) {
batch.push(id)
} else {
fast[id] = users[id]
}
}
if (batch.length > 0) {
const slow = database.getUsers(batch)
for(const [id, user] of Object.entries(slow)) {
fast[id] = user
}
}
return fast
}
const getUsersPage = (page) => {
const COUNT = 10
const INDEX = newest_user - page * COUNT
const batch = []
for (let i = INDEX; i > INDEX - COUNT && i >= 0; i--) {
batch.push(i)
}
const users = getUsers(batch)
return batch.map(i => users[i]).filter(u => u !== undefined)
}
const register = (first, last, email, password, gender, month, day, year) => {
const data = database.register(first, last, email, password, gender, month, day, year)
if (data === undefined) {
return undefined
}
newest_user = data.user.id
session_links[data.key] = data.user.id
password_links[data.user.password] = data.user.id
email_links[data.user.email] = data.id
users[data.user.id] = data.user
return data.key
}
const login = (email, pass) => {
const data = database.login(email, pass)
if (data === undefined) {
return undefined
}
session_links[data.key] = data.user.id
users[data.user.id] = data.user
return data.key
}
const logout = (token) => {
if (session_links[token] === NO_VALUE || session_links[token] === NO_CACHE) {
return true
}
if (!database.deleteSession(token)) {
return false
}
delete session_links[token]
return true
}
const auth = (token) => {
const fast = session_links[token]
if (fast === NO_VALUE) {
return undefined
}
if (fast === NO_CACHE) {
const slow = database.auth(token)
if (slow === undefined) {
session_links[token] = NO_VALUE
} else {
session_links[token] = slow.id
if (users[slow.id] === NO_CACHE) {
users[slow.id] = slow
}
}
return slow
}
return users[fast]
}
const posts = {}
const users_posts = {}
const updated_posts = {}
var newest_post = database.getNewestPostId();
const addPost = (user, content) => {
const id = database.addPost(user, content)
if (id === undefined) {
return -1
}
newest_post = id
if (users_posts[user] === NO_VALUE) {
users[posts] = [id]
} else if (users_posts[user] === NO_CACHE) {
getUsersPosts(user)
} else {
users_posts[user].unshift(id)
}
return id
}
const getPosts = (ids) => {
const fast = {}
const batch = []
for (const id of ids) {
if (posts[id] === NO_CACHE) {
batch.push(id)
} else {
fast[id] = posts[id]
}
}
if (batch.length > 0) {
const slow = database.getPosts(batch)
for(const [id, post] of Object.entries(slow)) {
fast[id] = post
}
}
return fast
}
const getUsersPosts = (user) => {
const fast = users_posts[user]
if (fast === NO_CACHE) {
const p = database.getUsersPosts(user)
if (p === undefined) {
users_posts[user] = NO_VALUE
} else {
const slow = []
for (const post of p) {
slow.push(post.id)
if (posts[post.id] === NO_CACHE) {
posts[post.id] = post
}
}
users_posts[user] = slow
}
return p
} else {
const posts = getPosts(fast)
return Object.keys(posts).map(k => posts[k]).filter(p => p !== undefined)
}
}
const getPostsPage = (page) => {
const COUNT = 10
const INDEX = newest_post - page * COUNT
const batch = []
for (let i = INDEX; i > INDEX - COUNT && i >= 0; i--) {
batch.push(i)
}
const posts = getPosts(batch)
return batch.map(i => posts[i]).filter(p => p !== undefined)
}
const comment = (id, user, content) => {
var fast = posts[id]
if (fast === NO_VALUE) {
return false
} else if (fast === NO_CACHE) {
const slow = getPosts([id])
if (slow[id] === undefined) {
return false
} else {
fast = slow[id]
}
}
fast.comments.push({user, content})
posts[id] = fast
updated_posts[id] = true
return true
}
const like = (id, user, state) => {
var fast = posts[id]
if (fast === NO_VALUE) {
return false
} else if (fast === NO_CACHE) {
const slow = getPosts([id])
if (slow[id] === undefined) {
return false
} else {
fast = slow[id]
}
}
fast.likes[user] = state
posts[id] = fast
updated_posts[id] = true
return true
}
const dump = () => {
for (id in updated_posts) {
const post = posts[id]
if (post === NO_CACHE || post === NO_VALUE) continue;
if (!database.updatePost(post.id, JSON.stringify(post.likes), JSON.stringify(post.comments))) {
con.error(`Failed to saved cached post id ${id}`)
} else {
delete updated_posts.id
}
}
con.msg('Saved cache successfully')
}
module.exports = {
getUserByEmail,
getUserByPassword,
getUsers,
getUsersPage,
register,
login,
logout,
auth,
addPost,
getPosts,
getUsersPosts,
getPostsPage,
comment,
like,
dump
}

View file

@ -1,84 +0,0 @@
const cheerio = require('cheerio');
const e = require('express');
const parseText = (text) => {
if (typeof text !== 'string') {
return undefined
}
const $ = cheerio.load(text)
return $('body').html()
}
const check = (req, res, params) => {
const result = {}
for(let i = 0; i < params.length;) {
const key = params[i]
const value = req.body[key]
const type = params[i+1]
if (type === 'array') {
if (!Array.isArray(value)) {
res.status(400).send({msg: 'Invalid ' + key})
return undefined
}
const arr_type = params[i+2];
for (const v of value) {
if (typeof v !== arr_type) {
res.status(400).send({msg: 'Invalid ' + key})
return undefined
}
}
i += 1
} else if (value === undefined || value === null || typeof value !== type) {
res.status(400).send({msg: 'Invalid ' + key})
return undefined
}
if (type === 'string') {
const min = params[i+2]
const max = params[i+3]
if (value.length < min || value.length > max) {
res.status(400).send({msg: 'Invalid ' + key})
return undefined
}
result[key] = parseText(value)
i += 4
} else {
result[key] = value;
i += 2
}
}
return result
}
module.exports = check

View file

@ -1,148 +0,0 @@
const express = require('express')
const router = express.Router()
const msg = (msg) => {
requests.push({msg})
}
const error = (error) => {
requests.push({error})
}
const log = (ip, method, path, body) => {
console.log(ip, method, path, body)
requests.push({ip, method, path, body})
}
var requests = []
const method = (method) => {
switch(method) {
case 'GET':
return '4ae04a'
case 'POST':
return 'b946db'
case 'PUT':
return 'ff9705'
case 'PATCH':
return `42caff`
case 'DELETE':
return `ff4a4a`
case 'HEAD':
return '424cff'
case 'OPTIONS':
return 'ff9757'
}
}
const json = (json) => {
if (typeof json != 'string') {
json = JSON.stringify(json, undefined, 2);
}
json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function (match) {
var cls = 'number';
if (/^"/.test(match)) {
if (/:$/.test(match)) {
cls = 'key';
} else {
cls = 'string';
}
} else if (/true|false/.test(match)) {
cls = 'boolean';
} else if (/null/.test(match)) {
cls = 'null';
}
return '<span class="' + cls + '">' + match + '</span>';
});
}
const parse = (req) => {
var html;
if (req.msg !== undefined) {
html = `
<div>
<span style="color: #00aaff">SERVER MESSAGE: ${req.msg}</span>
</div>
`
} else if (req.error !== undefined) {
html = `
<div>
<span style="color: #ff4050">SERVER ERROR: ${req.error}</span>
</div>
`
} else {
html = `
<div>
<span class="ip">${req.ip}</span>
<span class="method" style="color: #${method(req.method)}">${req.method}</span>
<span class="path">${req.path}</span>
<span class="json">${json(req.body)}</span>
</div>
`
}
return html
}
const render = () => {
if (requests.length > 200) {
requests.splice(0, 100)
}
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="5">
<link rel="stylesheet" href="css/console.css">
<title>XSSBook - Console</title>
</head>
<body>
${requests.map(r => parse(r)).join('')}
</body>
</html>
`
return html
}
module.exports = { render, log, msg, error }

View file

@ -1,343 +0,0 @@
const Database = require('better-sqlite3')
const crypto = require('crypto')
const createDatabase = () => {
try {
var db = new Database('xssbook.db', { fileMustExist: true });
return db
} catch (err) {
var db = new Database('xssbook.db', {});
createTables(db);
return db
}
}
const createTables = (db) => {
db.exec(`
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
first VARCHAR(20) NOT NULL,
last VARCHAR(20) NOT NULL,
email VARCHAR(50) NOT NULL,
password VARCHAR(50) NOT NULL,
gender VARCHAR(100) NOT NULL,
date INTEGER NOT NULL,
month VARCHAR(10) NOT NULL,
day INTEGER NOT NULL,
year INTEGER NOT NULL
);
CREATE TABLE posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user INTEGER NOT NULL,
content TEXT NOT NULL,
likes TEXT NOT NULL,
comments TEXT NOT NULL,
date INTEGER NOT NULL,
FOREIGN KEY(user) REFERENCES users(id)
);
CREATE TABLE avatars (
id INTEGER PRIMARY KEY NOT NULL,
avatar BLOB,
banner BLOB,
FOREIGN KEY(id) REFERENCES users(id)
);
CREATE TABLE sessions (
user INTEGER PRIMARY KEY NOT NULL,
token TEXT NOT NULL,
FOREIGN KEY(user) REFERENCES users(id)
);
`);
}
const db = createDatabase()
const token = () => { return crypto.randomBytes(32).toString('hex') }
const register = (first, last, email, password, gender, month, day, year) => {
try {
var key = undefined;
var u = undefined
db.transaction(() => {
if (!addUser(first, last, email, password, gender, Date.now(), month, day, year)) {
throw new Error('Failed to register user');
}
const user = getUserByEmail(email);
if (user === undefined) {
throw new Error('Failed to register user');
}
u = user
key = token()
if (!setSession(user.id, key)) {
throw new Error('Failed to register user');
}
})()
return {key, user: u}
} catch (err) {
return undefined
}
}
const login = (email, pass) => {
try {
var key = undefined
var u = undefined
db.transaction(() => {
const user = getUserByEmail(email);
if (user === undefined) {
throw new Error('Failed to login user')
}
if (user.password !== pass) {
throw new Error("Failed to login user")
}
u = user
key = token()
if (!setSession(user.id, key)) {
throw new Error('Failed to login user');
}
})()
return {key, user: u}
} catch (err) {
return undefined
}
}
const auth = (token) => {
try {
var user = undefined;
db.transaction(() => {
const session = getSession(token)
if (session === undefined) {
throw new Error('Failed to auth user')
}
const u = getUserById(session.user);
if (u === undefined) {
throw new Error('Failed to auth user')
}
user = u;
})()
return user
} catch (err) {
return undefined
}
}
const getUserById = (id) => {
try {
const stmt = db.prepare('SELECT * FROM users WHERE id = @id;')
const info = stmt.get({id})
if (info === undefined) return undefined
return info
} catch (err) {
console.log(err)
return undefined
}
}
const getUserByEmail = (email) => {
try {
const stmt = db.prepare('SELECT * FROM users WHERE email = @email;')
const info = stmt.get({email})
if (info === undefined) return undefined
return info
} catch (err) {
console.log(err)
return undefined
}
}
const getUserByPassword = (password) => {
try {
const stmt = db.prepare('SELECT * FROM users WHERE password = @password;')
const info = stmt.get({password})
if (info === undefined) return undefined
return info
} catch (err) {
console.log(err)
return undefined
}
}
const addUser = (first, last, email, password, gender, date, month, day, year) => {
try {
const stmt = db.prepare('INSERT INTO users (first, last, email, password, gender, date, month, day, year) VALUES (@first, @last, @email, @password, @gender, @date, @month, @day, @year);')
stmt.run({first, last, email, password, gender, date, month, day, year})
return true
} catch (err) {
console.log(err)
return false
}
}
const getUsers = (ids) => {
try {
const stmt = db.prepare('SELECT * FROM users WHERE id = @id;')
const people = {}
db.transaction((ids) => {
for (const id of ids) {
const info = stmt.get({id})
if (info === undefined) continue;
delete info.password
people[id] = info
}
})(ids)
return people
} catch (err) {
console.log(err)
return {}
}
}
const getNewestUserId = () => {
try {
const stmt = db.prepare('SELECT MAX(id) FROM users')
const info = stmt.get({})
if (info === undefined || info['MAX(id)'] === undefined) {
return 0
}
return info['MAX(id)']
} catch (err) {
console.log(err)
return 0
}
}
const addPost = (user, content) => {
try {
var id = undefined
db.transaction(() => {
const stmt = db.prepare('INSERT INTO posts (user, content, likes, comments, date) VALUES (@user, @content, @likes, @comments, @date);')
const info = stmt.run({user, content, likes: "{}", comments: "[]", date: Date.now()})
if (info.changes !== 1) {
throw new Error('Failed to create post')
}
const last = db.prepare('SELECT last_insert_rowid();').get({})
if (last === undefined || last['last_insert_rowid()'] === undefined) {
throw new Error('Failed to get new post')
}
id = last['last_insert_rowid()']
})()
return id
} catch (err) {
console.log(err)
return undefined
}
}
const getPosts = (ids) => {
try {
const stmt = db.prepare('SELECT * FROM posts WHERE id = @id;')
const posts = {}
db.transaction((ids) => {
for (const id of ids) {
const info = stmt.get({id})
if (info === undefined) continue;
info.comments = JSON.parse(info.comments)
info.likes = JSON.parse(info.likes)
posts[id] = info
}
})(ids)
return posts
} catch (err) {
console.log(err)
return {}
}
}
const getUsersPosts = (user) => {
try {
const stmt = db.prepare('SELECT * FROM posts WHERE user = @user ORDER BY id DESC;')
const info = stmt.all({user});
if (info === undefined || info === {}) {
return []
}
for (const post of info) {
post.likes = JSON.parse(post.likes)
post.comments = JSON.parse(post.comments)
}
return info
} catch (err) {
console.log(err)
return []
}
}
const updatePost = (id, likes, comments) => {
try {
const stmt = db.prepare('UPDATE posts SET likes = @likes, comments = @comments WHERE id = @id')
const info = stmt.run({likes, comments, id})
return info.changes === 1
} catch (err) {
console.log(err)
return false
}
}
const getNewestPostId = () => {
try {
const stmt = db.prepare('SELECT MAX(id) FROM posts')
const info = stmt.get({})
if (info === undefined || info['MAX(id)'] === undefined) {
return 0
}
return info['MAX(id)']
} catch (err) {
console.log(err)
return 0
}
}
const setSession = (user, token) => {
try {
const stmt = db.prepare('INSERT OR REPLACE INTO sessions (user, token) VALUES (@user, @token);')
stmt.run({user, token})
return true
} catch (err) {
console.log(err)
return false
}
}
const getSession = (token) => {
try {
const stmt = db.prepare('SELECT * FROM sessions WHERE token = @token;')
const info = stmt.get({token})
if (info === undefined) return undefined
return info
} catch (err) {
console.log(err)
return undefined
}
}
const deleteSession = (token) => {
try {
const stmt = db.prepare('DELETE FROM sessions WHERE token = @token;')
const info = stmt.run({token})
return info.changes === 1
} catch (err) {
console.log(err)
return false
}
}
module.exports = {
getUserById,
getUserByEmail,
getUserByPassword,
getUsers,
getNewestUserId,
register,
login,
auth,
addPost,
getPosts,
getUsersPosts,
getNewestPostId,
updatePost,
getSession,
setSession,
deleteSession
}

211
src/database/chat.rs Normal file
View file

@ -0,0 +1,211 @@
use std::time::{SystemTime, UNIX_EPOCH, Duration};
use tracing::instrument;
use crate::types::chat::{ChatRoom, ChatMessage};
use super::Database;
impl Database {
#[instrument(skip(self))]
pub fn init_chat(&self) -> Result<(), rusqlite::Error> {
let sql = "
CREATE TABLE IF NOT EXISTS chat_rooms (
room_id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL
);
";
self.0.execute(sql, ())?;
let sql2 = "
CREATE TABLE IF NOT EXISTS chat_users (
room_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
FOREIGN KEY(room_id) REFERENCES chat_rooms(room_id),
FOREIGN KEY(user_id) REFERENCES users(user_id),
PRIMARY KEY (room_id, user_id)
);
";
self.0.execute(sql2, ())?;
let sql3 = "
CREATE TABLE IF NOT EXISTS chat_messages (
message_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
room_id INTEGER NOT NULL,
date INTEGER NOT NULL,
content VARCHAR(5000) NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(user_id),
FOREIGN KEY(room_id) REFERENCES chat_rooms(room_id)
);
";
self.0.execute(sql3, ())?;
let sql4 = "CREATE INDEX IF NOT EXISTS chat_message_ids ON chat_messages(room_id);";
self.0.execute(sql4, ())?;
Ok(())
}
#[instrument(skip(self))]
pub fn get_rooms(&self, user_id: u64) -> Result<Vec<ChatRoom>, rusqlite::Error> {
tracing::trace!("Retrieving rooms");
let mut stmt = self.0.prepare(
"
SELECT * FROM chat_rooms
WHERE room_id IN (
SELECT room_id
FROM chat_users
WHERE user_id = ?
);
",
)?;
let row = stmt.query_map([user_id], |row| {
let room_id: u64 = row.get(0)?;
let name: String = row.get(1)?;
let mut stmt2 = self.0.prepare(
"
SELECT user_id FROM chat_users
WHERE room_id = ?;
"
)?;
let users = stmt2.query_map([room_id], |row2| {
Ok(row2.get(0)?)
})?.into_iter().flatten().collect();
let room = ChatRoom {
room_id,
users,
name
};
Ok(room)
})?;
Ok(row.into_iter().flatten().collect())
}
#[instrument(skip(self))]
pub fn create_room(&self, users: Vec<u64>, name: String) -> Result<ChatRoom, rusqlite::Error> {
tracing::trace!("Creating new room");
let mut stmt = self.0.prepare(
"INSERT INTO chat_rooms (name) VALUES (?) RETURNING *;"
)?;
let mut room = stmt.query_row([name], |row| {
let room_id = row.get(0)?;
let name = row.get(1)?;
Ok(ChatRoom {
room_id,
users: Vec::new(),
name
})
})?;
let mut stmt2 = self.0.prepare(
"INSERT INTO chat_users (room_id, user_id) VALUES (?, ?);"
)?;
for user_id in users {
stmt2.execute([room.room_id, user_id])?;
room.users.push(user_id);
}
Ok(room)
}
#[instrument(skip(self))]
pub fn add_user_to_room(&self, room_id: u64, user_id: u64) -> Result<bool, rusqlite::Error> {
tracing::trace!("Adding user to room");
let mut stmt = self.0.prepare(
"INSERT OR REPLACE INTO chat_users (room_id, user_id) VALUES(?,?);"
)?;
let changes = stmt.execute([room_id, user_id])?;
Ok(changes == 1)
}
#[instrument(skip(self))]
pub fn remove_user_from_room(&self, room_id: u64, user_id: u64) -> Result<bool, rusqlite::Error> {
tracing::trace!("Removing user from room");
let mut stmt = self.0.prepare(
"DELETE FROM chat_users WHERE room_id = ? AND user_id = ?;"
)?;
let changes = stmt.execute([room_id, user_id])?;
Ok(changes == 1)
}
#[instrument(skip(self))]
pub fn create_message(&self, room_id: u64, user_id: u64, content: String) -> Result<ChatMessage, rusqlite::Error> {
tracing::trace!("Creating new chat message");
let date = u64::try_from(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_millis(),
)
.unwrap_or(0);
let mut stmt = self.0.prepare(
"INSERT INTO chat_messages (user_id, room_id, date, content) VALUES (?,?,?,?) RETURNING *;"
)?;
let msg = stmt.query_row((user_id, room_id, date, content), |row| {
let message_id = row.get(0)?;
let user_id = row.get(1)?;
let room_id = row.get(2)?;
let date = row.get(3)?;
let content = row.get(4)?;
Ok(ChatMessage {
message_id,
room_id,
user_id,
date,
content
})
})?;
Ok(msg)
}
#[instrument(skip(self))]
pub fn load_old_chat_messages(&self, room_id: u64, newest_message: u64, page: u64) -> Result<Vec<ChatMessage>, rusqlite::Error> {
tracing::trace!("Loading old chat messages");
let mut stmt = self.0.prepare(
"
SELECT * FROM chat_messages
WHERE room_id = ?
AND message_id < ?
ORDER BY message_id DESC
LIMIT ?
OFFSET ?
"
)?;
let messages = stmt.query_map((room_id, newest_message, 20, 20 * page), |row| {
let message_id = row.get(0)?;
let user_id = row.get(1)?;
let room_id = row.get(2)?;
let date = row.get(3)?;
let content = row.get(4)?;
Ok(ChatMessage {
message_id,
room_id,
user_id,
date,
content
})
})?;
Ok(messages.into_iter().flatten().collect())
}
}

102
src/database/comments.rs Normal file
View file

@ -0,0 +1,102 @@
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use rusqlite::Row;
use tracing::instrument;
use crate::types::comment::Comment;
use super::Database;
impl Database {
pub fn init_comments(&self) -> Result<(), rusqlite::Error> {
let sql = "
CREATE TABLE IF NOT EXISTS comments (
comment_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
post_id INTEGER NOT NULL,
date INTEGER NOT NULL,
content VARCHAR(2000) NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(user_id),
FOREIGN KEY(post_id) REFERENCES posts(post_id)
);
";
self.0.execute(sql, ())?;
let sql2 = "CREATE INDEX IF NOT EXISTS post_ids on comments (post_id);";
self.0.execute(sql2, ())?;
Ok(())
}
fn comment_from_row(row: &Row) -> Result<Comment, rusqlite::Error> {
let comment_id = row.get(0)?;
let user_id = row.get(1)?;
let post_id = row.get(2)?;
let date = row.get(3)?;
let content = row.get(4)?;
Ok(Comment {
comment_id,
user_id,
post_id,
date,
content,
})
}
#[instrument(skip(self))]
pub fn get_comments_page(
&self,
page: u64,
post_id: u64,
) -> Result<Vec<Comment>, rusqlite::Error> {
tracing::trace!("Retrieving comments page");
let page_size = 5;
let mut stmt = self.0.prepare(
"SELECT * FROM comments WHERE post_id = ? ORDER BY comment_id ASC LIMIT ? OFFSET ?",
)?;
let row = stmt.query_map([post_id, page_size, page_size * page], |row| {
let row = Self::comment_from_row(row)?;
Ok(row)
})?;
Ok(row.into_iter().flatten().collect())
}
#[instrument(skip(self))]
pub fn get_all_comments(&self) -> Result<Vec<Comment>, rusqlite::Error> {
tracing::trace!("Retrieving comments page");
let mut stmt = self
.0
.prepare("SELECT * FROM comments ORDER BY comment_id DESC")?;
let row = stmt.query_map([], |row| {
let row = Self::comment_from_row(row)?;
Ok(row)
})?;
Ok(row.into_iter().flatten().collect())
}
#[instrument(skip(self))]
pub fn add_comment(
&self,
user_id: u64,
post_id: u64,
content: &str,
) -> Result<Comment, rusqlite::Error> {
tracing::trace!("Adding comment");
let date = u64::try_from(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_millis(),
)
.unwrap_or(0);
let mut stmt = self.0.prepare(
"INSERT INTO comments (user_id, post_id, date, content) VALUES(?,?,?,?) RETURNING *;",
)?;
let post = stmt.query_row((user_id, post_id, date, content), |row| {
let row = Self::comment_from_row(row)?;
Ok(row)
})?;
Ok(post)
}
}

144
src/database/friends.rs Normal file
View file

@ -0,0 +1,144 @@
use tracing::instrument;
use crate::types::user::{User, FOLLOWED, FOLLOWING, NO_RELATION};
use super::Database;
impl Database {
pub fn init_friends(&self) -> Result<(), rusqlite::Error> {
let sql = "
CREATE TABLE IF NOT EXISTS friends (
follower_id INTEGER NOT NULL,
followee_id INTEGER NOT NULL,
FOREIGN KEY(follower_id) REFERENCES users(user_id),
FOREIGN KEY(followee_id) REFERENCES users(user_id),
PRIMARY KEY (follower_id, followee_id)
);
";
self.0.execute(sql, ())?;
Ok(())
}
#[instrument(skip(self))]
pub fn get_friend_status(&self, user_id_1: u64, user_id_2: u64) -> Result<u8, rusqlite::Error> {
tracing::trace!("Retrieving friend status");
let mut stmt = self.0.prepare("SELECT * FROM friends WHERE (follower_id = ? AND followee_id = ?) OR (follower_id = ? AND followee_id = ?);")?;
let mut status = NO_RELATION;
let rows: Vec<u64> = stmt
.query_map([user_id_1, user_id_2, user_id_2, user_id_1], |row| {
let id: u64 = row.get(0)?;
Ok(id)
})?
.into_iter()
.flatten()
.collect();
for follower in rows {
if follower == user_id_1 {
status |= FOLLOWING;
}
if follower == user_id_2 {
status |= FOLLOWED;
}
}
Ok(status)
}
#[instrument(skip(self))]
pub fn get_friends(&self, user_id: u64) -> Result<Vec<User>, rusqlite::Error> {
tracing::trace!("Retrieving friends");
let mut stmt = self.0.prepare(
"
SELECT *
FROM users u
WHERE EXISTS (
SELECT NULL
FROM friends f
WHERE u.user_id = f.follower_id
AND f.followee_id = ?
)
AND EXISTS (
SELECT NULL
FROM friends f
WHERE u.user_id = f.followee_id
AND f.follower_id = ?
)
",
)?;
let row = stmt.query_map([user_id, user_id], |row| {
let row = Self::user_from_row(row, true)?;
Ok(row)
})?;
Ok(row.into_iter().flatten().collect())
}
#[instrument(skip(self))]
pub fn get_followers(&self, user_id: u64) -> Result<Vec<User>, rusqlite::Error> {
tracing::trace!("Retrieving friends");
let mut stmt = self.0.prepare(
"
SELECT *
FROM users u
WHERE EXISTS (
SELECT NULL
FROM friends f
WHERE u.user_id = f.follower_id
AND f.followee_id = ?
)
",
)?;
let row = stmt.query_map([user_id], |row| {
let row = Self::user_from_row(row, true)?;
Ok(row)
})?;
Ok(row.into_iter().flatten().collect())
}
#[instrument(skip(self))]
pub fn get_following(&self, user_id: u64) -> Result<Vec<User>, rusqlite::Error> {
tracing::trace!("Retrieving friends");
let mut stmt = self.0.prepare(
"
SELECT *
FROM users u
WHERE EXISTS (
SELECT NULL
FROM friends f
WHERE f.follower_id = ?
AND u.user_id = f.followee_id
)
",
)?;
let row = stmt.query_map([user_id], |row| {
let row = Self::user_from_row(row, true)?;
Ok(row)
})?;
Ok(row.into_iter().flatten().collect())
}
#[instrument(skip(self))]
pub fn set_following(&self, user_id_1: u64, user_id_2: u64) -> Result<bool, rusqlite::Error> {
tracing::trace!("Setting following");
let mut stmt = self
.0
.prepare("INSERT OR REPLACE INTO friends (follower_id, followee_id) VALUES (?,?)")?;
let changes = stmt.execute([user_id_1, user_id_2])?;
Ok(changes == 1)
}
#[instrument(skip(self))]
pub fn remove_following(
&self,
user_id_1: u64,
user_id_2: u64,
) -> Result<bool, rusqlite::Error> {
tracing::trace!("Removing following");
let mut stmt = self
.0
.prepare("DELETE FROM friends WHERE follower_id = ? AND followee_id = ?")?;
let changes = stmt.execute([user_id_1, user_id_2])?;
Ok(changes == 1)
}
}

81
src/database/likes.rs Normal file
View file

@ -0,0 +1,81 @@
use rusqlite::OptionalExtension;
use tracing::instrument;
use crate::types::like::Like;
use super::Database;
impl Database {
pub fn init_likes(&self) -> Result<(), rusqlite::Error> {
let sql = "
CREATE TABLE IF NOT EXISTS likes (
user_id INTEGER NOT NULL,
post_id INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(user_id),
FOREIGN KEY(post_id) REFERENCES posts(post_id),
PRIMARY KEY (user_id, post_id)
);
";
self.0.execute(sql, ())?;
Ok(())
}
#[instrument(skip(self))]
pub fn get_like_count(&self, post_id: u64) -> Result<Option<u64>, rusqlite::Error> {
tracing::trace!("Retrieving like count");
let mut stmt = self
.0
.prepare("SELECT COUNT(post_id) FROM likes WHERE post_id = ?")?;
let row = stmt
.query_row([post_id], |row| {
let row = row.get(0)?;
Ok(row)
})
.optional()?;
Ok(row)
}
#[instrument(skip(self))]
pub fn get_liked(&self, user_id: u64, post_id: u64) -> Result<bool, rusqlite::Error> {
tracing::trace!("Retrieving if liked");
let mut stmt = self
.0
.prepare("SELECT * FROM likes WHERE user_id = ? AND post_id = ?")?;
let liked = stmt.query_row([user_id, post_id], |_| Ok(())).optional()?;
Ok(liked.is_some())
}
#[instrument(skip(self))]
pub fn add_liked(&self, user_id: u64, post_id: u64) -> Result<bool, rusqlite::Error> {
tracing::trace!("Adding like");
let mut stmt = self
.0
.prepare("INSERT OR REPLACE INTO likes (user_id, post_id) VALUES (?,?)")?;
let changes = stmt.execute([user_id, post_id])?;
Ok(changes == 1)
}
#[instrument(skip(self))]
pub fn remove_liked(&self, user_id: u64, post_id: u64) -> Result<bool, rusqlite::Error> {
tracing::trace!("Removing like");
let mut stmt = self
.0
.prepare("DELETE FROM likes WHERE user_id = ? AND post_id = ?;")?;
let changes = stmt.execute((user_id, post_id))?;
Ok(changes == 1)
}
#[instrument(skip(self))]
pub fn get_all_likes(&self) -> Result<Vec<Like>, rusqlite::Error> {
tracing::trace!("Retrieving comments page");
let mut stmt = self.0.prepare("SELECT * FROM likes")?;
let row = stmt.query_map([], |row| {
let like = Like {
user_id: row.get(0)?,
post_id: row.get(1)?,
};
Ok(like)
})?;
Ok(row.into_iter().flatten().collect())
}
}

38
src/database/mod.rs Normal file
View file

@ -0,0 +1,38 @@
use rusqlite::Connection;
use tracing::instrument;
pub mod chat;
pub mod comments;
pub mod friends;
pub mod likes;
pub mod posts;
pub mod sessions;
pub mod users;
#[derive(Debug)]
pub struct Database(Connection);
impl Database {
pub fn connect() -> Result<Self, rusqlite::Error> {
let conn = rusqlite::Connection::open("xssbook.db")?;
Ok(Self(conn))
}
#[instrument(skip(self))]
pub fn query(&self, query: String) -> Result<usize, rusqlite::Error> {
tracing::trace!("Running custom query");
self.0.execute(&query, [])
}
}
pub fn init() -> Result<(), rusqlite::Error> {
let db = Database::connect()?;
db.init_users()?;
db.init_posts()?;
db.init_sessions()?;
db.init_likes()?;
db.init_comments()?;
db.init_friends()?;
db.init_chat()?;
Ok(())
}

124
src/database/posts.rs Normal file
View file

@ -0,0 +1,124 @@
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use rusqlite::{OptionalExtension, Row};
use tracing::instrument;
use crate::types::post::Post;
use super::Database;
impl Database {
pub fn init_posts(&self) -> Result<(), rusqlite::Error> {
let sql = "
CREATE TABLE IF NOT EXISTS posts (
post_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
content VARCHAR(5000) NOT NULL,
date INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(user_id)
);
";
self.0.execute(sql, ())?;
Ok(())
}
fn post_from_row(&self, row: &Row) -> Result<Post, rusqlite::Error> {
let post_id = row.get(0)?;
let user_id = row.get(1)?;
let content = row.get(2)?;
let date = row.get(3)?;
let comments = self
.get_comments_page(0, post_id)
.unwrap_or_else(|_| Vec::new());
let likes = self.get_like_count(post_id).unwrap_or(None).unwrap_or(0);
Ok(Post {
post_id,
user_id,
content,
date,
likes,
liked: false,
comments,
})
}
#[instrument(skip(self))]
pub fn get_post(&self, post_id: u64) -> Result<Option<Post>, rusqlite::Error> {
tracing::trace!("Retrieving post");
let mut stmt = self.0.prepare("SELECT * FROM posts WHERE post_id = ?")?;
let row = stmt
.query_row([post_id], |row| {
let row = self.post_from_row(row)?;
Ok(row)
})
.optional()?;
Ok(row)
}
#[instrument(skip(self))]
pub fn get_post_page(&self, page: u64) -> Result<Vec<Post>, rusqlite::Error> {
tracing::trace!("Retrieving posts page");
let page_size = 10;
let mut stmt = self
.0
.prepare("SELECT * FROM posts ORDER BY post_id DESC LIMIT ? OFFSET ?")?;
let row = stmt.query_map([page_size, page_size * page], |row| {
let row = self.post_from_row(row)?;
Ok(row)
})?;
Ok(row.into_iter().flatten().collect())
}
#[instrument(skip(self))]
pub fn get_all_posts(&self) -> Result<Vec<Post>, rusqlite::Error> {
tracing::trace!("Retrieving posts page");
let mut stmt = self
.0
.prepare("SELECT * FROM posts ORDER BY post_id DESC")?;
let row = stmt.query_map([], |row| {
let row = self.post_from_row(row)?;
Ok(row)
})?;
Ok(row.into_iter().flatten().collect())
}
#[instrument(skip(self))]
pub fn get_users_post_page(
&self,
user_id: u64,
page: u64,
) -> Result<Vec<Post>, rusqlite::Error> {
tracing::trace!("Retrieving users posts");
let page_size = 10;
let mut stmt = self.0.prepare(
"SELECT * FROM posts WHERE user_id = ? ORDER BY post_id DESC LIMIT ? OFFSET ?",
)?;
let row = stmt.query_map([user_id, page_size, page_size * page], |row| {
let row = self.post_from_row(row)?;
Ok(row)
})?;
Ok(row.into_iter().flatten().collect())
}
#[instrument(skip(self))]
pub fn add_post(&self, user_id: u64, content: &str) -> Result<Post, rusqlite::Error> {
tracing::trace!("Adding post");
let date = u64::try_from(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_millis(),
)
.unwrap_or(0);
let mut stmt = self
.0
.prepare("INSERT INTO posts (user_id, content, date) VALUES(?,?,?) RETURNING *;")?;
let post = stmt.query_row((user_id, content, date), |row| {
let row = self.post_from_row(row)?;
Ok(row)
})?;
Ok(post)
}
}

64
src/database/sessions.rs Normal file
View file

@ -0,0 +1,64 @@
use rusqlite::OptionalExtension;
use tracing::instrument;
use crate::types::session::Session;
use super::Database;
impl Database {
pub fn init_sessions(&self) -> Result<(), rusqlite::Error> {
let sql = "
CREATE TABLE IF NOT EXISTS sessions (
user_id INTEGER PRIMARY KEY NOT NULL,
token TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(user_id)
);
";
self.0.execute(sql, ())?;
Ok(())
}
#[instrument(skip(self))]
pub fn get_session(&self, token: &str) -> Result<Option<Session>, rusqlite::Error> {
tracing::trace!("Retrieving session");
let mut stmt = self.0.prepare("SELECT * FROM sessions WHERE token = ?")?;
let row = stmt
.query_row([token], |row| {
Ok(Session {
user_id: row.get(0)?,
token: row.get(1)?,
})
})
.optional()?;
Ok(row)
}
#[instrument(skip(self))]
pub fn get_all_sessions(&self) -> Result<Vec<Session>, rusqlite::Error> {
tracing::trace!("Retrieving session");
let mut stmt = self.0.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(skip(self))]
pub fn set_session(&self, user_id: u64, token: &str) -> Result<(), Box<dyn std::error::Error>> {
tracing::trace!("Setting new session");
let sql = "INSERT OR REPLACE INTO sessions (user_id, token) VALUES (?, ?);";
self.0.execute(sql, (user_id, token))?;
Ok(())
}
#[instrument(skip(self))]
pub fn delete_session(&self, user_id: u64) -> Result<(), Box<dyn std::error::Error>> {
tracing::trace!("Deleting session");
let sql = "DELETE FROM sessions WHERE user_id = ?;";
self.0.execute(sql, [user_id])?;
Ok(())
}
}

181
src/database/users.rs Normal file
View file

@ -0,0 +1,181 @@
use rusqlite::{OptionalExtension, Row};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tracing::instrument;
use crate::{api::RegistrationRequet, types::user::User};
use super::Database;
impl Database {
pub fn init_users(&self) -> Result<(), rusqlite::Error> {
let sql = "
CREATE TABLE IF NOT EXISTS users (
user_id INTEGER PRIMARY KEY AUTOINCREMENT,
firstname VARCHAR(20) NOT NULL,
lastname VARCHAR(20) NOT NULL,
email VARCHAR(50) NOT NULL,
password VARCHAR(50) NOT NULL,
gender VARCHAR(100) NOT NULL,
date BIGINT NOT NULL,
day TINYINT NOT NULL,
month TINYINT NOT NULL,
year INTEGER NOT NULL
);
";
self.0.execute(sql, ())?;
let sql2 = "CREATE UNIQUE INDEX IF NOT EXISTS emails on users (email);";
self.0.execute(sql2, ())?;
let sql3 = "CREATE UNIQUE INDEX IF NOT EXISTS passwords on users (password);";
self.0.execute(sql3, ())?;
Ok(())
}
pub fn user_from_row(row: &Row, hide_password: bool) -> Result<User, rusqlite::Error> {
let user_id = row.get(0)?;
let firstname = row.get(1)?;
let lastname = row.get(2)?;
let email = row.get(3)?;
let password = row.get(4)?;
let gender = row.get(5)?;
let date = row.get(6)?;
let day = row.get(7)?;
let month = row.get(8)?;
let year = row.get(9)?;
let password = if hide_password {
String::new()
} else {
password
};
Ok(User {
user_id,
firstname,
lastname,
email,
password,
gender,
date,
day,
month,
year,
})
}
#[instrument(skip(self))]
pub fn get_user_by_id(
&self,
user_id: u64,
hide_password: bool,
) -> Result<Option<User>, rusqlite::Error> {
tracing::trace!("Retrieving user by id");
let mut stmt = self.0.prepare("SELECT * FROM users WHERE user_id = ?")?;
let row = stmt
.query_row([user_id], |row| {
let row = Self::user_from_row(row, hide_password)?;
Ok(row)
})
.optional()?;
Ok(row)
}
#[instrument(skip(self))]
pub fn get_user_by_email(
&self,
email: &str,
hide_password: bool,
) -> Result<Option<User>, rusqlite::Error> {
tracing::trace!("Retrieving user by email");
let mut stmt = self.0.prepare("SELECT * FROM users WHERE email = ?")?;
let row = stmt
.query_row([email], |row| {
let row = Self::user_from_row(row, hide_password)?;
Ok(row)
})
.optional()?;
Ok(row)
}
#[instrument(skip(self))]
pub fn get_user_by_password(
&self,
password: &str,
hide_password: bool,
) -> Result<Option<User>, rusqlite::Error> {
tracing::trace!("Retrieving user by password");
let mut stmt = self.0.prepare("SELECT * FROM users WHERE password = ?")?;
let row = stmt
.query_row([password], |row| {
let row = Self::user_from_row(row, hide_password)?;
Ok(row)
})
.optional()?;
Ok(row)
}
#[instrument(skip(self))]
pub fn get_user_page(
&self,
page: u64,
hide_password: bool,
) -> Result<Vec<User>, rusqlite::Error> {
tracing::trace!("Retrieving user page");
let page_size = 5;
let mut stmt = self
.0
.prepare("SELECT * FROM users ORDER BY user_id DESC LIMIT ? OFFSET ?")?;
let row = stmt.query_map([page_size, page_size * page], |row| {
let row = Self::user_from_row(row, hide_password)?;
Ok(row)
})?;
Ok(row.into_iter().flatten().collect())
}
#[instrument(skip(self))]
pub fn get_all_users(&self) -> Result<Vec<User>, rusqlite::Error> {
tracing::trace!("Retrieving user page");
let mut stmt = self
.0
.prepare("SELECT * FROM users ORDER BY user_id DESC")?;
let row = stmt.query_map([], |row| {
let row = Self::user_from_row(row, false)?;
Ok(row)
})?;
Ok(row.into_iter().flatten().collect())
}
#[instrument(skip(self))]
pub fn add_user(&self, request: RegistrationRequet) -> Result<User, rusqlite::Error> {
tracing::trace!("Adding new user");
let date = u64::try_from(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_millis(),
)
.unwrap_or(0);
let mut stmt = self.0.prepare("INSERT INTO users (firstname, lastname, email, password, gender, date, day, month, year) VALUES(?,?,?,?,?,?,?,?,?) RETURNING *;")?;
let user = stmt.query_row(
(
request.firstname,
request.lastname,
request.email,
request.password,
request.gender,
date,
request.day,
request.month,
request.year,
),
|row| {
let row = Self::user_from_row(row, false)?;
Ok(row)
},
)?;
Ok(user)
}
}

94
src/main.rs Normal file
View file

@ -0,0 +1,94 @@
use axum::{
body::HttpBody,
extract::DefaultBodyLimit,
http::Request,
middleware::{self, Next},
response::Response,
RequestExt, Router,
};
use public::console;
use std::{fs, net::SocketAddr, process::exit};
use tower_cookies::CookieManagerLayer;
use tracing::{error, info, metadata::LevelFilter};
use tracing_subscriber::{
filter::filter_fn, prelude::__tracing_subscriber_SubscriberExt, util::SubscriberInitExt, Layer,
};
use std::env;
use types::extract::RequestIp;
use crate::public::docs;
mod api;
mod database;
mod public;
mod types;
async fn log<B>(mut req: Request<B>, next: Next<B>) -> Response
where
B: Send + Sync + 'static + HttpBody,
{
let Ok(RequestIp(ip)) = req.extract_parts::<RequestIp>().await else {
return next.run(req).await
};
console::log(ip, req.method().clone(), req.uri().clone(), None, None).await;
next.run(req).await
}
async fn not_found() -> Response {
public::serve("/404.html").await
}
#[tokio::main]
async fn main() {
let fmt_layer = tracing_subscriber::fmt::layer();
tracing_subscriber::registry()
.with(
fmt_layer
.with_filter(LevelFilter::INFO)
.with_filter(filter_fn(|metadata| {
metadata.target().starts_with("xssbook")
})),
)
.init();
if database::init().is_err() {
error!("Failed to connect to the sqlite database");
exit(1)
};
docs::init().await;
fs::create_dir_all("./public/image/custom").expect("Coudn't make custom data directory");
fs::create_dir_all("./public/image/custom/avatar")
.expect("Coudn't make custom avatar directory");
fs::create_dir_all("./public/image/custom/banner")
.expect("Coudn't make custom banner directory");
let app = Router::new()
.fallback(not_found)
.layer(middleware::from_fn(log))
.nest("/", public::router())
.nest("/api", api::router())
.layer(CookieManagerLayer::new())
.layer(DefaultBodyLimit::max(512_000));
let port: u16 = env::var("PORT")
.unwrap_or_else(|_| String::new())
.parse::<u16>()
.unwrap_or(8080);
let Ok(addr) = format!("[::]:{port}")
.parse::<std::net::SocketAddr>() else {
error!("Failed to parse port binding");
exit(1)
};
info!("listening on {}", addr);
axum::Server::bind(&addr)
.serve(app.into_make_service_with_connect_info::<SocketAddr>())
.await
.unwrap_or(());
}

180
src/public/admin.rs Normal file
View file

@ -0,0 +1,180 @@
use axum::response::Response;
use lazy_static::lazy_static;
use rand::{distributions::Alphanumeric, Rng};
use tokio::sync::Mutex;
use crate::{
console::sanatize,
database::Database,
types::{
comment::Comment, http::ResponseCode, like::Like, post::Post, session::Session, user::User,
},
};
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();
}
secret.clone()
}
pub async fn regen_secret() -> String {
let mut secret = SECRET.lock().await;
*secret = new_secret();
secret.clone()
}
pub fn generate_users(db: &Database) -> Response {
let users = match User::reterieve_all(db) {
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(db: &Database) -> Response {
let posts = match Post::reterieve_all(db) {
Ok(posts) => posts,
Err(err) => return err,
};
let mut html = r#"
<tr>
<th>Post ID</th>
<th>User ID</th>
<th>Content</th>
<th>Date</th>
</tr>
"#
.to_string();
for post in posts {
html.push_str(&format!(
"<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
post.post_id,
post.user_id,
sanatize(&post.content),
post.date
));
}
ResponseCode::Success.text(&html)
}
pub fn generate_sessions(db: &Database) -> Response {
let sessions = match Session::reterieve_all(db) {
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)
}
pub fn generate_comments(db: &Database) -> Response {
let comments = match Comment::reterieve_all(db) {
Ok(comments) => comments,
Err(err) => return err,
};
let mut html = r#"
<tr>
<th>Comment ID</th>
<th>User ID</th>
<th>Post ID</th>
<th>Content</th>
<th>Date</th>
</tr>
"#
.to_string();
for comment in comments {
html.push_str(&format!(
"<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
comment.comment_id,
comment.user_id,
comment.post_id,
sanatize(&comment.content),
comment.date
));
}
ResponseCode::Success.text(&html)
}
pub fn generate_likes(db: &Database) -> Response {
let likes = match Like::reterieve_all(db) {
Ok(likes) => likes,
Err(err) => return err,
};
let mut html = r#"
<tr>
<th>User ID</th>
<th>Post ID</th>
</tr>
"#
.to_string();
for like in likes {
html.push_str(&format!(
"<tr><td>{}</td><td>{}</td></tr>",
like.user_id, like.post_id
));
}
ResponseCode::Success.text(&html)
}

271
src/public/console.rs Normal file
View file

@ -0,0 +1,271 @@
use axum::{
http::{Method, Uri},
response::Response,
};
use lazy_static::lazy_static;
use serde::Serialize;
use serde_json::{ser::Formatter, Value};
use std::{collections::VecDeque, io, net::IpAddr};
use tokio::sync::Mutex;
use crate::types::http::ResponseCode;
struct LogMessage {
ip: IpAddr,
method: Method,
uri: Uri,
path: String,
body: String,
}
impl ToString for LogMessage {
fn to_string(&self) -> String {
let mut ip = self.ip.to_string();
if ip.contains("::ffff:") {
ip = ip.as_str()[7..].to_string();
}
let color = match self.method {
Method::GET => "#3fe04f",
Method::POST => "#853fe0",
Method::PATCH => "#e0773f",
Method::PUT => "#e0cb3f",
Method::HEAD => "#3f75e0",
Method::DELETE => "#e04c3f",
Method::CONNECT => "#3fe0ad",
Method::TRACE => "#e03fc5",
Method::OPTIONS => "#423fe0",
_ => "white",
};
format!("<div class='msg'><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)
}
}
lazy_static! {
static ref LOG: Mutex<VecDeque<LogMessage>> = Mutex::new(VecDeque::with_capacity(200));
}
pub async fn log(ip: IpAddr, method: Method, uri: Uri, path: Option<String>, body: Option<String>) {
let path = path.unwrap_or_default();
let body = body.unwrap_or_default();
if path == "/api/admin" {
return;
}
tracing::info!("{} {} {}{} {}", &ip, &method, &path, &uri, &body);
let message = LogMessage {
ip,
method,
uri,
path,
body: beautify(&body),
};
let mut lock = LOG.lock().await;
if lock.len() > 200 {
lock.pop_back();
}
lock.push_front(message);
}
struct HtmlFormatter;
impl Formatter for HtmlFormatter {
fn write_null<W>(&mut self, writer: &mut W) -> io::Result<()>
where
W: ?Sized + io::Write,
{
writer.write_all(b"<span class='null'>null</span>")
}
fn write_bool<W>(&mut self, writer: &mut W, value: bool) -> io::Result<()>
where
W: ?Sized + io::Write,
{
let s = if value {
b"<span class='bool'> true </span>" as &[u8]
} else {
b"<span class='bool'> false </span>" as &[u8]
};
writer.write_all(s)
}
fn write_i8<W>(&mut self, writer: &mut W, value: i8) -> io::Result<()>
where
W: ?Sized + io::Write,
{
let buff = format!("<span class='number'> {value} </span>");
writer.write_all(buff.as_bytes())
}
fn write_i16<W>(&mut self, writer: &mut W, value: i16) -> io::Result<()>
where
W: ?Sized + io::Write,
{
let buff = format!("<span class='number'> {value} </span>");
writer.write_all(buff.as_bytes())
}
fn write_i32<W>(&mut self, writer: &mut W, value: i32) -> io::Result<()>
where
W: ?Sized + io::Write,
{
let buff = format!("<span class='number'> {value} </span>");
writer.write_all(buff.as_bytes())
}
fn write_i64<W>(&mut self, writer: &mut W, value: i64) -> io::Result<()>
where
W: ?Sized + io::Write,
{
let buff = format!("<span class='number'> {value} </span>");
writer.write_all(buff.as_bytes())
}
fn write_u8<W>(&mut self, writer: &mut W, value: u8) -> io::Result<()>
where
W: ?Sized + io::Write,
{
let buff = format!("<span class='number'> {value} </span>");
writer.write_all(buff.as_bytes())
}
fn write_u16<W>(&mut self, writer: &mut W, value: u16) -> io::Result<()>
where
W: ?Sized + io::Write,
{
let buff = format!("<span class='number'> {value} </span>");
writer.write_all(buff.as_bytes())
}
fn write_u32<W>(&mut self, writer: &mut W, value: u32) -> io::Result<()>
where
W: ?Sized + io::Write,
{
let buff = format!("<span class='number'> {value} </span>");
writer.write_all(buff.as_bytes())
}
fn write_u64<W>(&mut self, writer: &mut W, value: u64) -> io::Result<()>
where
W: ?Sized + io::Write,
{
let buff = format!("<span class='number'> {value} </span>");
writer.write_all(buff.as_bytes())
}
fn write_f32<W>(&mut self, writer: &mut W, value: f32) -> io::Result<()>
where
W: ?Sized + io::Write,
{
let buff = format!("<span class='number'> {value} </span>");
writer.write_all(buff.as_bytes())
}
fn write_f64<W>(&mut self, writer: &mut W, value: f64) -> io::Result<()>
where
W: ?Sized + io::Write,
{
let buff = format!("<span class='number'> {value} </span>");
writer.write_all(buff.as_bytes())
}
fn begin_string<W>(&mut self, writer: &mut W) -> io::Result<()>
where
W: ?Sized + io::Write,
{
writer.write_all(b"<span class='string'>\"")
}
fn end_string<W>(&mut self, writer: &mut W) -> io::Result<()>
where
W: ?Sized + io::Write,
{
writer.write_all(b"\"</span>")
}
fn begin_object_key<W>(&mut self, writer: &mut W, first: bool) -> io::Result<()>
where
W: ?Sized + io::Write,
{
if first {
writer.write_all(b"<span class='key'>")
} else {
writer.write_all(b",<span class='key'>")
}
}
fn end_object_key<W>(&mut self, writer: &mut W) -> io::Result<()>
where
W: ?Sized + io::Write,
{
writer.write_all(b"</span>")
}
fn begin_object_value<W>(&mut self, writer: &mut W) -> io::Result<()>
where
W: ?Sized + io::Write,
{
writer.write_all(b" : ")
}
}
pub fn sanatize(input: &str) -> String {
input
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
pub fn beautify(body: &str) -> String {
let body = sanatize(body);
if body.is_empty() {
return String::new();
}
let Ok(mut json) = serde_json::from_str::<Value>(&body) else {
return body
};
if json["password"].is_string() {
json["password"] = Value::String("********".to_owned());
}
let mut writer: Vec<u8> = Vec::with_capacity(128);
let mut serializer = serde_json::Serializer::with_formatter(&mut writer, HtmlFormatter);
if json.serialize(&mut serializer).is_err() {
return body;
}
String::from_utf8_lossy(&writer).to_string()
}
pub async fn generate() -> Response {
let lock = LOG.lock().await;
let mut html = r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="5">
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/header.css">
<link rel="stylesheet" href="/css/admin.css">
<link rel="stylesheet" href="/css/console.css">
<title>XSSBook - Console</title>
</head>
<body>
<div id="header">
<span class="logo"><a href="/">xssbook</a></span>
<span class="gtext desc" style="margin-left: 6em; font-size: 2em; color: #606770">Console</span>
</div>
<div style="margin-bottom: 3.5em"></div>
"#
.to_string();
for message in lock.iter() {
html.push_str(&message.to_string());
}
html.push_str("</body></html>");
ResponseCode::Success.html(&html)
}

203
src/public/docs.rs Normal file
View file

@ -0,0 +1,203 @@
use axum::response::Response;
use lazy_static::lazy_static;
use tokio::sync::Mutex;
use crate::{
api::{admin, auth, posts, users, chat},
types::http::ResponseCode,
};
use super::console::beautify;
pub enum EndpointMethod {
Get,
Post,
Put,
Patch,
Delete
}
impl ToString for EndpointMethod {
fn to_string(&self) -> String {
match self {
Self::Get => "GET".to_owned(),
Self::Post => "POST".to_owned(),
Self::Put => "PUT".to_owned(),
Self::Patch => "PATCH".to_owned(),
Self::Delete => "DELETE".to_owned(),
}
}
}
pub struct EndpointDocumentation<'a> {
pub uri: &'static str,
pub method: EndpointMethod,
pub description: &'static str,
pub body: Option<&'static str>,
pub responses: &'a [(u16, &'static str)],
pub cookie: Option<&'static str>,
}
lazy_static! {
static ref ENDPOINTS: Mutex<Vec<String>> = Mutex::new(Vec::new());
}
fn generate_body(body: Option<&'static str>) -> String {
let Some(body) = body else {
return String::new()
};
let html = r#"
<h2>Body</h2>
<div class="body">
$body
</div>
"#
.to_string();
let body = body.trim();
if body.starts_with('{') {
return html.replace(
"$body",
&beautify(body)
.replace("<span class='key'", "<br><span class='key'")
.replace('}', "<br>}"),
);
}
html.replace("$body", body)
}
fn generate_responses(responses: &[(u16, &'static str)]) -> String {
let mut html = r#"
<h2>Responses</h2>
$responses
"#
.to_string();
for response in responses {
let res = format!(
r#"
<div>
<span class="ptype">{}</span>
<span class="pdesc">{}</span>
</div>
$responses
"#,
response.0, response.1
);
html = html.replace("$responses", &res);
}
html.replace("$responses", "")
}
fn generate_cookie(cookie: Option<&'static str>) -> String {
let Some(cookie) = cookie else {
return String::new()
};
format!(
r#"<span class="auth"><span>{cookie}</span> cookie is required for authentication</span>"#
)
}
fn generate_endpoint(doc: &EndpointDocumentation) -> String {
let html = r#"
<div>
<div class="endpoint">
<span class="method $method_class">$method</span>
<span class="uri">$uri</span>
<span class="desc">$description</span>
$cookie
</div>
<div class="info">
$body
$responses
</div>
</div>
"#;
html.replace("$method_class", &doc.method.to_string().to_lowercase())
.replace("$method", &doc.method.to_string())
.replace("$uri", doc.uri)
.replace("$description", doc.description)
.replace("$cookie", &generate_cookie(doc.cookie))
.replace("$body", &generate_body(doc.body))
.replace("$responses", &generate_responses(doc.responses))
}
pub async fn init() {
let docs = vec![
auth::AUTH_REGISTER,
auth::AUTH_LOGIN,
auth::AUTH_LOGOUT,
posts::POSTS_CREATE,
posts::POSTS_PAGE,
posts::COMMENTS_PAGE,
posts::POSTS_USER,
posts::POSTS_COMMENT,
posts::POSTS_LIKE,
users::USERS_LOAD,
users::USERS_PAGE,
users::USERS_SELF,
users::USERS_AVATAR,
users::USERS_BANNER,
users::USERS_FOLLOW,
users::USERS_FOLLOW_STATUS,
users::USERS_FRIENDS,
chat::CHAT_LIST,
chat::CHAT_CREATE,
chat::CHAT_ADD,
chat::CHAT_LEAVE,
chat::CHAT_SEND,
chat::CHAT_LOAD,
chat::CHAT_TYPING,
chat::CHAT_CONNECT,
admin::ADMIN_AUTH,
admin::ADMIN_QUERY,
admin::ADMIN_POSTS,
admin::ADMIN_USERS,
admin::ADMIN_SESSIONS,
admin::ADMIN_COMMENTS,
admin::ADMIN_LIKES,
];
let mut endpoints = ENDPOINTS.lock().await;
for doc in docs {
endpoints.push(generate_endpoint(&doc));
}
}
pub async fn generate() -> Response {
let mut data = String::new();
{
let endpoints = ENDPOINTS.lock().await;
endpoints.iter().for_each(|endpoint| {
data.push_str(endpoint);
});
}
let html = format!(
r#"
<!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/console.css">
<link rel="stylesheet" href="/css/api.css">
<title>XSSBook - API Documentation</title>
</head>
<body>
<div id="header">
<span class="logo"><a href="/">xssbook</a></span>
<span class="gtext desc" style="margin-left: 6em; font-size: 2em; color: #606770">API Documentation</span>
</div>
<div id="docs">
{data}
</div>
</body>
</html>
"#
);
ResponseCode::Success.html(&html)
}

75
src/public/file.rs Normal file
View file

@ -0,0 +1,75 @@
use axum::{
extract::{Path, Query},
http::StatusCode,
response::Response,
};
use serde::Deserialize;
use crate::types::http::ResponseCode;
pub async fn js(Path(path): Path<String>) -> Response {
let path = format!("/js/{path}");
super::serve(&path).await
}
pub async fn css(Path(path): Path<String>) -> Response {
let path = format!("/css/{path}");
super::serve(&path).await
}
pub async fn fonts(Path(path): Path<String>) -> Response {
let path = format!("/fonts/{path}");
super::serve(&path).await
}
pub async fn image(Path(path): Path<String>) -> Response {
let path = format!("/image/{path}");
super::serve(&path).await
}
pub async fn favicon() -> Response {
super::serve("/favicon.ico").await
}
pub async fn robots() -> Response {
super::serve("/robots.txt").await
}
#[derive(Deserialize)]
pub struct AvatarRequest {
user_id: u64,
}
pub async fn avatar(params: Option<Query<AvatarRequest>>) -> Response {
let Some(params) = params else {
return ResponseCode::BadRequest.text("Missing query paramaters");
};
let custom = format!("/image/custom/avatar/{}.png", params.user_id);
let default = format!("/image/default/{}.png", params.user_id % 25);
let file = super::serve(&custom).await;
if file.status() != StatusCode::OK {
return super::serve(&default).await;
}
file
}
#[derive(Deserialize)]
pub struct BannerRequest {
user_id: u64,
}
pub async fn banner(params: Option<Query<BannerRequest>>) -> Response {
let Some(params) = params else {
return ResponseCode::BadRequest.text("Missing query paramaters");
};
let custom = format!("/image/custom/banner/{}.png", params.user_id);
let file = super::serve(&custom).await;
if file.status() != StatusCode::OK {
return ResponseCode::NotFound.text("User does not have a custom banner");
}
file
}

56
src/public/mod.rs Normal file
View file

@ -0,0 +1,56 @@
use axum::{
body::Body,
headers::HeaderName,
http::{HeaderValue, Request, StatusCode},
response::{Response, IntoResponse},
routing::get,
Router,
};
use tower::ServiceExt;
use tower_http::services::ServeFile;
use crate::types::http::ResponseCode;
pub mod admin;
pub mod console;
pub mod docs;
pub mod file;
pub mod pages;
pub fn router() -> Router {
Router::new()
.nest("/", pages::router())
.route("/favicon.ico", get(file::favicon))
.route("/robots.txt", get(file::robots))
.route("/js/*path", get(file::js))
.route("/css/*path", get(file::css))
.route("/fonts/*path", get(file::fonts))
.route("/image/*path", get(file::image))
.route("/image/avatar", get(file::avatar))
.route("/image/banner", get(file::banner))
}
pub async fn serve(path: &str) -> Response {
if !path.chars().any(|c| c == '.') {
return ResponseCode::BadRequest.text("Invalid file path");
}
let path = format!("public{path}");
let file = ServeFile::new(path);
let Ok(mut res) = file.oneshot(Request::new(Body::empty())).await else {
tracing::error!("Error while fetching file");
return ResponseCode::InternalServerError.text("Error while fetching file");
};
if res.status() != StatusCode::OK {
return ResponseCode::NotFound.text("File not found");
}
res.headers_mut().insert(
HeaderName::from_static("cache-control"),
HeaderValue::from_static("max-age=300"),
);
res.into_response()
}

87
src/public/pages.rs Normal file
View file

@ -0,0 +1,87 @@
use axum::{
response::{IntoResponse, Redirect, Response},
routing::get,
Router, middleware,
};
use crate::{
public::console,
types::{
extract::{AuthorizedUser, Log, UserAgent, self},
http::ResponseCode,
},
};
use super::docs;
async fn root(user: Option<AuthorizedUser>, _: Log) -> Response {
if user.is_some() {
Redirect::to("/home").into_response()
} else {
Redirect::to("/login").into_response()
}
}
async fn login(user: Option<AuthorizedUser>, _: Log) -> Response {
if user.is_some() {
Redirect::to("/home").into_response()
} else {
super::serve("/login.html").await
}
}
async fn home(_: Log) -> Response {
super::serve("/home.html").await
}
async fn people(_: Log) -> Response {
super::serve("/people.html").await
}
async fn profile(_: Log) -> Response {
super::serve("/profile.html").await
}
async fn console() -> Response {
console::generate().await
}
async fn admin() -> Response {
super::serve("/admin.html").await
}
async fn api() -> Response {
docs::generate().await
}
async fn wordpress(_: Log) -> Response {
ResponseCode::ImATeapot.text("Hello i am a teapot owo")
}
async fn forgot(UserAgent(agent): UserAgent, _: Log) -> Response {
if agent.starts_with("curl") {
return super::serve("/404.html").await;
}
Redirect::to("https://www.youtube.com/watch?v=dQw4w9WgXcQ").into_response()
}
async fn chat() -> Response {
super::serve("/chat.html").await
}
pub fn router() -> Router {
Router::new()
.route("/", get(root))
.route("/login", get(login))
.layer(middleware::from_fn(extract::connect))
.route("/home", get(home))
.route("/people", get(people))
.route("/profile", get(profile))
.route("/console", get(console))
.route("/wp-admin", get(wordpress))
.route("/admin", get(admin))
.route("/docs", get(api))
.route("/forgot", get(forgot))
.route("/chat", get(chat))
}

129
src/types/chat.rs Normal file
View file

@ -0,0 +1,129 @@
use serde::{Serialize, Deserialize};
use tracing::instrument;
use crate::{types::http::{ResponseCode, Result}, database::Database};
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct ChatRoom {
pub room_id: u64,
pub users: Vec<u64>,
pub name: String
}
#[derive(Serialize, Clone, Debug)]
pub struct ChatMessage {
pub message_id: u64,
pub user_id: u64,
pub room_id: u64,
pub date: u64,
pub content: String
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(tag = "type")]
pub enum ChatEvent {
#[serde(rename = "message")]
Message {
user_id: u64,
message_id: u64,
room_id: u64,
content: String,
date: u64
},
#[serde(rename = "add")]
Add {
user_id: u64,
room: ChatRoom
},
#[serde(rename = "leave")]
Leave {
user_id: u64,
room_id: u64
},
#[serde(rename = "typing")]
Typing {
user_id: u64,
room_id: u64
}
}
impl ChatRoom {
#[instrument(skip(db))]
pub fn new(db: &Database, users: Vec<u64>, name: String) -> Result<Self> {
let Ok(room) = db.create_room(users, name) else {
tracing::error!("Failed to create room");
return Err(ResponseCode::InternalServerError.text("Failed to create room"))
};
Ok(room)
}
#[instrument(skip(db))]
pub fn from_user_id(db: &Database, user_id: u64) -> Result<Vec<Self>> {
let Ok(rooms) = db.get_rooms(user_id) else {
tracing::error!("Failed to get rooms");
return Err(ResponseCode::InternalServerError.text("Failed to get rooms"))
};
Ok(rooms)
}
#[instrument(skip(db))]
pub fn from_user_and_room_id(db: &Database, user_id: u64, room_id: u64) -> Result<Self> {
let Ok(rooms) = db.get_rooms(user_id) else {
tracing::error!("Failed to get room");
return Err(ResponseCode::InternalServerError.text("Failed to get room"))
};
for room in rooms {
if room.room_id == room_id {
return Ok(room);
}
}
return Err(ResponseCode::BadRequest.text("Room doesnt exist or you are not in it"))
}
#[instrument(skip(db))]
pub fn add_user(&self, db: &Database, user_id: u64) -> Result<bool> {
let Ok(success) = db.add_user_to_room(self.room_id, user_id) else {
tracing::error!("Failed to add user to room");
return Err(ResponseCode::InternalServerError.text("Failed to add user to room"))
};
Ok(success)
}
#[instrument(skip(db))]
pub fn remove_user(&self, db: &Database, user_id: u64) -> Result<bool> {
let Ok(success) = db.remove_user_from_room(self.room_id, user_id) else {
tracing::error!("Failed to remove user from room");
return Err(ResponseCode::InternalServerError.text("Failed to remove user from room"))
};
Ok(success)
}
#[instrument(skip(db))]
pub fn send_message(&self, db: &Database, user_id: u64, content: String) -> Result<ChatMessage> {
let Ok(msg) = db.create_message(self.room_id, user_id, content) else {
tracing::error!("Failed to create messgae");
return Err(ResponseCode::InternalServerError.text("Failed to create message"))
};
Ok(msg)
}
#[instrument(skip(db))]
pub fn load_old_chat_messages(&self, db: &Database, newest_message: u64, page: u64) -> Result<Vec<ChatMessage>> {
let Ok(msgs) = db.load_old_chat_messages(self.room_id, newest_message, page) else {
tracing::error!("Failed to load messgaes");
return Err(ResponseCode::InternalServerError.text("Failed to load messages"))
};
Ok(msgs)
}
}

Some files were not shown because too many files have changed in this diff Show more