WebAPP Exam 2025
8
.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
|
||||
96
README.md
Normal file
@ -0,0 +1,96 @@
|
||||
# Exam #1: "Forum"
|
||||
|
||||
## React Client Application Routes
|
||||
|
||||
- Route `/`: Navigation bar at the top, list of posts and comments based on login (registered user or anonymous). Button to add a new post if logged in, several buttons to edit, delete, add comments based on the role (owner or logged OTP). If no role is matched the buttons are not displayed. If the max number of comments under a post is reached the button is disabled.
|
||||
- Route `/add/post`: The page described at route '/', there is a pop up form to add a new post at the beginning of the page.
|
||||
- Route `/add/:postid/comment`: The page described at route '/', there is a pop up form to add a new comment at the beginning of the page.
|
||||
- Route `/edit/:commentid`: The page described at route '/', there is a pop up form to edit the text of a comment at the beginning of the page.
|
||||
- Route `/login`: Login form on an a different page. If already logged in the user will see the OTP login with the code, otherwise the normal one with username and password.
|
||||
|
||||
## API Server
|
||||
|
||||
- GET `/api/posts`: return the list of posts and comments.
|
||||
- POST `/api/posts`: create a new post.
|
||||
- request body: title, text, max number of comments.
|
||||
- response body: post object succesfully created.
|
||||
- POST `/api/posts/:id/comments`: create a new comment under a post.
|
||||
- request parameters: id of the post that the user commented.
|
||||
- response body: comment object succesfully created.
|
||||
- POST `/api/posts/:id/comments/:commentId/interest`: set "interesting" a comment.
|
||||
- request parameters: id of the post and id of the related comment that the user find interesting.
|
||||
- DELTE `/api/posts/:id/comments/:commentId/interest`: remove "interesting" from a comment.
|
||||
- request parameters: id of the post and id of the related comment that the user does not find interesting anymore.
|
||||
- PATCH `/api/posts/:id/comments/:commentId`: edit the text of a comment.
|
||||
- request parameters: id of the post and id of the related comment that the user edited.
|
||||
- request body: new text of the comment
|
||||
- DELETE `/api/posts/:id`: delete a given post.
|
||||
- request parameters: id of the post that the user wants to delete.
|
||||
- DELETE `/api/posts/:id/comments/:commentId`: delete a given comment.
|
||||
- request parameters: id of the post and id of the related comment that the user wants to delete.
|
||||
- POST `/api/sessions`: Login via username and password.
|
||||
- response body: user informations retrieved.
|
||||
- POST `/api/login-totp`: TOTP authentication (only for Admin users.)
|
||||
- request body: code of TOTP.
|
||||
- GET `/api/sessions/current`: Retrieve info about loggedin user.
|
||||
- response body: user informations retrieved.
|
||||
- DELETE `/api/sessions/current`: Logout.
|
||||
|
||||
## Database Tables
|
||||
|
||||
- Table `User`:
|
||||
- Columns: `ID`, `Username`, `Password`, `Salt`, `Secret`, `Type`.
|
||||
- Contains the users registered to the forum. The Type field specify which one are administrators.
|
||||
|
||||
- Table `Post`:
|
||||
- Columns: `Title`, `ID`, `AuthorID`, `MaxComments`, `Publication`, `Text`
|
||||
- Contains the posts created inside the forum, in particular the title is unique but the ID column is still used as primary key.
|
||||
|
||||
- Table `Comment`:
|
||||
- Columns: `ID`, `Text`, `Publication`, `AuthorID`, `PostID`
|
||||
- Contains the comments created inside the forum, each comment is binded to a post with the PostID column and to the author with the AuthorID column.
|
||||
|
||||
- Table `Interesting`:
|
||||
- Columns: `UserID`, `CommentID`
|
||||
- Contains which user found interesting which comment. It is a many to many relationship and the two columns UserID and CommentID are used as primary keys together in order to make unique the relationship.
|
||||
|
||||
## Main React Components
|
||||
- `ForumTable` (in `Forum.jsx`): iterates the `posts` array in order to generate all the posts and comments retrieved from the API.
|
||||
- `Post` (in `Forum.jsx`): create the post element with the information contained in `postData` passed by `ForumTable`.
|
||||
- `Comment` (in `Forum.jsx`): create the comment element with the information contained in `commentData` passed by `Post`.
|
||||
- `CommentForm` (in `CommentEdit.jsx`): create the form to add a new comment or edit an existing one and handle the submit.
|
||||
- `PostForm` (in `CommentEdit.jsx`): create the form to add a new post and handle the submit.
|
||||
- `TotpForm` (in `Auth.jsx`): create the form to perform the TOTP login and handle the submit
|
||||
- `LoginForm` (in `Auth.jsx`): create the form to perform the login and handle the submit.
|
||||
|
||||
## Screenshot
|
||||
### Login Page
|
||||

|
||||
### Login TOTP Page
|
||||

|
||||
### Anonymous
|
||||

|
||||
### Admin logged in
|
||||

|
||||
### User logged in
|
||||

|
||||
### Admin TOTP logged in
|
||||

|
||||
### Create a post
|
||||

|
||||
### Create a comment
|
||||

|
||||
### Edit a comment
|
||||

|
||||
### Set interesting a comment
|
||||

|
||||
|
||||
## Users Credentials
|
||||
|
||||
- AdminUno, PasswordAdmin1
|
||||
- AdminDue, PasswordAdmin2
|
||||
- UtenteUno, PasswordUtente1
|
||||
- UtenteDuo, PasswordUtente2
|
||||
- UtenteTre, PasswordUtente3
|
||||
|
||||
|
||||
24
client/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
12
client/README.md
Normal file
@ -0,0 +1,12 @@
|
||||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
33
client/eslint.config.js
Normal file
@ -0,0 +1,33 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
|
||||
export default [
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
13
client/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3124
client/package-lock.json
generated
Normal file
32
client/package.json
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"bootstrap": "^5.3.6",
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"react": "^19.1.0",
|
||||
"react-bootstrap": "^2.10.10",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router": "^7.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.0",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^16.0.0",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
1
client/public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
180
client/src/API.js
Normal file
@ -0,0 +1,180 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const SERVER_URL = 'http://localhost:3001/api/';
|
||||
function getJson(httpResponsePromise) {
|
||||
return new Promise((resolve, reject) => {
|
||||
httpResponsePromise
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
response.json()
|
||||
.then( json => resolve(json) )
|
||||
.catch( err => reject({ error: "Cannot parse server response" }))
|
||||
|
||||
} else {
|
||||
response.json()
|
||||
.then(obj =>
|
||||
reject(obj)
|
||||
)
|
||||
.catch(err => reject({ error: "Cannot parse server response" }))
|
||||
}
|
||||
})
|
||||
.catch(err =>
|
||||
reject({ error: "Cannot communicate" })
|
||||
) // connection error
|
||||
});
|
||||
}
|
||||
|
||||
const getPosts = async () => {
|
||||
// Clientside publication is called timestamp
|
||||
return getJson(
|
||||
fetch(SERVER_URL + 'posts', { credentials: 'include' })
|
||||
).then( json => {
|
||||
const posts = json.map((post) => {
|
||||
const clientPost = {
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
text: post.text,
|
||||
authorid: post.authorid,
|
||||
author: post.author,
|
||||
maxcomments: post.maxcomments,
|
||||
numcomments: post.numComments,
|
||||
timestamp: post.publication,
|
||||
comments: post.comments.map( c => { c.timestamp = c.publication; return c;})
|
||||
}
|
||||
//console.log(`POST: \n TITLE:${clientPost.title}\n ID:${clientPost.id}\n AUTHORID:${clientPost.authorid}\n AUTHOR:${clientPost.author}\n ${clientPost.timestamp} \n TEXT:${clientPost.text}`);
|
||||
return clientPost;
|
||||
});
|
||||
|
||||
return posts.sort((a,b) => dayjs(b.timestamp) - dayjs(a.timestamp));
|
||||
})
|
||||
}
|
||||
function editComment(comment) {
|
||||
const payload = { text: comment.text };
|
||||
return getJson(
|
||||
fetch(SERVER_URL + "posts/" + comment.postid + "/comments/" + comment.id, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function setCommentInteresting(comment) {
|
||||
return getJson(
|
||||
fetch(SERVER_URL + "posts/" + comment.postid + "/comments/" + comment.id + "/interest", {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function unsetCommentInteresting(comment) {
|
||||
return getJson(
|
||||
fetch(SERVER_URL + "posts/" + comment.postid + "/comments/" + comment.id + "/interest", {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function addPost(post) {
|
||||
return getJson(
|
||||
fetch(SERVER_URL + "posts/", {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(post)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function addComment(postid, comment) {
|
||||
return getJson(
|
||||
fetch(SERVER_URL + "posts/" + postid + "/comments/", {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(comment)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function deletePost(post) {
|
||||
return getJson(
|
||||
fetch(SERVER_URL + "posts/" + post.id, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function deleteComment(comment) {
|
||||
return getJson(
|
||||
fetch(SERVER_URL + "posts/" + comment.postid + "/comments/" + comment.id, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
const totpVerify = async (totpCode) => {
|
||||
return getJson(fetch(SERVER_URL + 'login-totp', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({code: totpCode}),
|
||||
})
|
||||
)
|
||||
};
|
||||
|
||||
|
||||
const logIn = async (credentials) => {
|
||||
return getJson(fetch(SERVER_URL + 'sessions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(credentials),
|
||||
})
|
||||
)
|
||||
};
|
||||
|
||||
const getUserInfo = async () => {
|
||||
return getJson(fetch(SERVER_URL + 'sessions/current', {
|
||||
credentials: 'include'
|
||||
})
|
||||
)
|
||||
};
|
||||
|
||||
const logOut = async() => {
|
||||
return getJson(fetch(SERVER_URL + 'sessions/current', {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const API = { getPosts, editComment, addPost, addComment, deletePost, deleteComment, setCommentInteresting, unsetCommentInteresting,
|
||||
logIn, getUserInfo, logOut, totpVerify };
|
||||
export default API;
|
||||
0
client/src/App.css
Normal file
184
client/src/App.jsx
Normal file
@ -0,0 +1,184 @@
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import 'bootstrap-icons/font/bootstrap-icons.css';
|
||||
|
||||
import { React, useState, useEffect } from 'react';
|
||||
import { Container } from 'react-bootstrap';
|
||||
import { Routes, Route, useLocation, Navigate, useNavigate } from 'react-router';
|
||||
|
||||
import './App.css';
|
||||
|
||||
import { GenericLayout, AddPostLayout, AddCommentLayout, EditLayout, LoginLayout, TotpLayout } from './components/Layout';
|
||||
import API from './API.js';
|
||||
|
||||
function App() {
|
||||
|
||||
const navigate = useNavigate();
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
const [user, setUser] = useState(null);
|
||||
const [loggedInTotp, setLoggedInTotp] = useState(false);
|
||||
|
||||
const [postList, setPostList] = useState([]);
|
||||
const [message, setMessage] = useState('');
|
||||
const [dirty, setDirty] = useState(true);
|
||||
|
||||
const handleErrors = (err) => {
|
||||
let msg = '';
|
||||
if (err.error)
|
||||
msg = err.error;
|
||||
else if (err.errors) {
|
||||
if (err.errors[0].msg)
|
||||
msg = err.errors[0].msg + " : " + err.errors[0].path;
|
||||
} else if (Array.isArray(err))
|
||||
msg = err[0].msg + " : " + err[0].path;
|
||||
else if (typeof err === "string") msg = String(err);
|
||||
else msg = "Unknown Error";
|
||||
|
||||
setMessage(msg);
|
||||
|
||||
if (msg === 'Not authenticated')
|
||||
setTimeout(() => { // do logout in the app state
|
||||
setUser(null); setLoggedIn(false); setLoggedInTotp(false); setDirty(true);
|
||||
}, 2000);
|
||||
else
|
||||
setTimeout(()=>setDirty(true), 2000);
|
||||
}
|
||||
|
||||
const checkAuth = async() => {
|
||||
try {
|
||||
const user = await API.getUserInfo();
|
||||
setLoggedIn(true);
|
||||
setUser(user);
|
||||
if (user.isTotp)
|
||||
setLoggedInTotp(true);
|
||||
} catch(err) {
|
||||
}
|
||||
};
|
||||
|
||||
/* This useeffect is called each time the location is changed, so each time a Cancel button goes back to
|
||||
the home the posts are correctly fetched again. */
|
||||
const location = useLocation();
|
||||
|
||||
useEffect( () => {
|
||||
setDirty(true);
|
||||
} ,[location, location.state]);
|
||||
|
||||
useEffect(()=> {
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const handleLogin = async (credentials) => {
|
||||
try {
|
||||
const user = await API.logIn(credentials);
|
||||
setUser(user);
|
||||
setLoggedIn(true);
|
||||
setDirty(true);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await API.logOut();
|
||||
setLoggedIn(false);
|
||||
setLoggedInTotp(false);
|
||||
setUser(null);
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
function deletePost(post) {
|
||||
API.deletePost(post)
|
||||
.then(()=> setDirty(true))
|
||||
.catch(err=>handleErrors(err));
|
||||
}
|
||||
|
||||
function deleteComment(comment) {
|
||||
API.deleteComment(comment)
|
||||
.then(()=> setDirty(true))
|
||||
.catch(err=>handleErrors(err));
|
||||
}
|
||||
|
||||
function editComment(comment) {
|
||||
API.editComment(comment)
|
||||
.then(()=>{setDirty(true); navigate('/');})
|
||||
.catch(err=>handleErrors(err));
|
||||
}
|
||||
|
||||
function setCommentInteresting(comment) {
|
||||
setDirty(true);
|
||||
API.setCommentInteresting(comment)
|
||||
.then(()=>{setDirty(true); navigate('/');})
|
||||
.catch(err=>handleErrors(err));
|
||||
}
|
||||
|
||||
function unsetCommentInteresting(comment) {
|
||||
API.unsetCommentInteresting(comment)
|
||||
.then(()=>{setDirty(true); navigate('/');})
|
||||
.catch(err=>handleErrors(err));
|
||||
}
|
||||
|
||||
function addPost(post) {
|
||||
API.addPost(post)
|
||||
.then(()=>{setDirty(true); navigate('/');})
|
||||
.catch(err=>handleErrors(err));
|
||||
}
|
||||
|
||||
function addComment(postid, comment) {
|
||||
API.addComment(postid,comment)
|
||||
.then(()=>{setDirty(true); navigate('/');})
|
||||
.catch(err=>handleErrors(err));
|
||||
}
|
||||
|
||||
return (
|
||||
<Container fluid className="bg-light">
|
||||
<Routes>
|
||||
<Route path="/" element={<GenericLayout
|
||||
type={user != null ? user.type : "nouser"}
|
||||
loggedIn={loggedIn} loggedInTotp={loggedInTotp}
|
||||
message={message} setMessage={setMessage}
|
||||
user={user} logout={handleLogout}
|
||||
checkAuth={checkAuth}
|
||||
postList={postList} setPostList={setPostList}
|
||||
deletePost={deletePost} deleteComment={deleteComment} editComment={editComment}
|
||||
disableAdd={!loggedIn} disableActions={!loggedInTotp}
|
||||
handleErrors={handleErrors}
|
||||
setCommentInteresting={setCommentInteresting} unsetCommentInteresting={unsetCommentInteresting}
|
||||
dirty={dirty} setDirty={setDirty} /> }>
|
||||
|
||||
<Route path="add">
|
||||
<Route path="post" element={loggedIn?
|
||||
<AddPostLayout setDirty={setDirty} addPost={addPost} />
|
||||
: <Navigate replate to='/'/>} />
|
||||
<Route path=":postid/comment" element={<AddCommentLayout
|
||||
setDirty={setDirty}
|
||||
addComment={addComment} />} />
|
||||
</Route>
|
||||
<Route path="edit/:commentid" element={loggedIn?
|
||||
<EditLayout setDirty={setDirty}
|
||||
posts={postList}
|
||||
editComment={editComment} /> :
|
||||
<Navigate replace to='/' />} />
|
||||
</Route>
|
||||
<Route path='/login' element={ <LoginWithTotp loggedIn={loggedIn} login={handleLogin}
|
||||
checkAuth={checkAuth}
|
||||
user={user} setLoggedInTotp={setLoggedInTotp} /> } />
|
||||
</Routes>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginWithTotp(props) {
|
||||
if (props.loggedIn) {
|
||||
if (props.user && props.user.type == "administrator") {
|
||||
if (props.loggedInTotp) {
|
||||
return <Navigate replace to='/' />;
|
||||
} else {
|
||||
return <TotpLayout totpSuccessful={() => props.setLoggedInTotp(true)} />;
|
||||
}
|
||||
} else {
|
||||
return <Navigate replace to='/' />;
|
||||
}
|
||||
} else {
|
||||
return <LoginLayout checkAuth={props.checkAuth} login={props.login} />;
|
||||
}
|
||||
}
|
||||
export default App
|
||||
1
client/src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
139
client/src/components/Auth.jsx
Normal file
@ -0,0 +1,139 @@
|
||||
import { useState } from 'react';
|
||||
import { Form, Button, Alert, Col, Row } from 'react-bootstrap';
|
||||
import { useNavigate } from 'react-router';
|
||||
import API from '../API.js'
|
||||
|
||||
function TotpForm(props) {
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
||||
const doTotpVerify = () => {
|
||||
API.totpVerify(totpCode)
|
||||
.then(() => {
|
||||
setErrorMessage('');
|
||||
props.totpSuccessful();
|
||||
navigate('/');
|
||||
})
|
||||
.catch(() => {
|
||||
setErrorMessage('Something went wrong, please try again');
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
setErrorMessage('');
|
||||
|
||||
let valid = true;
|
||||
|
||||
if(totpCode === '' || totpCode.length !== 6)
|
||||
valid = false;
|
||||
|
||||
if (valid) {
|
||||
doTotpVerify(totpCode);
|
||||
} else {
|
||||
setErrorMessage('Invalid content in form: either empty or not 6-char long');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<Col xs={4}></Col>
|
||||
<Col xs={4}>
|
||||
|
||||
<h2>Second Factor Authentication</h2>
|
||||
<h5>Please enter the code that you read on your device</h5>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
{errorMessage ? <Alert variant='danger' dismissible onClick={() => setErrorMessage('')}>{errorMessage}</Alert> : ''}
|
||||
<Form.Group controlId='totpCode'>
|
||||
<Form.Label>Code</Form.Label>
|
||||
<Form.Control type='text' required={true} value={totpCode} onChange={ev => setTotpCode(ev.target.value)} />
|
||||
</Form.Group>
|
||||
<Button className='my-2' type='submit'>Validate</Button>
|
||||
<Button className='my-2 mx-2' variant='danger' onClick={() => navigate('/')}>Cancel</Button>
|
||||
</Form>
|
||||
</Col>
|
||||
<Col xs={4}></Col>
|
||||
</Row>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
function LoginForm(props) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [istotp, setIstotp] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
||||
const handleSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
const credentials = { username, password };
|
||||
|
||||
if (!username) {
|
||||
setErrorMessage('Username cannot be empty');
|
||||
} else if (!password) {
|
||||
setErrorMessage('Password cannot be empty');
|
||||
} else {
|
||||
props.login(credentials)
|
||||
.catch((err) => {
|
||||
setErrorMessage(err.error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<Col xs={4}></Col>
|
||||
<Col xs={4}>
|
||||
<h1 className="pb-3">Login</h1>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
{errorMessage? <Alert dismissible onClose={() => setErrorMessage('')} variant="danger">{errorMessage}</Alert> : null}
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Username</Form.Label>
|
||||
<Form.Control
|
||||
type="username"
|
||||
value={username} placeholder="Enter your username"
|
||||
onChange={(ev) => setUsername(ev.target.value)}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Password</Form.Label>
|
||||
<Form.Control
|
||||
type="password"
|
||||
value={password} placeholder="Enter your password"
|
||||
onChange={(ev) => setPassword(ev.target.value)}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Check type="checkbox"
|
||||
label="Use OTP"
|
||||
onChange={(ev) => setIstotp(ev.target.value)}
|
||||
/>
|
||||
<Button className="my-2" type="submit" onClick={()=> istotp ? props.checkAuth() : navigate('/')} >Login</Button>
|
||||
<Button className='my-2 mx-2' variant='danger' onClick={() => navigate('/')}>Cancel</Button>
|
||||
</Form>
|
||||
</Col>
|
||||
<Col xs={4}></Col>
|
||||
</Row>
|
||||
|
||||
)
|
||||
};
|
||||
|
||||
function LogoutButton(props) {
|
||||
return (
|
||||
<Button variant="outline-light" onClick={props.logout}>Logout</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function LoginButton(props) {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<Button variant="outline-light" onClick={()=> navigate('/login')}>{props.text}</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export { LoginForm, LogoutButton, LoginButton, TotpForm };
|
||||
63
client/src/components/CommentEdit.jsx
Normal file
@ -0,0 +1,63 @@
|
||||
import {useState} from 'react';
|
||||
import {Form, Button, Alert} from 'react-bootstrap';
|
||||
import { Link, useParams } from 'react-router';
|
||||
/* Edit or add a comment */
|
||||
const CommentForm = (props) => {
|
||||
|
||||
const [text, setText] = useState(props.commentToEdit ? props.commentToEdit.text : '');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
const postid = useParams().postid;
|
||||
|
||||
const handleSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
// String.trim() method is used for removing leading and ending whitespaces from the title.
|
||||
const comment = { "text" : text.trim() };
|
||||
if(comment.text.length == 0){
|
||||
setErrorMsg('New text tength cannot be 0');
|
||||
}
|
||||
//Probably already checked by the form
|
||||
else if ( typeof comment.text !== "string" ){
|
||||
setErrorMsg("Text comment must be a string");
|
||||
}
|
||||
else {
|
||||
/* To edit we dont need postid since comment id is unique in db, instead to add a new comment to a post we need it. */
|
||||
if (props.commentToEdit) {
|
||||
comment.id = props.commentToEdit.id;
|
||||
comment.postid = props.commentToEdit.postid;
|
||||
props.editComment(comment);
|
||||
//navigate('/'); // not here, we must wait for the editing on the server to be finished
|
||||
} else {
|
||||
props.addComment(postid, comment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{ errorMsg ?
|
||||
<Alert variant='danger' onClose={()=>setErrorMsg('')} dismissible>{errorMsg}</Alert> :
|
||||
false }
|
||||
|
||||
{props.commentToEdit ?
|
||||
<h1>Edit comment</h1> :
|
||||
<h1> Insert new comment</h1>}
|
||||
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Text</Form.Label>
|
||||
<Form.Control as="textarea" required={true} value={text} onChange={event => setText(event.target.value)}/>
|
||||
</Form.Group>
|
||||
|
||||
<Button className="mb-3" variant="primary" type="submit">Save</Button>
|
||||
|
||||
<Link to={"/"} >
|
||||
<Button className="mb-3" variant="danger">Cancel</Button>
|
||||
</Link>
|
||||
</Form>
|
||||
</>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
export { CommentForm };
|
||||
119
client/src/components/Forum.jsx
Normal file
@ -0,0 +1,119 @@
|
||||
import 'dayjs';
|
||||
import { Button, Card} from 'react-bootstrap';
|
||||
import { useNavigate} from 'react-router';
|
||||
function ForumTable(props) {
|
||||
const { posts } = props;
|
||||
/*Posts embedded also comments*/
|
||||
return(
|
||||
<div>
|
||||
{posts.map((post) => <Post loggedIn={props.loggedIn} loggedInTotp={props.loggedInTotp}
|
||||
user={props.user}
|
||||
postData={post} key={post.title}
|
||||
setCommentInteresting={props.setCommentInteresting} unsetCommentInteresting={props.unsetCommentInteresting}
|
||||
deletePost={props.deletePost} deleteComment={props.deleteComment}
|
||||
editComment={props.editComment}/>)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Post(props) {
|
||||
const navigate = useNavigate();
|
||||
/* If is logged OTP or Owner then is always enabled */
|
||||
/* Somehow can happen if reloading the page in other form that loggedIn remains true without a user state */
|
||||
const isAuthor = props.loggedIn && props.user ?
|
||||
(props.user.id == props.postData.authorid ? true : false)
|
||||
: false;
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<Card.Header className="bg-primary text-white">{props.postData.title}</Card.Header>
|
||||
<Card.Body>
|
||||
<Card.Text style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{props.postData.text}
|
||||
</Card.Text>
|
||||
{isAuthor || props.loggedInTotp ?
|
||||
<Button className="mx-1 mt-1" variant='danger'
|
||||
onClick={() => { props.deletePost(props.postData) }} >
|
||||
<i className='bi bi-trash'></i>
|
||||
</Button> :
|
||||
null
|
||||
}
|
||||
<Button variant="primary" className="mx-1 mt-1" disabled={props.postData.maxcomments != -1 && props.postData.numcomments == props.postData.maxcomments ? true : false}
|
||||
onClick={()=>{navigate(`/add/${props.postData.id}/comment`); window.scrollTo(0,0);}} >
|
||||
<i className='bi bi-plus'/>
|
||||
</Button>
|
||||
</Card.Body>
|
||||
<Card.Footer>
|
||||
Published by: {props.postData.author} on {props.postData.timestamp}<br/>
|
||||
<b className="mx-1">Comments: {props.postData.numcomments} </b>
|
||||
{props.postData.maxcomments != -1 ? <b> Max:{props.postData.maxcomments} </b> : null}
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
<br/>
|
||||
<div>{props.postData.comments.map( (comment) => <Comment loggedInTotp={props.loggedInTotp} user={props.user} loggedIn={props.loggedIn}
|
||||
commentData={comment} key={comment.id}
|
||||
setCommentInteresting={props.setCommentInteresting} unsetCommentInteresting={props.unsetCommentInteresting}
|
||||
deleteComment={props.deleteComment} editComment={props.editComment}/>)}</div>
|
||||
<br/>
|
||||
</>
|
||||
);}
|
||||
|
||||
function Comment(props) {
|
||||
const navigate = useNavigate();
|
||||
/* Authorid is always defined (at least is null) instead author name is undefined if authorid is null
|
||||
One user can delete the comment if logged with totp or if OWNER of comment */
|
||||
function checkAuthor(user, comment){
|
||||
if(user){
|
||||
return user.id == comment.authorid;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const isAuthor = props.loggedIn ? checkAuthor(props.user,props.commentData) : false;
|
||||
const likedClass = "mx-5 my-3 bg-light border border-3 border-success rounded"
|
||||
const generalClass = "mx-5 my-3 bg-light border border-3 rounded"
|
||||
return (
|
||||
<>
|
||||
<Card className={props.commentData.isInterested ? likedClass : generalClass}>
|
||||
<Card.Header>
|
||||
<b className="mx-2 text-primary"> {props.commentData.authorid == null ? "Anonymous" : props.commentData.author }</b>
|
||||
{ props.commentData.isInterested ?
|
||||
<i className="bi bi-flag-fill"></i>
|
||||
: <br/> }
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<Card.Text style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{props.commentData.text}
|
||||
</Card.Text>
|
||||
{ props.loggedIn ?
|
||||
(props.commentData.isInterested ?
|
||||
|
||||
<Button className="mx-1" onClick={ () => props.unsetCommentInteresting(props.commentData) }>
|
||||
<i className={"bi bi-hand-thumbs-up-fill"}></i>
|
||||
</Button> :
|
||||
|
||||
<Button className="mx-1" onClick={ () => props.setCommentInteresting(props.commentData) } >
|
||||
<i className={"bi bi-hand-thumbs-up"}></i>
|
||||
</Button>)
|
||||
:
|
||||
null }
|
||||
{ isAuthor || props.loggedInTotp ? <>
|
||||
<Button className="mx-1" variant='danger'
|
||||
onClick={ () => props.deleteComment(props.commentData) } >
|
||||
<i className='bi bi-trash'></i>
|
||||
</Button>
|
||||
<Button className="mx-1" variant='warning'
|
||||
onClick={() => { navigate(`/edit/${props.commentData.id}`); window.scrollTo(0,0); }} >
|
||||
<i className='bi bi-pencil'></i>
|
||||
</Button> </> : null }
|
||||
</Card.Body>
|
||||
<Card.Footer>
|
||||
Published on {props.commentData.timestamp} <br/>
|
||||
{props.commentData.numInterested ?
|
||||
<b>{props.commentData.numInterested } interested </b> :
|
||||
<br/> }
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export { ForumTable };
|
||||
146
client/src/components/Layout.jsx
Normal file
@ -0,0 +1,146 @@
|
||||
import { Row, Col, Button, Spinner, Alert } from 'react-bootstrap';
|
||||
import { Outlet, Link, useParams, Navigate, useNavigate } from 'react-router';
|
||||
|
||||
import { Navigation } from './Navigation';
|
||||
import { ForumTable } from './Forum';
|
||||
import { CommentForm } from './CommentEdit';
|
||||
import { PostForm } from './PostCreate';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { LoginForm, TotpForm } from './Auth';
|
||||
import API from '../API.js';
|
||||
|
||||
|
||||
function NotFoundLayout(props) {
|
||||
return (
|
||||
<>
|
||||
<h2>This route is not valid!</h2>
|
||||
<Link to="/">
|
||||
<Button variant="primary">Go back to the main page!</Button>
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginLayout(props) {
|
||||
return (
|
||||
<Row>
|
||||
<Col>
|
||||
<LoginForm checkAuth={props.checkAuth} login={props.login} />
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function TotpLayout(props) {
|
||||
return (
|
||||
<Row>
|
||||
<Col>
|
||||
<TotpForm totpSuccessful={props.totpSuccessful} />
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function AddPostLayout(props) {
|
||||
return (
|
||||
<PostForm setDirty={props.setDirty} addPost={props.addPost} />
|
||||
);
|
||||
}
|
||||
|
||||
function AddCommentLayout(props) {
|
||||
return (
|
||||
<CommentForm setDirty={props.setDirty} addComment={props.addComment} />
|
||||
);
|
||||
}
|
||||
function EditLayout(props) {
|
||||
const { commentid } = useParams();
|
||||
const findComment = (posts) => {
|
||||
for (const post of posts){
|
||||
if( post.comments )
|
||||
for( const comment of post.comments ){
|
||||
if(comment.id == commentid)
|
||||
return comment;
|
||||
}
|
||||
}
|
||||
}
|
||||
const commentToEdit = findComment(props.posts)
|
||||
console.log("EDIT COMMENT: "+commentToEdit);
|
||||
return(
|
||||
<>
|
||||
{commentToEdit?
|
||||
<CommentForm setDirty={props.setDirty} editComment={props.editComment} commentToEdit={commentToEdit} />
|
||||
: <Navigate to={"/"} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TableLayout(props) {
|
||||
|
||||
const navigate = useNavigate();
|
||||
const [waiting, setWaiting] = useState(true);
|
||||
|
||||
useEffect( () => {
|
||||
if(props.dirty){
|
||||
API.getPosts()
|
||||
.then( posts => {
|
||||
props.setPostList(posts);
|
||||
props.setDirty(false);
|
||||
setWaiting(false);
|
||||
}).catch( e => { props.handleErrors(e); } );
|
||||
|
||||
props.setDirty(false);
|
||||
}
|
||||
},[props.dirty]);
|
||||
return (
|
||||
<>
|
||||
{ props.loggedIn ?
|
||||
<div className="d-flex flex-row justify-content-between">
|
||||
<Button variant="primary" className="my-2" disabled={props.disableAdd}
|
||||
onClick={()=>navigate('/add/post')}>+</Button>
|
||||
</div> :
|
||||
<Row><Col> <br/> </Col></Row> }
|
||||
{ waiting? <Spinner /> :
|
||||
<ForumTable
|
||||
loggedIn={props.loggedIn} loggedInTotp={props.loggedInTotp}
|
||||
posts={props.postList} deletePost={props.deletePost}
|
||||
deleteComment={props.deleteComment} editComment={props.editComment}
|
||||
setCommentInteresting={props.setCommentInteresting} unsetCommentInteresting={props.unsetCommentInteresting}
|
||||
user = {props.user} checkAuth = {props.checkAuth}
|
||||
disableActions={props.disableActions} />
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GenericLayout(props) {
|
||||
return (
|
||||
<>
|
||||
<Row>
|
||||
<Col>
|
||||
<Navigation type={props.type} checkAuth={props.checkAuth} loggedIn={props.loggedIn} user={props.user} loggedInTotp={props.loggedInTotp} logout={props.logout} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col>
|
||||
{props.message? <Alert className='my-1' onClose={() => props.setMessage('')} variant='danger' dismissible>
|
||||
{props.message}</Alert> : null}
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Outlet />
|
||||
<TableLayout
|
||||
loggedIn={props.loggedIn} loggedInTotp={props.loggedInTotp}
|
||||
user={props.user} checkAuth={props.checkAuth}
|
||||
message={props.message} setMessage={props.setMessage}
|
||||
postList={props.postList} setPostList={props.setPostList}
|
||||
deletePost={props.deletePost} deleteComment={props.deleteComment} editComment={props.editComment}
|
||||
disableAdd={!props.loggedIn} disableActions={!props.loggedInTotp}
|
||||
handleErrors={props.handleErrors}
|
||||
setCommentInteresting={props.setCommentInteresting} unsetCommentInteresting={props.unsetCommentInteresting}
|
||||
dirty={props.dirty} setDirty={props.setDirty} />
|
||||
</Row>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
export { GenericLayout, NotFoundLayout, TableLayout, AddPostLayout, AddCommentLayout, EditLayout, LoginLayout, TotpLayout };
|
||||
35
client/src/components/Navigation.jsx
Normal file
@ -0,0 +1,35 @@
|
||||
import 'bootstrap-icons/font/bootstrap-icons.css';
|
||||
|
||||
import { Navbar, Nav, Form } from 'react-bootstrap';
|
||||
import { Link } from 'react-router';
|
||||
import { LoginButton, LogoutButton } from './Auth';
|
||||
const spawnButton = (props) => {
|
||||
if(props.loggedIn){
|
||||
if(props.loggedInTotp)
|
||||
return <> <LogoutButton logout={props.logout}/> </>
|
||||
if(props.type == "administrator")
|
||||
return <> <LogoutButton logout={props.logout}/> <LoginButton text="OTP"/> </>
|
||||
return <> <LogoutButton logout={props.logout}/> </>
|
||||
}
|
||||
return <LoginButton text="Login"/>
|
||||
}
|
||||
const Navigation = (props) => {
|
||||
return (
|
||||
<Navbar bg="primary" expand="md" variant="dark" className="rounded navbar-padding">
|
||||
<Navbar.Brand as={Link} to="/" >
|
||||
<i className="bi bi-flask mx-2" />
|
||||
Forum Exam
|
||||
</Navbar.Brand>
|
||||
<Nav className="ms-auto">
|
||||
<Navbar.Text className="mx-2 fs-5">
|
||||
{props.user == undefined ? 'Anonymous' : (props.user.username && `Logged in ${props.loggedInTotp ? '(2FA)' : ''} as: ${props.user.username}`)}
|
||||
</Navbar.Text>
|
||||
<Form className="mx-2 mt-1">
|
||||
{spawnButton(props)}
|
||||
</Form>
|
||||
</Nav>
|
||||
</Navbar>
|
||||
);
|
||||
}
|
||||
|
||||
export { Navigation };
|
||||
63
client/src/components/PostCreate.jsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { useState } from 'react';
|
||||
import { Form, Button, Alert } from 'react-bootstrap';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
const PostForm = (props) => {
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [text, setText] = useState('');
|
||||
const [checked, setChecked] = useState(false)
|
||||
/* Default is no limit on comments */
|
||||
const [max, setMax] = useState(0);
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
const handleSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const post = { "title": title.trim(), "text": text.trim(), max: checked == true ? max : -1};
|
||||
if (post.title.length == 0) {
|
||||
setErrorMsg('Title length cannot be 0');
|
||||
} else if (post.text.length == 0){
|
||||
setErrorMsg('Text of post length cannot be 0');
|
||||
} else if (post.max < -1) {
|
||||
setErrorMsg('Invalid value for max number of comments');
|
||||
} else {
|
||||
props.addPost(post);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{errorMsg? <Alert variant='danger' onClose={()=>setErrorMsg('')} dismissible>{errorMsg}</Alert> : false }
|
||||
<h1>Create a new post</h1>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Title</Form.Label>
|
||||
<Form.Control type="text" required={true} value={title} onChange={event => setTitle(event.target.value)}/>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Text</Form.Label>
|
||||
<Form.Control as="textarea" required={true} value={text} onChange={event => setText(event.target.value)}/>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group className="mb-3"> <Form.Check type="checkbox"
|
||||
label="Set Max Comments"
|
||||
onChange={(ev) => setChecked(ev.target.checked)}
|
||||
/>
|
||||
<Form.Label>Max Comments</Form.Label>
|
||||
<Form.Control type="number" min={0} step={1} value={max} disabled={!checked} onChange={event => setMax(parseInt(event.target.value))}/>
|
||||
</Form.Group>
|
||||
|
||||
<Button className="mb-3" variant="primary" type="submit">Save</Button>
|
||||
|
||||
<Link to={"/"} >
|
||||
<Button className="mb-3" variant="danger">Cancel</Button>
|
||||
</Link>
|
||||
</Form>
|
||||
</>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
export { PostForm };
|
||||
0
client/src/index.css
Normal file
13
client/src/main.jsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import { BrowserRouter } from 'react-router';
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
7
client/vite.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
BIN
img/adminloggedin.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
img/anonymoushome.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
img/createcomment.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
img/createpost.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
img/editcomment.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
img/interestingcomment.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
img/loginpage.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
img/screenshot.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
img/totpadminloggedin.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
img/totploginpage.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
img/userloggedin.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
8
server/.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
|
||||
391
server/dao-forum.js
Normal file
@ -0,0 +1,391 @@
|
||||
'use strict';
|
||||
|
||||
/* Data Access Object (DAO) module for accessing forum data */
|
||||
|
||||
const db = require('./db');
|
||||
const dayjs = require("dayjs");
|
||||
const crypto = require("crypto");
|
||||
|
||||
exports.getUserById = (id) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sql = 'SELECT * FROM User WHERE id=?';
|
||||
db.get(sql, [id], (err, row) => {
|
||||
if (err)
|
||||
reject(err);
|
||||
else if (row === undefined)
|
||||
resolve({ error: 'User not found.' });
|
||||
else {
|
||||
const user = { id: row.ID, username: row.Username, type: row.Type}
|
||||
resolve(user);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
exports.getUserSecret = (id) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sql = 'SELECT Secret FROM User WHERE id=?';
|
||||
db.get(sql, [id], (err, row) => {
|
||||
if (err)
|
||||
reject(err);
|
||||
else if (row === undefined)
|
||||
resolve({ error: 'Secret not found.' });
|
||||
else {
|
||||
const secret = row.Secret;
|
||||
resolve(secret);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
exports.getUser = (username, password) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sql = 'SELECT * FROM User WHERE username=?';
|
||||
db.get(sql, [username], (err, row) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else if (row === undefined) {
|
||||
resolve(false);
|
||||
}
|
||||
else {
|
||||
const user = { id: row.ID, username: row.Username, type: row.Type};
|
||||
|
||||
crypto.scrypt(password, row.Salt, 64, function (err, hashedPassword) {
|
||||
if (err) reject(err);
|
||||
if (!crypto.timingSafeEqual(Buffer.from(row.Password, 'hex'), hashedPassword))
|
||||
resolve(false);
|
||||
else
|
||||
resolve(user);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const convertPostFromDbRecord = (dbRecord) => {
|
||||
const post = {};
|
||||
post.id = dbRecord.ID;
|
||||
post.title = dbRecord.Title;
|
||||
post.authorid = dbRecord.AuthorID;
|
||||
post.maxcomments = dbRecord.MaxComments;
|
||||
post.publication = dbRecord.Publication;
|
||||
post.text = dbRecord.Text;
|
||||
|
||||
return post;
|
||||
}
|
||||
|
||||
const convertCommentFromDbRecord = (dbRecord) => {
|
||||
const comment = {};
|
||||
comment.id = dbRecord.ID;
|
||||
comment.text = dbRecord.Text;
|
||||
comment.publication = dbRecord.Publication;
|
||||
comment.authorid = dbRecord.AuthorID;
|
||||
comment.postid = dbRecord.PostID;
|
||||
return comment;
|
||||
}
|
||||
|
||||
// This function retrieves the whole list of posts from the database.
|
||||
|
||||
exports.listPosts = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sql = "SELECT * FROM Post";
|
||||
db.all(sql, (err,rows) => {
|
||||
if (err) { reject(err); }
|
||||
|
||||
const posts = rows.map((e) => {
|
||||
const post = convertPostFromDbRecord(e);
|
||||
return post;
|
||||
});
|
||||
resolve(posts);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
exports.listCommentsOfPost = (PostID) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sql = "SELECT * FROM Comment AS C WHERE C.PostID = ?";
|
||||
db.all(sql, [PostID], (err,rows) => {
|
||||
if (err) { reject(err); }
|
||||
|
||||
const comments = rows.map((e) => {
|
||||
const comment = convertCommentFromDbRecord(e);
|
||||
return comment;
|
||||
});
|
||||
resolve(comments);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
exports.listAnonCommentsOfPost = (PostID) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sql = "SELECT * FROM Comment AS C WHERE C.PostID = ?";
|
||||
db.all(sql, [PostID], (err,rows) => {
|
||||
if (err) { reject(err); }
|
||||
|
||||
const comments = rows.filter( (e) => e.AuthorID == null)
|
||||
.map((e) => {
|
||||
const comment = convertCommentFromDbRecord(e);
|
||||
return comment;
|
||||
});
|
||||
comments.forEach( c => console.log(c.text));
|
||||
resolve(comments);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
exports.getNumCommentsOfPost = (PostID) => {
|
||||
return new Promise((resolve, reject)=>{
|
||||
const sql = 'SELECT COUNT(*) AS N FROM Comment AS C WHERE C.PostID=?';
|
||||
db.get(sql,[PostID], (err,row)=>{
|
||||
if(err){
|
||||
reject(err);
|
||||
}
|
||||
if( row == undefined){
|
||||
resolve({error: 'Post not found.'});
|
||||
} else {
|
||||
const num = row.N;
|
||||
resolve(num);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
exports.getPost = (id) => {
|
||||
return new Promise((resolve, reject)=>{
|
||||
const sql = 'SELECT * FROM Post WHERE id=?';
|
||||
db.get(sql,[id], (err,row)=>{
|
||||
if(err){
|
||||
reject(err);
|
||||
}
|
||||
if( row == undefined){
|
||||
resolve({error: 'Post not found.'});
|
||||
} else {
|
||||
const post = convertPostFromDbRecord(row);
|
||||
resolve(post);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
exports.getComment = (id) => {
|
||||
return new Promise((resolve, reject)=>{
|
||||
const sql = 'SELECT * FROM Comment WHERE ID=?';
|
||||
db.get(sql,[id], (err,row)=>{
|
||||
if(err){
|
||||
reject(err);
|
||||
}
|
||||
if( row == undefined){
|
||||
resolve({error: 'Comment not found.'});
|
||||
} else {
|
||||
const comment = convertCommentFromDbRecord(row);
|
||||
resolve(comment);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
exports.getAuthorOfPost = (PostID) => {
|
||||
return new Promise((resolve, reject)=>{
|
||||
const sql = 'SELECT Username FROM User as U, Post as P WHERE P.ID = ? AND P.AuthorID = U.ID';
|
||||
db.get(sql,[PostID], (err,row)=>{
|
||||
if(err){
|
||||
reject(err);
|
||||
}
|
||||
if( row == undefined){
|
||||
resolve({error: 'Author not found.'});
|
||||
} else {
|
||||
const author = row.Username;
|
||||
resolve(author);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
exports.getAuthorOfComment = (CommentID) => {
|
||||
return new Promise((resolve, reject)=>{
|
||||
const sql = 'SELECT Username FROM User as U, Comment as C WHERE C.ID = ? AND C.AuthorID = U.ID';
|
||||
db.get(sql,[CommentID], (err,row)=>{
|
||||
if(err){
|
||||
reject(err);
|
||||
}
|
||||
if( row == undefined){
|
||||
resolve({error: 'Author not found.'});
|
||||
} else {
|
||||
const author = row.Username;
|
||||
resolve(author);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
exports.getAuthorIDOfPost = (PostID) => {
|
||||
return new Promise((resolve, reject)=>{
|
||||
const sql = 'SELECT AuthorID FROM Post as P WHERE P.ID = ?';
|
||||
db.get(sql,[PostID], (err,row)=>{
|
||||
if(err){
|
||||
reject(err);
|
||||
}
|
||||
if( row == undefined){
|
||||
resolve({error: 'Author not found.'});
|
||||
} else {
|
||||
const id = row.AuthorID;
|
||||
resolve(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
exports.getAuthorIDOfComment = (CommentID) => {
|
||||
return new Promise((resolve, reject)=>{
|
||||
const sql = 'SELECT AuthorID FROM Comment as C WHERE C.ID = ?';
|
||||
db.get(sql,[CommentID], (err,row)=>{
|
||||
if(err){
|
||||
reject(err);
|
||||
}
|
||||
if( row == undefined){
|
||||
resolve({error: 'Author not found.'});
|
||||
} else {
|
||||
const id = row.AuthorID;
|
||||
resolve(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
exports.createPost = (post) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log("POST DAO:"+post.text);
|
||||
const sql = 'INSERT INTO Post (Title, AuthorID, MaxComments, Publication, Text) VALUES(?, ?, ?, ?, ?)';
|
||||
db.run(sql, [post.title, post.authorid, post.maxcomments, post.publication, post.text], function(err){
|
||||
if (err) {
|
||||
reject(err);
|
||||
}
|
||||
resolve(exports.getPost(this.lastID));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
exports.createComment = (comment) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sql = 'INSERT INTO Comment (Text, Publication, AuthorID, PostID) VALUES(?, ?, ?, ?)';
|
||||
db.run(sql, [comment.text, comment.publication, comment.authorid, comment.postid], function(err){
|
||||
if (err) {
|
||||
reject(err);
|
||||
}
|
||||
resolve(exports.getComment(this.lastID));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
exports.setInteresting = (UserID, CommentID) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sql = 'INSERT INTO Interesting(UserID, CommentID) VALUES(?, ?)';
|
||||
db.run(sql, [UserID, CommentID], function(err){
|
||||
if (err){
|
||||
reject(err);
|
||||
}
|
||||
resolve(exports.isUserInterested(UserID, CommentID));
|
||||
});
|
||||
});
|
||||
}
|
||||
exports.getNumInterested = (CommentID) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sql = 'SELECT COUNT(UserID) AS N FROM Interesting AS I WHERE I.CommentID = ?'
|
||||
db.get(sql, [CommentID], (err,row) => {
|
||||
if(err){
|
||||
reject(err);
|
||||
}
|
||||
if( row == undefined){
|
||||
resolve({error: 'Comment not found.'});
|
||||
} else {
|
||||
const num = row.N;
|
||||
resolve(num);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
// Return true if user is interested to comment, otherwise return false
|
||||
exports.isUserInterested = (UserID, CommentID) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sql = 'SELECT * FROM Interesting AS I WHERE I.CommentID = ? AND I.UserID=?'
|
||||
db.get(sql, [CommentID, UserID], (err,row) => {
|
||||
if(err){
|
||||
reject(err);
|
||||
}
|
||||
if( row == undefined){
|
||||
resolve(false);
|
||||
} else {
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
exports.deletePost = (PostID) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.serialize(() =>{
|
||||
db.run('DELETE FROM Interesting as I, Comment as C WHERE I.CommentID = C.ID AND C.PostID = ? ', [PostID], (err) =>{
|
||||
if(err){
|
||||
reject(err);
|
||||
}
|
||||
})
|
||||
.run('DELETE FROM Comment as C WHERE C.PostID = ?', [PostID], (err)=>{
|
||||
if(err){
|
||||
reject(err);
|
||||
}
|
||||
})
|
||||
.run('DELETE FROM Post as P WHERE P.ID = ?',[PostID], (err)=>{
|
||||
if(err){
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
resolve(exports.listPosts());
|
||||
});
|
||||
}
|
||||
|
||||
exports.deleteComment = (CommentID) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.serialize(() =>{
|
||||
db.run('DELETE FROM Interesting as I WHERE I.CommentID = ?', [CommentID], (err) =>{
|
||||
if(err){
|
||||
reject(err);
|
||||
}
|
||||
})
|
||||
.run('DELETE FROM Comment as C WHERE C.ID = ?', [CommentID], (err)=>{
|
||||
if(err){
|
||||
reject(err);
|
||||
}
|
||||
})
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
exports.deleteInteresting = (UserID,CommentID) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.serialize(() =>{
|
||||
db.run('DELETE FROM Interesting as I WHERE I.CommentID = ? AND I.UserID= ?', [CommentID, UserID], (err) =>{
|
||||
if(err){
|
||||
reject(err);
|
||||
}
|
||||
})
|
||||
});
|
||||
resolve(exports.isUserInterested(UserID, CommentID));
|
||||
});
|
||||
}
|
||||
|
||||
exports.editComment = (commentID, text) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sql = 'UPDATE Comment SET Text=? WHERE ID = ?';
|
||||
db.run(sql, [text, commentID], function (err) {
|
||||
if (err) {
|
||||
reject(err);
|
||||
}
|
||||
if (this.changes !== 1) {
|
||||
resolve({ error: 'Comment not found.' });
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
11
server/db.js
Normal file
@ -0,0 +1,11 @@
|
||||
'use strict';
|
||||
|
||||
const sqlite = require('sqlite3');
|
||||
|
||||
// open the database
|
||||
|
||||
const db = new sqlite.Database('test.db', (err) => {
|
||||
if (err) throw(err);
|
||||
});
|
||||
|
||||
module.exports = db;
|
||||
353
server/index.js
Normal file
@ -0,0 +1,353 @@
|
||||
'use strict';
|
||||
const express = require('express');
|
||||
const morgan = require('morgan'); // logging middleware
|
||||
const { check, validationResult, oneOf } = require('express-validator'); // validation middleware
|
||||
const cors = require('cors');
|
||||
|
||||
const forumDao = require('./dao-forum');
|
||||
const dayjs = require('dayjs');
|
||||
const app = express();
|
||||
app.use(morgan('dev'));
|
||||
app.use(express.json());
|
||||
|
||||
const corsOptions = {
|
||||
origin: 'http://localhost:5173',
|
||||
credentials: true,
|
||||
};
|
||||
app.use(cors(corsOptions));
|
||||
|
||||
/* Passport */
|
||||
|
||||
const passport = require('passport'); // authentication middleware
|
||||
const LocalStrategy = require('passport-local'); // authentication strategy (username and password)
|
||||
|
||||
const base32 = require('thirty-two');
|
||||
const TotpStrategy = require('passport-totp').Strategy; // totp
|
||||
|
||||
passport.use(new LocalStrategy(async function verify(username, password, callback) {
|
||||
const user = await forumDao.getUser(username, password)
|
||||
if(!user)
|
||||
return callback(null, false, 'Incorrect username or password');
|
||||
|
||||
return callback(null, user); // NOTE: user info in the session (all fields returned by userDao.getUser, i.e, id, username, name)
|
||||
}));
|
||||
|
||||
passport.serializeUser(function (user, callback) {
|
||||
callback(null, user);
|
||||
});
|
||||
|
||||
passport.deserializeUser(function (user, callback) {
|
||||
|
||||
return callback(null, user);
|
||||
});
|
||||
|
||||
|
||||
const session = require('express-session');
|
||||
/* The secret field is used to hash the session with HMAC */
|
||||
app.use(session({
|
||||
secret: "very l0ng s3cr3t used h4sh with HM4C the sessi0n",
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
}));
|
||||
|
||||
app.use(passport.authenticate('session'));
|
||||
/* If user does not exist the db will simply throw an error and everything will be fine */
|
||||
passport.use(new TotpStrategy(
|
||||
async function (user, done) {
|
||||
const secret = await forumDao.getUserSecret(user.id);
|
||||
if(!secret)
|
||||
return done(null, false, {message: "TOTP is not enabled for this account"});
|
||||
return done(null, base32.decode(secret), 30);
|
||||
})
|
||||
)
|
||||
|
||||
const isLoggedIn = (req, res, next) => {
|
||||
if(req.isAuthenticated()) {
|
||||
return next();
|
||||
}
|
||||
return res.status(401).json({error: 'Not authorized'});
|
||||
}
|
||||
|
||||
/* This one is used because we still need to serve even anonymous user in some queries */
|
||||
/* If Authenticated is True otherwise is False and therefore anonymous */
|
||||
const isLoggedInOrAnon = (req, res) => {
|
||||
if(req.isAuthenticated()) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isTotp(req, res, next) {
|
||||
if(req.session.method === 'totp')
|
||||
return next();
|
||||
return res.status(401).json({ error: 'Missing TOTP authentication'});
|
||||
}
|
||||
// Created to check this not as a middleware
|
||||
function isLoggedInTotp(req, res){
|
||||
if(req.session.method === 'totp')
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const maxTitleLength = 160;
|
||||
const maxCommentLength = 1000;
|
||||
const maxTextLength = 1500;
|
||||
|
||||
const errorFormatter = ({ location, msg, param, value, nestedErrors }) => {
|
||||
return `${location}[${param}]: ${msg}`;
|
||||
};
|
||||
|
||||
app.get('/api/posts', async (req, res) => {
|
||||
const isLogged = isLoggedInOrAnon(req);
|
||||
try{
|
||||
|
||||
let posts = await forumDao.listPosts();
|
||||
posts = posts.map( async (p) => {
|
||||
let comments;
|
||||
/* If authorid is null the Author of the comment is not searched at all
|
||||
The field author exists only if there is a valid author id */
|
||||
if( isLogged == true ){
|
||||
comments = await forumDao.listCommentsOfPost(p.id);
|
||||
comments = comments.map( async (c) => {
|
||||
/* numInterested and isInterested should be visible only to authenticated users. */
|
||||
/* isInterested should be true ONLY for the exact user that has marked the comment interesting */
|
||||
c.numInterested = await forumDao.getNumInterested(c.id);
|
||||
c.isInterested = await forumDao.isUserInterested(req.user.id, c.id);
|
||||
if(c.authorid != null){
|
||||
c.author = await forumDao.getAuthorOfComment(c.id);
|
||||
}
|
||||
return c;
|
||||
});
|
||||
}
|
||||
else{
|
||||
comments = await forumDao.listAnonCommentsOfPost(p.id);
|
||||
}
|
||||
|
||||
p.comments = await Promise.all(comments);
|
||||
p.author = await forumDao.getAuthorOfPost(p.id);
|
||||
p.numComments = await forumDao.getNumCommentsOfPost(p.id);
|
||||
|
||||
return p;
|
||||
});
|
||||
|
||||
const postsWithComments = await Promise.all(posts);
|
||||
res.json(postsWithComments);
|
||||
}
|
||||
catch(err){
|
||||
console.log(err);
|
||||
res.status(503).json({error: 'Database error'});
|
||||
}
|
||||
});
|
||||
|
||||
/* Create a new post (Only authenticated user) */
|
||||
/* INPUT: Title, Text, Max number of comments */
|
||||
app.post('/api/posts', isLoggedIn, [check('title').isLength({min:1, max: maxTitleLength}).withMessage(`The text of the post MUST be >=1 and <= ${maxTitleLength}`),
|
||||
check('text').isLength({min:1, max: maxTextLength}).withMessage(`The title of the post MUST be >=1 and <= ${maxTextLength}`),
|
||||
check('max').isInt({min:-1}).withMessage("The max number of comments should be specificed and >= -1")],async (req, res) => {
|
||||
|
||||
const errors = validationResult(req).formatWith(errorFormatter); // format error message
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(422).json( errors.errors ); // error message is sent back as a json with the error info
|
||||
}
|
||||
|
||||
const post = {title: req.body.title, text: req.body.text, publication: dayjs().format("YYYY-MM-DD HH:mm:ss"), maxcomments: req.body.max, authorid: req.user.id};
|
||||
try{
|
||||
const result = await forumDao.createPost(post);
|
||||
res.status(201).json(result);
|
||||
}catch(err){
|
||||
console.log(err);
|
||||
res.status(503).json({error: 'Database error'});
|
||||
}
|
||||
});
|
||||
|
||||
/* Create a new comment related to a post (Anyone) */
|
||||
/* INPUT: Post ID, Text*/
|
||||
app.post('/api/posts/:id/comments', [ check('id').isInt({min: 1}).withMessage("Post ID MUST be an integer >= 1"),
|
||||
check('text').isLength({min: 1, max: maxCommentLength}).withMessage(`The text of the comment MUST be >=1 and <= ${maxCommentLength}`) ],async (req,res) => {
|
||||
const errors = validationResult(req).formatWith(errorFormatter); // format error message
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(422).json( errors.errors ); // error message is sent back as a json with the error info
|
||||
}
|
||||
const comment = {text: req.body.text, publication: dayjs().format("YYYY-MM-DD HH:mm:ss") ,authorid: req.user == null ? null : req.user.id , postid: req.params.id};
|
||||
try{
|
||||
const checkPost = await forumDao.getPost(req.params.id);
|
||||
const numcomments = await forumDao.getNumCommentsOfPost(req.params.id);
|
||||
if(checkPost.maxcomments != -1 && numcomments == checkPost.maxcomments){
|
||||
return res.status(400).json({error: "Reached max number of comments"});
|
||||
}
|
||||
const result = await forumDao.createComment(comment);
|
||||
res.status(201).json(result);
|
||||
|
||||
}catch(err){
|
||||
console.log(err);
|
||||
res.status(503).json({error: 'Database error'});
|
||||
}
|
||||
});
|
||||
|
||||
/* Toggle true/false Interesting flag on comment (Only authenticated and owner) */
|
||||
//Handled as POST/DELETE requests because we are creating a new record in a table.
|
||||
app.post('/api/posts/:id/comments/:commentId/interest', isLoggedIn, [check('id').isInt({min:1}).withMessage("Post ID MUST be an integer >= 1"),
|
||||
check('commentId').isInt({min:1}).withMessage("Comment ID MUST be an integer >= 1")], async (req,res) => {
|
||||
|
||||
const errors = validationResult(req).formatWith(errorFormatter); // format error message
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(422).json( errors.errors ); // error message is sent back as a json with the error info
|
||||
}
|
||||
try{
|
||||
const result = await forumDao.setInteresting(req.user.id,req.params.commentId);
|
||||
res.status(201).json({});
|
||||
}catch(err){
|
||||
console.log(err);
|
||||
res.status(503).json({error: 'Database error'});
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
app.delete('/api/posts/:id/comments/:commentId/interest', [check('id').isInt({min:1}).withMessage("Post ID MUST be an integer >= 1"),
|
||||
check('commentId').isInt({min:1}).withMessage("Comment ID MUST be an integer >= 1")], async (req,res) => {
|
||||
|
||||
const errors = validationResult(req).formatWith(errorFormatter); // format error message
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(422).json( errors.errors ); // error message is sent back as a json with the error info
|
||||
}
|
||||
try{
|
||||
const result = await forumDao.deleteInteresting(req.user.id,req.params.commentId);
|
||||
res.status(201).json(result);
|
||||
}catch(err){
|
||||
console.log(err);
|
||||
res.status(503).json({error: 'Database error'});
|
||||
}
|
||||
});
|
||||
|
||||
/* Edit comment (Only authenticated and owner) */
|
||||
/* INPUT: Text */
|
||||
app.patch('/api/posts/:id/comments/:commentId', isLoggedIn,[check('id').isInt({min:1}).withMessage("Post ID MUST be an integer >= 1"),
|
||||
check('commentId').isInt({min:1}).withMessage("Comment ID MUST be an integer >= 1"),
|
||||
check('text').isLength({min: 1, max: maxCommentLength}).withMessage(`The text of the comment MUST be >=1 and <= ${maxCommentLength}`)], async (req, res) => {
|
||||
const errors = validationResult(req).formatWith(errorFormatter); // format error message
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(422).json( errors.errors ); // error message is sent back as a json with the error info
|
||||
}
|
||||
|
||||
try{
|
||||
// Check if user.id is the author id of comment
|
||||
const authorid = await forumDao.getAuthorIDOfComment(req.params.commentId);
|
||||
if( !isLoggedInTotp(req, res) && req.user.id != authorid )
|
||||
return res.status(401).json({error: 'Not authorized'});
|
||||
const result = await forumDao.editComment(req.params.commentId, req.body.text);
|
||||
/* Must be 200 and not 204, because 204 implies empty body and the client want a json eachtime even empty*/
|
||||
res.status(200).json({});
|
||||
}catch(err){
|
||||
console.log(err);
|
||||
res.status(503).json({error: 'Database error'});
|
||||
}
|
||||
});
|
||||
|
||||
/* Delete selected post (Only authenticated totp and owner)*/
|
||||
/* INPUT: Post ID */
|
||||
app.delete('/api/posts/:id', isLoggedIn, [check('id').isInt({min:1}).withMessage("Post ID MUST be an integer >= 1")], async (req, res) => {
|
||||
const errors = validationResult(req).formatWith(errorFormatter); // format error message
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(422).json( errors.errors ); // error message is sent back as a json with the error info
|
||||
}
|
||||
try{
|
||||
const authorid = await forumDao.getAuthorIDOfPost(req.params.id);
|
||||
if( !isLoggedInTotp(req,res) && req.user.id != authorid )
|
||||
return res.status(401).json({error: 'Not authorized'});
|
||||
|
||||
const checkPost = await forumDao.getPost(req.params.id);
|
||||
if( checkPost.error != undefined ){
|
||||
return res.status(404).json({error: checkPost.error})
|
||||
}
|
||||
const result = await forumDao.deletePost(req.params.id);
|
||||
res.status(200).json({});
|
||||
}catch(err){
|
||||
console.log(err);
|
||||
res.status(503).json({error: 'Database error'});
|
||||
}
|
||||
});
|
||||
|
||||
/* Delete selected comment (Only authenticated and owner)*/
|
||||
|
||||
app.delete('/api/posts/:id/comments/:commentId',isLoggedIn, [check('id').isInt({min:1}).withMessage("Post ID MUST be an integer >= 1"),
|
||||
check('commentId').isInt({min:1}).withMessage("Comment ID MUST be an integer >= 1")], async (req, res) => {
|
||||
const errors = validationResult(req).formatWith(errorFormatter); // format error message
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(422).json( errors.errors ); // error message is sent back as a json with the error info
|
||||
}
|
||||
try{
|
||||
const authorid = await forumDao.getAuthorIDOfComment(req.params.commentId);
|
||||
if( !isLoggedInTotp(req, res) && req.user.id != authorid )
|
||||
return res.status(401).json({error: 'Not authorized'});
|
||||
|
||||
const checkComment = await forumDao.getComment(req.params.commentId);
|
||||
if( checkComment.error != undefined ){
|
||||
return res.status(404).json({error: checkComment.error})
|
||||
}
|
||||
const result = await forumDao.deleteComment(req.params.commentId);
|
||||
res.status(200).json({});
|
||||
|
||||
}catch(err){
|
||||
console.log(err);
|
||||
res.status(503).json({error: 'Database error'});
|
||||
}
|
||||
});
|
||||
|
||||
function clientUserInfo(req) {
|
||||
const user=req.user;
|
||||
return {id: user.id, username: user.username, canDoTotp: user.secret? true: false, isTotp: req.session.method === 'totp'};
|
||||
}
|
||||
|
||||
/* POST /api/sessions
|
||||
Used to login */
|
||||
app.post('/api/sessions', function(req, res, next) {
|
||||
passport.authenticate('local', (err, user, info) => {
|
||||
if (err)
|
||||
return next(err);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: info});
|
||||
}
|
||||
req.login(user, (err) => {
|
||||
if (err)
|
||||
return next(err);
|
||||
|
||||
return res.json(req.user);
|
||||
});
|
||||
})(req, res, next);
|
||||
});
|
||||
|
||||
/* Perform OTP (only after login) */
|
||||
app.post('/api/login-totp', isLoggedIn,
|
||||
passport.authenticate('totp'),
|
||||
function(req, res) {
|
||||
req.session.method = 'totp';
|
||||
res.json({otp: 'authorized'});
|
||||
}
|
||||
);
|
||||
|
||||
/* GET /api/sessions/current
|
||||
This route checks whether the user is logged in or not. */
|
||||
app.get('/api/sessions/current', (req, res) => {
|
||||
if(req.isAuthenticated()) {
|
||||
res.status(200).json(req.user);}
|
||||
else
|
||||
res.status(401).json({error: 'Not authenticated'});
|
||||
});
|
||||
|
||||
/* DELETE /api/session/current
|
||||
This route is used for loggin out the current user. */
|
||||
app.delete('/api/sessions/current', isLoggedIn, (req, res) => {
|
||||
req.logout(() => {
|
||||
res.status(200).json({});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
const PORT = 3001;
|
||||
// Activate the server
|
||||
app.listen(PORT, (err) => {
|
||||
if (err)
|
||||
console.log(err);
|
||||
else
|
||||
console.log(`Server listening at http://localhost:${PORT}`);
|
||||
});
|
||||
2330
server/package-lock.json
generated
Normal file
24
server/package.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"dayjs": "^1.11.13",
|
||||
"express": "^5.1.0",
|
||||
"express-session": "^1.18.1",
|
||||
"express-validator": "^7.2.1",
|
||||
"morgan": "^1.10.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-local": "^1.0.0",
|
||||
"passport-totp": "^0.0.2",
|
||||
"sqlite3": "^5.1.7",
|
||||
"thirty-two": "^1.0.2"
|
||||
}
|
||||
}
|
||||
118
server/scripts/initdb.js
Normal file
@ -0,0 +1,118 @@
|
||||
'use strict';
|
||||
const db = require('../db')
|
||||
const crypto = require('crypto')
|
||||
const dayjs = require('dayjs')
|
||||
const insertHashedPwd = (UserID, password) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hashLen = 64;
|
||||
const salt = crypto.randomBytes(16).toString("hex");
|
||||
const sql = "UPDATE User SET password=?, salt=? WHERE User.ID=?"
|
||||
crypto.scrypt(password,salt, hashLen, function(err,hash){
|
||||
if(err) reject(err);
|
||||
db.run(sql,[hash.toString("hex"),salt,UserID], function(err){
|
||||
if(err)
|
||||
reject(err);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
const createDB = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.serialize(() => {
|
||||
db.run(`CREATE TABLE "User" (
|
||||
"ID" INTEGER NOT NULL UNIQUE,
|
||||
"Username" TEXT NOT NULL,
|
||||
"Password" TEXT NOT NULL,
|
||||
"Salt" TEXT NOT NULL,
|
||||
"Secret" TEXT NULL,
|
||||
"Type" TEXT NOT NULL,
|
||||
PRIMARY KEY("ID" AUTOINCREMENT));`)
|
||||
/* At least five users, 2 are Administrators*/
|
||||
.run(`INSERT INTO User(Username, Password, Salt, Secret, Type) VALUES('AdminUno', 'placeholder', 'placeholder', 'LXBSMDTMSP2I5XFXIYRGFVWSFI', 'administrator')`)
|
||||
.run(`INSERT INTO User(Username, Password, Salt, Secret, Type) VALUES('AdminDue', 'ph', 'ph', 'LXBSMDTMSP2I5XFXIYRGFVWSFI', 'administrator')`)
|
||||
.run(`INSERT INTO User(Username, Password, Salt, Type) VALUES('UtenteUno', 'ph','ph', 'viewer')`)
|
||||
.run(`INSERT INTO User(Username, Password, Salt, Type) VALUES('UtenteDue', 'ph','ph', 'viewer')`)
|
||||
.run(`INSERT INTO User(Username, Password, Salt, Type) VALUES('UtenteTre', 'ph','ph', 'viewer')`)
|
||||
.run(`CREATE TABLE "Post" (
|
||||
"Title" TEXT NOT NULL UNIQUE,
|
||||
"ID" INTEGER NOT NULL UNIQUE,
|
||||
"AuthorID" INTEGER NOT NULL,
|
||||
"MaxComments" INTEGER,
|
||||
"Publication" INTEGER NOT NULL,
|
||||
"Text" TEXT NOT NULL,
|
||||
PRIMARY KEY("ID" AUTOINCREMENT),
|
||||
FOREIGN KEY("AuthorID") REFERENCES "User"("ID"));`)
|
||||
/* Four users including two administrators should have published two posts each */
|
||||
/*Post of AdminUno*/
|
||||
.run(`INSERT INTO Post (Title, AuthorID, Publication, Text, MaxComments) VALUES('Example post 1',1,'2025-06-06 12:20:05','Lorem ipsum dolor sit amet consectetur adipiscing elit. ', -1)`)
|
||||
.run(`INSERT INTO Post (Title, AuthorID, Publication, Text, MaxComments) VALUES('Example post 2',1,'2025-06-12 10:59:01','Quisque faucibus ex sapien vitae pellentesque sem placerat.', -1)`)
|
||||
/*Post of AdminDue*/
|
||||
.run(`INSERT INTO Post (Title, AuthorID, Publication, Text, MaxComments) VALUES('Example post 3',2,'2025-06-18 08:23:12','In id cursus mi pretium tellus duis convallis.', -1)`)
|
||||
.run(`INSERT INTO Post (Title, AuthorID, MaxComments, Publication, Text) VALUES('Example post 4',2,2,'2025-06-01 23:20:11','Tempus leo eu aenean sed diam urna tempor.')`)
|
||||
/*Post of UtenteUno */
|
||||
.run(`INSERT INTO Post (Title, AuthorID, Publication, Text, MaxComments) VALUES('Example post 5',3,'2025-06-09 07:20:05','Pulvinar vivamus fringilla lacus nec metus bibendum egestas.', -1)`)
|
||||
.run(`INSERT INTO Post (Title, AuthorID, Publication, Text, MaxComments) VALUES('Example post 6',3,'2025-06-20 07:25:59','Iaculis massa nisl malesuada lacinia integer nunc posuere.', -1)`)
|
||||
/*Post of UtenteDue */
|
||||
.run(`INSERT INTO Post (Title, AuthorID, MaxComments, Publication, Text) VALUES('Example post 7',4, 3, '2025-06-03 13:20:05','Ut hendrerit semper vel class aptent taciti sociosqu.')`)
|
||||
.run(`INSERT INTO Post (Title, AuthorID, Publication, Text, MaxComments) VALUES('Example post 8',4,'2025-06-15 16:20:00','Ad litora torquent per conubia nostra inceptos himenaeos.', -1)`)
|
||||
.run(`CREATE TABLE "Comment" (
|
||||
"ID" INTEGER NOT NULL UNIQUE,
|
||||
"Text" TEXT NOT NULL,
|
||||
"Publication" TEXT NOT NULL,
|
||||
"AuthorID" INTEGER,
|
||||
"PostID" INTEGER NOT NULL,
|
||||
PRIMARY KEY("ID" AUTOINCREMENT),
|
||||
FOREIGN KEY("AuthorID") REFERENCES "User"("ID"),
|
||||
FOREIGN KEY("PostID") REFERENCES "Post"("ID"));`)
|
||||
// Comments for PostID 4 (AdminDue's post with MaxComments = 2)
|
||||
// This post should have 1 comment (one less than the limit)
|
||||
.run(`INSERT INTO Comment(Text, Publication, AuthorID, PostID) VALUES('This post has a comment limit, interesting!', '2025-06-05 14:35:10', 3, 4);`) // UtenteUno comments on AdminDue's post
|
||||
// Comments ensuring each user has comments from other users (2-3 comments each)
|
||||
//Comments from AdminUno (ID: 1) on other users' posts
|
||||
.run(`INSERT INTO Comment(Text, Publication, AuthorID, PostID) VALUES('Good point!', '2025-06-18 09:02:45', 1, 5);`) // AdminUno on UtenteUno's post
|
||||
.run(`INSERT INTO Comment(Text, Publication, AuthorID, PostID) VALUES('Enjoyed reading this.', '2025-06-01 23:59:01', 1, 7);`) // AdminUno on UtenteDue's post
|
||||
// Comments from AdminDue (ID: 2) on other users' posts
|
||||
.run(`INSERT INTO Comment(Text, Publication, AuthorID, PostID) VALUES('Nice work!', '2025-06-11 04:17:30', 2, 6);`) // AdminDue on UtenteUno's post
|
||||
// Comments from UtenteUno (ID: 3) on other users' posts (already one on PostID 4)
|
||||
.run(`INSERT INTO Comment(Text, Publication, AuthorID, PostID) VALUES('Totally agree!', '2025-06-14 10:05:00', 3, 2);`) // UtenteUno on AdminUno's post
|
||||
.run(`INSERT INTO Comment(Text, Publication, AuthorID, PostID) VALUES('Great thoughts.', '2025-06-03 01:50:55', 3, 3);`) // UtenteUno on AdminDue's post
|
||||
// Comments from UtenteDue (ID: 4) on other users' posts
|
||||
.run(`INSERT INTO Comment(Text, Publication, AuthorID, PostID) VALUES('Well said.', '2025-06-19 16:30:20', 4, 1);`) // UtenteDue on AdminUno's post
|
||||
.run(`INSERT INTO Comment(Text, Publication, AuthorID, PostID) VALUES('This is very helpful!', '2025-06-02 21:12:05', 4, 5);`) // UtenteDue on UtenteUno's post
|
||||
// Comments from UtenteTre (ID: 5) on other users' posts
|
||||
.run(`INSERT INTO Comment(Text, Publication, AuthorID, PostID) VALUES('Appreciate your perspective.', '2025-06-16 12:08:50', 5, 3);`) // UtenteTre on AdminDue's post
|
||||
.run(`INSERT INTO Comment(Text, Publication, AuthorID, PostID) VALUES('Inspiring content.', '2025-06-10 06:25:30', 5, 8);`) // UtenteTre on UtenteDue's post
|
||||
// Anonymous comments
|
||||
.run(`INSERT INTO Comment(Text, Publication, PostID) VALUES('Appreciate my anonymous perspective.', '2025-06-04 17:00:00', 1);`)
|
||||
.run(`INSERT INTO Comment(Text, Publication, PostID) VALUES('Dont appreciate my anonymous perspective.', '2025-06-17 22:45:10', 1);`)
|
||||
.run(`INSERT INTO Comment(Text, Publication, PostID) VALUES('Dont appreciate my anonymous perspective.', '2025-06-13 03:00:00', 3);`)
|
||||
.run(`INSERT INTO Comment(Text, Publication, PostID) VALUES('Totally an anonymous comment, you will never know the author!', '2025-06-13 03:00:00', 7);`)
|
||||
// Create Interesting table
|
||||
.run(`CREATE TABLE "Interesting" (
|
||||
"UserID" INTEGER NOT NULL,
|
||||
"CommentID" INTEGER NOT NULL,
|
||||
PRIMARY KEY("UserID", "CommentID"),
|
||||
FOREIGN KEY("CommentID") REFERENCES "Comment"("ID"),
|
||||
FOREIGN KEY("UserID") REFERENCES "User"("ID"));`).run(`INSERT INTO Interesting(UserID,CommentID) VALUES(1,1)`)
|
||||
.run(`INSERT INTO Interesting(UserID,CommentID) VALUES(2,1)`)
|
||||
.run(`INSERT INTO Interesting(UserID,CommentID) VALUES(1,2)`)
|
||||
.run(`INSERT INTO Interesting(UserID,CommentID) VALUES(1,3)`)
|
||||
.run(`INSERT INTO Interesting(UserID,CommentID) VALUES(2,2)`);
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
//Ugly but ensures that the passwords are updated after the creation of db
|
||||
createDB().then(
|
||||
insertHashedPwd(1,"PasswordAdmin1").then(
|
||||
insertHashedPwd(2,"PasswordAdmin2").then(
|
||||
insertHashedPwd(3,"PasswordUtente1").then(
|
||||
insertHashedPwd(4,"PasswordUtente2").then(
|
||||
insertHashedPwd(5,"PasswordUtente3")
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
BIN
server/test.db
Normal file
18
server/test.js
Normal file
@ -0,0 +1,18 @@
|
||||
const forumDao = require('./dao-forum.js');
|
||||
const crypto = require('crypto');
|
||||
|
||||
/*/forumDao.listPosts().then(console.log);
|
||||
|
||||
forumDao.listCommentsOfPost(1).then(console.log);
|
||||
|
||||
forumDao.getNumCommentsOfPost(1).then(console.log);
|
||||
forumDao.getPost(1).then(console.log);
|
||||
forumDao.getAuthorOfPost(1).then(console.log);*/
|
||||
|
||||
//forumDao.createPost({title: "Post Creato",authorid: 1, maxcomments: null, publication: "2023-03-02", text: "Testo post creato"}).then(console.log);
|
||||
//forumDao.createComment({text: "Commento creato 2", publication:"2024-02-01",authorid: 1, postid: 1}).then(console.log);
|
||||
//forumDao.listCommentsOfPost(1).then(console.log);
|
||||
//forumDao.setInterestingComment(1,2).then(console.log);
|
||||
//forumDao.deletePost(2).then(console.log)
|
||||
//forumDao.deleteComment(2).then(console.log)
|
||||
//forumDao.listCommentsOfPost(3).then(console.log);
|
||||