/*! @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 SafeChat() { /// Create UID from a name by appending an E-Mail function uid(name) { return name+' <'+name+'@'+hostname+'>' } /// @class Crypto cryptographic functions /** @param view is of class SafeChat.View */ function Crypto(view) { /// cache client's key from local strorage var k = null /// detect hosstname, default to safechat.ch var hostname = window.location.hostname!='localhost'?window.location.hostname:'safechat.ch' /// get user key /** @internal key ist cached in k @return key */ function key() { if (k) return k if (typeof localStorage.key == 'undefined') return null return k = openpgp.key.readArmored(localStorage.key) } /// get own user name /** get user name as user id of first public key */ function user() { if (k || key()) return k.pub.keys[0].getUserIds()[0] return null } /// create New User function createuser(user, email, pwd) { view.notice("generating keys") openpgp.generateKey({ numBits: 4096, userIds: [{name: user, email: email}], passphrase: pwd }).then(function(keyPair) { view.success("keys generated") localStorage.key = keyPair.privateKeyArmored k = keyPair.key }).catch(function(e) { console.log(e) view.fatal("generating key pairs failed") }) } /// 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 } } /// 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) { console.log("<=snd "+signal) socket.broadcast.emit(signal, data) } function emit(signal, data) { console.log("<-snd "+signal) socket.emit(signal, data) } 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) { return n<10 ? '0'+n : n } /// escape text to show in html @see htmldec function htmlenc(html) { return $('
').text(html).html() } /// decode html encoded text @see htmlenc 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() { if (navigator.vibrate) navigator.vibrate(1000) (new Audio("sounds/beep.mp3")).play() } /// show fatal error /** something completely failed, abort @param msg */ 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) { if (nexttimer) clearTimeout(nexttimer) $("#status").hide() $("#status").addClass("error") $("#status").removeClass("notice") $("#status").removeClass("success") if (data) { if (typeof data == 'string') { $("#status").html(data) console.log("error: "+data) } else { $("#status").html('error') console.log("error: "+JSON.stringify(data)) } } else { $("#status").html('error') console.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) { $("#status").hide() $("#status").addClass("notice") $("#status").removeClass("error") $("#status").removeClass("success") if (text) { $("#status").html(text) console.log("notice: "+text) } else { $("#status").html('') console.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) { $("#status").hide() $("#status").addClass("success") $("#status").removeClass("error") $("#status").removeClass("notice") if (text) { $("#status").html(text) console.log("success: "+text) } else { $("#status").html('') console.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) { console.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() { console.log("server connected") $("#connectionstatus #bad").hide() $("#connectionstatus #good").show() success("server connected") } /// show server disconnected status function disconnected() { console.log("server disconnected") $("#connectionstatus #good").hide() $("#connectionstatus #bad").show() error("server disconnected", true) } /// toggle menu display function togglemenu() { $("#menu").toggle() } function checkFeature(id, query) { if (query) $('#'+id+':before') .css('color', 'green') .css('content', '✔') else $('#'+id+':before') .css('color', 'red') .css('content', '✘') if (query) $('#'+id) .css('color', 'green') .css('text-decoration', 'line-through') else $('#'+id) .css('color', 'red') .css('text-decoration', 'none') } function checkFeatures() { $('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) } 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) { 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") console.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) { var db = new Database() var communication = new Communication(this) var users = new Users() function fail(msg) { console.log('rcv-> fail('+msg+')') error(msg) } function loggedin() { console.log("rcv-> login") success("login successful") chat() } function user(usr) { console.log("rcv-> user") if (usr.exits) users.add(usr) } function users() { console.log("rcv-> users") } function message(msg) { console.log("rcv-> message") } function messages(msgs) { console.log("rcv-> messages") } this.connected = view.connected this.reconnect = view.connected this.disconnect = view.disconnected this.error = view.disconnected function login() { } function user(usr) { if (usr.exists) db.adduser } function initBrowser() { window.indexedDB = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB window.IDBTransaction = window.IDBTransaction || window.webkitIDBTransaction || window.msIDBTransaction window.IDBKeyRange = window.IDBKeyRange || window.webkitIDBKeyRange || window.msIDBKeyRange navigator.vibrate = navigator.vibrate || navigator.webkitVibrate || navigator.mozVibrate || navigator.msVibrate return window.indexedDB && window.crypto.getRandomValues && Storage } function run() { } function start() { view.reboot = run var compatible = initBrowser() view.checkFeatures() if (!compatible) view.fatal("your browser is not supported") else run() } } //============================================================================== //------------------------------------------------------------------------------ return new Controller(new View()) } var filecontent = new Array() ///< temporary storage for attachments var reboottimer = null function connectionstatus() { if (socket.connected) connected() else disconnected() } /// Configure local groups /** … */ 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) { $("#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) { $("#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) { 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) { console.log(e) error("generating key pairs failed") }) } /// Get Own Public Key /** @return public key object */ 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() { 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() { 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() { $("#message").prop(":disabled", true) filecontent = new Array() $('#preview').empty() $("#msg").val("") $("#message").prop(":disabled", false) } 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) { if (files) files.forEach(function(file) { console.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() { if (recorder) { recorder.stop() recorder.recording(function(data) { previewfile(data, "video/webm") abort() }) } } function abort() { if (recorder) { $("#videorecorder").hide() recorder.release() delete recorder recorder = null } } /// Record Video from builtin camera 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) { console.log(e) error("cannot access camera", true) } } 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) { 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) { $("#recv").val(name) checkpartner(name) $("#msg").focus() } var userMap = null function users(userlist) { console.log("rcv-> users") userMap = new Array() $("#allusers").empty() userlist.forEach(function(usr) { userMap[usr.name] = usr.pubkey $("#allusers").append('