federation !

main
jerl 2024-06-12 05:00:50 -05:00
parent f34099de9c
commit 02294b0327
31 changed files with 1199 additions and 5240 deletions

View File

@ -1,21 +1,40 @@
# vybe server
very very unfinished !! right now it can do a chatroom ## vybe
## usage vybe is a work-in-progress decentralized **communication network**.
a vybe instance (server) features user accounts and **threads**.
user accounts are owned by PGP keys stored in users' clients.
each thread features a chatroom, a **space**, and audio streams.
a space is like a text blackboard, basically. you can write text and move it around and resize it with your mouse.
once you have an account on any instance, you can connect to and interact with any other instance, right from within your home instance ui.
to run your own instance, clone this repo and:
> `npm install`
`npm install`
then then
`node index.js`
## clients > `node .`
there are two clients! you can choose one it will run on port 1312 by default; you can change this by setting PORT on first-run:
### vybeclient > `PORT=1234 node .`
standard official client, written w/ vanilla js. served on :3435 by index.js after first-run, the port is configured in instance.json.
### vyber currently, you'll need to proxy it through a webserver like nginx or Caddy, which needs to have https enabled for things to work correctly. if you want the easy option, i recommend Caddy. example Caddy config:
alternative client, developed by june, w/ webcomponents. start by running `npm start` in the vyber-client folder > ```
> vybe.my.domain {
> reverse_proxy localhost:1312
> }
> ```
then go to `https://vybe.my.domain` to start using vybe!
let me know if you have any questions or issues. my website is [jerl.zone](https://jerl.zone).

View File

@ -1,254 +1,5 @@
import { render, html } from '/uhtml.js'; import { render, html } from '/uhtml.js';
import loadMessages from '/message.js'; import loadThreads from '/thread.js';
import loadSpace from '/space.js';
import loadStreams from '/stream.js';
function setVisibility() {
document.getElementById('visibility').innerText = `${
window.currentThread.permissions.everyone.view.value ?
'this thread is visible to everyone' :
'members can view this thread'}
${window.currentThread.permissions.everyone.post.value ?
'anyone can post' :
window.currentThread.permissions.members.post.value ?
'only members can post' : 'some members can post'}`;
}
function chooseThread() {
const edit = document.getElementById('edit');
if (window.currentThread) {
document.getElementById(`thread${window.currentThread.id}`).classList.remove('active');
let editform = document.getElementById('editthread');
if (editform) {
editform.remove();
edit.textContent = 'edit';
}
if (window.currentThread.id === this.thread.id) {
document.getElementById('thread').classList.add('hidden');
window.currentThread = null;
return;
}
}
else
document.getElementById('thread').classList.remove('hidden');
if (this.thread.permissions.admin)
edit.classList.remove('hidden');
else
edit.classList.add('hidden');
document.getElementById('threadname').textContent = this.thread.name;
this.classList.add('active');
window.currentThread = this.thread;
loadStreams();
if (this.tab)
switchTab(document.getElementById(this.tab));
else // load first tab that has any content
loadMessages(true, messages => {
if (messages.length)
switchTab(document.getElementById(this.tab = 'messagetab'));
else
loadSpace(spans => {
if (spans.length)
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.currentThread = msg.thread;
setVisibility();
document.getElementById('memberlist').replaceChildren(
...msg.thread.members.map(member =>
html.node`<p class='member'>${member.name}</p>`)
);
});
}
function switchTab(tab) {
for (let tab of document.querySelectorAll('.tab'))
tab.classList.remove('active');
for (let tab of document.querySelectorAll('.tabcontent'))
tab.classList.add('hidden');
tab.classList.add('active');
document
.getElementById(tab.id.slice(0, -3))
.classList.remove('hidden');
if (tab.id === 'messagetab')
loadMessages(true);
else if (tab.id === 'spacetab')
loadSpace();
}
async function createThread(event) {
event.preventDefault();
let name = document.getElementById('newthreadname').value;
if (!name) {
document.getElementById('newnameempty').classList.remove('hidden');
return;
}
const perms = document.querySelector(
'input[name="newpermissions"]: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 };
// generate key
/* wip
var buf = new Uint8Array(32);
crypto.getRandomValues(buf);
const key = aesjs.utils.hex.fromBytes(Array.from(buf));
// sign it to each of the members
for (let i = 0; i < newmembers.length; i++) {
const member = newmembers[i];
const sig = await openpgp.encrypt({
message: await openpgp.createMessage({ text: key }),
signingKeys: window.keys.priv
});
}
*/
}
window.emit('create_thread',
{ name, permissions, members: window.threadmembers },
msg => {
chooseThread.call(document.getElementById('thread' + msg.id));
// since the form exists, this will perform cleanup
newThread();
document.getElementById('loadmore').classList.add('hidden');
}
);
}
function addMember() {
const name = document.getElementById('membername');
if (!name.value)
return;
window.threadmembers.push({ name: name.value });
document.getElementById('newmembers')
.appendChild(html.node`<p class='member'>${name.value}</p>`);
name.value = '';
}
function newThread() {
let form = document.getElementById('createthread');
if (form) {
form.remove();
document.getElementById('newthread').textContent = 'create';
return;
}
window.threadmembers = [{
name: window.name,
permissions: { admin: 'true' }
}];
document.querySelector('#home + .separator').insertAdjacentElement('afterend', html.node`
<form id='createthread' class='column' onsubmit=${createThread}>
<h3>create thread</h3>
<label for='newthreadname' class='heading'>thread name</label>
<p id='newnameempty' class='hidden'>name cannot be empty</p>
<input type='text' id='newthreadname' />
<p id='permissions'>thread permissions</p>
<input type='radio' name='newpermissions'
id='public' value='public' checked />
<label for='public'>anyone can view and post</label><br>
<input type='radio' name='newpermissions'
id='private_post' value='private_post' />
<label for='private_post'>anyone can view, only members can post</label><br>
<input type='radio' name='newpermissions'
id='private_view' value='private_view' />
<label for='private_view'>only members can view and post</label><br>
<label class='heading' for='membername'>members</label>
<input type='text' id='membername' placeholder='username' onkeydown=${event => {
if (event.key === 'Enter') {
event.preventDefault();
addMember();
}
}} />
<button id='addmember' type='button' onclick=${addMember}>add</button>
<div id='newmembers'>
<p class='member'>${window.name}</p>
</div>
<br>
<button id='submitthread' type='submit'>create</button>
</form>`);
document.getElementById('newthread').textContent = 'cancel';
}
async function saveThread(event) {
event.preventDefault();
let name = document.getElementById('editthreadname').value;
if (!name) {
document.getElementById('nameempty').classList.remove('hidden');
return;
}
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 };
// todo: generate key and encrypt
}
window.emit('edit_thread',
{ id: window.currentThread.id, name, permissions },
msg => {
if (!msg.success) {
console.log('edit_thread failed: ', msg.message);
return;
}
editThread();
});
}
function editThread() {
let form = document.getElementById('editthread');
if (form) {
form.remove();
document.getElementById('edit').textContent = 'edit';
return;
}
form = html.node`
<form id='editthread' class='column' onsubmit=${saveThread}>
<label for='editthreadname' class='heading'>thread name</label>
<input type='text' id='editthreadname' />
<p id='nameempty' class='hidden'>name cannot be empty</p>
<p id='permissions'>thread permissions</p>
<input type='radio' name='permissions'
id='public' value='public' />
<label for='public'>anyone can view and post</label><br>
<input type='radio' name='permissions'
id='private_post' value='private_post' />
<label for='private_post'>anyone can view, only members can post</label><br>
<input type='radio' name='permissions'
id='private_view' value='private_view' />
<label for='private_view'>only members can view and post</label><br>
<br>
<button id='savethread'>save</button>
</form>`;
form['editthreadname'].value = window.currentThread.name;
if (window.currentThread.permissions.everyone.post.value)
form['public'].checked = true;
else if (window.currentThread.permissions.everyone.view.value)
form['private_post'].checked = true;
else
form['private_view'].checked = true;
document.querySelector('#thread').append(form);
document.getElementById('edit').textContent = 'cancel';
}
function clickedTab(event) {
switchTab(event.target);
document.getElementById(`thread${window.currentThread.id}`).tab = event.target.id;
}
function changeName(e) { function changeName(e) {
let displayname = document.getElementById('newname').value; let displayname = document.getElementById('newname').value;
@ -265,14 +16,140 @@ function changeName(e) {
}) })
} }
window.connectInstance = async url => {
let instance = window.instances[url];
let connected;
function connecting(resolve, reject) {
instance.socket.on('connect', resolve);
instance.socket.on('connect_error', error => {
console.log(`error connecting to ${url}: ${error}`);
if (connected)
return;
instance.socket.disconnect();
reject(error.message);
});
}
if (instance) {
if (!instance.socket?.connected) {
if (instance.socket)
instance.socket.connect();
else
instance.socket = io(url);
await new Promise(connecting);
}
}
else {
instance = window.instances[url] = {
socket: io(url),
url
};
await new Promise(connecting);
}
connected = true;
return instance;
};
window.getUser = async (id, name) => {
let instance = window.instancelist[0];
if (id) {
id = id.split('@');
instance = await connectInstance(id[1]);
id = id[0];
}
return new Promise((resolve, reject) =>
instance.socket.emit('get_user', { id, name }, msg => {
if (!msg.success)
return reject('get_user failed: ' + msg.message);
resolve(msg.user);
})
);
};
async function authenticateInstance(div, select) {
return new Promise((resolve, reject) =>
div.instance.socket.emit('authenticate', {
name: window.name,
id: window.id + '@' + location.host,
message: window.signedMessage,
pubkey: window.keys.armored.publicKey
}, msg => {
if (!msg.success) {
reject(`${div.instance.url} authentication failed: ${msg.message}`);
return;
}
div.instance.id = msg.instance.id;
div.id = 'instance' + msg.instance.id;
div.instance.emit = (event, data, callback) =>
div.instance.socket.emit(event, {
...data,
__session: window.session,
}, callback);
loadThreads(div, select);
resolve();
}));
}
function expandInstance(event) {
this.parentElement.parentElement.children['threads'].classList.toggle('hidden');
this.children[0].classList.toggle('collapsed');
}
function instanceClicked(event) {}
function saveInstances() {
localStorage.setItem('instances', JSON.stringify(
window.instancelist.map(i => ({
id: i.id,
url: i.url
}))
));
}
async function addInstance() {
this.textContent = this.textContent.trim().toLowerCase();
let instancediv = this.parentElement.parentElement;
if (!this.textContent)
return instancediv.remove();
let url = (/^[\w-]+:.+/.test(this.textContent) ? '' : 'https://') + this.textContent;
if (window.instancelist.find(i => i.url === url))
return instancediv.remove();
let instance = await connectInstance(url);
instancediv.instance = instance;
await authenticateInstance(instancediv, true);
this.contentEditable = false;
/* expander is initially just a span with a non-space space
in it to make the title span style work */
let expander = this.parentElement.children[0];
expander.classList.add('expander');
expander.onclick = expandInstance;
expander.innerHTML = `<span class='arrow'>◿</span>`;
window.instancelist.push(instance);
saveInstances();
this.onclick = instanceClicked;
}
// main app html // main app html
document.body.append(html.node` document.body.append(html.node`
<div id='home' class='column'> <div id='home' class='column'>
<div id='threads'> <div id='instancelist'>
<h3>vybe</h3> <h3>vybe</h3>
<h4>threads</h4> <p id='instances'>instances:<button onclick=${() => {
<div id='threadlist'>loading...</div> let div = html.node`
<button id='newthread' onclick=${newThread}>create</button> <div>
<div class='instance'>
<span></span>
<span onblur=${addInstance} onkeydown=${function(event) {
if (event.key === 'Enter') {
event.preventDefault();
addInstance.call(this);
}
}} class='title' contenteditable='true'>
</span>
</div>
</div>`;
document.getElementById('instancelist').append(div);
div.children[0].children[1].focus();
}}>add</button></p>
</div> </div>
<div id='user' onclick=${() => <div id='user' onclick=${() =>
document.getElementById('profile').classList.toggle('hidden') document.getElementById('profile').classList.toggle('hidden')
@ -282,6 +159,7 @@ document.body.append(html.node`
<!-- create thread column goes here --> <!-- create thread column goes here -->
<hr class='separator' color='#505050'> <hr class='separator' color='#505050'>
<div id='profile' class='column hidden'> <div id='profile' class='column hidden'>
<p><strong>profile</strong></p>
<label class='heading'>display name</label> <label class='heading'>display name</label>
<input id='newname' onkeyup=${function(event) { <input id='newname' onkeyup=${function(event) {
if (window.displayname === this.value) if (window.displayname === this.value)
@ -296,63 +174,31 @@ document.body.append(html.node`
<div id='authrequests'></div> <div id='authrequests'></div>
</div> </div>
<hr class='separator' color='#505050'> <hr class='separator' color='#505050'>
<div id='thread' class='column'>
<div id='content'>
<div id='titlebar'>
<span id='title'>thread: <strong id='threadname'>meow</strong></span>
<button id='edit' class='hidden' onclick=${editThread}>edit</button>
</div>
<div id='buttonbar'>
<div id='tabs'>
<button id='messagetab' class='tab active' onclick=${clickedTab}>messages
</button><button id='spacetab' class='tab' onclick=${clickedTab}>space
</button><button id='streamtab' class='tab' onclick=${clickedTab}>streams</button>
</div>
<button id='showmembers' onclick=${() =>
document.getElementById('members').classList.toggle('hidden')
}>members</button>
</div>
<div id='message' class='tabcontent'></div>
<div id='space' class='tabcontent hidden'></div>
<div id='stream' class='tabcontent hidden'></div>
</div>
<hr class='separator' color='#505050'>
<div id='members' class='hidden'>
<p id='visibility'></p>
<p><strong>members</strong></p>
<div id='memberlist'>
</div>
</div>
<hr class='separator' color='#505050'>
</div>
`); `);
document.getElementById('newname').value = window.displayname; for (let i = 0; i < instancelist.length; ++i) {
let instance = instancelist[i];
function makeThread(thread) { let div = html.node`
let node = html.node` <div>
<div class='thread' onclick=${chooseThread}>${ <div class='instance'>
thread.name <span class='expander' onclick=${expandInstance}>
}</div>`; <span class='arrow'></span>
node.id = 'thread' + thread.id; </span>
node.thread = thread; <span class='title' onclick=${instanceClicked}>${instance.url}</span>
return node; </div>
</div>`;
div.id = 'instance' + instance.id;
div.instance = instance;
document.getElementById('instancelist').append(div);
if (i === 0)
loadThreads(div, true);
else {
await connectInstance(instance.url);
authenticateInstance(div);
}
} }
window.socket.on('thread', thread => { document.getElementById('newname').value = window.displayname;
let el = document.getElementById('thread' + thread.id);
if (el) {
el.thread = thread;
el.textContent = thread.name;
if (window.currentThread?.id === thread.id) {
Object.assign(window.currentThread, thread);
document.getElementById('threadname').textContent = thread.name;
setVisibility();
}
return;
}
document.getElementById('threadlist').prepend(makeThread(thread));
});
function authRequest(authrequest) { function authRequest(authrequest) {
const p = html.node` const p = html.node`
@ -374,12 +220,6 @@ function authRequest(authrequest) {
window.socket.on('authrequest', authRequest); window.socket.on('authrequest', authRequest);
window.emit('list_threads', {}, msg => {
const threadlist = document.getElementById('threadlist');
threadlist.replaceChildren(...msg.threads.map(makeThread));
chooseThread.call(threadlist.firstChild);
});
export { export {
authRequest authRequest
}; };

View File

@ -12,33 +12,59 @@ function rand() {
} }
async function auth() { async function auth() {
const sig = await openpgp.sign({ window.session = rand();
window.emit = (event, data, callback) =>
window.socket.emit(event, {
...data,
__session: window.session,
}, callback);
window.signedMessage = await openpgp.sign({
message: new openpgp.CleartextMessage('vybe_auth ' + window.session, ''), message: new openpgp.CleartextMessage('vybe_auth ' + window.session, ''),
signingKeys: window.keys.priv signingKeys: window.keys.priv
}); });
window.socket.emit(
'authenticate', window.socket.emit('authenticate', {
{
name: window.name, name: window.name,
message: sig, id: `${window.id || ''}@${location.host}`,
message: signedMessage,
pubkey: window.keys.armored.publicKey pubkey: window.keys.armored.publicKey
}, },
async msg => { async msg => {
let register = document.getElementById('register'); let register = document.getElementById('register');
if (!msg.success) { if (!msg.success) {
console.log('authenticate failed', msg); console.log('authenticate failed:', msg.message);
document.getElementById('result').innerText = msg.message; document.getElementById('result').innerText = msg.message;
register.classList.remove('hidden'); register.classList.remove('hidden');
return; return;
} }
localStorage.setItem('keys', JSON.stringify(window.keys.armored)); localStorage.setItem('keys', JSON.stringify(window.keys.armored));
localStorage.setItem('name', window.name); localStorage.setItem('name', window.name = msg.name);
localStorage.setItem('id', window.id = msg.id);
register.classList.add('hidden'); register.classList.add('hidden');
window.displayname = msg.displayname; window.displayname = msg.displayname;
if (window.instancelist) {
if (window.instancelist[0].url !== location.host
|| window.instancelist[0].id !== msg.instance.id) {
console.log('instance url or id changed ??');
return;
}
}
else {
localStorage.setItem('instances', JSON.stringify(window.instancelist = [
{
id: msg.instance.id,
url: location.host
}
]));
}
instancelist[0].socket = window.socket;
instancelist[0].emit = window.emit;
window.instances = Object.fromEntries(instancelist.map(i => [i.url, i]));
const { authRequest } = await import('/app.js'); const { authRequest } = await import('/app.js');
msg.authrequests.forEach(authRequest); msg.authrequests.forEach(authRequest);
} });
);
} }
async function submit(event) { async function submit(event) {
@ -54,7 +80,7 @@ async function submit(event) {
window.keys = { priv, pub, armored: keys }; window.keys = { priv, pub, armored: keys };
window.name = name; window.name = name;
if (this.id === 'registerform') { if (this.id === 'registerform') {
window.emit('create_user', window.socket.emit('create_user',
{ name, pubkey: keys.publicKey }, { name, pubkey: keys.publicKey },
(msg) => { (msg) => {
if (!msg.success) { if (!msg.success) {
@ -96,28 +122,14 @@ render(document.body, html`
</div> </div>
`); `);
function gensession() {
window.session = rand();
window.emit = (type, data, callback) =>
window.socket.emit(
type,
{
...data,
__session: window.session,
},
callback
);
}
window.onload = async () => { window.onload = async () => {
window.socket = io(); window.socket = io();
gensession();
let keys = localStorage.getItem('keys'); let keys = localStorage.getItem('keys');
if (keys) { if (keys) {
window.name = localStorage.getItem('name'); window.name = localStorage.getItem('name');
window.id = localStorage.getItem('id');
window.instancelist = JSON.parse(localStorage.getItem('instances'));
keys = JSON.parse(keys); keys = JSON.parse(keys);
window.keys = { window.keys = {
priv: await openpgp.readKey({ armoredKey: keys.privateKey }), priv: await openpgp.readKey({ armoredKey: keys.privateKey }),
@ -130,7 +142,6 @@ window.onload = async () => {
document.getElementById('register').classList.remove('hidden'); document.getElementById('register').classList.remove('hidden');
window.socket.io.on('reconnect', async attempt => { window.socket.io.on('reconnect', async attempt => {
gensession();
if (window.keys) if (window.keys)
await auth(); await auth();
}); });

View File

@ -55,7 +55,7 @@
outline: none; outline: none;
border: 1px solid #444; border: 1px solid #444;
&:focus { &:focus {
padding-bottom: 3px; padding-bottom: 2px;
border-bottom: 3px solid #777; border-bottom: 3px solid #777;
} }
&::placeholder { &::placeholder {
@ -70,20 +70,21 @@
margin-inline: 14px; margin-inline: 14px;
max-width: 800px; max-width: 800px;
} }
:not(#register) > p {
margin: 5px 1px;
}
.thread:hover, .thread:hover,
.tab:hover, .tab:hover,
.instance > span:hover,
#user:hover { #user:hover {
background-color: #303030; background-color: #333;
} }
.tab.active,
.thread.active, .thread.active,
.tab.active,
button:hover { button:hover {
background-color: #4f4f4f; background-color: #4f4f4f;
color: #fff; color: #fff;
} }
p {
margin: 5px 1px;
}
label.heading { label.heading {
margin: 10px 1px 4px; margin: 10px 1px 4px;
display: block; display: block;
@ -109,13 +110,47 @@
.separator:last-child { .separator:last-child {
display: none; display: none;
} }
.instance {
margin: 2px;
> span {
display: table-cell;
background-color: #222;
}
> .title {
padding-block: 6px;
width: 100%;
}
}
.expander {
padding-inline: 6px;
}
.arrow {
display: block;
position: relative;
top: -1px;
rotate: 45deg;
&.collapsed {
rotate: -45deg;
top: 0;
left: -3px;
}
}
#home { #home {
margin: 0;
max-width: 250px; max-width: 250px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
} }
#instancelist > :not(div) {
margin: 3px;
}
#instances {
display: flex;
justify-content: space-between;
}
#threads { #threads {
margin: 0 2px 6px;
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -124,8 +159,9 @@
overflow: auto; overflow: auto;
} }
#user { #user {
margin: 2px;
padding: 6px; padding: 6px;
background-color: #191919; background-color: #222;
} }
#profile { #profile {
max-width: 250px; max-width: 250px;

View File

@ -2,11 +2,11 @@ import { render, html } from '/uhtml.js';
let msg; let msg;
function sendMessage(e) { function sendMessage(event) {
e.preventDefault(); event.preventDefault();
if (!msg.value) if (!msg.value)
return; return;
window.emit('send_message', { window.currentInstance.emit('send_message', {
message: msg.value, message: msg.value,
thread: window.currentThread.id thread: window.currentThread.id
}); });
@ -15,23 +15,9 @@ function sendMessage(e) {
let earliestMessage; let earliestMessage;
window.socket.on('new_message', message => { function loadMessages(callback) {
if (message.thread !== window.currentThread?.id) let instance = window.currentInstance;
return;
const messages = document.getElementById('messages');
let scroll = messages.scrollHeight - messages.scrollTop <= messages.clientHeight;
messages.appendChild(html.node`
<div class='message'>
<strong>${message.name}: </strong>
${message.content}
</div>`);
if (scroll)
messages.scroll(0, messages.scrollHeight - messages.clientHeight);
if (!earliestMessage)
earliestMessage = message.id;
});
function loadMessages(firstRender, callback) {
if (!msg) { if (!msg) {
render(document.getElementById('message'), html` render(document.getElementById('message'), html`
<button id='loadmore' class='hidden' onclick=${loadMessages}> <button id='loadmore' class='hidden' onclick=${loadMessages}>
@ -54,32 +40,62 @@ function loadMessages(firstRender, callback) {
else else
document.getElementById('msginput').classList.add('hidden'); document.getElementById('msginput').classList.add('hidden');
} }
window.emit('get_history', { instance.emit('get_history', {
before: earliestMessage, before: earliestMessage,
thread: window.currentThread.id thread: window.currentThread.id
}, msg => { }, async msg => {
if (!msg.success) { if (!msg.success) {
console.log('get_history failed: ' + msg.message); console.log('get_history failed: ' + msg.message);
return; return;
} }
callback && callback(msg.messages); callback && callback(msg.messages);
if (firstRender && messages.hasChildNodes())
return;
if (msg.messages.length > 0) { if (msg.messages.length > 0) {
earliestMessage = msg.messages[msg.messages.length - 1].id; earliestMessage = msg.messages[msg.messages.length - 1].id;
for (let message of msg.messages) let users = {};
for (let message of msg.messages) {
if (!message.name) {
try {
message.name = (users[message.userid] || (
users[message.userid] = await window.getUser(message.userid)
)).displayname;
}
catch (e) {
console.log(`error getting user ${message.userid}`, e);
message.name = message.userid;
}
}
messages.prepend(html.node` messages.prepend(html.node`
<div class='message'> <div class='message'>
<strong>${message.name}: </strong> <strong>${message.name}: </strong>
${message.content} ${message.content}
</div>`); </div>`);
} }
}
if (msg.more) if (msg.more)
document.getElementById('loadmore').classList.remove('hidden'); document.getElementById('loadmore').classList.remove('hidden');
else else
document.getElementById('loadmore').classList.add('hidden'); document.getElementById('loadmore').classList.add('hidden');
messages.scroll(0, messages.scrollHeight - messages.clientHeight); messages.scroll(0, messages.scrollHeight - messages.clientHeight);
}); });
if (!instance.messaged) {
instance.socket.on('new_message', message => {
if (message.thread !== window.currentThread?.id || instance !== window.currentInstance)
return;
const messages = document.getElementById('messages');
let scroll = messages.scrollTop + 10 >= messages.scrollHeight - messages.clientHeight;
messages.appendChild(html.node`
<div class='message'>
<strong>${message.name}: </strong>
${message.content}
</div>`);
if (scroll)
messages.scroll(0, messages.scrollHeight - messages.clientHeight);
if (!earliestMessage)
earliestMessage = message.id;
});
instance.messaged = true;
}
} }
export default loadMessages; export default loadMessages;

View File

@ -1,7 +1,6 @@
let space; let space;
let spaceId;
let scale = 1; let scale = 1; // todo: make zooming work
let editing; let editing;
let dragging; let dragging;
@ -26,8 +25,8 @@ function save(span) {
return; return;
} }
saving = true; saving = true;
window.emit('save_span', { window.currentInstance.emit('save_span', {
thread: spaceId, thread: window.currentThread.id,
id: span.id ? span.id.slice(4) : '', id: span.id ? span.id.slice(4) : '',
content: span.innerText, content: span.innerText,
x: span.style.left.slice(0, -2), x: span.style.left.slice(0, -2),
@ -71,8 +70,9 @@ function add(s) {
span.onblur = function(event) { span.onblur = function(event) {
if (this.innerText) if (this.innerText)
return; return;
save(this);
this.remove(); this.remove();
if (this.id)
save(this);
}; };
span.onwheel = function(event) { span.onwheel = function(event) {
event.preventDefault(); event.preventDefault();
@ -107,23 +107,10 @@ function add(s) {
return span; return span;
} }
window.socket.on('span', msg => {
if (msg.thread !== spaceId)
return;
let span = document.getElementById('span' + msg.id);
if (span) {
span.innerText = msg.content;
span.x = msg.x;
span.y = msg.y;
span.scale = msg.scale;
span.style.transform = `translate(-50%, -50%) scale(${msg.scale})`;
}
else
add(msg);
});
export default function loadSpace(callback) { export default function loadSpace(callback) {
if (!space) { let instance = window.currentInstance;
if (!instance.spaceid) {
space = document.getElementById('space'); space = document.getElementById('space');
space.onmouseup = event => { space.onmouseup = event => {
if (dragging) { if (dragging) {
@ -143,18 +130,34 @@ export default function loadSpace(callback) {
}); });
editing.focus(); editing.focus();
}; };
}
if (spaceId === window.window.currentThread.id) instance.socket.on('span', msg => {
if (msg.thread !== instance.spaceid || window.currentInstance !== instance)
return; return;
spaceId = window.window.currentThread.id; let span = document.getElementById('span' + msg.id);
if (span) {
span.innerText = msg.content;
span.style.left = `${msg.x}px`;
span.style.top = `${msg.y}px`;
span.scale = msg.scale;
span.style.transform = `translate(-50%, -50%) scale(${msg.scale})`;
}
else
add(msg);
});
}
else if (instance.spaceid === window.currentThread.id)
return;
instance.spaceid = window.currentThread.id;
space.innerHTML = ''; space.innerHTML = '';
window.emit('get_space', { thread: window.window.currentThread.id }, msg => { instance.emit('get_space', {
thread: window.currentThread.id
}, msg => {
if (!msg.success) { if (!msg.success) {
console.log('get space failed: ' + msg.message); console.log('get space failed: ' + msg.message);
return; return;
} }
callback && callback(msg.spans); callback && callback(msg.spans);
for (const span of msg.spans) msg.spans.forEach(add);
add(span);
}); });
}; };

View File

@ -1,20 +1,17 @@
import { render, html } from '/uhtml.js'; import { render, html } from '/uhtml.js';
let streamid;
let handle;
let mediaStream; let mediaStream;
let recorder;
async function stream() { async function stream() {
if (handle) { let thread = window.currentThread;
clearInterval(handle); if (thread.handle) {
handle = null; clearInterval(thread.handle);
if (recorder.state === 'recording') delete thread.handle;
recorder.stop(); if (thread.recorder.state === 'recording')
window.emit('stream', { thread.recorder.stop();
id: streamid, thread.instance.emit('stream', {
thread: window.currentThread.id, id: thread.streamid,
thread: thread.id,
stop: true stop: true
}); });
document.getElementById('streaming').innerText = 'start streaming'; document.getElementById('streaming').innerText = 'start streaming';
@ -30,21 +27,22 @@ async function stream() {
sampleSize: 16 sampleSize: 16
} }
}); });
if (!mediaStream) if (!mediaStream) {
console.log("couldn't get media stream");
return; return;
window.emit('stream', { }
thread: window.currentThread.id, thread.instance.emit('stream', {
thread: thread.id,
name: document.getElementById('streamname').value name: document.getElementById('streamname').value
}, msg => { }, msg => {
if (!msg.success) { if (!msg.success) {
console.log('stream failed: ', msg.message); console.log('stream failed:', msg.message);
return; return;
} }
streamid = msg.id; thread.streamid = msg.id;
document.getElementById('streaming').innerText = 'stop streaming'; document.getElementById('streaming').innerText = 'stop streaming';
});
function record() { function record() {
let r = recorder = new MediaRecorder(mediaStream); let r = thread.recorder = new MediaRecorder(mediaStream);
let chunks = []; let chunks = [];
r.ondataavailable = event => { r.ondataavailable = event => {
if (!event.data.size) if (!event.data.size)
@ -52,15 +50,15 @@ async function stream() {
chunks.push(event.data); chunks.push(event.data);
}; };
r.onstop = async () => { r.onstop = async () => {
if (!chunks.length || !handle) if (!chunks.length || !thread.handle)
return; return;
//console.log(`${Date.now()} ${chunks.length}`); //console.log(`${Date.now()} ${chunks.length}`);
window.emit('streamdata', { thread.instance.emit('streamdata', {
id: streamid, id: thread.streamid,
audio: await (new Blob(chunks, { type: chunks[0].type })).arrayBuffer() audio: await (new Blob(chunks, { type: chunks[0].type })).arrayBuffer()
}, msg => { }, msg => {
if (!msg.success) if (!msg.success)
console.log('streamdata failed: ', msg.message); console.log('streamdata failed:', msg.message);
}); });
}; };
r.onstart = () => { r.onstart = () => {
@ -69,29 +67,54 @@ async function stream() {
r.start(); r.start();
} }
record(); record();
handle = setInterval(record, 500); thread.handle = setInterval(record, 500);
});
} }
let audioctx; let audioctx;
let streaming = {};
function addStream(stream) { function loadStreams() {
let instance = window.currentInstance;
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}>
${window.currentThread.handle ? 'stop' : 'start'} streaming
</button>
<span>stream name:</span>
<input id='streamname' oninput=${function(event) {
if (window.currentThread.handle)
instance.emit('stream', {
id: window.currentThread.streamid,
thread: window.currentThread.id,
name: this.value
});
}}>`);
}
div.insertAdjacentHTML('beforeend', `
<p>streams:</p>
<div id='streams'></div>`);
function addStream(stream) {
let p = html.node` let p = html.node`
<p> <p>
<button id='play' onclick=${e => { <button id='play' onclick=${e => {
if (stream.playing) { if (stream.playing) {
audioctx.suspend(); audioctx.suspend();
delete streaming[stream.id]; delete instance.streaming[stream.id];
stream.playing = false; stream.playing = false;
e.target.innerText = '▶'; e.target.innerText = '▶';
} }
else { else {
audioctx = new AudioContext(); audioctx = new AudioContext();
streaming[stream.id] = stream; instance.streaming[stream.id] = stream;
stream.playing = true; stream.playing = true;
e.target.innerText = '⏹'; e.target.innerText = '⏹';
} }
window.emit('play_stream', { instance.emit('play_stream', {
id: stream.id, id: stream.id,
thread: window.currentThread.id, thread: window.currentThread.id,
playing: stream.playing playing: stream.playing
@ -99,63 +122,53 @@ function addStream(stream) {
if (!msg.success) if (!msg.success)
console.log('play stream failed: ', msg.message); console.log('play stream failed: ', msg.message);
}); });
}}></button> }}>${instance.streaming[stream.id] ? '⏹' : '▶'}</button>
${stream.user}<span id='name'>${stream.name ? ` - ${stream.name}` : ''}</span> ${stream.user}<span id='name'>${stream.name ? ` - ${stream.name}` : ''}</span>
</p>`; </p>`;
p.id = 'stream' + stream.id; p.id = 'stream' + stream.id;
document.getElementById('streams').append(p); 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' oninput=${event => {
if (handle)
window.emit('stream', {
id: streamid,
thread: window.currentThread.id,
name: event.target.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 (!instance.streaming) {
if (msg.thread !== window.currentThread?.id) instance.streaming = {};
return;
instance.socket.on('stream', async msg => {
let streams = document.querySelector(
`#instance${instance.id} > #threads > #threadlist > #thread${msg.thread}`)
.thread.streams;
let i = streams.findIndex(s => s.id === msg.id);
let p = document.getElementById('stream' + msg.id); let p = document.getElementById('stream' + msg.id);
if (p) {
if (msg.stopped) { if (msg.stopped) {
if (i !== -1) {
streams.splice(i, 1);
if (msg.thread === window.currentThread?.id)
p.remove(); 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) { else if (i === -1) {
window.currentThread.streams.push(msg); streams.push(msg);
if (msg.thread === window.currentThread?.id)
addStream(msg); addStream(msg);
} }
}); else {
streams[i].name = msg.name;
if (msg.thread === window.currentThread?.id)
p.children['name'].innerText = msg.name ? ' - ' + msg.name : '';
}
});
window.socket.on('streamdata', async msg => { instance.socket.on('streamdata', async msg => {
if (!streaming[msg.id]) if (!instance.streaming[msg.id])
return; return;
let source = audioctx.createBufferSource(); let source = audioctx.createBufferSource();
source.buffer = await audioctx.decodeAudioData(msg.audio); source.buffer = await audioctx.decodeAudioData(msg.audio);
source.connect(audioctx.destination); source.connect(audioctx.destination);
source.start(/*audioStartTime*/); source.start(/*audioStartTime*/);
}); });
}
for (let stream of window.currentThread.streams)
addStream(stream);
}
export default loadStreams; export default loadStreams;

353
client/thread.js Normal file
View File

@ -0,0 +1,353 @@
import { render, html } from '/uhtml.js';
import loadMessages from '/message.js';
import loadSpace from '/space.js';
import loadStreams from '/stream.js';
function setVisibility() {
document.getElementById('visibility').innerText = `${
window.currentThread.permissions.everyone.view.value ?
'this thread is visible to everyone' :
'members can view this thread'}
${window.currentThread.permissions.everyone.post.value ?
'anyone can post' :
window.currentThread.permissions.members.post.value ?
'only members can post' : 'some members can post'}`;
}
function chooseThread() {
const edit = document.getElementById('edit');
let thread = this.thread;
if (window.currentThread) {
window.currentThread.div.classList.remove('active');
let editform = document.getElementById('editthread');
if (editform) {
editform.remove();
edit.textContent = 'edit';
}
if (window.currentThread.id === thread.id) {
document.getElementById('thread').classList.add('hidden');
window.currentThread = null;
return;
}
}
else
document.getElementById('thread').classList.remove('hidden');
if (thread.permissions.admin)
edit.classList.remove('hidden');
else
edit.classList.add('hidden');
document.getElementById('threadname').textContent = thread.name;
this.classList.add('active');
window.currentThread = thread;
window.currentInstance = thread.instance;
if (this.tab) {
switchTab(document.getElementById(this.tab));
if (this.tab === 'messagetab')
loadMessages();
else if (this.tab === 'spacetab')
loadSpace();
}
else // load first tab that has any content
loadMessages(messages => {
if (messages.length)
switchTab(document.getElementById(this.tab = 'messagetab'));
else
loadSpace(spans => {
if (spans.length)
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.currentInstance.emit('get_thread', { thread: thread.id }, async msg => {
if (!msg.success) {
console.log('get_thread failed:', msg.message);
return;
}
Object.assign(thread, msg.thread);
loadStreams();
setVisibility();
document.getElementById('memberlist').replaceChildren(
...await Promise.all(msg.thread.members.map(async member =>
html.node`<p class='member'>${
member.name || (await window.getUser(member.id)).name
}</p>`))
);
});
}
function switchTab(tab) {
document.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active'));
document.querySelectorAll('.tabcontent').forEach(tab => tab.classList.add('hidden'));
tab.classList.add('active');
document.getElementById(tab.id.slice(0, -3)).classList.remove('hidden');
}
function newThread() {
let form = document.getElementById('createthread');
if (form) {
form.remove();
for (let create of document.querySelectorAll('#newthread')) {
create.textContent = 'create';
create.classList.remove('hidden');
}
return;
}
const instancediv = this.parentElement.parentElement;
let members = [{
id: String(window.id),
name: window.name,
permissions: { admin: 'true' }
}];
if (instancediv.instance !== window.instancelist[0]) {
members[0].id += '@' + location.host;
members[0].name += '@' + location.host;
}
async function addMember() {
const name = document.getElementById('membername');
if (!name.value)
return;
let at = name.value.split('@');
let url = at[1] || instancediv.instance.url;
let user = await window.getUser('@' + url, at[0]);
if (instancediv.instance.url !== url) {
user.id += '@' + url;
user.name += '@' + url;
}
if (members.find(member => member.name === user.value))
return;
members.push(user);
document.getElementById('newmembers')
.append(html.node`<p class='member'>${user.name}</p>`);
name.value = '';
}
document.querySelector('#home + .separator').insertAdjacentElement('afterend', html.node`
<form id='createthread' class='column' onsubmit=${event => {
event.preventDefault();
let name = document.getElementById('newthreadname').value;
if (!name) {
document.getElementById('newnameempty').classList.remove('hidden');
return;
}
const perms = document.querySelector(
'input[name="newpermissions"]: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 };
// todo: generate key and encrypt
}
instancediv.instance.emit('create_thread',
{ name, permissions, members },
msg => {
if (!msg.success) {
console.log('create_thread error:', msg.message);
return;
}
// since the form exists, this will perform cleanup
newThread();
chooseThread.call(instancediv.children['threads'].children['threadlist']
.children['thread' + msg.id]);
document.getElementById('loadmore').classList.add('hidden');
}
);
}}>
<h3>create thread</h3>
<label for='newthreadname' class='heading'>thread name</label>
<p id='newnameempty' class='hidden'>name cannot be empty</p>
<input type='text' id='newthreadname' />
<p id='permissions'>thread permissions</p>
<input type='radio' name='newpermissions'
id='public' value='public' checked />
<label for='public'>anyone can view and post</label><br>
<input type='radio' name='newpermissions'
id='private_post' value='private_post' />
<label for='private_post'>anyone can view, only members can post</label><br>
<input type='radio' name='newpermissions'
id='private_view' value='private_view' />
<label for='private_view'>only members can view and post</label><br>
<label class='heading' for='membername'>members</label>
<input type='text' id='membername' placeholder='username' onkeydown=${event => {
if (event.key === 'Enter') {
event.preventDefault();
addMember();
}
}} />
<button id='addmember' type='button' onclick=${addMember}>add</button>
<div id='newmembers'>
<p class='member'>${members[0].name}</p>
</div>
<br>
<button id='submitthread' type='submit'>create</button>
</form>`);
for (let create of document.querySelectorAll('#newthread')) {
if (create === this)
create.textContent = 'cancel';
else
create.classList.add('hidden');
}
}
function editThread() {
let form = document.getElementById('editthread');
if (form) {
form.remove();
document.getElementById('edit').textContent = 'edit';
return;
}
form = html.node`
<form id='editthread' class='column' onsubmit=${event => {
event.preventDefault();
let name = document.getElementById('editthreadname').value;
if (!name) {
document.getElementById('nameempty').classList.remove('hidden');
return;
}
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 };
// todo: generate key and encrypt
}
window.currentInstance.emit('edit_thread',
{ id: window.currentThread.id, name, permissions },
msg => {
if (!msg.success) {
console.log('edit_thread failed: ', msg.message);
return;
}
editThread();
});
}}>
<label for='editthreadname' class='heading'>thread name</label>
<input type='text' id='editthreadname' />
<p id='nameempty' class='hidden'>name cannot be empty</p>
<p id='permissions'>thread permissions</p>
<input type='radio' name='permissions'
id='public' value='public' />
<label for='public'>anyone can view and post</label><br>
<input type='radio' name='permissions'
id='private_post' value='private_post' />
<label for='private_post'>anyone can view, only members can post</label><br>
<input type='radio' name='permissions'
id='private_view' value='private_view' />
<label for='private_view'>only members can view and post</label><br>
<br>
<button id='savethread'>save</button>
</form>`;
form['editthreadname'].value = window.currentThread.name;
if (window.currentThread.permissions.everyone.post.value)
form['public'].checked = true;
else if (window.currentThread.permissions.everyone.view.value)
form['private_post'].checked = true;
else
form['private_view'].checked = true;
document.querySelector('#thread').append(form);
document.getElementById('edit').textContent = 'cancel';
}
function clickedTab(event) {
switchTab(this);
document.getElementById(`thread${window.currentThread.id}`).tab = this.id;
if (this.id === 'messagetab')
loadMessages();
else if (this.id === 'spacetab')
loadSpace();
}
async function loadThreads(instancediv, select) {
function makeThread(thread) {
thread.instance = instancediv.instance;
let node = html.node`
<div class='thread' onclick=${chooseThread}>${
thread.name
}</div>`;
node.id = 'thread' + thread.id;
node.thread = thread;
thread.div = node;
return node;
}
if (!document.getElementById('thread'))
document.body.append(html.node`
<div id='thread' class='column hidden'>
<div id='content'>
<div id='titlebar'>
<span id='title'>thread: <strong id='threadname'>meow</strong></span>
<button id='edit' class='hidden' onclick=${editThread}>edit</button>
</div>
<div id='buttonbar'>
<div id='tabs'>
<button id='messagetab' class='tab active' onclick=${clickedTab}>messages
</button><button id='spacetab' class='tab' onclick=${clickedTab}>space
</button><button id='streamtab' class='tab' onclick=${clickedTab}>streams</button>
</div>
<button id='showmembers' onclick=${() =>
document.getElementById('members').classList.toggle('hidden')
}>members</button>
</div>
<div id='message' class='tabcontent'></div>
<div id='space' class='tabcontent hidden'></div>
<div id='stream' class='tabcontent hidden'></div>
</div>
<hr class='separator' color='#505050'>
<div id='members' class='hidden'>
<p id='visibility'></p>
<p><strong>members</strong></p>
<div id='memberlist'>
</div>
</div>
<hr class='separator' color='#505050'>
</div>`);
if (!instancediv.children['threads']) {
instancediv.append(html.node`
<div id='threads'>
<p><strong>threads</strong></p>
<div id='threadlist'>loading...</div>
<button id='newthread' onclick=${newThread}>create</button>
</div>`);
const threadlist = instancediv.children['threads'].children['threadlist'];
instancediv.instance.emit('list_threads', {}, msg => {
threadlist.replaceChildren(...msg.threads.map(makeThread));
if (select && msg.threads.length)
chooseThread.call(threadlist.firstChild);
});
instancediv.instance.socket.on('thread', thread => {
let el = threadlist.children['thread' + thread.id];
if (el) {
Object.assign(el.thread, thread);
el.textContent = thread.name;
if (window.currentThread?.id === thread.id) {
Object.assign(window.currentThread, thread);
document.getElementById('threadname').textContent = thread.name;
setVisibility();
}
return;
}
threadlist.prepend(makeThread(thread));
});
}
}
export default loadThreads;

View File

@ -16,38 +16,34 @@ create table key (
create table thread ( create table thread (
id integer primary key asc, id integer primary key asc,
name text, name text,
creator integer, creator text,
created timestamp default current_timestamp, created timestamp default current_timestamp
foreign key(creator) references user(id)
); );
create table permission ( create table permission (
thread integer, thread integer,
user integer, user text,
type text, type text,
mutable boolean, mutable boolean,
permission text, permission text,
value text, value text,
foreign key(user) references user(id),
foreign key(thread) references thread(id) foreign key(thread) references thread(id)
); );
create table member ( create table member (
thread integer, thread integer,
user integer, user text,
created timestamp default current_timestamp, created timestamp default current_timestamp,
foreign key(user) references user(id),
foreign key(thread) references thread(id) foreign key(thread) references thread(id)
); );
create table post ( create table post (
id integer primary key asc, id integer primary key asc,
user integer, user text,
thread integer, thread integer,
content text, content text,
encrypted bool, encrypted bool,
created timestamp default current_timestamp, created timestamp default current_timestamp,
foreign key(user) references user(id),
foreign key(thread) references thread(id) foreign key(thread) references thread(id)
); );

View File

@ -1,63 +0,0 @@
const fs = require('fs');
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const compression = require('compression');
const events = {};
for (let file of fs.readdirSync('./src/events')) {
file = require('./src/events/' + file);
for (const event in file)
events[event] = file[event];
}
const PORT = process.env.PORT || 3435;
const app = express();
app.use(compression());
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: true
}
});
app.use(express.static('client'));
global.vybe = {
users: {},
threads: {},
streams: {}
};
io.on('connection', (socket) => {
for (let event in events) {
socket.on(event, (msg, callback) => {
if (!events[event]) {
callback('no such event ' + event);
return;
}
try {
events[event](msg, callback, socket);
}
catch (e) {
console.log(`${event} threw exception: `, e);
}
});
}
socket.on('disconnect', reason => {
let user = vybe.users[socket.username];
if (user)
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();
}
});
});
server.listen(PORT, () => {
console.log('server running on port ' + PORT);
});

View File

@ -1,8 +1,8 @@
{ {
"name": "server", "name": "vybe",
"version": "1.0.0", "version": "1.0.0",
"description": "", "description": "",
"main": "index.js", "main": "server.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1"
}, },
@ -15,6 +15,7 @@
"express": "^4.18.2", "express": "^4.18.2",
"openpgp": "^5.8.0", "openpgp": "^5.8.0",
"socket.io": "^4.6.1", "socket.io": "^4.6.1",
"socket.io-client": "^4.7.5",
"sqlite3": "^5.1.6" "sqlite3": "^5.1.6"
} }
} }

121
server.js Normal file
View File

@ -0,0 +1,121 @@
const fs = require('fs');
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const ioclient = require('socket.io-client');
const compression = require('compression');
const events = {};
for (let file of fs.readdirSync('./src/events')) {
file = require('./src/events/' + file);
for (const event in file)
events[event] = file[event];
}
function rand32() {
let str = '';
const lookups = 'bcdefghjklmnpqrstvwxyz0123456789'.split('');
while (str.length < 16) {
const n = Math.random() * lookups.length;
str += lookups[Math.floor(n)];
}
return str;
}
let instance;
function saveSettings() {
fs.writeFileSync('instance.json', JSON.stringify(instance, null, 2));
}
if (fs.existsSync('instance.json'))
instance = JSON.parse(fs.readFileSync('instance.json'));
else {
instance = {
id: rand32(),
port: process.env.PORT || 1312,
url: process.env.URL || null
};
saveSettings();
}
if (!instance.url)
console.log(
`!! no instance url !
it will be auto-set by the first user authentication !
it can be set in instance.json .`);
global.vybe = {
instance,
saveSettings,
instances: {},
users: {},
threads: {},
streams: {},
connectInstance: async url => {
let instance = vybe.instances[url];
function connecting(resolve, reject) {
instance.socket.on('connect', resolve);
instance.socket.on('connect_error', error => {
instance.socket.disconnect();
reject(error.message);
});
}
if (instance) {
if (!instance.socket.connected) {
instance.socket.connect();
await new Promise(connecting);
}
}
else {
instance = vybe.instances[url] = {
socket: ioclient('https://' + url)
};
await new Promise(connecting);
}
return instance;
}
};
const app = express();
app.use(compression());
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: true
}
});
app.use(express.static('client'));
io.on('connection', (socket) => {
for (let event in events) {
socket.on(event, (msg, callback) => {
if (!events[event]) {
callback('no such event ' + event);
return;
}
try {
events[event](msg, callback, socket);
}
catch (e) {
console.log(`${event} threw exception: `, e);
}
});
}
socket.on('disconnect', reason => {
let user = vybe.users[socket.__userid];
if (user)
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();
}
});
});
server.listen(instance.port, () => {
console.log('server running on port ' + instance.port);
});

View File

@ -3,7 +3,7 @@ const db = require('./db');
const authwrap = (fn) => async (msg, respond, socket) => { const authwrap = (fn) => async (msg, respond, socket) => {
if (!respond) if (!respond)
respond = () => {}; respond = () => {};
if (!msg || !msg.__session || socket.auth !== msg.__session) { if (!msg || !msg.__session || socket.__auth !== msg.__session) {
return respond({ return respond({
success: false, success: false,
message: 'not authenticated' message: 'not authenticated'
@ -11,7 +11,7 @@ const authwrap = (fn) => async (msg, respond, socket) => {
} }
return await fn({ return await fn({
...msg, ...msg,
auth_user: vybe.users[socket.username] auth_user: vybe.users[socket.__userid]
}, respond, socket); }, respond, socket);
}; };

View File

@ -13,11 +13,9 @@ const check_permission = async (user_id, thread_id) => {
).rows.length > 0; ).rows.length > 0;
let perms = { is_member }; let perms = { is_member };
for (let p of permissions.rows) { for (let p of permissions.rows) {
if (p.type === 'everyone' && p.value === 'true') if ((p.type === 'everyone' && p.value === 'true')
perms[p.permission] = true; || (p.type === 'members' && is_member && p.value === 'true')
else if (p.type === 'members' && is_member && p.value === 'true') || (p.type === 'user' && p.user == user_id && p.value === 'true'))
perms[p.permission] = true;
else if (p.type === 'user' && p.user === user_id && p.value === 'true')
perms[p.permission] = true; perms[p.permission] = true;
} }
return perms; return perms;

View File

@ -1,9 +1,10 @@
const sqlite3 = require('sqlite3'); const sqlite3 = require('sqlite3');
const fs = require('fs'); const fs = require('fs');
const dbPath = 'vybe.db'; const path = 'vybe.db';
const db = new sqlite3.Database(dbPath); const existed = fs.existsSync(path);
const db = new sqlite3.Database(path);
db.query = function (sql, params) { db.query = function (sql, params) {
let self = this; let self = this;
@ -18,10 +19,9 @@ db.query = function (sql, params) {
}; };
(async () => { (async () => {
if (fs.existsSync(dbPath)) if (!existed)
return;
for (let sql of fs.readFileSync('./db/1-init.sql').toString().split(';')) for (let sql of fs.readFileSync('./db/1-init.sql').toString().split(';'))
if (sql) if (sql.trim())
await db.query(sql); await db.query(sql);
})(); })();

View File

@ -16,27 +16,25 @@ async function send_message(msg, respond) {
}); });
} }
// add message and send it to everyone // add message and send it to everyone
const id = await db.query( const id = (await db.query(
'insert into post (user, thread, content, encrypted) values (?, ?, ?, ?) returning id', 'insert into post (user, thread, content, encrypted) values (?, ?, ?, ?) returning id',
[msg.auth_user.id, msg.thread, msg.message, msg.encrypted] [msg.auth_user.id, msg.thread, msg.message, msg.encrypted]
); )).rows[0].id;
// get thread members // get thread members
const members = ( const members = (
await db.query( await db.query('select user from member where member.thread = ?', msg.thread)
'select name from user join member on member.user = user.id where member.thread = ?', ).rows.map(i => i.user);
[msg.thread]
)
).rows.map((i) => i.name);
// 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
[msg.thread]); where thread = ? and type = 'everyone' and value = 'true' and permission = 'view'`,
for (let username in vybe.users) { msg.thread);
if (permissions.rows.length > 0 || members.includes(username)) { for (let id in vybe.users) {
for (let s of vybe.users[username].sockets) { if (permissions.rows.length > 0 || members.includes(id)) {
for (let s of vybe.users[id].sockets) {
s.emit('new_message', { s.emit('new_message', {
id: id.rows[0].id, id,
username: msg.auth_user.name, userid: msg.auth_user.id,
name: msg.auth_user.displayname, name: msg.auth_user.displayname,
content: msg.message, content: msg.message,
thread: msg.thread thread: msg.thread
@ -46,7 +44,7 @@ async function send_message(msg, respond) {
} }
return respond({ return respond({
success: true, success: true,
id: id.rows[0].id id
}); });
} }
@ -69,20 +67,20 @@ async function get_history(msg, respond) {
message: "you can't view this thread" message: "you can't view this thread"
}); });
} }
const messages = await db.query( const messages = (await db.query(
`select coalesce(displayname, name) as name, post.id, content `select coalesce(displayname, name) as name, user as userid, post.id, content
from post from post
join user on post.user = user.id left join user on post.user = user.id
where ${msg.before ? 'post.id < ? and' : ''} where ${msg.before ? 'post.id < ? and' : ''}
thread = ? thread = ?
order by post.created desc order by post.created desc
limit 101`, limit 101`,
msg.before ? [msg.before, msg.thread] : [msg.thread] msg.before ? [msg.before, msg.thread] : [msg.thread]
); )).rows;
return respond({ return respond({
success: true, success: true,
messages: messages.rows.slice(0, 100), messages: messages.slice(0, 100),
more: messages.rows.length > 100 more: messages.length > 100
}); });
} }

View File

@ -17,7 +17,7 @@ async function get_space(msg, respond) {
} }
const spans = await db.query( const spans = await db.query(
'select id, content, x, y, scale from span where thread = ? and deleted = false', 'select id, content, x, y, scale from span where thread = ? and deleted = false',
[msg.thread] msg.thread
); );
return respond({ return respond({
success: true, success: true,
@ -43,10 +43,11 @@ async function save_span(msg, respond, socket) {
if (msg.id) { if (msg.id) {
id = msg.id; id = msg.id;
if (msg.content) if (msg.content)
await db.query('update span set content=?, x=?, y=?, scale=?, deleted=false where id=?', await db.query(
'update span set content = ?, x = ?, y = ?, scale = ?, deleted = false where id = ?',
[msg.content, msg.x, msg.y, msg.scale, msg.id]); [msg.content, msg.x, msg.y, msg.scale, msg.id]);
else else
await db.query('update span set deleted=true where id=?', [msg.id]); await db.query('update span set deleted = true where id = ?', msg.id);
} }
else { else {
id = (await db.query( id = (await db.query(
@ -56,19 +57,17 @@ async function save_span(msg, respond, socket) {
} }
// get thread members // get thread members
const members = ( const members = (
await db.query( await db.query('select user from member where member.thread = ?', msg.thread)
'select name from user join member on member.user = user.id where member.thread = ?', ).rows.map(i => i.user);
[msg.thread]
)
).rows.map(i => i.name);
// 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 = ?
[msg.thread] and type = 'everyone' and value = 'true' and permission = 'view'`,
msg.thread
); );
for (let username in vybe.users) { for (let userid in vybe.users) {
if (permissions.rows.length > 0 || members.includes(username)) { if (permissions.rows.length > 0 || members.includes(userid)) {
for (let s of vybe.users[username].sockets) { for (let s of vybe.users[userid].sockets) {
if (s !== socket) if (s !== socket)
s.emit('span', { s.emit('span', {
id, id,

View File

@ -17,25 +17,21 @@ async function stream(msg, respond, socket) {
}); });
async function send() { async function send() {
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
[msg.thread]); where thread = ? and type = 'everyone' and value = 'true' and permission = 'view'`,
msg.thread);
if (permissions.rows.length) { if (permissions.rows.length) {
for (let user in vybe.users) { for (let id in vybe.users)
user = vybe.users[user]; for (let socket of vybe.users[id].sockets)
for (let socket of user.sockets)
socket.emit('stream', stream); socket.emit('stream', stream);
}
} else { } else {
const members = ( for (let member of (
await db.query( await db.query(
'select name from user join member on member.user = user.id where member.thread = ?', 'select user from member where member.thread = ?', msg.thread)
[msg.thread] ).rows) {
) member = vybe.users[member.user];
).rows.map((i) => i.name); if (member)
for (let member of members) { for (let socket of member.sockets)
let user = vybe.users[member];
if (user)
for (let socket of user.sockets)
socket.emit('stream', stream); socket.emit('stream', stream);
} }
} }
@ -47,6 +43,11 @@ async function stream(msg, respond, socket) {
success: false, success: false,
message: 'stream not found' message: 'stream not found'
}); });
if (msg.auth_user.id !== stream.userid)
return respond({
success: false,
message: "stream doesn't belong to user"
});
if (msg.stop) { if (msg.stop) {
stream.stop(); stream.stop();
return respond({ return respond({
@ -66,6 +67,7 @@ async function stream(msg, respond, socket) {
thread.streams.push(stream); thread.streams.push(stream);
vybe.streams[stream.id] = { vybe.streams[stream.id] = {
stream, stream,
userid: msg.auth_user.id,
listeners: {}, listeners: {},
socket: socket.id, socket: socket.id,
stop: () => { stop: () => {
@ -91,7 +93,7 @@ async function streamdata(msg, respond) {
message: 'stream not found' message: 'stream not found'
}); });
} }
if (stream.stream.user !== msg.auth_user.name) { if (stream.userid !== msg.auth_user.id) {
return respond({ return respond({
success: false, success: false,
message: "stream doesn't belong to user" message: "stream doesn't belong to user"

View File

@ -14,11 +14,16 @@ async function create_thread(msg, respond) {
success: false, success: false,
message: 'thread name 200 chars max' message: 'thread name 200 chars max'
}); });
if (!Array.isArray(msg.members) || msg.members.find(m => typeof m?.name !== 'string')) if (!Array.isArray(msg.members) || msg.members.find(m => typeof m?.id !== 'string'))
return respond({ return respond({
success: false, success: false,
message: 'invalid members' message: 'invalid members'
}); });
if (msg.members.find(m => m.id == msg.auth_user.id)?.permissions?.admin !== 'true')
return respond({
success: false,
message: 'creator missing admin permission'
});
// add to db // add to db
const thread_id = (await db.query( const thread_id = (await db.query(
'insert into thread (name, creator) values (?, ?) returning id', 'insert into thread (name, creator) values (?, ?) returning id',
@ -59,25 +64,21 @@ async function create_thread(msg, respond) {
// add members // add members
let members = {}; let members = {};
for (let member of msg.members) { for (let member of msg.members) {
// get user id let id = member.id.split('@');
const id = await db.query('select id from user where name = ?', [ id = id[1] === vybe.instance.url ? id[0] : member.id;
member.name, if (members[id])
]);
if (id.rows.length === 0) {
console.log('user not found: ' + member.name);
continue; continue;
}
await db.query( await db.query(
'insert into member (thread, user) values (?, ?)', 'insert into member (thread, user) values (?, ?)',
[thread_id, id.rows[0].id] [thread_id, id]
); );
members[member.name] = true; members[id] = true;
if (typeof member.permissions === 'object') if (typeof member.permissions === 'object')
for (let permission in member.permissions) for (let permission in member.permissions)
await db.query(` await db.query(`
insert into permission (thread, type, user, mutable, permission, value) insert into permission (thread, type, user, mutable, permission, value)
values (?, ?, ?, ?, ?, ?)`, values (?, ?, ?, ?, ?, ?)`,
[thread_id, 'user', id.rows[0].id, true, permission, member.permissions[permission]]); [thread_id, 'user', id, true, permission, member.permissions[permission]]);
} }
let thread = { let thread = {
id: thread_id, id: thread_id,
@ -85,30 +86,31 @@ async function create_thread(msg, respond) {
streams: [] streams: []
}; };
if (!msg.permissions?.view_limited) { if (!msg.permissions?.view_limited) {
for (let username in vybe.users) { for (let id in vybe.users) {
for (let socket of vybe.users[username].sockets) { for (let socket of vybe.users[id].sockets) {
socket.emit('thread', { socket.emit('thread', {
...thread, ...thread,
permissions: { permissions: {
view: true, view: true,
post: !msg.permissions?.post_limited || members[username], post: !msg.permissions?.post_limited || members[id],
admin: username === msg.auth_user.name admin: id === msg.auth_user.id
} }
}); });
} }
} }
} }
else { else {
for (let member of msg.members) { for (let member in members) {
if (!vybe.users[member.name]) member = vybe.users[member];
if (!member)
continue; continue;
for (let socket of vybe.users[member.name].sockets) { for (let socket of member.sockets) {
socket.emit('thread', { socket.emit('thread', {
...thread, ...thread,
permissions: { permissions: {
view: true, view: true,
post: true, post: true,
admin: member.name === msg.auth_user.name admin: member.id === msg.auth_user.id
} }
}); });
} }
@ -127,13 +129,12 @@ async function list_threads(msg, respond) {
`select name, id from thread `select name, id from thread
join permission on thread.id = permission.thread join permission on thread.id = permission.thread
left join member on thread.id = member.thread left join member on thread.id = member.thread
where permission.permission = 'view' where permission.permission = 'view' and permission.value = 'true'
and permission.value = 'true'
and ((permission.type = 'everyone') or and ((permission.type = 'everyone') or
permission.type = 'members' and member.user = ?) permission.type = 'members' and member.user = ?)
group by thread.id group by thread.id
order by thread.created desc`, order by thread.created desc`,
[msg.auth_user.id] msg.auth_user.id
); );
threads = await Promise.all(threads.rows.map(async thread => { threads = await Promise.all(threads.rows.map(async thread => {
thread.streams = vybe.threads[thread.id]?.streams || []; thread.streams = vybe.threads[thread.id]?.streams || [];
@ -161,19 +162,25 @@ async function get_thread(msg, respond) {
}); });
} }
let thread = await db.query( let thread = await db.query(
`select thread.name, user.name as user, user.id from thread `select thread.name, member.user, user.name as username,
coalesce(user.displayname, user.name) as displayname
from thread
left join member on thread.id = member.thread left join member on thread.id = member.thread
left join user on user.id = member.user left join user on user.id = member.user
where thread.id = ?`, where thread.id = ?`,
[msg.thread] msg.thread
); );
const permissions = await db.query( const permissions = await db.query(
`select type, user, permission, value, mutable `select type, user, permission, value, mutable
from permission where thread = ?`, from permission where thread = ?`,
[msg.thread] msg.thread
); );
let members = Object.fromEntries(thread.rows.map(member => let members = Object.fromEntries(thread.rows.map(member =>
[member.id, { name: member.user }] [member.user, {
id: member.user,
name: member.username,
displayname: member.displayname
}]
)); ));
for (let permission of permissions.rows) { for (let permission of permissions.rows) {
const member = members[permission.user]; const member = members[permission.user];
@ -207,11 +214,8 @@ async function get_thread(msg, respond) {
id: msg.thread, id: msg.thread,
name: thread.rows[0].name, name: thread.rows[0].name,
permissions: perms, permissions: perms,
members: Object.entries(members).map(member => ({ members: Object.values(members),
id: member[0], streams: vybe.threads[msg.thread]?.streams || []
...member[1]
})),
streams: vybe.threads[thread.id]?.streams || []
}; };
return respond({ return respond({
success: true, success: true,
@ -251,7 +255,7 @@ async function edit_thread(msg, respond) {
`select type, permission, value, mutable `select type, permission, value, mutable
from permission from permission
where type != 'user' and thread = ?`, where type != 'user' and thread = ?`,
[msg.id] msg.id
)).rows) { )).rows) {
(permissions[p.type] || (permissions[p.type] = {})) (permissions[p.type] || (permissions[p.type] = {}))
[p.permission] = { [p.permission] = {
@ -324,41 +328,41 @@ async function edit_thread(msg, respond) {
permissions.members.view.value = permissions.members.view.value === 'true'; permissions.members.view.value = permissions.members.view.value === 'true';
permissions.members.post.value = permissions.members.post.value === 'true'; permissions.members.post.value = permissions.members.post.value === 'true';
let members = Object.fromEntries((await db.query( let members = Object.fromEntries((await db.query(
`select user.name, user.id from thread `select member.user from thread
join member on thread.id = member.thread join member on thread.id = member.thread
join user on user.id = member.user
where thread.id = ?`, where thread.id = ?`,
[msg.id] msg.id
)).rows.map(row => [row.name, row.id])); )).rows.map(row => [row.user, true]));
let thread = { let thread = {
id: msg.id, id: msg.id,
name: msg.name, name: msg.name,
streams: vybe.threads[msg.id]?.streams || [] streams: vybe.threads[msg.id]?.streams || []
}; };
if (!msg.permissions?.view_limited) { if (!msg.permissions?.view_limited) {
for (let username in vybe.users) for (let id in vybe.users)
for (let socket of vybe.users[username].sockets) for (let socket of vybe.users[id].sockets)
socket.emit('thread', { socket.emit('thread', {
...thread, ...thread,
permissions: { permissions: {
view: true, view: true,
post: !msg.permissions?.post_limited || username in members, post: !msg.permissions?.post_limited || id in members,
admin: username === msg.auth_user.name && perms.admin, admin: id === msg.auth_user.id && perms.admin,
...permissions ...permissions
} }
}); });
} }
else { else {
for (let membername in members) { for (let member in members) {
if (!vybe.users[membername]) member = vybe.users[member];
if (!member)
continue; continue;
for (let socket of vybe.users[membername].sockets) for (let socket of member.sockets)
socket.emit('thread', { socket.emit('thread', {
...thread, ...thread,
permissions: { permissions: {
view: true, view: true,
post: true, post: true,
admin: membername === msg.auth_user.name && perms.admin, admin: member.id === msg.auth_user.id && perms.admin,
...permissions ...permissions
} }
}); });

View File

@ -4,16 +4,16 @@ const openpgp = require('openpgp');
async function create_user(msg, respond) { async function create_user(msg, respond) {
// validate inputs // validate inputs
if (!msg.name) { if (typeof msg.name !== 'string') {
return respond({ return respond({
success: false, success: false,
message: 'username required' message: 'invalid username'
}); });
} }
if (!msg.pubkey) { if (typeof msg.pubkey !== 'string') {
return respond({ return respond({
success: false, success: false,
message: 'public key required' message: 'invalid public key'
}); });
} }
// ensure username is not taken // ensure username is not taken
@ -53,15 +53,52 @@ async function create_user(msg, respond) {
}); });
} }
async function getUser(id, name) {
return (id ? await db.query(
`select name, id, displayname from user where id = ?`, id)
: await db.query(
`select name, id, displayname from user where name = ?`, name)
).rows[0];
}
async function authenticate(msg, respond, socket) { async function authenticate(msg, respond, socket) {
if (!msg.name || !msg.message) { if (typeof msg.name !== 'string'
|| typeof msg.id !== 'string'
|| typeof msg.message !== 'string') {
return respond({ return respond({
success: false, success: false,
message: 'invalid message' message: 'invalid message'
}); });
} }
let user = (await db.query( let user;
`select id, displayname from user where name = ?`, [msg.name])).rows[0]; let id = msg.id.split('@');
if (id.length !== 2 || !id[1])
return respond({
success: false,
message: 'invalid user id'
});
try {
// verify key and get session auth string
let pubkey = msg.pubkey;
const key = await openpgp.readKey({ armoredKey: pubkey });
const verification = await openpgp.verify({
message: await openpgp.readCleartextMessage({
cleartextMessage: msg.message
}),
verificationKeys: key,
expectSigned: true,
date: new Date(Date.now() + 60000 * 4) // slightly in the future to compensate for some system clocks
});
const data = verification.data.split(' ');
if (data[0] !== 'vybe_auth') {
return respond({
success: false,
message: 'bad auth message'
});
}
if (vybe.instance.url === id[1] || (!vybe.instance.url && !id[0])) {
// this should be user's home instance
user = await getUser(id[0], msg.name);
if (!user) { if (!user) {
return respond({ return respond({
success: false, success: false,
@ -72,33 +109,8 @@ async function authenticate(msg, respond, socket) {
`select user.id from user `select user.id from user
join key on key.user = user.id join key on key.user = user.id
where name = ? and pubkey = ? and active = true`, where name = ? and pubkey = ? and active = true`,
[msg.name, msg.pubkey] [msg.name, pubkey]
); );
try {
const key = await openpgp.readKey({ armoredKey: msg.pubkey });
const verification = await openpgp.verify({
message: await openpgp.readCleartextMessage({
cleartextMessage: msg.message
}),
verificationKeys: key,
expectSigned: true,
date: new Date(Date.now() + 60000 * 4) // slightly in the future to compensate for some system times
});
const data = verification.data.split(' ');
if (data[0] !== 'vybe_auth') {
return respond({
success: false,
message: 'bad auth message'
});
}
if (!user.displayname)
user.displayname = msg.name;
user = vybe.users[msg.name] || (vybe.users[msg.name] = {
...user,
name: msg.name,
sockets: [],
authrequests: {}
});
if (result.rows.length === 0) { if (result.rows.length === 0) {
// request auth from logged in sessions // request auth from logged in sessions
let id = key.getFingerprint().slice(0, 8); let id = key.getFingerprint().slice(0, 8);
@ -118,14 +130,59 @@ async function authenticate(msg, respond, socket) {
return; return;
await db.query( await db.query(
'insert into key (user, pubkey, active) values (?, ?, true)', 'insert into key (user, pubkey, active) values (?, ?, true)',
[user.id, msg.pubkey]); [user.id, pubkey]);
}
// default instance url to first authenticated user's location.host
if (!vybe.instance.url) {
vybe.instance.url = id[1];
vybe.saveSettings();
}
}
else {
// connect to user's home instance and ask for their key
try {
let instance = await vybe.connectInstance(id[1]);
await new Promise((resolve, reject) => {
instance.socket.emit('get_keys', { ids: [id[0]] }, msg => {
if (!msg.success) {
console.log('get_keys error: ' + msg.message);
return reject('get_keys error: ' + msg.message);
}
if (!Array.isArray(msg.keys?.[id[0]]))
return reject('got invalid keys');
if (msg.keys[id[0]].indexOf(pubkey) === -1)
return reject('pubkey not authenticated');
resolve();
});
});
user = { id: msg.id, name: msg.name };
}
catch (error) {
console.log(`error authenticating with ${id[1]}: ${error}`);
return respond({
success: false,
message: `error authenticating with ${id[1]}: ${error}`
});
}
} }
// this socket is now authenticated // this socket is now authenticated
socket.auth = data[1]; if (!user.displayname)
socket.username = msg.name; user.displayname = user.name;
user = vybe.users[user.id] || (vybe.users[user.id] = {
...user,
sockets: [],
authrequests: {}
});
socket.__auth = data[1];
socket.__userid = user.id;
user.sockets.push(socket); user.sockets.push(socket);
respond({ respond({
success: true, success: true,
instance: {
id: vybe.instance.id
},
id: user.id,
name: user.name,
displayname: user.displayname, displayname: user.displayname,
authrequests: Object.entries(user.authrequests).map(authrequest => ({ authrequests: Object.entries(user.authrequests).map(authrequest => ({
id: authrequest[0], id: authrequest[0],
@ -143,7 +200,7 @@ async function authenticate(msg, respond, socket) {
} }
async function authorize_key(msg, respond) { async function authorize_key(msg, respond) {
let authrequest = vybe.users[msg.auth_user.name].authrequests[msg.id]; let authrequest = vybe.users[msg.auth_user.id].authrequests[msg.id];
if (!authrequest) { if (!authrequest) {
return respond({ return respond({
success: false, success: false,
@ -158,43 +215,58 @@ async function authorize_key(msg, respond) {
async function update_user(msg, respond) { async function update_user(msg, respond) {
if ((await db.query( if ((await db.query(
`update user set displayname = ? `select id from user where id = ?`, msg.auth_user.id)).rows.length === 0)
where name = ?
returning id`,
[msg.displayname, msg.auth_user.name])).rows.length === 0)
return respond({ return respond({
success: false, success: false,
message: 'user not found' message: 'user not found'
}); });
vybe.users[msg.auth_user.name].displayname = msg.displayname; await db.query(
`update user set displayname = ? where id = ?`,
[msg.displayname, msg.auth_user.id]);
vybe.users[msg.auth_user.id].displayname = msg.displayname;
respond({ respond({
success: true success: true
}); });
} }
async function get_user(msg, respond) {
if (typeof msg.name !== 'string' && typeof msg.id !== 'string')
return respond({
success: false,
message: 'name or id required'
});
let user = await getUser(msg.id, msg.name);
if (!user)
return respond({
success: false,
message: 'user not found'
});
if (!user.displayname)
user.displayname = user.name;
return respond({
success: true,
user
});
}
async function get_keys(msg, respond) { async function get_keys(msg, respond) {
// validate inputs // validate inputs
if (!msg.names) { if (!Array.isArray(msg.ids)) {
return respond({ return respond({
success: false, success: false,
message: 'user names required' message: 'invalid ids'
}); });
} }
if (typeof msg.names !== 'object') { let keys = Object.fromEntries(msg.ids.map(id => [id, []]));
return respond({ (await db.query(
success: false, `select pubkey, user from key where user in
message: "can't iterate user names" (${msg.ids.map(i => '?').join(',')})`,
}); msg.ids
} )).rows.forEach(key => keys[key.user].push(key.pubkey));
const keys = await db.query( // todo: encryption !
`select name from user where name in
(${msg.names.map((i) => '?').join(',')})`,
msg.names
);
// respond // respond
respond({ respond({
success: true, success: true,
keys: keys.rows keys
}); });
} }
@ -203,5 +275,6 @@ module.exports = {
authenticate, authenticate,
authorize_key: authwrap(authorize_key), authorize_key: authwrap(authorize_key),
update_user: authwrap(update_user), update_user: authwrap(update_user),
get_keys: authwrap(get_keys) get_user,
get_keys
}; };

View File

@ -1,29 +0,0 @@
# EditorConfig helps developers define and maintain consistent
# coding styles between different editors and IDEs
# editorconfig.org
root = true
[*]
# Change these settings to your own preference
indent_style = space
indent_size = 2
# We recommend you to keep these unchanged
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
[*.json]
indent_size = 2
[*.{html,js,md}]
block_comment_start = /**
block_comment = *
block_comment_end = */

View File

@ -1,24 +0,0 @@
## editors
/.idea
/.vscode
## system files
.DS_Store
## npm
/node_modules/
/npm-debug.log
## testing
/coverage/
## temp folders
/.tmp/
# build
/_site/
/dist/
/out-tsc/
storybook-static
custom-elements.json

View File

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2023 vyber-client
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,31 +0,0 @@
<p align="center">
<img width="200" src="https://open-wc.org/hero.png"></img>
</p>
## Open-wc Starter App
[![Built with open-wc recommendations](https://img.shields.io/badge/built%20with-open--wc-blue.svg)](https://github.com/open-wc)
## Quickstart
To get started:
```bash
npm init @open-wc
# requires node 10 & npm 6 or higher
```
## Scripts
- `start` runs your app for development, reloading on file changes
- `start:build` runs your app after it has been built using the build command
- `build` builds your app and outputs it in your `dist` directory
- `test` runs your test suite with Web Test Runner
- `lint` runs the linter for your project
- `format` fixes linting and formatting errors
## Tooling configs
For most of the tools, the configuration is in the `package.json` to reduce the amount of files in your project.
If you customize the configuration a lot, you can consider moving them to individual files.

View File

@ -1,29 +0,0 @@
<svg
width="244px"
height="244px"
viewBox="0 0 244 244"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
>
<defs>
<linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="linearGradient-1">
<stop stop-color="#9B00FF" offset="0%"></stop>
<stop stop-color="#0077FF" offset="100%"></stop>
</linearGradient>
</defs>
<g
id="Page-1"
stroke="none"
stroke-width="1"
fill="none"
fill-rule="evenodd"
>
<path
d="M205.639259,176.936244 C207.430887,174.217233 209.093339,171.405629 210.617884,168.510161 M215.112174,158.724316 C216.385153,155.50304 217.495621,152.199852 218.433474,148.824851 M220.655293,138.874185 C221.231935,135.482212 221.637704,132.03207 221.863435,128.532919 M222,118.131039 C221.860539,114.466419 221.523806,110.85231 221.000113,107.299021 M218.885321,96.8583653 C218.001583,93.4468963 216.942225,90.1061026 215.717466,86.8461994 M211.549484,77.3039459 C209.957339,74.1238901 208.200597,71.0404957 206.290425,68.0649233 M200.180513,59.5598295 C181.848457,36.6639805 153.655709,22 122.036748,22 C66.7879774,22 22,66.771525 22,122 C22,177.228475 66.7879774,222 122.036748,222 C152.914668,222 180.52509,208.015313 198.875424,186.036326"
id="Shape"
stroke="url(#linearGradient-1)"
stroke-width="42.0804674"
></path>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1,28 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="Description" content="Put your description here.">
<base href="/">
<style>
html,
body {
margin: 0;
padding: 0;
font-family: sans-serif;
background-color: #ededed;
}
</style>
<title>vyber-client</title>
</head>
<body>
<vyber-client></vyber-client>
<script type="module" src="./src/vyber-client.js"></script>
</body>
</html>

View File

@ -1,35 +0,0 @@
{
"name": "vyber-client",
"description": "Webcomponent vyber-client following open-wc recommendations",
"license": "MIT",
"author": "vyber-client",
"version": "0.0.0",
"type": "module",
"scripts": {
"build": "rimraf dist && rollup -c rollup.config.js && npm run analyze -- --exclude dist",
"start:build": "web-dev-server --root-dir dist --app-index index.html --open",
"analyze": "cem analyze --litelement",
"start": "web-dev-server"
},
"dependencies": {
"lit": "^2.0.2"
},
"devDependencies": {
"@babel/preset-env": "^7.16.4",
"@custom-elements-manifest/analyzer": "^0.4.17",
"@open-wc/building-rollup": "^2.0.2",
"@rollup/plugin-babel": "^5.3.0",
"@rollup/plugin-node-resolve": "^13.0.6",
"@web/dev-server": "^0.1.34",
"@web/rollup-plugin-html": "^1.11.0",
"@web/rollup-plugin-import-meta-assets": "^1.0.7",
"babel-plugin-template-html-minifier": "^4.1.0",
"deepmerge": "^4.2.2",
"esbuild": "^0.17.19",
"rimraf": "^3.0.2",
"rollup": "^2.60.0",
"rollup-plugin-esbuild": "^5.0.0",
"rollup-plugin-workbox": "^6.2.0"
},
"customElements": "custom-elements.json"
}

View File

@ -1,71 +0,0 @@
import nodeResolve from '@rollup/plugin-node-resolve';
import babel from '@rollup/plugin-babel';
import html from '@web/rollup-plugin-html';
import { importMetaAssets } from '@web/rollup-plugin-import-meta-assets';
import esbuild from 'rollup-plugin-esbuild';
import { generateSW } from 'rollup-plugin-workbox';
import path from 'path';
export default {
input: 'index.html',
output: {
entryFileNames: '[hash].js',
chunkFileNames: '[hash].js',
assetFileNames: '[hash][extname]',
format: 'es',
dir: 'dist',
},
preserveEntrySignatures: false,
plugins: [
/** Enable using HTML as rollup entrypoint */
html({
minify: true,
injectServiceWorker: true,
serviceWorkerPath: 'dist/sw.js',
}),
/** Resolve bare module imports */
nodeResolve(),
/** Minify JS, compile JS to a lower language target */
esbuild({
minify: true,
target: ['chrome64', 'firefox67', 'safari11.1'],
}),
/** Bundle assets references via import.meta.url */
importMetaAssets(),
/** Minify html and css tagged template literals */
babel({
plugins: [
[
require.resolve('babel-plugin-template-html-minifier'),
{
modules: { lit: ['html', { name: 'css', encapsulation: 'style' }] },
failOnError: false,
strictCSS: true,
htmlMinifier: {
collapseWhitespace: true,
conservativeCollapse: true,
removeComments: true,
caseSensitive: true,
minifyCSS: true,
},
},
],
],
}),
/** Create and inject a service worker */
generateSW({
globIgnores: ['polyfills/*.js', 'nomodule-*.js'],
navigateFallback: '/index.html',
// where to output the generated sw
swDest: path.join('dist', 'sw.js'),
// directory to match patterns against to be precached
globDirectory: path.join('dist'),
// cache any html js and css by default
globPatterns: ['**/*.{html,js,css,webmanifest}'],
skipWaiting: true,
clientsClaim: true,
runtimeCaching: [{ urlPattern: 'polyfills/*.js', handler: 'CacheFirst' }],
}),
],
};

View File

@ -1,88 +0,0 @@
import { LitElement, html, css } from 'lit';
const logo = new URL('../assets/open-wc-logo.svg', import.meta.url).href;
class VyberClient extends LitElement {
static properties = {
header: { type: String },
}
static styles = css`
:host {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
font-size: calc(10px + 2vmin);
color: #1a2b42;
max-width: 960px;
margin: 0 auto;
text-align: center;
background-color: var(--vyber-client-background-color);
}
main {
flex-grow: 1;
}
.logo {
margin-top: 36px;
animation: app-logo-spin infinite 20s linear;
}
@keyframes app-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.app-footer {
font-size: calc(12px + 0.5vmin);
align-items: center;
}
.app-footer a {
margin-left: 5px;
}
`;
constructor() {
super();
this.header = 'My app';
}
render() {
return html`
<main>
<div class="logo"><img alt="open-wc logo" src=${logo} /></div>
<h1>${this.header}</h1>
<p>Edit <code>src/VyberClient.js</code> and save to reload.</p>
<a
class="app-link"
href="https://open-wc.org/guides/developing-components/code-examples/"
target="_blank"
rel="noopener noreferrer"
>
Code examples
</a>
</main>
<p class="app-footer">
🚽 Made with love by
<a
target="_blank"
rel="noopener noreferrer"
href="https://github.com/open-wc"
>open-wc</a
>.
</p>
`;
}
}
customElements.define('vyber-client', VyberClient);

View File

@ -1,26 +0,0 @@
// import { hmrPlugin, presets } from '@open-wc/dev-server-hmr';
/** Use Hot Module replacement by adding --hmr to the start command */
const hmr = process.argv.includes('--hmr');
export default /** @type {import('@web/dev-server').DevServerConfig} */ ({
open: '/',
watch: !hmr,
/** Resolve bare module imports */
nodeResolve: {
exportConditions: ['browser', 'development'],
},
/** Compile JS for older browsers. Requires @web/dev-server-esbuild plugin */
// esbuildTarget: 'auto'
/** Set appIndex to enable SPA routing */
appIndex: './index.html',
plugins: [
/** Use Hot Module Replacement by uncommenting. Requires @open-wc/dev-server-hmr plugin */
// hmr && hmrPlugin({ exclude: ['**/*/node_modules/**/*'], presets: [presets.litElement] }),
],
// See documentation for all available options
});

File diff suppressed because it is too large Load Diff