This commit is contained in:
Tyler Murphy 2023-02-14 19:28:10 -05:00
parent b6fbeb5124
commit f02524b592
20 changed files with 490 additions and 75 deletions

View file

@ -119,7 +119,7 @@ body {
border-bottom: 3px solid var(--logo) !important;
}
#about {
#about, #friends {
margin-top: 2em;
align-self: center;
padding: 0;
@ -161,3 +161,35 @@ body {
.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;
}

View file

@ -79,6 +79,18 @@ export const loadself = async () => {
return await request("/users/self", {})
}
export const follow = async (state, user_id) => {
return await request('/users/follow', {state, user_id}, 'PUT')
}
export const follow_status = async (user_id) => {
return await request('/users/follow', {user_id})
}
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')
}

View file

@ -34,22 +34,7 @@ const data = {
}
async function load() {
let request = await loadself()
if (request.status === 429) {
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) {

View file

@ -1,31 +1,55 @@
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'
import { loadself, loadusers, loadusersposts, updateavatar, updatebanner, logout, follow, follow_status, friends } from './api.js'
import { parsePost, parseUser, 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];
function swap(tab) {
let post_button = document.querySelector("#profilepostbutton");
let about_button = document.querySelector("#profileaboutbutton");
let friends_button = document.querySelector("#profilefriendsbutton");
if (value) {
let posts_section = document.querySelector("#posts");
let about_section = document.querySelector("#about");
let friends_section = document.querySelector("#friends");
postsb.classList.add("selected")
aboutb.classList.remove("selected")
about.classList.add("hidden")
posts.classList.remove("hidden")
let load = document.querySelector(".loadp");
if (tab === 0) {
post_button.classList.add("selected")
about_button.classList.remove("selected")
friends_button.classList.remove("selected")
posts_section.classList.remove("hidden")
about_section.classList.add("hidden")
friends_section.classList.add("hidden")
if (load) {
load.classList.remove("hidden")
}
} else {
} else if (tab === 1) {
postsb.classList.remove("selected")
aboutb.classList.add("selected")
about.classList.remove("hidden")
posts.classList.add("hidden")
post_button.classList.remove("selected")
about_button.classList.add("selected")
friends_button.classList.remove("selected")
posts_section.classList.add("hidden")
about_section.classList.remove("hidden")
friends_section.classList.add("hidden")
if (load) {
load.classList.add("hidden")
}
} else if (tab === 2) {
post_button.classList.remove("selected")
about_button.classList.remove("selected")
friends_button.classList.add("selected")
posts_section.classList.add("hidden")
about_section.classList.add("hidden")
friends_section.classList.remove("hidden")
if (load) {
load.classList.add("hidden")
@ -67,7 +91,35 @@ function changeimage(fn) {
input.click();
}
function render() {
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 friends_arr = (await friends(data.user.user_id)).json
if (friends_arr == undefined) {
friends_arr = []
}
let new_body =
body({},
@ -91,16 +143,53 @@ function render() {
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(true)},
button({id: 'profilepostbutton', class: 'selected', onclick: () => swap(0)},
parse('Posts')
),
button({id: 'profileaboutbutton', onclick: () => swap(false)},
button({id: 'profileaboutbutton', onclick: () => swap(1)},
parse('About')
),
button({id: 'profilefriendsbutton', onclick: () => swap(2)},
parse('Friends')
),
div({style: 'flex: 20'}),
isself ? button({class: 'logout', onclick: async () => {
const response = await logout()
@ -154,6 +243,9 @@ function render() {
)
)
),
div({id: 'friends', class: 'hidden'},
...friends_arr.map(u => parseUser(u))
),
div({id: 'popup', class: 'hidden'},
div({class: 'createpost'},
div({class: 'close', onclick: () => document.getElementById('popup').classList.add('hidden')}),
@ -207,7 +299,6 @@ async function load(id) {
if (el) {
el.remove()
}
return []
} else {
page++
}

View file

@ -18,6 +18,7 @@
<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">

View file

@ -16,7 +16,7 @@ pub use auth::RegistrationRequet;
pub fn router() -> Router {
let governor_conf = Box::new(
GovernorConfigBuilder::default()
.burst_size(10)
.burst_size(15)
.per_second(1)
.key_extractor(SmartIpKeyExtractor)
.finish()

View file

@ -138,7 +138,7 @@ pub const COMMENTS_PAGE: EndpointDocumentation = EndpointDocumentation {
#[derive(Deserialize)]
struct CommentsPageRequest {
page: u64,
post_id: u64
post_id: u64,
}
impl Check for CommentsPageRequest {

View file

@ -1,7 +1,7 @@
use crate::{
public::docs::{EndpointDocumentation, EndpointMethod},
types::{
extract::{AuthorizedUser, Check, CheckResult, Json, Png},
extract::{AuthorizedUser, Check, CheckResult, Json, Log, Png},
http::ResponseCode,
user::User,
},
@ -116,7 +116,7 @@ pub const USERS_SELF: EndpointDocumentation = EndpointDocumentation {
cookie: Some("auth"),
};
async fn load_self(AuthorizedUser(user): AuthorizedUser) -> Response {
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")
};
@ -172,6 +172,143 @@ async fn banner(AuthorizedUser(user): AuthorizedUser, Png(img): Png) -> Response
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,
Json(body): Json<UserFollowRequest>,
) -> Response {
if body.state {
if let Err(err) = User::add_following(user.user_id, body.user_id) {
return err;
}
} else if let Err(err) = User::remove_following(user.user_id, body.user_id) {
return err;
}
match User::get_following(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,
Json(body): Json<UserFollowStatusRequest>,
) -> Response {
match User::get_following(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, Json(body): Json<UserFriendsRequest>) -> Response {
let Ok(users) = User::get_friends(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))
@ -179,4 +316,6 @@ pub fn router() -> Router {
.route("/page", post(load_page))
.route("/avatar", put(avatar))
.route("/banner", put(banner))
.route("/follow", put(follow).post(follow_status))
.route("/friends", post(friends))
}

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

@ -0,0 +1,97 @@
use tracing::instrument;
use crate::{
database::{self, users::user_from_row},
types::user::{User, FOLLOWED, FOLLOWING, NO_RELATION},
};
pub fn init() -> 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)
);
";
let conn = database::connect()?;
conn.execute(sql, ())?;
Ok(())
}
#[instrument()]
pub fn get_friend_status(user_id_1: u64, user_id_2: u64) -> Result<u8, rusqlite::Error> {
tracing::trace!("Retrieving friend status");
let conn = database::connect()?;
let mut stmt = conn.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()]
pub fn get_friends(user_id: u64) -> Result<Vec<User>, rusqlite::Error> {
tracing::trace!("Retrieving friends");
let conn = database::connect()?;
let mut stmt = conn.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 = user_from_row(row, true)?;
Ok(row)
})?;
Ok(row.into_iter().flatten().collect())
}
#[instrument()]
pub fn set_following(user_id_1: u64, user_id_2: u64) -> Result<bool, rusqlite::Error> {
tracing::trace!("Setting following");
let conn = database::connect()?;
let mut stmt =
conn.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()]
pub fn remove_following(user_id_1: u64, user_id_2: u64) -> Result<bool, rusqlite::Error> {
tracing::trace!("Removing following");
let conn = database::connect()?;
let mut stmt = conn.prepare("DELETE FROM friends WHERE follower_id = ? AND followee_id = ?")?;
let changes = stmt.execute([user_id_1, user_id_2])?;
Ok(changes == 1)
}

View file

@ -37,9 +37,7 @@ pub fn get_liked(user_id: u64, post_id: u64) -> Result<bool, rusqlite::Error> {
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()?;
let liked = stmt.query_row([user_id, post_id], |_| Ok(())).optional()?;
Ok(liked.is_some())
}
@ -69,7 +67,7 @@ pub fn get_all_likes() -> Result<Vec<Like>, rusqlite::Error> {
let row = stmt.query_map([], |row| {
let like = Like {
user_id: row.get(0)?,
post_id: row.get(1)?
post_id: row.get(1)?,
};
Ok(like)
})?;

View file

@ -1,6 +1,7 @@
use tracing::instrument;
pub mod comments;
pub mod friends;
pub mod likes;
pub mod posts;
pub mod sessions;
@ -16,6 +17,7 @@ pub fn init() -> Result<(), rusqlite::Error> {
sessions::init()?;
likes::init()?;
comments::init()?;
friends::init()?;
Ok(())
}

View file

@ -31,7 +31,7 @@ pub fn init() -> Result<(), rusqlite::Error> {
Ok(())
}
fn user_from_row(row: &Row, hide_password: bool) -> Result<User, rusqlite::Error> {
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)?;

View file

@ -5,7 +5,9 @@ use tokio::sync::Mutex;
use crate::{
console::sanatize,
types::{http::ResponseCode, post::Post, session::Session, user::User, comment::Comment, like::Like},
types::{
comment::Comment, http::ResponseCode, like::Like, post::Post, session::Session, user::User,
},
};
lazy_static! {
@ -141,7 +143,11 @@ pub fn generate_comments() -> Response {
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
comment.comment_id,
comment.user_id,
comment.post_id,
sanatize(&comment.content),
comment.date
));
}

View file

@ -192,7 +192,7 @@ impl Formatter for HtmlFormatter {
if first {
writer.write_all(b"<span class='key'>")
} else {
writer.write_all(b"<span class='key'>,")
writer.write_all(b",<span class='key'>")
}
}
@ -202,6 +202,13 @@ impl Formatter for HtmlFormatter {
{
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 {

View file

@ -49,6 +49,7 @@ fn generate_body(body: Option<&'static str>) -> String {
</div>
"#
.to_string();
let body = body.trim();
if body.starts_with('{') {
return html.replace(
@ -135,13 +136,16 @@ pub async fn init() {
users::USERS_SELF,
users::USERS_AVATAR,
users::USERS_BANNER,
users::USERS_FOLLOW,
users::USERS_FOLLOW_STATUS,
users::USERS_FRIENDS,
admin::ADMIN_AUTH,
admin::ADMIN_QUERY,
admin::ADMIN_POSTS,
admin::ADMIN_USERS,
admin::ADMIN_SESSIONS,
admin::ADMIN_COMMENTS,
admin::ADMIN_LIKES
admin::ADMIN_LIKES,
];
let mut endpoints = ENDPOINTS.lock().await;
for doc in docs {

View file

@ -25,7 +25,7 @@ pub mod pages;
pub fn router() -> Router {
let governor_conf = Box::new(
GovernorConfigBuilder::default()
.burst_size(20)
.burst_size(30)
.per_second(1)
.key_extractor(SmartIpKeyExtractor)
.finish()

View file

@ -1,6 +1,7 @@
use axum::{
response::{IntoResponse, Redirect, Response},
routing::get, Router
routing::get,
Router,
};
use crate::{
@ -58,9 +59,8 @@ async fn wordpress(_: Log) -> Response {
}
async fn forgot(UserAgent(agent): UserAgent, _: Log) -> Response {
if agent.starts_with("curl") {
return super::serve("/404.html").await
return super::serve("/404.html").await;
}
Redirect::to("https://www.youtube.com/watch?v=dQw4w9WgXcQ").into_response()

View file

@ -7,7 +7,7 @@ use axum::{
async_trait,
body::HttpBody,
extract::{ConnectInfo, FromRequest, FromRequestParts},
http::{request::Parts, Request, header::USER_AGENT},
http::{header::USER_AGENT, request::Parts, Request},
response::Response,
BoxError, RequestExt,
};
@ -205,7 +205,7 @@ where
};
let Ok(value) = serde_json::from_str::<T>(&body) else {
return Err(ResponseCode::BadRequest.text("Invalid request body"))
return Err(ResponseCode::BadRequest.text("Body does not match paramaters"))
};
if let Err(msg) = value.check() {
@ -256,7 +256,7 @@ where
return Err(ResponseCode::BadRequest.text("Bad Request"));
};
Ok(UserAgent(agent.to_string()))
Ok(Self(agent.to_string()))
}
}

View file

@ -7,13 +7,12 @@ use crate::types::http::{ResponseCode, Result};
#[derive(Serialize)]
pub struct Like {
pub user_id: u64,
pub post_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"))
};

View file

@ -19,6 +19,10 @@ pub struct User {
pub year: u32,
}
pub const NO_RELATION: u8 = 0;
pub const FOLLOWING: u8 = 1;
pub const FOLLOWED: u8 = 2;
impl User {
#[instrument()]
pub fn from_user_id(user_id: u64, hide_password: bool) -> Result<Self> {
@ -95,4 +99,42 @@ impl User {
Ok(user)
}
pub fn add_following(user_id_1: u64, user_id_2: u64) -> Result<()> {
let Ok(followed) = database::friends::set_following(user_id_1, user_id_2) else {
return Err(ResponseCode::BadRequest.text("Failed to add follow status"))
};
if !followed {
return Err(ResponseCode::InternalServerError.text("Failed to add follow status"));
}
Ok(())
}
pub fn remove_following(user_id_1: u64, user_id_2: u64) -> Result<()> {
let Ok(followed) = database::friends::remove_following(user_id_1, user_id_2) else {
return Err(ResponseCode::BadRequest.text("Failed to remove follow status"))
};
if !followed {
return Err(ResponseCode::InternalServerError.text("Failed to remove follow status"));
}
Ok(())
}
pub fn get_following(user_id_1: u64, user_id_2: u64) -> Result<u8> {
let Ok(followed) = database::friends::get_friend_status(user_id_1, user_id_2) else {
return Err(ResponseCode::InternalServerError.text("Failed to get follow status"))
};
Ok(followed)
}
pub fn get_friends(user_id: u64) -> Result<Vec<Self>> {
let Ok(users) = database::friends::get_friends(user_id) else {
return Err(ResponseCode::InternalServerError.text("Failed to fetch friends"))
};
Ok(users)
}
}