some rearrangements

master
Marc Wäckerlin 8 years ago
parent 94b952fbcc
commit cc4f5d6619
  1. 1698
      nodejs/public/javascripts/safechat.js
  2. 0
      nodejs/public/sounds/beep.mp3
  3. 27
      nodejs/views/index.ejs

@ -37,870 +37,1026 @@
// 1 2 3 4 5 6 7 8 // 1 2 3 4 5 6 7 8
// 45678901234567890123456789012345678901234567890123456789012345678901234567890 // 45678901234567890123456789012345678901234567890123456789012345678901234567890
function SafeChatClient(success, notice, error) { function SafeChat() {
/// Cache Client's Key from local Strorage /// Create UID from a name by appending an E-Mail
var k = null; function uid(name) {
return name+' <'+name+'@'+hostname+'>'
function browserSupported() {
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
return window.indexedDB && window.crypto.getRandomValues && Storage
} }
/// Get User Key /// @class Crypto cryptographic functions
/** @internal key ist cached in k /** @param view is of class SafeChat.View */
@return key */ function Crypto(view) {
function key() {
if (k) return k /// cache client's key from local strorage
if (typeof localStorage.key == 'undefined') return null var k = null
return k = openpgp.key.readArmored(localStorage.key)
} /// 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
}
/// Get Own User Name
/** Get user name as user id of first public key */
function uid() {
if (k || key()) return k.pub.keys[0].getUserIds()[0]
return null
} }
/// Create New User /// database that stores in indexed db
function createuser(user, email, pwd) { function DataBase() {
notice("generating keys")
openpgp.generateKey({ function user(name, key) {
numBits: 4096, }
userIds: [{name: user, email: email}],
passphrase: pwd
}).then(function(keyPair) {
success("keys generated")
localStorage.key = keyPair.privateKeyArmored
k = keyPair.key
}).catch(function(e) {
console.log(e)
error("generating key pairs failed")
})
} }
function password(pwd) { /// manage local copy of users
return (k || keys()) && k.keys[0].decrypt(pwd) 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
}
} }
/// Encrypt Message /// manage local copy of messages
function encrypt(targetkeys, message, done, failed) { function Messages() {
if (!k) return false
openpgp.encrypt({ var messages = {};
publicKeys: targets.keys.concat(k.keys),
privateKeys: k, function add() {
data: message, }
armor: false})
.then(done)
.else(failed)
return true
} }
/// Decrypt Message /// @class Communication client socket communication
function decrypt(message) { /** @param view is of class SafeChat.View */
if (!k) return false 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)
return true
} }
}
var password = null; ///< password, only stored temporary, until reload /// @class View provides the glue to the GUI in the index.ejs file
var username = null; ///< username, only used during registration /** View provides the following callbacks:
var filecontent = new Array(); ///< temporary storage for attachments - status updates:
var socket = io.connect(); - @c notice(msg) to display information
var hostname = window.location.hostname!='localhost'?window.location.hostname:'safechat.ch'; - @c warning(msg)
- @c error(msg)
- @c fatal(msg) */
function View() {
/// Padding for numbers in dates var nexttimer = null
function pad(n) {
return n<10 ? '0'+n : n
}
function uid(name) { /// Padding for numbers in dates
return name+' <'+name+'@'+hostname+'>'; function pad(n) {
} return n<10 ? '0'+n : n
}
/// escape text to show in html @see htmldec
function htmlenc(html) {
return $('<div/>').text(html).html()
}
/// decode html encoded text @see htmlenc
function htmldec(data) {
return $('<div/>').html(data).text()
}
/// Convert number of bytes to readable text /// alert user accoustically or by vibration
function size(num) { /** alert user, e.g. that a new message has arrived. */
if (num>0.6*1024) { function beep() {
if (num>0.6*1024*1024) { if (navigator.vibrate) navigator.vibrate(1000)
if (num>0.6*1024*1024*1024) { (new Audio("sounds/beep.mp3")).play()
if (num>0.6*1024*1024*1024*1024) { }
return Math.round(num/1024/1024/1024/1024)+"TB";
/// 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 { } else {
return Math.round(num/1024/1024/1024)+"GB"; $("#status").html('error')
console.log("error: "+JSON.stringify(data))
} }
} else { } else {
return Math.round(num/1024/1024)+"MB"; $("#status").html('error')
console.log("error")
} }
} else { $("#status").show()
return Math.round(num/1024)+"kB"; if (next) nexttimer = setTimeout(function() {
nexttimer = null
next()
}, 5000)
} }
} else {
return num+"B";
}
}
var reboottimer = null; /// show notice messsage
/// Show error messsage /** shows an notice message and logs to console.
/** Fades in an error message and logs to console. @param text (optional) The data is a string. */
@param data (optional) The error can be a string or any structure. function notice(text) {
Strings are shown to the user, structures are logged only. $("#status").hide()
@param stay (optional) If not given as @c true, reloads page after 5s. */ $("#status").addClass("notice")
function error(data, stay) { $("#status").removeClass("error")
$("#status").hide(); $("#status").removeClass("success")
$("#status").addClass("error") if (text) {
$("#status").removeClass("notice") $("#status").html(text)
$("#status").removeClass("success") console.log("notice: "+text)
if (data) { } else {
if (typeof data == 'string') { $("#status").html('')
$("#status").html(data); console.log("notice")
console.log("error: "+data); }
} else { $("#status").show()
$("#status").html('unknown error: '+JSON.stringify(data));
console.log("error: "+JSON.stringify(data));
} }
} else {
$("#status").html('error');
console.log("error");
}
$("#status").show();
if (!stay) {
console.log("reboot in 5s");
console.log((new Error('stacktrace')).stack);
if (!reboottimer) reboottimer = setTimeout(function() {
reboottimer = null;
start();
}, 5000);
}
}
/// Show notice messsage /// show success messsage
/** Fades in an notice message and logs to console. /** shows an success message and logs to console.
@param text (optional) The data is a string. */ @param text (optional) The data is a string. */
function notice(text) { function success(text) {
$("#status").hide() $("#status").hide()
$("#status").addClass("notice") $("#status").addClass("success")
$("#status").removeClass("error") $("#status").removeClass("error")
$("#status").removeClass("success") $("#status").removeClass("notice")
if (text) { if (text) {
$("#status").html(text); $("#status").html(text)
console.log("notice: "+text); console.log("success: "+text)
} else { } else {
$("#status").html(''); $("#status").html('')
console.log("notice"); console.log("success")
} }
$("#status").show(); $("#status").show()
} }
/// Show notice messsage /// show a specific screen given the element id
/** Fades in an success message and logs to console. /** @param id html id to be shown.
@param text (optional) The data is a string. */ @param msg (optional) the success message text */
function success(text) { function show(id, msg) {
$("#status").hide(); console.log("state: "+id)
$("#status").addClass("success") if (msg) success(msg) else $("#status").hide()
$("#status").removeClass("error") $("#main").children(":not(#"+id+")").hide()
$("#status").removeClass("notice") $("#main #"+id).show()
if (text) { $("#main #"+id+" form input:first-child").focus()
$("#status").html(text); }
console.log("success: "+text);
} else {
$("#status").html('');
console.log("success");
}
$("#status").show();
}
/// Show status message in the main screen area /// show server connected status
/** @param id HTML id to be shown. function connected() {
@param msg The success message text */ console.log("server connected")
function status(id, msg) { $("#connectionstatus #bad").hide()
console.log("state: "+id); $("#connectionstatus #good").show()
if (msg) success(msg); else $("#status").hide(); success("server connected")
$("#main").children(":not(#"+id+")").hide(); }
$("#main #"+id).show();
$("#main #"+id+" form input:first-child").focus();
}
function emit(signal, data) { /// show server disconnected status
console.log("<-snd "+signal); function disconnected() {
socket.emit(signal, data); console.log("server disconnected")
} $("#connectionstatus #good").hide()
$("#connectionstatus #bad").show()
error("server disconnected", true)
}
/// toggle menu display
function togglemenu() {
$("#menu").toggle()
}
function connected() { function checkFeature(id, query) {
console.log("server connected"); if (query) $('#'+id+':before')
$("#connectionstatus #bad").hide(); .css('color', 'green')
$("#connectionstatus #good").show(); .css('content', '&#x2714;')
success("server connected"); else $('#'+id+':before')
} .css('color', 'red')
.css('content', '&#x2718;')
if (query) $('#'+id)
.css('color', 'green')
.css('text-decoration', 'line-through')
else $('#'+id)
.css('color', 'red')
.css('text-decoration', 'none')
}
function disconnected() { function checkFeatures() {
console.log("server disconnected"); $('ul.features').css('list-style-type', 'none')
$("#connectionstatus #good").hide(); checkFeature("localstorage", Storage)
$("#connectionstatus #bad").show(); checkFeature("indexeddb", window.indexedDB)
error("server disconnected", true); checkFeature("randomvalues", window.crypto.getRandomValues)
} checkFeature("vibrate", navigator.vibrate)
checkFeature("filereader", window.FileReader)
}
function connectionstatus() { function DataTransfer() {
if (socket.connected) connected(); else disconnected();
} 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)
}
}
function htmlenc(html) { if (!window.FileReader) {
return $('<div/>').text(html).html(); $('restore-menu-item').hide()
} error("your browser does not support file upload")
}
function htmldec(data) { }
return $('<div/>').html(data).text();
}
/// Alert user
/** Alert user, e.g. that a new message has arrived. */
function beep(user) {
if (user) success("message from "+htmlenc(user)+" received");
navigator.vibrate =
navigator.vibrate || navigator.webkitVibrate || navigator.mozVibrate || navigator.msVibrate;
if (navigator.vibrate) {
// vibration API supported
navigator.vibrate(1000);
} }
(new Audio("sounds/A-Tone-His_Self-1266414414.mp3")).play();
}
/// Toggle Menu Display /// @class Controller defines the programm flow
function togglemenu() { function Controller(view) {
$("#menu").toggle();
} var db = new Database()
var communication = new Communication(this)
var users = new Users()
function fail(msg) {
console.log('rcv-> fail('+msg+')')
error(msg)
}
/// Download Profile Backup function loggedin() {
function backup() { console.log("rcv-> login")
var download = document.createElement('a'); success("login successful")
download.href = 'data:attachment/text,'+encodeURI(JSON.stringify(localStorage)); chat()
download.target = '_blank'; }
var now = new Date();
download.download = function user(usr) {
pad(now.getFullYear())+pad(now.getMonth()+1)+pad(now.getDate())+ console.log("rcv-> user")
"-"+userid()+"@"+hostname+".bak"; if (usr.exits) users.add(usr)
var clickEvent = new MouseEvent("click", { }
"view": window,
"bubbles": true,
"cancelable": false
});
download.dispatchEvent(clickEvent);
togglemenu();
}
/// Upload Profile Backup function users() {
function restore(evt) { console.log("rcv-> users")
if (!window.FileReader) }
return error("your browser does not support file upload", true);
for (var i=0, f; f=evt.target.files[i]; ++i) { function message(msg) {
var file = f; console.log("rcv-> message")
var reader = new FileReader(); }
reader.onload = function(evt) {
if (evt.target.error) return error("error reading file", true); function messages(msgs) {
if (evt.target.readyState==0) return notice("waiting for data …"); console.log("rcv-> messages")
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) reboottimer = setTimeout(function() {
reboottimer = null;
start();
}, 2000);
}
reader.readAsText(file);
}
}
/// Configure local groups this.connected = view.connected
/** … */ this.reconnect = view.connected
function groups() { this.disconnect = view.disconnected
} this.error = view.disconnected
/// Check if password is set and matches the repeated password function login() {
/** 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. function user(usr) {
/** Calls checknewuser.php on server and enables the message submit if (usr.exists) db.adduser
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 function initBrowser() {
/** Called if user has not yet his keys, just generates a new key pair. */ window.indexedDB = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB
function createkeypair(user, pwd) { window.IDBTransaction = window.IDBTransaction || window.webkitIDBTransaction || window.msIDBTransaction
notice("generating keys"); window.IDBKeyRange = window.IDBKeyRange || window.webkitIDBKeyRange || window.msIDBKeyRange
openpgp.generateKey({ navigator.vibrate = navigator.vibrate || navigator.webkitVibrate || navigator.mozVibrate || navigator.msVibrate
numBits: 4096, return window.indexedDB && window.crypto.getRandomValues && Storage
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 function run() {
/** @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 function start() {
/** @return private key object */ view.reboot = run
function privateKey() { var compatible = initBrowser()
if (typeof localStorage.privkey == 'undefined') { view.checkFeatures()
if (typeof localStorage.privKey == 'undefined') { if (!compatible)
return null; view.fatal("your browser is not supported")
} else { else
localStorage.privkey = localStorage.privKey; run()
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 */ return new Controller(new View())
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;
} }
var filecontent = new Array() ///< temporary storage for attachments
var reboottimer = null
/// Display Image Attachments function connectionstatus() {
function attachments(files, id, from, date) { if (socket.connected) connected() else disconnected()
if (files) files.forEach(function(file) { }
console.log(file);
if (!file.name) file.name = guessfilename(file.type, from, date); /// Configure local groups
var a = document.createElement('a'); /** … */
a.href = file.content; function groups() {
a.download = file.name; }
a.target = '_blank';
if (file.type.match('^image/')) { /// Check if password is set and matches the repeated password
var img = document.createElement('img'); /** Checks if both passwords are identical and valid and gives
img.title = file.name; feedback to the user.
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; Called when user edits the password fields.
function done() { Sets @ref username and checks @ref password - if both are well
if (recorder) { defined, enables the submit button.
recorder.stop();
recorder.recording(function(data) { @param pwd The password.
previewfile(data, "video/webm"); @param pwd2 The repeated password. */
abort(); 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")
}
} }
}
function abort() { /// Checks if the receiver of a message exists on server.
if (recorder) { /** Calls checknewuser.php on server and enables the message submit
$("#videorecorder").hide(); button if the receiver of the message exists on the server. */
recorder.release(); function checkpartner(user) {
delete recorder; recorder = null; $("#chat").submit(function(event) {
return false
})
emit("user", uid(user))
} }
}
/// Record Video from builtin camera /// Create Local Public-/Private-Key Pair
function recordvideo() { /** Called if user has not yet his keys, just generates a new key pair. */
try { function createkeypair(user, pwd) {
abort(); notice("generating keys")
$("#videorecorder").show(); openpgp.generateKey({
recorder = new MediaStreamRecorder({ numBits: 4096,
video: { userIds: [{name: user, email: user+'@'+hostname}],
width: {ideal: 180}, passphrase: pwd
height: {ideal: 160} }).then(function(keyPair) {
}, success("keys generated")
audio: true localStorage.pubkey = keyPair.publicKeyArmored
}); localStorage.privkey = keyPair.privateKeyArmored
recorder.on("ready", function() { login()
$("#videorecorder video").attr("src", recorder.preview()); }).catch(function(e) {
$("#videorecorder video").css("width", 180); console.log(e)
$("#videorecorder video").css("height", 160); error("generating key pairs failed")
$("#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) { /// Get Own Public Key
if (!name) name = guessfilename(type); /** @return public key object */
if (type.match('^image/')) { function publicKey() {
var img = document.createElement("img"); if (typeof localStorage.pubkey == 'undefined') {
img.onload = function() { // resize image to maximum 400px if (typeof localStorage.pubKey == 'undefined') {
var MAX = 400; return null
var width = img.width; } else {
var height = img.height; localStorage.pubkey = localStorage.pubKey
if (width > MAX) { localStorage.removeItem(pubKey)
height *= MAX / width;
width = MAX;
}
if (height > MAX) {
width *= MAX / height;
height = MAX;
} }
var canvas = document.createElement("canvas"); }
canvas.width = width; return openpgp.key.readArmored(localStorage.pubkey)
canvas.height = height; }
var ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0, width, height); /// Get Own Private Key
img.onload = function() { /** @return private key object */
filecontent.push({name: name, type: type, content: img.src}); function privateKey() {
$("#preview").append(img); if (typeof localStorage.privkey == 'undefined') {
success('image is ready to be sent'); if (typeof localStorage.privKey == 'undefined') {
return null
} else {
localStorage.privkey = localStorage.privKey
localStorage.removeItem(privKey)
} }
img.src = canvas.toDataURL(file.type); }
img.title = name+"\n"+size(img.src.length); return openpgp.key.readArmored(localStorage.privkey)
}
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 /// Get Own User Name
/** Prepares attachment to be sent in a message. If the attachment is /** Get user name as user id of first public key */
an image, it resizes the image to 400px on the lager side. function userid() {
if (!publicKey() ||
By now, only images are supported. publicKey().keys.length < 1 ||
publicKey().keys[0].getUserIds().length < 1) return null
Stores data in global variable @ref filecontent. */ return publicKey().keys[0].getUserIds()[0]
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 /// Clear Message Text And Attachments
/** Called when clicked on a receiver's name. Sets focus to the /** Does not remove the receiver's name */
message text field. function clearmessage() {
$("#message").prop(":disabled", true)
filecontent = new Array()
$('#preview').empty()
$("#msg").val("")
$("#message").prop(":disabled", false)
}
@param name The receiver's name. */ function guessfilename(mimetype, user, date) {
function setreceiver(name) { if (!user) user = userid()
$("#recv").val(name); if (!date) date = new Date()
checkpartner(name); var ext = mimetype.replace(/.*\/(x-)?/i, "")
$("#msg").focus(); return pad(date.getFullYear())+pad(date.getMonth()+1)+pad(date.getDate())
} +"-"+ext+"-"+user+"@"+hostname+'.'+ext
}
var userMap = null; /// Display Image Attachments
function users(userlist) { function attachments(files, id, from, date) {
console.log("rcv-> users"); if (files) files.forEach(function(file) {
userMap = new Array(); console.log(file)
$("#allusers").empty(); if (!file.name) file.name = guessfilename(file.type, from, date)
userlist.forEach(function(usr) { var a = document.createElement('a')
userMap[usr.name] = usr.pubkey; a.href = file.content
$("#allusers").append('<option value="'+htmlenc(usr.name)+'">') a.download = file.name
$("#allusers").hide(); a.target = '_blank'
console.log(" user: "+usr.name); if (file.type.match('^image/')) {
}); var img = document.createElement('img')
localStorage.userMap = JSON.stringify(userMap); 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)
})
}
function fail(msg) { var recorder
console.log("rcv-> fail");
error(msg);
}
function loggedin() { function done() {
console.log("rcv-> login"); if (recorder) {
success("login successful"); recorder.stop()
chat(); recorder.recording(function(data) {
} previewfile(data, "video/webm")
abort()
})
}
}
function user(usr) { function abort() {
if (usr.exists) console.log("rcv-> user("+usr.name+")"); if (recorder) {
else console.log("rcv-> user("+usr.name+"): name is available"); $("#videorecorder").hide()
if ($("#newuser").is(":visible") && usr.name==uid($('#user').val())) { recorder.release()
// same username as in the create user form delete recorder recorder = null
$("#createuser").prop("disabled", usr.exists); // todo: check password
if (!usr.exists) {
username = usr.name;
success("user name "+usr.name+" is available");
} else {
username = null;
error("user name "+usr.name+" is in use", true);
} }
} }
if ($("#chat").is(":visible") && usr.name==uid($("#recv").val())) { // same username as in receiver
$('#send').prop("disabled", !usr.exists); /// Record Video from builtin camera
$("label[for=send] img").css("opacity", usr.exists?"1.0":"0.4"); function recordvideo() {
$("label[for=send] img").css("filter", usr.exists?"alpha(opacity=100)":"alpha(opacity=40)"); try {
if (usr.exists) success("recipient exists"); abort()
else error("unknown recipient", true); $("#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)
}
} }
if (userMap == null) {
if (localStorage.userMap) { function previewfile(content, type, name) {
userMap = JSON.parse(localStorage.userMap); 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 { } else {
userMap = new Array(); 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)
} }
} }
if (usr.exists && usr.pubkey && userMap[usr.name] != usr.pubkey) {
userMap[usr.name] = usr.pubkey; /// Upload Attachment
$("#allusers").append('option value="'+htmlenc(usr.name)+'"') /** Prepares attachment to be sent in a message. If the attachment is
localStorage.userMap = JSON.stringify(userMap); 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)
}
} }
}
function queryuser(usr) { /// Sets Receiver's Name
console.log("query user: "+uid(usr)); /** Called when clicked on a receiver's name. Sets focus to the
socket.emit("user", uid(usr)); message text field.
}
/// Get a user's public key. @param name The receiver's name. */
/** The first time, gets it from the server, later from the cache. */ function setreceiver(name) {
function getPublicKey(user) { $("#recv").val(name)
var deferredObject = $.Deferred(); checkpartner(name)
if (userMap && userMap[user]) deferredObject.resolve(userMap[user]); $("#msg").focus()
else deferredObject.reject("unknown user"); }
return deferredObject.promise();
}
/// Received a list of messages from server
function messages(msgs) {
console.log("rcv-> messages("+msgs.length+")");
if (!password || !privateKey())
return setTimeout(function() {emit("messages");}, 1000); // try again later
status("allmessages");
notice("load messages, please wait …");
msgs.forEach(function(msg) {message(msg, true);});
status("chat");
}
/// Received a message from server var userMap = null
function message(m, internal) { function users(userlist) {
if (!internal) console.log("rcv-> message("+m.user+")"); console.log("rcv-> users")
if (!password || !privateKey()) return; userMap = new Array()
var key=openpgp.key.readArmored(m.pubkey); $("#allusers").empty()
if (key.err) return error("key of sender unreadable", true); userlist.forEach(function(usr) {
var message = openpgp.message.readArmored(m.msg); userMap[usr.name] = usr.pubkey
var privkey = privateKey().keys[0]; $("#allusers").append('<option value="'+htmlenc(usr.name)+'">')
if (privkey.decrypt(password)) // prepare own key $("#allusers").hide()
openpgp.decrypt({ console.log(" user: "+usr.name)
privateKeys: privkey, })
publicKeys: key.keys, localStorage.userMap = JSON.stringify(userMap)
message: message }
}).then(function(msg) { // decryption succeded
// prepend message to list of messages
var message = JSON.parse(msg.text);
$("#msgs") // todo: check msg.signatures[0].valid
.prepend('<div id="id'+(m.id)+'" class="msg '+
(m.user==userid()?"me":"other")+
'"><div class="header">'+
'<span class="date">'+
(new Date(m.time)).toLocaleString()+
'</span><span class="sender">'+
'<a href="javascript:void(0)" '+
'onclick="setreceiver(this.innerHTML)">'+
htmlenc(m.user)+
'</a>'+(message.receiver?' → <a href="javascript:void(0)" '+
'onclick="setreceiver(this.innerHTML)">'
+htmlenc(message.receiver)+'</a>':"")+
'</span></div>'+
'<div class="text">'+
htmlenc(message.text)+
'</div></div><div class="clear"/>');
// show attachments
attachments(message.files, '#id'+m.id+' .text', m.user, new Date(m.time));
// calculate and show emoticons
$('#id'+m.id).emoticonize();
if (!internal) beep(m.user);
}).catch(function(e) {
// not for me
success();
});
}
/// Send Message To Server function user(usr) {
/** User wants to send a message. Encrypt message with own private and if (usr.exists) console.log("rcv-> user("+usr.name+")")
the receiver's public key, then send it to the server. */ else console.log("rcv-> user("+usr.name+"): name is available")
function sendmessage(recv, txt) { if ($("#newuser").is(":visible") && usr.name==uid($('#user').val())) {
notice("1/3 preparing message …"); // same username as in the create user form
$("#message").prop(":disabled", true); $("#createuser").prop("disabled", usr.exists) // todo: check password
getPublicKey(recv) // get receiver's public key if (!usr.exists) {
.done(function(pk) { username = usr.name
var key=openpgp.key.readArmored(pk); success("user name "+usr.name+" is available")
if (!pk||key.err) { } else {
$("#message").prop(":disabled", false); username = null
error("receiver's key not found", true); error("user name "+usr.name+" is in use", true)
return;
} }
var privkey = privateKey().keys[0]; }
privkey.decrypt(password); // get own private key ready if ($("#chat").is(":visible") && usr.name==uid($("#recv").val())) { // same username as in receiver
var message = JSON.stringify({receiver: recv, text: txt, files: filecontent}); $('#send').prop("disabled", !usr.exists)
notice("2/3 encrypting message …"); $("label[for=send] img").css("opacity", usr.exists?"1.0":"0.4")
openpgp.encrypt({publicKeys: key.keys.concat(publicKey().keys), $("label[for=send] img").css("filter", usr.exists?"alpha(opacity=100)":"alpha(opacity=40)")
privateKeys: privkey, if (usr.exists) success("recipient exists")
data: message, else error("unknown recipient", true)
armor: false}) }
.then(function(msg) { // message is encrypted if (userMap == null) {
notice("3/3 sending message …"); if (localStorage.userMap) {
emit("message", {user: userid(), content: msg}); userMap = JSON.parse(localStorage.userMap)
clearmessage(); } else {
}) userMap = new Array()
.catch(function(e) { }
$("#message").prop(":disabled", false); }
error("encryption of message failed", true); if (usr.exists && usr.pubkey && userMap[usr.name] != usr.pubkey) {
}); userMap[usr.name] = usr.pubkey
}) $("#allusers").append('option value="'+htmlenc(usr.name)+'"')
.fail(function(e) { localStorage.userMap = JSON.stringify(userMap)
$("#message").prop(":disabled", false); }
error("user not found", true); }
});
}
/// Check And Set Password function queryuser(usr) {
/** Check if given password matches to decrypt the private key. If so, console.log("query user: "+uid(usr))
store it in global temporary variable @ref password and start the socket.emit("user", uid(usr))
chat. The password matches, when the private key can be decrypted.
@param pwd The password to check. */
function setpw(pwd) {
if (privateKey().keys[0].decrypt(pwd)) {
success("password matches");
$("#removeKey").hide();
password = pwd;
chat();
} else {
notice("password does not match");
} }
}
/// Create Password Entry Field /// Get a user's public key.
/** Asks user for password. When user starts to enter it, it is /** The first time, gets it from the server, later from the cache. */
permanentely checked in setpw(). As soon as the password matches, function getPublicKey(user) {
setpw() continues automatically. No submit is required by the var deferredObject = $.Deferred()
user. */ if (userMap && userMap[user]) deferredObject.resolve(userMap[user])
function getpwd() { else deferredObject.reject("unknown user")
if (password) return; return deferredObject.promise()
$("#removeKey").show(); }
status("getpwd");
}
function deleteUser() { /// Received a list of messages from server
var uid = userid(); function messages(msgs) {
localStorage.removeItem(pubkey); console.log("rcv-> messages("+msgs.length+")")
localStorage.removeItem(privkey); if (!password || !privateKey())
error("user "+uid+" permanentely lost"); return setTimeout(function() {emit("messages")}, 1000) // try again later
} status("allmessages")
notice("load messages, please wait …")
msgs.forEach(function(msg) {message(msg, true)})
status("chat")
}
function removeKey() { /// Received a message from server
togglemenu(); function message(m, internal) {
$("#removeKey").hide(); if (!internal) console.log("rcv-> message("+m.user+")")
status('forgotpassword'); if (!password || !privateKey()) return
} var key=openpgp.key.readArmored(m.pubkey)
if (key.err) return error("key of sender unreadable", true)
var message = openpgp.message.readArmored(m.msg)
var privkey = privateKey().keys[0]
if (privkey.decrypt(password)) // prepare own key
openpgp.decrypt({
privateKeys: privkey,
publicKeys: key.keys,
message: message
}).then(function(msg) { // decryption succeded
openpgp.decrypt({
privateKeys: privkey,
publicKeys: key.keys,
message: message
}).then(function(msg) { // decryption succeded
// prepend message to list of messages
var message = JSON.parse(msg.text)
$("#msgs") // todo: check msg.signatures[0].valid
.prepend('<div id="id'+(m.id)+'" class="msg '+
(m.user==userid()?"me":"other")+
'"><div class="header">'+
'<span class="date">'+
(new Date(m.time)).toLocaleString()+
'</span><span class="sender">'+
'<a href="javascript:void(0)" '+
'onclick="setreceiver(this.innerHTML)">'+
htmlenc(m.user)+
'</a>'+(message.receiver?' → <a href="javascript:void(0)" '+
'onclick="setreceiver(this.innerHTML)">'
+htmlenc(message.receiver)+'</a>':"")+
'</span></div>'+
'<div class="text">'+
htmlenc(message.text)+
'</div></div><div class="clear"/>')
// show attachments
attachments(message.files, '#id'+m.id+' .text', m.user, new Date(m.time))
// calculate and show emoticons
$('#id'+m.id).emoticonize()
if (!internal) beep(m.user)
}).catch(function(e) {
// not for me
success()
})
}
/// Main Chat Window /// Send Message To Server
/** Gets chat widgets from server and displays them. Starts timer for /** User wants to send a message. Encrypt message with own private and
get() which polls for new messages. */ the receiver's public key, then send it to the server. */
var firsttime = true; function sendmessage(recv, txt) {
function chat() { notice("1/3 preparing message …")
if (!password) return getpwd(); $("#message").prop(":disabled", true)
status("chat"); getPublicKey(recv) // get receiver's public key
if (firsttime && $('#msgs').is(':empty')) { .done(function(pk) {
firsttime = false; var key=openpgp.key.readArmored(pk)
notice("getting previous messages, please wait …"); if (!pk||key.err) {
emit("messages"); $("#message").prop(":disabled", false)
} error("receiver's key not found", true)
} return
}
var privkey = privateKey().keys[0]
privkey.decrypt(password) // get own private key ready
var message = JSON.stringify({receiver: recv, text: txt, files: filecontent})
notice("2/3 encrypting message …")
openpgp.encrypt({publicKeys: key.keys.concat(publicKey().keys),
privateKeys: privkey,
data: message,
armor: false})
.then(function(msg) { // message is encrypted
notice("3/3 sending message …")
emit("message", {user: userid(), content: msg})
clearmessage()
})
.catch(function(e) {
$("#message").prop(":disabled", false)
error("encryption of message failed", true)
})
})
.fail(function(e) {
$("#message").prop(":disabled", false)
error("user not found", true)
})
}
/// Login User /// Check And Set Password
/** This is not really a login, it is just some kind of validation. /** Check if given password matches to decrypt the private key. If so,
The server does not care if a user is online or not, it is only store it in global temporary variable @ref password and start the
interesting to the client to make sure, everything is fine. User chat. The password matches, when the private key can be decrypted.
is logged in the following way: User name and public key are sent
to the server. If the user name exists on the server and the @param pwd The password to check. */
public key is the same, the user is considered logged in, his function setpw(pwd) {
credentials seem to be valid. If user does not yet exits on if (privateKey().keys[0].decrypt(pwd)) {
server, it is created now. If user exists, but public key is success("password matches")
different, then this is a complete failure, something went $("#removeKey").hide()
terribly wrong. */ password = pwd
function login() { chat()
$("#username").html(userid()+"@"+hostname); } else {
emit("login", {name: userid(), notice("password does not match")
pubkey: localStorage.pubkey}); }
success("login sent to server"); }
}
/// Get And Display Form To Create New User /// Create Password Entry Field
/** Shows user creation form. On submit, a private key is generated in /** Asks user for password. When user starts to enter it, it is
createkeypair(), then login() creates the user. */ permanentely checked in setpw(). As soon as the password matches,
function newuser() { setpw() continues automatically. No submit is required by the
status("newuser"); user. */
} function getpwd() {
if (password) return
$("#removeKey").show()
status("getpwd")
}
/// Check if local storage is available function deleteUser() {
function checkLocalStorage() { var uid = userid()
var test = 'test'; localStorage.removeItem(pubkey)
try { localStorage.removeItem(privkey)
localStorage.setItem(test, test); error("user "+uid+" permanentely lost")
localStorage.removeItem(test); }
return true;
} catch(e) {
status("nolocalstorage");
error("local storage not available");
}
return false;
}
/// Initial Function: Startup function removeKey() {
/** Decide whether to login or to create a new user */ togglemenu()
function start() { $("#removeKey").hide()
$("#menu").hide(); status('forgotpassword')
//status("startup"); }
if (checkLocalStorage())
try {
if (!userid()) {
newuser();
} else {
login();
}
} catch (m) {
console.log(m.stack);
error(m);
}
}
function init() { /// Main Chat Window
/// On Load, Call @ref start /** Gets chat widgets from server and displays them. Starts timer for
$(window.onbeforeunload = function() { get() which polls for new messages. */
return "Are you sure you want to navigate away?"; var firsttime = true
}); function chat() {
/// Allow Running in Background on Android if (!password) return getpwd()
document.addEventListener('deviceready', function () { status("chat")
if (cordova && cordova.plugins.backgroundMode) { if (firsttime && $('#msgs').is(':empty')) {
cordova.plugins.backgroundMode.enable(); firsttime = false
} notice("getting previous messages, please wait …")
}, false); emit("messages")
socket.io.on("connect", connected); }
socket.io.on("reconnect", connected); }
socket.io.on("disconnect", disconnected);
socket.io.on("error", disconnected); /// Login User
socket.on("login", loggedin); /** This is not really a login, it is just some kind of validation.
socket.on("fail", fail); The server does not care if a user is online or not, it is only
socket.on("user", user); interesting to the client to make sure, everything is fine. User
socket.on("users", users); is logged in the following way: User name and public key are sent
socket.on("message", message); to the server. If the user name exists on the server and the
socket.on("messages", messages); public key is the same, the user is considered logged in, his
connectionstatus(); credentials seem to be valid. If user does not yet exits on
if (openpgp.initWorker("openpgp.worker.min.js")) server, it is created now. If user exists, but public key is
console.log("asynchronous openpgp enabled"); different, then this is a complete failure, something went
else terribly wrong. */
console.log("asynchronous openpgp failed"); function login() {
emit('users'); $("#username").html(userid()+"@"+hostname)
start(); emit("login", {name: userid(),
} pubkey: localStorage.pubkey})
success("login sent to server")
}
/// Get And Display Form To Create New User
/** Shows user creation form. On submit, a private key is generated in
createkeypair(), then login() creates the user. */
function newuser() {
status("newuser")
}
/// Check if local storage is available
function checkLocalStorage() {
var test = 'test'
try {
localStorage.setItem(test, test)
localStorage.removeItem(test)
return true
} catch(e) {
status("nolocalstorage")
error("local storage not available")
}
return false
}
/// Initial Function: Startup
/** Decide whether to login or to create a new user */
function start() {
$("#menu").hide()
//status("startup")
if (checkLocalStorage())
try {
if (!userid()) {
newuser()
} else {
login()
}
} catch (m) {
console.log(m.stack)
error(m)
}
}
function init() {
/// On Load, Call @ref start
$(window.onbeforeunload = function() {
return "Are you sure you want to navigate away?"
})
/// Allow Running in Background on Android
document.addEventListener('deviceready', function () {
if (cordova && cordova.plugins.backgroundMode) {
cordova.plugins.backgroundMode.enable()
}
}, false)
connectionstatus()
if (openpgp.initWorker("openpgp.worker.min.js"))
console.log("asynchronous openpgp enabled")
else
console.log("asynchronous openpgp failed")
emit('users')
start()
}
/// Start Main Loop /// Start Main Loop
$(init); $(init)

@ -30,7 +30,7 @@
<ul id="menu" style="display: none"> <ul id="menu" style="display: none">
<li onclick="backup()">Download Backup</li> <li onclick="backup()">Download Backup</li>
<li class="toolbutton"><label for="restore">Restore Backup</label><input autocomplete="off" type="file" accept="*.bak" id="restore" /></li> <li id="restore-menu-item" class="toolbutton"><label for="restore">Restore Backup</label><input autocomplete="off" type="file" accept="*.bak" id="restore" /></li>
<li id="groups" onclick="groups()">Edit Groups</li> <li id="groups" onclick="groups()">Edit Groups</li>
<li id="removeKey" style="display: none" onclick="removeKey()">Password Forgotten</li> <li id="removeKey" style="display: none" onclick="removeKey()">Password Forgotten</li>
<li id="android-download" href="safechat.apk"><a href="safechat.apk">Download Android-App</a></li> <li id="android-download" href="safechat.apk"><a href="safechat.apk">Download Android-App</a></li>
@ -182,6 +182,25 @@
</div> </div>
</div> </div>
<!-- Fatal: Abort -->
<div id="fatal">
<h2 id="fatal-msg">Failure</h2>
<p>The SafeChat has been aborted due to a fatal error.</p>
<p>There is a problem in your browser. Please try to reload. If the problem persists, please update your web browser or try SafeChat in another browser.</p>
<p>The following java script features are required:</p>
<ul class="features">
<li id="localstorage">Local Storage</li>
<li id="indexeddb">Indexed DB</li>
<li id="randomvalues">Cryptography: Random Values</li>
</ul>
<p>The following java script features are optional:</p>
<ul class="features">
<li id="vibrate">Vibration (vibrates when new message arrives)</li>
<li id="filereader">File Reader (required to restore backup)</li>
</ul>
</div>
<!-- Error: Missing JavaScript --> <!-- Error: Missing JavaScript -->
<noscript> <noscript>
<h2>JavasScript Required!</h2> <h2>JavasScript Required!</h2>
@ -193,12 +212,6 @@
<p><a href="<%= package.documentation %>" target="_blank">more information</a></p> <p><a href="<%= package.documentation %>" target="_blank">more information</a></p>
</noscript> </noscript>
<!-- Error: Missing LocalStorage -->
<div id="nolocalstorage" style="display: none">
<p>No access to local storage. Please allow access to local
storage, i.e. do not block cookies.<p>
</div>
<!-- Notice: Setup Messages --> <!-- Notice: Setup Messages -->
<div id="allmessages" style="display: none"> <div id="allmessages" style="display: none">
<p>Setting up all previous messages, please wait …</p> <p>Setting up all previous messages, please wait …</p>

Loading…
Cancel
Save