$ git clone http://ion.nu/git/webchat
commit f758ef994e68e2f1ba8b0de921a3682a1baed926
Author: Alicia <...>
Date:   Mon Dec 28 23:40:32 2015 +0100

    Initial commit

diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..8dfad09
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,15 @@
+CFLAGS=-g3
+LIBS=
+CFLAGS+=$(shell pkg-config --cflags gnutls)
+LIBS+=$(shell pkg-config --libs gnutls)
+CFLAGS+=$(shell pkg-config --cflags sqlite3)
+LIBS+=$(shell pkg-config --libs sqlite3)
+chatd: src/chat.o src/users.o src/channels.o src/httpcam.o src/db.o libwebsocket/libwebsocket.so
+ $(CC) -Wl,-R,libwebsocket $^ $(LIBS) -o $@
+
+libwebsocket/libwebsocket.so:
+ make -C libwebsocket
+
+clean:
+ make -C libwebsocket clean
+ rm -f chatd src/*.o
diff --git a/README b/README
new file mode 100644
index 0000000..d31d46b
--- /dev/null
+++ b/README
@@ -0,0 +1,8 @@
+About
+-----
+This project is a complete multi-channel webchat solution with accounts, channel registration and moderation, colors to distinguish users, and options to broadcast cameras and/or microphones.
+
+Licensing
+---------
+This project is licensed under the GNU Affero General Public License (AGPL) version 3,
+with the exception of mic.xcf/mic.png which is licensed under Creative Commons CC-by-3.0, based on work from https://github.com/break24/PhantomOpenEmoji
diff --git a/cams.js b/cams.js
new file mode 100644
index 0000000..ba051d6
--- /dev/null
+++ b/cams.js
@@ -0,0 +1,185 @@
+/**
+ * @licstart  The following is the entire license notice for the 
+ *  JavaScript code in this page.
+ *
+ * Copyright (C) 2015  Alicia ( https://ion.nu/ )
+ *
+ * The JavaScript code in this page is free software: you can
+ * redistribute it and/or modify it under the terms of the GNU
+ * Affero General Public License (GNU AGPL) as published by the Free Software
+ * Foundation, version 3 of the License.
+ * The code is distributed WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS
+ * FOR A PARTICULAR PURPOSE.  See the GNU AGPL for more details.
+ *
+ * As additional permission under GNU AGPL version 3 section 7, you
+ * may distribute non-source (e.g., minimized or compacted) forms of
+ * that code without the copy of the GNU AGPL normally required by
+ * section 4, provided you include this license notice and a URL
+ * through which recipients can access the Corresponding Source.
+ *
+ * @licend  The above is the entire license notice
+ * for the JavaScript code in this page.
+ */
+var cams=new Array();
+var mycam;
+var broadcasting=false;
+var broadcaststream;
+var mediarec;
+var usermediaerror;
+function broadcast()
+{
+  if(!window.MediaRecorder)
+  {
+    // Check useragent and if it's chrome, suggest enabling "experimental web platform features", if it's not firefox, suggest trying that, if it is firefox, suggest upgrading
+    if(navigator.userAgent.indexOf(' Chrome/')>-1)
+    {
+      chatnote("MediaRecorder API is not available in your browser, cannot broadcast. Enabling \"Experimental web platform features\" in chrome://flags might enable it.");
+    }else if(navigator.userAgent.indexOf(' Firefox/')==-1)
+    {
+      chatnote("MediaRecorder API is not available in your browser, cannot broadcast. Firefox provides this API, as does Chrome if you enable \"Experimental web platform features\".");
+    }else{
+      chatnote("MediaRecorder API is not available in your browser, cannot broadcast");
+    }
+    return;
+  }
+  if(!broadcasting)
+  {
+// TODO: Try to constrain video to a low enough resolution for shit connections
+    navigator.getUserMedia({video:true, audio:true}, broadcast_success, function(e){alert('getUserMedia failed (is your camera connected?)'); usermediaerror=e;});
+  }else{
+    broadcasting=false;
+    clearInterval(mycam.interval);
+    broadcaststream.stop();
+    document.getElementById('broadcastbutton').textContent='Broadcast';
+    document.getElementById('cams').removeChild(mycam.video);
+    var index=cams.indexOf(mycam);
+    if(index>-1){cams.splice(index,1);}
+    mycam=undefined;
+    // TODO: check that a video isn't playing
+    if(cams.length==0){hidecams();}
+    connection.send('mediastop');
+  }
+}
+function broadcast_success(stream)
+{
+  broadcasting=1;
+  document.getElementById('broadcastbutton').textContent='Stop broadcasting';
+  connection.send('mediastart');
+  broadcaststream=stream;
+  showcams();
+  mycam=new Object();
+  mycam.video=document.createElement('video');
+  mycam.video.autoplay=true;
+  mycam.video.mozSrcObject=stream;
+  mycam.video.src=window.URL.createObjectURL(stream);
+  mycam.video.style.height='100%';
+  mycam.video.muted=true; // Don't echo ourselves
+  document.getElementById('cams').appendChild(mycam.video);
+  cams.push(mycam);
+  // Streaming to the server
+  mediarec=new MediaRecorder(stream);
+  mediarec.ondataavailable=function(data)
+  {
+    if(data.data.size==0){return;} // Don't send 0-byte data
+    connection.send('media');
+    connection.send(data.data);
+  };
+  mediarec.start();
+  mycam.interval=setInterval(function(){mediarec.requestData();}, 100);
+}
+function opencam(user)
+{
+  var cam=new Object();
+  cam.user=user;
+  cam.video=document.createElement('video');
+  cam.video.autoplay=true;
+  cam.video.style.height='100%';
+  cam.video.poster='mic.png';
+  cam.sourcebuffer=false;
+  if(!window.MediaSource)
+  {
+    chatnote('MediaSource missing, using httpcam fallback');
+    connection.send("httpcamkey:"+user.nickname);
+  }else{
+    cam.mediasource=new MediaSource();
+    cam.mediasource.addEventListener('sourceopen', function()
+    {
+      if(!cam.sourcebuffer){cam.sourcebuffer=this.addSourceBuffer('video/webm');}
+    });
+    cam.video.src=window.URL.createObjectURL(cam.mediasource);
+    cam.filereader=new FileReader();
+    cam.filereader.onloadend=function(progress)
+    {
+      cam.sourcebuffer.appendBuffer(this.result);
+      if(!cam.video.played.length)
+      {
+        cam.video.play();
+      }
+      cam.video.currentTime+=3600;
+    }
+    connection.send('mediasubscribe:'+user.nickname);
+  }
+  cam.cambox=document.createElement('div');
+  cam.cambox.className='cambox';
+  var info=document.createElement('span');
+  info.textContent=user.nickname;
+  info.oncontextmenu=function(e){usermenu(user, e.pageX, e.pageY);return false;};
+  cam.cambox.appendChild(cam.video);
+  cam.cambox.appendChild(info);
+  document.getElementById('cams').appendChild(cam.cambox);
+  cams.push(cam);
+  showcams();
+}
+function handlecamdata(user, data)
+{
+  var i=0;
+  while(i<cams.length)
+  {
+    if(cams[i].user==user)
+    {
+      cams[i].filereader.readAsArrayBuffer(data);
+      return;
+    }
+  }
+}
+function closecam(user)
+{
+  var cam=false;
+  var i=0;
+  while(i<cams.length)
+  {
+    if(cams[i].user==user){cam=cams[i]; break;}
+    ++i;
+  }
+  if(!cam){return;}
+  document.getElementById('cams').removeChild(cam.cambox);
+  cam.cambox.removeChild(cam.video);
+  cams.splice(cams.indexOf(cam), 1);
+  // TODO: check that a video isn't playing
+  if(cams.length==0){hidecams();}
+//  connection.send('mediaunsubscribe:'+cam.user.username); (probably do this from elsewhere)
+}
+function showcams()
+{
+  var div=document.getElementById('chat');
+  div.style.height='40%';
+  div=document.getElementById('userlist');
+  div.style.height='40%';
+  div=document.getElementById('cams');
+  div.style.display='inline-block';
+}
+function hidecams()
+{
+  var div=document.getElementById('cams');
+  div.style.display='none';
+  div=document.getElementById('chat');
+  setheightcalc(div, '100% - 30px');
+  div=document.getElementById('userlist');
+  setheightcalc(div, '100% - 30px');
+}
+// Compatibility...
+if(navigator.getUserMedia){}
+else if(navigator.webkitGetUserMedia){navigator.getUserMedia=navigator.webkitGetUserMedia;}
+else if(navigator.mozGetUserMedia){navigator.getUserMedia=navigator.mozGetUserMedia;}
+else if(navigator.msGetUserMedia){navigator.getUserMedia=navigator.msGetUserMedia;}
diff --git a/chat.css b/chat.css
new file mode 100644
index 0000000..c7de655
--- /dev/null
+++ b/chat.css
@@ -0,0 +1,285 @@
+html
+{
+  height:100%;
+}
+body
+{
+  font-family: arial,sans;
+  margin:0px;
+  height:100%;
+  background-color:#333333;
+}
+div.cams
+{
+  display:none;
+  height:calc(60% - 30px);
+  height:-webkit-calc(60% - 30px);
+  height:-moz-calc(60% - 30px);
+  height:-o-calc(60% - 30px);
+  height:-ms-calc(60% - 30px);
+  width:100%;
+  background-color:#707070;
+  float:top;
+}
+video
+{
+  margin-bottom:-5px;
+}
+div#videoplayer
+{
+  height:100%;
+  width:22%;
+  display:none;
+  position:relative;
+}
+div#videoplayer>button
+{
+  font-size:8px;
+  padding:0px;
+  position:absolute;
+  top:0px;
+  right:0px;
+}
+iframe#videoplayerframe
+{
+  border-width:0px;
+  margin:0px;
+  // For some reason, at least on firefox, iframes have a little bump below them, so we take 4 pixels off to avoid that
+  height:calc(100% - 4px);
+  height:-webkit-calc(100% - 4px);
+  height:-moz-calc(100% - 4px);
+  height:-o-calc(100% - 4px);
+  height:-ms-calc(100% - 4px);
+  width:100%;
+}
+div#chat
+{
+  display:inline-block;
+  height:calc(100% - 30px);
+  height:-webkit-calc(100% - 30px);
+  height:-moz-calc(100% - 30px);
+  height:-o-calc(100% - 30px);
+  height:-ms-calc(100% - 30px);
+  width:85%;
+  background-color:#e0e0e0;
+  float:left;
+}
+div#tabs
+{
+  display:block;
+  width:100%;
+  height:30px;
+  background-color:#7070a0;
+}
+div.tab>button
+{
+  font-size:8px;
+  padding:0px;
+  margin-left:6px;
+  margin-right:-4px;
+  float:top;
+  position:relative;
+  top:-4px;
+}
+div.tab
+{
+  display:inline-block;
+  height:25px;
+  padding-left:6px;
+  padding-right:6px;
+  padding-top:4px;
+  border-top-style:solid;
+  border-left-style:solid;
+  border-right-style:solid;
+  border-width:1px;
+  border-color:#505050;
+  border-top-left-radius:6px;
+  border-top-right-radius:6px;
+  background-color:#a0a0a0;
+  cursor:default;
+}
+div.tabfocus
+{
+  background-color:#c0c0c0;
+}
+div.tabactivity
+{
+  background-color:#ffa000;
+}
+div.chatbox
+{
+  display:inline-block;
+  width:100%;
+  height:calc(100% - 30px);
+  height:-webkit-calc(100% - 30px);
+  height:-moz-calc(100% - 30px);
+  height:-o-calc(100% - 30px);
+  height:-ms-calc(100% - 30px);
+  overflow:auto;
+}
+div.userlist
+{
+  display:inline-block;
+  height:calc(100% - 30px);
+  height:-webkit-calc(100% - 30px);
+  height:-moz-calc(100% - 30px);
+  height:-o-calc(100% - 30px);
+  height:-ms-calc(100% - 30px);
+  width:15%;
+  background-color:#c0c0c0;
+  float:right;
+  overflow:auto;
+}
+div.input
+{
+  display:inline-block;
+  height:30px;
+  width:100%;
+  background-color:#a0a0a0;
+  float:bottom;
+}
+input#message
+{
+  width:calc(100% - 70px);
+  width:-webkit-calc(100% - 70px);
+  width:-moz-calc(100% - 70px);
+  width:-o-calc(100% - 70px);
+  width:-ms-calc(100% - 70px);
+  height:22px;
+}
+input#send
+{
+  width:55px;
+}
+div#usermenu
+{
+  position:absolute;
+  display:none;
+  border-style:solid;
+  border-width:1px;
+  border-color:#000000;
+  background-color:#707070;
+  padding-top:2px;
+  padding-bottom:2px;
+}
+div.usermenuoption
+{
+  display:block;
+  cursor:default;
+  padding-left:20px;
+  padding-right:20px;
+  white-space:nowrap;
+}
+div.usermenuoption:hover
+{
+  background-color:#909090;
+}
+pre
+{
+  display:inline-block;
+  margin:0px;
+}
+div.cambox
+{
+  display:inline-block;
+  position:relative;
+  height:100%;
+  background-color:#262626;
+}
+div.cambox>span
+{
+  position:absolute;
+  top:calc(50% - 0.5em);
+  top:-webkit-calc(50% - 0.5em);
+  top:-moz-calc(50% - 0.5em);
+  top:-o-calc(50% - 0.5em);
+  top:-ms-calc(50% - 0.5em);
+  left:0px;
+  display:none;
+  text-align:center;
+  width:100%;
+  color:#e0e0e0;
+}
+div.cambox:hover>span
+{
+  display:inline-block;
+}
+div.cambox:hover>video
+{
+  opacity:0.3;
+}
+div.fullscreen
+{
+  display:none; /* Start hidden */
+  position:absolute;
+  left:0px;
+  top:0px;
+  width:100%;
+  height:100%;
+  background-color:rgba(0, 0, 0, 0.8);
+  text-align:center;
+  color:#c0c0c0;
+}
+div.fullscreen>*
+{
+  display:inline-block;
+  vertical-align:middle;
+}
+button.fullscreenclose
+{
+  position:absolute;
+  right:0px;
+  top:0px;
+  border-style:none;
+  border-width:0px;
+  background-color:rgba(0,0,0,0);
+  color:#c0c0c0;
+  font-size:50px;
+}
+div.centerhelper
+{
+  display:inline-block;
+  vertical-align:middle;
+  height:100%;
+}
+div#loggedout
+{
+  padding-bottom:10px;
+}
+div#loggedin
+{
+  display:none;
+  padding-bottom:10px;
+}
+button#registerchannel
+{
+  display:none; /* Start hidden */
+}
+button#managemods_button
+{
+  display:none; /* Start hidden */
+}
+div#modmanagerlist
+{
+  display:block;
+}
+div.modlistitem /* TODO: Rename to something more general for fullscreen panel lists? */
+{
+  text-align:left;
+  padding:8px;
+  border-style:solid;
+  border-width:1px;
+  border-color:#c0c0c0;
+}
+div.modlistitem>button
+{
+  float:right;
+}
+button#banlist_button
+{
+  display:none; /* Start hidden */
+}
+button#setchanpass_button
+{
+  display:none; /* Start hidden */
+}
diff --git a/chat.js b/chat.js
new file mode 100644
index 0000000..276ca6f
--- /dev/null
+++ b/chat.js
@@ -0,0 +1,287 @@
+/**
+ * @licstart  The following is the entire license notice for the 
+ *  JavaScript code in this page.
+ *
+ * Copyright (C) 2015  Alicia ( https://ion.nu/ )
+ *
+ * The JavaScript code in this page is free software: you can
+ * redistribute it and/or modify it under the terms of the GNU
+ * Affero General Public License (GNU AGPL) as published by the Free Software
+ * Foundation, version 3 of the License.
+ * The code is distributed WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS
+ * FOR A PARTICULAR PURPOSE.  See the GNU AGPL for more details.
+ *
+ * As additional permission under GNU AGPL version 3 section 7, you
+ * may distribute non-source (e.g., minimized or compacted) forms of
+ * that code without the copy of the GNU AGPL normally required by
+ * section 4, provided you include this license notice and a URL
+ * through which recipients can access the Corresponding Source.
+ *
+ * @licend  The above is the entire license notice
+ * for the JavaScript code in this page.
+ */
+var userlist=new Array();
+var nickname=''; // Will be set with 'startnick:'
+var me;
+function User(name)
+{
+  this.nickname=name;
+  this.listlabel=document.createElement("span");
+  this.listlabel.textContent=this.nickname;
+  this.listlabel.style.display='block';
+  var user=this;
+  this.listlabel.oncontextmenu=function(e)
+//  this.listlabel.onclick=function(e)
+  {
+    usermenu(user, e.pageX, e.pageY);
+    return false;
+  };
+  this.color='000000';
+  this.modprivileges=0;
+  document.getElementById('userlist').appendChild(this.listlabel);
+}
+function finduser(name)
+{
+  var i=0;
+  while(i<userlist.length)
+  {
+    if(userlist[i].nickname==name){return userlist[i];}
+    ++i;
+  }
+  return false;
+}
+function chatnoteon(msg, tab)
+{
+  var scroll=(!tab.chat.scrollTopMax || tab.chat.scrollTop==tab.chat.scrollTopMax);
+  var text=document.createElement("div");
+  text.textContent=msg;
+  tab.chat.appendChild(text);
+  if(scroll){text.scrollIntoView();}
+}
+function chatnote(msg)
+{
+  chatnoteon(msg, tabs[0]);
+}
+var unseenmessages=0;
+function chatmsgon(name, msg, color, tab)
+{
+  if(msg.substring(0,6)=='video:') // TODO: Proper command, and mod privileges to play them
+  {
+    document.getElementById('videoplayer').style.display='inline-block';
+    document.getElementById('videoplayerframe').src='https://www.youtube-nocookie.com/embed/'+msg.substring(6,msg.length)+'?rel=0&controls=0&showinfo=0&autoplay=1';
+    showcams();
+    chatnote(name+' started a video');
+    return;
+  }
+  if(document.hasFocus && !document.hasFocus())
+  {
+    ++unseenmessages;
+    document.title='('+unseenmessages+' unseen messages) Chat (work in progress)';
+  }
+  var scroll=(!tab.chat.scrollTopMax || tab.chat.scrollTop==tab.chat.scrollTopMax);
+  var line=document.createElement("div");
+  var timestamp=document.createElement("pre");
+  timestamp.textContent='[00:00]';
+  var text=document.createElement("span");
+  if(msg.substring(0,4)=='/me ')
+  {
+    text.textContent='* '+name+' '+msg.substring(4,msg.length);
+  }else{
+    text.textContent=name+': '+msg;
+  }
+  text.style.color='#'+color;
+  line.appendChild(timestamp);
+  line.appendChild(text);
+  tab.chat.appendChild(line);
+  if(scroll){text.scrollIntoView();}
+  if(tab!=currenttab){tab.tab.className='tab tabactivity';}
+}
+function chatmsg(name, msg, color)
+{
+  chatmsgon(name, msg, color, tabs[0]);
+}
+var connection;
+function connect(channel, password)
+{
+  if(password===undefined)
+  {
+    connection=new WebSocket(chatdprotocol+chatd+'/chat/'+channel, 'chat-0.1');
+  }else{
+    connection=new WebSocket(chatdprotocol+chatd+'/chat/'+channel+'?password='+encodeURIComponent(password), 'chat-0.1');
+  }
+  connection.onmessage=handlecommands;
+  connection.onclose=function(){chatnote('-'); chatnote('The connection to the server was lost. Reload the page to reconnect');};
+}
+function sendmsg()
+{
+  if(currenttab.dead){return;} // Don't send to dead tabs
+  var msg=document.getElementById('message').value;
+  document.getElementById('message').value='';
+  if(msg==''){return;}
+  if(msg.substring(0,6)=='/nick '){connection.send('nick:'+msg.substring(6,msg.length)); return;}
+  if(currenttab==tabs[0])
+  {
+    chatmsg(nickname, msg, me.color);
+    connection.send('msg:'+msg);
+  }else{ // PMs
+    chatmsgon(nickname, msg, me.color, currenttab);
+    connection.send('pm:'+currenttab.name+':'+msg);
+  }
+}
+function setcolor(color)
+{
+  if(color.substring(0,1)=='#'){color=color.substring(1,color.length);}
+  connection.send('color:'+color);
+  me.color=color;
+  me.listlabel.style.color='#'+color;
+}
+var channelname;
+function init(channel)
+{
+  channelname=channel;
+  tabs.push(new Tab('Main'));
+  document.getElementById('color').value='#000000'; // The color defaults to black, but sometimes input values are retained when reloading the page
+  connect(channel);
+}
+function setheightcalc(element, calc)
+{
+  element.style.height='calc('+calc+')';
+  element.style.height='-webkit-calc('+calc+')';
+  element.style.height='-moz-calc('+calc+')';
+  element.style.height='-o-calc('+calc+')';
+  element.style.height='-ms-calc('+calc+')';
+}
+function closevideo()
+{
+  document.getElementById('videoplayer').style.display='none';
+  document.getElementById('videoplayerframe').src='about:blank';
+  // Close the cam pane if no one is on cam
+  if(cams.length==0){hidecams();}
+}
+function handleclick()
+{
+  document.getElementById('usermenu').style.display='none';
+}
+function changenick()
+{
+  var newnick=prompt('New nickname?', nickname);
+  if(newnick){connection.send('nick:'+newnick);}
+}
+function usermenu(user, x, y)
+{
+  if(document.body.clientWidth && x>document.body.clientWidth/2)
+  {
+    document.getElementById('usermenu').style.right=(document.body.clientWidth-x)+'px';
+    document.getElementById('usermenu').style.left='auto';
+  }else{
+    document.getElementById('usermenu').style.left=x+'px';
+    document.getElementById('usermenu').style.right='auto';
+  }
+  document.getElementById('usermenu').style.top=y+'px';
+  document.getElementById('usermenu').style.display='block';
+  // Send private message
+  document.getElementById('usermenu').children.pm.onclick=function()
+  {
+    var tab=findtab(user.nickname);
+    if(!tab){tabs.push(tab=new Tab(user.nickname));}
+    tab.focus();
+    document.getElementById('usermenu').style.display='none';
+  };
+  // Account/profile
+  document.getElementById('usermenu').children.whois.onclick=function()
+  {
+    connection.send('whois:'+user.nickname);
+    document.getElementById('usermenu').style.display='none';
+  }
+  // Close media stream
+  var cam=false;
+  if(me.modprivileges&PRIV_CLOSECAM)
+  {
+    var i=0;
+    while(i<cams.length)
+    {
+      if(cams[i].user==user){cam=cams[i]; break;}
+      ++i;
+    }
+  }
+  if(cam)
+  {
+    document.getElementById('usermenu').children.closemedia.style.display='block';
+    document.getElementById('usermenu').children.closemedia.onclick=function()
+    {
+      connection.send('closemedia:'+user.nickname);
+      document.getElementById('usermenu').style.display='none';
+    }
+  }else{
+    document.getElementById('usermenu').children.closemedia.style.display='none';
+  }
+  // Ban
+  if(me.modprivileges&PRIV_KICK)
+  {
+    document.getElementById('usermenu').children.ban.style.display='block';
+    document.getElementById('usermenu').children.ban.onclick=function()
+    {
+      connection.send('ban:'+user.nickname);
+      document.getElementById('usermenu').style.display='none';
+    }
+  }else{
+    document.getElementById('usermenu').children.ban.style.display='none';
+  }
+}
+function login(form)
+{
+  document.getElementById('login').style.display='none';
+  connection.send('login:'+form.user.value+':'+form.pass.value);
+  form.pass.value='';
+}
+function createaccount(form)
+{
+// TODO: Check username availability upon onchange
+  if(form.user.value.indexOf(':')>-1)
+  {
+    alert('Error: Usernames may not contain :');
+  }
+  if(form.pass.value!=form.pass2.value)
+  {
+    alert('Error: Passwords don\'t match!');
+    return;
+  }
+  connection.send('createaccount:'+form.user.value+':'+form.pass.value);
+  form.pass.value='';
+  form.pass2.value='';
+  document.getElementById('createaccount').style.display='none';
+}
+function showfullscreen(name, focus)
+{
+  document.getElementById(name).style.display='block';
+  if(focus)
+  {
+    document.getElementById(focus).focus();
+    document.getElementById(focus).select();
+  }
+}
+function setupmodmanager()
+{
+  var list=document.getElementById('modmanagerlist');
+  while(list.children.length>0)
+  {
+    list.removeChild(list.children[0]);
+  }
+  connection.send('listmods');
+}
+function addmoderator()
+{
+  connection.send('addmod:'+document.getElementById('addmod_account').value+':'+PRIV_NONOWNER);
+  document.getElementById('addmod_account').value='';
+}
+function setupbanlist()
+{
+  document.getElementById('banlist_placeholder').style.display='inline-block';
+  var list=document.getElementById('banlist_list');
+  while(list.children.length>0)
+  {
+    list.removeChild(list.children[0]);
+  }
+  connection.send('listbans');
+}
diff --git a/chat.php b/chat.php
new file mode 100644
index 0000000..534bdc3
--- /dev/null
+++ b/chat.php
@@ -0,0 +1,92 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <meta charset="utf-8" />
+  <title>Chat (work in progress)</title>
+  <script src="config.js"></script>
+  <script src="tabs.js"></script>
+  <script src="proto.js"></script>
+  <script src="chat.js"></script>
+  <script src="cams.js"></script>
+  <link rel="stylesheet" type="text/css" href="chat.css" />
+</head>
+<body onload="init(&quot;<?php print($_GET['c']); ?>&quot;);" onfocus="unseenmessages=0; document.title='Chat (work in progress)';">
+  <div style="display:block; height:100%; width:100%;" onclick="handleclick();">
+    <div class="cams" id="cams">
+      <div id="videoplayer">
+        <iframe id="videoplayerframe"></iframe>
+        <button onclick="closevideo();">X</button>
+      </div>
+    </div>
+    <div id="chat">
+      <div id="tabs"></div>
+    </div>
+    <div class="userlist" id="userlist">
+      <div id="loggedout">
+        <button onclick="showfullscreen('login', 'login_user');">Log in</button>
+        <button onclick="showfullscreen('createaccount', 'createaccount_user');">Create account</button>
+      </div>
+      <div id="loggedin">
+        <button onclick="connection.send('logout');">Log out</button><br />
+        <button onclick="connection.send('registerchannel');" id="registerchannel">Register channel</button>
+        <button onclick="setupmodmanager(); showfullscreen('managemods', false);" id="managemods_button">Manage moderators</button>
+        <button onclick="setupbanlist(); showfullscreen('banlist', false);" id="banlist_button">Banlist</button>
+        <button onclick="var p=prompt('New channel password? (empty for no password)');if(p)connection.send('setchanpass:'+p);" id="setchanpass_button">Set channel password</button>
+        <br />
+        <!-- TODO: more account stuff? changing password -->
+      </div>
+      <button onclick="broadcast();" id="broadcastbutton">Broadcast</button><br />
+      <input type="color" id="color" onchange="setcolor(this.value);" value="#000000" />
+    </div>
+    <div class="input">
+      <form action="javascript:" onsubmit="sendmsg();" style="display:inline;">
+        <input type="text" id="message" autofocus autocomplete="off" />
+        <input type="submit" id="send" value="Send" />
+      </form>
+    </div>
+  </div>
+  <div id="usermenu">
+    <div name="pm" class="usermenuoption">Send private message</div>
+    <div name="whois" class="usermenuoption">Account</div>
+    <div name="closemedia" class="usermenuoption">Close media stream</div>
+    <div name="ban" class="usermenuoption">Ban</div>
+  </div>
+  <div id="login" class="fullscreen">
+    <button class="fullscreenclose" onclick="document.getElementById('login').style.display='none';">X</button>
+    <div class="centerhelper"></div>
+    <form action="javascript:;" onsubmit="login(this);">
+      <div style="text-align:right;">
+        Username:<input type="text" name="user" id="login_user" /><br />
+        Password:<input type="password" name="pass" />
+      </div>
+      <input type="submit" value="Login" />
+    </form>
+  </div>
+  <div id="createaccount" class="fullscreen">
+    <button class="fullscreenclose" onclick="document.getElementById('createaccount').style.display='none';">X</button>
+    <div class="centerhelper"></div>
+    <form action="javascript:;" onsubmit="createaccount(this);">
+      <div style="text-align:right;">
+        Username:<input type="text" name="user" id="createaccount_user" /><br />
+        Password:<input type="password" name="pass" /><br />
+        Password (confirm):<input type="password" name="pass2" />
+      </div>
+      <input type="submit" value="Login" />
+    </form>
+  </div>
+  <div id="managemods" class="fullscreen">
+    <button class="fullscreenclose" onclick="document.getElementById('managemods').style.display='none';">X</button>
+    <div class="centerhelper"></div>
+    <div>
+      <div id="modmanagerlist"></div>
+      <nobr><input type="text" id="addmod_account" /><button onclick="addmoderator();">Add moderator</button></nobr>
+    </div>
+  </div>
+  <div id="banlist" class="fullscreen">
+    <button class="fullscreenclose" onclick="document.getElementById('banlist').style.display='none';">X</button>
+    <div class="centerhelper"></div>
+    <div id="banlist_placeholder" class="modlistitem">No one is banned</div>
+    <div id="banlist_list"></div>
+  </div>
+</body>
+</html>
diff --git a/config.js b/config.js
new file mode 100644
index 0000000..13909fe
--- /dev/null
+++ b/config.js
@@ -0,0 +1,3 @@
+var chatdprotocol='wss://';
+var chatd='127.0.0.1:4000';
+// var chatd='chat.ion.nu:4000';
diff --git a/db.sql b/db.sql
new file mode 100644
index 0000000..b9014d9
--- /dev/null
+++ b/db.sql
@@ -0,0 +1,33 @@
+PRAGMA foreign_keys = ON; -- Apparently this one needs to be run every time we open the database (or maybe not, but better safe than sorry)
+
+create table users(
+  name text,
+  password text,
+  salt text,
+  id integer,
+  primary key(id)
+);
+create unique index userindex on users(name);
+create unique index useridindex on users(id);
+create table channels(
+  name text,
+  password text,
+  id integer,
+  primary key(id)
+);
+create unique index channelindex on channels(name);
+create table mods(
+  channel integer,
+  userid integer,
+  privilege integer,
+  foreign key(channel) references channels(id),
+  foreign key(userid) references users(id)
+);
+create index modindex on mods(channel); -- mods per channel should be few enough not to index
+
+-- Sample data (user passwords are "test")
+--insert into users values('A', '07e007fe5f99ee5851dd519bf6163a0d2dda54d45e6fe0127824f5b45a5ec59183a08aaa270979deb2f048815d05066c306e3694473d84d6aca0825c3dccd559', 'salt', null);
+--insert into users values('B', '07e007fe5f99ee5851dd519bf6163a0d2dda54d45e6fe0127824f5b45a5ec59183a08aaa270979deb2f048815d05066c306e3694473d84d6aca0825c3dccd559', 'salt', null);
+--insert into channels values('testchannel', 'password', null);
+--insert into mods values(1, 1, 15);
+--insert into mods values(1, 2, 15);
diff --git a/libwebsocket/Makefile b/libwebsocket/Makefile
new file mode 100644
index 0000000..50157fa
--- /dev/null
+++ b/libwebsocket/Makefile
@@ -0,0 +1,7 @@
+CFLAGS=-g3 -fPIC -Wall $(shell pkg-config --libs gnutls)
+LIBS=$(shell pkg-config --cflags gnutls)
+libwebsocket.so: websock.o
+ $(CC) -shared $^ -o $@
+
+clean:
+ rm -f libwebsocket.so websock.o
diff --git a/libwebsocket/websock.c b/libwebsocket/websock.c
new file mode 100644
index 0000000..28055f1
--- /dev/null
+++ b/libwebsocket/websock.c
@@ -0,0 +1,241 @@
+/*
+    libwebsocket, an implementation of websockets version 13
+    Copyright (C) 2015  alicia@ion.nu
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, version 3 of the License.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+#include <stdlib.h>
+#include <unistd.h>
+#include <string.h>
+#include <stdio.h>
+#include <stdarg.h>
+#include <gnutls/gnutls.h>
+#include "websock.h"
+
+unsigned short websock_be16(unsigned short in)
+{
+#if(__BYTE_ORDER__==__ORDER_LITTLE_ENDIAN__)
+  return ((in&0xff)<<8) |
+         ((in&0xff00)>>8);
+#else
+  return in;
+#endif
+}
+
+unsigned long long websock_be64(unsigned long long in)
+{
+#if(__BYTE_ORDER__==__ORDER_LITTLE_ENDIAN__)
+  return ((in&0xff)<<56) |
+         ((in&0xff00)<<40) |
+         ((in&0xff0000)<<24) |
+         ((in&0xff000000)<<8) |
+         ((in&0xff00000000)>>8) |
+         ((in&0xff0000000000)>>24) |
+         ((in&0xff000000000000)>>40) |
+         ((in&0xff00000000000000)>>56);
+#else
+  return in;
+#endif
+}
+
+// TLS/non-TLS agnostic read/write
+#define awrite(sock,buf,len) (tls?gnutls_record_send(sock, buf, len):write(*(int*)sock, buf, len))
+#define aread(sock,buf,len) (tls?gnutls_record_recv(sock, buf, len):read(*(int*)sock, buf, len))
+
+// TODO: figure out how to stop clients from re-requesting upon 400
+#define WEBSOCK_BADREQ "HTTP/1.1 400 Bad Request\n" \
+"Sec-WebSocket-Version: 13\n" \
+"Content-length: 81\n" \
+"\n" \
+"<h3>Error</h3>Wrong websocket version, protocol, path, or not a websocket request"
+char websock_handshake_server(void* fd, const char*(*cb)(const char* path, const char* host, char* protocol, const char* origin), char(*nonsockcb)(const char* path, const char* host), char tls)
+{
+  unsigned int bufsize=257;
+  char* buf=malloc(bufsize);
+  unsigned int buflen=0;
+  int readlen;
+  while((readlen=aread(fd, &buf[buflen], bufsize-buflen-1))>0)
+  {
+    buflen+=readlen;
+    buf[buflen]=0;
+    if(strstr(buf, "\n\n") || strstr(buf, "\r\n\r\n")){break;} // Break the loop when we received a full request
+    if(buflen+256>bufsize)
+    {
+      bufsize+=256;
+      buf=realloc(buf, bufsize);
+    }
+  }
+// printf("Got request:\n%s\n\n", buf);
+  char* path=0;
+  char* host=0;
+  char* key=0;
+  char* protocol=0;
+  char* origin=0;
+
+  char* line=buf;
+  char* nextline;
+  char firstline=1;
+  unsigned int i;
+  while(line[0])
+  {
+    while(line[0]=='\r' || line[0]=='\n'){line=&line[1];}
+    for(i=0; line[i] && line[i]!='\r' && line[i]!='\n'; ++i);
+    nextline=&line[i+(!!line[i])];
+    line[i]=0;
+    if(firstline)
+    {
+      firstline=0;
+//printf("First line: '%s'\n", line);
+      if(strncmp(line, "GET ", 4)){awrite(fd, WEBSOCK_BADREQ, strlen(WEBSOCK_BADREQ)); free(buf); return 0;}
+      path=&line[4];
+      char* end=strchr(path, ' ');
+      if(end){end[0]=0;}
+      if((end=strchr(path, '\r'))){end[0]=0;}
+      if((end=strchr(path, '\n'))){end[0]=0;}
+    }else{
+      char* colon=strchr(line, ':');
+      if(colon)
+      {
+        colon[0]=0;
+        do{colon=&colon[1];}while(colon[0]==' ');
+//printf("Header: '%s', value: '%s'\n", line, colon);
+        if(!strcasecmp(line, "Sec-WebSocket-Key")){key=colon;}
+        else if(!strcasecmp(line, "Sec-WebSocket-Version") && strcmp(colon, "13")){awrite(fd, WEBSOCK_BADREQ, strlen(WEBSOCK_BADREQ)); free(buf); return 0;} // TODO: handle other versions maybe?
+        else if(!strcasecmp(line, "Origin")){origin=colon;}
+        else if(!strcasecmp(line, "Sec-WebSocket-Protocol")){protocol=colon;}
+        else if(!strcasecmp(line, "Host")){host=colon;}
+      }
+    }
+    line=nextline;
+  }
+  if(!key)
+  {
+    if(!nonsockcb || !nonsockcb(path, host))
+    {
+      awrite(fd, WEBSOCK_BADREQ, strlen(WEBSOCK_BADREQ));
+    }
+    free(buf);
+    return 0;
+  }
+  // Check path and protocols
+  const char* decidedprotocol=cb(path, host, protocol, origin);
+  if(!decidedprotocol){awrite(fd, WEBSOCK_BADREQ, strlen(WEBSOCK_BADREQ)); free(buf); return 0;}
+  // Hash the key+websocket HMAC
+  unsigned int keylen=strlen(key);
+  unsigned char keybuf[keylen+36];
+  memcpy(keybuf, key, keylen);
+  memcpy(&keybuf[keylen], "258EAFA5-E914-47DA-95CA-C5AB0DC85B11", 36);
+  gnutls_datum_t data;
+  data.data=keybuf;
+  data.size=keylen+36;
+  size_t hashsize=20;
+  unsigned char hashdata[hashsize];
+  gnutls_fingerprint(GNUTLS_DIG_SHA1, &data, hashdata, &hashsize);
+  // Base64-encode (using PEM function because gnutls doesn't seem to provide plain base64)
+// TODO: use glib's g_base64_encode() instead? (no PEM header/footer)
+  gnutls_datum_t hash={.data=hashdata, .size=hashsize};
+  gnutls_pem_base64_encode_alloc("", &hash, &data);
+  // Throw away the PEM header and footer
+  char* acceptkey=strchr((char*)data.data, '\n')+1;
+  char* end;
+  if((end=strchr(acceptkey, '\n'))){end[0]=0;}
+
+  awrite(fd, "HTTP/1.1 101 Switching Protocols\n", 33);
+  awrite(fd, "Upgrade: websocket\n", 19);
+  awrite(fd, "Connection: Upgrade\n", 20);
+  awrite(fd, "Sec-WebSocket-Accept: ", 22);
+  awrite(fd, acceptkey, strlen(acceptkey));
+  awrite(fd, "\nSec-WebSocket-Protocol: ", 25);
+  awrite(fd, decidedprotocol, strlen(decidedprotocol));
+  awrite(fd, "\n\n", 2);
+  gnutls_free(data.data); // Free the base64-encoded hash
+  free(buf); // Not freeing until here because pointing decidedprotocol to somewhere in it (e.g. the protocol header) is valid
+  return 1;
+}
+
+void websock_write(void* fd, void* buf, unsigned int len, unsigned char opcode, char tls)
+{
+  unsigned char head[2];
+  head[0]=0x80|opcode;
+  // Handle lengths > 125
+  if(len>0xffff)
+  {
+    head[1]=127;
+  }
+  else if(len>0x7e)
+  {
+    head[1]=126;
+  }else{
+    head[1]=len;
+  }
+  awrite(fd, head, sizeof(unsigned char)*2);
+  if((head[1]&0x7f)==126){unsigned short l=websock_be16(len); awrite(fd, &l, sizeof(l));}
+  else if((head[1]&0x7f)==127){unsigned long long int l=websock_be64(len); awrite(fd, &l, sizeof(l));}
+// TODO: do masks on outgoing messages too?
+  awrite(fd, buf, len);
+}
+
+char websock_readhead(void* fd, struct websock_head* head_info, char tls)
+{
+  unsigned char head[2];
+  if(aread(fd, head, sizeof(unsigned char)*2)!=sizeof(unsigned char)*2){return 0;}
+  if(!(head[0]&0x80))
+  {
+printf("FIN not set! TODO: handle this scenario\n");
+// TODO: handle the FIN(ished) bit if not set
+  }
+//  printf("headbits: %u\n", (unsigned int)(head[0]&0xf0)/16);
+  head_info->opcode=head[0]&0xf;
+  head_info->masked=!!(head[1]&0x80);
+  head_info->length=head[1]&0x7f;
+  // Handle larger length, 126=16bit, 127=64bit
+  if(head_info->length==126)
+  {
+    unsigned short l;
+    if(aread(fd, &l, sizeof(l))<1){return 0;}
+    head_info->length=websock_be16(l);
+  }
+  else if(head_info->length==127)
+  {
+    if(aread(fd, &head_info->length, sizeof(head_info->length))<1){return 0;}
+    head_info->length=websock_be64(head_info->length);
+  }
+  if(!(head[0]&0x80))
+  {
+printf("Non-FIN length: %llu\n", head_info->length);
+  }
+  if(head_info->masked)
+  {
+    if(aread(fd, head_info->mask, sizeof(unsigned char)*4)<sizeof(unsigned char)*4){return 0;}
+  }
+  return 1;
+}
+
+void websock_readcontent(void* fd, void* buf_, struct websock_head* head, char tls)
+{
+  unsigned char* buf=buf_;
+  unsigned int pos=0;
+  int r;
+  while(pos<head->length && (r=aread(fd, buf+pos, head->length-pos))>0)
+  {
+    pos+=r;
+  }
+  if(head->masked)
+  {
+    unsigned int i;
+    for(i=0; i<head->length; ++i)
+    {
+      buf[i]^=head->mask[i%4];
+    }
+  }
+}
diff --git a/libwebsocket/websock.h b/libwebsocket/websock.h
new file mode 100644
index 0000000..eac98e0
--- /dev/null
+++ b/libwebsocket/websock.h
@@ -0,0 +1,37 @@
+/*
+    libwebsocket, an implementation of websockets version 13
+    Copyright (C) 2015  alicia@ion.nu
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, version 3 of the License.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+#define WEBSOCK_CONT   0x0
+#define WEBSOCK_TEXT   0x1
+#define WEBSOCK_BINARY 0x2
+#define WEBSOCK_CLOSE  0x8
+#define WEBSOCK_PING   0x9
+#define WEBSOCK_PONG   0xa
+struct websock_head
+{
+// TODO: finished (last segment)?
+  unsigned char opcode;
+  char masked;
+  unsigned char mask[4];
+  unsigned long long int length;
+};
+
+// NOTE: The callback decides whether or not to accept the request, and if so with which protocol, return 0/NULL to reject, nonsockcb is an option to not treat the session as a websocket session
+extern char websock_handshake_server(void* fd, const char*(*cb)(const char* path, const char* host, char* protocol, const char* origin), char(*nonsockcb)(const char* path, const char* host), char tls);
+// TODO: implement websock_handshake_client()
+extern void websock_write(void* fd, void* buf, unsigned int len, unsigned char opcode, char tls);
+extern char websock_readhead(void* fd, struct websock_head* head_info, char tls);
+extern void websock_readcontent(void* fd, void* buf_, struct websock_head* head, char tls);
diff --git a/mic.png b/mic.png
new file mode 100644
index 0000000..acb70d9
Binary files /dev/null and b/mic.png differ
diff --git a/mic.xcf b/mic.xcf
new file mode 100644
index 0000000..550a4eb
Binary files /dev/null and b/mic.xcf differ
diff --git a/proto.js b/proto.js
new file mode 100644
index 0000000..8cb2ea7
--- /dev/null
+++ b/proto.js
@@ -0,0 +1,321 @@
+/**
+ * @licstart  The following is the entire license notice for the 
+ *  JavaScript code in this page.
+ *
+ * Copyright (C) 2015  Alicia ( https://ion.nu/ )
+ *
+ * The JavaScript code in this page is free software: you can
+ * redistribute it and/or modify it under the terms of the GNU
+ * Affero General Public License (GNU AGPL) as published by the Free Software
+ * Foundation, version 3 of the License.
+ * The code is distributed WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS
+ * FOR A PARTICULAR PURPOSE.  See the GNU AGPL for more details.
+ *
+ * As additional permission under GNU AGPL version 3 section 7, you
+ * may distribute non-source (e.g., minimized or compacted) forms of
+ * that code without the copy of the GNU AGPL normally required by
+ * section 4, provided you include this license notice and a URL
+ * through which recipients can access the Corresponding Source.
+ *
+ * @licend  The above is the entire license notice
+ * for the JavaScript code in this page.
+ */
+const PRIV_CLOSECAM=2;
+const PRIV_KICK=8;
+const PRIV_SETMODS=16;
+const PRIV_PASSWORD=32;
+const PRIV_NONOWNER=15;
+var bincmd=false;
+function handlecommands(data)
+{
+  if(bincmd)
+  {
+    if(bincmd.substring(0,6)=='media:')
+    {
+      handlecamdata(finduser(bincmd.substring(6, bincmd.length)), data.data);
+    }
+    bincmd=false;
+    return;
+  }
+  if(data.data.substring(0,5)=='join:')
+  {
+    var user=new User(data.data.substring(5,data.data.length));
+    if(user.nickname==nickname)
+    {
+      me=user;
+      user.listlabel.style.fontWeight='bold'; // Distinguish ourselves
+      user.listlabel.onclick=changenick;
+    }
+    userlist.push(user);
+    chatnote(user.nickname+' entered the channel');
+  }
+  else if(data.data.substring(0,4)=='msg:' || data.data.substring(0,3)=='pm:')
+  {
+    var pm=(data.data.substring(0,3)=='pm:');
+    data=data.data.substring(data.data.indexOf(':')+1, data.data.length);
+    var sep=data.indexOf(':');
+    if(sep<0){alert("Error, no separator found");}
+    var from=data.substring(0,sep);
+    var msg=data.substring(sep+1, data.length);
+    var tab;
+    if(pm)
+    {
+      if(!(tab=findtab(from))){tabs.push((tab=new Tab(from)));}
+    }else{
+      tab=tabs[0];
+    }
+    if(from=finduser(from)){chatmsgon(from.nickname, msg, from.color, tab);}
+  }
+  else if(data.data.substring(0,5)=='nick:')
+  {
+    var sep=data.data.indexOf(':', 5);
+    if(sep<0){alert("Error, no separator found");}
+    var from=data.data.substring(5,sep);
+    var to=data.data.substring(sep+1, data.data.length);
+    if(nickname==from){nickname=to;}
+    chatnote(from+' changed nickname to '+to);
+    var tab;
+    if(tab=findtab(from))
+    {
+      tab.setname(to);
+      chatnoteon(from+' changed nickname to '+to, tab);
+    }
+    if(from==nickname){nickname=to;}
+    if(from=finduser(from))
+    {
+      from.nickname=to;
+      from.listlabel.textContent=from.nickname;
+    }
+  }
+  else if(data.data.substring(0,10)=='startnick:')
+  {
+    nickname=data.data.substring(10, data.data.length);
+  }
+  else if(data.data.substring(0,6)=='color:')
+  {
+    var sep=data.data.indexOf(':', 6);
+    if(sep<0){alert("Error, no separator found");}
+    var user=data.data.substring(6,sep);
+    var color=data.data.substring(sep+1, data.data.length);
+    if(user=finduser(user))
+    {
+      user.color=color;
+      user.listlabel.style.color='#'+color;
+    }
+  }
+  else if(data.data.substring(0,5)=='quit:')
+  {
+    var user=data.data.substring(5, data.data.length);
+    chatnote(user+' quit');
+    var tab;
+    if(tab=findtab(user)){tab.dead=true; chatnoteon(user+' quit', tab);}
+    if(user=finduser(user))
+    {
+      document.getElementById('userlist').removeChild(user.listlabel);
+      userlist.splice(userlist.indexOf(user), 1);
+      closecam(user);
+    }
+  }
+  else if(data.data.substring(0,11)=='mediastart:')
+  {
+    var user=data.data.substring(11, data.data.length);
+    chatnote(user+' started broadcasting');
+    opencam(finduser(user));
+  }
+  else if(data.data.substring(0,10)=='mediastop:')
+  {
+    var user=data.data.substring(10, data.data.length);
+    chatnote(user+' stopped broadcasting');
+    closecam(finduser(user));
+  }
+  else if(data.data.substring(0,6)=='media:')
+  {
+    bincmd=data.data;
+  }
+  else if(data.data.substring(0,11)=='httpcamkey:')
+  {
+    var sep=data.data.indexOf(':', 11);
+    if(sep<0){return;}
+    var user=finduser(data.data.substring(11,sep));
+    var key=data.data.substring(sep+1, data.data.length);
+    var i=0;
+    while(i<cams.length)
+    {
+      if(cams[i].user==user)
+      {
+        cams[i].video.src='https://'+chatd+'/cam/'+key;
+        break;
+      }
+      ++i;
+    }
+  }
+  else if(data.data=='password')
+  {
+    var pass=prompt('This channel is password protected');
+    if(pass!==null)
+    {
+      connect(channelname, pass);
+    }
+  }
+  else if(data.data=='createdaccount')
+  {
+// TODO: Some prettier display for this, maybe a big block with rounded corners that fades out
+    alert('Account created successfully!');
+  }
+  else if(data.data=='loggedin')
+  {
+    document.getElementById('loggedout').style.display='none';
+    document.getElementById('loggedin').style.display='block';
+  }
+  else if(data.data=='loggedout')
+  {
+    document.getElementById('loggedin').style.display='none';
+    document.getElementById('loggedout').style.display='block';
+  }
+  else if(data.data.substring(0,14)=='channelstatus:')
+  {
+    if(data.data.substring(14,data.data.length)=='registered')
+    {
+      document.getElementById('registerchannel').style.display='none';
+    }else{
+      document.getElementById('registerchannel').style.display='inline';
+    }
+  }
+  else if(data.data.substring(0,4)=='mod:')
+  {
+    var sep=data.data.indexOf(':', 4);
+    if(sep<0){alert("Error, no separator found");}
+    var user=finduser(data.data.substring(4,sep));
+    user.modprivileges=parseInt(data.data.substring(sep+1, data.data.length));
+    if(user.modprivileges)
+    {
+      chatnote(user.nickname+' is a moderator');
+    }else{
+      chatnote(user.nickname+' is no longer a moderator');
+    }
+    if(user==me)
+    {
+      // Show applicable channel operations for the given privileges
+      if(user.modprivileges&PRIV_SETMODS)
+      {
+        document.getElementById('managemods_button').style.display='inline-block';
+      }else{
+        document.getElementById('managemods_button').style.display='none';
+      }
+      if(user.modprivileges&PRIV_KICK)
+      {
+        document.getElementById('banlist_button').style.display='inline-block';
+      }else{
+        document.getElementById('banlist_button').style.display='none';
+      }
+      if(user.modprivileges&PRIV_PASSWORD)
+      {
+        document.getElementById('setchanpass_button').style.display='inline-block';
+      }else{
+        document.getElementById('setchanpass_button').style.display='none';
+      }
+    }
+  }
+  else if(data.data.substring(0,9)=='listmods:' || data.data.substring(0,7)=='addmod:')
+  {
+    data=data.data.substring(data.data.indexOf(':')+1, data.data.length);
+    var sep=data.indexOf(':');
+    if(sep<0){alert("Error, no separator found");}
+    var account=data.substring(0,sep);
+//    var modprivileges=parseInt(data.substring(sep+1, data.length));
+    var listitem=document.createElement('div');
+    listitem.className='modlistitem';
+    listitem.textContent=account;
+    listitem.account=account;
+    var button=document.createElement('button');
+    button.textContent='X';
+    button.onclick=function(){connection.send('removemod:'+account);};
+    listitem.appendChild(button);
+    document.getElementById('modmanagerlist').appendChild(listitem);
+  }
+  else if(data.data.substring(0,10)=='removemod:')
+  {
+    var account=data.data.substring(10,data.data.length);
+    var list=document.getElementById('modmanagerlist');
+    var i=0;
+    while(i<list.children.length)
+    {
+      if(list.children[i].account==account)
+      {
+        list.removeChild(list.children[i]);
+      }
+      ++i;
+    }
+  }
+  else if(data.data.substring(0,11)=='closemedia:')
+  {
+    var user=finduser(data.data.substring(11, data.data.length));
+    chatnote(user.nickname+'\'s cam was closed');
+    closecam(user);
+    // Handle self being closed
+    if(user==me && broadcasting){broadcast();} // Toggle broadcasting to off
+  }
+  else if(data.data.substring(0,6)=='whois:')
+  {
+    var sep=data.data.indexOf(':', 6);
+// TODO: Prettier presentation
+    if(sep>-1)
+    {
+      alert(data.data.substring(6,sep)+' is logged in as '+data.data.substring(sep+1,data.data.length));
+    }else{
+      alert(data.data.substring(6,data.data.length)+' is not logged in');
+    }
+  }
+  else if(data.data.substring(0,4)=='ban:')
+  {
+    var sep=data.data.indexOf(':', 4);
+    if(sep<0){alert("Error, no separator found");}
+    var banned=data.data.substring(4,sep);
+    var banner=data.data.substring(sep+1,data.data.length);
+    chatnote(banned+' was banned by '+banner);
+  }
+  else if(data.data=='banned')
+  {
+    chatnote('You are banned from this channel');
+    chatnote=function(){}; // Don't tell banned people to reconnect
+  }
+  else if(data.data.substring(0,9)=='listbans:')
+  {
+    document.getElementById('banlist_placeholder').style.display='none';
+    data=data.data.substring(9, data.data.length);
+    var sep=data.indexOf(':');
+    if(sep<0){alert("Error, no separator found");}
+    var name=data.substring(0,sep);
+    var id=parseInt(data.substring(sep+1, data.length));
+    var listitem=document.createElement('div');
+    listitem.className='modlistitem';
+    listitem.textContent=name;
+    listitem.banid=id;
+    var button=document.createElement('button');
+    button.textContent='X';
+    button.onclick=function(){connection.send('unban:'+id);};
+    listitem.appendChild(button);
+    document.getElementById('banlist_list').appendChild(listitem);
+  }
+  else if(data.data.substring(0,6)=='unban:')
+  {
+    var id=data.data.substring(6,data.data.length);
+    var list=document.getElementById('banlist_list');
+    var i=0;
+    while(i<list.children.length)
+    {
+      if(list.children[i].banid==id)
+      {
+        list.removeChild(list.children[i]);
+      }
+      ++i;
+    }
+  }
+  else if(data.data.substring(0,4)=='err:')
+  {
+    alert('Error: '+data.data.substring(4, data.data.length));
+  }
+else
+  alert('Unknown data received: '+data.data);
+}
diff --git a/src/channels.c b/src/channels.c
new file mode 100644
index 0000000..e1e7fef
--- /dev/null
+++ b/src/channels.c
@@ -0,0 +1,173 @@
+/*
+    webchat, an HTML5/websocket chat platform
+    Copyright (C) 2015  alicia@ion.nu
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, version 3 of the License.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+#include <string.h>
+#include <stdlib.h>
+#include <stdio.h>
+#include <netinet/in.h>
+#include "../libwebsocket/websock.h"
+#include "users.h"
+#include "db.h"
+#include "channels.h"
+
+struct channel** channels=0;
+unsigned int channelcount=0;
+
+struct channel* getchannel(const char* name)
+{
+  unsigned int i;
+  for(i=0; i<channelcount; ++i)
+  {
+    if(!strcmp(name, channels[i]->name)){return channels[i];}
+  }
+  ++channelcount;
+  channels=realloc(channels, sizeof(struct channel*)*channelcount);
+  struct channel* channel=malloc(sizeof(struct channel));
+  channels[channelcount-1]=channel;
+  channel->name=strdup(name);
+  channel->usercount=0;
+  channel->users=0;
+  channel->password=0;
+  channel->id=-1;
+  channel->bancount=0;
+  channel->bans=0; // TODO: Load/save bans in the database too?
+printf("Loading channel data for '%s'\n", name);
+  db_findchannel(name, &channel->id, &channel->password);
+printf("ID: %i, password: %s\n", channel->id, channel->password);
+  return channel;
+}
+
+void joinchannel(struct user* user, struct channel* channel)
+{
+  user->channel=channel;
+  ++user->channel->usercount;
+  user->channel->users=realloc(user->channel->users, sizeof(struct user*)*user->channel->usercount);
+  user->channel->users[user->channel->usercount-1]=user;
+  if(channel->id>-1)
+  {
+    websock_write(user->socket, "channelstatus:registered", 24, WEBSOCK_TEXT, use_tls);
+  }else{
+    websock_write(user->socket, "channelstatus:unregistered", 26, WEBSOCK_TEXT, use_tls);
+  }
+  // TODO: move more join-related stuff here, like sending "join:" messages and "names:"
+}
+
+struct user* channel_finduser(struct channel* chan, const unsigned char* nickname)
+{
+  unsigned int i;
+  for(i=0; i<chan->usercount; ++i)
+  {
+    if(!strcmp(nickname, chan->users[i]->nickname)){return chan->users[i];}
+  }
+  return 0;
+}
+
+void channel_write(struct channel* chan, unsigned char* msg, unsigned int msglen, unsigned char opcode, struct user* skip)
+{
+  unsigned int i;
+  for(i=0; i<chan->usercount; ++i)
+  {
+    if(chan->users[i]==skip || !chan->users[i]->handshake){continue;}
+    websock_write(chan->users[i]->socket, msg, msglen, WEBSOCK_TEXT, 1);
+  }
+}
+
+char channel_register(struct channel* channel, struct user* owner)
+{
+  int userid;
+  if(!db_finduser(owner->account, &userid, 0, 0)){return 0;}
+  if(!db_createchannel(channel->name)){return 0;}
+  if(!db_findchannel(channel->name, &channel->id, 0)){return 0;}
+  if(db_addmod(channel->id, userid, PRIV_ALL))
+  {
+    owner->modprivileges=PRIV_ALL;
+    channel_write(channel, "channelstatus:registered", 24, WEBSOCK_TEXT, 0);
+    // Also notify about the new mod
+    user_sendmodmsg(owner);
+    return 1;
+  }
+  db_removechannel(channel->name); // If something went wrong, try to prevent damage in the form of orphaned channels
+  return 0;
+}
+
+void channel_ban(struct channel* channel, struct user* user)
+{
+  unsigned int banid=0;
+  if(channel->bancount>0){banid=channel->bans[channel->bancount-1].id+1;} // ID of last+1 should be unique
+  ++channel->bancount;
+  channel->bans=realloc(channel->bans, sizeof(struct ban)*channel->bancount);
+  struct ban* ban=&channel->bans[channel->bancount-1];
+  ban->addr_family=user->sockaddr.sa_family;
+  char* addr;
+  switch(user->sockaddr.sa_family)
+  {
+  case AF_INET:
+    ban->addr_size=4;
+    addr=malloc(ban->addr_size);
+    memcpy(addr, &((struct sockaddr_in*)&user->sockaddr)->sin_addr.s_addr, ban->addr_size);
+    break;
+  case AF_INET6:
+    ban->addr_size=16;
+    addr=malloc(ban->addr_size);
+    memcpy(addr, ((struct sockaddr_in6*)&user->sockaddr)->sin6_addr.__in6_u.__u6_addr8, ban->addr_size);
+    break;
+  default: // This shouldn't happen, I don't think we're even listening on anything but IPv4 yet, but in case it does happen...
+    printf("Unknown address family %u, banning all of them as a workaround\n", user->sockaddr.sa_family);
+    ban->addr_size=0;
+    addr=0;
+  }
+  ban->addr=addr;
+  ban->id=banid;
+  ban->nickname=strdup(user->nickname);
+}
+
+char channel_unban(struct channel* channel, unsigned int id)
+{
+  unsigned int i;
+  for(i=0; i<channel->bancount; ++i)
+  {
+    if(channel->bans[i].id==id)
+    {
+      free((void*)channel->bans[i].addr);
+      free((void*)channel->bans[i].nickname);
+      --channel->bancount;
+      memmove(&channel->bans[i], &channel->bans[i+1], sizeof(struct ban)*(channel->bancount-i));
+      return 1;
+    }
+  }
+  return 0;
+}
+
+char channel_checkban(struct channel* channel, struct sockaddr* useraddr)
+{
+  unsigned int i;
+  for(i=0; i<channel->bancount; ++i)
+  {
+    if(channel->bans[i].addr_family!=useraddr->sa_family){continue;}
+    void* addr;
+    switch(useraddr->sa_family)
+    {
+    case AF_INET:
+      addr=&((struct sockaddr_in*)useraddr)->sin_addr.s_addr;
+      break;
+    case AF_INET6:
+      addr=((struct sockaddr_in6*)useraddr)->sin6_addr.__in6_u.__u6_addr8;
+      break;
+    }
+    if(!memcmp(addr, channel->bans[i].addr, channel->bans[i].addr_size)){return 1;}
+  }
+  return 0;
+}
diff --git a/src/channels.h b/src/channels.h
new file mode 100644
index 0000000..23baf7e
--- /dev/null
+++ b/src/channels.h
@@ -0,0 +1,44 @@
+/*
+    webchat, an HTML5/websocket chat platform
+    Copyright (C) 2015  alicia@ion.nu
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, version 3 of the License.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+struct ban // Note: we don't bother supporting account bans because accounts are so easily remade it's not worth it
+{
+  sa_family_t addr_family;
+  unsigned int addr_size;
+  const char* addr;
+  unsigned int id; // To avoid weirdness with multiple mods reviewing the mod list at the same time
+  const char* nickname; // For identification in banlist
+// TODO: timestamps for further clarification?
+};
+struct channel
+{
+  const char* name;
+  unsigned int usercount;
+  struct user** users;
+  char* password;
+  int id;
+  unsigned int bancount;
+  struct ban* bans;
+  // TODO: topic?
+};
+extern struct channel* getchannel(const char* name);
+extern void joinchannel(struct user* user, struct channel* channel);
+extern struct user* channel_finduser(struct channel* chan, const unsigned char* nickname);
+extern void channel_write(struct channel* chan, unsigned char* msg, unsigned int msglen, unsigned char opcode, struct user* skip);
+extern char channel_register(struct channel* channel, struct user* owner);
+extern void channel_ban(struct channel* channel, struct user* user);
+extern char channel_unban(struct channel* channel, unsigned int id);
+extern char channel_checkban(struct channel* channel, struct sockaddr* useraddr);
diff --git a/src/chat.c b/src/chat.c
new file mode 100644
index 0000000..2022ab3
--- /dev/null
+++ b/src/chat.c
@@ -0,0 +1,626 @@
+/*
+    webchat, an HTML5/websocket chat platform
+    Copyright (C) 2015  alicia@ion.nu
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, version 3 of the License.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+#include <stdio.h>
+#include <unistd.h>
+#include <string.h>
+#include <stdlib.h>
+#include <sys/socket.h>
+#include <netinet/in.h>
+#include <errno.h>
+#include <poll.h>
+#include <signal.h>
+#include <gnutls/gnutls.h>
+#include "../libwebsocket/websock.h"
+#include "users.h"
+#include "channels.h"
+#include "httpcam.h"
+#include "db.h"
+#define use_tls 1
+
+int createserversock(int port)
+{
+  int sock=socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);
+  struct sockaddr_in srvaddr;
+  srvaddr.sin_family=AF_INET;
+  srvaddr.sin_port=htons(port);
+  srvaddr.sin_addr.s_addr=0;
+  if(bind(sock, (struct sockaddr*)&srvaddr, sizeof(srvaddr)))
+  {
+    perror("bind");
+    printf("Retrying bind to port %i... ", port);
+    fflush(stdout);
+    while(bind(sock, (struct sockaddr*)&srvaddr, sizeof(srvaddr))){sleep(1);}
+    printf("done\n");
+    errno=0;
+  }
+  #define backlog 10 /* Todo: make this configurable */
+  if(listen(sock, backlog)){perror("listen");}
+  return sock;
+}
+
+void uridecode(char* string)
+{
+  if(!string){return;}
+  char buf[3];
+  buf[2]=0;
+  while((string=strchr(string, '%')))
+  {
+    buf[0]=string[1];
+    buf[1]=string[2];
+    string[0]=strtol(buf, 0, 16);
+    string=&string[1];
+    memmove(string, &string[2], strlen(string)-1); // only -1 because we also want to move the null-terminator
+  }
+}
+
+char* channel=0;
+char* channelpassword=0;
+const char* websocket_requirements(const char* path, const char* host, char* protocol, const char* origin)
+{
+  if(strncmp(path, "/chat/", 6)){return 0;}
+/*
+  if(!host || strcasecmp(host, HOSTNAME)){return 0;}
+  if(!origin || strcasecmp(origin, "https://" HOSTNAME "/")){printf("Bad origin: %s\n", origin); return 0;}
+*/
+  if(!protocol || strcmp(protocol, "chat-0.1")){return 0;}
+  free(channel);
+  channel=strdup(&path[6]);
+  channelpassword=0;
+  char* options=strchr(channel, '?');;
+  while(options)
+  {
+    options[0]=0;
+    options=&options[1];
+// printf("Option: %s\n", options);
+    if(!strncmp(options, "password=", 9)){channelpassword=&options[9];}
+    options=strchr(options, '&');
+  }
+  return "chat-0.1";
+}
+
+int main()
+{
+  signal(SIGPIPE, SIG_IGN);
+  int sock=createserversock(4000);
+  db_init();
+  struct sockaddr addr;
+  socklen_t socklen;
+  struct pollfd* fds=malloc(sizeof(struct pollfd));
+  fds[0].fd=sock;
+  fds[0].events=POLLIN;
+  fds[0].revents=0;
+  unsigned int i;
+  unsigned int j;
+  struct websock_head head;
+  while(1)
+  {
+    poll(fds, usercount+1, -1);
+    if(fds[0].revents)
+    {
+printf("New connection\n");
+      fds[0].revents=0;
+      socklen=sizeof(addr);
+      int socket=accept(sock, &addr, &socklen);
+      // TODO: handle accept() returning -1
+      adduser(socket, &addr, socklen);
+      fds=realloc(fds, sizeof(struct pollfd)*(usercount+1));
+      fds[usercount].fd=socket;
+      fds[usercount].events=POLLIN;
+      fds[usercount].revents=0;
+    }
+    for(i=0; i<usercount; ++i)
+    {
+      if(!fds[i+1].revents){continue;}
+      if(fds[i+1].revents&(POLLERR|POLLHUP|POLLNVAL))
+      {
+        if(users[i]->channel)
+        {
+          char quitmsg[strlen("quit:0")+strlen(users[i]->nickname)];
+          sprintf(quitmsg, "quit:%s", users[i]->nickname);
+          channel_write(users[i]->channel, quitmsg, strlen(quitmsg), WEBSOCK_TEXT, users[i]);
+        }
+        freeuser(users[i]);
+        --usercount;
+        memmove(&users[i], &users[i+1], sizeof(struct user*)*(usercount-i));
+        memmove(&fds[i+1], &fds[i+2], sizeof(struct pollfd)*(usercount-i));
+        break;
+      }
+      fds[i+1].revents=0;
+      if(!users[i]->handshake)
+      {
+        if(users[i]->httpcam) // httpcams shouldn't speak
+        {
+          char buf[128];
+          read(users[i]->rawsocket, buf, 128); // Read it and ignore it
+          continue;
+        }
+        users[i]->handshake=websock_handshake_server(users[i]->socket, websocket_requirements, httpcam_cb, use_tls);
+        if(users[i]->handshake)
+        {
+          uridecode(channelpassword);
+          struct channel* chan=getchannel(channel);;
+          if(chan->password && chan->password[0] && (!channelpassword || strcmp(chan->password, channelpassword)))
+          {
+//printf("Wrong/no password given ('%s' != '%s')\n", channelpassword, chan->password);
+            websock_write(users[i]->socket, "password", 8, WEBSOCK_TEXT, use_tls);
+            freeuser(users[i]);
+            --usercount;
+            memmove(&users[i], &users[i+1], sizeof(struct user*)*(usercount-i));
+            memmove(&fds[i+1], &fds[i+2], sizeof(struct pollfd)*(usercount-i));
+            break;
+          }
+          if(channel_checkban(chan, &users[i]->sockaddr))
+          {
+            websock_write(users[i]->socket, "banned", 6, WEBSOCK_TEXT, use_tls);
+            freeuser(users[i]);
+            --usercount;
+            memmove(&users[i], &users[i+1], sizeof(struct user*)*(usercount-i));
+            memmove(&fds[i+1], &fds[i+2], sizeof(struct pollfd)*(usercount-i));
+            break;
+          }
+//printf("User %u finished handshake, and passed password/ban checks, joining channel '%s'\n", i, channel);
+          joinchannel(users[i], chan);
+          char join[strlen(users[i]->nickname)+11];
+          sprintf(join, "startnick:%s", users[i]->nickname);
+          websock_write(users[i]->socket, join, strlen(join), WEBSOCK_TEXT, use_tls);
+          sprintf(join, "join:%s", users[i]->nickname);
+          struct user** chanusers=users[i]->channel->users;
+          unsigned int chanusercount=users[i]->channel->usercount;
+          unsigned int j;
+          for(j=0; j<chanusercount; ++j)
+          {
+            if(!chanusers[j]->handshake){continue;}
+            websock_write(chanusers[j]->socket, join, strlen(join), WEBSOCK_TEXT, use_tls);
+            if(users[i]!=chanusers[j]) // Let user know about everyone already present, TODO: do this cleaner with maybe a 'names:' command instead
+            {
+              char join[strlen(chanusers[j]->nickname)+strlen("color::ffffff0")];
+              sprintf(join, "join:%s", chanusers[j]->nickname);
+              websock_write(users[i]->socket, join, strlen(join), WEBSOCK_TEXT, use_tls);
+              sprintf(join, "color:%s:%s", chanusers[j]->nickname, chanusers[j]->color);
+              websock_write(users[i]->socket, join, strlen(join), WEBSOCK_TEXT, use_tls);
+              if(chanusers[j]->broadcasting)
+              {
+                sprintf(join, "mediastart:%s", chanusers[j]->nickname);
+                websock_write(users[i]->socket, join, strlen(join), WEBSOCK_TEXT, use_tls);
+              }
+            }
+          }
+        }
+        else if(httpcam_cb_cam){httpcam_handle(users[i]);}
+        else{
+          printf("User %u failed handshake\n", i);
+          freeuser(users[i]);
+          --usercount;
+          memmove(&users[i], &users[i+1], sizeof(struct user*)*(usercount-i));
+          memmove(&fds[i+1], &fds[i+2], sizeof(struct pollfd)*(usercount-i));
+        }
+        continue;
+      }
+      char status;
+      if(!(status=websock_readhead(users[i]->socket, &head, use_tls)) || head.opcode==WEBSOCK_CLOSE) // Disconnect
+      {
+        if(status){websock_write(users[i]->socket, 0, 0, WEBSOCK_CLOSE, use_tls);}
+printf("User %u disconnected\n", i);
+        if(users[i]->channel)
+        {
+          char quitmsg[strlen("quit:0")+strlen(users[i]->nickname)];
+          sprintf(quitmsg, "quit:%s", users[i]->nickname);
+          channel_write(users[i]->channel, quitmsg, strlen(quitmsg), WEBSOCK_TEXT, users[i]);
+        }
+        freeuser(users[i]); // also takes care of removing user from channel
+        --usercount;
+        memmove(&users[i], &users[i+1], sizeof(struct user*)*(usercount-i));
+        memmove(&fds[i+1], &fds[i+2], sizeof(struct pollfd)*(usercount-i));
+        break;
+      }
+      unsigned char data[head.length+1];
+      websock_readcontent(users[i]->socket, data, &head, use_tls);
+      data[head.length]=0;
+      // Handle commands
+      if(head.opcode==WEBSOCK_TEXT)
+      {
+printf("Got data from user %u (length: %llu, opcode %u): '%s'\n", i, head.length, head.opcode, data);
+        if(!strncmp(data, "msg:", 4))
+        {
+          unsigned int msglen=head.length+strlen(users[i]->nickname)+1;
+          unsigned char msg[msglen];
+          sprintf(msg, "msg:%s:", users[i]->nickname);
+          memcpy(&msg[strlen(users[i]->nickname)+5], &data[4], head.length-4);
+          channel_write(users[i]->channel, msg, msglen, WEBSOCK_TEXT, users[i]);
+        }
+        else if(!strncmp(data, "pm:", 3))
+        {
+          char* to=&data[3];
+          char* text=strchr(to, ':');
+          if(!text){continue;}
+          text[0]=0;
+          text=&text[1];
+          unsigned int msglen=snprintf(0,0,"pm:%s:%s", users[i]->nickname, text);
+          unsigned char msg[msglen+1];
+          sprintf(msg, "pm:%s:%s", users[i]->nickname, text);
+          struct user* recipient=channel_finduser(users[i]->channel, to);
+          if(!recipient || !recipient->handshake)
+          {
+            websock_write(users[i]->socket, "err:User not found", 18, WEBSOCK_TEXT, use_tls);
+            continue;
+          }
+          websock_write(recipient->socket, msg, msglen, WEBSOCK_TEXT, use_tls);
+        }
+        else if(!strncmp(data, "nick:", 5))
+        {
+          if(strchr(&data[5], ':'))
+          {
+            websock_write(users[i]->socket, "err:Nicknames may not contain :", 31, WEBSOCK_TEXT, use_tls);
+            continue;
+          }
+          if(channel_finduser(users[i]->channel, &data[5]))
+          {
+            websock_write(users[i]->socket, "err:Nickname is already taken", 29, WEBSOCK_TEXT, use_tls);
+            continue;
+          }
+          unsigned int msglen=head.length+strlen(users[i]->nickname)+1;
+          unsigned char msg[msglen];
+          sprintf(msg, "nick:%s:", users[i]->nickname);
+          memcpy(&msg[strlen(users[i]->nickname)+6], &data[5], head.length-5);
+          free(users[i]->nickname);
+          users[i]->nickname=strdup(&data[5]);
+          channel_write(users[i]->channel, msg, msglen, WEBSOCK_TEXT, 0);
+        }
+        if(!strncmp(data, "color:", 6) && head.length==12)
+        {
+          strcpy(users[i]->color, &data[6]);
+          unsigned int msglen=head.length+strlen(users[i]->nickname)+1;
+          unsigned char msg[msglen+1];
+          sprintf(msg, "color:%s:%s", users[i]->nickname, &data[6]);
+          channel_write(users[i]->channel, msg, msglen, WEBSOCK_TEXT, users[i]);
+        }
+        else if(!strcmp(data, "mediastart"))
+        {
+          char msg[strlen("mediastart:0")+strlen(users[i]->nickname)];
+          sprintf(msg, "mediastart:%s", users[i]->nickname);
+          channel_write(users[i]->channel, msg, strlen(msg), WEBSOCK_TEXT, users[i]);
+          users[i]->broadcasting=1;
+        }
+        else if(!strcmp(data, "mediastop"))
+        {
+          char msg[strlen("mediastop:0")+strlen(users[i]->nickname)];
+          sprintf(msg, "mediastop:%s", users[i]->nickname);
+          channel_write(users[i]->channel, msg, strlen(msg), WEBSOCK_TEXT, users[i]);
+          users[i]->broadcasting=0;
+          free(users[i]->firstmedia);
+          users[i]->firstmedia=0;
+          users[i]->firstmedialength=0;
+          user_removerelations(users[i], relation_media);
+        }
+        else if(!strncmp(data, "mediasubscribe:", 15))
+        {
+          struct user* user=channel_finduser(users[i]->channel, &data[15]);
+          if(!user){continue;}
+          user_addrelation(user, users[i], relation_media);
+          if(user->firstmedia)
+          {
+            char msg[strlen("media:0")+strlen(user->nickname)];
+            sprintf(msg, "media:%s", user->nickname);
+            websock_write(users[i]->socket, msg, strlen(msg), WEBSOCK_TEXT, use_tls);
+            websock_write(users[i]->socket, user->firstmedia, user->firstmedialength, WEBSOCK_BINARY, use_tls);
+          }
+        }
+        else if(!strncmp(data, "mediaunsubscribe:", 17))
+        {
+          struct user* user=channel_finduser(users[i]->channel, &data[17]);
+          if(!user){continue;}
+          user_removerelation(user, users[i], relation_media);
+        }
+        else if(!strcmp(data, "media"))
+        {
+          users[i]->bincmd=bincmd_media;
+        }
+        else if(!strncmp(data, "httpcamkey:", 11))
+        {
+          struct user* user=channel_finduser(users[i]->channel, &data[11]);
+          if(!user){continue;}
+          const char* key=httpcam_getkey(user);
+          char msg[strlen("httpcamkey::0")+strlen(user->nickname)+strlen(key)];
+          sprintf(msg, "httpcamkey:%s:%s", user->nickname, key);
+          websock_write(users[i]->socket, msg, strlen(msg), WEBSOCK_TEXT, use_tls);
+        }
+        else if(!strncmp(data, "login:", 6))
+        {
+          char* username=&data[6];
+          char* password=strchr(username, ':');
+          if(!password){continue;}
+          password[0]=0;
+          password=&password[1];
+          if(user_login(users[i], username, password))
+          {
+// TODO: what other info does the client need? does it need to know its username?
+            websock_write(users[i]->socket, "loggedin", 8, WEBSOCK_TEXT, use_tls);
+            if(users[i]->modprivileges)
+            {
+              user_sendmodmsg(users[i]);
+            }
+          }else{
+            websock_write(users[i]->socket, "err:Incorrect username and/or password", 38, WEBSOCK_TEXT, use_tls);
+          }
+        }
+        else if(!strcmp(data, "logout"))
+        {
+          if(!users[i]->account)
+          {
+            websock_write(users[i]->socket, "err:Cannot log out, you were not logged in", 42, WEBSOCK_TEXT, use_tls);
+            continue;
+          }
+          if(users[i]->modprivileges)
+          {
+            users[i]->modprivileges=0;
+            user_sendmodmsg(users[i]);
+          }
+          free(users[i]->account);
+          users[i]->account=0;
+          websock_write(users[i]->socket, "loggedout", 9, WEBSOCK_TEXT, use_tls);
+        }
+        else if(!strncmp(data, "createaccount:", 14))
+        {
+          char* username=&data[14];
+          char* password=strchr(username, ':');
+          if(!password){continue;}
+          password[0]=0;
+          password=&password[1];
+          if(!db_createuser(username, password))
+          {
+            websock_write(users[i]->socket, "err:Failed to create account, maybe the username is already taken", 65, WEBSOCK_TEXT, use_tls);
+          }else{
+            websock_write(users[i]->socket, "createdaccount", 14, WEBSOCK_TEXT, use_tls);
+          }
+        }
+        else if(!strcmp(data, "registerchannel"))
+        {
+          if(!users[i]->account)
+          {
+            websock_write(users[i]->socket, "err:You need an account to register a channel", 45, WEBSOCK_TEXT, use_tls);
+            continue;
+          }
+          if(users[i]->channel->id>-1)
+          {
+            websock_write(users[i]->socket, "err:Channel is already registered", 33, WEBSOCK_TEXT, use_tls);
+            continue;
+          }
+          if(!channel_register(users[i]->channel, users[i]))
+          {
+            websock_write(users[i]->socket, "err:Something unexpected went wrong while registering the channel", 65, WEBSOCK_TEXT, use_tls);
+          }
+        }
+        else if(!strcmp(data, "listmods")) // TODO: should this be restricted to SETMODS? not that non-mods don't deserve to know who the mods are, but it seems like it might be a little inefficient
+        {
+          if(!(users[i]->modprivileges&PRIV_SETMODS)){continue;}
+          if(users[i]->channel->id<0)
+          {
+            websock_write(users[i]->socket, "err:The channel is not registered, it has no mods", 49, WEBSOCK_TEXT, use_tls);
+            continue;
+          }
+          void listcallback(int userid, int privileges) // TODO: move to global scope for compatibility with non-gcc compilers?
+          {
+            char* name=db_getusername(userid);
+            char msg[snprintf(0,0, "listmods:%s:%i", name, privileges)+1];
+            sprintf(msg, "listmods:%s:%i", name, privileges);
+            free(name);
+            websock_write(users[i]->socket, msg, strlen(msg), WEBSOCK_TEXT, use_tls);
+          }
+          db_listmods(users[i]->channel->id, listcallback);
+        }
+        else if(!strncmp(data, "removemod:", 10))
+        {
+          if(!(users[i]->modprivileges&PRIV_SETMODS))
+          {
+            websock_write(users[i]->socket, "err:Insufficient moderator privileges", 37, WEBSOCK_TEXT, use_tls);
+            continue;
+          }
+          if(users[i]->account && !strcmp(users[i]->account, &data[10]))
+          {
+            websock_write(users[i]->socket, "err:To avoid accidentally losing control over your channel, removing yourself as a mod is not permitted", 103, WEBSOCK_TEXT, use_tls);
+            continue;
+          }
+          int userid;
+          db_finduser(&data[10], &userid, 0, 0);
+          if(db_removemod(users[i]->channel->id, userid))
+          {
+            websock_write(users[i]->socket, data, head.length, WEBSOCK_TEXT, use_tls);
+            // See if any of the currently online users are logged in with this account and demod them if they are
+            for(j=0; j<users[i]->channel->usercount; ++j)
+            {
+              if(!users[i]->channel->users[j]->account){continue;}
+              if(strcmp(users[i]->channel->users[j]->account, &data[10])){continue;}
+              users[i]->channel->users[j]->modprivileges=0;
+              user_sendmodmsg(users[i]->channel->users[j]);
+            }
+          }else{
+            websock_write(users[i]->socket, "err:Failed to remove mod", 24, WEBSOCK_TEXT, use_tls);
+          }
+        }
+        else if(!strncmp(data, "addmod:", 7))
+        {
+          char* priv=strchr(&data[7], ':');
+          if(!priv){continue;}
+          unsigned int len=(void*)priv-(void*)&data[7];
+          char account[len+1];
+          memcpy(account, &data[7], len);
+          account[len]=0;
+          if(!(users[i]->modprivileges&PRIV_SETMODS))
+          {
+            websock_write(users[i]->socket, "err:Insufficient moderator privileges", 37, WEBSOCK_TEXT, use_tls);
+            continue;
+          }
+          int privileges=atoi(&priv[1]);
+          int userid;
+          if(!db_finduser(account, &userid, 0, 0))
+          {
+            websock_write(users[i]->socket, "err:Account not found", 21, WEBSOCK_TEXT, use_tls);
+            continue;
+          }
+          if(db_addmod(users[i]->channel->id, userid, privileges))
+          {
+            websock_write(users[i]->socket, data, head.length, WEBSOCK_TEXT, use_tls);
+            // See if any of the currently online users are logged in with this account and mod them if they are
+            for(j=0; j<users[i]->channel->usercount; ++j)
+            {
+              if(!users[i]->channel->users[j]->account){continue;}
+              if(strcmp(users[i]->channel->users[j]->account, account)){continue;}
+              users[i]->channel->users[j]->modprivileges=privileges;
+              user_sendmodmsg(users[i]->channel->users[j]);
+            }
+          }else{
+            websock_write(users[i]->socket, "err:Failed to add mod", 21, WEBSOCK_TEXT, use_tls);
+          }
+        }
+        else if(!strncmp(data, "closemedia:", 11))
+        {
+          if(!(users[i]->modprivileges&PRIV_CLOSECAM))
+          {
+            websock_write(users[i]->socket, "err:Insufficient moderator privileges", 37, WEBSOCK_TEXT, use_tls);
+            continue;
+          }
+          struct user* user=channel_finduser(users[i]->channel, &data[11]);
+          if(!user)
+          {
+            websock_write(users[i]->socket, "err:User not found", 18, WEBSOCK_TEXT, use_tls);
+            continue;
+          }
+          channel_write(user->channel, data, head.length, WEBSOCK_TEXT, 0);
+          user->broadcasting=0;
+          free(user->firstmedia);
+          user->firstmedia=0;
+          user->firstmedialength=0;
+          user_removerelations(user, relation_media);
+        }
+        else if(!strncmp(data, "whois:", 6))
+        {
+          struct user* user=channel_finduser(users[i]->channel, &data[6]);
+          if(!user)
+          {
+            websock_write(users[i]->socket, "err:User not found", 18, WEBSOCK_TEXT, use_tls);
+            continue;
+          }
+          char msg[strlen("whois::0")+strlen(user->nickname)+(user->account?strlen(user->account):0)];
+          if(user->account)
+          {
+            sprintf(msg, "whois:%s:%s", user->nickname, user->account);
+          }else{
+            sprintf(msg, "whois:%s", user->nickname);
+          }
+          websock_write(users[i]->socket, msg, strlen(msg), WEBSOCK_TEXT, use_tls);
+        }
+        else if(!strncmp(data, "ban:", 4))
+        {
+          if(!(users[i]->modprivileges&PRIV_KICK))
+          {
+            websock_write(users[i]->socket, "err:Insufficient moderator privileges", 37, WEBSOCK_TEXT, use_tls);
+            continue;
+          }
+          struct user* user=channel_finduser(users[i]->channel, &data[4]);
+          if(!user)
+          {
+            websock_write(users[i]->socket, "err:User not found", 18, WEBSOCK_TEXT, use_tls);
+            continue;
+          }
+          channel_ban(user->channel, user);
+          char msg[strlen("ban::0")+strlen(user->nickname)+strlen(users[i]->nickname)];
+          sprintf(msg, "ban:%s:%s", user->nickname, users[i]->nickname);
+          channel_write(user->channel, msg, strlen(msg), WEBSOCK_TEXT, 0);
+          close(user->rawsocket);
+        }
+        else if(!strcmp(data, "listbans"))
+        {
+          if(!(users[i]->modprivileges&PRIV_KICK)) // TODO: is there any harm in letting non-mods see the banlist?
+          {
+            websock_write(users[i]->socket, "err:Insufficient moderator privileges", 37, WEBSOCK_TEXT, use_tls);
+            continue;
+          }
+          for(j=0; j<users[i]->channel->bancount; ++j)
+          {
+            char msg[snprintf(0,0, "listbans:%s:%u", users[i]->channel->bans[j].nickname, users[i]->channel->bans[j].id)+1];
+            sprintf(msg, "listbans:%s:%u", users[i]->channel->bans[j].nickname, users[i]->channel->bans[j].id);
+            websock_write(users[i]->socket, msg, strlen(msg), WEBSOCK_TEXT, use_tls);
+          }
+        }
+        else if(!strncmp(data, "unban:", 6))
+        {
+          if(!(users[i]->modprivileges&PRIV_KICK))
+          {
+            websock_write(users[i]->socket, "err:Insufficient moderator privileges", 37, WEBSOCK_TEXT, use_tls);
+            continue;
+          }
+          if(channel_unban(users[i]->channel, strtoul(&data[6],0,0)))
+          {
+            websock_write(users[i]->socket, data, head.length, WEBSOCK_TEXT, use_tls);
+          }else{
+            websock_write(users[i]->socket, "err:Failed to unban", 19, WEBSOCK_TEXT, use_tls);
+          }
+        }
+        else if(!strncmp(data, "setchanpass:", 12))
+        {
+          if(!(users[i]->modprivileges&PRIV_PASSWORD))
+          {
+            websock_write(users[i]->socket, "err:Insufficient moderator privileges", 37, WEBSOCK_TEXT, use_tls);
+            continue;
+          }
+          free(users[i]->channel->password);
+          users[i]->channel->password=strdup(&data[12]);
+          if(!db_setchannelpassword(users[i]->channel->name, users[i]->channel->password))
+          {
+            websock_write(users[i]->socket, "err:Failed to save password, the change will be temporary", 57, WEBSOCK_TEXT, use_tls);
+          }
+        }
+      }
+      else if(head.opcode==WEBSOCK_BINARY)
+      {
+printf("Got binary data from user %u (length: %llu, opcode %u)\n", i, head.length, head.opcode);
+        switch(users[i]->bincmd)
+        {
+        case bincmd_none:
+          printf("Got unexpected binary data! (->bincmd=bincmd_none)\n");
+          break;
+        case bincmd_media:
+          if(head.length==0){break;}
+          if(!users[i]->broadcasting){break;}
+          if(!users[i]->firstmedia)
+          {
+printf("Saving firstmedia of %llu bytes\n", head.length);
+            users[i]->firstmedia=malloc(head.length);
+            users[i]->firstmedialength=head.length;
+            memcpy(users[i]->firstmedia, data, head.length);
+          }
+printf("Got video packet\n");
+          char msg[strlen("media:0")+strlen(users[i]->nickname)];
+          sprintf(msg, "media:%s", users[i]->nickname);
+          for(j=0; j<users[i]->relationcount; ++j)
+          {
+            if(users[i]->relations[j].type!=relation_media){continue;}
+            if(users[i]->relations[j].user->httpcam)
+            {
+              httpcam_sendchunk(users[i]->relations[j].user->socket, data, head.length);
+              continue;
+            }
+            websock_write(users[i]->relations[j].user->socket, msg, strlen(msg), WEBSOCK_TEXT, use_tls);
+            websock_write(users[i]->relations[j].user->socket, data, head.length, WEBSOCK_BINARY, use_tls);
+          }
+        }
+        users[i]->bincmd=bincmd_none;
+      }
+    }
+  }
+  return 0;
+}
diff --git a/src/db.c b/src/db.c
new file mode 100644
index 0000000..8076f57
--- /dev/null
+++ b/src/db.c
@@ -0,0 +1,309 @@
+/*
+    webchat, an HTML5/websocket chat platform
+    Copyright (C) 2015  alicia@ion.nu
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, version 3 of the License.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+#include <string.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <unistd.h>
+#include <fcntl.h>
+#include <ctype.h>
+#include <sqlite3.h>
+#include <gnutls/gnutls.h>
+#include "db.h"
+
+#define db_processing(code) ((code)==SQLITE_ROW)
+
+sqlite3* db=0;
+void db_init(void)
+{
+  // TODO: Specify filename somewhere else?
+  sqlite3_initialize();
+  if(sqlite3_open_v2("chat.db", &db, SQLITE_OPEN_READWRITE, 0))
+  {
+    printf("Error: Failed to open database! accounts/channel registration/mods will not work\n");
+  }
+  sqlite3_exec(db, "PRAGMA foreign_keys = ON;", 0, 0, 0);
+}
+
+void db_shutdown(void)
+{
+  sqlite3_shutdown();
+}
+
+char db_findchannel(const char* name, int* id, char** password)
+{
+  sqlite3_stmt* statement;
+  // TODO: consider saving statements and reusing them by sqlite3_reset() and sqlite3_clear_bindings()
+  sqlite3_prepare_v2(db, "select password, id from channels where name=?1;", -1, &statement, 0);
+  sqlite3_bind_text(statement, 1, name, strlen(name), SQLITE_STATIC);
+  char foundchannel=0;
+  int r;
+  while(db_processing(r=sqlite3_step(statement)))
+  {
+    if(r==SQLITE_ROW)
+    {
+      if(password){*password=strdup(sqlite3_column_text(statement, 0));}
+      if(id){*id=sqlite3_column_int(statement, 1);}
+      foundchannel=1;
+      break;
+    }
+  }
+  sqlite3_finalize(statement);
+  return foundchannel;
+}
+
+char db_finduser(const char* name, int* id, char** password, char** salt)
+{
+  sqlite3_stmt* statement;
+  // TODO: consider saving statements and reusing them by sqlite3_reset() and sqlite3_clear_bindings()
+  sqlite3_prepare_v2(db, "select password, salt, id from users where name=?1;", -1, &statement, 0);
+  sqlite3_bind_text(statement, 1, name, strlen(name), SQLITE_STATIC);
+  char founduser=0;
+  int r;
+  while(db_processing(r=sqlite3_step(statement)))
+  {
+    if(r==SQLITE_ROW)
+    {
+      if(password){*password=strdup(sqlite3_column_text(statement, 0));}
+      if(salt){*salt=strdup(sqlite3_column_text(statement, 1));}
+      if(id){*id=sqlite3_column_int(statement, 2);}
+      founduser=1;
+      break;
+    }
+  }
+  sqlite3_finalize(statement);
+  return founduser;
+}
+
+int db_getmod(int channel, int user)
+{
+  int privilege=0;
+  sqlite3_stmt* statement;
+  // TODO: consider saving statements and reusing them by sqlite3_reset() and sqlite3_clear_bindings()
+  sqlite3_prepare_v2(db, "select privilege from mods where channel=?1 and userid=?2;", -1, &statement, 0);
+  sqlite3_bind_int(statement, 1, channel);
+  sqlite3_bind_int(statement, 2, user);
+  int r;
+  while(db_processing(r=sqlite3_step(statement)))
+  {
+    if(r==SQLITE_ROW)
+    {
+      privilege=sqlite3_column_int(statement, 0);
+      break;
+    }
+  }
+  sqlite3_finalize(statement);
+  return privilege;
+}
+
+char db_setchannelpassword(const char* channel, const char* password)
+{
+  sqlite3_stmt* statement;
+  // TODO: consider saving statements and reusing them by sqlite3_reset() and sqlite3_clear_bindings()
+  sqlite3_prepare_v2(db, "update channels set password=?1 where name=?2;", -1, &statement, 0);
+  sqlite3_bind_text(statement, 1, password, strlen(password), SQLITE_STATIC);
+  sqlite3_bind_text(statement, 2, channel, strlen(channel), SQLITE_STATIC);
+  int r;
+  while(db_processing(r=sqlite3_step(statement)));
+  sqlite3_finalize(statement);
+  return (r==SQLITE_DONE);
+}
+
+void db_mkhash(const char* password, const char* salt, char output[129])
+{
+  // Hash the given password+salt
+  gnutls_datum_t data;
+  data.size=strlen(salt)+strlen(password);
+  char buf[data.size+1];
+  data.data=buf;
+  strcpy(buf, salt);
+  strcat(buf, password);
+  char hash[64];
+  size_t hashsize=64;
+  char ret=0;
+  int r=gnutls_fingerprint(GNUTLS_DIG_SHA512, &data, hash, &hashsize);
+  // Encode the binary hash to hexadecimal
+  data.data=hash;
+  data.size=hashsize;
+  hashsize=129;
+  r=gnutls_hex_encode(&data, output, &hashsize);
+  output[128]=0;
+// printf("db_mkhash(): '%s'\n", output);
+}
+
+void db_gensalt(char salt[17])
+{
+  int f=open("/dev/urandom", O_RDONLY);
+  unsigned int i;
+  for(i=0; i<16; ++i)
+  {
+    do{
+      read(f, &salt[i], 1);
+    }while(!isprint(salt[i]));
+  }
+  salt[16]=0;
+  close(f);
+// printf("db_gensalt(): '%s'\n", salt);
+}
+
+char db_setuserpassword(const char* user, const char* password)
+{
+  // Generate random 16-byte salt
+  char salt[17];
+  db_gensalt(salt);
+  char hash[129];
+  db_mkhash(password, salt, hash);
+  sqlite3_stmt* statement;
+  // TODO: consider saving statements and reusing them by sqlite3_reset() and sqlite3_clear_bindings()
+  sqlite3_prepare_v2(db, "update users set password=?1, salt=?2 where name=?3;", -1, &statement, 0);
+  sqlite3_bind_text(statement, 1, hash, strlen(hash), SQLITE_STATIC);
+  sqlite3_bind_text(statement, 2, salt, 16, SQLITE_STATIC);
+  sqlite3_bind_text(statement, 3, user, strlen(user), SQLITE_STATIC);
+  int r;
+  while(db_processing(r=sqlite3_step(statement)));
+  sqlite3_finalize(statement);
+  return (r==SQLITE_DONE);
+}
+
+char db_createuser(const char* user, const char* password)
+{
+  // Generate random 16-byte salt
+  char salt[17];
+  db_gensalt(salt);
+  char hash[129];
+  db_mkhash(password, salt, hash);
+  sqlite3_stmt* statement;
+  // TODO: consider saving statements and reusing them by sqlite3_reset() and sqlite3_clear_bindings()
+  sqlite3_prepare_v2(db, "insert into users values(?1, ?2, ?3, null);", -1, &statement, 0);
+  sqlite3_bind_text(statement, 1, user, strlen(user), SQLITE_STATIC);
+  sqlite3_bind_text(statement, 2, hash, strlen(hash), SQLITE_STATIC);
+  sqlite3_bind_text(statement, 3, salt, 16, SQLITE_STATIC);
+  int r;
+  while(db_processing(r=sqlite3_step(statement)));
+  sqlite3_finalize(statement);
+  return (r==SQLITE_DONE);
+}
+
+char db_addmod(int channel, int user, int privileges)
+{
+  sqlite3_stmt* statement;
+  // TODO: consider saving statements and reusing them by sqlite3_reset() and sqlite3_clear_bindings()
+  sqlite3_prepare_v2(db, "insert into mods values(?1, ?2, ?3);", -1, &statement, 0);
+  sqlite3_bind_int(statement, 1, channel);
+  sqlite3_bind_int(statement, 2, user);
+  sqlite3_bind_int(statement, 3, privileges);
+  int r;
+  while(db_processing(r=sqlite3_step(statement)));
+  sqlite3_finalize(statement);
+  return (r==SQLITE_DONE);
+}
+
+char db_removemod(int channel, int user)
+{
+  sqlite3_stmt* statement;
+  // TODO: consider saving statements and reusing them by sqlite3_reset() and sqlite3_clear_bindings()
+  sqlite3_prepare_v2(db, "delete from mods where channel=?1 and userid=?2;", -1, &statement, 0);
+  sqlite3_bind_int(statement, 1, channel);
+  sqlite3_bind_int(statement, 2, user);
+  int r;
+  while(db_processing(r=sqlite3_step(statement)));
+  sqlite3_finalize(statement);
+  return (r==SQLITE_DONE);
+}
+
+char db_changemod(int channel, int user, int privileges)
+{
+  sqlite3_stmt* statement;
+  // TODO: consider saving statements and reusing them by sqlite3_reset() and sqlite3_clear_bindings()
+  sqlite3_prepare_v2(db, "update mods set privilege=?1 where channel=?2 and userid=?3;", -1, &statement, 0);
+  sqlite3_bind_int(statement, 1, privileges);
+  sqlite3_bind_int(statement, 2, channel);
+  sqlite3_bind_int(statement, 3, user);
+  int r;
+  while(db_processing(r=sqlite3_step(statement)));
+  sqlite3_finalize(statement);
+  return (r==SQLITE_DONE);
+}
+
+void db_listmods(int channel, void(*modcallback)(int userid, int privileges))
+{
+  sqlite3_stmt* statement;
+  // TODO: consider saving statements and reusing them by sqlite3_reset() and sqlite3_clear_bindings()
+  sqlite3_prepare_v2(db, "select userid, privilege from mods where channel=?1;", -1, &statement, 0);
+  sqlite3_bind_int(statement, 1, channel);
+  int r;
+  while(db_processing(r=sqlite3_step(statement)))
+  {
+    if(r==SQLITE_ROW)
+    {
+      modcallback(sqlite3_column_int(statement, 0), sqlite3_column_int(statement, 1));
+    }
+  }
+  sqlite3_finalize(statement);
+}
+
+char* db_getusername(int userid) // Caller will need to free the returned string
+{
+  sqlite3_stmt* statement;
+  // TODO: consider saving statements and reusing them by sqlite3_reset() and sqlite3_clear_bindings()
+  sqlite3_prepare_v2(db, "select name from users where id=?1;", -1, &statement, 0);
+  sqlite3_bind_int(statement, 1, userid);
+  char* username=0;
+  int r;
+  while(db_processing(r=sqlite3_step(statement)))
+  {
+    if(r==SQLITE_ROW)
+    {
+      username=strdup(sqlite3_column_text(statement, 0));
+      break;
+    }
+  }
+  sqlite3_finalize(statement);
+  return username;
+}
+
+char db_createchannel(const char* channel)
+{
+  sqlite3_stmt* statement;
+  // TODO: consider saving statements and reusing them by sqlite3_reset() and sqlite3_clear_bindings()
+  sqlite3_prepare_v2(db, "insert into channels values(?1, '', null);", -1, &statement, 0);
+  sqlite3_bind_text(statement, 1, channel, strlen(channel), SQLITE_STATIC);
+  int r;
+  while(db_processing(r=sqlite3_step(statement)));
+  sqlite3_finalize(statement);
+  return (r==SQLITE_DONE);
+}
+
+// TODO: function to remove account too? though this brings up questions about how to deal with sole channel owners
+char db_removechannel(const char* channel)
+{
+  sqlite3_stmt* statement;
+  int id;
+  if(!db_findchannel(channel, &id, 0)){return 0;}
+  // Delete channel's mods
+  // TODO: consider saving statements and reusing them by sqlite3_reset() and sqlite3_clear_bindings()
+  sqlite3_prepare_v2(db, "delete from mods where channel=?1;", -1, &statement, 0);
+  sqlite3_bind_int(statement, 1, id);
+  int r;
+  while(db_processing(r=sqlite3_step(statement)));
+  sqlite3_finalize(statement);
+  // Delete the channel itself
+  sqlite3_prepare_v2(db, "delete from channels where name=?1;", -1, &statement, 0);
+  sqlite3_bind_text(statement, 1, channel, strlen(channel), SQLITE_STATIC);
+  while(db_processing(r=sqlite3_step(statement)));
+  sqlite3_finalize(statement);
+  return (r==SQLITE_DONE);
+}
diff --git a/src/db.h b/src/db.h
new file mode 100644
index 0000000..d347eac
--- /dev/null
+++ b/src/db.h
@@ -0,0 +1,43 @@
+/*
+    webchat, an HTML5/websocket chat platform
+    Copyright (C) 2015  alicia@ion.nu
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, version 3 of the License.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+#define PRIV_PLAYMEDIA   1 // Play youtube videos
+#define PRIV_CLOSECAM    2 // Close frozen, asleep or inappropriate cams
+#define PRIV_TOPIC       4 // Set the channel topic
+#define PRIV_KICK        8 // Kick or ban trolls/creeps
+#define PRIV_SETMODS    16 // Add/remove mods, set privileges
+#define PRIV_PASSWORD 32 // Change the channel password (TODO: Override entrance for mods? only some mods?)
+#define PRIV_ALL        63
+#define PRIV_NONOWNER   15 // For simplicity's sake, start with just distinguishing owner (who can set mods) and non-owner mods
+// Idea: privilege to temporarily mod users with e.g. PLAYMEDIA, CLOSECAM, TOPIC and KICK privileges
+
+extern void db_init(void);
+extern void db_shutdown(void);
+extern char db_findchannel(const char* name, int* id, char** password);
+extern char db_finduser(const char* name, int* id, char** password, char** salt);
+extern int db_getmod(int channel, int user);
+extern char db_setchannelpassword(const char* channel, const char* password);
+extern void db_mkhash(const char* password, const char* salt, char output[129]);
+extern char db_setuserpassword(const char* user, const char* password);
+extern char db_createuser(const char* user, const char* password);
+extern char db_addmod(int channel, int user, int privileges);
+extern char db_removemod(int channel, int user);
+extern char db_changemod(int channel, int user, int privileges);
+extern void db_listmods(int channel, void(*modcallback)(int userid, int privileges));
+extern char* db_getusername(int userid); // Caller will need to free the returned string
+extern char db_createchannel(const char* channel);
+extern char db_removechannel(const char* channel);
+// TODO: function to remove account too? though this brings up questions about how to deal with sole channel owners
diff --git a/src/httpcam.c b/src/httpcam.c
new file mode 100644
index 0000000..abebef4
--- /dev/null
+++ b/src/httpcam.c
@@ -0,0 +1,127 @@
+/*
+    webchat, an HTML5/websocket chat platform
+    Copyright (C) 2015  alicia@ion.nu
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, version 3 of the License.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+// A workaround for MediaSource not being widely supported yet
+#include <string.h>
+#include <stdlib.h>
+#include <unistd.h>
+#include <stdio.h>
+#include <fcntl.h>
+#include <ctype.h>
+#include <gnutls/gnutls.h>
+#include "users.h"
+#include "httpcam.h"
+struct httpcamkey
+{
+  struct user* user;
+  const char* key;
+};
+struct httpcamkey* httpcam_keys=0;
+unsigned int httpcam_keycount=0;
+
+struct user* httpcam_cb_cam=0;
+char httpcam_cb(const char* path, const char* host)
+{
+  httpcam_cb_cam=0;
+//  if(!host || strcmp(host, HOSTNAME)){return 0;}
+  if(path && !strncmp(path, "/cam/", 5))
+  {
+    unsigned int i;
+    for(i=0; i<httpcam_keycount; ++i)
+    {
+      if(!strcmp(&path[5], httpcam_keys[i].key))
+      {
+        httpcam_cb_cam=httpcam_keys[i].user;
+        free((void*)httpcam_keys[i].key);
+        --httpcam_keycount;
+        memmove(&httpcam_keys[i], &httpcam_keys[i+1], sizeof(struct httpcamkey)*(httpcam_keycount-i));
+        return 1;
+      }
+    }
+  }
+  return 0;
+}
+
+void httpcam_handle(struct user* user)
+{
+  // Set up user to be a httpcam user, send initial headers to set up chunking
+  user->httpcam=1;
+#ifdef use_tls
+  gnutls_record_send(user->socket, "HTTP/1.1 200 Ok\r\nContent-type: video/webm\r\nTransfer-encoding: chunked\r\n\r\n", 73);
+#else
+  write(*user->socket, "HTTP/1.1 200 Ok\r\nContent-type: video/webm\r\nTransfer-encoding: chunked\r\n\r\n", 73);
+#endif
+  user_addrelation(httpcam_cb_cam, user, relation_media);
+  httpcam_cb_cam=0;
+// TODO: is it safe to assume that httpcam users only have one relation?
+  if(user->relations[0].user->firstmedia)
+  {
+    httpcam_sendchunk(user->socket, user->relations[0].user->firstmedia, user->relations[0].user->firstmedialength);
+  }
+}
+
+const char* httpcam_getkey(struct user* user)
+{
+  // Create a key to match this user
+  char* key=malloc(17);
+  int f=open("/dev/urandom", O_RDONLY);
+  unsigned int i=0;
+  char buf;
+  while(i<16)
+  {
+    read(f, &buf, 1);
+    if(isalnum(buf))
+    {
+      key[i]=buf;
+      ++i;
+    }
+  }
+  close(f);
+  key[16]=0;
+  ++httpcam_keycount;
+  httpcam_keys=realloc(httpcam_keys, sizeof(struct httpcamkey)*httpcam_keycount);
+  httpcam_keys[httpcam_keycount-1].user=user;
+  httpcam_keys[httpcam_keycount-1].key=key;
+  return key;
+}
+
+#ifdef use_tls
+void httpcam_sendchunk(gnutls_session_t socket, void* data, unsigned int size)
+{
+  char buf[snprintf(0,0,"%x\r\n", size)+1];
+  sprintf(buf, "%x\r\n", size);
+  gnutls_record_send(socket, buf, strlen(buf));
+  int w;
+  while(size>0 && (w=gnutls_record_send(socket, data, size))>0)
+  {
+    data+=w;
+    size-=w;
+  }
+  gnutls_record_send(socket, "\r\n", 2);
+}
+#else
+void httpcam_sendchunk(int* socket, void* data, unsigned int size)
+{
+  dprintf(*socket, "%x\r\n", size);
+  int w;
+  while(size>0 && (w=write(*socket, data, size))>0)
+  {
+    data+=w;
+    size-=w;
+  }
+  dprintf(*socket, "\r\n", 2);
+}
+#endif
diff --git a/src/httpcam.h b/src/httpcam.h
new file mode 100644
index 0000000..cb70fc2
--- /dev/null
+++ b/src/httpcam.h
@@ -0,0 +1,25 @@
+/*
+    webchat, an HTML5/websocket chat platform
+    Copyright (C) 2015  alicia@ion.nu
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, version 3 of the License.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+extern struct user* httpcam_cb_cam;
+extern char httpcam_cb(const char* path, const char* host);
+extern void httpcam_handle(struct user* user);
+extern const char* httpcam_getkey(struct user* user);
+#ifdef use_tls
+extern void httpcam_sendchunk(gnutls_session_t socket, void* data, unsigned int size);
+#else
+extern void httpcam_sendchunk(int socket, void* data, unsigned int size);
+#endif
diff --git a/src/users.c b/src/users.c
new file mode 100644
index 0000000..00e057c
--- /dev/null
+++ b/src/users.c
@@ -0,0 +1,210 @@
+/*
+    webchat, an HTML5/websocket chat platform
+    Copyright (C) 2015  alicia@ion.nu
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, version 3 of the License.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+#include <string.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <unistd.h>
+#include <sys/socket.h>
+#include <gnutls/gnutls.h>
+#include "../libwebsocket/websock.h"
+#include "channels.h"
+#include "db.h"
+#include "users.h"
+
+unsigned int usercount=0;
+struct user** users=0;
+
+gnutls_certificate_credentials_t cert=0;
+gnutls_priority_t priority=0;
+
+struct user* adduser(int socket, struct sockaddr* addr, socklen_t addrlen)
+{
+  ++usercount;
+  users=realloc(users, sizeof(struct user*)*usercount);
+  users[usercount-1]=malloc(sizeof(struct user));
+#ifdef use_tls
+  if(!cert)
+  {
+    gnutls_global_init();
+    gnutls_certificate_allocate_credentials(&cert);
+    gnutls_certificate_set_x509_key_file(cert, "cert.pem", "key.pem", GNUTLS_X509_FMT_PEM);
+    gnutls_priority_init(&priority, "PERFORMANCE:%SERVER_PRECEDENCE", 0);
+  }
+  gnutls_init(&users[usercount-1]->socket, GNUTLS_SERVER);
+  gnutls_priority_set(users[usercount-1]->socket, priority);
+  gnutls_credentials_set(users[usercount-1]->socket, GNUTLS_CRD_CERTIFICATE, cert);
+  gnutls_certificate_server_set_request(users[usercount-1]->socket, GNUTLS_CERT_IGNORE);
+  gnutls_transport_set_int(users[usercount-1]->socket, socket);
+  int ret;
+  do{
+    ret=gnutls_handshake(users[usercount-1]->socket);
+  }
+  while(ret<0 && !gnutls_error_is_fatal(ret));
+
+  users[usercount-1]->rawsocket=socket;
+#else
+  users[usercount-1]->rawsocket=socket;
+  users[usercount-1]->socket=&users[usercount-1]->rawsocket;
+#endif
+  users[usercount-1]->handshake=0;
+  users[usercount-1]->channel=0;
+  users[usercount-1]->bincmd=bincmd_none;
+  users[usercount-1]->relations=0;
+  users[usercount-1]->relationcount=0;
+  users[usercount-1]->broadcasting=0;
+  users[usercount-1]->firstmedia=0;
+  users[usercount-1]->firstmedialength=0;
+  users[usercount-1]->httpcam=0;
+  users[usercount-1]->account=0;
+  users[usercount-1]->modprivileges=0;
+  unsigned int i=0;
+  unsigned int j=0;
+  while(j<usercount-1) // Find unique startnick
+  {
+    if(!strncmp(users[j]->nickname, "Guest-", 6) && atoi(&users[j]->nickname[6])==i){++i; j=0; continue;}
+    ++j;
+  }
+// TODO: option to set nickname during handshake if it's not already taken, avoid guest joins
+  users[usercount-1]->nickname=malloc(snprintf(0,0,"Guest-%u", i)+1);
+  sprintf(users[usercount-1]->nickname, "Guest-%u", i);
+  strcpy(users[usercount-1]->color, "000000");
+  memcpy(&users[usercount-1]->sockaddr, addr, addrlen);
+  return users[usercount-1];
+}
+
+void freeuser(struct user* user)
+{
+  unsigned int i;
+  if(user->channel)
+  {
+    for(i=0; i<user->channel->usercount; ++i)
+    {
+      if(user->channel->users[i]==user)
+      {
+        --user->channel->usercount;
+        memmove(&user->channel->users[i], &user->channel->users[i+1], sizeof(struct user*)*(user->channel->usercount-i));
+      }
+    }
+  }
+  for(i=0; i<user->relationcount; ++i)
+  {
+    unsigned int j;
+    for(j=0; j<user->relations[i].user->relationcount; ++j)
+    {
+      if(user->relations[i].user->relations[j].user==user)
+      {
+        --user->relations[i].user->relationcount;
+        memmove(&user->relations[i].user->relations[j], &user->relations[i].user->relations[j+1], sizeof(struct relation)*(user->relations[i].user->relationcount-j));
+        --j; // (moved everything one step down)
+      }
+    }
+  }
+  free(user->relations);
+  free(user->nickname);
+#ifdef use_tls
+  gnutls_deinit(user->socket);
+#endif
+  close(user->rawsocket);
+  free(user->account);
+  free(user);
+}
+
+void user_addrelation(struct user* a, struct user* b, enum relationtype type)
+{
+  ++a->relationcount;
+  a->relations=realloc(a->relations, sizeof(struct relation)*a->relationcount);
+  a->relations[a->relationcount-1].user=b;
+  a->relations[a->relationcount-1].type=type;
+  type=(type%2?type-1:type+1); // Opposite type
+  ++b->relationcount;
+  b->relations=realloc(b->relations, sizeof(struct relation)*b->relationcount);
+  b->relations[b->relationcount-1].user=a;
+  b->relations[b->relationcount-1].type=type;
+}
+
+void user_removerelation(struct user* a, struct user* b, enum relationtype type)
+{
+  unsigned int i;
+  for(i=0; i<a->relationcount; ++i)
+  {
+    if(a->relations[i].user==b && a->relations[i].type==type)
+    {
+      --a->relationcount;
+      memmove(&a->relations[i], &a->relations[i+1], sizeof(struct relation)*(a->relationcount-i));
+      --i;
+    }
+  }
+  type=(type%2?type-1:type+1); // Opposite type
+  for(i=0; i<b->relationcount; ++i)
+  {
+    if(b->relations[i].user==a && b->relations[i].type==type)
+    {
+      --b->relationcount;
+      memmove(&b->relations[i], &b->relations[i+1], sizeof(struct relation)*(b->relationcount-i));
+      --i;
+    }
+  }
+}
+
+void user_removerelations(struct user* user, enum relationtype type)
+{
+  unsigned int i;
+  for(i=0; i<user->relationcount; ++i)
+  {
+    if(user->relations[i].type==type)
+    {
+      user_removerelation(user, user->relations[i].user, type);
+      --i;
+    }
+  }
+}
+
+char user_login(struct user* user, const char* username, const char* password)
+{
+  char* passhash;
+  char* salt;
+  int userid;
+  if(!db_finduser(username, &userid, &passhash, &salt)){return 0;}
+  // Hash the given password+salt
+  char hash[129];
+  db_mkhash(password, salt, hash);
+  if(!strcmp(hash, passhash))
+  {
+    free(passhash);
+    free(salt);
+    // Mark user as logged in, check for mod privileges
+    free(user->account); // Avoid unlikely but potential memory leak (logging in again without logging out)
+    user->account=strdup(username);
+    if(user->channel->id>-1)
+    {
+      user->modprivileges=db_getmod(user->channel->id, userid);
+    }else{
+      user->modprivileges=0;
+    }
+    return 1;
+  }
+  free(passhash);
+  free(salt);
+  return 0;
+}
+
+void user_sendmodmsg(struct user* user)
+{
+  char modmsg[snprintf(0,0, "mod:%s:%i", user->nickname, user->modprivileges)+1];
+  sprintf(modmsg, "mod:%s:%i", user->nickname, user->modprivileges);
+  channel_write(user->channel, modmsg, strlen(modmsg), WEBSOCK_TEXT, 0);
+}
diff --git a/src/users.h b/src/users.h
new file mode 100644
index 0000000..d6f8446
--- /dev/null
+++ b/src/users.h
@@ -0,0 +1,67 @@
+/*
+    webchat, an HTML5/websocket chat platform
+    Copyright (C) 2015  alicia@ion.nu
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU Affero General Public License as published by
+    the Free Software Foundation, version 3 of the License.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU Affero General Public License for more details.
+
+    You should have received a copy of the GNU Affero General Public License
+    along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+#include <sys/socket.h>
+#include <gnutls/gnutls.h>
+#define use_tls 1
+struct channel;
+enum bincmd_type
+{
+  bincmd_none,
+  bincmd_media
+};
+enum relationtype
+{
+  relation_media=0,
+  relation_media_receiver
+};
+struct relation
+{
+  struct user* user;
+  enum relationtype type;
+};
+struct user
+{
+#ifdef use_tls
+  gnutls_session_t socket;
+#else
+  int* socket;
+#endif
+  int rawsocket;
+  char handshake;
+  char* nickname;
+  char color[7];
+  struct channel* channel;
+  enum bincmd_type bincmd; // For multipart commands
+  struct relation* relations;
+  unsigned int relationcount;
+  char broadcasting;
+  void* firstmedia;
+  unsigned int firstmedialength;
+  char httpcam;
+  char* account;
+  int modprivileges;
+  struct sockaddr sockaddr;
+};
+extern unsigned int usercount;
+extern struct user** users;
+extern struct user* adduser(int socket, struct sockaddr* addr, socklen_t addrlen);
+extern void freeuser(struct user* user);
+extern void user_addrelation(struct user* a, struct user* b, enum relationtype type);
+extern void user_removerelation(struct user* a, struct user* b, enum relationtype type);
+extern void user_removerelations(struct user* user, enum relationtype type);
+extern char user_login(struct user* user, const char* username, const char* password);
+extern void user_sendmodmsg(struct user* user);
diff --git a/tabs.js b/tabs.js
new file mode 100644
index 0000000..35dba8e
--- /dev/null
+++ b/tabs.js
@@ -0,0 +1,95 @@
+/**
+ * @licstart  The following is the entire license notice for the 
+ *  JavaScript code in this page.
+ *
+ * Copyright (C) 2015  Alicia ( https://ion.nu/ )
+ *
+ * The JavaScript code in this page is free software: you can
+ * redistribute it and/or modify it under the terms of the GNU
+ * Affero General Public License (GNU AGPL) as published by the Free Software
+ * Foundation, version 3 of the License.
+ * The code is distributed WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS
+ * FOR A PARTICULAR PURPOSE.  See the GNU AGPL for more details.
+ *
+ * As additional permission under GNU AGPL version 3 section 7, you
+ * may distribute non-source (e.g., minimized or compacted) forms of
+ * that code without the copy of the GNU AGPL normally required by
+ * section 4, provided you include this license notice and a URL
+ * through which recipients can access the Corresponding Source.
+ *
+ * @licend  The above is the entire license notice
+ * for the JavaScript code in this page.
+ */
+var tabs=new Array();
+var currenttab;
+function tab_focus()
+{
+  currenttab=this;
+  var i=0;
+  while(i<tabs.length)
+  {
+    tabs[i].chat.style.display='none';
+    tabs[i].tab.className='tab';
+    ++i;
+  }
+  this.chat.style.display='inline-block';
+  this.tab.className='tab tabfocus';
+}
+function tab_setname(name)
+{
+  this.name=name;
+  this.tab.textContent=name;
+}
+function tab_close()
+{
+  document.getElementById('chat').removeChild(this.chat);
+  document.getElementById('tabs').removeChild(this.tab);
+  var index=tabs.indexOf(this);
+  if(index<0){return;} // Shouldn't happen (hasn't happened, but just in case)
+  tabs.splice(index, 1);
+  this.focus=function(){}; // Prevent re-focusing on closed tab
+  if(this==currenttab){tabs[0].focus();}
+}
+function Tab(name)
+{
+  this.name=name;
+  this.chat=document.createElement('div');
+  this.chat.className='chatbox';
+  this.tab=document.createElement('div');
+  this.tab.textContent=name;
+  var tab=this;
+  if(tabs.length>0)
+  {
+    this.chat.style.display='none'; // Hide new tabs
+    this.tab.className='tab';
+    // And give them close-buttons
+    var close=document.createElement('button');
+    close.textContent='X';
+    close.onclick=function(){tab.close();}
+    this.tab.appendChild(close);
+  }else{
+    this.tab.className='tab tabfocus';
+    currenttab=this;
+  }
+  this.tab.onclick=function()
+  {
+    tab.focus();
+  };
+  document.getElementById('chat').appendChild(this.chat);
+  document.getElementById('tabs').appendChild(this.tab);
+  this.focus=tab_focus;
+  this.setname=tab_setname;
+  this.close=tab_close;
+  this.dead=false; // dead tab = user disconnected
+}
+function findtab(name)
+{
+  var i=1; // Skip the main tab
+  while(i<tabs.length)
+  {
+    if(!tabs[i].dead && tabs[i].name==name){return tabs[i];}
+    ++i;
+  }
+  return false;
+}