audio streaming

main
jerl 2024-05-24 02:29:09 -05:00
parent 4416a3766b
commit 8ef0172d11
17 changed files with 884 additions and 570 deletions

View File

@ -1,6 +1,7 @@
import { render, html } from '/uhtml.js'; import { render, html } from '/uhtml.js';
import loadMessages from '/message.js'; import loadMessages from '/message.js';
import loadSpace from '/space.js'; import loadSpace from '/space.js';
import loadStreams from '/stream.js';
function setVisibility() { function setVisibility() {
document.getElementById('visibility').innerText = `${ document.getElementById('visibility').innerText = `${
@ -33,6 +34,7 @@ function chooseThread() {
document.getElementById('threadname').textContent = this.thread.name; document.getElementById('threadname').textContent = this.thread.name;
this.classList.add('active'); this.classList.add('active');
window.currentThread = this.thread; window.currentThread = this.thread;
loadStreams();
if (this.tab) if (this.tab)
switchTab(document.getElementById(this.tab)); switchTab(document.getElementById(this.tab));
else // load first tab that has any content else // load first tab that has any content
@ -40,8 +42,16 @@ function chooseThread() {
if (messages.length) if (messages.length)
switchTab(document.getElementById(this.tab = 'messagetab')); switchTab(document.getElementById(this.tab = 'messagetab'));
else else
loadSpace(spans => switchTab( loadSpace(spans => {
document.getElementById(this.tab = spans.length ? 'spacetab' : 'messagetab'))); if (spans)
switchTab(document.getElementById(this.tab = 'spacetab'));
else if (window.currentThread.streams.length)
switchTab(document.getElementById(this.tab = 'streamtab'));
else
switchTab(document.getElementById(
this.tab = spans.length ? 'spacetab' :
window.currentThread.streams.length ? 'streamtab' : 'messagetab'));
});
}); });
window.emit('get_thread', { thread: this.thread.id }, msg => { window.emit('get_thread', { thread: this.thread.id }, msg => {
window.currentThread = msg.thread; window.currentThread = msg.thread;
@ -148,7 +158,6 @@ function newThread() {
<input type='radio' name='newpermissions' <input type='radio' name='newpermissions'
id='private_view' value='private_view' /> id='private_view' value='private_view' />
<label for='private_view'>only members can view and post</label><br> <label for='private_view'>only members can view and post</label><br>
<br>
<label class='heading' for='membername'>members</label> <label class='heading' for='membername'>members</label>
<input type='text' id='membername' placeholder='username' onkeydown=${event => { <input type='text' id='membername' placeholder='username' onkeydown=${event => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
@ -267,7 +276,8 @@ document.body.append(html.node`
<div id='buttonbar'> <div id='buttonbar'>
<div id='tabs'> <div id='tabs'>
<button id='messagetab' class='tab active' onclick=${clickedTab}>messages <button id='messagetab' class='tab active' onclick=${clickedTab}>messages
</button><button id='spacetab' class='tab' onclick=${clickedTab}>space</button> </button><button id='spacetab' class='tab' onclick=${clickedTab}>space
</button><button id='streamtab' class='tab' onclick=${clickedTab}>streams</button>
</div> </div>
<button id='showmembers' onclick=${() => <button id='showmembers' onclick=${() =>
document.getElementById('members').classList.toggle('hidden') document.getElementById('members').classList.toggle('hidden')
@ -275,6 +285,7 @@ document.body.append(html.node`
</div> </div>
<div id='message' class='tabcontent'></div> <div id='message' class='tabcontent'></div>
<div id='space' class='tabcontent hidden'></div> <div id='space' class='tabcontent hidden'></div>
<div id='stream' class='tabcontent hidden'></div>
</div> </div>
<hr class='separator' color='#505050'> <hr class='separator' color='#505050'>
<div id='members' class='hidden'> <div id='members' class='hidden'>

View File

@ -81,8 +81,11 @@
background-color: #4f4f4f; background-color: #4f4f4f;
color: #fff; color: #fff;
} }
p {
margin: 5px 1px;
}
label.heading { label.heading {
margin-bottom: 5px; margin: 10px 1px 4px;
display: block; display: block;
} }
h3 { h3 {
@ -218,9 +221,6 @@
white-space: pre-line; white-space: pre-line;
margin-bottom: 12px; margin-bottom: 12px;
} }
.member {
margin: 5px 0;
}
#space { #space {
margin: -2px; /* offset column margin */ margin: -2px; /* offset column margin */
position: relative; position: relative;
@ -230,6 +230,13 @@
position: absolute; position: absolute;
white-space: nowrap; white-space: nowrap;
} }
#stream {
margin: 4px 2px;
}
#play {
line-height: 1.4;
font-family: 'Segoe UI Symbol', math;
}
</style> </style>
</head> </head>
<body></body> <body></body>

162
client/stream.js Normal file
View File

@ -0,0 +1,162 @@
import { render, html } from '/uhtml.js';
let streamid;
let handle;
let mediaStream;
let recorder;
async function stream() {
if (handle) {
clearInterval(handle);
handle = null;
if (recorder.state === 'recording')
recorder.stop();
window.emit('stream', {
id: streamid,
thread: window.currentThread.id,
stop: true
});
document.getElementById('streaming').innerText = 'start streaming';
return;
}
if (!mediaStream)
mediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
autoGainControl: false,
echoCancellation: false,
noiseSuppression: false,
sampleRate: 48000,
sampleSize: 16
}
});
if (!mediaStream)
return;
window.emit('stream', {
thread: window.currentThread.id,
name: document.getElementById('streamname').value
}, msg => {
if (!msg.success) {
console.log('stream failed: ', msg.message);
return;
}
streamid = msg.id;
document.getElementById('streaming').innerText = 'stop streaming';
});
function record() {
let r = recorder = new MediaRecorder(mediaStream);
let chunks = [];
r.ondataavailable = event => {
if (!event.data.size)
return;
chunks.push(event.data);
};
r.onstop = async () => {
if (!chunks.length || !handle)
return;
//console.log(`${Date.now()} ${chunks.length}`);
window.emit('streamdata', {
id: streamid,
audio: await (new Blob(chunks, { type: chunks[0].type })).arrayBuffer()
}, msg => {
if (!msg.success)
console.log('streamdata failed: ', msg.message);
});
};
r.onstart = () => {
setTimeout(() => r.state === 'recording' && r.stop(), 100);
};
r.start();
}
record();
handle = setInterval(record, 100);
}
let audioctx;
let streaming = {};
function addStream(stream) {
let p = html.node`
<p>
<button id='play' onclick=${e => {
if (stream.playing) {
audioctx.suspend();
delete streaming[stream.id];
stream.playing = false;
e.target.innerText = '▶';
}
else {
audioctx = new AudioContext();
streaming[stream.id] = stream;
stream.playing = true;
e.target.innerText = '⏹';
}
window.emit('play_stream', {
id: stream.id,
thread: window.currentThread.id,
playing: stream.playing
}, msg => {
if (!msg.success)
console.log('play stream failed: ', msg.message);
});
}}></button>
${stream.user}<span id='name'>${stream.name ? ` - ${stream.name}` : ''}</span>
</p>`;
p.id = 'stream' + stream.id;
document.getElementById('streams').append(p);
}
function loadStreams() {
let div = document.getElementById('stream');
div.innerHTML = '';
if (window.currentThread.permissions.post) {
// why doesn't html` work here? html.node` does
render(div, html.node`
<button id='streaming' onclick=${stream}>start streaming</button>
<span>stream name:</span>
<input id='streamname'>`);
div.children['streamname'].oninput = event => {
if (handle)
window.emit('stream', {
id: streamid,
thread: window.currentThread.id,
name: div.children['streamname'].value
});
};
}
div.insertAdjacentHTML('beforeend', `
<p>streams:</p>
<div id='streams'></div>`);
for (let stream of window.currentThread.streams)
addStream(stream);
}
window.socket.on('stream', async msg => {
if (msg.thread !== window.currentThread.id)
return;
let p = document.getElementById('stream' + msg.id);
if (p) {
if (msg.stopped) {
p.remove();
window.currentThread.streams.splice(
window.currentThread.streams.findIndex(s => s.id === msg.id), 1);
}
else
p.children['name'].innerText = msg.name ? ' - ' + msg.name : '';
}
else if (!msg.stopped) {
window.currentThread.streams.push(msg);
addStream(msg);
}
});
window.socket.on('streamdata', async msg => {
if (!streaming[msg.id])
return;
let source = audioctx.createBufferSource();
source.buffer = await audioctx.decodeAudioData(msg.audio);
source.connect(audioctx.destination);
source.start(/*audioStartTime*/);
});
export default loadStreams;

View File

@ -4,10 +4,12 @@ const http = require('http');
const { Server } = require('socket.io'); const { Server } = require('socket.io');
const compression = require('compression'); const compression = require('compression');
const events = Object.fromEntries( const events = {};
fs.readdirSync('./src/event') for (let file of fs.readdirSync('./src/events')) {
.map(event => [event.slice(0, -3), require('./src/event/' + event)]) file = require('./src/events/' + file);
); for (const event in file)
events[event] = file[event];
}
const PORT = process.env.PORT || 3435; const PORT = process.env.PORT || 3435;
@ -16,10 +18,18 @@ app.use(compression());
const server = http.createServer(app); const server = http.createServer(app);
const io = new Server(server, { const io = new Server(server, {
cors: { cors: {
origin: true, origin: true
}, }
}); });
app.use(express.static('client'));
global.vybe = {
users: {},
threads: {},
streams: {}
};
io.on('connection', (socket) => { io.on('connection', (socket) => {
for (let event in events) { for (let event in events) {
socket.on(event, (msg, callback) => { socket.on(event, (msg, callback) => {
@ -27,22 +37,27 @@ io.on('connection', (socket) => {
callback('no such event ' + event); callback('no such event ' + event);
return; return;
} }
try {
events[event](msg, callback, socket); events[event](msg, callback, socket);
}
catch (e) {
console.log(`${event} threw exception: `, e);
}
}); });
} }
socket.on('disconnect', reason => { socket.on('disconnect', reason => {
let user = vybe.users[socket.username]; let user = vybe.users[socket.username];
if (user) if (user)
user.sockets.splice(user.sockets.indexOf(socket), 1); user.sockets.splice(user.sockets.indexOf(socket), 1);
}) for (let id in vybe.streams) {
const stream = vybe.streams[id];
delete stream.listeners[socket.id];
if (stream.socket === socket.id)
stream.stop();
}
});
}); });
global.vybe = {
users: {}
};
server.listen(PORT, () => { server.listen(PORT, () => {
console.log('server running on port ' + PORT); console.log('server running on port ' + PORT);
}); });
app.use(express.static('client'));

View File

@ -1,119 +0,0 @@
const db = require('../db');
const authwrap = require('../authwrap');
const create_thread = async (msg, respond) => {
// validate inputs
if (typeof msg.name !== 'string') {
return respond({
success: false,
message: 'thread name required'
});
}
if (msg.name.length > 200) {
return respond({
success: false,
message: 'thread name 200 chars max'
});
}
// add to db
const insert = await db.query(
'insert into thread (name, creator) values (?, ?) returning id',
[msg.name, msg.auth_user.id]
);
const thread_id = insert.rows[0].id;
// set up permissions
if (!msg.permissions || !msg.permissions.view_limited) {
await db.query(
`insert into permission (thread, type, mutable, permission, value)
values (?, ?, ?, ?, ?)`,
[thread_id, 'everyone', true, 'view', 'true']
);
if (!msg.permissions || !msg.permissions.post_limited) {
await db.query(
`insert into permission (thread, type, mutable, permission, value)
values (?, ?, ?, ?, ?)`,
[thread_id, 'everyone', true, 'post', 'true']
);
} else {
await db.query(
`insert into permission (thread, type, mutable, permission, value)
values (?, ?, ?, ?, ?)`,
[thread_id, 'members', true, 'post', 'true']
);
}
} else {
await db.query(
`insert into permission (thread, type, mutable, permission, value)
values (?, ?, ?, ?, ?)`,
[thread_id, 'members', true, 'view', 'true']
);
await db.query(
`insert into permission (thread, type, mutable, permission, value)
values (?, ?, ?, ?, ?)`,
[thread_id, 'members', true, 'post', 'true']
);
}
// add members
if (Array.isArray(msg.members)) {
for (let member of msg.members) {
if (!member) continue;
// get user id
const id = await db.query('select id from user where name = ?', [
member.name,
]);
if (id.rows.length === 0) {
console.log('user not found: ' + member.name);
continue;
}
await db.query(
'insert into member (thread, user) values (?, ?)',
[thread_id, id.rows[0].id]
);
if (typeof member.permissions === 'object')
for (let permission in member.permissions)
await db.query(`
insert into permission (thread, type, user, mutable, permission, value)
values (?, ?, ?, ?, ?, ?)`,
[thread_id, 'user', id.rows[0].id, true, permission, member.permissions[permission]]);
}
}
if (!msg.permissions || !msg.permissions.view_limited) {
for (let username in vybe.users) {
for (let socket of vybe.users[username].sockets) {
socket.emit('thread', {
name: msg.name,
id: insert.rows[0].id,
permissions: {
is_member: false,
view: true,
post: !msg.permissions || !msg.permissions.post_limited
}
});
}
}
}
else if (Array.isArray(msg.members)) {
for (let member of msg.members) {
if (!vybe.users[member.name])
continue;
for (let socket of vybe.users[member.name].sockets) {
socket.emit('thread', {
name: msg.name,
id: insert.rows[0].id,
permissions: {
is_member: true,
view: true,
post: true
}
});
}
}
}
// respond
return respond({
success: true,
id: thread_id
});
};
module.exports = authwrap(create_thread);

View File

@ -1,55 +0,0 @@
const db = require('../db');
const openpgp = require('openpgp');
const create_user = async (msg, respond) => {
// validate inputs
if (!msg.name) {
return respond({
success: false,
message: 'username required'
});
}
if (!msg.pubkey) {
return respond({
success: false,
message: 'public key required'
});
}
// ensure username is not taken
const result = await db.query('select * from user where name = ?', [
msg.name
]);
if (result.rows.length > 0) {
console.log(`username already exists: ${result}`);
return respond({
success: false,
message: 'a user with this name already exists on this server'
});
}
// validate public key
try {
await openpgp.readKey({ armoredKey: msg.pubkey });
} catch (err) {
console.err('error in create_user readkey: ' + err);
return respond({
success: false,
message: 'public key invalid'
});
}
// add to db
const insert = await db.query(
'insert into user (name) values (?) returning id',
[msg.name]
);
await db.query(
'insert into key (user, pubkey, active) values (?, ?, true)',
[insert.rows[0].id, msg.pubkey]
)
// respond
return respond({
success: true,
id: insert.rows[0].id
});
};
module.exports = create_user;

View File

@ -1,155 +0,0 @@
const db = require('../db');
const authwrap = require('../authwrap');
const check_permission = require('../check_permission');
const edit_thread = async (msg, respond) => {
// validate inputs
if (!msg.id || typeof msg.name !== 'string') {
return respond({
success: false,
message: 'invalid msg'
});
}
if (msg.name.length > 200) {
return respond({
success: false,
message: 'thread name 200 chars max'
});
}
const perms = await check_permission(msg.auth_user.id, msg.id);
if (!perms.admin) {
return respond({
success: false,
message: "user doesn't have permission"
});
}
// update name
await db.query(
'update thread set name = ? where id = ?',
[msg.name, msg.id]
);
// update permissions
let permissions = {};
for (const p of (await db.query(
`select type, permission, value, mutable
from permission
where type != 'user' and thread = ?`,
[msg.id]
)).rows) {
(permissions[p.type] || (permissions[p.type] = {}))
[p.permission] = {
value: p.value,
mutable: p.mutable
};
}
async function setPermission(type, permission, value) {
if (permissions[type] && permissions[type][permission]) {
if (!permissions[type][permission].mutable) {
respond({
success: false,
message: 'permission not mutable'
});
return false;
}
if (permissions[type][permission].value !== value) {
await db.query(`
update permission set value = ?
where thread = ? and type = ? and permission = ?`,
[value, msg.id, type, permission]
);
permissions[type][permission].value = value;
}
}
else {
await db.query(
`insert into permission (thread, type, mutable, permission, value)
values (?, ?, ?, ?, ?)`,
[msg.id, type, true, permission, value]
);
(permissions[type] || (permissions[type] = {}))
[permission] = {
value,
mutable: true
};
}
return true;
}
if (!msg.permissions || !msg.permissions.view_limited) {
if (!await setPermission('everyone', 'view', 'true'))
return;
if (!msg.permissions || !msg.permissions.post_limited) {
if (!await setPermission('everyone', 'post', 'true'))
return;
}
else {
if (!await setPermission('members', 'post', 'true'))
return;
if (!await setPermission('everyone', 'post', 'false'))
return;
}
} else {
if (!await setPermission('members', 'view', 'true'))
return;
if (!await setPermission('members', 'post', 'true'))
return;
if (!await setPermission('everyone', 'view', 'false'))
return;
if (!await setPermission('everyone', 'post', 'false'))
return;
}
permissions.everyone.view.value = permissions.everyone.view.value === 'true';
permissions.everyone.post.value = permissions.everyone.post.value === 'true';
if (!permissions.members) permissions.members = {};
if (!permissions.members.view)
permissions.members.view = { value: 'true', mutable: true };
if (!permissions.members.post)
permissions.members.post = { value: 'true', mutable: true };
permissions.members.view.value = permissions.members.view.value === 'true';
permissions.members.post.value = permissions.members.post.value === 'true';
if (!msg.permissions || !msg.permissions.view_limited) {
for (let username in vybe.users) {
for (let socket of vybe.users[username].sockets) {
socket.emit('thread', {
name: msg.name,
id: msg.id,
permissions: {
is_member: false,
view: true,
post: !msg.permissions || !msg.permissions.post_limited,
...permissions
}
});
}
}
}
else {
for (let member of (await db.query(
`select user.name, user.id from thread
join member on thread.id = member.thread
join user on user.id = member.user
where thread.id = ?`,
[msg.id]
)).rows) {
if (!vybe.users[member.name])
continue;
for (let socket of vybe.users[member.name].sockets) {
socket.emit('thread', {
name: msg.name,
id: msg.id,
permissions: {
is_member: true,
view: true,
post: true,
admin: member.id === msg.auth_user.id && perms.admin,
...permissions
}
});
}
}
}
return respond({
success: true
});
};
module.exports = authwrap(edit_thread);

View File

@ -1,42 +0,0 @@
const db = require('../db');
const authwrap = require('../authwrap');
const check_permission = require('../check_permission');
const get_history = async (msg, respond) => {
if (msg.before && isNaN(Number(msg.before))) {
return respond({
success: false,
message: 'before must be a number',
});
}
if (!msg.thread) {
return respond({
success: false,
message: 'thread ID required',
});
}
if (!(await check_permission(msg.auth_user.id, msg.thread)).view) {
return respond({
success: false,
message: "you can't view this thread",
});
}
const messages = await db.query(
`select user.name, post.id, content from post
join user on post.user = user.id
${msg.before ? 'where post.id < ? and' : 'where'}
thread = ?
order by post.created desc
limit 101`,
msg.before ? [msg.before, msg.thread] : [msg.thread]
);
return respond({
success: true,
messages: messages.rows
.slice(0, 100)
.map((i) => ({ id: i.id, name: i.name, message: i.content })),
more: messages.rows.length > 100,
});
};
module.exports = authwrap(get_history);

View File

@ -1,30 +0,0 @@
const db = require('../db');
const authwrap = require('../authwrap');
const get_keys = async (msg, respond) => {
// validate inputs
if (!msg.names) {
return respond({
success: false,
message: 'user names required'
});
}
if (typeof msg.names !== 'object') {
return respond({
success: false,
message: "can't iterate user names"
});
}
const keys = await db.query( // todo: encryption !
`select name from user where name in
(${msg.names.map((i) => '?').join(',')})`,
msg.names
);
// respond
return respond({
success: true,
keys: keys.rows
});
};
module.exports = authwrap(get_keys);

View File

@ -1,28 +0,0 @@
const db = require('../db');
const authwrap = require('../authwrap');
const check_permission = require('../check_permission');
const get_space = async (msg, respond) => {
if (!msg.thread) {
return respond({
success: false,
message: 'thread ID required'
});
}
if (!(await check_permission(msg.auth_user.id, msg.thread)).view) {
return respond({
success: false,
message: "you can't view this thread"
});
}
const spans = await db.query(
'select id, content, x, y, scale from span where thread = ? and deleted = false',
[msg.thread]
);
return respond({
success: true,
spans: spans.rows
});
};
module.exports = authwrap(get_space);

View File

@ -1,76 +0,0 @@
const db = require('../db');
const authwrap = require('../authwrap');
const check_permission = require('../check_permission');
const get_thread = async (msg, respond) => {
if (!msg.thread) {
return respond({
success: false,
message: 'thread ID required'
});
}
let perms = await check_permission(msg.auth_user.id, msg.thread);
if (!perms.view) {
return respond({
success: false,
message: "you can't view this thread"
});
}
const thread = await db.query(
`select thread.name, user.name as user, user.id from thread
left join member on thread.id = member.thread
left join user on user.id = member.user
where thread.id = ?`,
[msg.thread]
);
const permissions = await db.query(
`select type, user, permission, value, mutable
from permission where thread = ?`,
[msg.thread]
);
let members = Object.fromEntries(thread.rows.map(member =>
[member.id, { name: member.user }]
));
for (let permission of permissions.rows) {
const member = members[permission.user];
if (member)
(member.permissions || (member.permissions = {}))
[permission.permission] = {
value: permission.value,
mutable: permission.mutable
};
else
(perms[permission.type] || (perms[permission.type] = {}))
[permission.permission] = {
value: permission.value,
mutable: permission.mutable
};
}
function makeBool(type, permission) {
if (perms[type]) {
if (perms[type][permission])
perms[type][permission].value = perms[type][permission].value === 'true';
else
perms[type][permission] = { value: false, mutable: true };
} else
(perms[type] = {})[permission] = { value: false, mutable: true };
}
makeBool('everyone', 'view');
makeBool('everyone', 'post');
makeBool('members', 'view');
makeBool('members', 'post');
return respond({
success: true,
thread: {
id: msg.thread,
name: thread.rows[0].name,
permissions: perms,
members: Object.entries(members).map(member => ({
id: member[0],
...member[1]
}))
}
});
};
module.exports = authwrap(get_thread);

View File

@ -1,32 +0,0 @@
const db = require('../db');
const authwrap = require('../authwrap');
const check_permission = require('../check_permission');
const list_threads = async (msg, respond) => {
const threads = await db.query(
`select name, id from thread
join permission on thread.id = permission.thread
left join member on thread.id = member.thread
where permission.permission = 'view'
and permission.value = 'true'
and ((permission.type = 'everyone') or
permission.type = 'members' and member.user = ?)
group by thread.id
order by thread.created desc`,
[msg.auth_user.id]
);
// respond
const rows = [];
for (let thread of threads.rows) {
rows.push({
...thread,
permissions: await check_permission(msg.auth_user.id, thread.id)
});
}
return respond({
success: true,
threads: rows
});
};
module.exports = authwrap(list_threads);

View File

@ -2,7 +2,7 @@ const db = require('../db');
const authwrap = require('../authwrap'); const authwrap = require('../authwrap');
const check_permission = require('../check_permission'); const check_permission = require('../check_permission');
const send_message = async (msg, respond) => { async function send_message(msg, respond) {
if (!msg.thread) { if (!msg.thread) {
return respond({ return respond({
success: false, success: false,
@ -30,8 +30,7 @@ const send_message = async (msg, respond) => {
// get perms // get perms
const permissions = await db.query( const permissions = await db.query(
"select * from permission where thread = ? and type = 'everyone' and value = 'true' and permission = 'view'", "select * from permission where thread = ? and type = 'everyone' and value = 'true' and permission = 'view'",
[msg.thread] [msg.thread]);
);
for (let username in vybe.users) { for (let username in vybe.users) {
if (permissions.rows.length > 0 || members.includes(username)) { if (permissions.rows.length > 0 || members.includes(username)) {
for (let s of vybe.users[username].sockets) { for (let s of vybe.users[username].sockets) {
@ -48,6 +47,46 @@ const send_message = async (msg, respond) => {
success: true, success: true,
id: id.rows[0].id id: id.rows[0].id
}); });
}; }
module.exports = authwrap(send_message); async function get_history(msg, respond) {
if (msg.before && isNaN(Number(msg.before))) {
return respond({
success: false,
message: 'before must be a number'
});
}
if (!msg.thread) {
return respond({
success: false,
message: 'thread ID required'
});
}
if (!(await check_permission(msg.auth_user.id, msg.thread)).view) {
return respond({
success: false,
message: "you can't view this thread"
});
}
const messages = await db.query(
`select user.name, post.id, content from post
join user on post.user = user.id
${msg.before ? 'where post.id < ? and' : 'where'}
thread = ?
order by post.created desc
limit 101`,
msg.before ? [msg.before, msg.thread] : [msg.thread]
);
return respond({
success: true,
messages: messages.rows
.slice(0, 100)
.map((i) => ({ id: i.id, name: i.name, message: i.content })),
more: messages.rows.length > 100
});
}
module.exports = {
send_message: authwrap(send_message),
get_history: authwrap(get_history)
};

View File

@ -2,7 +2,30 @@ const db = require('../db');
const authwrap = require('../authwrap'); const authwrap = require('../authwrap');
const check_permission = require('../check_permission'); const check_permission = require('../check_permission');
const save_span = async (msg, respond, socket) => { async function get_space(msg, respond) {
if (!msg.thread) {
return respond({
success: false,
message: 'thread ID required'
});
}
if (!(await check_permission(msg.auth_user.id, msg.thread)).view) {
return respond({
success: false,
message: "you can't view this thread"
});
}
const spans = await db.query(
'select id, content, x, y, scale from span where thread = ? and deleted = false',
[msg.thread]
);
return respond({
success: true,
spans: spans.rows
});
}
async function save_span(msg, respond, socket) {
if (!msg.thread) { if (!msg.thread) {
return respond({ return respond({
success: false, success: false,
@ -62,6 +85,9 @@ const save_span = async (msg, respond, socket) => {
success: true, success: true,
id id
}); });
}; }
module.exports = authwrap(save_span); module.exports = {
get_space: authwrap(get_space),
save_span: authwrap(save_span)
};

132
src/events/stream.js Normal file
View File

@ -0,0 +1,132 @@
const db = require('../db');
const authwrap = require('../authwrap');
const check_permission = require('../check_permission');
let newstreamid = 0;
async function stream(msg, respond, socket) {
if (!(await check_permission(msg.auth_user.id, msg.thread)).post) {
return respond({
success: false,
message: "user doesn't have permission"
});
}
let stream;
let thread = vybe.threads[msg.thread] || (vybe.threads[msg.thread] = {
streams: []
});
async function send() {
const permissions = await db.query(
"select * from permission where thread = ? and type = 'everyone' and value = 'true' and permission = 'view'",
[msg.thread]);
if (permissions.rows.length) {
for (let user in vybe.users) {
user = vybe.users[user];
for (let socket of user.sockets)
socket.emit('stream', stream);
}
} else {
const members = (
await db.query(
'select name from user join member on member.user = user.id where member.thread = ?',
[msg.thread]
)
).rows.map((i) => i.name);
for (let member of members) {
let user = vybe.users[member];
if (user)
for (let socket of user.sockets)
socket.emit('stream', stream);
}
}
}
if (msg.id) {
stream = vybe.streams[msg.id];
if (!stream)
return respond({
success: false,
message: 'stream not found'
});
if (msg.stop) {
stream.stop();
return respond({
success: true
});
}
stream = stream.stream;
stream.name = msg.name;
}
else {
stream = {
id: newstreamid++,
thread: msg.thread,
user: msg.auth_user.name,
name: msg.name
};
thread.streams.push(stream);
vybe.streams[stream.id] = {
stream,
listeners: {},
socket: socket.id,
stop: () => {
stream.stopped = true;
thread.streams.splice(thread.streams.findIndex(s => s.id === stream.id), 1);
delete vybe.streams[stream.id];
send();
}
};
}
await send();
respond({
success: true,
id: stream.id
});
}
async function streamdata(msg, respond) {
let stream = vybe.streams[msg.id];
if (!stream) {
return respond({
success: false,
message: 'stream not found'
});
}
if (stream.stream.user !== msg.auth_user.name) {
return respond({
success: false,
message: "stream doesn't belong to user"
});
}
for (let id in stream.listeners)
stream.listeners[id].emit('streamdata', msg);
respond({
success: true
});
}
async function play_stream(msg, respond, socket) {
if (!(await check_permission(msg.auth_user.id, msg.thread)).view) {
return respond({
success: false,
message: "user doesn't have permission"
});
}
if (!vybe.streams[msg.id])
return respond({
success: false,
message: 'stream not found'
});
if (msg.playing)
vybe.streams[msg.id].listeners[socket.id] = socket;
else
delete vybe.streams[msg.id].listeners[socket.id];
respond({
success: true
});
}
module.exports = {
stream: authwrap(stream),
streamdata: authwrap(streamdata),
play_stream: authwrap(play_stream)
};

377
src/events/thread.js Normal file
View File

@ -0,0 +1,377 @@
const db = require('../db');
const authwrap = require('../authwrap');
const check_permission = require('../check_permission');
async function create_thread(msg, respond) {
// validate inputs
if (typeof msg.name !== 'string') {
return respond({
success: false,
message: 'thread name required'
});
}
if (msg.name.length > 200) {
return respond({
success: false,
message: 'thread name 200 chars max'
});
}
// add to db
const insert = await db.query(
'insert into thread (name, creator) values (?, ?) returning id',
[msg.name, msg.auth_user.id]
);
const thread_id = insert.rows[0].id;
// set up permissions
if (!msg.permissions || !msg.permissions.view_limited) {
await db.query(
`insert into permission (thread, type, mutable, permission, value)
values (?, ?, ?, ?, ?)`,
[thread_id, 'everyone', true, 'view', 'true']
);
if (!msg.permissions || !msg.permissions.post_limited) {
await db.query(
`insert into permission (thread, type, mutable, permission, value)
values (?, ?, ?, ?, ?)`,
[thread_id, 'everyone', true, 'post', 'true']
);
} else {
await db.query(
`insert into permission (thread, type, mutable, permission, value)
values (?, ?, ?, ?, ?)`,
[thread_id, 'members', true, 'post', 'true']
);
}
} else {
await db.query(
`insert into permission (thread, type, mutable, permission, value)
values (?, ?, ?, ?, ?)`,
[thread_id, 'members', true, 'view', 'true']
);
await db.query(
`insert into permission (thread, type, mutable, permission, value)
values (?, ?, ?, ?, ?)`,
[thread_id, 'members', true, 'post', 'true']
);
}
// add members
if (Array.isArray(msg.members)) {
for (let member of msg.members) {
if (!member) continue;
// get user id
const id = await db.query('select id from user where name = ?', [
member.name,
]);
if (id.rows.length === 0) {
console.log('user not found: ' + member.name);
continue;
}
await db.query(
'insert into member (thread, user) values (?, ?)',
[thread_id, id.rows[0].id]
);
if (typeof member.permissions === 'object')
for (let permission in member.permissions)
await db.query(`
insert into permission (thread, type, user, mutable, permission, value)
values (?, ?, ?, ?, ?, ?)`,
[thread_id, 'user', id.rows[0].id, true, permission, member.permissions[permission]]);
}
}
if (!msg.permissions || !msg.permissions.view_limited) {
for (let username in vybe.users) {
for (let socket of vybe.users[username].sockets) {
socket.emit('thread', {
name: msg.name,
id: insert.rows[0].id,
permissions: {
is_member: false,
view: true,
post: !msg.permissions || !msg.permissions.post_limited
}
});
}
}
}
else if (Array.isArray(msg.members)) {
for (let member of msg.members) {
if (!vybe.users[member.name])
continue;
for (let socket of vybe.users[member.name].sockets) {
socket.emit('thread', {
name: msg.name,
id: insert.rows[0].id,
permissions: {
is_member: true,
view: true,
post: true
}
});
}
}
}
// respond
return respond({
success: true,
id: thread_id
});
}
async function list_threads(msg, respond) {
// this can be optimized by merging the permission check into here
let threads = await db.query(
`select name, id from thread
join permission on thread.id = permission.thread
left join member on thread.id = member.thread
where permission.permission = 'view'
and permission.value = 'true'
and ((permission.type = 'everyone') or
permission.type = 'members' and member.user = ?)
group by thread.id
order by thread.created desc`,
[msg.auth_user.id]
);
threads = await Promise.all(threads.rows.map(async thread => {
if (vybe.threads[thread.id])
Object.assign(thread, vybe.threads[thread.id]);
else
thread.streams = [];
thread.permissions = await check_permission(msg.auth_user.id, thread.id);
return thread;
}));
return respond({
success: true,
threads
});
}
async function get_thread(msg, respond) {
if (!msg.thread) {
return respond({
success: false,
message: 'thread ID required'
});
}
let perms = await check_permission(msg.auth_user.id, msg.thread);
if (!perms.view) {
return respond({
success: false,
message: "you can't view this thread"
});
}
let thread = await db.query(
`select thread.name, user.name as user, user.id from thread
left join member on thread.id = member.thread
left join user on user.id = member.user
where thread.id = ?`,
[msg.thread]
);
const permissions = await db.query(
`select type, user, permission, value, mutable
from permission where thread = ?`,
[msg.thread]
);
let members = Object.fromEntries(thread.rows.map(member =>
[member.id, { name: member.user }]
));
for (let permission of permissions.rows) {
const member = members[permission.user];
if (member)
(member.permissions || (member.permissions = {}))
[permission.permission] = {
value: permission.value,
mutable: permission.mutable
};
else
(perms[permission.type] || (perms[permission.type] = {}))
[permission.permission] = {
value: permission.value,
mutable: permission.mutable
};
}
function makeBool(type, permission, fallback) {
if (perms[type]) {
if (perms[type][permission])
perms[type][permission].value = perms[type][permission].value === 'true';
else
perms[type][permission] = { value: fallback, mutable: true };
} else
(perms[type] = {})[permission] = { value: fallback, mutable: true };
}
makeBool('everyone', 'view', false);
makeBool('everyone', 'post', false);
makeBool('members', 'view', true);
makeBool('members', 'post', true);
thread = {
id: msg.thread,
name: thread.rows[0].name,
permissions: perms,
members: Object.entries(members).map(member => ({
id: member[0],
...member[1]
}))
};
if (vybe.threads[thread.id])
Object.assign(thread, vybe.threads[thread.id]);
else
thread.streams = [];
return respond({
success: true,
thread
});
}
async function edit_thread(msg, respond) {
// validate inputs
if (!msg.id || typeof msg.name !== 'string') {
return respond({
success: false,
message: 'invalid msg'
});
}
if (msg.name.length > 200) {
return respond({
success: false,
message: 'thread name 200 chars max'
});
}
const perms = await check_permission(msg.auth_user.id, msg.id);
if (!perms.admin) {
return respond({
success: false,
message: "user doesn't have permission"
});
}
// update name
await db.query(
'update thread set name = ? where id = ?',
[msg.name, msg.id]
);
// update permissions
let permissions = {};
for (const p of (await db.query(
`select type, permission, value, mutable
from permission
where type != 'user' and thread = ?`,
[msg.id]
)).rows) {
(permissions[p.type] || (permissions[p.type] = {}))
[p.permission] = {
value: p.value,
mutable: p.mutable
};
}
async function setPermission(type, permission, value) {
if (permissions[type] && permissions[type][permission]) {
if (!permissions[type][permission].mutable) {
respond({
success: false,
message: 'permission not mutable'
});
return false;
}
if (permissions[type][permission].value !== value) {
await db.query(`
update permission set value = ?
where thread = ? and type = ? and permission = ?`,
[value, msg.id, type, permission]
);
permissions[type][permission].value = value;
}
}
else {
await db.query(
`insert into permission (thread, type, mutable, permission, value)
values (?, ?, ?, ?, ?)`,
[msg.id, type, true, permission, value]
);
(permissions[type] || (permissions[type] = {}))
[permission] = {
value,
mutable: true
};
}
return true;
}
if (!msg.permissions || !msg.permissions.view_limited) {
if (!await setPermission('everyone', 'view', 'true'))
return;
if (!msg.permissions || !msg.permissions.post_limited) {
if (!await setPermission('everyone', 'post', 'true'))
return;
}
else {
if (!await setPermission('members', 'post', 'true'))
return;
if (!await setPermission('everyone', 'post', 'false'))
return;
}
} else {
if (!await setPermission('members', 'view', 'true'))
return;
if (!await setPermission('members', 'post', 'true'))
return;
if (!await setPermission('everyone', 'view', 'false'))
return;
if (!await setPermission('everyone', 'post', 'false'))
return;
}
permissions.everyone.view.value = permissions.everyone.view.value === 'true';
permissions.everyone.post.value = permissions.everyone.post.value === 'true';
if (!permissions.members) permissions.members = {};
if (!permissions.members.view)
permissions.members.view = { value: 'true', mutable: true };
if (!permissions.members.post)
permissions.members.post = { value: 'true', mutable: true };
permissions.members.view.value = permissions.members.view.value === 'true';
permissions.members.post.value = permissions.members.post.value === 'true';
let members = Object.fromEntries((await db.query(
`select user.name, user.id from thread
join member on thread.id = member.thread
join user on user.id = member.user
where thread.id = ?`,
[msg.id]
)).rows.map(row => [row.name, row.id]));
if (!msg.permissions || !msg.permissions.view_limited) {
for (let username in vybe.users)
for (let socket of vybe.users[username].sockets)
socket.emit('thread', {
name: msg.name,
id: msg.id,
permissions: {
is_member: username in members,
view: true,
post: !msg.permissions || !msg.permissions.post_limited,
...permissions
}
});
}
else {
for (let member in members) {
if (!vybe.users[member])
continue;
for (let socket of vybe.users[member].sockets)
socket.emit('thread', {
name: msg.name,
id: msg.id,
permissions: {
is_member: true,
view: true,
post: true,
admin: member.id === msg.auth_user.id && perms.admin,
...permissions
}
});
}
}
return respond({
success: true
});
}
module.exports = {
create_thread: authwrap(create_thread),
list_threads: authwrap(list_threads),
get_thread: authwrap(get_thread),
edit_thread: authwrap(edit_thread)
};

View File

@ -1,7 +1,59 @@
const db = require('../db'); const db = require('../db');
const authwrap = require('../authwrap');
const openpgp = require('openpgp'); const openpgp = require('openpgp');
const authenticate = async (msg, respond, socket) => { async function create_user(msg, respond) {
// validate inputs
if (!msg.name) {
return respond({
success: false,
message: 'username required'
});
}
if (!msg.pubkey) {
return respond({
success: false,
message: 'public key required'
});
}
// ensure username is not taken
const result = await db.query('select * from user where name = ?', [
msg.name
]);
if (result.rows.length > 0) {
console.log(`username already exists: ${result}`);
return respond({
success: false,
message: 'a user with this name already exists on this server'
});
}
// validate public key
try {
await openpgp.readKey({ armoredKey: msg.pubkey });
} catch (err) {
console.err('error in create_user readkey: ' + err);
return respond({
success: false,
message: 'public key invalid'
});
}
// add to db
const insert = await db.query(
'insert into user (name) values (?) returning id',
[msg.name]
);
await db.query(
'insert into key (user, pubkey, active) values (?, ?, true)',
[insert.rows[0].id, msg.pubkey]
)
// respond
return respond({
success: true,
id: insert.rows[0].id
});
}
async function authenticate(msg, respond, socket) {
if (!msg.name || !msg.message) { if (!msg.name || !msg.message) {
return respond({ return respond({
success: false, success: false,
@ -91,6 +143,36 @@ const authenticate = async (msg, respond, socket) => {
message: 'message signature verification failed' message: 'message signature verification failed'
}); });
} }
}; }
module.exports = authenticate; async function get_keys(msg, respond) {
// validate inputs
if (!msg.names) {
return respond({
success: false,
message: 'user names required'
});
}
if (typeof msg.names !== 'object') {
return respond({
success: false,
message: "can't iterate user names"
});
}
const keys = await db.query( // todo: encryption !
`select name from user where name in
(${msg.names.map((i) => '?').join(',')})`,
msg.names
);
// respond
return respond({
success: true,
keys: keys.rows
});
}
module.exports = {
create_user,
authenticate,
get_keys: authwrap(get_keys)
};