diff --git a/DOCS.md b/DOCS.md index 56c0d2e..fb6dae8 100644 --- a/DOCS.md +++ b/DOCS.md @@ -90,7 +90,12 @@ Message format: ```json { - "name": "thread name" + "name": "thread name", + "permissions": { + "view_limited": true, + "post_limited": true + }, + "members": ["username1", "username2"] } ``` diff --git a/client/chat.js b/client/chat.js index 6c8d413..680f397 100644 --- a/client/chat.js +++ b/client/chat.js @@ -1,161 +1,218 @@ function rand() { - let str = ""; - const lookups = - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".split(""); - while (str.length < 16) { - const n = Math.random() * lookups.length; - str += lookups[Math.floor(n)]; - } - return str; + let str = ""; + const lookups = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".split(""); + while (str.length < 16) { + const n = Math.random() * lookups.length; + str += lookups[Math.floor(n)]; + } + return str; } async function auth() { - let session = rand(); - const sig = await openpgp.sign({ - message: new openpgp.CleartextMessage("vybe_auth " + session, ""), - signingKeys: window.keys.priv, - }); - window.session = session; - window.socket.emit("authenticate", { name: window.name, message: sig }); + let session = rand(); + const sig = await openpgp.sign({ + message: new openpgp.CleartextMessage("vybe_auth " + session, ""), + signingKeys: window.keys.priv, + }); + window.session = session; + window.socket.emit("authenticate", { name: window.name, message: sig }); } async function loadKeys(keys) { - const priv = await openpgp.readKey({ armoredKey: keys.privateKey }); - const pub = await openpgp.readKey({ armoredKey: keys.publicKey }); - window.keys = { priv, pub }; - await auth(); + const priv = await openpgp.readKey({ armoredKey: keys.privateKey }); + const pub = await openpgp.readKey({ armoredKey: keys.publicKey }); + window.keys = { priv, pub }; + await auth(); } function chooseThread(thread) { - window.currentThreadId = thread.id; - window.earliestMessage = null; - document.getElementById("messages").innerHTML = ""; - document.getElementById("threadname").innerHTML = thread.name; + window.currentThreadId = thread.id; + window.earliestMessage = null; + document.getElementById("messages").innerHTML = ""; + document.getElementById("threadname").innerHTML = thread.name; } function loadMessages() { - window.socket.emit( - "get_history", - { before: window.earliestMessage, thread: window.currentThreadId } - ); + window.socket.emit("get_history", { + before: window.earliestMessage, + thread: window.currentThreadId, + }); } function addThread(thread) { - const el = document.createElement("div"); - el.classList.add("thread"); - el.innerHTML = thread.name; - const btn = document.createElement("button"); - btn.innerHTML = "choose"; - btn.onclick = () => { - chooseThread(thread); - loadMessages(); - document.getElementById("loadmore").classList.remove("hidden"); - }; - el.appendChild(btn); - document.getElementById("threadlist").appendChild(el); + const el = document.createElement("div"); + el.classList.add("thread"); + el.innerHTML = thread.name; + const btn = document.createElement("button"); + btn.innerHTML = "choose"; + btn.onclick = () => { + chooseThread(thread); + loadMessages(); + document.getElementById("loadmore").classList.remove("hidden"); + if (!thread.permissions.post) { + document.getElementById("msginput").classList.add("hidden"); + } else { + document.getElementById("msginput").classList.remove("hidden"); + } + }; + el.appendChild(btn); + document.getElementById("threadlist").appendChild(el); +} + +function addMember() { + const name = document.getElementById("membername").value; + if (!window.threadmembers) { + window.threadmembers = [window.name, name]; + } else { + window.threadmembers.push(name); + } + const member = document.createElement("p"); + member.textContent = name; + member.classList.add("member"); + document.getElementById("memberlist").appendChild(member); + document.getElementById("membername").value = ""; } window.onload = () => { - window.currentThreadId = 1; - window.socket = io(); - window.socket.on("create_user", auth); - window.socket.on("new_message", (msg) => { - if (msg.thread !== window.currentThreadId) return; - const el = document.createElement("div"); - el.classList.add("message"); - const strong = document.createElement('strong'); - strong.textContent = msg.name + ': '; - el.append(strong, msg.message); - document.getElementById("messages").appendChild(el); - if (!window.earliestMessage) - window.earliestMessage = msg.id; - }); - window.socket.on("get_history", (msg) => { - if (msg.messages.length > 0) { - window.earliestMessage = msg.messages[msg.messages.length - 1].id; - for (let message of msg.messages) { - const el = document.createElement("div"); - el.classList.add("message"); - const strong = document.createElement('strong'); - strong.textContent = message.name + ': '; - el.append(strong, message.message); - document.getElementById("messages").prepend(el); - } - } - if (!msg.more) - document.getElementById("loadmore").classList.add("hidden"); - }); - window.socket.on("authenticate", (msg) => { - if (msg.success) { - document.getElementById("register").classList.add("hidden"); - document.getElementById("threads").classList.remove("hidden"); - document.getElementById("chat").classList.remove("hidden"); - window.socket.emit("list_threads"); - } - let emitter = window.socket.emit; - window.socket.emit = (type, data) => { - if (data) - return emitter.call(window.socket, type, { - ...data, - __session: window.session, - }); - else return emitter.call(window.socket, type); - }; - }); - window.socket.on("list_threads", (msg) => { - document.getElementById("threadlist").innerHTML = ""; - for (let thread of msg.threads) - addThread(thread); - }); - window.socket.on('new_thread', addThread); - window.socket.on("create_thread", (msg) => { - chooseThread({ - name: document.getElementById("newthreadname").value, - id: msg.id, - }); - document.getElementById("newthreadname").value = ""; - }); + window.currentThreadId = 1; + window.socket = io(); + window.socket.on("create_user", auth); + window.socket.on("new_message", (msg) => { + if (msg.thread !== window.currentThreadId) return; + const el = document.createElement("div"); + el.classList.add("message"); + const strong = document.createElement("strong"); + strong.textContent = msg.name + ": "; + el.append(strong, msg.message); + document.getElementById("messages").appendChild(el); + if (!window.earliestMessage) window.earliestMessage = msg.id; + }); + window.socket.on("get_history", (msg) => { + if (msg.messages.length > 0) { + window.earliestMessage = msg.messages[msg.messages.length - 1].id; + for (let message of msg.messages) { + const el = document.createElement("div"); + el.classList.add("message"); + const strong = document.createElement("strong"); + strong.textContent = message.name + ": "; + el.append(strong, message.message); + document.getElementById("messages").prepend(el); + } + } + if (!msg.more) document.getElementById("loadmore").classList.add("hidden"); + }); + window.socket.on("authenticate", (msg) => { + if (msg.success) { + document.getElementById("register").classList.add("hidden"); + document.getElementById("threads").classList.remove("hidden"); + document.getElementById("chat").classList.remove("hidden"); + const member = document.createElement("p"); + member.textContent = window.name; + member.classList.add("member"); + document.getElementById("memberlist").appendChild(member); + let emitter = window.socket.emit; + window.socket.emit = (type, data) => { + if (data) + return emitter.call(window.socket, type, { + ...data, + __session: window.session, + }); + else return emitter.call(window.socket, type); + }; + window.socket.emit("list_threads", {}); + } else { + document.getElementById("register").classList.remove("hidden"); + } + }); + window.socket.on("list_threads", (msg) => { + document.getElementById("threadlist").innerHTML = ""; + for (let thread of msg.threads) addThread(thread); + }); + window.socket.on("new_thread", addThread); + window.socket.on("create_thread", (msg) => { + chooseThread({ + name: document.getElementById("newthreadname").value, + id: msg.id, + }); + document.getElementById("newthreadname").value = ""; + document.getElementById("loadmore").classList.add("hidden"); + document.getElementById("msginput").classList.remove("hidden"); + }); - document.getElementById("registerform").onsubmit = async e => { - e.preventDefault(); - const name = document.getElementById("name").value; - if (!name) return; - const keys = await openpgp.generateKey({ - userIDs: [{ name }], - }); - const priv = await openpgp.readKey({ armoredKey: keys.privateKey }); - const pub = await openpgp.readKey({ armoredKey: keys.publicKey }); - window.keys = { priv, pub }; - localStorage.setItem("keys", JSON.stringify(keys)); - localStorage.setItem("name", name); - window.name = name; - window.socket.emit("create_user", { name, pubkey: keys.publicKey }); - }; - document.getElementById("msginput").onsubmit = e => { - e.preventDefault(); - const msg = document.getElementById("msg").value; - if (!msg) return; - window.socket.emit("send_message", { - message: msg, - thread: window.currentThreadId, - }); - document.getElementById("msg").value = ""; - }; - document.getElementById("loadmore").onclick = e => { - loadMessages(); - }; - document.getElementById("createthread").onsubmit = e => { - e.preventDefault(); - window.socket.emit("create_thread", { - name: document.getElementById("newthreadname").value, - }); - }; + document.getElementById("registerform").onsubmit = async (e) => { + e.preventDefault(); + const name = document.getElementById("name").value; + if (!name) return; + const keys = await openpgp.generateKey({ + userIDs: [{ name }], + }); + const priv = await openpgp.readKey({ armoredKey: keys.privateKey }); + const pub = await openpgp.readKey({ armoredKey: keys.publicKey }); + window.keys = { priv, pub }; + localStorage.setItem("keys", JSON.stringify(keys)); + localStorage.setItem("name", name); + window.name = name; + window.socket.emit("create_user", { name, pubkey: keys.publicKey }); + }; + document.getElementById("msginput").onsubmit = (e) => { + e.preventDefault(); + const msg = document.getElementById("msg").value; + if (!msg) return; + window.socket.emit("send_message", { + message: msg, + thread: window.currentThreadId, + }); + document.getElementById("msg").value = ""; + }; + document.getElementById("loadmore").onclick = (e) => { + loadMessages(); + }; + document.getElementById("createthread").onsubmit = (e) => { + e.preventDefault(); + const perms = document.querySelector( + 'input[name="permissions"]:checked' + ).value; + let permissions; + if (perms === "public") { + permissions = { + view_limited: false, + post_limited: false, + }; + } else if (perms === "private_post") { + permissions = { + view_limited: false, + post_limited: true, + }; + } else if (perms === "private_view") { + permissions = { + view_limited: true, + post_limited: true, + }; + } + window.socket.emit("create_thread", { + name: document.getElementById("newthreadname").value, + permissions, + members: window.threadmembers || [window.name], + }); + document.getElementById(perms).checked = false; + window.threadmembers = null; + document.getElementById("memberlist").innerHTML = ""; + const member = document.createElement("p"); + member.textContent = window.name; + member.classList.add("member"); + document.getElementById("memberlist").appendChild(member); + }; + document.getElementById("membername").onkeydown = (e) => { + if (e.key == "Enter") { + addMember(); + } + }; + document.getElementById("addmember").onclick = addMember; - const keys = localStorage.getItem("keys"); - if (keys) { - window.name = localStorage.getItem("name"); - loadKeys(JSON.parse(keys)).then(() => {}); - } - else - document.getElementById("register").classList.remove("hidden"); + const keys = localStorage.getItem("keys"); + if (keys) { + window.name = localStorage.getItem("name"); + loadKeys(JSON.parse(keys)).then(() => {}); + } else document.getElementById("register").classList.remove("hidden"); }; diff --git a/client/index.html b/client/index.html index c374def..b748e55 100644 --- a/client/index.html +++ b/client/index.html @@ -1,89 +1,125 @@ - - - - - - - - vybe - - - - - - - + + + + + + + + vybe + + + + + + + diff --git a/db/1-init.sql b/db/1-init.sql index 00ab34b..bc37f09 100644 --- a/db/1-init.sql +++ b/db/1-init.sql @@ -28,14 +28,14 @@ CREATE TABLE permissions ( value text, foreign key(user) references users(id), foreign key(thread) references threads(id) -) +); CREATE TABLE members ( thread integer, user integer, foreign key(user) references users(id), foreign key(thread) references threads(id) -) +); CREATE TABLE posts ( id integer primary key asc, diff --git a/db/dbdocs.md b/db/dbdocs.md index 37f30b8..27207bb 100644 --- a/db/dbdocs.md +++ b/db/dbdocs.md @@ -9,8 +9,8 @@ ## permissions -> permission - manage_permissions -- add_users -- remove_users +- add_members +- remove_members - view - post diff --git a/index.js b/index.js index 25f9aa3..4a9455b 100644 --- a/index.js +++ b/index.js @@ -14,10 +14,17 @@ const PORT = process.env.PORT || 3435; const actions = require("./src/actions"); +io.cache = {}; + io.on("connection", (socket) => { for (let action in actions) { socket.on(action, (msg) => - actions[action](msg, (response) => socket.emit(action, response), socket, io) + actions[action]( + msg, + (response) => socket.emit(action, response), + socket, + io + ) ); } }); diff --git a/src/authenticate.js b/src/authenticate.js index ac685ab..c3a7f5b 100644 --- a/src/authenticate.js +++ b/src/authenticate.js @@ -1,7 +1,7 @@ const db = require("../db"); const openpgp = require("openpgp"); -const authenticate = async (msg, respond, socket) => { +const authenticate = async (msg, respond, socket, io) => { if (!msg.name || !msg.message) { return respond({ success: false, @@ -43,6 +43,11 @@ const authenticate = async (msg, respond, socket) => { data[1], ]); socket.userid = result.rows[0].id; + if (io.cache[msg.name]) { + io.cache[msg.name].push(socket.id); + } else { + io.cache[msg.name] = [socket.id]; + } return respond({ success: true, }); diff --git a/src/authwrap.js b/src/authwrap.js index c818106..d1bdef0 100644 --- a/src/authwrap.js +++ b/src/authwrap.js @@ -1,7 +1,7 @@ const db = require("../db"); const authwrap = (fn) => async (msg, respond, socket, io) => { - if (!msg.__session) { + if (!msg || !msg.__session) { return respond({ success: false, message: "Not authenticated", diff --git a/src/create_thread.js b/src/create_thread.js index bb7b42a..2fb9b56 100644 --- a/src/create_thread.js +++ b/src/create_thread.js @@ -20,10 +20,82 @@ const create_thread = async (msg, respond, socket, io) => { "insert into threads (name, creator) values (?, ?) returning id", [msg.name, msg.auth_user.id] ); - io.emit('new_thread', { - name: msg.name, - id: insert.rows[0].id - }); + const thread_id = insert.rows[0].id; + // set up permissions + if (!msg.permissions || !msg.permissions.view_limited) { + await db.query( + `insert into permissions (thread, type, flexible, permission, value) + values (?, ?, ?, ?, ?)`, + [thread_id, "everyone", false, "view", "true"] + ); + if (!msg.permissions || !msg.permissions.post_limited) { + await db.query( + `insert into permissions (thread, type, flexible, permission, value) + values (?, ?, ?, ?, ?)`, + [thread_id, "everyone", false, "post", "true"] + ); + } else { + await db.query( + `insert into permissions (thread, type, flexible, permission, value) + values (?, ?, ?, ?, ?)`, + [thread_id, "members", false, "post", "true"] + ); + } + } else { + await db.query( + `insert into permissions (thread, type, flexible, permission, value) + values (?, ?, ?, ?, ?)`, + [thread_id, "members", false, "view", "true"] + ); + await db.query( + `insert into permissions (thread, type, flexible, permission, value) + values (?, ?, ?, ?, ?)`, + [thread_id, "members", false, "post", "true"] + ); + } + // add members + for (let user of msg.members) { + // get user id + const id = await db.query("select id from users where name = ?", [user]); + if (id.rows.length > 0) { + const user_id = id.rows[0].id; + await db.query("insert into members (thread, user) values (?, ?)", [ + thread_id, + user_id, + ]); + } + } + const member_perms = { + is_member: true, + view: true, + post: true, + }; + const general_perms = { + is_member: false, + view: !msg.permissions || !msg.permissions.view_limited, + post: !msg.permissions || !msg.permissions.post_limited, + }; + for (let username in io.cache) { + if (msg.members.includes(username)) { + const sockets = io.cache[username]; + for (let s of sockets) { + io.to(s).emit("new_thread", { + name: msg.name, + id: insert.rows[0].id, + permissions: member_perms, + }); + } + } else if (general_perms.view) { + const sockets = io.cache[username]; + for (let s of sockets) { + io.to(s).emit("new_thread", { + name: msg.name, + id: insert.rows[0].id, + permissions: general_perms, + }); + } + } + } // respond return respond({ success: true, diff --git a/src/get_history.js b/src/get_history.js index 3a02c34..db2e678 100644 --- a/src/get_history.js +++ b/src/get_history.js @@ -1,4 +1,6 @@ const db = require("../db"); +const authwrap = require("./authwrap"); +const check_permissions = require("./helpers/check_permissions"); const get_history = async (msg, respond) => { if (msg.before && isNaN(Number(msg.before))) { @@ -13,6 +15,12 @@ const get_history = async (msg, respond) => { message: "thread ID required", }); } + if (!(await check_permissions(msg.auth_user.id, msg.thread)).view) { + return respond({ + success: false, + message: "you can't view this thread", + }); + } const messages = await db.query( `select users.name, posts.id, content from posts join users on posts.user = users.id @@ -31,4 +39,4 @@ const get_history = async (msg, respond) => { }); }; -module.exports = get_history; +module.exports = authwrap(get_history); diff --git a/src/helpers/check_permissions.js b/src/helpers/check_permissions.js new file mode 100644 index 0000000..2f04f28 --- /dev/null +++ b/src/helpers/check_permissions.js @@ -0,0 +1,37 @@ +const db = require("../../db"); + +const check_permissions = async (user_id, thread_id) => { + // get all the permissions for the thread + const permissions = await db.query( + "select * from permissions where thread = ?", + [thread_id] + ); + // check if the user is a member + const is_member = + ( + await db.query("select * from members where thread = ? and user = ?", [ + thread_id, + user_id, + ]) + ).rows.length > 0; + const get_permission = (permission) => { + const relevant = permissions.rows.filter( + (i) => i.permission === permission + ); + for (let i of relevant) { + if (i.type === "everyone" && i.value === "true") { + return true; + } + if (i.type === "members" && i.value === "true" && is_member) { + return true; + } + } + }; + return { + is_member, + view: get_permission("view"), + post: get_permission("post"), + }; +}; + +module.exports = check_permissions; diff --git a/src/list_threads.js b/src/list_threads.js index b59ad27..8e0dff4 100644 --- a/src/list_threads.js +++ b/src/list_threads.js @@ -1,14 +1,32 @@ const db = require("../db"); +const authwrap = require("./authwrap"); +const check_permissions = require("./helpers/check_permissions"); const list_threads = async (msg, respond) => { const threads = await db.query( - "select name, id from threads order by created desc" + `select name, id from threads + join permissions on threads.id = permissions.thread + join members on threads.id = members.thread + where permissions.permission = 'view' + and permissions.value = 'true' + and ((permissions.type = 'everyone') or + permissions.type = 'members' and members.user = ?) + group by threads.id + order by created desc`, + [msg.auth_user.id] ); // respond + const rows = []; + for (let i of threads.rows) { + rows.push({ + ...i, + permissions: await check_permissions(msg.auth_user.id, i.id), + }); + } return respond({ success: true, - threads: threads.rows, + threads: rows, }); }; -module.exports = list_threads; +module.exports = authwrap(list_threads); diff --git a/src/send_message.js b/src/send_message.js index 692972a..8adad14 100644 --- a/src/send_message.js +++ b/src/send_message.js @@ -1,24 +1,60 @@ const db = require("../db"); const authwrap = require("./authwrap"); +const check_permissions = require("./helpers/check_permissions"); -const send_message = async (msg, respond, socket) => { +const send_message = async (msg, respond, socket, io) => { if (!msg.thread) { return respond({ success: false, message: "thread ID required", }); } + if (!(await check_permissions(msg.auth_user.id, msg.thread)).post) { + return respond({ + success: false, + message: "you can't post to this thread", + }); + } // add message and send it to everyone const id = await db.query( "insert into posts (user, thread, content) values (?, ?, ?) returning id", [msg.auth_user.id, msg.thread, msg.message] ); - socket.broadcast.emit("new_message", { - id: id.rows[0].id, - name: msg.auth_user.name, - message: msg.message, - thread: msg.thread, - }); + // get thread members + const members = ( + await db.query( + "select name from users join members on members.user = users.id where members.thread = ?", + [msg.thread] + ) + ).rows.map((i) => i.name); + // get perms + const permissions = await db.query( + "select * from permissions where thread = ? and type = 'everyone' and value = 'true' and permission = 'view'", + [msg.thread] + ); + for (let username in io.cache) { + if (members.includes(username)) { + const sockets = io.cache[username]; + for (let s of sockets) { + io.to(s).emit("new_message", { + id: id.rows[0].id, + name: msg.auth_user.name, + message: msg.message, + thread: msg.thread, + }); + } + } else if (permissions.rows.length > 0) { + const sockets = io.cache[username]; + for (let s of sockets) { + io.to(s).emit("new_message", { + id: id.rows[0].id, + name: msg.auth_user.name, + message: msg.message, + thread: msg.thread, + }); + } + } + } return respond({ success: true, id: id.rows[0].id,