/*! @file @id $Id$ This is the main application as it is fully run in the user's browser. @dot digraph X { start [URL="\ref start()"]; newuser [URL="\ref newuser()"]; login [URL="\ref login()"]; createkeypair [URL="\ref createkeypair()"]; chat [URL="\ref chat()"]; getpwd [URL="\ref getpwd()"]; setpw [URL="\ref setpw()"]; get [URL="\ref get()"]; sendmessage [URL="\ref sendmessage()"]; start -> newuser [label="if no keys exist"]; start -> login [label="if keys exist"]; newuser -> createkeypair [label="on submit"]; createkeypair -> "openpgp.generateKey"; "openpgp.generateKey" -> login [label="keys generated in local store"]; login -> chat [label="user is valid on server"]; chat -> getpwd [label="password not yet entered"]; getpwd -> setpw [label="on input"]; setpw -> chat [label="password is valid"]; chat -> chat [label="remain in chat"]; chat -> get [label="start timer"]; get -> get [label="restart timer"]; chat -> sendmessage [label="on submit"]; sendmessage -> chat [label="remain in chat"]; } @enddot */ // 1 2 3 4 5 6 7 8 // 45678901234567890123456789012345678901234567890123456789012345678901234567890 function log() { //[].push.call(arguments, (new Error()).stack) console.log.apply(null, arguments) } function SafeChat() { /// Create UID from a name by appending an E-Mail function uid(name) { return name+' <'+mail(name)+'>' } function mail(name) { var hostname = window.location.hostname!='localhost'?window.location.hostname:'safechat.ch' return name+'@'+hostname } //============================================================================== /// @class Crypto cryptographic functions /** @param view is of class SafeChat.View */ function Crypto(controller) { /// cache client's key from local strorage var k = null /// detect hostname, default to safechat.ch /// get user key /** @internal key ist cached in k @return key */ this.key = function() { if (k) return k // cached key if (typeof localStorage.privkey === 'undefined') return null return k = openpgp.key.readArmored(localStorage.privkey) } /// get own user name /** get user name as user id of first public key */ this.user = function() { if (k || key()) return k.pub.keys[0].getUserIds()[0] return null } /// open private key with password /** @return @c true if password matches */ function password(pwd) { return (k || keys()) && k.keys[0].decrypt(pwd) } /// Encrypt Message function encrypt(message, targetkeys, done, failed) { if (!k) return false openpgp.encrypt({ publicKeys: targetkeys.keys.concat(k.keys), privateKeys: k, data: message, armor: false }).then(done).else(failed) return true } /// Decrypt Message function decrypt(message, sourcekeys, done, failed) { if (!k) return false openpgp.decrypt({ privateKeys: k.keys, publicKeys: sourcekeys.keys, message: message }).then(done).else(failed) return true } //------------------------------------------------------------------------------ if (openpgp.initWorker("openpgp.worker.min.js")) log("asynchronous openpgp enabled") else log("asynchronous openpgp failed") } //============================================================================== /// database that stores in indexed db function DataBase() { function user(name, key) { } } //============================================================================== /// manage local copy of users function Users() { var users = new Map() function add(usr) { if (!users[usr.name]) users[usr.name].valid = true else users[usr.name].valid = users[usr.name].valid && users[usr.name].key == usr.key users[usr.name].key = usr.key users[usr.name].online = usr.online } } //------------------------------------------------------------------------------ /// manage local copy of messages function Messages() { var messages = {}; function add() { } } //============================================================================== /// @class Communication client socket communication /** @param view is of class SafeChat.View */ function Communication(controller) { var socket = io.connect() function broadcast(signal, data) { log(' function broadcast(signal, data)') log("<=snd "+signal) socket.broadcast.emit(signal, data) } function emit(signal, data, next) { log(' function emit(signal, data, next)') log("<-snd "+signal) socket.emit(signal, data, next) } this.lookup = function(usr, next) { log(' this.lookup = function(usr, next)') emit('user', usr, next) } socket .on("login", controller.loggedin) .on("fail", controller.fail) .on("user", controller.user) .on("users", controller.users) .on("message", controller.message) .on("messages", controller.messages) .io .on("connect", controller.connected) .on("reconnect", controller.connected) .on("disconnect", controller.disconnected) .on("error", controller.disconnected) } //============================================================================== /// @class View provides the glue to the GUI in the index.ejs file /** View provides the following callbacks: - status updates: - @c notice(msg) to display information - @c warning(msg) - @c error(msg) - @c fatal(msg) */ function View() { var nexttimer = null /// Padding for numbers in dates function pad(n) { log(' function pad(n)') return n<10 ? '0'+n : n } /// escape text to show in html @see htmldec function htmlenc(html) { log(' function htmlenc(html)') return $('
').text(html).html() } /// decode html encoded text @see htmlenc function htmldec(data) { log(' function htmldec(data)') return $('').html(data).text() } /// alert user accoustically or by vibration /** alert user, e.g. that a new message has arrived. */ function beep() { log(' function beep()') if (navigator.vibrate) navigator.vibrate(1000) (new Audio("sounds/beep.mp3")).play() } /// show fatal error /** something completely failed, abort @param msg */ function fatal(msg) { log(' function fatal(msg)') if (nexttimer) clearTimeout(nexttimer) if (msg) { error(msg) $('#fatal-msg').html(msg) } show('fatal') } /// show error messsage /** shows an error message and logs to console. @param data (optional) The error can be a string or any structure. Strings are shown to the user, structures are logged only. @param next (optional) next function to call */ function error(data, next) { log(' function error(data, next)') if (nexttimer) clearTimeout(nexttimer) $("#status").hide() $("#status").addClass("error") $("#status").removeClass("notice") $("#status").removeClass("success") if (data) { if (typeof data == 'string') { $("#status").html(data) log("error: "+data) } else { $("#status").html('error') log("error: "+JSON.stringify(data)) } } else { $("#status").html('error') log("error") } $("#status").show() if (next) nexttimer = setTimeout(function() { nexttimer = null next() }, 5000) } /// show notice messsage /** shows an notice message and logs to console. @param text (optional) The data is a string. */ function notice(text) { log(' function notice(text)') $("#status").hide() $("#status").addClass("notice") $("#status").removeClass("error") $("#status").removeClass("success") if (text) { $("#status").html(text) log("notice: "+text) } else { $("#status").html('') log("notice") } $("#status").show() } /// show success messsage /** shows an success message and logs to console. @param text (optional) The data is a string. */ function success(text) { log(' function success(text)') $("#status").hide() $("#status").addClass("success") $("#status").removeClass("error") $("#status").removeClass("notice") if (text) { $("#status").html(text) log("success: "+text) } else { $("#status").html('') log("success") } $("#status").show() } /// show a specific screen given the element id /** @param id html id to be shown. @param msg (optional) the success message text */ function show(id, msg) { log(' function show(id, msg)') log("state: "+id) if (msg) success(msg); else $("#status").hide(); $("#main").children(":not(#"+id+")").hide() $("#main #"+id).show() $("#main #"+id+" form input:first-child").focus() } /// show server connected status function connected() { log(' function connected()') log("server connected") $("#connectionstatus #bad").hide() $("#connectionstatus #good").show() success("server connected") } /// show server disconnected status function disconnected() { log(' function disconnected()') log("server disconnected") $("#connectionstatus #good").hide() $("#connectionstatus #bad").show() error("server disconnected", true) } /// toggle menu display this.togglemenu = function() { log(' function togglemenu()') $("#menu").toggle() } function checkFeature(id, query) { log(' function checkFeature(id, query)') if (query) $('#'+id) .css('color', 'green') .prepend('✔') else $('#'+id) .css('color', 'red') .prepend('✘') } this.checkFeatures = function() { log(' this.checkFeatures = function()') $('ul.features').css('list-style-type', 'none') checkFeature("localstorage", Storage) checkFeature("indexeddb", window.indexedDB) checkFeature("randomvalues", window.crypto.getRandomValues) checkFeature("vibrate", navigator.vibrate) checkFeature("filereader", window.FileReader) } /// @name create new user /// @{ this.newuser = function() { log(' this.newuser = function()') show('newuser') } var user = null var pwd = false function invalid(usr) { log(' function invalid(usr)') return !user || !user.exists && user.name.length<3 } this.available = function(usr) { log(' this.available = function(usr)') user = usr log("props:", invalid(user) || !pwd) $("#createuser").prop(":disabled", invalid(user) || !pwd) if (user.length==0) notice("please chose a user name") else if (user.length<3) notice("please chose a longer user name") else if (user.exists) notice("user name is already in use") else if (!pwd) notice("please chose a password") else success("user is ready to be created") } this.passwords = function(pwd1, pwd2) { log(' this.passwords = function(pwd1, pwd2)') return pwd = pwd1==pwd2 && pwd1.length>5 log("props:", invalid(user) || !pwd) $("#createuser").prop(":disabled", invalid(user) || !pwd) if (pwd1.length==0) notice('please chose a password') else if (pwd1.length<6) notice('please chose a longer password') else if (pwd1 != pwd2) notice("passwords don't match") else if (invalid(user)) notice("please chose a user name") else success("user is ready to be created") } /// @} function DataTransfer() { log(' function DataTransfer()') var reboottimer = null var data = new DataTransfer() /// download profile backup function backup() { var download = document.createElement('a') download.href = 'data:attachment/text,'+encodeURI(JSON.stringify(localStorage)) download.target = '_blank' var now = new Date() download.download = pad(now.getFullYear())+pad(now.getMonth()+1)+pad(now.getDate())+ "-"+userid()+"@"+hostname+".bak" var clickEvent = new MouseEvent("click", { "view": window, "bubbles": true, "cancelable": false }) download.dispatchEvent(clickEvent) togglemenu() } /// Upload Profile Backup function restore(evt) { log(' function restore(evt)') for (var i=0, f; f=evt.target.files[i]; ++i) { var file = f var reader = new FileReader() reader.onload = function(evt) { if (evt.target.error) return error("error reading file") if (evt.target.readyState==0) return notice("waiting for data …") if (evt.target.readyState==1) return notice("loading data …") var parsed=JSON.parse(evt.target.result) togglemenu() localStorage.pubkey = parsed.pubkey localStorage.privkey = parsed.privkey success("backup is restored") log("reboot after restore in 2s") if (!reboottimer && reboot) reboottimer = setTimeout(function() { reboottimer = null }, 2000) } reader.readAsText(file) } } if (!window.FileReader) { $('restore-menu-item').hide() error("your browser does not support file upload") } } } //============================================================================== /// @class Controller defines the programm flow function Controller(view) { log(' function Controller(view)') var db = new DataBase() var crypto = new Crypto(this) var communication = new Communication(this) var users = new Users() /// @name error handling /// @{ this.fatal = view.fatal this.error = view.error this.warning = view.warning this.notice = view.notice /// @} /// @name access to view /// @{ this.togglemenu = view.togglemenu /// @} /// @name signals from server /// @{ function fail(msg) { log(' function fail(msg)') log('rcv-> fail('+msg+')') error(msg) } function loggedin() { log(' function loggedin()') log("rcv-> login") success("login successful") chat() } function user(usr) { log(' function user(usr)') log("rcv-> user") if (usr.exits) users.add(usr) } function users() { log(' function users()') log("rcv-> users") } function message(msg) { log(' function message(msg)') log("rcv-> message") } function messages(msgs) { log(' function messages(msgs)') log("rcv-> messages") } this.connected = view.connected this.reconnect = view.connected this.disconnect = view.disconnected this.error = view.disconnected // @} /// @name signals from view /// @{ /// @name new user registration /// @{ this.lookup = function(usr) { log(' this.lookup = function(usr)') return if (usr.length > 2) communication.lookup(uid(usr), function(res) { view.available(res) }) } this.checkpasswords = view.passwords this.createuser = function(name, pwd) { log(' this.createuser = function(name, pwd)') crypto.createuser(name, name+'@'+hostname, pwd).then(function() { if (!crypto.password(pwd)) fatal("private key decryption failed") else chat() }) } /// @} /// @} function initBrowser() { log(' function initBrowser()') log('A') //window.indexedDB = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB log('B') window.IDBTransaction = window.IDBTransaction || window.webkitIDBTransaction || window.msIDBTransaction log('C') window.IDBKeyRange = window.IDBKeyRange || window.webkitIDBKeyRange || window.msIDBKeyRange log('D') navigator.vibrate = navigator.vibrate || navigator.webkitVibrate || navigator.mozVibrate || navigator.msVibrate log('E') log(' end of function initBrowser()') return window.indexedDB && window.crypto.getRandomValues && Storage } var newuser = view.newuser function chat() { log(' function chat()') } function password() { log(' function password()') } function login() { log(' function login()') if (!crypto.key()) newuser(); else password(); } this.run = function() { log(' this.run = function()') login() } this.start = function() { log(' this.start = function()') view.reboot = this.run var compatible = initBrowser() view.checkFeatures() if (!compatible) { log('incompatible') view.fatal("your browser is not supported") } else { log('incompatible') this.run() } } } //============================================================================== return new Controller(new View()) } //============================================================================== //------------------------------------------------------------------------------ var filecontent = new Array() ///< temporary storage for attachments var reboottimer = null function connectionstatus() { log('function connectionstatus()') if (socket.connected) connected(); else disconnected(); } /// Configure local groups /** … */ function groups() { log('function groups()') } /// Check if password is set and matches the repeated password /** Checks if both passwords are identical and valid and gives feedback to the user. Called when user edits the password fields. Sets @ref username and checks @ref password - if both are well defined, enables the submit button. @param pwd The password. @param pwd2 The repeated password. */ function checkpwd(pwd, pwd2) { log('function checkpwd(pwd, pwd2)') $("#register").submit(function(event) { return false }) if (pwd==pwd2) password=pwd else password=null if (!password||password.length<1) password=null $("#createuser").prop("disabled", !(username && password)) if (password) { if (username) success("user is ready to be created") else notice("password matches, please chose a valid user name") } else { if (username) notice("passwords don't match") else if ($('#user').val()) notice("user name is already in use") else notice("please chose a user name") } } /// Checks if the receiver of a message exists on server. /** Calls checknewuser.php on server and enables the message submit button if the receiver of the message exists on the server. */ function checkpartner(user) { log('function checkpartner(user)') $("#chat").submit(function(event) { return false }) emit("user", uid(user)) } /// Create Local Public-/Private-Key Pair /** Called if user has not yet his keys, just generates a new key pair. */ function createkeypair(user, pwd) { log('function createkeypair(user, pwd)') notice("generating keys") openpgp.generateKey({ numBits: 4096, userIds: [{name: user, email: user+'@'+hostname}], passphrase: pwd }).then(function(keyPair) { success("keys generated") localStorage.pubkey = keyPair.publicKeyArmored localStorage.privkey = keyPair.privateKeyArmored login() }).catch(function(e) { log(e) error("generating key pairs failed") }) } /// Get Own Public Key /** @return public key object */ function publicKey() { log('function publicKey()') if (typeof localStorage.pubkey == 'undefined') { if (typeof localStorage.pubKey == 'undefined') { return null } else { localStorage.pubkey = localStorage.pubKey localStorage.removeItem(pubKey) } } return openpgp.key.readArmored(localStorage.pubkey) } /// Get Own Private Key /** @return private key object */ function privateKey() { log('function privateKey()') if (typeof localStorage.privkey == 'undefined') { if (typeof localStorage.privKey == 'undefined') { return null } else { localStorage.privkey = localStorage.privKey localStorage.removeItem(privKey) } } return openpgp.key.readArmored(localStorage.privkey) } /// Get Own User Name /** Get user name as user id of first public key */ function userid() { log('function userid()') if (!publicKey() || publicKey().keys.length < 1 || publicKey().keys[0].getUserIds().length < 1) return null return publicKey().keys[0].getUserIds()[0] } /// Clear Message Text And Attachments /** Does not remove the receiver's name */ function clearmessage() { log('function clearmessage()') $("#message").prop(":disabled", true) filecontent = new Array() $('#preview').empty() $("#msg").val("") $("#message").prop(":disabled", false) } function guessfilename(mimetype, user, date) { log('function guessfilename(mimetype, user, date)') if (!user) user = userid() if (!date) date = new Date() var ext = mimetype.replace(/.*\/(x-)?/i, "") return pad(date.getFullYear())+pad(date.getMonth()+1)+pad(date.getDate()) +"-"+ext+"-"+user+"@"+hostname+'.'+ext } /// Display Image Attachments function attachments(files, id, from, date) { log('function attachments(files, id, from, date)') if (files) files.forEach(function(file) { log(file) if (!file.name) file.name = guessfilename(file.type, from, date) var a = document.createElement('a') a.href = file.content a.download = file.name a.target = '_blank' if (file.type.match('^image/')) { var img = document.createElement('img') img.title = file.name img.src = file.content a.appendChild(img) } else if (file.type.match('^video/')) { var video = document.createElement('video') video.controls = true video.title = file.name video.src = file.content a.appendChild(video) } else { var img = document.createElement('img') img.title = file.name img.src = "images/Document_sans_PICOL-PIctorial-COmmunication-Language.svg" a.appendChild(img) } $(id).append(a) }) } var recorder function done() { log('function done()') if (recorder) { recorder.stop() recorder.recording(function(data) { previewfile(data, "video/webm") abort() }) } } function abort() { log('function abort()') if (recorder) { $("#videorecorder").hide() recorder.release() delete recorder recorder = null } } /// Record Video from builtin camera function recordvideo() { log('function recordvideo()') try { abort() $("#videorecorder").show() recorder = new MediaStreamRecorder({ video: { width: {ideal: 180}, height: {ideal: 160} }, audio: true }) recorder.on("ready", function() { $("#videorecorder video").attr("src", recorder.preview()) $("#videorecorder video").css("width", 180) $("#videorecorder video").css("height", 160) $("#videorecorder video").attr("width", 180) $("#videorecorder video").attr("height", 160) recorder.start() }) } catch (e) { log(e) error("cannot access camera", true) } } function previewfile(content, type, name) { log('function previewfile(content, type, name)') if (!name) name = guessfilename(type) if (type.match('^image/')) { var img = document.createElement("img") img.onload = function() { // resize image to maximum 400px var MAX = 400 var width = img.width var height = img.height if (width > MAX) { height *= MAX / width width = MAX } if (height > MAX) { width *= MAX / height height = MAX } var canvas = document.createElement("canvas") canvas.width = width canvas.height = height var ctx = canvas.getContext("2d") ctx.drawImage(img, 0, 0, width, height) img.onload = function() { filecontent.push({name: name, type: type, content: img.src}) $("#preview").append(img) success('image is ready to be sent') } img.src = canvas.toDataURL(file.type) img.title = name+"\n"+size(img.src.length) } img.src=content } else if (type.match('^video/')) { filecontent.push({name: name, type: type, content: content}) var video = document.createElement("video") video.setAttribute("controls", "controls") video.setAttribute("loop", "loop") video.setAttribute("src", content) video.setAttribute("title", name+"\n"+size(content.length)) $("#preview").append(video) } else { filecontent.push({name: name, type: type, content: content}) var img = document.createElement("img") img.src = "images/Document_sans_PICOL-PIctorial-COmmunication-Language.svg" img.title = name+"\n"+size(content.length) $("#preview").append(img) } } /// Upload Attachment /** Prepares attachment to be sent in a message. If the attachment is an image, it resizes the image to 400px on the lager side. By now, only images are supported. Stores data in global variable @ref filecontent. */ function fileupload(evt) { log('function fileupload(evt)') if (!window.FileReader) return error("your browser does not support file upload", true) for (var i=0, f; f=evt.target.files[i]; ++i) { var file = f var reader = new FileReader() reader.onload = function(evt) { if (evt.target.error) return error("error reading file", true) if (evt.target.readyState==0) return notice("waiting for data …") if (evt.target.readyState==1) return notice("loading data …") previewfile(evt.target.result, file.type, file.name) } reader.readAsDataURL(file) } } /// Sets Receiver's Name /** Called when clicked on a receiver's name. Sets focus to the message text field. @param name The receiver's name. */ function setreceiver(name) { log('function setreceiver(name)') $("#recv").val(name) checkpartner(name) $("#msg").focus() } var userMap = null function users(userlist) { log('function users(userlist)') log("rcv-> users") userMap = new Array() $("#allusers").empty() userlist.forEach(function(usr) { userMap[usr.name] = usr.pubkey $("#allusers").append('