master
root 2022-11-18 14:21:28 +00:00
commit 6be4405ec1
29 changed files with 4072 additions and 0 deletions

1
backend/.dockerignore Executable file
View File

@ -0,0 +1 @@
node_modules

1
backend/.gitignore vendored Executable file
View File

@ -0,0 +1 @@
node_modules/

28
backend/Dockerfile Executable file
View File

@ -0,0 +1,28 @@
FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm install
RUN groupadd appgroup && useradd -g appgroup appuser
RUN apt-get update && apt-get install openssl
RUN mkdir /keys && \
openssl genrsa --out /keys/private.pem 2048 && \
openssl rsa -in /keys/private.pem -outform PEM -pubout -out /keys/public.pem && \
chmod 744 /keys/*
COPY . ./
#RUN npm install -g nodemon
RUN mkdir /uploads && chown appuser:appgroup /uploads
EXPOSE 3000
USER appuser
CMD ["node", "server.js"]
# CMD ["nodemon", "server.js"]

7
backend/config.js Executable file
View File

@ -0,0 +1,7 @@
const fs = require('fs');
const PRIV_KEY = fs.readFileSync('/keys/private.pem');
const PUB_KEY = fs.readFileSync('/keys/public.pem');
module.exports = { PRIV_KEY, PUB_KEY };

12
backend/db.js Executable file
View File

@ -0,0 +1,12 @@
const { Client } = require('pg');
const db = new Client({
user: 'postgres',
host: 'db',
database: 'chatdb',
password: 'password'
});
db.connect();
module.exports = db;

2688
backend/package-lock.json generated Executable file

File diff suppressed because it is too large Load Diff

15
backend/package.json Executable file
View File

@ -0,0 +1,15 @@
{
"dependencies": {
"cookie-parser": "^1.4.6",
"ejs": "^3.1.8",
"express": "^4.18.1",
"express-async-errors": "^3.1.1",
"express-fileupload": "^1.4.0",
"jsonwebtoken": "^8.5.1",
"lil-uuid": "^0.1.1",
"passport": "^0.6.0",
"passport-jwt": "^4.0.0",
"pg": "^8.8.0",
"sharp": "^0.31.1"
}
}

88
backend/routes/auth.js Executable file
View File

@ -0,0 +1,88 @@
const express = require('express');
const jwt = require('jsonwebtoken');
var passport = require('passport');
const JwtStrategy = require('passport-jwt').Strategy;
const db = require('../db');
const { PRIV_KEY, PUB_KEY } = require('../config');
const router = express.Router();
router.get('/login', function (req, res, next) {
res.render('login');
});
router.post('/login', async (req, res, next) => {
const { email, password } = req.body;
const r = await db.query('SELECT * FROM users WHERE email=$1', [email]);
if (r.rowCount < 1 || r.rows[0].password !== password) {
res.locals.errormsg = 'Wrong credentials';
return res.status(403).render('login');
}
const token = jwt.sign({ email }, PRIV_KEY, { algorithm: 'RS256' });
res.cookie('session', token);
res.redirect('/');
});
router.get('/register', function (req, res, next) {
res.render('register');
});
router.post('/register', async (req, res, next) => {
const { email, nickname, password } = req.body;
const reg = /^[\w\.@]{4,40}$/;
if (!reg.test(email) || !reg.test(nickname) || !reg.test(password)) {
res.locals.errormsg = 'Bad data';
return res.render('register');
}
const token = jwt.sign({ email }, PRIV_KEY, { algorithm: 'RS256' });
res.cookie('session', token);
try {
await db.query('INSERT INTO users (email, nickname, password, propic) VALUES ($1,$2,$3,\'/static/img/default.png\')', [email, nickname, password]);
} catch (error) {
res.locals.errormsg = 'Email or nickname already used';
res.clearCookie('session');
return res.render('register');
}
res.redirect('/');
});
router.get('/logout', function (req, res, next) {
res.clearCookie('session');
res.redirect('/');
});
const cookieExtractor = function (req) {
var token = null;
// console.log(req.cookies);
if (req && req.cookies) {
token = req.cookies['session'];
}
return token;
};
const opts = {};
opts.jwtFromRequest = cookieExtractor;
opts.secretOrKey = PUB_KEY;
opts.algorithms = ['RS256', 'ES256', 'HS256'];
//opts.issuer = 'localhost';
//opts.audience = 'localhost';
passport.use(new JwtStrategy(opts, function (jwt_payload, done) {
if (!jwt_payload.email) {
return done('Error', false);
}
return done(null, { email: jwt_payload.email, nickname: jwt_payload.nickname });
}));
router.use(passport.authenticate('jwt', { session: false, failureRedirect: '/login' }));
module.exports = router;

145
backend/routes/channel.js Executable file
View File

@ -0,0 +1,145 @@
const express = require('express');
const passport = require('passport');
const crypto = require('crypto');
const db = require('../db');
const router = express.Router();
async function create_message(channel_id, msg, author) {
let r = await db.query('SELECT * FROM channels WHERE id=$1', [channel_id]);
if (r.rowCount < 1) {
console.error('Channel not found');
return false;
}
const channel = r.rows[0];
if (channel.private) {
// check permissions
r = await db.query('SELECT * FROM allowed_users WHERE channel_id = $1 AND user_email = $2', [channel_id, author]);
if (r.rowCount < 1) {
console.error('You are not allowed to write in this channel');
return false;
}
}
await db.query('INSERT INTO messages (id, channel_id, data, author) VALUES ($1,$2,$3,$4)', [crypto.randomUUID(), channel_id, msg, author]);
return true;
}
async function list_channels(user) {
const r = await db.query('SELECT id, detail, private FROM channels WHERE private=FALSE UNION SELECT id, detail, private FROM channels, allowed_users WHERE id = channel_id AND user_email = $1', [user]);
return r.rows;
}
router.get('/channel', async (req, res, next) => {
const channels = await list_channels(req.user.email);
res.render('channels', { channels, favorites: req.cookies });
});
router.get('/channel/:id', async (req, res, next) => {
const chanid = req.params.id;
let r = await db.query('SELECT * FROM channels WHERE id=$1', [chanid]);
const channel = r.rows[0];
r = await db.query('SELECT * FROM messages WHERE channel_id=$1', [chanid]);
const messages = r.rows;
if (!channel) {
return res.status(404).send('not found');
}
if (channel.private) {
let r = await db.query('SELECT user_email FROM allowed_users WHERE user_email=$1 AND channel_id=$2', [req.user.email, chanid]);
if (r.rowCount < 1) {
res.locals.errormsg = 'You are not allowed to do this';
return res.status(403).render('error');
}
}
res.render('channel', { channel, messages });
});
router.post('/new_channel', async (req, res, next) => {
const { channelid, detail } = req.body;
const user = req.user.email;
if (!/[a-zA-Z0-9]{4,40}/.test(channelid)) {
return res.status(400).json({ error: 'Invalid channel name' });
}
await db.query('INSERT INTO channels (id, detail, private) VALUES ($1,$2,$3)', [channelid, detail, true]);
await db.query('INSERT INTO allowed_users (user_email, channel_id) VALUES ($1,$2)', [user, channelid]);
res.cookie(channelid, 'true');
return res.json({ msg: 'Channel created' });
});
router.post('/new_message', async (req, res, next) => {
const { msg, channelid } = req.body;
const author = req.user.email;
try {
await create_message(channelid, msg, author);
res.json({ msg: 'Message created' });
} catch (error) {
// console.error(error);
res.status(500).send(error.toString());
}
});
router.post('/invite', async (req, res, next) => {
const { channelid, user } = req.body;
const logged_user = req.user.email;
let r = await db.query('SELECT user_email FROM allowed_users WHERE user_email=$1 AND channel_id=$2', [logged_user, channelid]);
if (r.rowCount < 1) {
return res.status(403).json({ error: 'You are not allowed to do this' });
}
r = await db.query('SELECT email FROM users WHERE nickname=$1 OR email=$1', [user]);
if (r.rowCount < 1) {
return res.status(403).json({ error: 'User not found' });
}
const invited_email = r.rows[0].email;
try {
await db.query('INSERT INTO allowed_users (user_email, channel_id) VALUES ($1,$2)', [invited_email, channelid]);
} catch (error) {
console.log(error);
}
return res.json({ msg: 'User invited' });
});
router.get('/broadcast', async (req, res, next) => {
const channels = await list_channels(req.user.email);
res.render('broadcast', { channels, favorites: req.cookies });
});
router.post('/broadcast', async (req, res, next) => {
let { msg } = req.body;
const author = req.user.email;
const channels = Object.keys(req.cookies);
console.log('BROADCAST');
console.error(channels);
let promises = [];
for (const c of channels) {
promises.push(create_message(c, msg, author));
}
await Promise.all(promises);
res.redirect('/channel');
});
module.exports = router;

60
backend/routes/user.js Executable file
View File

@ -0,0 +1,60 @@
const express = require('express');
const fileUpload = require('express-fileupload');
const uuid = require('lil-uuid');
const sharp = require('sharp');
const db = require('../db');
const router = express.Router();
router.get('/profile', async (req, res, next) => {
const r = await db.query('SELECT * FROM users WHERE email = $1', [req.user.email]);
if (r.rowCount < 1) {
return res.redirect('/logout');
}
res.render('profile', r.rows[0]);
});
router.get('/change_propic', async (req, res, next) => {
res.render('change_propic');
});
router.post('/change_propic',
fileUpload({
limits: { fileSize: 1 * 1024 * 1024 },
}),
async (req, res, next) => {
const r = await db.query('SELECT * FROM users WHERE email = $1', [req.user.email]);
if (r.rows[0].propic !== '/static/img/default.png') {
res.locals.errormsg = 'This is a non-fungible propic';
return res.render('change_propic');
}
const propic = req.files.propic;
if (!propic) {
res.locals.errormsg = 'Error, upload a file';
return res.render('change_propic');
}
const filename = '/uploads/' + uuid();
try {
await sharp(propic.data).resize(100, 100).withMetadata().toFile(filename);
await db.query('UPDATE users SET propic = $1 WHERE email = $2', [filename, req.user.email]);
return res.redirect('/profile');
} catch (e) {
console.log(e);
res.locals.errormsg = 'Bad image';
return res.render('change_propic');
}
}
);
module.exports = router;

49
backend/server.js Executable file
View File

@ -0,0 +1,49 @@
const express = require('express');
require('express-async-errors');
const cookieParser = require('cookie-parser');
const db = require('./db');
const PORT = 3000;
const app = express();
// const indexRouter = require('./routes/index');
const authRouter = require('./routes/auth');
const channelRouter = require('./routes/channel');
const userRouter = require('./routes/user');
app.set('view engine', 'ejs');
app.use(express.urlencoded());
app.use(cookieParser());
app.use((req, res, next) => {
if (!req.headers.httpversion || req.headers.httpversion !== 'HTTP/3.0') {
return res.send('You need to use http3 to access this web3 website, sorry :/<br>Try to refresh the page');
}
next();
});
app.use((req, res, next) => {
if (req.cookies['session']) {
res.locals.isAuth = true;
}
next();
});
app.get('/', (req, res, next) => {
res.render('home');
});
app.use('/', authRouter);
app.use('/', channelRouter);
app.use('/', userRouter);
app.listen(PORT, () => {
console.log(`Listening on port ${PORT}`);
});

35
backend/views/broadcast.ejs Executable file
View File

@ -0,0 +1,35 @@
<%- include('header') %>
<div class="container-fluid">
<div class="row justify-content-md-center">
<div class="col-md-3">
<div class="card" style="padding: 2em; text-align: center;">
<form method="post">
<h3><i class="bi bi-send-plus-fill"></i> Broadcast message</h3>
<div class="mb-3">
<label for="channels_selection" class="form-label">Your favorites channels</label>
<ul class="list-group" id="channels_selection">
<% for (const c of channels){ %>
<% if(c.id in favorites){ %>
<li class="list-group-item">
<%= c.id %>
</li>
<% } %>
<% } %>
</ul>
</div>
<div class="mb-3">
<label for="message_input" class="form-label">Message content</label>
<input class="form-control" id="message_input" name="msg" placeholder="message">
</div>
<button class="btn btn-outline-primary" id="button-submit" type="submit"><i class="bi bi-send-plus-fill"></i> Send
broadcast
</button>
</form>
</div>
</div>
</div>
</div>
<%- include('footer') %>

24
backend/views/change_propic.ejs Executable file
View File

@ -0,0 +1,24 @@
<%- include('header') %>
<div class="container">
<div class="row justify-content-center">
<div class="col-6">
<div class="container card" style="padding: 20px; text-align: center;box-shadow: 0 0 4px 0 rgba(13, 110, 253, 0.3);">
<form method="post" encType="multipart/form-data">
<h3><i class="bi bi-pencil-fill"></i> Change Propic</h3>
<div class="container-fluid mb-3" style="margin-top: 30px">
<label for="formFile" class="form-label">Select your new profile picture</label>
<input class="form-control" type="file" id="formFile" name="propic">
</div>
<button type="submit" class="btn btn-outline-primary" style="margin-top: 10px"><i
class="bi bi-cloud-arrow-up-fill"></i>
Upload
</button>
</form>
</div>
</div>
</div>
</div>
<%- include('footer') %>

52
backend/views/channel.ejs Executable file
View File

@ -0,0 +1,52 @@
<%# include('header') %>
<!--<form action="/new_message" method="post">
<input hidden name="channelid" value="<%= channel.id %>">
<input name="msg" placeholder="message" autofocus>
<br>
<button type="submit">Send</button>
</form>-->
<div class="chat">
<div class="chat-header clearfix">
<div class="row">
<div class="col-lg-6">
<div class="chat-about">
<h6 class="m-b-0"><%= channel.id %></h6>
<small><%= channel.detail %></small>
</div>
</div>
<div class="col">
<p class="text-end" style="margin-bottom: 0; ">
<button type="button" class="btn btn-outline-primary" onclick="show_modal('add_user_modal')" style="padding: 0;">
<i class="bi bi-plus" style="font-size: 2rem;"></i>
</button>
</p>
</div>
</div>
</div>
<div class="chat-history h-75">
<ul class="m-b-0">
<% for (const m of messages){ %>
<li class="clearfix">
<div class="message-data">
<span class="message-data-time"><%= m.author %></span>
</div>
<div class="message my-message"><%= m.data %></div>
</li>
<% } %>
</ul>
</div>
<div class="chat-message clearfix">
<div class="input-group mb-3">
<input type="text" class="form-control" placeholder="Start messaging" aria-describedby="button-send"
id="msg_txt">
<button class="btn btn-outline-primary" type="button" id="button-send" onclick="send_message()">
<i class="bi bi-send-fill"></i> Send
</button>
</div>
<input hidden name="channelid" id="channel_id" value="<%= channel.id %>">
</div>
</div>
<%# include('footer') %>

229
backend/views/channels.ejs Executable file
View File

@ -0,0 +1,229 @@
<%- include('header') %>
<div class="container">
<div class="row">
<div class="col-3">
<div id="clist" class="channels-list card">
<h6 class="m-b-0">
<% if (channels.length===0){ %>
No channels!
<% } else { %>
Channels
</h6>
<ul class="list-unstyled chat-list mt-2 mb-0">
<% for (const c of channels){ %>
<li class="clearfix" onclick="open_chat('<%= c.id %>')" id="entry_<%= c.id %>">
<div class="about">
<div class="name">
<% if(c.id in favorites){ %>
<i id="star_<%= c.id %>" class="bi bi-star-fill" style="color:#fcba03"> </i>
<% } else { %>
<i id="star_<%= c.id %>" class="bi bi-star"> </i>
<% } %>
<%= c.id %>
</div>
</div>
</li>
<% } %>
<li class="clearfix" onclick="show_modal('new_channel_modal')">
<div class="about">
<div class="name" id="button-new-channel-modal">
<i class="bi bi-plus-circle"></i>
Create new channel
</div>
</div>
</li>
</ul>
<% } %>
</div>
</div>
<div class="col-lg-9">
<div class="card chat-app" id="channel_content">
<div class="chat">
<div class="chat-header clearfix">
<div class="row">
<div class="col-lg-6">
<div class="chat-about">
<h6 class="m-b-0">No channel selected</h6>
</div>
</div>
</div>
</div>
<div class="chat-history">
<h6 class="m-b-0">Please select a channel to start messaging</h6>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Modal new channel-->
<div class="modal fade" id="new_channel_modal" tabindex="-1" aria-labelledby="new_channel_modal_label" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="new_channel_modal_label">Create new channel</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-danger" role="alert" id="new-channel-alert-id" style="display: none;">
</div>
<div class="input-group input-group-sm mb-3">
<span class="input-group-text" id="inputGroup-channel-id">Channel id</span>
<input type="text" id="input_channel_id" name="channelid" class="form-control" aria-describedby="inputGroup-channel-id">
</div>
<div class="input-group input-group-sm mb-3">
<span class="input-group-text" id="inputGroup-detail">Details</span>
<input type="text" id="input_detail" name="detail" class="form-control" aria-describedby="inputGroup-detail">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-outline-primary" id="button-create-channel" onclick="create_channel()">Create</button>
</div>
</div>
</div>
</div>
<!-- Modal add user-->
<div class="modal fade" id="add_user_modal" tabindex="-1" aria-labelledby="add_user_modal_label" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="add_user_modal_label">Add user to channel</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-danger" role="alert" id="add-user-alert-id" style="display: none;">
</div>
<div class="input-group input-group-sm mb-3">
<span class="input-group-text" id="inputGroup-userid">User ID</span>
<input type="text" id="input_userid" name="detail" class="form-control" aria-describedby="inputGroup-detail">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-outline-primary" onclick="add_user()">Add</button>
</div>
</div>
</div>
</div>
<script>
window.onload = load_last();
async function add_user() {
const data = new URLSearchParams();
data.append('channelid', document.getElementById('channel_id').value);
data.append('user', document.getElementById('input_userid').value);
const res = await do_post("/invite", data);
const json = await res.json();
const modal = document.getElementById('add-user-alert-id');
if (json.error) {
modal.innerText = json.error;
modal.className = "alert alert-danger";
} else {
modal.innerText = json.msg;
modal.className = "alert alert-success";
}
modal.style.display = "block";
}
function show_modal(modalId) {
const modal = new bootstrap.Modal(document.getElementById(modalId));
modal.show();
}
async function open_chat(s) {
const r = await fetch('/channel/' + s);
if (r.status === 200){
Array.from(document.querySelectorAll('.clearfix.active')).forEach((el) => el.classList.remove('active'));
document.getElementById('entry_' + s).classList.add('active');
document.getElementById('channel_content').innerHTML = await r.text();
document.getElementById('msg_txt').addEventListener('keydown', send_with_enter);
}
}
async function send_with_enter(e) {
if (e.key === "Enter") {
send_message();
}
}
function save_last(){
if(document.getElementById('channel_id')){
channel = document.getElementById('channel_id').value;
if (channel){
location.hash = channel;
}
}
}
function load_last(){
const last = location.hash.replace('#', '');
if(last){
open_chat(last)
}
}
async function send_message() {
if (document.getElementById('msg_txt').value === "")
return;
const data = new URLSearchParams();
data.append('channelid', document.getElementById('channel_id').value);
data.append('msg', document.getElementById('msg_txt').value);
await do_post('/new_message', data);
open_chat(document.getElementById('channel_id').value);
}
async function create_channel() {
const data = new URLSearchParams();
data.append('channelid', document.getElementById('input_channel_id').value);
data.append('detail', document.getElementById('input_detail').value);
const res = await do_post('/new_channel', data);
const json = await res.json();
const modal = document.getElementById('new-channel-alert-id');
if (json.error) {
modal.innerText = json.error;
modal.className = "alert alert-danger";
modal.style.display = "block";
} else {
save_last();
location.reload();
}
}
async function do_post(url, data) {
return fetch(url, {
method: 'post',
body: data,
});
}
async function click_event(e) {
clicked = encodeURIComponent(e.target.id).split('star_')[1];
selected = document.cookie.split(';').map(c => c.split('=')[0].trim());
if (selected.indexOf(clicked) !== -1) {
document.cookie = clicked + '=;expires=Thu, 01 Jan 1970 00:00:01 GMT'
} else {
document.cookie = clicked + '=true'
}
save_last();
location.reload()
}
for (const fav of document.getElementsByTagName('i')) {
fav.addEventListener('click', click_event)
}
</script>
<%- include('footer') %>

3
backend/views/error.ejs Executable file
View File

@ -0,0 +1,3 @@
<%- include('header') %>
<%- include('footer') %>

4
backend/views/footer.ejs Executable file
View File

@ -0,0 +1,4 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-OERcA2EqjJCMA+/3y+gxIOqMEjwtxJY7qPCqsdltbNJuaOe923+mo//f6V8Qbsw3" crossorigin="anonymous"></script>
</body>
</html>

64
backend/views/header.ejs Executable file
View File

@ -0,0 +1,64 @@
<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8'>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title></title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-Zenh87qX5JnK2Jl0vWa8Ck2rdkQ2Bzep5IDxbcnCeuOxjzrPF/et3URy9Bv1WTRi" crossorigin="anonymous">
<link href="static/style.css" rel="stylesheet" type="text/css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.9.1/font/bootstrap-icons.css">
</head>
<body>
<nav class="navbar navbar-expand-lg bg-light sticky-top">
<div class="container-fluid">
<a class="navbar-brand" href="/">3ON</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNavAltMarkup"
aria-controls="navbarNavAltMarkup" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNavAltMarkup">
<div class="navbar-nav">
<% if (locals.isAuth){ %>
<a class="nav-link" href="/channel">Channels</a>
<a class="nav-link" href="/broadcast">Broadcast</a>
<a class="nav-link" href="/profile">Profile</a>
<a class="nav-link" href="/logout">Logout</a>
<% } else {%>
<a class="nav-link" href="/login">Login</a>
<a class="nav-link" href="/register">Register</a>
<% } %>
</div>
</div>
</div>
</nav>
<br>
<% if(locals.errormsg){ %>
<svg xmlns="http://www.w3.org/2000/svg" style="display: none;">
<symbol id="exclamation-triangle-fill" viewBox="0 0 16 16">
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"></path>
</symbol>
</svg>
<div class="container-fluid">
<div class="row justify-content-md-center">
<div class="col-md-3">
<div class="alert alert-danger d-flex align-items-center" role="alert">
<div>
<%= errormsg %>
</div>
</div>
</div>
</div>
</div>
<% } %>

106
backend/views/home.ejs Executable file
View File

@ -0,0 +1,106 @@
<%- include('header') %>
<div class="container">
<div class="card" style="padding: 20px; text-align: center;">
<div class="row justify-content-lg-center">
<div class="col-lg-8">
<br>
<h4>Do you like Crypto?
<br>Is Starbucks your favorite place to live in?
<br>Is decentralization your main goal in life?
<br><br>then
</h4>
<h2 style="margin-bottom: 0;">3ON</h2>
<h6>(Tron)</h6>
<br>
<h4>Is your new favorite (messaging) app!</h4>
<br>
<p>
<button type="submit" class="btn btn-outline-primary" onclick="location.href='/login';"><i
class="bi bi-shield-lock-fill"></i> Login
</button>
&
<button type="submit" class="btn btn-outline-primary" onclick="location.href='/channel';"><i
class="bi bi-send-fill"></i> start messaging
</button>
now!
</p>
</div>
</div>
<hr>
<div class="row justify-content-lg-center">
<div class="col">
<h2>Why using 3ON?</h2>
</div>
</div>
<div class="row justify-content-lg-center">
<div class="col-auto">
<div class="card why">
<div class="card-body">
<h5 class="card-title">Modern and sleek-looking</h5>
<!--Non è vero, sta grafica è tutta rotta, sta in piedi per miracolo. Sono un povero sviluppatore backend java che è stato obbligato. Xato dittatore!-->
<!--<h6 class="card-subtitle mb-2 text-muted">Card subtitle</h6>-->
<p>The latest and greatest trends in design, UI & UX to give you your best experience yet.
You'll be the coolest in your local starbucks!</p>
</div>
</div>
</div>
<div class="col-auto">
<div class="card why">
<div class="card-body">
<h5 class="card-title">HTTP 3 is here!</h5>
<!--<h6 class="card-subtitle mb-2 text-muted">Card subtitle</h6>-->
<p>HTTP 3 is the most <b>secure</b> and <b>private</b> way to browse the internet yet
and this is the first and only chat app in the world* to use it</p>
</div>
</div>
</div>
<div class="col-auto">
<div class="card why">
<div class="card-body">
<h5 class="card-title">Trusted by many</h5>
<!--<h6 class="card-subtitle mb-2 text-muted">Card subtitle</h6>-->
<p>Used by cybersecurity teams such as <b>pwnthem0le</b> and <b>printer toner level: 90%</b><br>&nbsp;</p>
</div>
</div>
</div>
</div>
<div class="row justify-content-lg-center">
<div class="col-auto">
<div class="card why">
<div class="card-body">
<h5 class="card-title">Advanced features</h5>
<p>With <b>broadcast messages</b> type your messages just once and send them to
<b>all</b> your <b>favorite</b> channels!<br>&nbsp;</p>
</div>
</div>
</div>
<div class="col-auto">
<div class="card why">
<div class="card-body">
<h5 class="card-title">Why not Matrix?</h5>
<!--<h6 class="card-subtitle mb-2 text-muted">Card subtitle</h6>-->
<p>3on is better than Matrix in: <b>speed, privacy</b> and <b>security</b>!*<br>
And to be fair TRON >> The Matrix (concerning movies)</p>
</div>
</div>
</div>
<div class="col-auto">
<div class="card why">
<div class="card-body">
<h5 class="card-title">Why no dark theme?</h5>
<!--<h6 class="card-subtitle mb-2 text-muted">Card subtitle</h6>-->
<p>Dark themes are for gloomy shut-in nerds. This is the <b>future</b> baby!
Go out and touch some grass while trading cryptos and drinking a <i>venti</i></p>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="fixed-bottom">
<p style="font-size: 10px">*According to us</p>
</div>
<%- include('footer') %>

24
backend/views/login.ejs Executable file
View File

@ -0,0 +1,24 @@
<%- include('header') %>
<div class="container-fluid">
<div class="row justify-content-md-center">
<div class="col-md-2">
<div class="card" style="padding: 20px; text-align: center;">
<form method="post">
<h3><i class="bi bi-shield-lock-fill"></i> Login</h3>
<div class="mb-3">
<label for="inputUsername" class="form-label">Email</label>
<input type="email" class="form-control" id="inputUsername" name="email">
</div>
<div class="mb-3">
<label for="inputPassword" class="form-label">Password</label>
<input type="password" class="form-control" id="inputPassword" name="password">
</div>
<button type="submit" id="submitButton" class="btn btn-outline-primary"><i class="bi bi-shield-lock-fill"></i> Login</button>
</form>
</div>
</div>
</div>
</div>
<%- include('footer') %>

20
backend/views/profile.ejs Executable file
View File

@ -0,0 +1,20 @@
<%- include('header') %>
<div class="container">
<div class="row justify-content-md-center">
<div class="col-6">
<div class="card justify-content-md-center" style="padding: 20px; text-align: center;box-shadow: 0 0 4px 0 rgba(13, 110, 253, 0.3);">
<a href="
/change_propic">
<img class="profile-pic" src="<%= propic %>">
</a>
<br>
<h3 style="margin-top: 20px"><%= nickname %></h3>
<h5 style="margin-top: 10px"><%= email %></h5>
<br>
</div>
</div>
</div>
</div>
<%- include('footer') %>

28
backend/views/register.ejs Executable file
View File

@ -0,0 +1,28 @@
<%- include('header') %>
<div class="container-fluid">
<div class="row justify-content-md-center">
<div class="col-md-2">
<div class="card" style="padding: 20px; text-align: center;">
<form method="post">
<h3><i class="bi bi-pencil-square"></i> Register</h3>
<div class="mb-3">
<label for="inputEmail" class="form-label">Email</label>
<input type="email" class="form-control" id="inputEmail" name="email">
</div>
<div class="mb-3">
<label for="inputUsername" class="form-label">Nickname</label>
<input type="text" class="form-control" id="inputUsername" name="nickname">
</div>
<div class="mb-3">
<label for="inputPassword" class="form-label">Password</label>
<input type="password" class="form-control" id="inputPassword" name="password">
</div>
<button type="submit" id="submitButton" class="btn btn-outline-primary"><i class="bi bi-pencil-square"></i> Register</button>
</form>
</div>
</div>
</div>
</div>
<%- include('footer') %>

13
db/init/create_schema.sql Executable file
View File

@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS channels (id VARCHAR(40), detail TEXT, private BOOL, PRIMARY KEY(id));
CREATE TABLE IF NOT EXISTS users (email VARCHAR(40), nickname VARCHAR(40) UNIQUE, password VARCHAR(40), propic TEXT, PRIMARY KEY(email));
CREATE TABLE IF NOT EXISTS allowed_users (user_email VARCHAR(40), channel_id VARCHAR(40), PRIMARY KEY(user_email,channel_id),
FOREIGN KEY(user_email) REFERENCES users(email));
CREATE TABLE IF NOT EXISTS messages (id VARCHAR(40), channel_id VARCHAR(40), data TEXT, author VARCHAR(40),ts timestamp NOT NULL DEFAULT NOW(), PRIMARY KEY(id),
FOREIGN KEY(author) REFERENCES users(email));
INSERT INTO channels (id, detail, private) VALUES ('Crypto','web3 without crypto?',FALSE);
INSERT INTO channels (id, detail, private) VALUES ('Random','whatever',FALSE);
INSERT INTO channels (id, detail, private) VALUES ('Brews','Share your best caffeine based concoction',FALSE);

34
docker-compose.yml Executable file
View File

@ -0,0 +1,34 @@
version: '3'
services:
backend:
build: ./backend
depends_on:
- db
volumes:
- "vol-uploads:/uploads"
restart: on-failure
frontend:
build: ./frontend
depends_on:
- backend
restart: on-failure
ports:
- "443:443/tcp"
- "443:443/udp"
volumes:
- ./frontend/static:/static/static
- /secrets/3on/:/etc/nginx/ssl
- "vol-uploads:/uploads/uploads"
db:
image: postgres
environment:
- POSTGRES_PASSWORD=password
- POSTGRES_DB=chatdb
volumes:
- ./db/init:/docker-entrypoint-initdb.d:ro
volumes:
vol-uploads:
driver: local

1
frontend/.gitignore vendored Executable file
View File

@ -0,0 +1 @@
test-http3

5
frontend/Dockerfile Executable file
View File

@ -0,0 +1,5 @@
FROM macbre/nginx-http3:1.23.2
COPY config/https.conf /etc/nginx/conf.d/https.conf

46
frontend/config/https.conf Executable file
View File

@ -0,0 +1,46 @@
# https://www.nginx.com/blog/introducing-technology-preview-nginx-support-for-quic-http-3/
server {
# quic and http/3
listen 443 http3 reuseport;
# http/2 and http/1.1
listen 443 ssl http2;
# openssl-generated pair for local development
# https://letsencrypt.org/docs/certificates-for-localhost/
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
# Enable all TLS versions (TLSv1.3 is required for QUIC).
ssl_protocols TLSv1.2 TLSv1.3;
# 0-RTT QUIC connection resumption
ssl_early_data on;
# Add Alt-Svc header to negotiate HTTP/3.
add_header alt-svc 'h3=":443";ma=86400,h3-27=":443";ma=86400,h3-28=":443";ma=86400,h3-29=":443";ma=86400';
add_header httpversion $server_protocol; # Sent when QUIC was used
location /static/ {
root /static;
}
location /uploads/ {
root /uploads;
}
location / {
proxy_pass http://backend:3000;
proxy_set_header httpversion $server_protocol;
}
# location / {
# root /static;
# gzip_static on;
# brotli_static on;
# expires 1d;
# }
}

BIN
frontend/static/img/default.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

290
frontend/static/style.css Executable file
View File

@ -0,0 +1,290 @@
body{
background-color: #f4f7f6;
}
.plus {
width: 15px;
height: 15px;
fill: red;
z-index: -1;
}
.card {
background: #fff;
transition: .5s;
border: 0;
margin-bottom: 30px;
border-radius: .55rem;
position: relative;
width: 100%;
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 10%);
}
.why {
background: #fff;
transition: .5s;
border: 0;
margin-bottom: 30px;
border-radius: .55rem;
position: relative;
width: 18rem;
box-shadow: 0 0 4px 0 rgba(13, 110, 253, 0.3);
/*0d6efd*/
}
.profile-pic {
width: 125px;
height: 125px;
border-radius: 50%;
object-fit: contain;
background-color: #f4f7f6;
}
.chat-app .channels-list {
width: 280px;
position: absolute;
left: 0;
top: 0;
padding: 20px;
z-index: 7
}
/*.chat-app .chat {
margin-left: 280px;
border-left: 1px solid #eaeaea
}*/
.channels-list {
-moz-transition: .5s;
-o-transition: .5s;
-webkit-transition: .5s;
transition: .5s;
padding: 20px;
}
.channels-list .chat-list li {
padding: 10px 15px;
list-style: none;
border-radius: 3px
}
.channels-list .chat-list li:hover {
background: #efefef;
cursor: pointer
}
.channels-list .chat-list li.active {
background: #efefef
}
.channels-list .chat-list li .name {
font-size: 15px
}
.channels-list .chat-list img {
width: 45px;
border-radius: 50%
}
.channels-list img {
float: left;
border-radius: 50%
}
.channels-list .about {
float: left;
padding-left: 8px
}
.channels-list .status {
color: #999;
font-size: 13px
}
.chat .chat-header {
padding: 15px 20px;
border-bottom: 2px solid #f4f7f6
}
.chat .chat-header img {
float: left;
border-radius: 40px;
width: 40px
}
.chat .chat-header .chat-about {
float: left;
padding-left: 10px
}
.chat-history{
max-height: 70vh;
overflow: auto;
}
.chat .chat-history {
padding: 20px;
border-bottom: 2px solid #fff
}
.chat .chat-history ul {
padding: 0
}
.chat .chat-history ul li {
list-style: none;
margin-bottom: 30px
}
.chat .chat-history ul li:last-child {
margin-bottom: 0px
}
.chat .chat-history .message-data {
margin-bottom: 15px
}
.chat .chat-history .message-data img {
border-radius: 40px;
width: 40px
}
.chat .chat-history .message-data-time {
color: #434651;
padding-left: 6px
}
.chat .chat-history .message {
color: #444;
padding: 18px 20px;
line-height: 26px;
font-size: 16px;
border-radius: 7px;
display: inline-block;
position: relative
}
.chat .chat-history .message:after {
bottom: 100%;
left: 7%;
border: solid transparent;
content: " ";
height: 0;
width: 0;
position: absolute;
pointer-events: none;
border-bottom-color: #fff;
border-width: 10px;
margin-left: -10px
}
.chat .chat-history .my-message {
background: #efefef;
max-width: 900px;
}
.chat .chat-history .my-message:after {
bottom: 100%;
left: 30px;
border: solid transparent;
content: " ";
height: 0;
width: 0;
position: absolute;
pointer-events: none;
border-bottom-color: #efefef;
border-width: 10px;
margin-left: -10px
}
.chat .chat-history .other-message {
background: #e8f1f3;
text-align: right
}
.chat .chat-history .other-message:after {
border-bottom-color: #e8f1f3;
left: 93%
}
.chat .chat-message {
padding: 20px
}
.online,
.offline,
.me {
margin-right: 2px;
font-size: 8px;
vertical-align: middle
}
.online {
color: #86c541
}
.offline {
color: #e47297
}
.me {
color: #1d8ecd
}
.float-right {
float: right
}
.clearfix:after {
visibility: hidden;
display: block;
font-size: 0;
content: " ";
clear: both;
height: 0
}
@media only screen and (max-width: 767px) {
.chat-app .channels-list {
height: 465px;
width: 100%;
overflow-x: auto;
background: #fff;
left: -400px;
display: none
}
.chat-app .channels-list.open {
left: 0
}
.chat-app .chat {
margin: 0
}
.chat-app .chat .chat-header {
border-radius: 0.55rem 0.55rem 0 0
}
.chat-app .chat-history {
height: 300px;
overflow-x: auto
}
}
@media only screen and (min-width: 768px) and (max-width: 992px) {
.chat-app .chat-list {
height: 650px;
overflow-x: auto
}
.chat-app .chat-history {
height: 600px;
overflow-x: auto
}
}
@media only screen and (min-device-width: 768px) and (max-device-width: 1024px) and (orientation: landscape) and (-webkit-min-device-pixel-ratio: 1) {
.chat-app .chat-list {
height: 480px;
overflow-x: auto
}
.chat-app .chat-history {
height: calc(100vh - 350px);
overflow-x: auto
}
}