Initial
commit
6be4405ec1
|
@ -0,0 +1 @@
|
|||
node_modules
|
|
@ -0,0 +1 @@
|
|||
node_modules/
|
|
@ -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"]
|
|
@ -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 };
|
|
@ -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;
|
File diff suppressed because it is too large
Load Diff
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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}`);
|
||||
});
|
|
@ -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') %>
|
|
@ -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') %>
|
|
@ -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') %>
|
|
@ -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') %>
|
|
@ -0,0 +1,3 @@
|
|||
<%- include('header') %>
|
||||
|
||||
<%- include('footer') %>
|
|
@ -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>
|
|
@ -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>
|
||||
|
||||
<% } %>
|
|
@ -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> </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> </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') %>
|
|
@ -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') %>
|
|
@ -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') %>
|
|
@ -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') %>
|
|
@ -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);
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
test-http3
|
|
@ -0,0 +1,5 @@
|
|||
FROM macbre/nginx-http3:1.23.2
|
||||
|
||||
COPY config/https.conf /etc/nginx/conf.d/https.conf
|
||||
|
||||
|
|
@ -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;
|
||||
# }
|
||||
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 2.3 KiB |
|
@ -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
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue