diff --git a/public/admin.html b/public/admin.html index cd79337..2d2bcd6 100644 --- a/public/admin.html +++ b/public/admin.html @@ -5,17 +5,16 @@ + XSSBook - Admin Panel - - @@ -23,10 +22,12 @@
- - - - + + + + + +
diff --git a/public/home.html b/public/home.html index 865e53a..5cc7776 100644 --- a/public/home.html +++ b/public/home.html @@ -2,16 +2,12 @@ - + XSSBook - Home + - - - - - \ No newline at end of file diff --git a/public/js/admin.js b/public/js/admin.js index e4364ec..18799b7 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -1,3 +1,5 @@ +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; @@ -10,12 +12,14 @@ async function auth(event) { } 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(); @@ -26,6 +30,7 @@ async function posts() { let table = document.getElementById("table") table.innerHTML = response.msg } +window.posts = posts async function users() { let response = await adminusers(); @@ -36,6 +41,7 @@ async function users() { let table = document.getElementById("table") table.innerHTML = response.msg } +window.users = users async function sessions() { let response = await adminsessions(); @@ -46,6 +52,29 @@ async function sessions() { 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(); diff --git a/public/js/api.js b/public/js/api.js index 886a017..7d9598d 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -43,78 +43,90 @@ const request = async (url, body, method) => { } } -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, day, month, 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 logout = async () => { +export const logout = async () => { return await request('/auth/logout', {}) } -const loadpostspage = async (page) => { +export const loadpostspage = async (page) => { return await request('/posts/page', {page}) } -const loadusersposts = async (user_id, page) => { +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}) } -const loadusers = async (ids) => { +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 (post_id, content) => { +export const postcomment = async (post_id, content) => { return await request('/posts/comment', {post_id, content}, 'PATCH') } -const postlike = async (post_id, state) => { +export const postlike = async (post_id, state) => { return await request('/posts/like', {post_id, state}, 'PATCH') } -const createpost = async (content) => { +export const createpost = async (content) => { return await request('/posts/create', {content}) } -const adminauth = async (secret) => { +export const adminauth = async (secret) => { return await request('/admin/auth', {secret}) } -const admincheck = async () => { +export const admincheck = async () => { return await request('/admin/check', {}) } -const adminquery = async (query) => { +export const adminquery = async (query) => { return await request('/admin/query', {query}) } -const adminposts = async () => { +export const adminposts = async () => { return await request('/admin/posts', {}) } -const adminusers = async () => { +export const adminusers = async () => { return await request('/admin/users', {}) } -const adminsessions = async () => { +export const adminsessions = async () => { return await request('/admin/sessions', {}) } -const updateavatar = async (file) => { +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') } -const updatebanner = async (file) => { +export const updatebanner = async (file) => { return await fileRequest('/users/banner', file, 'PUT') } \ No newline at end of file diff --git a/public/js/components.js b/public/js/components.js new file mode 100644 index 0000000..9816457 --- /dev/null +++ b/public/js/components.js @@ -0,0 +1,249 @@ +import { div, a, pfp, span, i, parse, parseDate, p, form, input, svg, path, parseMonth } from './main.js' +import { postlike, postcomment, loadcommentspage } from './api.js'; + +window.parse = parse; + +export function header(home, people, user_id) { + return [ + div({id: 'header'}, + span({class: 'logo'}, + a({href: '/'}, + parse('xssbook') + ) + ), + div({class: 'buttons'}, + a({id: 'home', class: home ? '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"}) + ) + ), + a({id: 'people', class: people ? '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"}) + ) + ) + ), + a({class: 'pfp', id: 'profile', href: '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}`}, + 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,'
')) + ), + 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: () => this.parentElement.parentElement.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({id: 'load', class: 'load', style: 'justify-content: inherit; margin-left: 3.5em; font-size: .9em; margin-bottom: -.5em;'}, + a({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'}, + 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('load')[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', id: 'newcomment', class: 'newcomment'}) + ) + ) + ) + ) + ) + + +} + +export function parseComment(comment, users) { + + let author = users[comment.user_id] + + return ( + div({class: 'comment'}, + a({class: 'pfp'}, + 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}`}, + 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) + ) + ) + ) + ) +} \ No newline at end of file diff --git a/public/js/header.js b/public/js/header.js deleted file mode 100644 index f296567..0000000 --- a/public/js/header.js +++ /dev/null @@ -1,24 +0,0 @@ -function header(home, people, user_id) { - const html = ` - -
- ` - append(html) -} \ No newline at end of file diff --git a/public/js/home.js b/public/js/home.js index 2e5818a..25fe0e9 100644 --- a/public/js/home.js +++ b/public/js/home.js @@ -1,124 +1,6 @@ -function parseComment(comment) { - let author = data.users[comment[0]] - if (author === undefined) { - author = {} - } - const html = ` -
- - ${pfp(author.user_id)} - - - ${author.firstname + ' ' + author.lastname} -

${comment[1]}

-
-
- ` - return html -} - -function parsePost(post) { - let author = data.users[post.user_id] - if (author === undefined) { - author = {} - } - const html = ` -
-
- - ${pfp(author.user_id)} - -
- ${author.firstname + ' ' + author.lastname} - ${parseDate(new Date(post.date))} -
-
-

- ${post.content.replace(/\n/g,'
')} -

- - ${post.likes.length} Likes - -
-
- - - Like - - - - Comment - -
-
-
- ${post.comments.map(parseComment).join('')} - -
-
- ` - return html -} - -function getPost(post_id) { - for (let i = 0; i < data.posts.length; i++) { - if (data.posts[i].post_id === post_id) { - return i - } - } - return -1 -} - -async function like(span) { - const container = span.parentElement.parentElement; - const id = parseInt(container.getAttribute('postid')) - const post = data.posts[getPost(id)] - const index = post.likes.indexOf(data.user.user_id) - const current = index !== -1 - const response = await postlike(id, !current) - if (response.status != 200) return; - if (current) { - post.likes.splice(index, 1) - } else { - post.likes.push(data.user.user_id) - } - const buttons = container - .getElementsByClassName("postbuttons")[0] - .getElementsByClassName("likeclicky")[0] - .getElementsByClassName("liketoggle") - if (current) { - buttons[0].classList.remove("blue") - buttons[1].classList.remove("bltext") - } else { - buttons[0].classList.add("blue") - buttons[1].classList.add("bltext") - } - container.getElementsByClassName("likes")[0].innerHTML = post.likes.length + " Likes" -} - -async function comment(event) { - event.preventDefault(); - const text = event.target.elements.text.value.trim(); - if (text.length < 1) return; - const container = event.target.parentElement.parentElement.parentElement; - const post_id = parseInt(container.getAttribute('postid')) - var index = getPost(post_id); - if (index === -1) return; - const response = await postcomment(post_id, text) - if (response.status != 200) return; - event.target.elements.text.value = ''; - let new_comment = [data.user.user_id, text] - data.posts[index].comments.push(new_comment) - let comments = container.getElementsByClassName("comments")[0] - prepend(parseComment(new_comment), comments, comments.getElementsByClassName("commentsubmit")[0]) -} +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() @@ -133,7 +15,7 @@ async function post() { error.innerHTML = ''; let post = { post_id: response.json.post_id, - user_id: data.user.user_id, + user_id: data.self.user_id, date: Date.now(), content: text, likes: [], @@ -145,113 +27,127 @@ async function post() { document.getElementById('popup').classList.add('hidden') } -async function loadMore() { - const posts = await load() - data.posts.push(... posts) - const posts_block = document.getElementById("posts") - for (p of posts) { - append(parsePost(p), posts_block) - } -} - function render() { - const html = ` -
-
- - ${pfp(data.user.user_id)} - - -
-
-
- ${data.posts.map(p => parsePost(p)).join('')} -
- ` + + let new_body = + body({}, + ...header(true, 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' 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'}, + 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'}, + a({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') + ) + ) + ) - append(html) + document.body.replaceWith(new_body) - const popup = ` - - ` - - append(popup) - - const load = ` -
- Load more posts -
- ` - - append(load) } var page = 0 const data = { - user: {}, + self: {}, users: {}, posts: [] } async function load() { const posts = (await loadpostspage(page)).json + if (posts.length === 0) { - page = -1 - remove('load') + document.getElementById('load').remove() return [] } else { page++ } - const batch = [] - for (const post of posts) { - for(const comment of post.comments) { - if (data.users[comment[0]] !== undefined) continue - if (batch.includes(comment[0])) continue - batch.push(comment[0]) + + 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_id] !== undefined) continue - if (batch.includes(post.user_id)) continue - batch.push(post.user_id) - } - const users = batch.length == 0 ? [] : (await loadusers(batch)).json - for (const user of users) { - data.users[user.user_id] = user } + return posts } async function init() { + let request = (await loadself()); + if (request.status === 429) { - header(true, false) + let new_body = + body({}, + ...header(true, false) + ) + + document.body.replaceWith(new_body) throw new Error("Rate limited"); } - data.user = request.json - header(true, false, data.user.user_id) - data.users[data.user.user_id] = data.user + + data.self = request.json + data.users[data.self.user_id] = data.self + const posts = await load() data.posts.push(... posts) + render() -} \ No newline at end of file +} + + +init() \ No newline at end of file diff --git a/public/js/login.js b/public/js/login.js index 0d9feb8..8c32865 100644 --- a/public/js/login.js +++ b/public/js/login.js @@ -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 @@ -26,4 +30,6 @@ async function onregister() { } else { location.href = '/home' } -} \ No newline at end of file +} + +window.onregister = onregister \ No newline at end of file diff --git a/public/js/main.js b/public/js/main.js index ffbc1f3..9993cee 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -1,43 +1,108 @@ -function prepend(html, container, before) { - if (container === undefined) { - container = document.body +function createElement(name, attrs, ...children) { + const el = document.createElement(name); + + for (const attr in attrs) { + if(attr.startsWith("on")) { + el[attr] = attrs[attr]; + } else { + el.setAttribute(attr, attrs[attr]) + } } - if (before === undefined) { - before = container.firstChild + + for (const child of children) { + if (child == null) { + continue + } + el.appendChild(child) } - console.log(html, container, before) - var range = document.createRange() - range.setStart(container, 0); - container.insertBefore( - range.createContextualFragment(html), - before - ) + + return el } -function append(html, container) { - if (container === undefined) { - container = document.body +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]) + } } - var range = document.createRange() - range.setStart(container, 0); - container.appendChild( - range.createContextualFragment(html) - ) -} -function remove(id) { - const old = document.getElementById(id) - if (old !== null) { - old.remove() + for (const child of children) { + if (child == null) { + continue + } + el.appendChild(child) } + + return el } -function pfp(id) { - return `` +export function p(attrs, ...children) { + return createElement("p", attrs, ...children) } -function banner(id) { - return `` +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(attrs, ...children) { + 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 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({src: `/image/avatar?user_id=${id}`}) +} + +export function banner(id) { + return img({src: `/image/banner?user_id=${id}`, onerror: () => {this.remove()}}) } const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', @@ -46,7 +111,7 @@ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 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']; -function parseMonth(month) { +export function parseMonth(month) { if (month > -1 && month < 26) { return months[month] } else { @@ -57,6 +122,18 @@ function parseMonth(month) { } } -function parseDate(date) { +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 } \ No newline at end of file diff --git a/public/js/people.js b/public/js/people.js index 6568ccc..be0c988 100644 --- a/public/js/people.js +++ b/public/js/people.js @@ -1,64 +1,60 @@ -function parseUser(user) { - const html = ` - -
- ${pfp(user.user_id)} -
-
- ${user.firstname + ' ' + user.lastname} - Joined ${parseDate(new Date(user.date))} - Gender: ${user.gender} - Birthday: ${parseMonth(user.month) + ' ' + user.day + ', ' + user.year} - User ID: ${user.user_id} -
-
- ` - return html -} +import { div, body, a, parse } from './main.js' +import { loadself, loaduserspage } from './api.js' +import { header, parseUser } from './components.js' function render() { - const html = ` -
- ${data.users.map(u => parseUser(u)).join('')} -
- ` + + let new_body = + body({}, + ...header(false, true, data.self.user_id), + div({id: 'users'}, + ...data.users.map(u => parseUser(u)) + ), + div({id: 'load'}, + a({class: 'bold gtext', onclick: async () => { + let users = await load() - append(html) + let el = document.getElementById("users") + for (const user of users) { + el.appendChild(parseUser(user)) + } + }}, + parse("Load more users") + ) + ) + ) - const load = ` -
- Load more users -
- ` - - append(load) + document.body.replaceWith(new_body) } var page = 0 -var data = { - users: [] -} - -async function loadMore() { - let users = await load() - const users_block = document.getElementById("users") - for (user of users) { - append(parseUser(user), users_block) - } +const data = { + users: [], + self: {} } async function load() { let request = await loadself() + if (request.status === 429) { - header(false, true) + let new_body = + body({}, + ...header(false, true) + ) + + document.body.replaceWith(new_body) throw new Error("Rate limited"); } + + const self = request.json + header(false, true, self.user_id) const users = (await loaduserspage(page)).json + if (users.length === 0) { - page = -1 - remove('load') + document.getElementById('load').remove() + return [] } else { page++ } @@ -66,8 +62,24 @@ async function load() { } async function init() { - let users = await load() + + let request = (await loadself()); + + if (request.status === 429) { + let new_body = + body({}, + ...header(true, false, data.self.user_id) + ) + + document.body.replaceWith(new_body) + throw new Error("Rate limited"); + } + + data.self = request.json + + const users = await load() data.users.push(... users) + render() } diff --git a/public/js/profile.js b/public/js/profile.js index bde0654..b053b5d 100644 --- a/public/js/profile.js +++ b/public/js/profile.js @@ -1,18 +1,36 @@ +import { div, pfp, banner, parse, button, body, a, span, crawl, parseDate, parseMonth } from './main.js' +import { loadself, loadusers, loadusersposts, updateavatar, updatebanner, logout } from './api.js' +import { parsePost, header } from './components.js' + function swap(value) { let postsb = document.getElementById("profilepostbutton"); let aboutb = document.getElementById("profileaboutbutton"); let posts = document.getElementById("posts"); let about = document.getElementById("about"); + let load = document.getElementsByClassName("loadp")[0]; + if (value) { + postsb.classList.add("selected") aboutb.classList.remove("selected") about.classList.add("hidden") posts.classList.remove("hidden") + + if (load) { + load.classList.remove("hidden") + } + } else { + postsb.classList.remove("selected") aboutb.classList.add("selected") about.classList.remove("hidden") posts.classList.add("hidden") + + if (load) { + load.classList.add("hidden") + } + } } @@ -50,129 +68,155 @@ function changeimage(fn) { } function render() { - const html = ` -
- -
-
- ${pfp(data.user.user_id)} - ${ isself ? `
` : '' } -
-
- ${data.user.firstname + ' ' + data.user.lastname} - Joined ${parseDate(new Date(data.user.date))} -
-
-
-
- - -
- ${ isself ? `` : ''} -
-
- ` - append(html) + let new_body = + body({}, + ...header(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))) + ) + ) + ), + div({class: 'fullline', style: 'width: 80em; margin-bottom: 0; z-index: 0;'}), + div({class: 'profilebuttons'}, + button({id: 'profilepostbutton', class: 'selected', onclick: () => swap(true)}, + parse('Posts') + ), + button({id: 'profileaboutbutton', onclick: () => swap(false)}, + parse('About') + ), + 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 postsh = ` -
- ${data.posts.map(p => parsePost(p)).join('')} -
-
- Load more posts -
- ` - - if (data.posts.length > 0) { - append(postsh) + 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_id) + ) + ) + ), + 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() } - const about = ` -
- About -
- Name: ${data.user.firstname + ' ' + data.user.lastname} - Email: ${data.user.email} - Gender: ${data.user.gender} - Birthday: ${parseMonth(data.user.month) + ' ' + data.user.day + ', ' + data.user.year} - User ID: ${data.user.user_id} -
-
- ` - - append(about) - - const popup = ` - - ` - - append(popup) - } -async function logout_button() { - const response = await logout() - if (response.status != 200) return; - location.href = '/login' -} - -async function loadMore() { - const posts = await load() - data.posts.push(... posts) - const posts_block = document.getElementById("posts") - for (p of posts) { - append(parsePost(p), posts_block) - } -} - -var posts = true var isself = false var page = 0 -var id +const data = { + self: {}, + user: {}, + users: {}, + posts: [] +} + +async function load(id) { + + if (id === undefined) { + id = data.user.user_id + } -async function load() { const posts = (await loadusersposts(id, page)).json - if (posts.length === 0) { - page = -1 - remove('load') + + if (posts.length < 1) { + document.getElementsByClassName('loadp')[0].remove() + return [] } else { page++ } - let batch = [] + + const batch = Array.from(new Set(crawl('user_id', posts))).filter(id => data.users[id] == undefined) + if (!isself) { batch.push(id) } - for (const post of posts) { - for(const comment of post.comments) { - if (data.users[comment[0]] !== undefined) continue - if (batch.includes(comment[0])) continue - batch.push(comment[0]) + + 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_id] !== undefined) continue - if (batch.includes(post.user_id)) continue - batch.push(post.user_id) - } - const users = batch.length == 0 ? [] : (await loadusers(batch)).json - for (const user of users) { - data.users[user.user_id] = user } return posts } @@ -180,21 +224,26 @@ async function load() { async function init() { let request = await loadself() + if (request.status === 429) { - header(false, false) + let new_body = + body({}, + ...header(false, false) + ) + + document.body.replaceWith(new_body) throw new Error("Rate limited"); } data.self = request.json; data.users[data.self.user_id] = data.self - - header(false, false, data.self.user_id) var params = {}; for (const [key, value] of new URLSearchParams(location.search)) { params[key] = value } + let id; if (params.id !== undefined && !isNaN(params.id)) { id = parseInt(params.id); } else { @@ -203,9 +252,11 @@ async function init() { isself = id === data.self.user_id - const posts = await load() + const posts = await load(id) data.posts.push(... posts) + data.user = data.users[id] + render() } diff --git a/public/login.html b/public/login.html index e0428b9..ce4d0ff 100644 --- a/public/login.html +++ b/public/login.html @@ -4,8 +4,7 @@ - - + XSSBook - Login @@ -18,7 +17,7 @@ - + Forgot Password?
@@ -159,7 +158,7 @@ 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. XSSBook is not responsible for any ones loss of finances, mental state, relationships, or life when using this site. - + diff --git a/public/people.html b/public/people.html index 399751a..0a7775a 100644 --- a/public/people.html +++ b/public/people.html @@ -3,13 +3,11 @@ - + + XSSBook - People - - - - - \ No newline at end of file + + \ No newline at end of file diff --git a/public/profile.html b/public/profile.html index d17ab09..893ef3f 100644 --- a/public/profile.html +++ b/public/profile.html @@ -6,12 +6,9 @@ + XSSBook - Profile - - - - - - \ No newline at end of file + + \ No newline at end of file diff --git a/src/api/admin.rs b/src/api/admin.rs index 8db3032..6030315 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -6,7 +6,10 @@ use tower_cookies::{Cookie, Cookies}; use crate::{ database, - public::{admin, docs::{EndpointDocumentation, EndpointMethod}}, + public::{ + admin, + docs::{EndpointDocumentation, EndpointMethod}, + }, types::{ extract::{AdminUser, Check, CheckResult, Json}, http::ResponseCode, @@ -17,14 +20,16 @@ pub const ADMIN_AUTH: EndpointDocumentation = EndpointDocumentation { uri: "/api/admin/auth", method: EndpointMethod::Post, description: "Authenticates on the admin panel", - body: Some(r#" + body: Some( + r#" { "secret" : "admin" } - "#), + "#, + ), responses: &[ (200, "Successfully executed SQL query"), - (400, " Successfully authed, admin cookie returned") + (400, " Successfully authed, admin cookie returned"), ], cookie: None, }; @@ -60,16 +65,18 @@ pub const ADMIN_QUERY: EndpointDocumentation = EndpointDocumentation { uri: "/api/admin/query", method: EndpointMethod::Post, description: "Run a SQL query on the database", - body: Some(r#" + 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") + (500, "SQL query ran into an error"), ], cookie: Some("admin"), }; @@ -102,7 +109,7 @@ pub const ADMIN_POSTS: EndpointDocumentation = EndpointDocumentation { responses: &[ (200, "Returns sql table in text/html"), (401, "Unauthorized"), - (500, "Failed to fetch data") + (500, "Failed to fetch data"), ], cookie: Some("admin"), }; @@ -119,7 +126,7 @@ pub const ADMIN_USERS: EndpointDocumentation = EndpointDocumentation { responses: &[ (200, "Returns sql table in text/html"), (401, "Unauthorized"), - (500, "Failed to fetch data") + (500, "Failed to fetch data"), ], cookie: Some("admin"), }; @@ -136,7 +143,7 @@ pub const ADMIN_SESSIONS: EndpointDocumentation = EndpointDocumentation { responses: &[ (200, "Returns sql table in text/html"), (401, "Unauthorized"), - (500, "Failed to fetch data") + (500, "Failed to fetch data"), ], cookie: Some("admin"), }; @@ -145,6 +152,40 @@ async fn sessions(_: AdminUser) -> Response { admin::generate_sessions() } +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 text/html"), + (401, "Unauthorized"), + (500, "Failed to fetch data"), + ], + cookie: Some("admin"), +}; + +async fn comments(_: AdminUser) -> Response { + admin::generate_comments() +} + +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 text/html"), + (401, "Unauthorized"), + (500, "Failed to fetch data"), + ], + cookie: Some("admin"), +}; + +async fn likes(_: AdminUser) -> Response { + admin::generate_likes() +} + async fn check(check: Option) -> Response { if check.is_none() { ResponseCode::Success.text("false") @@ -160,5 +201,7 @@ pub fn router() -> Router { .route("/posts", post(posts)) .route("/users", post(users)) .route("/sessions", post(sessions)) + .route("/comments", post(comments)) + .route("/likes", post(likes)) .route("/check", post(check)) } diff --git a/src/api/auth.rs b/src/api/auth.rs index 0ff180e..60ddc80 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -3,18 +3,22 @@ use serde::Deserialize; use time::{Duration, OffsetDateTime}; use tower_cookies::{Cookie, Cookies}; -use crate::{types::{ - extract::{AuthorizedUser, Check, CheckResult, Json, Log}, - http::ResponseCode, - session::Session, - user::User, -}, public::docs::{EndpointDocumentation, EndpointMethod}}; +use crate::{ + public::docs::{EndpointDocumentation, EndpointMethod}, + types::{ + extract::{AuthorizedUser, Check, CheckResult, 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#" + body: Some( + r#" { "firstname": "[Object]", "lastname": "object]", @@ -25,7 +29,8 @@ pub const AUTH_REGISTER: EndpointDocumentation = EndpointDocumentation { "month": 1, "year": 1970 } - "#), + "#, + ), responses: &[ (201, "Successfully registered new user"), (400, "Body does not match parameters"), @@ -123,15 +128,20 @@ pub const AUTH_LOGIN: EndpointDocumentation = EndpointDocumentation { uri: "/api/auth/login", method: EndpointMethod::Post, description: "Logs into an existing account", - body: Some(r#" + 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"), + ( + 400, + "Body does not match parameters, or invalid email password combination", + ), ], cookie: None, }; @@ -184,7 +194,7 @@ pub const AUTH_LOGOUT: EndpointDocumentation = EndpointDocumentation { responses: &[ (200, "Successfully logged out"), (401, "Unauthorized"), - (500, "Failed to log out user") + (500, "Failed to log out user"), ], cookie: None, }; diff --git a/src/api/posts.rs b/src/api/posts.rs index f1cdab3..ca459cd 100644 --- a/src/api/posts.rs +++ b/src/api/posts.rs @@ -5,26 +5,33 @@ use axum::{ }; use serde::Deserialize; -use crate::{types::{ - extract::{AuthorizedUser, Check, CheckResult, Json}, - http::ResponseCode, - post::Post, -}, public::docs::{EndpointDocumentation, EndpointMethod}}; +use crate::{ + public::docs::{EndpointDocumentation, EndpointMethod}, + types::{ + comment::Comment, + extract::{AuthorizedUser, Check, CheckResult, 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#" + 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") + (500, "Failed to create post"), ], cookie: Some("auth"), }; @@ -65,16 +72,18 @@ 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#" + body: Some( + r#" { "page": 0 } - "#), + "#, + ), responses: &[ (200, "Returns posts in application/json"), (400, "Body does not match parameters"), (401, "Unauthorized"), - (500, "Failed to fetch posts") + (500, "Failed to fetch posts"), ], cookie: Some("auth"), }; @@ -105,21 +114,71 @@ async fn page( 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 application/json"), + (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, + Json(body): Json, +) -> Response { + let Ok(comments) = Comment::from_comment_page(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#" + body: Some( + r#" { "user_id": 3, "page": 0 } - "#), + "#, + ), responses: &[ (200, "Returns posts in application/json"), (400, "Body does not match parameters"), (401, "Unauthorized"), - (500, "Failed to fetch posts") + (500, "Failed to fetch posts"), ], cookie: Some("auth"), }; @@ -155,17 +214,19 @@ pub const POSTS_COMMENT: EndpointDocumentation = EndpointDocumentation { uri: "/api/posts/comment", method: EndpointMethod::Patch, description: "Add a comment to a post", - body: Some(r#" + 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") + (500, "Failed to add comment"), ], cookie: Some("auth"), }; @@ -192,11 +253,7 @@ async fn comment( AuthorizedUser(user): AuthorizedUser, Json(body): Json, ) -> Response { - let Ok(mut post) = Post::from_post_id(body.post_id) else { - return ResponseCode::InternalServerError.text("Failed to add comment") - }; - - if let Err(err) = post.comment(user.user_id, body.content) { + if let Err(err) = Comment::new(user.user_id, body.post_id, &body.content) { return err; } @@ -207,17 +264,19 @@ pub const POSTS_LIKE: EndpointDocumentation = EndpointDocumentation { uri: "/api/posts/like", method: EndpointMethod::Patch, description: "Set like status on a post", - body: Some(r#" + 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") + (500, "Failed to set like status"), ], cookie: Some("auth"), }; @@ -235,11 +294,11 @@ impl Check for PostLikeRequest { } async fn like(AuthorizedUser(user): AuthorizedUser, Json(body): Json) -> Response { - let Ok(mut post) = Post::from_post_id(body.post_id) else { - return ResponseCode::InternalServerError.text("Failed to fetch posts") - }; - - if let Err(err) = post.like(user.user_id, body.state) { + if body.state { + if let Err(err) = Like::add_liked(user.user_id, body.post_id) { + return err; + } + } else if let Err(err) = Like::remove_liked(user.user_id, body.post_id) { return err; } @@ -250,6 +309,7 @@ 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)) diff --git a/src/api/users.rs b/src/api/users.rs index 7d1f006..0ce9988 100644 --- a/src/api/users.rs +++ b/src/api/users.rs @@ -1,8 +1,11 @@ -use crate::{types::{ - extract::{AuthorizedUser, Check, CheckResult, Json, Png}, - http::ResponseCode, - user::User, -}, public::docs::{EndpointDocumentation, EndpointMethod}}; +use crate::{ + public::docs::{EndpointDocumentation, EndpointMethod}, + types::{ + extract::{AuthorizedUser, Check, CheckResult, Json, Png}, + http::ResponseCode, + user::User, + }, +}; use axum::{ response::Response, routing::{post, put}, @@ -14,16 +17,18 @@ pub const USERS_LOAD: EndpointDocumentation = EndpointDocumentation { uri: "/api/users/load", method: EndpointMethod::Post, description: "Loads a requested set of users", - body: Some(r#" + body: Some( + r#" { "ids": [0, 3, 7] } - "#), + "#, + ), responses: &[ (200, "Returns users in application/json"), (400, "Body does not match parameters"), (401, "Unauthorized"), - (500, "Failed to fetch users") + (500, "Failed to fetch users"), ], cookie: Some("auth"), }; @@ -55,17 +60,19 @@ 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#" + body: Some( + r#" { "user_id": 3, "page": 0 } - "#), + "#, + ), responses: &[ (200, "Returns users in application/json"), (400, "Body does not match parameters"), (401, "Unauthorized"), - (500, "Failed to fetch users") + (500, "Failed to fetch users"), ], cookie: Some("auth"), }; @@ -104,7 +111,7 @@ pub const USERS_SELF: EndpointDocumentation = EndpointDocumentation { responses: &[ (200, "Successfully executed SQL query"), (401, "Unauthorized"), - (500, "Failed to fetch user") + (500, "Failed to fetch user"), ], cookie: Some("auth"), }; @@ -126,7 +133,7 @@ pub const USERS_AVATAR: EndpointDocumentation = EndpointDocumentation { (200, "Successfully updated avatar"), (400, "Invalid PNG or disallowed size"), (401, "Unauthorized"), - (500, "Failed to update avatar") + (500, "Failed to update avatar"), ], cookie: Some("auth"), }; @@ -150,7 +157,7 @@ pub const USERS_BANNER: EndpointDocumentation = EndpointDocumentation { (200, "Successfully updated banner"), (400, "Invalid PNG or disallowed size"), (401, "Unauthorized"), - (500, "Failed to update banner") + (500, "Failed to update banner"), ], cookie: Some("auth"), }; diff --git a/src/database/comments.rs b/src/database/comments.rs new file mode 100644 index 0000000..9e0eaf9 --- /dev/null +++ b/src/database/comments.rs @@ -0,0 +1,91 @@ +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use rusqlite::Row; +use tracing::instrument; + +use crate::{database, types::comment::Comment}; + +pub fn init() -> 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(255) NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(user_id), + FOREIGN KEY(post_id) REFERENCES posts(post_id) + ); + "; + let conn = database::connect()?; + conn.execute(sql, ())?; + + let sql2 = "CREATE INDEX IF NOT EXISTS post_ids on comments (post_id);"; + conn.execute(sql2, ())?; + + Ok(()) +} + +fn comment_from_row(row: &Row) -> Result { + 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()] +pub fn get_comments_page(page: u64, post_id: u64) -> Result, rusqlite::Error> { + tracing::trace!("Retrieving comments page"); + let page_size = 5; + let conn = database::connect()?; + let mut stmt = conn.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 = comment_from_row(row)?; + Ok(row) + })?; + Ok(row.into_iter().flatten().collect()) +} + +#[instrument()] +pub fn get_all_comments() -> Result, rusqlite::Error> { + tracing::trace!("Retrieving comments page"); + let conn = database::connect()?; + let mut stmt = conn.prepare("SELECT * FROM comments ORDER BY comment_id DESC")?; + let row = stmt.query_map([], |row| { + let row = comment_from_row(row)?; + Ok(row) + })?; + Ok(row.into_iter().flatten().collect()) +} + +#[instrument()] +pub fn add_comment(user_id: u64, post_id: u64, content: &str) -> Result { + 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 conn = database::connect()?; + let mut stmt = conn.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 = comment_from_row(row)?; + Ok(row) + })?; + Ok(post) +} diff --git a/src/database/likes.rs b/src/database/likes.rs new file mode 100644 index 0000000..6f6939e --- /dev/null +++ b/src/database/likes.rs @@ -0,0 +1,77 @@ +use rusqlite::OptionalExtension; +use tracing::instrument; + +use crate::{database, types::like::Like}; + +pub fn init() -> 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) + ); + "; + let conn = database::connect()?; + conn.execute(sql, ())?; + Ok(()) +} + +#[instrument()] +pub fn get_like_count(post_id: u64) -> Result, rusqlite::Error> { + tracing::trace!("Retrieving like count"); + let conn = database::connect()?; + let mut stmt = conn.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()] +pub fn get_liked(user_id: u64, post_id: u64) -> Result { + tracing::trace!("Retrieving if liked"); + let conn = database::connect()?; + let mut stmt = conn.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()] +pub fn add_liked(user_id: u64, post_id: u64) -> Result { + tracing::trace!("Adding like"); + let conn = database::connect()?; + let mut stmt = conn.prepare("INSERT OR REPLACE INTO likes (user_id, post_id) VALUES (?,?)")?; + let changes = stmt.execute([user_id, post_id])?; + Ok(changes == 1) +} + +#[instrument()] +pub fn remove_liked(user_id: u64, post_id: u64) -> Result { + tracing::trace!("Removing like"); + let conn = database::connect()?; + let mut stmt = conn.prepare("DELETE FROM likes WHERE user_id = ? AND post_id = ?;")?; + let changes = stmt.execute((user_id, post_id))?; + Ok(changes == 1) +} + +#[instrument()] +pub fn get_all_likes() -> Result, rusqlite::Error> { + tracing::trace!("Retrieving comments page"); + let conn = database::connect()?; + let mut stmt = conn.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()) +} diff --git a/src/database/mod.rs b/src/database/mod.rs index 55cbe4f..6d4853a 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,5 +1,7 @@ use tracing::instrument; +pub mod comments; +pub mod likes; pub mod posts; pub mod sessions; pub mod users; @@ -12,6 +14,8 @@ pub fn init() -> Result<(), rusqlite::Error> { users::init()?; posts::init()?; sessions::init()?; + likes::init()?; + comments::init()?; Ok(()) } diff --git a/src/database/posts.rs b/src/database/posts.rs index 8ca9b2d..7da3bf0 100644 --- a/src/database/posts.rs +++ b/src/database/posts.rs @@ -1,4 +1,3 @@ -use std::collections::HashSet; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use rusqlite::{OptionalExtension, Row}; @@ -7,14 +6,14 @@ use tracing::instrument; use crate::database; use crate::types::post::Post; +use super::{comments, likes}; + pub fn init() -> Result<(), rusqlite::Error> { let sql = " CREATE TABLE IF NOT EXISTS posts ( post_id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, content VARCHAR(500) NOT NULL, - likes TEXT NOT NULL, - comments TEXT NOT NULL, date INTEGER NOT NULL, FOREIGN KEY(user_id) REFERENCES users(user_id) ); @@ -28,25 +27,20 @@ fn post_from_row(row: &Row) -> Result { let post_id = row.get(0)?; let user_id = row.get(1)?; let content = row.get(2)?; - let likes_json: String = row.get(3)?; - let comments_json: String = row.get(4)?; - let date = row.get(5)?; + let date = row.get(3)?; - let Ok(likes) = serde_json::from_str(&likes_json) else { - return Err(rusqlite::Error::InvalidQuery) - }; - - let Ok(comments) = serde_json::from_str(&comments_json) else { - return Err(rusqlite::Error::InvalidQuery) - }; + let comments = comments::get_comments_page(0, post_id).unwrap_or_else(|_| Vec::new()); + let likes = likes::get_like_count(post_id).unwrap_or(None).unwrap_or(0); + let liked = likes::get_liked(user_id, post_id).unwrap_or(false); Ok(Post { post_id, user_id, content, - likes, - comments, date, + likes, + liked, + comments, }) } @@ -106,14 +100,6 @@ pub fn get_users_post_page(user_id: u64, page: u64) -> Result, rusqlit #[instrument()] pub fn add_post(user_id: u64, content: &str) -> Result { tracing::trace!("Adding post"); - let likes: HashSet = HashSet::new(); - let comments: Vec<(u64, String)> = Vec::new(); - let Ok(likes_json) = serde_json::to_string(&likes) else { - return Err(rusqlite::Error::InvalidQuery) - }; - let Ok(comments_json) = serde_json::to_string(&comments) else { - return Err(rusqlite::Error::InvalidQuery) - }; let date = u64::try_from( SystemTime::now() .duration_since(UNIX_EPOCH) @@ -122,29 +108,11 @@ pub fn add_post(user_id: u64, content: &str) -> Result { ) .unwrap_or(0); let conn = database::connect()?; - let mut stmt = conn.prepare("INSERT INTO posts (user_id, content, likes, comments, date) VALUES(?,?,?,?,?) RETURNING *;")?; - let post = stmt.query_row((user_id, content, likes_json, comments_json, date), |row| { + let mut stmt = + conn.prepare("INSERT INTO posts (user_id, content, date) VALUES(?,?,?) RETURNING *;")?; + let post = stmt.query_row((user_id, content, date), |row| { let row = post_from_row(row)?; Ok(row) })?; Ok(post) } - -#[instrument()] -pub fn update_post( - post_id: u64, - likes: &HashSet, - comments: &Vec<(u64, String)>, -) -> Result<(), rusqlite::Error> { - tracing::trace!("Updating post"); - let Ok(likes_json) = serde_json::to_string(&likes) else { - return Err(rusqlite::Error::InvalidQuery) - }; - let Ok(comments_json) = serde_json::to_string(&comments) else { - return Err(rusqlite::Error::InvalidQuery) - }; - let conn = database::connect()?; - let sql = "UPDATE posts SET likes = ?, comments = ? WHERE post_id = ?"; - conn.execute(sql, (likes_json, comments_json, post_id))?; - Ok(()) -} diff --git a/src/database/users.rs b/src/database/users.rs index 8045bc4..15565f1 100644 --- a/src/database/users.rs +++ b/src/database/users.rs @@ -21,6 +21,13 @@ pub fn init() -> Result<(), rusqlite::Error> { "; let conn = database::connect()?; conn.execute(sql, ())?; + + let sql2 = "CREATE UNIQUE INDEX IF NOT EXISTS emails on users (email);"; + conn.execute(sql2, ())?; + + let sql3 = "CREATE UNIQUE INDEX IF NOT EXISTS passwords on users (password);"; + conn.execute(sql3, ())?; + Ok(()) } diff --git a/src/public/admin.rs b/src/public/admin.rs index 1da2f1e..25941f1 100644 --- a/src/public/admin.rs +++ b/src/public/admin.rs @@ -4,8 +4,8 @@ use rand::{distributions::Alphanumeric, Rng}; use tokio::sync::Mutex; use crate::{ - console::{self, sanatize}, - types::{http::ResponseCode, post::Post, session::Session, user::User}, + console::sanatize, + types::{http::ResponseCode, post::Post, session::Session, user::User, comment::Comment, like::Like}, }; lazy_static! { @@ -79,24 +79,17 @@ pub fn generate_posts() -> Response { Post ID User ID Content - Likes - Comments Date "# .to_string(); for post in posts { - let Ok(likes) = serde_json::to_string(&post.likes) else { continue }; - let Ok(comments) = serde_json::to_string(&post.comments) else { continue }; - html.push_str(&format!( - "{}{}{}{}{}{}", + "{}{}{}{}", post.post_id, post.user_id, sanatize(&post.content), - console::beautify(&likes), - console::beautify(&comments), post.date )); } @@ -127,3 +120,54 @@ pub fn generate_sessions() -> Response { ResponseCode::Success.text(&html) } + +pub fn generate_comments() -> Response { + let comments = match Comment::reterieve_all() { + Ok(comments) => comments, + Err(err) => return err, + }; + + let mut html = r#" + + Comment ID + User ID + Post ID + Content + Date + + "# + .to_string(); + + for comment in comments { + html.push_str(&format!( + "{}{}{}{}{}", + comment.comment_id, comment.user_id, comment.post_id, sanatize(&comment.content), comment.date + )); + } + + ResponseCode::Success.text(&html) +} + +pub fn generate_likes() -> Response { + let likes = match Like::reterieve_all() { + Ok(likes) => likes, + Err(err) => return err, + }; + + let mut html = r#" + + User ID + Post ID + + "# + .to_string(); + + for like in likes { + html.push_str(&format!( + "{}{}", + like.user_id, like.post_id + )); + } + + ResponseCode::Success.text(&html) +} diff --git a/src/public/docs.rs b/src/public/docs.rs index 1f1448b..f4e26be 100644 --- a/src/public/docs.rs +++ b/src/public/docs.rs @@ -2,7 +2,10 @@ use axum::response::Response; use lazy_static::lazy_static; use tokio::sync::Mutex; -use crate::{api::{admin, users, posts, auth}, types::http::ResponseCode}; +use crate::{ + api::{admin, auth, posts, users}, + types::http::ResponseCode, +}; use super::console::beautify; @@ -40,10 +43,10 @@ fn generate_body(body: Option<&'static str>) -> String { return String::new() }; let html = r#" -

Body

-
- $body -
+

Body

+
+ $body +
"# .to_string(); let body = body.trim(); @@ -60,20 +63,20 @@ fn generate_body(body: Option<&'static str>) -> String { fn generate_responses(responses: &[(u16, &'static str)]) -> String { let mut html = r#" -

Responses

- $responses +

Responses

+ $responses "# .to_string(); for response in responses { let res = format!( r#" -
- {} - {} -
- $responses - "#, +
+ {} + {} +
+ $responses + "#, response.0, response.1 ); html = html.replace("$responses", &res); @@ -93,18 +96,18 @@ fn generate_cookie(cookie: Option<&'static str>) -> String { fn generate_endpoint(doc: &EndpointDocumentation) -> String { let html = r#" -
-
- $method - $uri - $description - $cookie -
-
- $body - $responses -
+
+
+ $method + $uri + $description + $cookie
+
+ $body + $responses +
+
"#; html.replace("$method_class", &doc.method.to_string().to_lowercase()) @@ -123,6 +126,7 @@ pub async fn init() { auth::AUTH_LOGOUT, posts::POSTS_CREATE, posts::POSTS_PAGE, + posts::COMMENTS_PAGE, posts::POSTS_USER, posts::POSTS_COMMENT, posts::POSTS_LIKE, @@ -136,6 +140,8 @@ pub async fn init() { 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 { @@ -154,27 +160,27 @@ pub async fn generate() -> Response { let html = format!( r#" - - - - - - - - - XSSBook - API Documentation - - - -
- {data} -
- - - "# + + + + + + + + + XSSBook - API Documentation + + + +
+ {data} +
+ + + "# ); ResponseCode::Success.html(&html) diff --git a/src/types/comment.rs b/src/types/comment.rs new file mode 100644 index 0000000..cf94bd3 --- /dev/null +++ b/src/types/comment.rs @@ -0,0 +1,44 @@ +use serde::Serialize; +use tracing::instrument; + +use crate::{ + database::{self, comments}, + types::http::{ResponseCode, Result}, +}; + +#[derive(Serialize)] +pub struct Comment { + pub comment_id: u64, + pub user_id: u64, + pub post_id: u64, + pub date: u64, + pub content: String, +} + +impl Comment { + #[instrument()] + pub fn new(user_id: u64, post_id: u64, content: &str) -> Result { + let Ok(comment) = comments::add_comment(user_id, post_id, content) else { + tracing::error!("Failed to create comment"); + return Err(ResponseCode::InternalServerError.text("Failed to create post")) + }; + + Ok(comment) + } + + #[instrument()] + pub fn from_comment_page(page: u64, post_id: u64) -> Result> { + let Ok(posts) = database::comments::get_comments_page(page, post_id) else { + return Err(ResponseCode::BadRequest.text("Failed to fetch comments")) + }; + Ok(posts) + } + + #[instrument()] + pub fn reterieve_all() -> Result> { + let Ok(posts) = database::comments::get_all_comments() else { + return Err(ResponseCode::InternalServerError.text("Failed to fetch comments")) + }; + Ok(posts) + } +} diff --git a/src/types/like.rs b/src/types/like.rs new file mode 100644 index 0000000..bf10b2d --- /dev/null +++ b/src/types/like.rs @@ -0,0 +1,48 @@ +use serde::Serialize; +use tracing::instrument; + +use crate::database; +use crate::types::http::{ResponseCode, Result}; + +#[derive(Serialize)] +pub struct Like { + pub user_id: u64, + pub post_id: u64 +} + +impl Like { + #[instrument()] + pub fn add_liked(user_id: u64, post_id: u64) -> Result<()> { + + let Ok(liked) = database::likes::add_liked(user_id, post_id) else { + return Err(ResponseCode::BadRequest.text("Failed to add like status")) + }; + + if !liked { + return Err(ResponseCode::InternalServerError.text("Failed to add like status")); + } + + Ok(()) + } + + #[instrument()] + pub fn remove_liked(user_id: u64, post_id: u64) -> Result<()> { + let Ok(liked) = database::likes::remove_liked(user_id, post_id) else { + return Err(ResponseCode::BadRequest.text("Failed to remove like status")) + }; + + if !liked { + return Err(ResponseCode::InternalServerError.text("Failed to remove like status")); + } + + Ok(()) + } + + #[instrument()] + pub fn reterieve_all() -> Result> { + let Ok(likes) = database::likes::get_all_likes() else { + return Err(ResponseCode::InternalServerError.text("Failed to fetch likes")) + }; + Ok(likes) + } +} diff --git a/src/types/mod.rs b/src/types/mod.rs index 3449d5c..1ee2d08 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -1,5 +1,7 @@ +pub mod comment; pub mod extract; pub mod http; +pub mod like; pub mod post; pub mod session; pub mod user; diff --git a/src/types/post.rs b/src/types/post.rs index ea7cbbf..a067512 100644 --- a/src/types/post.rs +++ b/src/types/post.rs @@ -1,19 +1,21 @@ use core::fmt; use serde::Serialize; -use std::collections::HashSet; use tracing::instrument; use crate::database; use crate::types::http::{ResponseCode, Result}; +use super::comment::Comment; + #[derive(Serialize)] pub struct Post { pub post_id: u64, pub user_id: u64, pub content: String, - pub likes: HashSet, - pub comments: Vec<(u64, String)>, pub date: u64, + pub likes: u64, + pub liked: bool, + pub comments: Vec, } impl fmt::Debug for Post { @@ -67,34 +69,4 @@ impl Post { Ok(post) } - - #[instrument()] - pub fn comment(&mut self, user_id: u64, content: String) -> Result<()> { - self.comments.push((user_id, content)); - - if database::posts::update_post(self.post_id, &self.likes, &self.comments).is_err() { - tracing::error!("Failed to comment on post"); - return Err(ResponseCode::InternalServerError.text("Failed to comment on post")); - } - - Ok(()) - } - - #[instrument()] - pub fn like(&mut self, user_id: u64, state: bool) -> Result<()> { - if state { - self.likes.insert(user_id); - } else { - self.likes.remove(&user_id); - } - - if database::posts::update_post(self.post_id, &self.likes, &self.comments).is_err() { - tracing::error!("Failed to change like state on post"); - return Err( - ResponseCode::InternalServerError.text("Failed to change like state on post") - ); - } - - Ok(()) - } }