Merge branch 'staging' into soon

pull/1/head
ansuz 5 years ago
commit bd163e65f3

@ -1,3 +1,42 @@
# L release (3.11.0)
## Goals
* major server refactor to prepare for:
* trim-history
* allow lists
## Update notes
* dropped support for retainData
* archives are on by default
* you will need a new chainpad server
## Features
* restyled corner popup
* cool new scheduler library
* operations on channels are queued
* trim-history rpc
* unified historykeeper and rpc
* more visible styles for unanswered support tickets
* hidden hashes/safe links
* new "security" tab in settings
* queue'd popups
* reconnect alert
* link to user profile in notifications
* prompt anonymous users to register when viewing a profile
* spreadsheets
* reconnecting spreadsheets
* faster spreadsheets
* don't hijack chat cursor
* friends are now "contacts"
## Bug fixes
* friend request/accept race condition
* throw errors in 'mkAsync' if no function is passed
# Kouprey release (3.10.0)
## Goals

@ -231,26 +231,17 @@ module.exports = {
*/
inactiveTime: 90, // days
/* CryptPad can be configured to remove inactive data which has not been pinned.
* Deletion of data is always risky and as an operator you have the choice to
* archive data instead of deleting it outright. Set this value to true if
* you want your server to archive files and false if you want to keep using
* the old behaviour of simply removing files.
/* CryptPad archives some data instead of deleting it outright.
* This archived data still takes up space and so you'll probably still want to
* remove these files after a brief period.
*
* cryptpad/scripts/evict-inactive.js is intended to be run daily
* from a crontab or similar scheduling service.
*
* WARNING: this is not implemented universally, so at the moment this will
* only apply to the removal of 'channels' due to inactivity.
*/
retainData: true,
/* As described above, CryptPad offers the ability to archive some data
* instead of deleting it outright. This archived data still takes up space
* and so you'll probably still want to remove these files after a brief period.
* The intent with this feature is to provide a safety net in case of accidental
* deletion. Set this value to the number of days you'd like to retain
* archived data before it's removed permanently.
*
* If 'retainData' is set to false, there will never be any archived data
* to remove.
*/
archiveRetentionTime: 15,

@ -107,7 +107,7 @@ define([
])*/
])
]),
h('div.cp-version-footer', "CryptPad v3.10.0 (Kouprey)")
h('div.cp-version-footer', "CryptPad v3.11.0 (LabradorDuck)")
]);
};

@ -10,7 +10,7 @@
@alertify-input-fg: @colortheme_modal-input-fg;
input:not(.form-control), textarea {
background-color: @alertify-input-fg;
// background-color: @alertify-input-fg;
color: @cryptpad_text_col;
border: 1px solid @alertify-input-bg;
width: 100%;
@ -23,6 +23,10 @@
}
}
input:not(.form-control) {
height: @variables_input-height;
}
div.cp-alertify-type {
display: flex;
input {

@ -135,7 +135,7 @@
@colortheme_oocell-bg: #40865c;
@colortheme_oocell-color: #FFF;
@colortheme_oocell-warn: #cd2532;
@colortheme_oocell-warn: #ffbcc0;
@colortheme_kanban-bg: #8C4;
@colortheme_kanban-color: #000;

@ -4,9 +4,9 @@
--LessLoader_require: LessLoader_currentFile();
};
& {
@corner-button-ok: #2c9b00;
@corner-button-cancel: #990000;
@corner-link: #ffff7a;
@corner-blue: @colortheme_logo-1;
@corner-white: @colortheme_base;
@keyframes appear {
0% {
@ -27,21 +27,23 @@
.cp-corner-container {
position: absolute;
right: 0;
bottom: 0;
width: 300px;
height: 200px;
border-top-left-radius: 200px;
padding: 15px;
text-align: right;
background-color: @colortheme_logo-1;
color: @colortheme_base;
right: 10px;
bottom: 10px;
width: 350px;
padding: 10px;
background-color: fade(@corner-blue, 95%);
border: 1px solid @corner-blue;
color: @corner-white;
z-index: 9999;
transform-origin: bottom right;
animation: appear 0.8s ease-in-out;
box-shadow: 0 0 10px 0 @colortheme_logo-1;
//transform: scale(0.1);
//transform: scale(1);
//box-shadow: 0 0 10px 0 @corner-blue;
&.cp-corner-alt {
background-color: fade(@corner-white, 95%);
border: 1px solid @corner-blue;
color: @corner-blue;
}
h1, h2, h3 {
font-size: 1.5em;
@ -64,7 +66,7 @@
line-height: 15px;
display: none;
&:hover {
color: darken(@colortheme_base, 15%);
color: darken(@corner-white, 15%);
}
}
.cp-corner-minimize {
@ -86,46 +88,95 @@
}
}
&.cp-corner-big {
width: 400px;
height: 250px;
width: 500px;
}
.cp-corner-dontshow {
cursor: pointer;
.fa {
margin-right: 0.3em;
font-size: 1.1em;
}
&:hover {
color: darken(@corner-white, 10%);
}
}
&.cp-corner-alt {
.cp-corner-dontshow {
&:hover {
color: lighten(@corner-blue, 10%);
}
}
}
.cp-corner-actions {
min-height: 30px;
margin: 15px auto;
display: inline-block;
margin: 10px auto;
display: block;
text-align: right;
}
.cp-corner-footer {
font-style: italic;
font-size: 0.8em;
}
.cp-corner-footer, .cp-corner-text {
a {
color: @corner-link;
color: @corner-white;
text-decoration: underline;
&:hover {
color: darken(@corner-link, 20%);
color: darken(@corner-white, 10%);
}
}
}
&.cp-corner-alt a {
color: @corner-blue;
&:hover {
color: lighten(@corner-blue, 10%);
}
}
button {
border: 0px;
padding: 5px;
color: @colortheme_base;
margin-left: 5px;
color: @corner-white;
&:not(:first-child) {
margin-left: 10px;
}
outline: none;
text-transform: uppercase;
border: 1px solid @corner-white;
.fa, .cptools {
margin-right: 0.3em;
}
&.cp-corner-primary {
background-color: @corner-button-ok;
font-weight: bold;
background-color: @corner-white;
color: @corner-blue;
&:hover {
background-color: lighten(@corner-button-ok, 10%);
background-color: lighten(@corner-blue, 50%);
border-color: lighten(@corner-blue, 50%);
}
}
&.cp-corner-cancel {
background-color: @corner-button-cancel;
margin-left: 10px;
background-color: @corner-blue;
color: @corner-white;
&:hover {
background-color: darken(@corner-blue, 10%);
}
}
}
&.cp-corner-alt button {
border-color: @corner-blue;
&.cp-corner-primary {
background-color: @corner-blue;
color: @corner-white;
&:hover {
background-color: darken(@corner-blue, 10%);
border-color: darken(@corner-blue, 10%);
}
}
&.cp-corner-cancel {
background-color: @corner-white;
color: @corner-blue;
&:hover {
background-color: lighten(@corner-button-cancel, 10%);
background-color: lighten(@corner-blue, 50%);
}
}
}

@ -14,9 +14,11 @@
right: 10vw;
bottom: 10vh;
box-sizing: border-box;
z-index: 1000000; //Z file upload table container
z-index: 100000; //Z file upload table container
display: none;
color: darken(@colortheme_drive-bg, 10%);
max-height: 180px;
overflow-y: auto;
@media screen and (max-width: @browser_media-medium-screen) {
left: 5vw; right: 5vw; bottom: 5vw;
@ -26,6 +28,9 @@
display: flex;
background-color: darken(@colortheme_modal-bg, 10%);
font-weight: bold;
position: sticky;
top: 0;
z-index: 1;
.cp-fileupload-header-title {
padding: 0.25em 0.5em;
flex-grow: 1;

@ -8,6 +8,7 @@
@notif-height: 50px;
.cp-notifications-container {
max-width: 300px;
width: 300px;
display: flex;
flex-flow: column;
& hr {
@ -16,6 +17,14 @@
.cp-notification {
min-height: @notif-height;
display: flex;
.cp-avatar {
.avatar_main(30px);
padding: 0 5px;
cursor: pointer;
&:hover {
background-color: rgba(0,0,0,0.1);
}
}
.cp-notification-content {
flex: 1;
align-items: stretch;

@ -1,6 +1,7 @@
@import (reference) "/customize/src/less2/include/colortheme-all.less";
@import (reference) "/customize/src/less2/include/leftside-menu.less";
@import (reference) "/customize/src/less2/include/buttons.less";
@import (reference) "/customize/src/less2/include/browser.less";
@sidebar_button-width: 400px;
@ -73,6 +74,7 @@
padding: 5px 20px;
color: @rightside-color;
overflow: auto;
padding-bottom: 200px;
// Following rules are only in settings
.cp-sidebarlayout-element {
@ -107,6 +109,7 @@
.cp-sidebarlayout-input-block {
display: inline-flex;
width: @sidebar_button-width;
max-width: 100%;
input {
flex: 1;
//border-radius: 0.25em 0 0 0.25em;
@ -117,6 +120,8 @@
//border-radius: 0 0.25em 0.25em 0;
//border: 1px solid #adadad;
border-left: 0px;
height: @variables_input-height;
margin: 0 !important;
}
}
&>div {
@ -161,6 +166,25 @@
}
*/
}
@media screen and (max-width: @browser_media-medium-screen) {
flex-flow: column;
overflow: auto;
#cp-sidebarlayout-leftside {
width: 100% !important; // Override "narrow" mode
padding-bottom: 20px;
.cp-sidebarlayout-categories {
.cp-sidebarlayout-category {
margin: 0;
span.cp-sidebar-layout-category-name {
display: inline !important; // override "narrow" mode
}
}
}
}
#cp-sidebarlayout-rightside {
overflow: unset;
}
}
}
}

@ -3,6 +3,7 @@
// Elements size
@variables_bar-height: 32px;
@variables_input-height: 38px;
// Used in modal.less and alertify.less
@variables_padding: 12px;

@ -106,7 +106,7 @@ server {
if ($uri ~ ^\/common\/onlyoffice\/.*\/index\.html.*$) { set $unsafe 1; }
# everything except the sandbox domain is a privileged scope, as they might be used to handle keys
if ($host != sandbox.cryptpad.info) { set $unsafe 0; }
if ($host != $sandbox_domain) { set $unsafe 0; }
# privileged contexts allow a few more rights than unprivileged contexts, though limits are still applied
if ($unsafe) {

File diff suppressed because it is too large Load Diff

@ -0,0 +1,48 @@
/* jshint esversion: 6 */
const WebSocketServer = require('ws').Server;
const NetfluxSrv = require('chainpad-server');
module.exports.create = function (config) {
// asynchronously create a historyKeeper and RPC together
require('./historyKeeper.js').create(config, function (err, historyKeeper) {
if (err) { throw err; }
var log = config.log;
// spawn ws server and attach netflux event handlers
NetfluxSrv.create(new WebSocketServer({ server: config.httpServer}))
.on('channelClose', historyKeeper.channelClose)
.on('channelMessage', historyKeeper.channelMessage)
.on('channelOpen', historyKeeper.channelOpen)
.on('sessionClose', function (userId, reason) {
if (['BAD_MESSAGE', 'SOCKET_ERROR', 'SEND_MESSAGE_FAIL_2'].indexOf(reason) !== -1) {
if (reason && reason.code === 'ECONNRESET') { return; }
return void log.error('SESSION_CLOSE_WITH_ERROR', {
userId: userId,
reason: reason,
});
}
if (reason && reason === 'SOCKET_CLOSED') { return; }
log.verbose('SESSION_CLOSE_ROUTINE', {
userId: userId,
reason: reason,
});
})
.on('error', function (error, label, info) {
if (!error) { return; }
/* labels:
SEND_MESSAGE_FAIL, SEND_MESSAGE_FAIL_2, FAIL_TO_DISCONNECT,
FAIL_TO_TERMINATE, HANDLE_CHANNEL_LEAVE, NETFLUX_BAD_MESSAGE,
NETFLUX_WEBSOCKET_ERROR
*/
log.error(label, {
code: error.code,
message: error.message,
stack: error.stack,
info: info,
});
})
.register(historyKeeper.id, historyKeeper.directMessage);
});
};

@ -0,0 +1,122 @@
/*jshint esversion: 6 */
const BatchRead = require("../batch-read");
const nThen = require("nthen");
const getFolderSize = require("get-folder-size");
//const Util = require("../common-util");
var Fs = require("fs");
var Admin = module.exports;
var getActiveSessions = function (Env, Server, cb) {
var stats = Server.getSessionStats();
cb(void 0, [
stats.total,
stats.unique
]);
};
var shutdown = function (Env, Server, cb) {
if (true) {
return void cb('E_NOT_IMPLEMENTED');
}
// disconnect all users and reject new connections
Server.shutdown();
// stop all intervals that may be running
Object.keys(Env.intervals).forEach(function (name) {
clearInterval(Env.intervals[name]);
});
// set a flag to prevent incoming database writes
// wait until all pending writes are complete
// then process.exit(0);
// and allow system functionality to restart the server
};
const batchRegisteredUsers = BatchRead("GET_REGISTERED_USERS");
var getRegisteredUsers = function (Env, cb) {
batchRegisteredUsers('', cb, function (done) {
var dir = Env.paths.pin;
var folders;
var users = 0;
nThen(function (waitFor) {
Fs.readdir(dir, waitFor(function (err, list) {
if (err) {
waitFor.abort();
return void done(err);
}
folders = list;
}));
}).nThen(function (waitFor) {
folders.forEach(function (f) {
var dir = Env.paths.pin + '/' + f;
Fs.readdir(dir, waitFor(function (err, list) {
if (err) { return; }
users += list.length;
}));
});
}).nThen(function () {
done(void 0, users);
});
});
};
const batchDiskUsage = BatchRead("GET_DISK_USAGE");
var getDiskUsage = function (Env, cb) {
batchDiskUsage('', cb, function (done) {
var data = {};
nThen(function (waitFor) {
getFolderSize('./', waitFor(function(err, info) {
data.total = info;
}));
getFolderSize(Env.paths.pin, waitFor(function(err, info) {
data.pin = info;
}));
getFolderSize(Env.paths.blob, waitFor(function(err, info) {
data.blob = info;
}));
getFolderSize(Env.paths.staging, waitFor(function(err, info) {
data.blobstage = info;
}));
getFolderSize(Env.paths.block, waitFor(function(err, info) {
data.block = info;
}));
getFolderSize(Env.paths.data, waitFor(function(err, info) {
data.datastore = info;
}));
}).nThen(function () {
done(void 0, data);
});
});
};
Admin.command = function (Env, safeKey, data, cb, Server) {
var admins = Env.admins;
//var unsafeKey = Util.unescapeKeyCharacters(safeKey);
if (admins.indexOf(safeKey) === -1) {
return void cb("FORBIDDEN");
}
// Handle commands here
switch (data[0]) {
case 'ACTIVE_SESSIONS':
return getActiveSessions(Env, Server, cb);
case 'ACTIVE_PADS':
return cb(void 0, Server.getActiveChannelCount());
case 'REGISTERED_USERS':
return getRegisteredUsers(Env, cb);
case 'DISK_USAGE':
return getDiskUsage(Env, cb);
case 'FLUSH_CACHE':
Env.flushCache();
return cb(void 0, true);
case 'SHUTDOWN':
return shutdown(Env, Server, cb);
default:
return cb('UNHANDLED_ADMIN_COMMAND');
}
};

@ -0,0 +1,172 @@
/*jshint esversion: 6 */
/* globals Buffer*/
var Block = module.exports;
const Fs = require("fs");
const Fse = require("fs-extra");
const Path = require("path");
const Nacl = require("tweetnacl/nacl-fast");
const nThen = require("nthen");
const Util = require("../common-util");
/*
We assume that the server is secured against MitM attacks
via HTTPS, and that malicious actors do not have code execution
capabilities. If they do, we have much more serious problems.
The capability to replay a block write or remove results in either
a denial of service for the user whose block was removed, or in the
case of a write, a rollback to an earlier password.
Since block modification is destructive, this can result in loss
of access to the user's drive.
So long as the detached signature is never observed by a malicious
party, and the server discards it after proof of knowledge, replays
are not possible. However, this precludes verification of the signature
at a later time.
Despite this, an integrity check is still possible by the original
author of the block, since we assume that the block will have been
encrypted with xsalsa20-poly1305 which is authenticated.
*/
var validateLoginBlock = function (Env, publicKey, signature, block, cb) { // FIXME BLOCKS
// convert the public key to a Uint8Array and validate it
if (typeof(publicKey) !== 'string') { return void cb('E_INVALID_KEY'); }
var u8_public_key;
try {
u8_public_key = Nacl.util.decodeBase64(publicKey);
} catch (e) {
return void cb('E_INVALID_KEY');
}
var u8_signature;
try {
u8_signature = Nacl.util.decodeBase64(signature);
} catch (e) {
Env.Log.error('INVALID_BLOCK_SIGNATURE', e);
return void cb('E_INVALID_SIGNATURE');
}
// convert the block to a Uint8Array
var u8_block;
try {
u8_block = Nacl.util.decodeBase64(block);
} catch (e) {
return void cb('E_INVALID_BLOCK');
}
// take its hash
var hash = Nacl.hash(u8_block);
// validate the signature against the hash of the content
var verified = Nacl.sign.detached.verify(hash, u8_signature, u8_public_key);
// existing authentication ensures that users cannot replay old blocks
// call back with (err) if unsuccessful
if (!verified) { return void cb("E_COULD_NOT_VERIFY"); }
return void cb(null, u8_block);
};
var createLoginBlockPath = function (Env, publicKey) { // FIXME BLOCKS
// prepare publicKey to be used as a file name
var safeKey = Util.escapeKeyCharacters(publicKey);
// validate safeKey
if (typeof(safeKey) !== 'string') {
return;
}
// derive the full path
// /home/cryptpad/cryptpad/block/fg/fg32kefksjdgjkewrjksdfksjdfsdfskdjfsfd
return Path.join(Env.paths.block, safeKey.slice(0, 2), safeKey);
};
Block.writeLoginBlock = function (Env, safeKey, msg, cb) { // FIXME BLOCKS
//console.log(msg);
var publicKey = msg[0];
var signature = msg[1];
var block = msg[2];
validateLoginBlock(Env, publicKey, signature, block, function (e, validatedBlock) {
if (e) { return void cb(e); }
if (!(validatedBlock instanceof Uint8Array)) { return void cb('E_INVALID_BLOCK'); }
// derive the filepath
var path = createLoginBlockPath(Env, publicKey);
// make sure the path is valid
if (typeof(path) !== 'string') {
return void cb('E_INVALID_BLOCK_PATH');
}
var parsed = Path.parse(path);
if (!parsed || typeof(parsed.dir) !== 'string') {
return void cb("E_INVALID_BLOCK_PATH_2");
}
nThen(function (w) {
// make sure the path to the file exists
Fse.mkdirp(parsed.dir, w(function (e) {
if (e) {
w.abort();
cb(e);
}
}));
}).nThen(function () {
// actually write the block
// flow is dumb and I need to guard against this which will never happen
/*:: if (typeof(validatedBlock) === 'undefined') { throw new Error('should never happen'); } */
/*:: if (typeof(path) === 'undefined') { throw new Error('should never happen'); } */
Fs.writeFile(path, Buffer.from(validatedBlock), { encoding: "binary", }, function (err) {
if (err) { return void cb(err); }
cb();
});
});
});
};
/*
When users write a block, they upload the block, and provide
a signature proving that they deserve to be able to write to
the location determined by the public key.
When removing a block, there is nothing to upload, but we need
to sign something. Since the signature is considered sensitive
information, we can just sign some constant and use that as proof.
*/
Block.removeLoginBlock = function (Env, safeKey, msg, cb) { // FIXME BLOCKS
var publicKey = msg[0];
var signature = msg[1];
var block = Nacl.util.decodeUTF8('DELETE_BLOCK'); // clients and the server will have to agree on this constant
validateLoginBlock(Env, publicKey, signature, block, function (e /*::, validatedBlock */) {
if (e) { return void cb(e); }
// derive the filepath
var path = createLoginBlockPath(Env, publicKey);
// make sure the path is valid
if (typeof(path) !== 'string') {
return void cb('E_INVALID_BLOCK_PATH');
}
// FIXME COLDSTORAGE
Fs.unlink(path, function (err) {
Env.Log.info('DELETION_BLOCK_BY_OWNER_RPC', {
publicKey: publicKey,
path: path,
status: err? String(err): 'SUCCESS',
});
if (err) { return void cb(err); }
cb();
});
});
};

@ -0,0 +1,274 @@
/*jshint esversion: 6 */
const Channel = module.exports;
const Util = require("../common-util");
const nThen = require("nthen");
const Core = require("./core");
const Metadata = require("./metadata");
Channel.clearOwnedChannel = function (Env, safeKey, channelId, cb, Server) {
if (typeof(channelId) !== 'string' || channelId.length !== 32) {
return cb('INVALID_ARGUMENTS');
}
var unsafeKey = Util.unescapeKeyCharacters(safeKey);
Metadata.getMetadata(Env, channelId, function (err, metadata) {
if (err) { return void cb(err); }
if (!Core.hasOwners(metadata)) { return void cb('E_NO_OWNERS'); }
// Confirm that the channel is owned by the user in question
if (!Core.isOwner(metadata, unsafeKey)) {
return void cb('INSUFFICIENT_PERMISSIONS');
}
return void Env.msgStore.clearChannel(channelId, function (e) {
if (e) { return void cb(e); }
cb();
const channel_cache = Env.historyKeeper.channel_cache;
const clear = function () {
// delete the channel cache because it will have been invalidated
delete channel_cache[channelId];
};
nThen(function (w) {
Server.getChannelUserList(channelId).forEach(function (userId) {
Server.send(userId, [
0,
Env.historyKeeper.id,
'MSG',
userId,
JSON.stringify({
error: 'ECLEARED',
channel: channelId
})
], w());
});
}).nThen(function () {
clear();
}).orTimeout(function () {
Env.Log.warn("ON_CHANNEL_CLEARED_TIMEOUT", channelId);
clear();
}, 30000);
});
});
};
Channel.removeOwnedChannel = function (Env, safeKey, channelId, cb, Server) {
if (typeof(channelId) !== 'string' || !Core.isValidId(channelId)) {
return cb('INVALID_ARGUMENTS');
}
var unsafeKey = Util.unescapeKeyCharacters(safeKey);
if (Env.blobStore.isFileId(channelId)) {
var blobId = channelId;
return void nThen(function (w) {
// check if you have permissions
Env.blobStore.isOwnedBy(safeKey, blobId, w(function (err, owned) {
if (err || !owned) {
w.abort();
return void cb("INSUFFICIENT_PERMISSIONS");
}
}));
}).nThen(function (w) {
// remove the blob
return void Env.blobStore.archive.blob(blobId, w(function (err) {
Env.Log.info('ARCHIVAL_OWNED_FILE_BY_OWNER_RPC', {
safeKey: safeKey,
blobId: blobId,
status: err? String(err): 'SUCCESS',
});
if (err) {
w.abort();
return void cb(err);
}
}));
}).nThen(function () {
// archive the proof
return void Env.blobStore.archive.proof(safeKey, blobId, function (err) {
Env.Log.info("ARCHIVAL_PROOF_REMOVAL_BY_OWNER_RPC", {
safeKey: safeKey,
blobId: blobId,
status: err? String(err): 'SUCCESS',
});
if (err) {
return void cb("E_PROOF_REMOVAL");
}
cb(void 0, 'OK');
});
});
}
Metadata.getMetadata(Env, channelId, function (err, metadata) {
if (err) { return void cb(err); }
if (!Core.hasOwners(metadata)) { return void cb('E_NO_OWNERS'); }
if (!Core.isOwner(metadata, unsafeKey)) {
return void cb('INSUFFICIENT_PERMISSIONS');
}
// temporarily archive the file
return void Env.msgStore.archiveChannel(channelId, function (e) {
Env.Log.info('ARCHIVAL_CHANNEL_BY_OWNER_RPC', {
unsafeKey: unsafeKey,
channelId: channelId,
status: e? String(e): 'SUCCESS',
});
if (e) {
return void cb(e);
}
cb(void 0, 'OK');
const channel_cache = Env.historyKeeper.channel_cache;
const metadata_cache = Env.historyKeeper.metadata_cache;
const clear = function () {
delete channel_cache[channelId];
Server.clearChannel(channelId);
delete metadata_cache[channelId];
};
// an owner of a channel deleted it
nThen(function (w) {
// close the channel in the store
Env.msgStore.closeChannel(channelId, w());
}).nThen(function (w) {
// Server.channelBroadcast would be better
// but we can't trust it to track even one callback,
// let alone many in parallel.
// so we simulate it on this side to avoid race conditions
Server.getChannelUserList(channelId).forEach(function (userId) {
Server.send(userId, [
0,
Env.historyKeeper.id,
"MSG",
userId,
JSON.stringify({
error: 'EDELETED',
channel: channelId,
})
], w());
});
}).nThen(function () {
// clear the channel's data from memory
// once you've sent everyone a notice that the channel has been deleted
clear();
}).orTimeout(function () {
Env.Log.warn('ON_CHANNEL_DELETED_TIMEOUT', channelId);
clear();
}, 30000);
});
});
};
Channel.trimHistory = function (Env, safeKey, data, cb) {
if (!(data && typeof(data.channel) === 'string' && typeof(data.hash) === 'string' && data.hash.length === 64)) {
return void cb('INVALID_ARGS');
}
var channelId = data.channel;
var unsafeKey = Util.unescapeKeyCharacters(safeKey);
var hash = data.hash;
nThen(function (w) {
Metadata.getMetadata(Env, channelId, w(function (err, metadata) {
if (err) { return void cb(err); }
if (!Core.hasOwners(metadata)) {
w.abort();
return void cb('E_NO_OWNERS');
}
if (!Core.isOwner(metadata, unsafeKey)) {
w.abort();
return void cb("INSUFFICIENT_PERMISSIONS");
}
// else fall through to the next block
}));
}).nThen(function () {
Env.msgStore.trimChannel(channelId, hash, function (err) {
if (err) { return void cb(err); }
// clear historyKeeper's cache for this channel
Env.historyKeeper.channelClose(channelId);
cb(void 0, 'OK');
delete Env.historyKeeper.channel_cache[channelId];
delete Env.historyKeeper.metadata_cache[channelId];
});
});
};
var ARRAY_LINE = /^\[/;
/* Files can contain metadata but not content
call back with true if the channel log has no content other than metadata
otherwise false
*/
Channel.isNewChannel = function (Env, channel, cb) {
if (!Core.isValidId(channel)) { return void cb('INVALID_CHAN'); }
if (channel.length !== 32) { return void cb('INVALID_CHAN'); }
var done = false;
Env.msgStore.getMessages(channel, function (msg) {
if (done) { return; }
try {
if (typeof(msg) === 'string' && ARRAY_LINE.test(msg)) {
done = true;
return void cb(void 0, false);
}
} catch (e) {
Env.WARN('invalid message read from store', e);
}
}, function () {
if (done) { return; }
// no more messages...
cb(void 0, true);
});
};
/* writePrivateMessage
allows users to anonymously send a message to the channel
prevents their netflux-id from being stored in history
and from being broadcast to anyone that might currently be in the channel
Otherwise behaves the same as sending to a channel
*/
Channel.writePrivateMessage = function (Env, args, cb, Server) {
var channelId = args[0];
var msg = args[1];
// don't bother handling empty messages
if (!msg) { return void cb("INVALID_MESSAGE"); }
// don't support anything except regular channels
if (!Core.isValidId(channelId) || channelId.length !== 32) {
return void cb("INVALID_CHAN");
}
// We expect a modern netflux-websocket-server instance
// if this API isn't here everything will fall apart anyway
if (!(Server && typeof(Server.send) === 'function')) {
return void cb("NOT_IMPLEMENTED");
}
// historyKeeper expects something with an 'id' attribute
// it will fail unless you provide it, but it doesn't need anything else
var channelStruct = {
id: channelId,
};
// construct a message to store and broadcast
var fullMessage = [
0, // idk
null, // normally the netflux id, null isn't rejected, and it distinguishes messages written in this way
"MSG", // indicate that this is a MSG
channelId, // channel id
msg // the actual message content. Generally a string
];
// historyKeeper already knows how to handle metadata and message validation, so we just pass it off here
// if the message isn't valid it won't be stored.
Env.historyKeeper.channelMessage(Server, channelStruct, fullMessage);
Server.getChannelUserList(channelId).forEach(function (userId) {
Server.send(userId, fullMessage);
});
cb();
};

@ -0,0 +1,190 @@
/*jshint esversion: 6 */
/* globals process */
const Core = module.exports;
const Util = require("../common-util");
const escapeKeyCharacters = Util.escapeKeyCharacters;
/* Use Nacl for checking signatures of messages */
const Nacl = require("tweetnacl/nacl-fast");
Core.DEFAULT_LIMIT = 50 * 1024 * 1024;
Core.SESSION_EXPIRATION_TIME = 60 * 1000;
Core.isValidId = function (chan) {
return chan && chan.length && /^[a-zA-Z0-9=+-]*$/.test(chan) &&
[32, 48].indexOf(chan.length) > -1;
};
var makeToken = Core.makeToken = function () {
return Number(Math.floor(Math.random() * Number.MAX_SAFE_INTEGER))
.toString(16);
};
Core.makeCookie = function (token) {
var time = (+new Date());
time -= time % 5000;
return [
time,
process.pid,
token
];
};
var parseCookie = function (cookie) {
if (!(cookie && cookie.split)) { return null; }
var parts = cookie.split('|');
if (parts.length !== 3) { return null; }
var c = {};
c.time = new Date(parts[0]);
c.pid = Number(parts[1]);
c.seq = parts[2];
return c;
};
Core.getSession = function (Sessions, key) {
var safeKey = escapeKeyCharacters(key);
if (Sessions[safeKey]) {
Sessions[safeKey].atime = +new Date();
return Sessions[safeKey];
}
var user = Sessions[safeKey] = {};
user.atime = +new Date();
user.tokens = [
makeToken()
];
return user;
};
Core.expireSession = function (Sessions, safeKey) {
var session = Sessions[safeKey];
if (!session) { return; }
if (session.blobstage) {
session.blobstage.close();
}
delete Sessions[safeKey];
};
Core.expireSessionAsync = function (Env, safeKey, cb) {
setTimeout(function () {
Core.expireSession(Env.Sessions, safeKey);
cb(void 0, 'OK');
});
};
var isTooOld = function (time, now) {
return (now - time) > 300000;
};
Core.expireSessions = function (Sessions) {
var now = +new Date();
Object.keys(Sessions).forEach(function (safeKey) {
var session = Sessions[safeKey];
if (session && isTooOld(session.atime, now)) {
Core.expireSession(Sessions, safeKey);
}
});
};
var addTokenForKey = function (Sessions, publicKey, token) {
if (!Sessions[publicKey]) { throw new Error('undefined user'); }
var user = Core.getSession(Sessions, publicKey);
user.tokens.push(token);
user.atime = +new Date();
if (user.tokens.length > 2) { user.tokens.shift(); }
};
Core.isValidCookie = function (Sessions, publicKey, cookie) {
var parsed = parseCookie(cookie);
if (!parsed) { return false; }
var now = +new Date();
if (!parsed.time) { return false; }
if (isTooOld(parsed.time, now)) {
return false;
}
// different process. try harder
if (process.pid !== parsed.pid) {
return false;
}
var user = Core.getSession(Sessions, publicKey);
if (!user) { return false; }
var idx = user.tokens.indexOf(parsed.seq);
if (idx === -1) { return false; }
if (idx > 0) {
// make a new token
addTokenForKey(Sessions, publicKey, Core.makeToken());
}
return true;
};
Core.checkSignature = function (Env, signedMsg, signature, publicKey) {
if (!(signedMsg && publicKey)) { return false; }
var signedBuffer;
var pubBuffer;
var signatureBuffer;
try {
signedBuffer = Nacl.util.decodeUTF8(signedMsg);
} catch (e) {
Env.Log.error('INVALID_SIGNED_BUFFER', signedMsg);
return null;
}
try {
pubBuffer = Nacl.util.decodeBase64(publicKey);
} catch (e) {
return false;
}
try {
signatureBuffer = Nacl.util.decodeBase64(signature);
} catch (e) {
return false;
}
if (pubBuffer.length !== 32) {
Env.Log.error('PUBLIC_KEY_LENGTH', publicKey);
return false;
}
if (signatureBuffer.length !== 64) {
return false;
}
return Nacl.sign.detached.verify(signedBuffer, signatureBuffer, pubBuffer);
};
// E_NO_OWNERS
Core.hasOwners = function (metadata) {
return Boolean(metadata && Array.isArray(metadata.owners));
};
Core.hasPendingOwners = function (metadata) {
return Boolean(metadata && Array.isArray(metadata.pending_owners));
};
// INSUFFICIENT_PERMISSIONS
Core.isOwner = function (metadata, unsafeKey) {
return metadata.owners.indexOf(unsafeKey) !== -1;
};
Core.isPendingOwner = function (metadata, unsafeKey) {
return metadata.pending_owners.indexOf(unsafeKey) !== -1;
};
Core.haveACookie = function (Env, safeKey, cb) {
cb();
};

@ -0,0 +1,128 @@
/*jshint esversion: 6 */
const Data = module.exports;
const Meta = require("../metadata");
const BatchRead = require("../batch-read");
const WriteQueue = require("../write-queue");
const Core = require("./core");
const Util = require("../common-util");
const batchMetadata = BatchRead("GET_METADATA");
Data.getMetadata = function (Env, channel, cb/* , Server */) {
if (!Core.isValidId(channel)) { return void cb('INVALID_CHAN'); }
if (channel.length !== 32) { return cb("INVALID_CHAN_LENGTH"); }
// FIXME get metadata from the server cache if it is available
batchMetadata(channel, cb, function (done) {
var ref = {};
var lineHandler = Meta.createLineHandler(ref, Env.Log.error);
return void Env.msgStore.readChannelMetadata(channel, lineHandler, function (err) {
if (err) {
// stream errors?
return void done(err);
}
done(void 0, ref.meta);
});
});
};
/* setMetadata
- write a new line to the metadata log if a valid command is provided
- data is an object: {
channel: channelId,
command: metadataCommand (string),
value: value
}
*/
var queueMetadata = WriteQueue();
Data.setMetadata = function (Env, safeKey, data, cb, Server) {
var unsafeKey = Util.unescapeKeyCharacters(safeKey);
var channel = data.channel;
var command = data.command;
if (!channel || !Core.isValidId(channel)) { return void cb ('INVALID_CHAN'); }
if (!command || typeof (command) !== 'string') { return void cb ('INVALID_COMMAND'); }
if (Meta.commands.indexOf(command) === -1) { return void('UNSUPPORTED_COMMAND'); }
queueMetadata(channel, function (next) {
Data.getMetadata(Env, channel, function (err, metadata) {
if (err) {
cb(err);
return void next();
}
if (!Core.hasOwners(metadata)) {
cb('E_NO_OWNERS');
return void next();
}
// if you are a pending owner and not an owner
// you can either ADD_OWNERS, or RM_PENDING_OWNERS
// and you should only be able to add yourself as an owner
// everything else should be rejected
// else if you are not an owner
// you should be rejected
// else write the command
// Confirm that the channel is owned by the user in question
// or the user is accepting a pending ownership offer
if (Core.hasPendingOwners(metadata) &&
Core.isPendingOwner(metadata, unsafeKey) &&
!Core.isOwner(metadata, unsafeKey)) {
// If you are a pending owner, make sure you can only add yourelf as an owner
if ((command !== 'ADD_OWNERS' && command !== 'RM_PENDING_OWNERS')
|| !Array.isArray(data.value)
|| data.value.length !== 1
|| data.value[0] !== unsafeKey) {
cb('INSUFFICIENT_PERMISSIONS');
return void next();
}
// FIXME wacky fallthrough is hard to read
// we could pass this off to a writeMetadataCommand function
// and make the flow easier to follow
} else if (!Core.isOwner(metadata, unsafeKey)) {
cb('INSUFFICIENT_PERMISSIONS');
return void next();
}
// Add the new metadata line
var line = [command, data.value, +new Date()];
var changed = false;
try {
changed = Meta.handleCommand(metadata, line);
} catch (e) {
cb(e);
return void next();
}
// if your command is valid but it didn't result in any change to the metadata,
// call back now and don't write any "useless" line to the log
if (!changed) {
cb(void 0, metadata);
return void next();
}
Env.msgStore.writeMetadata(channel, JSON.stringify(line), function (e) {
if (e) {
cb(e);
return void next();
}
cb(void 0, metadata);
next();
const metadata_cache = Env.historyKeeper.metadata_cache;
const channel_cache = Env.historyKeeper.channel_cache;
metadata_cache[channel] = metadata;
var index = Util.find(channel_cache, [channel, 'index']);
if (index && typeof(index) === 'object') { index.metadata = metadata; }
Server.channelBroadcast(channel, JSON.stringify(metadata), Env.historyKeeper.id);
});
});
});
};

@ -0,0 +1,464 @@
/*jshint esversion: 6 */
const Core = require("./core");
const BatchRead = require("../batch-read");
const Pins = require("../pins");
const Pinning = module.exports;
const Nacl = require("tweetnacl/nacl-fast");
const Util = require("../common-util");
const nThen = require("nthen");
const Saferphore = require("saferphore");
const Pinned = require('../../scripts/pinned');
//const escapeKeyCharacters = Util.escapeKeyCharacters;
const unescapeKeyCharacters = Util.unescapeKeyCharacters;
var sumChannelSizes = function (sizes) {
return Object.keys(sizes).map(function (id) { return sizes[id]; })
.filter(function (x) {
// only allow positive numbers
return !(typeof(x) !== 'number' || x <= 0);
})
.reduce(function (a, b) { return a + b; }, 0);
};
// FIXME it's possible for this to respond before the server has had a chance
// to fetch the limits. Maybe we should respond with an error...
// or wait until we actually know the limits before responding
var getLimit = Pinning.getLimit = function (Env, publicKey, cb) {
var unescapedKey = unescapeKeyCharacters(publicKey);
var limit = Env.limits[unescapedKey];
var defaultLimit = typeof(Env.defaultStorageLimit) === 'number'?
Env.defaultStorageLimit: Core.DEFAULT_LIMIT;
var toSend = limit && typeof(limit.limit) === "number"?
[limit.limit, limit.plan, limit.note] : [defaultLimit, '', ''];
cb(void 0, toSend);
};
var addPinned = function (
Env,
publicKey /*:string*/,
channelList /*Array<string>*/,
cb /*:()=>void*/)
{
Env.evPinnedPadsReady.reg(() => {
channelList.forEach((c) => {
const x = Env.pinnedPads[c] = Env.pinnedPads[c] || {};
x[publicKey] = 1;
});
cb();
});
};
var removePinned = function (
Env,
publicKey /*:string*/,
channelList /*Array<string>*/,
cb /*:()=>void*/)
{
Env.evPinnedPadsReady.reg(() => {
channelList.forEach((c) => {
const x = Env.pinnedPads[c];
if (!x) { return; }
delete x[publicKey];
});
cb();
});
};
var getMultipleFileSize = function (Env, channels, cb) {
if (!Array.isArray(channels)) { return cb('INVALID_PIN_LIST'); }
if (typeof(Env.msgStore.getChannelSize) !== 'function') {
return cb('GET_CHANNEL_SIZE_UNSUPPORTED');
}
var i = channels.length;
var counts = {};
var done = function () {
i--;
if (i === 0) { return cb(void 0, counts); }
};
channels.forEach(function (channel) {
Pinning.getFileSize(Env, channel, function (e, size) {
if (e) {
// most likely error here is that a file no longer exists
// but a user still has it in their drive, and wants to know
// its size. We should find a way to inform them of this in
// the future. For now we can just tell them it has no size.
//WARN('getFileSize', e);
counts[channel] = 0;
return done();
}
counts[channel] = size;
done();
});
});
};
const batchUserPins = BatchRead("LOAD_USER_PINS");
var loadUserPins = function (Env, publicKey, cb) {
var session = Core.getSession(Env.Sessions, publicKey);
if (session.channels) {
return cb(session.channels);
}
batchUserPins(publicKey, cb, function (done) {
var ref = {};
var lineHandler = Pins.createLineHandler(ref, function (label, data) {
Env.Log.error(label, {
log: publicKey,
data: data,
});
});
// if channels aren't in memory. load them from disk
Env.pinStore.getMessages(publicKey, lineHandler, function () {
// no more messages
// only put this into the cache if it completes
session.channels = ref.pins;
done(ref.pins); // FIXME no error handling?
});
});
};
var truthyKeys = function (O) {
return Object.keys(O).filter(function (k) {
return O[k];
});
};
var getChannelList = Pinning.getChannelList = function (Env, publicKey, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
loadUserPins(Env, publicKey, function (pins) {
cb(truthyKeys(pins));
});
};
const batchTotalSize = BatchRead("GET_TOTAL_SIZE");
Pinning.getTotalSize = function (Env, publicKey, cb) {
var unescapedKey = unescapeKeyCharacters(publicKey);
var limit = Env.limits[unescapedKey];
// Get a common key if multiple users share the same quota, otherwise take the public key
var batchKey = (limit && Array.isArray(limit.users)) ? limit.users.join('') : publicKey;
batchTotalSize(batchKey, cb, function (done) {
var channels = [];
var bytes = 0;
nThen(function (waitFor) {
// Get the channels list for our user account
Pinning.getChannelList(Env, publicKey, waitFor(function (_channels) {
if (!_channels) {
waitFor.abort();
return done('INVALID_PIN_LIST');
}
Array.prototype.push.apply(channels, _channels);
}));
// Get the channels list for users sharing our quota
if (limit && Array.isArray(limit.users) && limit.users.length > 1) {
limit.users.forEach(function (key) {
if (key === unescapedKey) { return; } // Don't count ourselves twice
getChannelList(Env, key, waitFor(function (_channels) {
if (!_channels) { return; } // Broken user, don't count their quota
Array.prototype.push.apply(channels, _channels);
}));
});
}
}).nThen(function (waitFor) {
// Get size of the channels
var list = []; // Contains the channels already counted in the quota to avoid duplicates
channels.forEach(function (channel) { // TODO semaphore?
if (list.indexOf(channel) !== -1) { return; }
list.push(channel);
Pinning.getFileSize(Env, channel, waitFor(function (e, size) {
if (!e) { bytes += size; }
}));
});
}).nThen(function () {
done(void 0, bytes);
});
});
};
/* Users should be able to clear their own pin log with an authenticated RPC
*/
Pinning.removePins = function (Env, safeKey, cb) {
if (typeof(Env.pinStore.removeChannel) !== 'function') {
return void cb("E_NOT_IMPLEMENTED");
}
Env.pinStore.removeChannel(safeKey, function (err) {
Env.Log.info('DELETION_PIN_BY_OWNER_RPC', {
safeKey: safeKey,
status: err? String(err): 'SUCCESS',
});
if (err) { return void cb(err); }
cb(void 0, 'OK');
});
};
Pinning.trimPins = function (Env, safeKey, cb) {
cb("NOT_IMPLEMENTED");
};
var getFreeSpace = Pinning.getFreeSpace = function (Env, publicKey, cb) {
getLimit(Env, publicKey, function (e, limit) {
if (e) { return void cb(e); }
Pinning.getTotalSize(Env, publicKey, function (e, size) {
if (typeof(size) === 'undefined') { return void cb(e); }
var rem = limit[0] - size;
if (typeof(rem) !== 'number') {
return void cb('invalid_response');
}
cb(void 0, rem);
});
});
};
var hashChannelList = function (A) {
var uniques = [];
A.forEach(function (a) {
if (uniques.indexOf(a) === -1) { uniques.push(a); }
});
uniques.sort();
var hash = Nacl.util.encodeBase64(Nacl.hash(Nacl
.util.decodeUTF8(JSON.stringify(uniques))));
return hash;
};
var getHash = Pinning.getHash = function (Env, publicKey, cb) {
getChannelList(Env, publicKey, function (channels) {
cb(void 0, hashChannelList(channels));
});
};
Pinning.pinChannel = function (Env, publicKey, channels, cb) {
if (!channels && channels.filter) {
return void cb('INVALID_PIN_LIST');
}
// get channel list ensures your session has a cached channel list
getChannelList(Env, publicKey, function (pinned) {
var session = Core.getSession(Env.Sessions, publicKey);
// only pin channels which are not already pinned
var toStore = channels.filter(function (channel) {
return pinned.indexOf(channel) === -1;
});
if (toStore.length === 0) {
return void getHash(Env, publicKey, cb);
}
getMultipleFileSize(Env, toStore, function (e, sizes) {
if (typeof(sizes) === 'undefined') { return void cb(e); }
var pinSize = sumChannelSizes(sizes);
getFreeSpace(Env, publicKey, function (e, free) {
if (typeof(free) === 'undefined') {
Env.WARN('getFreeSpace', e);
return void cb(e);
}
if (pinSize > free) { return void cb('E_OVER_LIMIT'); }
Env.pinStore.message(publicKey, JSON.stringify(['PIN', toStore, +new Date()]),
function (e) {
if (e) { return void cb(e); }
toStore.forEach(function (channel) {
session.channels[channel] = true;
});
addPinned(Env, publicKey, toStore, () => {});
getHash(Env, publicKey, cb);
});
});
});
});
};
Pinning.unpinChannel = function (Env, publicKey, channels, cb) {
if (!channels && channels.filter) {
// expected array
return void cb('INVALID_PIN_LIST');
}
getChannelList(Env, publicKey, function (pinned) {
var session = Core.getSession(Env.Sessions, publicKey);
// only unpin channels which are pinned
var toStore = channels.filter(function (channel) {
return pinned.indexOf(channel) !== -1;
});
if (toStore.length === 0) {
return void getHash(Env, publicKey, cb);
}
Env.pinStore.message(publicKey, JSON.stringify(['UNPIN', toStore, +new Date()]),
function (e) {
if (e) { return void cb(e); }
toStore.forEach(function (channel) {
delete session.channels[channel];
});
removePinned(Env, publicKey, toStore, () => {});
getHash(Env, publicKey, cb);
});
});
};
Pinning.resetUserPins = function (Env, publicKey, channelList, cb) {
if (!Array.isArray(channelList)) { return void cb('INVALID_PIN_LIST'); }
var session = Core.getSession(Env.Sessions, publicKey);
if (!channelList.length) {
return void getHash(Env, publicKey, function (e, hash) {
if (e) { return cb(e); }
cb(void 0, hash);
});
}
var pins = {};
getMultipleFileSize(Env, channelList, function (e, sizes) {
if (typeof(sizes) === 'undefined') { return void cb(e); }
var pinSize = sumChannelSizes(sizes);
getLimit(Env, publicKey, function (e, limit) {
if (e) {
Env.WARN('[RESET_ERR]', e);
return void cb(e);
}
/* we want to let people pin, even if they are over their limit,
but they should only be able to do this once.
This prevents data loss in the case that someone registers, but
does not have enough free space to pin their migrated data.
They will not be able to pin additional pads until they upgrade
or delete enough files to go back under their limit. */
if (pinSize > limit[0] && session.hasPinned) { return void(cb('E_OVER_LIMIT')); }
Env.pinStore.message(publicKey, JSON.stringify(['RESET', channelList, +new Date()]),
function (e) {
if (e) { return void cb(e); }
channelList.forEach(function (channel) {
pins[channel] = true;
});
var oldChannels;
if (session.channels && typeof(session.channels) === 'object') {
oldChannels = Object.keys(session.channels);
} else {
oldChannels = [];
}
removePinned(Env, publicKey, oldChannels, () => {
addPinned(Env, publicKey, channelList, ()=>{});
});
// update in-memory cache IFF the reset was allowed.
session.channels = pins;
getHash(Env, publicKey, function (e, hash) {
cb(e, hash);
});
});
});
});
};
Pinning.getFileSize = function (Env, channel, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
if (!Core.isValidId(channel)) { return void cb('INVALID_CHAN'); }
if (channel.length === 32) {
if (typeof(Env.msgStore.getChannelSize) !== 'function') {
return cb('GET_CHANNEL_SIZE_UNSUPPORTED');
}
return void Env.msgStore.getChannelSize(channel, function (e, size /*:number*/) {
if (e) {
if (e.code === 'ENOENT') { return void cb(void 0, 0); }
return void cb(e.code);
}
cb(void 0, size);
});
}
// 'channel' refers to a file, so you need another API
Env.blobStore.size(channel, function (e, size) {
if (typeof(size) === 'undefined') { return void cb(e); }
cb(void 0, size);
});
};
/* accepts a list, and returns a sublist of channel or file ids which seem
to have been deleted from the server (file size 0)
we might consider that we should only say a file is gone if fs.stat returns
ENOENT, but for now it's simplest to just rely on getFileSize...
*/
Pinning.getDeletedPads = function (Env, channels, cb) {
if (!Array.isArray(channels)) { return cb('INVALID_LIST'); }
var L = channels.length;
var sem = Saferphore.create(10);
var absentees = [];
var job = function (channel, wait) {
return function (give) {
Pinning.getFileSize(Env, channel, wait(give(function (e, size) {
if (e) { return; }
if (size === 0) { absentees.push(channel); }
})));
};
};
nThen(function (w) {
for (var i = 0; i < L; i++) {
sem.take(job(channels[i], w));
}
}).nThen(function () {
cb(void 0, absentees);
});
};
// inform that the
Pinning.loadChannelPins = function (Env) {
Pinned.load(function (err, data) {
if (err) {
Env.Log.error("LOAD_CHANNEL_PINS", err);
// FIXME not sure what should be done here instead
Env.pinnedPads = {};
Env.evPinnedPadsReady.fire();
return;
}
Env.pinnedPads = data;
Env.evPinnedPadsReady.fire();
}, {
pinPath: Env.paths.pin,
});
};
Pinning.isChannelPinned = function (Env, channel, cb) {
Env.evPinnedPadsReady.reg(() => {
if (Env.pinnedPads[channel] && Object.keys(Env.pinnedPads[channel]).length) { // FIXME 'Object.keys' here is overkill. We only need to know that it isn't empty
cb(void 0, true);
} else {
delete Env.pinnedPads[channel];
cb(void 0, false);
}
});
};

@ -0,0 +1,107 @@
/*jshint esversion: 6 */
/* globals Buffer*/
const Quota = module.exports;
const Util = require("../common-util");
const Package = require('../../package.json');
const Https = require("https");
Quota.applyCustomLimits = function (Env) {
var isLimit = function (o) {
var valid = o && typeof(o) === 'object' &&
typeof(o.limit) === 'number' &&
typeof(o.plan) === 'string' &&
typeof(o.note) === 'string';
return valid;
};
// read custom limits from the Environment (taken from config)
var customLimits = (function (custom) {
var limits = {};
Object.keys(custom).forEach(function (k) {
k.replace(/\/([^\/]+)$/, function (all, safeKey) {
var id = Util.unescapeKeyCharacters(safeKey || '');
limits[id] = custom[k];
return '';
});
});
return limits;
}(Env.customLimits || {}));
Object.keys(customLimits).forEach(function (k) {
if (!isLimit(customLimits[k])) { return; }
Env.limits[k] = customLimits[k];
});
};
Quota.updateCachedLimits = function (Env, cb) {
if (Env.adminEmail === false) {
Quota.applyCustomLimits(Env);
if (Env.allowSubscriptions === false) { return; }
throw new Error("allowSubscriptions must be false if adminEmail is false");
}
var body = JSON.stringify({
domain: Env.myDomain,
subdomain: Env.mySubdomain || null,
adminEmail: Env.adminEmail,
version: Package.version
});
var options = {
host: 'accounts.cryptpad.fr',
path: '/api/getauthorized',
method: 'POST',
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body)
}
};
var req = Https.request(options, function (response) {
if (!('' + response.statusCode).match(/^2\d\d$/)) {
return void cb('SERVER ERROR ' + response.statusCode);
}
var str = '';
response.on('data', function (chunk) {
str += chunk;
});
response.on('end', function () {
try {
var json = JSON.parse(str);
Env.limits = json;
Quota.applyCustomLimits(Env);
cb(void 0);
} catch (e) {
cb(e);
}
});
});
req.on('error', function (e) {
Quota.applyCustomLimits(Env);
// FIXME this is always falsey. Maybe we just suppress errors?
if (!Env.domain) { return cb(); }
cb(e);
});
req.end(body);
};
// The limits object contains storage limits for all the publicKey that have paid
// To each key is associated an object containing the 'limit' value and a 'note' explaining that limit
Quota.getUpdatedLimit = function (Env, safeKey, cb) { // FIXME BATCH?S
Quota.updateCachedLimits(Env, function (err) {
if (err) { return void cb(err); }
var limit = Env.limits[safeKey];
if (limit && typeof(limit.limit) === 'number') {
return void cb(void 0, [limit.limit, limit.plan, limit.note]);
}
return void cb(void 0, [Env.defaultStorageLimit, '', '']);
});
};

@ -0,0 +1,57 @@
/*jshint esversion: 6 */
const Upload = module.exports;
const Util = require("../common-util");
const Pinning = require("./pin-rpc");
const nThen = require("nthen");
const Core = require("./core");
Upload.status = function (Env, safeKey, filesize, _cb) { // FIXME FILES
var cb = Util.once(Util.mkAsync(_cb));
// validate that the provided size is actually a positive number
if (typeof(filesize) !== 'number' &&
filesize >= 0) { return void cb('E_INVALID_SIZE'); }
if (filesize >= Env.maxUploadSize) { return cb('TOO_LARGE'); }
nThen(function (w) {
var abortAndCB = Util.both(w.abort, cb);
Env.blobStore.status(safeKey, w(function (err, inProgress) {
// if there's an error something is weird
if (err) { return void abortAndCB(err); }
// we cannot upload two things at once
if (inProgress) { return void abortAndCB(void 0, true); }
}));
}).nThen(function () {
// if yuo're here then there are no pending uploads
// check if you have space in your quota to upload something of this size
Pinning.getFreeSpace(Env, safeKey, function (e, free) {
if (e) { return void cb(e); }
if (filesize >= free) { return cb('NOT_ENOUGH_SPACE'); }
var user = Core.getSession(Env.Sessions, safeKey);
user.pendingUploadSize = filesize;
user.currentUploadSize = 0;
cb(void 0, false);
});
});
};
Upload.upload = function (Env, safeKey, chunk, cb) {
Env.blobStore.upload(safeKey, chunk, cb);
};
Upload.complete = function (Env, safeKey, arg, cb) {
Env.blobStore.complete(safeKey, arg, cb);
};
Upload.cancel = function (Env, safeKey, arg, cb) {
Env.blobStore.cancel(safeKey, arg, cb);
};
Upload.complete_owned = function (Env, safeKey, arg, cb) {
Env.blobStore.completeOwned(safeKey, arg, cb);
};

@ -0,0 +1,97 @@
/* jshint esversion: 6 */
const nThen = require('nthen');
const Crypto = require('crypto');
const WriteQueue = require("./write-queue");
const BatchRead = require("./batch-read");
const RPC = require("./rpc");
const HK = require("./hk-util.js");
module.exports.create = function (config, cb) {
const Log = config.log;
Log.silly('HK_LOADING', 'LOADING HISTORY_KEEPER MODULE');
// TODO populate Env with everything that you use from config
// so that you can stop passing around your raw config
// and more easily share state between historyKeeper and rpc
const Env = {
Log: Log,
// tasks
// store
id: Crypto.randomBytes(8).toString('hex'),
metadata_cache: {},
channel_cache: {},
queueStorage: WriteQueue(),
batchIndexReads: BatchRead("HK_GET_INDEX"),
};
config.historyKeeper = {
metadata_cache: Env.metadata_cache,
channel_cache: Env.channel_cache,
id: Env.id,
channelMessage: function (Server, channel, msgStruct) {
// netflux-server emits 'channelMessage' events whenever someone broadcasts to a channel
// historyKeeper stores these messages if the channel id indicates that they are
// a channel type with permanent history
HK.onChannelMessage(Env, Server, channel, msgStruct);
},
channelClose: function (channelName) {
// netflux-server emits 'channelClose' events whenever everyone leaves a channel
// we drop cached metadata and indexes at the same time
HK.dropChannel(Env, channelName);
},
channelOpen: function (Server, channelName, userId) {
Env.channel_cache[channelName] = {};
Server.send(userId, [
0,
Env.id,
'JOIN',
channelName
]);
},
directMessage: function (Server, seq, userId, json) {
// netflux-server allows you to register an id with a handler
// this handler is invoked every time someone sends a message to that id
HK.onDirectMessage(Env, Server, seq, userId, json);
},
};
Log.verbose('HK_ID', 'History keeper ID: ' + Env.id);
nThen(function (w) {
require('../storage/file').create(config, w(function (_store) {
config.store = _store;
Env.store = _store;
}));
}).nThen(function (w) {
require("../storage/tasks").create(config, w(function (e, tasks) {
if (e) {
throw e;
}
Env.tasks = tasks;
config.tasks = tasks;
if (config.disableIntegratedTasks) { return; }
config.intervals = config.intervals || {};
config.intervals.taskExpiration = setInterval(function () {
tasks.runAll(function (err) {
if (err) {
// either TASK_CONCURRENCY or an error with tasks.list
// in either case it is already logged.
}
});
}, 1000 * 60 * 5); // run every five minutes
}));
}).nThen(function () {
RPC.create(config, function (err, _rpc) {
if (err) { throw err; }
Env.rpc = _rpc;
cb(void 0, config.historyKeeper);
});
});
};

@ -0,0 +1,929 @@
/* jshint esversion: 6 */
/* global Buffer */
var HK = module.exports;
const nThen = require('nthen');
const Once = require("./once");
const Meta = require("./metadata");
const Nacl = require('tweetnacl/nacl-fast');
const now = function () { return (new Date()).getTime(); };
const ONE_DAY = 1000 * 60 * 60 * 24; // one day in milliseconds
/* getHash
* this function slices off the leading portion of a message which is
most likely unique
* these "hashes" are used to identify particular messages in a channel's history
* clients store "hashes" either in memory or in their drive to query for new messages:
* when reconnecting to a pad
* when connecting to chat or a mailbox
* thus, we can't change this function without invalidating client data which:
* is encrypted clientside
* can't be easily migrated
* don't break it!
*/
const getHash = HK.getHash = function (msg, Log) {
if (typeof(msg) !== 'string') {
if (Log) {
Log.warn('HK_GET_HASH', 'getHash() called on ' + typeof(msg) + ': ' + msg);
}
return '';
}
return msg.slice(0,64);
};
// historyKeeper should explicitly store any channel
// with a 32 character id
const STANDARD_CHANNEL_LENGTH = HK.STANDARD_CHANNEL_LENGTH = 32;
// historyKeeper should not store messages sent to any channel
// with a 34 character id
const EPHEMERAL_CHANNEL_LENGTH = HK.EPHEMERAL_CHANNEL_LENGTH = 34;
const tryParse = function (Env, str) {
try {
return JSON.parse(str);
} catch (err) {
Env.Log.error('HK_PARSE_ERROR', err);
}
};
/* sliceCpIndex
returns a list of all checkpoints which might be relevant for a client connecting to a session
* if there are two or fewer checkpoints, return everything you have
* if there are more than two
* return at least two
* plus any more which were received within the last 100 messages
This is important because the additional history is what prevents
clients from forking on checkpoints and dropping forked history.
*/
const sliceCpIndex = function (cpIndex, line) {
// Remove "old" checkpoints (cp sent before 100 messages ago)
const minLine = Math.max(0, (line - 100));
let start = cpIndex.slice(0, -2);
const end = cpIndex.slice(-2);
start = start.filter(function (obj) {
return obj.line > minLine;
});
return start.concat(end);
};
const isMetadataMessage = function (parsed) {
return Boolean(parsed && parsed.channel);
};
// validateKeyStrings supplied by clients must decode to 32-byte Uint8Arrays
const isValidValidateKeyString = function (key) {
try {
return typeof(key) === 'string' &&
Nacl.util.decodeBase64(key).length === Nacl.sign.publicKeyLength;
} catch (e) {
return false;
}
};
var CHECKPOINT_PATTERN = /^cp\|(([A-Za-z0-9+\/=]+)\|)?/;
/* expireChannel is here to clean up channels that should have been removed
but for some reason are still present
*/
const expireChannel = function (Env, channel) {
return void Env.store.archiveChannel(channel, function (err) {
Env.Log.info("ARCHIVAL_CHANNEL_BY_HISTORY_KEEPER_EXPIRATION", {
channelId: channel,
status: err? String(err): "SUCCESS",
});
});
};
/* dropChannel
* cleans up memory structures which are managed entirely by the historyKeeper
*/
const dropChannel = HK.dropChannel = function (Env, chanName) {
delete Env.metadata_cache[chanName];
delete Env.channel_cache[chanName];
};
/* checkExpired
* synchronously returns true or undefined to indicate whether the channel is expired
* according to its metadata
* has some side effects:
* closes the channel via the store.closeChannel API
* and then broadcasts to all channel members that the channel has expired
* removes the channel from the netflux-server's in-memory cache
* removes the channel metadata from history keeper's in-memory cache
FIXME the boolean nature of this API should be separated from its side effects
*/
const checkExpired = function (Env, Server, channel) {
const store = Env.store;
const metadata_cache = Env.metadata_cache;
if (!(channel && channel.length === STANDARD_CHANNEL_LENGTH)) { return false; }
let metadata = metadata_cache[channel];
if (!(metadata && typeof(metadata.expire) === 'number')) { return false; }
// the number of milliseconds ago the channel should have expired
let pastDue = (+new Date()) - metadata.expire;
// less than zero means that it hasn't expired yet
if (pastDue < 0) { return false; }
// if it should have expired more than a day ago...
// there may have been a problem with scheduling tasks
// or the scheduled tasks may not be running
// so trigger a removal from here
if (pastDue >= ONE_DAY) { expireChannel(Env, channel); }
// close the channel
store.closeChannel(channel, function () {
Server.channelBroadcast(channel, {
error: 'EEXPIRED',
channel: channel
}, Env.id);
dropChannel(channel);
});
// return true to indicate that it has expired
return true;
};
/* computeIndex
can call back with an error or a computed index which includes:
* cpIndex:
* array including any checkpoints pushed within the last 100 messages
* processed by 'sliceCpIndex(cpIndex, line)'
* offsetByHash:
* a map containing message offsets by their hash
* this is for every message in history, so it could be very large...
* except we remove offsets from the map if they occur before the oldest relevant checkpoint
* size: in bytes
* metadata:
* validationKey
* expiration time
* owners
* ??? (anything else we might add in the future)
* line
* the number of messages in history
* including the initial metadata line, if it exists
*/
const computeIndex = function (Env, channelName, cb) {
const store = Env.store;
const Log = Env.Log;
const cpIndex = [];
let messageBuf = [];
let metadata;
let i = 0;
const ref = {};
const CB = Once(cb);
const offsetByHash = {};
let size = 0;
nThen(function (w) {
// iterate over all messages in the channel log
// old channels can contain metadata as the first message of the log
// remember metadata the first time you encounter it
// otherwise index important messages in the log
store.readMessagesBin(channelName, 0, (msgObj, readMore) => {
let msg;
// keep an eye out for the metadata line if you haven't already seen it
// but only check for metadata on the first line
if (!i && !metadata && msgObj.buff.indexOf('{') === 0) {
i++; // always increment the message counter
msg = tryParse(Env, msgObj.buff.toString('utf8'));
if (typeof msg === "undefined") { return readMore(); }
// validate that the current line really is metadata before storing it as such
if (isMetadataMessage(msg)) {
metadata = msg;
return readMore();
}
}
i++;
if (msgObj.buff.indexOf('cp|') > -1) {
msg = msg || tryParse(Env, msgObj.buff.toString('utf8'));
if (typeof msg === "undefined") { return readMore(); }
// cache the offsets of checkpoints if they can be parsed
if (msg[2] === 'MSG' && msg[4].indexOf('cp|') === 0) {
cpIndex.push({
offset: msgObj.offset,
line: i
});
// we only want to store messages since the latest checkpoint
// so clear the buffer every time you see a new one
messageBuf = [];
}
}
// if it's not metadata or a checkpoint then it should be a regular message
// store it in the buffer
messageBuf.push(msgObj);
return readMore();
}, w((err) => {
if (err && err.code !== 'ENOENT') {
w.abort();
return void CB(err);
}
// once indexing is complete you should have a buffer of messages since the latest checkpoint
// map the 'hash' of each message to its byte offset in the log, to be used for reconnecting clients
messageBuf.forEach((msgObj) => {
const msg = tryParse(Env, msgObj.buff.toString('utf8'));
if (typeof msg === "undefined") { return; }
if (msg[0] === 0 && msg[2] === 'MSG' && typeof(msg[4]) === 'string') {
// msgObj.offset is API guaranteed by our storage module
// it should always be a valid positive integer
offsetByHash[getHash(msg[4], Log)] = msgObj.offset;
}
// There is a trailing \n at the end of the file
size = msgObj.offset + msgObj.buff.length + 1;
});
}));
}).nThen(function (w) {
// create a function which will iterate over amendments to the metadata
const handler = Meta.createLineHandler(ref, Log.error);
// initialize the accumulator in case there was a foundational metadata line in the log content
if (metadata) { handler(void 0, metadata); }
// iterate over the dedicated metadata log (if it exists)
// proceed even in the event of a stream error on the metadata log
store.readDedicatedMetadata(channelName, handler, w(function (err) {
if (err) {
return void Log.error("DEDICATED_METADATA_ERROR", err);
}
}));
}).nThen(function () {
// when all is done, cache the metadata in memory
if (ref.index) { // but don't bother if no metadata was found...
metadata = Env.metadata_cache[channelName] = ref.meta;
}
// and return the computed index
CB(null, {
// Only keep the checkpoints included in the last 100 messages
cpIndex: sliceCpIndex(cpIndex, i),
offsetByHash: offsetByHash,
size: size,
metadata: metadata,
line: i
});
});
};
/* getIndex
calls back with an error if anything goes wrong
or with a cached index for a channel if it exists
(along with metadata)
otherwise it calls back with the index computed by 'computeIndex'
as an added bonus:
if the channel exists but its index does not then it caches the index
*/
const getIndex = (Env, channelName, cb) => {
const channel_cache = Env.channel_cache;
const chan = channel_cache[channelName];
// if there is a channel in memory and it has an index cached, return it
if (chan && chan.index) {
// enforce async behaviour
return void setTimeout(function () {
cb(undefined, chan.index);
});
}
Env.batchIndexReads(channelName, cb, function (done) {
computeIndex(Env, channelName, (err, ret) => {
// this is most likely an unrecoverable filesystem error
if (err) { return void done(err); }
// cache the computed result if possible
if (chan) { chan.index = ret; }
// return
done(void 0, ret);
});
});
};
/* storeMessage
* channel id
* the message to store
* whether the message is a checkpoint
* optionally the hash of the message
* it's not always used, but we guard against it
* async but doesn't have a callback
* source of a race condition whereby:
* two messaages can be inserted
* two offsets can be computed using the total size of all the messages
* but the offsets don't correspond to the actual location of the newlines
* because the two actions were performed like ABba...
* the fix is to use callbacks and implement queueing for writes
* to guarantee that offset computation is always atomic with writes
*/
const storeMessage = function (Env, channel, msg, isCp, optionalMessageHash) {
const id = channel.id;
const Log = Env.Log;
Env.queueStorage(id, function (next) {
const msgBin = Buffer.from(msg + '\n', 'utf8');
// Store the message first, and update the index only once it's stored.
// store.messageBin can be async so updating the index first may
// result in a wrong cpIndex
nThen((waitFor) => {
Env.store.messageBin(id, msgBin, waitFor(function (err) {
if (err) {
waitFor.abort();
Log.error("HK_STORE_MESSAGE_ERROR", err.message);
// this error is critical, but there's not much we can do at the moment
// proceed with more messages, but they'll probably fail too
// at least you won't have a memory leak
// TODO make it possible to respond to clients with errors so they know
// their message wasn't stored
return void next();
}
}));
}).nThen((waitFor) => {
getIndex(Env, id, waitFor((err, index) => {
if (err) {
Log.warn("HK_STORE_MESSAGE_INDEX", err.stack);
// non-critical, we'll be able to get the channel index later
return void next();
}
if (typeof (index.line) === "number") { index.line++; }
if (isCp) {
index.cpIndex = sliceCpIndex(index.cpIndex, index.line || 0);
for (let k in index.offsetByHash) {
if (index.offsetByHash[k] < index.cpIndex[0]) {
delete index.offsetByHash[k];
}
}
index.cpIndex.push({
offset: index.size,
line: ((index.line || 0) + 1)
});
}
if (optionalMessageHash) { index.offsetByHash[optionalMessageHash] = index.size; }
index.size += msgBin.length;
// handle the next element in the queue
next();
}));
});
});
};
/* getHistoryOffset
returns a number representing the byte offset from the start of the log
for whatever history you're seeking.
query by providing a 'lastKnownHash',
which is really just a string of the first 64 characters of an encrypted message.
OR by -1 which indicates that we want the full history (byte offset 0)
OR nothing, which indicates that you want whatever messages the historyKeeper deems relevant
(typically the last few checkpoints)
this function embeds a lot of the history keeper's logic:
0. if you passed -1 as the lastKnownHash it means you want the complete history
* I'm not sure why you'd need to call this function if you know it will return 0 in this case...
* it has a side-effect of filling the index cache if it's empty
1. if you provided a lastKnownHash and that message does not exist in the history:
* either the client has made a mistake or the history they knew about no longer exists
* call back with EINVAL
2. if you did not provide a lastKnownHash
* and there are fewer than two checkpoints:
* return 0 (read from the start of the file)
* and there are two or more checkpoints:
* return the offset of the earliest checkpoint which 'sliceCpIndex' considers relevant
3. if you did provide a lastKnownHash
* read through the log until you find the hash that you're looking for
* call back with either the byte offset of the message that you found OR
* -1 if you didn't find it
*/
const getHistoryOffset = (Env, channelName, lastKnownHash, cb) => {
const store = Env.store;
const Log = Env.Log;
// lastKnownhash === -1 means we want the complete history
if (lastKnownHash === -1) { return void cb(null, 0); }
let offset = -1;
nThen((waitFor) => {
getIndex(Env, channelName, waitFor((err, index) => {
if (err) { waitFor.abort(); return void cb(err); }
// check if the "hash" the client is requesting exists in the index
const lkh = index.offsetByHash[lastKnownHash];
// we evict old hashes from the index as new checkpoints are discovered.
// if someone connects and asks for a hash that is no longer relevant,
// we tell them it's an invalid request. This is because of the semantics of "GET_HISTORY"
// which is only ever used when connecting or reconnecting in typical uses of history...
// this assumption should hold for uses by chainpad, but perhaps not for other uses cases.
// EXCEPT: other cases don't use checkpoints!
// clients that are told that their request is invalid should just make another request
// without specifying the hash, and just trust the server to give them the relevant data.
// QUESTION: does this mean mailboxes are causing the server to store too much stuff in memory?
if (lastKnownHash && typeof(lkh) !== "number") {
waitFor.abort();
return void cb(new Error('EINVAL'));
}
// Since last 2 checkpoints
if (!lastKnownHash) {
waitFor.abort();
// Less than 2 checkpoints in the history: return everything
if (index.cpIndex.length < 2) { return void cb(null, 0); }
// Otherwise return the second last checkpoint's index
return void cb(null, index.cpIndex[0].offset);
/* LATER...
in practice, two checkpoints can be very close together
we have measures to avoid duplicate checkpoints, but editors
can produce nearby checkpoints which are slightly different,
and slip past these protections. To be really careful, we can
seek past nearby checkpoints by some number of patches so as
to ensure that all editors have sufficient knowledge of history
to reconcile their differences. */
}
offset = lkh;
}));
}).nThen((waitFor) => {
// if offset is less than zero then presumably the channel has no messages
// returning falls through to the next block and therefore returns -1
if (offset !== -1) { return; }
// do a lookup from the index
// FIXME maybe we don't need this anymore?
// otherwise we have a non-negative offset and we can start to read from there
store.readMessagesBin(channelName, 0, (msgObj, readMore, abort) => {
// tryParse return a parsed message or undefined
const msg = tryParse(Env, msgObj.buff.toString('utf8'));
// if it was undefined then go onto the next message
if (typeof msg === "undefined") { return readMore(); }
if (typeof(msg[4]) !== 'string' || lastKnownHash !== getHash(msg[4], Log)) {
return void readMore();
}
offset = msgObj.offset;
abort();
}, waitFor(function (err) {
if (err) { waitFor.abort(); return void cb(err); }
}));
}).nThen(() => {
cb(null, offset);
});
};
/* getHistoryAsync
* finds the appropriate byte offset from which to begin reading using 'getHistoryOffset'
* streams through the rest of the messages, safely parsing them and returning the parsed content to the handler
* calls back when it has reached the end of the log
Used by:
* GET_HISTORY
*/
const getHistoryAsync = (Env, channelName, lastKnownHash, beforeHash, handler, cb) => {
const store = Env.store;
let offset = -1;
nThen((waitFor) => {
getHistoryOffset(Env, channelName, lastKnownHash, waitFor((err, os) => {
if (err) {
waitFor.abort();
return void cb(err);
}
offset = os;
}));
}).nThen((waitFor) => {
if (offset === -1) { return void cb(new Error("could not find offset")); }
const start = (beforeHash) ? 0 : offset;
store.readMessagesBin(channelName, start, (msgObj, readMore, abort) => {
if (beforeHash && msgObj.offset >= offset) { return void abort(); }
var parsed = tryParse(Env, msgObj.buff.toString('utf8'));
if (!parsed) { return void readMore(); }
handler(parsed, readMore);
}, waitFor(function (err) {
return void cb(err);
}));
});
};
/* getOlderHistory
* allows clients to query for all messages until a known hash is read
* stores all messages in history as they are read
* can therefore be very expensive for memory
* should probably be converted to a streaming interface
Used by:
* GET_HISTORY_RANGE
*/
const getOlderHistory = function (Env, channelName, oldestKnownHash, cb) {
const store = Env.store;
const Log = Env.Log;
var messageBuffer = [];
var found = false;
store.getMessages(channelName, function (msgStr) {
if (found) { return; }
let parsed = tryParse(Env, msgStr);
if (typeof parsed === "undefined") { return; }
// identify classic metadata messages by their inclusion of a channel.
// and don't send metadata, since:
// 1. the user won't be interested in it
// 2. this metadata is potentially incomplete/incorrect
if (isMetadataMessage(parsed)) { return; }
var content = parsed[4];
if (typeof(content) !== 'string') { return; }
var hash = getHash(content, Log);
if (hash === oldestKnownHash) {
found = true;
}
messageBuffer.push(parsed);
}, function (err) {
if (err) {
Log.error("HK_GET_OLDER_HISTORY", err);
}
cb(messageBuffer);
});
};
const handleRPC = function (Env, Server, seq, userId, parsed) {
const HISTORY_KEEPER_ID = Env.id;
/* RPC Calls... */
var rpc_call = parsed.slice(1);
Server.send(userId, [seq, 'ACK']);
try {
// slice off the sequence number and pass in the rest of the message
Env.rpc(Server, rpc_call, function (err, output) {
if (err) {
Server.send(userId, [0, HISTORY_KEEPER_ID, 'MSG', userId, JSON.stringify([parsed[0], 'ERROR', err])]);
return;
}
Server.send(userId, [0, HISTORY_KEEPER_ID, 'MSG', userId, JSON.stringify([parsed[0]].concat(output))]);
});
} catch (e) {
// if anything throws in the middle, send an error
Server.send(userId, [0, HISTORY_KEEPER_ID, 'MSG', userId, JSON.stringify([parsed[0], 'ERROR', 'SERVER_ERROR'])]);
}
};
const handleGetHistory = function (Env, Server, seq, userId, parsed) {
const store = Env.store;
const tasks = Env.tasks;
const metadata_cache = Env.metadata_cache;
const channel_cache = Env.channel_cache;
const HISTORY_KEEPER_ID = Env.id;
const Log = Env.Log;
// parsed[1] is the channel id
// parsed[2] is a validation key or an object containing metadata (optionnal)
// parsed[3] is the last known hash (optionnal)
Server.send(userId, [seq, 'ACK']);
var channelName = parsed[1];
var config = parsed[2];
var metadata = {};
var lastKnownHash;
// clients can optionally pass a map of attributes
// if the channel already exists this map will be ignored
// otherwise it will be stored as the initial metadata state for the channel
if (config && typeof config === "object" && !Array.isArray(parsed[2])) {
lastKnownHash = config.lastKnownHash;
metadata = config.metadata || {};
if (metadata.expire) {
metadata.expire = +metadata.expire * 1000 + (+new Date());
}
}
metadata.channel = channelName;
metadata.created = +new Date();
// if the user sends us an invalid key, we won't be able to validate their messages
// so they'll never get written to the log anyway. Let's just drop their message
// on the floor instead of doing a bunch of extra work
// TODO send them an error message so they know something is wrong
if (metadata.validateKey && !isValidValidateKeyString(metadata.validateKey)) {
return void Log.error('HK_INVALID_KEY', metadata.validateKey);
}
nThen(function (waitFor) {
var w = waitFor();
/* unless this is a young channel, we will serve all messages from an offset
this will not include the channel metadata, so we need to explicitly fetch that.
unfortunately, we can't just serve it blindly, since then young channels will
send the metadata twice, so let's do a quick check of what we're going to serve...
*/
getIndex(Env, channelName, waitFor((err, index) => {
/* if there's an error here, it should be encountered
and handled by the next nThen block.
so, let's just fall through...
*/
if (err) { return w(); }
// it's possible that the channel doesn't have metadata
// but in that case there's no point in checking if the channel expired
// or in trying to send metadata, so just skip this block
if (!index || !index.metadata) { return void w(); }
// And then check if the channel is expired. If it is, send the error and abort
// FIXME this is hard to read because 'checkExpired' has side effects
if (checkExpired(Env, Server, channelName)) { return void waitFor.abort(); }
// always send metadata with GET_HISTORY requests
Server.send(userId, [0, HISTORY_KEEPER_ID, 'MSG', userId, JSON.stringify(index.metadata)], w);
}));
}).nThen(() => {
let msgCount = 0;
// TODO compute lastKnownHash in a manner such that it will always skip past the metadata line?
getHistoryAsync(Env, channelName, lastKnownHash, false, (msg, readMore) => {
msgCount++;
// avoid sending the metadata message a second time
if (isMetadataMessage(msg) && metadata_cache[channelName]) { return readMore(); }
Server.send(userId, [0, HISTORY_KEEPER_ID, 'MSG', userId, JSON.stringify(msg)], readMore);
}, (err) => {
if (err && err.code !== 'ENOENT') {
if (err.message !== 'EINVAL') { Log.error("HK_GET_HISTORY", err); }
const parsedMsg = {error:err.message, channel: channelName};
Server.send(userId, [0, HISTORY_KEEPER_ID, 'MSG', userId, JSON.stringify(parsedMsg)]);
return;
}
const chan = channel_cache[channelName];
if (msgCount === 0 && !metadata_cache[channelName] && Server.channelContainsUser(channelName, userId)) {
metadata_cache[channelName] = metadata;
// the index will have already been constructed and cached at this point
// but it will not have detected any metadata because it hasn't been written yet
// this means that the cache starts off as invalid, so we have to correct it
if (chan && chan.index) { chan.index.metadata = metadata; }
// new channels will always have their metadata written to a dedicated metadata log
// but any lines after the first which are not amendments in a particular format will be ignored.
// Thus we should be safe from race conditions here if just write metadata to the log as below...
// TODO validate this logic
// otherwise maybe we need to check that the metadata log is empty as well
store.writeMetadata(channelName, JSON.stringify(metadata), function (err) {
if (err) {
// FIXME tell the user that there was a channel error?
return void Log.error('HK_WRITE_METADATA', {
channel: channelName,
error: err,
});
}
});
// write tasks
if(metadata.expire && typeof(metadata.expire) === 'number') {
// the fun part...
// the user has said they want this pad to expire at some point
tasks.write(metadata.expire, "EXPIRE", [ channelName ], function (err) {
if (err) {
// if there is an error, we don't want to crash the whole server...
// just log it, and if there's a problem you'll be able to fix it
// at a later date with the provided information
Log.error('HK_CREATE_EXPIRE_TASK', err);
Log.info('HK_INVALID_EXPIRE_TASK', JSON.stringify([metadata.expire, 'EXPIRE', channelName]));
}
});
}
Server.send(userId, [0, HISTORY_KEEPER_ID, 'MSG', userId, JSON.stringify(metadata)]);
}
// End of history message:
let parsedMsg = {state: 1, channel: channelName};
Server.send(userId, [0, HISTORY_KEEPER_ID, 'MSG', userId, JSON.stringify(parsedMsg)]);
});
});
};
const handleGetHistoryRange = function (Env, Server, seq, userId, parsed) {
var channelName = parsed[1];
var map = parsed[2];
const HISTORY_KEEPER_ID = Env.id;
if (!(map && typeof(map) === 'object')) {
return void Server.send(userId, [seq, 'ERROR', 'INVALID_ARGS', HISTORY_KEEPER_ID]);
}
var oldestKnownHash = map.from;
var desiredMessages = map.count;
var desiredCheckpoint = map.cpCount;
var txid = map.txid;
if (typeof(desiredMessages) !== 'number' && typeof(desiredCheckpoint) !== 'number') {
return void Server.send(userId, [seq, 'ERROR', 'UNSPECIFIED_COUNT', HISTORY_KEEPER_ID]);
}
if (!txid) {
return void Server.send(userId, [seq, 'ERROR', 'NO_TXID', HISTORY_KEEPER_ID]);
}
Server.send(userId, [seq, 'ACK']);
return void getOlderHistory(Env, channelName, oldestKnownHash, function (messages) {
var toSend = [];
if (typeof (desiredMessages) === "number") {
toSend = messages.slice(-desiredMessages);
} else {
let cpCount = 0;
for (var i = messages.length - 1; i >= 0; i--) {
if (/^cp\|/.test(messages[i][4]) && i !== (messages.length - 1)) {
cpCount++;
}
toSend.unshift(messages[i]);
if (cpCount >= desiredCheckpoint) { break; }
}
}
toSend.forEach(function (msg) {
Server.send(userId, [0, HISTORY_KEEPER_ID, 'MSG', userId,
JSON.stringify(['HISTORY_RANGE', txid, msg])]);
});
Server.send(userId, [0, HISTORY_KEEPER_ID, 'MSG', userId,
JSON.stringify(['HISTORY_RANGE_END', txid, channelName])
]);
});
};
const handleGetFullHistory = function (Env, Server, seq, userId, parsed) {
const HISTORY_KEEPER_ID = Env.id;
const Log = Env.Log;
// parsed[1] is the channel id
// parsed[2] is a validation key (optionnal)
// parsed[3] is the last known hash (optionnal)
Server.send(userId, [seq, 'ACK']);
// FIXME should we send metadata here too?
// none of the clientside code which uses this API needs metadata, but it won't hurt to send it (2019-08-22)
return void getHistoryAsync(Env, parsed[1], -1, false, (msg, readMore) => {
Server.send(userId, [0, HISTORY_KEEPER_ID, 'MSG', userId, JSON.stringify(['FULL_HISTORY', msg])], readMore);
}, (err) => {
let parsedMsg = ['FULL_HISTORY_END', parsed[1]];
if (err) {
Log.error('HK_GET_FULL_HISTORY', err.stack);
parsedMsg = ['ERROR', parsed[1], err.message];
}
Server.send(userId, [0, HISTORY_KEEPER_ID, 'MSG', userId, JSON.stringify(parsedMsg)]);
});
};
const directMessageCommands = {
GET_HISTORY: handleGetHistory,
GET_HISTORY_RANGE: handleGetHistoryRange,
GET_FULL_HISTORY: handleGetFullHistory,
};
/* onDirectMessage
* exported for use by the netflux-server
* parses and handles all direct messages directed to the history keeper
* check if it's expired and execute all the associated side-effects
* routes queries to the appropriate handlers
*/
HK.onDirectMessage = function (Env, Server, seq, userId, json) {
const Log = Env.Log;
Log.silly('HK_MESSAGE', json);
let parsed;
try {
parsed = JSON.parse(json[2]);
} catch (err) {
Log.error("HK_PARSE_CLIENT_MESSAGE", json);
return;
}
// If the requested history is for an expired channel, abort
// Note the if we don't have the keys for that channel in metadata_cache, we'll
// have to abort later (once we know the expiration time)
if (checkExpired(Env, Server, parsed[1])) { return; }
// look up the appropriate command in the map of commands or fall back to RPC
var command = directMessageCommands[parsed[0]] || handleRPC;
// run the command with the standard function signature
command(Env, Server, seq, userId, parsed);
};
/* onChannelMessage
Determine what we should store when a message a broadcasted to a channel"
* ignores ephemeral channels
* ignores messages sent to expired channels
* rejects duplicated checkpoints
* validates messages to channels that have validation keys
* caches the id of the last saved checkpoint
* adds timestamps to incoming messages
* writes messages to the store
*/
HK.onChannelMessage = function (Env, Server, channel, msgStruct) {
const Log = Env.Log;
// TODO our usage of 'channel' here looks prone to errors
// we only use it for its 'id', but it can contain other stuff
// also, we're using this RPC from both the RPC and Netflux-server
// we should probably just change this to expect a channel id directly
// don't store messages if the channel id indicates that it's an ephemeral message
if (!channel.id || channel.id.length === EPHEMERAL_CHANNEL_LENGTH) { return; }
const isCp = /^cp\|/.test(msgStruct[4]);
let id;
if (isCp) {
// id becomes either null or an array or results...
id = CHECKPOINT_PATTERN.exec(msgStruct[4]);
if (Array.isArray(id) && id[2] && id[2] === channel.lastSavedCp) {
// Reject duplicate checkpoints
return;
}
}
let metadata;
nThen(function (w) {
// getIndex (and therefore the latest metadata)
getIndex(Env, channel.id, w(function (err, index) {
if (err) {
w.abort();
return void Log.error('CHANNEL_MESSAGE_ERROR', err);
}
if (!index.metadata) {
// if there's no channel metadata then it can't be an expiring channel
// nor can we possibly validate it
return;
}
metadata = index.metadata;
// don't write messages to expired channels
if (checkExpired(Env, Server, channel)) { return void w.abort(); }
// if there's no validateKey present skip to the next block
if (!metadata.validateKey) { return; }
// trim the checkpoint indicator off the message if it's present
let signedMsg = (isCp) ? msgStruct[4].replace(CHECKPOINT_PATTERN, '') : msgStruct[4];
// convert the message from a base64 string into a Uint8Array
// FIXME this can fail and the client won't notice
signedMsg = Nacl.util.decodeBase64(signedMsg);
// FIXME this can blow up
// TODO check that that won't cause any problems other than not being able to append...
const validateKey = Nacl.util.decodeBase64(metadata.validateKey);
// validate the message
const validated = Nacl.sign.open(signedMsg, validateKey);
if (!validated) {
// don't go any further if the message fails validation
w.abort();
Log.info("HK_SIGNED_MESSAGE_REJECTED", 'Channel '+channel.id);
return;
}
}));
}).nThen(function () {
// do checkpoint stuff...
// 1. get the checkpoint id
// 2. reject duplicate checkpoints
if (isCp) {
// if the message is a checkpoint we will have already validated
// that it isn't a duplicate. remember its id so that we can
// repeat this process for the next incoming checkpoint
// WARNING: the fact that we only check the most recent checkpoints
// is a potential source of bugs if one editor has high latency and
// pushes a duplicate of an earlier checkpoint than the latest which
// has been pushed by editors with low latency
// FIXME
if (Array.isArray(id) && id[2]) {
// Store new checkpoint hash
channel.lastSavedCp = id[2];
}
}
// add the time to the message
msgStruct.push(now());
// storeMessage
storeMessage(Env, channel, JSON.stringify(msgStruct), isCp, getHash(msgStruct[4], Log));
});
};

@ -211,12 +211,14 @@ Meta.createLineHandler = function (ref, errorHandler) {
line: JSON.stringify(line),
});
}
// the case above is special, everything else should increment the index
var index = ref.index++;
if (typeof(line) === 'undefined') { return; }
if (Array.isArray(line)) {
try {
handleCommand(ref.meta, line);
ref.index++;
} catch (err2) {
errorHandler("METADATA_COMMAND_ERR", {
error: err2.stack,
@ -226,8 +228,15 @@ Meta.createLineHandler = function (ref, errorHandler) {
return;
}
if (ref.index === 0 && typeof(line) === 'object') {
ref.index++;
// the first line of a channel is processed before the dedicated metadata log.
// it can contain a map, in which case it should be used as the initial state.
// it's possible that a trim-history command was interrupted, in which case
// this first message might exist in parallel with the more recent metadata log
// which will contain the computed state of the previous metadata log
// which has since been archived.
// Thus, accept both the first and second lines you process as valid initial state
// preferring the second if it exists
if (index < 2 && line && typeof(line) === 'object') {
// special case!
ref.meta = line;
return;
@ -235,7 +244,7 @@ Meta.createLineHandler = function (ref, errorHandler) {
errorHandler("METADATA_HANDLER_WEIRDLINE", {
line: line,
index: ref.index++,
index: index,
});
};
};

@ -0,0 +1,306 @@
/*jshint esversion: 6 */
const nThen = require("nthen");
const Util = require("./common-util");
const mkEvent = Util.mkEvent;
const Core = require("./commands/core");
const Admin = require("./commands/admin-rpc");
const Pinning = require("./commands/pin-rpc");
const Quota = require("./commands/quota");
const Block = require("./commands/block");
const Metadata = require("./commands/metadata");
const Channel = require("./commands/channel");
const Upload = require("./commands/upload");
var RPC = module.exports;
const Store = require("../storage/file");
const BlobStore = require("../storage/blob");
const UNAUTHENTICATED_CALLS = {
GET_FILE_SIZE: Pinning.getFileSize,
GET_MULTIPLE_FILE_SIZE: Pinning.getMultipleFileSize,
GET_DELETED_PADS: Pinning.getDeletedPads,
IS_CHANNEL_PINNED: Pinning.isChannelPinned,
IS_NEW_CHANNEL: Channel.isNewChannel,
WRITE_PRIVATE_MESSAGE: Channel.writePrivateMessage,
GET_METADATA: Metadata.getMetadata,
};
var isUnauthenticateMessage = function (msg) {
return msg && msg.length === 2 && typeof(UNAUTHENTICATED_CALLS[msg[0]]) === 'function';
};
var handleUnauthenticatedMessage = function (Env, msg, respond, Server) {
Env.Log.silly('LOG_RPC', msg[0]);
var method = UNAUTHENTICATED_CALLS[msg[0]];
method(Env, msg[1], function (err, value) {
if (err) {
Env.WARN(err, msg[1]);
return void respond(err);
}
respond(err, [null, value, null]);
}, Server);
};
const AUTHENTICATED_USER_TARGETED = {
RESET: Pinning.resetUserPins,
PIN: Pinning.pinChannel,
UNPIN: Pinning.unpinChannel,
CLEAR_OWNED_CHANNEL: Channel.clearOwnedChannel,
REMOVE_OWNED_CHANNEL: Channel.removeOwnedChannel,
TRIM_HISTORY: Channel.trimHistory,
UPLOAD_STATUS: Upload.status,
UPLOAD: Upload.upload,
UPLOAD_COMPLETE: Upload.complete,
UPLOAD_CANCEL: Upload.cancel,
OWNED_UPLOAD_COMPLETE: Upload.complete_owned,
WRITE_LOGIN_BLOCK: Block.writeLoginBlock,
REMOVE_LOGIN_BLOCK: Block.removeLoginBlock,
ADMIN: Admin.command,
SET_METADATA: Metadata.setMetadata,
};
const AUTHENTICATED_USER_SCOPED = {
GET_HASH: Pinning.getHash,
GET_TOTAL_SIZE: Pinning.getTotalSize,
UPDATE_LIMITS: Quota.getUpdatedLimit,
GET_LIMIT: Pinning.getLimit,
EXPIRE_SESSION: Core.expireSessionAsync,
REMOVE_PINS: Pinning.removePins,
TRIM_PINS: Pinning.trimPins,
COOKIE: Core.haveACookie,
};
var isAuthenticatedCall = function (call) {
if (call === 'UPLOAD') { return false; }
return typeof(AUTHENTICATED_USER_TARGETED[call] || AUTHENTICATED_USER_SCOPED[call]) === 'function';
};
var handleAuthenticatedMessage = function (Env, unsafeKey, msg, respond, Server) {
/* If you have gotten this far, you have signed the message with the
public key which you provided.
*/
var safeKey = Util.escapeKeyCharacters(unsafeKey);
var Respond = function (e, value) {
var session = Env.Sessions[safeKey];
var token = session? session.tokens.slice(-1)[0]: '';
var cookie = Core.makeCookie(token).join('|');
respond(e ? String(e): e, [cookie].concat(typeof(value) !== 'undefined' ?value: []));
};
msg.shift();
// discard validated cookie from message
if (!msg.length) {
return void Respond('INVALID_MSG');
}
var TYPE = msg[0];
Env.Log.silly('LOG_RPC', TYPE);
if (typeof(AUTHENTICATED_USER_TARGETED[TYPE]) === 'function') {
return void AUTHENTICATED_USER_TARGETED[TYPE](Env, safeKey, msg[1], function (e, value) {
Env.WARN(e, value);
return void Respond(e, value);
}, Server);
}
if (typeof(AUTHENTICATED_USER_SCOPED[TYPE]) === 'function') {
return void AUTHENTICATED_USER_SCOPED[TYPE](Env, safeKey, function (e, value) {
if (e) {
Env.WARN(e, safeKey);
return void Respond(e);
}
Respond(e, value);
});
}
return void Respond('UNSUPPORTED_RPC_CALL', msg);
};
var rpc = function (Env, Server, data, respond) {
if (!Array.isArray(data)) {
Env.Log.debug('INVALID_ARG_FORMET', data);
return void respond('INVALID_ARG_FORMAT');
}
if (!data.length) {
return void respond("INSUFFICIENT_ARGS");
} else if (data.length !== 1) {
Env.Log.debug('UNEXPECTED_ARGUMENTS_LENGTH', data);
}
var msg = data[0].slice(0);
if (!Array.isArray(msg)) {
return void respond('INVALID_ARG_FORMAT');
}
if (isUnauthenticateMessage(msg)) {
return handleUnauthenticatedMessage(Env, msg, respond, Server);
}
var signature = msg.shift();
var publicKey = msg.shift();
// make sure a user object is initialized in the cookie jar
if (publicKey) {
Core.getSession(Env.Sessions, publicKey);
} else {
Env.Log.debug("NO_PUBLIC_KEY_PROVIDED", publicKey);
}
var cookie = msg[0];
if (!Core.isValidCookie(Env.Sessions, publicKey, cookie)) {
// no cookie is fine if the RPC is to get a cookie
if (msg[1] !== 'COOKIE') {
return void respond('NO_COOKIE');
}
}
var serialized = JSON.stringify(msg);
if (!(serialized && typeof(publicKey) === 'string')) {
return void respond('INVALID_MESSAGE_OR_PUBLIC_KEY');
}
var command = msg[1];
if (command === 'UPLOAD') {
// UPLOAD is a special case that skips signature validation
// intentional fallthrough behaviour
return void handleAuthenticatedMessage(Env, publicKey, msg, respond, Server);
}
if (isAuthenticatedCall(command)) {
// check the signature on the message
// refuse the command if it doesn't validate
if (Core.checkSignature(Env, serialized, signature, publicKey) === true) {
return void handleAuthenticatedMessage(Env, publicKey, msg, respond, Server);
}
return void respond("INVALID_SIGNATURE_OR_PUBLIC_KEY");
}
Env.Log.warn('INVALID_RPC_CALL', command);
return void respond("INVALID_RPC_CALL");
};
RPC.create = function (config, cb) {
var Log = config.log;
// load pin-store...
Log.silly('LOADING RPC MODULE');
var keyOrDefaultString = function (key, def) {
return typeof(config[key]) === 'string'? config[key]: def;
};
var WARN = function (e, output) {
if (e && output) {
Log.warn(e, {
output: output,
message: String(e),
stack: new Error(e).stack,
});
}
};
if (typeof(config.domain) !== 'undefined') {
throw new Error('fuck');
}
var Env = {
historyKeeper: config.historyKeeper,
intervals: config.intervals || {},
maxUploadSize: config.maxUploadSize || (20 * 1024 * 1024),
Sessions: {},
paths: {},
msgStore: config.store,
pinStore: undefined,
pinnedPads: {},
evPinnedPadsReady: mkEvent(true),
limits: {},
admins: [],
Log: Log,
WARN: WARN,
flushCache: config.flushCache,
adminEmail: config.adminEmail,
allowSubscriptions: config.allowSubscriptions,
myDomain: config.myDomain,
mySubdomain: config.mySubdomain,
customLimits: config.customLimits,
// FIXME this attribute isn't in the default conf
// but it is referenced in Quota
domain: config.domain
};
Env.defaultStorageLimit = typeof(config.defaultStorageLimit) === 'number' && config.defaultStorageLimit > 0?
config.defaultStorageLimit:
Core.DEFAULT_LIMIT;
try {
Env.admins = (config.adminKeys || []).map(function (k) {
k = k.replace(/\/+$/, '');
var s = k.split('/');
return s[s.length-1];
});
} catch (e) {
console.error("Can't parse admin keys. Please update or fix your config.js file!");
}
var Sessions = Env.Sessions;
var paths = Env.paths;
var pinPath = paths.pin = keyOrDefaultString('pinPath', './pins');
paths.block = keyOrDefaultString('blockPath', './block');
paths.data = keyOrDefaultString('filePath', './datastore');
paths.staging = keyOrDefaultString('blobStagingPath', './blobstage');
paths.blob = keyOrDefaultString('blobPath', './blob');
var updateLimitDaily = function () {
Quota.updateCachedLimits(Env, function (e) {
if (e) {
WARN('limitUpdate', e);
}
});
};
Quota.applyCustomLimits(Env);
updateLimitDaily();
Env.intervals.dailyLimitUpdate = setInterval(updateLimitDaily, 24*3600*1000);
Pinning.loadChannelPins(Env);
nThen(function (w) {
Store.create({
filePath: pinPath,
}, w(function (s) {
Env.pinStore = s;
}));
BlobStore.create({
blobPath: config.blobPath,
blobStagingPath: config.blobStagingPath,
archivePath: config.archivePath,
getSession: function (safeKey) {
return Core.getSession(Sessions, safeKey);
},
}, w(function (err, blob) {
if (err) { throw new Error(err); }
Env.blobStore = blob;
}));
}).nThen(function () {
cb(void 0, function (Server, data, respond) {
try {
return rpc(Env, Server, data, respond);
} catch (e) {
console.log("Error from RPC with data " + JSON.stringify(data));
console.log(e.stack);
}
});
// expire old sessions once per minute
Env.intervals.sessionExpirationInterval = setInterval(function () {
Core.expireSessions(Sessions);
}, Core.SESSION_EXPIRATION_TIME);
});
};

@ -0,0 +1,172 @@
var WriteQueue = require("./write-queue");
var Util = require("./common-util");
/* This module provides implements a FIFO scheduler
which assumes the existence of three types of async tasks:
1. ordered tasks which must be executed sequentially
2. unordered tasks which can be executed in parallel
3. blocking tasks which must block the execution of all other tasks
The scheduler assumes there will be many resources identified by strings,
and that the constraints described above will only apply in the context
of identical string ids.
Many blocking tasks may be executed in parallel so long as they
concern resources identified by different ids.
USAGE:
const schedule = require("./schedule")();
// schedule two sequential tasks using the resource 'pewpew'
schedule.ordered('pewpew', function (next) {
appendToFile('beep\n', next);
});
schedule.ordered('pewpew', function (next) {
appendToFile('boop\n', next);
});
// schedule a task that can happen whenever
schedule.unordered('pewpew', function (next) {
displayFileSize(next);
});
// schedule a blocking task which will wait
// until the all unordered tasks have completed before commencing
schedule.blocking('pewpew', function (next) {
deleteFile(next);
});
// this will be queued for after the blocking task
schedule.ordered('pewpew', function (next) {
appendFile('boom', next);
});
*/
// return a uid which is not already in a map
var unusedUid = function (set) {
var uid = Util.uid();
if (set[uid]) { return unusedUid(); }
return uid;
};
// return an existing session, creating one if it does not already exist
var lookup = function (map, id) {
return (map[id] = map[id] || {
//blocking: [],
active: {},
blocked: {},
});
};
var isEmpty = function (map) {
for (var key in map) {
if (map.hasOwnProperty(key)) { return false; }
}
return true;
};
module.exports = function () {
// every scheduler instance has its own queue
var queue = WriteQueue();
// ordered tasks don't require any extra logic
var Ordered = function (id, task) {
queue(id, task);
};
// unordered and blocking tasks need a little extra state
var map = {};
// regular garbage collection keeps memory consumption low
var collectGarbage = function (id) {
// avoid using 'lookup' since it creates a session implicitly
var local = map[id];
// bail out if no session
if (!local) { return; }
// bail out if there are blocking or active tasks
if (local.lock) { return; }
if (!isEmpty(local.active)) { return; }
// if there are no pending actions then delete the session
delete map[id];
};
// unordered tasks run immediately if there are no blocking tasks scheduled
// or immediately after blocking tasks finish
var runImmediately = function (local, task) {
// set a flag in the map of active unordered tasks
// to prevent blocking tasks from running until you finish
var uid = unusedUid(local.active);
local.active[uid] = true;
task(function () {
// remove the flag you set to indicate that your task completed
delete local.active[uid];
// don't do anything if other unordered tasks are still running
if (!isEmpty(local.active)) { return; }
// bail out if there are no blocking tasks scheduled or ready
if (typeof(local.waiting) !== 'function') {
return void collectGarbage();
}
setTimeout(local.waiting);
});
};
var runOnceUnblocked = function (local, task) {
var uid = unusedUid(local.blocked);
local.blocked[uid] = function () {
runImmediately(local, task);
};
};
// 'unordered' tasks are scheduled to run in after the most recently received blocking task
// or immediately and in parallel if there are no blocking tasks scheduled.
var Unordered = function (id, task) {
var local = lookup(map, id);
if (local.lock) { return runOnceUnblocked(local, task); }
runImmediately(local, task);
};
var runBlocked = function (local) {
for (var task in local.blocked) {
runImmediately(local, local.blocked[task]);
}
};
// 'blocking' tasks must be run alone.
// They are queued alongside ordered tasks,
// and wait until any running 'unordered' tasks complete before commencing.
var Blocking = function (id, task) {
var local = lookup(map, id);
queue(id, function (next) {
// start right away if there are no running unordered tasks
if (isEmpty(local.active)) {
local.lock = true;
return void task(function () {
delete local.lock;
runBlocked(local);
next();
});
}
// otherwise wait until the running tasks have completed
local.waiting = function () {
local.lock = true;
task(function () {
delete local.lock;
delete local.waiting;
runBlocked(local);
next();
});
};
});
};
return {
ordered: Ordered,
unordered: Unordered,
blocking: Blocking,
};
};

99
package-lock.json generated

@ -1,6 +1,6 @@
{
"name": "cryptpad",
"version": "3.10.0",
"version": "3.11.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@ -99,9 +99,9 @@
"integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg="
},
"chainpad-crypto": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/chainpad-crypto/-/chainpad-crypto-0.2.2.tgz",
"integrity": "sha512-7MJ7qPz/C4sJPsDhPMjdSRmliOCPoRO0XM1vUomcgXA6HINlW+if9AAt/H4q154nYhZ/b57njgC6cWgd/RDidg==",
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/chainpad-crypto/-/chainpad-crypto-0.2.4.tgz",
"integrity": "sha512-fWbVyeAv35vf/dkkQaefASlJcEfpEvfRI23Mtn+/TBBry7+LYNuJMXJiovVY35pfyw2+trKh1Py5Asg9vrmaVg==",
"requires": {
"tweetnacl": "git://github.com/dchest/tweetnacl-js.git#v0.12.2"
},
@ -113,14 +113,13 @@
}
},
"chainpad-server": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/chainpad-server/-/chainpad-server-3.0.5.tgz",
"integrity": "sha512-USKOMSHsNjnme81Qy3nQ+ji9eCkBPokYH4T82LVHAI0aayTSCXcTPUDLVGDBCRqe8NsXU4io1WPXn1KiZwB8fA==",
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/chainpad-server/-/chainpad-server-4.0.2.tgz",
"integrity": "sha512-9NrFsATd70uAdksxsCZBIJ/SiREmJ6QLYTNaeFLH/nJpeZ2b7wblVGABCj3JYWvngdEZ7Umc+afbWH8sUmtgeQ==",
"requires": {
"nthen": "^0.1.8",
"nthen": "0.1.8",
"pull-stream": "^3.6.9",
"stream-to-pull-stream": "^1.7.3",
"tweetnacl": "~0.12.2",
"ws": "^3.3.1"
}
},
@ -161,9 +160,9 @@
"dev": true
},
"commander": {
"version": "2.20.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.0.tgz",
"integrity": "sha512-7j2y+40w61zy6YC2iRNpUe/NwhNyoXrYpHMrSunaMG64nRnaf96zO/KMQR4OyN/UnE5KLyEBnKHd4aG3rskjpQ==",
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"dev": true
},
"concat-map": {
@ -241,9 +240,9 @@
}
},
"dom-serializer": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.1.tgz",
"integrity": "sha512-sK3ujri04WyjwQXVoK4PU3y8ula1stq10GJZpqHIUgoGZdsGzAGu65BnU3d08aTVSvO7mGPZUc0wTEDL+qGE0Q==",
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz",
"integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==",
"dev": true,
"requires": {
"domelementtype": "^2.0.1",
@ -398,15 +397,9 @@
}
},
"flatten": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/flatten/-/flatten-1.0.2.tgz",
"integrity": "sha1-2uRqnXj74lKSJYzB54CkHZXAN4I=",
"dev": true
},
"flow-bin": {
"version": "0.59.0",
"resolved": "https://registry.npmjs.org/flow-bin/-/flow-bin-0.59.0.tgz",
"integrity": "sha512-yJDRffvby5mCTkbwOdXwiGDjeea8Z+BPVuP53/tHqHIZC+KtQD790zopVf7mHk65v+wRn+TZ7tkRSNA9oDmyLg==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/flatten/-/flatten-1.0.3.tgz",
"integrity": "sha512-dVsPA/UwQ8+2uoFe5GHtiBMu48dWLTdsuEd7CKGlZlD78r1TTWBvDuFaFGKCo/ZfEr95Uk56vZoX86OsHkUeIg==",
"dev": true
},
"forwarded": {
@ -450,9 +443,9 @@
}
},
"glob": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz",
"integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==",
"version": "7.1.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
"dev": true,
"requires": {
"fs.realpath": "^1.0.0",
@ -478,9 +471,9 @@
}
},
"graceful-fs": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.2.tgz",
"integrity": "sha512-IItsdsea19BoLC7ELy13q1iJFNmd7ofZH5+X/pJr90/nRoPEX0DJo1dHDbgtYWOhJhcCgMDTOw84RZ72q6lB+Q=="
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.3.tgz",
"integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ=="
},
"has-ansi": {
"version": "2.0.0",
@ -593,9 +586,9 @@
"dev": true
},
"jshint": {
"version": "2.10.2",
"resolved": "https://registry.npmjs.org/jshint/-/jshint-2.10.2.tgz",
"integrity": "sha512-e7KZgCSXMJxznE/4WULzybCMNXNAd/bf5TSrvVEq78Q/K8ZwFpmBqQeDtNiHc3l49nV4E/+YeHU/JZjSUIrLAA==",
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/jshint/-/jshint-2.11.0.tgz",
"integrity": "sha512-ooaD/hrBPhu35xXW4gn+o3SOuzht73gdBuffgJzrZBJZPGgGiiTvJEgTyxFvBO2nz0+X1G6etF8SzUODTlLY6Q==",
"dev": true,
"requires": {
"cli": "~1.0.0",
@ -635,9 +628,9 @@
"dev": true
},
"readable-stream": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz",
"integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==",
"version": "2.3.7",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
"integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
"dev": true,
"requires": {
"core-util-is": "~1.0.0",
@ -766,16 +759,16 @@
"integrity": "sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ=="
},
"mime-db": {
"version": "1.40.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.40.0.tgz",
"integrity": "sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA=="
"version": "1.43.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.43.0.tgz",
"integrity": "sha512-+5dsGEEovYbT8UY9yD7eE4XTc4UwJ1jBYlgaQQF38ENsKR3wj/8q8RFZrF9WIZpB2V1ArTVFUva8sAul1NzRzQ=="
},
"mime-types": {
"version": "2.1.24",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.24.tgz",
"integrity": "sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==",
"version": "2.1.26",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.26.tgz",
"integrity": "sha512-01paPWYgLrkqAyrlDorC1uDwl2p3qZT7yl806vW7DvDoxwXi46jsjFbg+WdwotBIk6/MbEhO/dh5aZ5sNj/dWQ==",
"requires": {
"mime-db": "1.40.0"
"mime-db": "1.43.0"
}
},
"minimatch": {
@ -848,9 +841,9 @@
"dev": true
},
"pako": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.10.tgz",
"integrity": "sha512-0DTvPVU3ed8+HNXOu5Bs+o//Mbdj9VNQMUOe9oKCwh8l0GNwpTDMKCWbRjgtD291AWnkAgkqA/LOnQS8AmS1tw==",
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"dev": true
},
"parseurl": {
@ -1305,19 +1298,19 @@
}
},
"xml2js": {
"version": "0.4.19",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.19.tgz",
"integrity": "sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==",
"version": "0.4.23",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
"integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
"dev": true,
"requires": {
"sax": ">=0.6.0",
"xmlbuilder": "~9.0.1"
"xmlbuilder": "~11.0.0"
}
},
"xmlbuilder": {
"version": "9.0.7",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz",
"integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=",
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"dev": true
}
}

@ -1,7 +1,7 @@
{
"name": "cryptpad",
"description": "realtime collaborative visual editor with zero knowlege server",
"version": "3.10.0",
"version": "3.11.0",
"license": "AGPL-3.0+",
"repository": {
"type": "git",
@ -13,7 +13,7 @@
},
"dependencies": {
"chainpad-crypto": "^0.2.2",
"chainpad-server": "^3.0.5",
"chainpad-server": "^4.0.0",
"express": "~4.16.0",
"fs-extra": "^7.0.0",
"get-folder-size": "^2.0.1",
@ -27,7 +27,6 @@
"ws": "^3.3.1"
},
"devDependencies": {
"flow-bin": "^0.59.0",
"jshint": "^2.10.2",
"less": "2.7.1",
"lesshint": "^4.5.0",
@ -40,8 +39,8 @@
"package": "PACKAGE=1 node server.js",
"lint": "jshint --config .jshintrc --exclude-path .jshintignore . && ./node_modules/lesshint/bin/lesshint -c ./.lesshintrc ./customize.dist/src/less2/",
"lint:js": "jshint --config .jshintrc --exclude-path .jshintignore .",
"lint:server": "jshint --config .jshintrc lib",
"lint:less": "./node_modules/lesshint/bin/lesshint -c ./.lesshintrc ./customize.dist/src/less2/",
"flow": "./node_modules/.bin/flow",
"test": "node scripts/TestSelenium.js",
"test-rpc": "cd scripts/tests && node test-rpc",
"template": "cd customize.dist/src && for page in ../index.html ../privacy.html ../terms.html ../about.html ../contact.html ../what-is-cryptpad.html ../features.html ../../www/login/index.html ../../www/register/index.html ../../www/user/index.html;do echo $page; cp template.html $page; done;",

1766
rpc.js

File diff suppressed because it is too large Load Diff

@ -15,8 +15,6 @@ var inactiveTime = +new Date() - (config.inactiveTime * 24 * 3600 * 1000);
// files which were archived before this date can be considered safe to remove
var retentionTime = +new Date() - (config.archiveRetentionTime * 24 * 3600 * 1000);
var retainData = Boolean(config.retainData);
var getNewestTime = function (stats) {
return stats[['atime', 'ctime', 'mtime'].reduce(function (a, b) {
return stats[b] > stats[a]? b: a;
@ -176,23 +174,6 @@ nThen(function (w) {
if (pins[item.blobId]) { return void next(); }
if (item && getNewestTime(item) > retentionTime) { return void next(); }
if (!retainData) {
return void blobs.remove.blob(item.blobId, function (err) {
if (err) {
Log.error("EVICT_BLOB_ERROR", {
error: err,
item: item,
});
return void next();
}
Log.info("EVICT_BLOB_INACTIVE", {
item: item,
});
removed++;
next();
});
}
blobs.archive.blob(item.blobId, function (err) {
if (err) {
Log.error("EVICT_ARCHIVE_BLOB_ERROR", {
@ -247,7 +228,6 @@ nThen(function (w) {
Log.info("EVICT_BLOB_PROOFS_REMOVED", removed);
}));
}).nThen(function (w) {
var removed = 0;
var channels = 0;
var archived = 0;
@ -279,8 +259,6 @@ nThen(function (w) {
// ignore the channel if it's pinned
if (pins[item.channel]) { return void cb(); }
// if the server is configured to retain data, archive the channel
if (config.retainData) {
return void store.archiveChannel(item.channel, w(function (err) {
if (err) {
Log.error('EVICT_CHANNEL_ARCHIVAL_ERROR', {
@ -293,28 +271,10 @@ nThen(function (w) {
archived++;
cb();
}));
}
// otherwise remove it
store.removeChannel(item.channel, w(function (err) {
if (err) {
Log.error('EVICT_CHANNEL_REMOVAL_ERROR', {
error: err,
channel: item.channel,
});
return void cb();
}
Log.info('EVICT_CHANNEL_REMOVAL', item.channel);
removed++;
cb();
}));
};
var done = function () {
if (config.retainData) {
return void Log.info('EVICT_CHANNELS_ARCHIVED', archived);
}
return void Log.info('EVICT_CHANNELS_REMOVED', removed);
};
store.listChannels(handler, w(done));

@ -159,6 +159,13 @@ var createUser = function (config, cb) {
}
wc.leave();
}));
}).nThen(function (w) {
// FIXME give the server time to write your mailbox data before checking that it's correct
// chainpad-server sends an ACK before the channel has actually been created
// causing you to think that everything is good.
// without this timeout the GET_METADATA rpc occasionally returns before
// the metadata has actually been written to the disk.
setTimeout(w(), 500);
}).nThen(function (w) {
// confirm that you own your mailbox
user.anonRpc.send("GET_METADATA", user.mailboxChannel, w(function (err, data) {
@ -227,6 +234,18 @@ var createUser = function (config, cb) {
return void cb(err);
}
}));
}).nThen(function (w) {
// some basic sanity checks...
user.rpc.getServerHash(w(function (err, hash) {
if (err) {
w.abort();
return void cb(err);
}
if (hash !== EMPTY_ARRAY_HASH) {
console.error("EXPECTED EMPTY ARRAY HASH");
process.exit(1);
}
}));
}).nThen(function () {
user.cleanup = function (cb) {

@ -0,0 +1,220 @@
/* three types of actions:
* read
* write
* append
each of which take a random amount of time
*/
var Util = require("../../lib/common-util");
var schedule = require("../../lib/schedule")();
var nThen = require("nthen");
var rand = function (n) {
return Math.floor(Math.random() * n);
};
var rand_time = function () {
// between 51 and 151
return rand(300) + 25;
};
var makeAction = function (type) {
var i = 0;
return function (time) {
var j = i++;
return function (next) {
console.log(" Beginning action: %s#%s", type, j);
setTimeout(function () {
console.log(" Completed action: %s#%s", type, j);
next();
}, time);
return j;
};
};
};
var TYPES = ['WRITE', 'READ', 'APPEND'];
var chooseAction = function () {
var n = rand(100);
if (n < 50) { return 'APPEND'; }
if (n < 90) { return 'READ'; }
return 'WRITE';
//return TYPES[rand(3)];
};
var test = function (script, cb) {
var uid = Util.uid();
var TO_RUN = script.length;
var total_run = 0;
var parallel = 0;
var last_run_ordered = -1;
//var i = 0;
var ACTIONS = {};
TYPES.forEach(function (type) {
ACTIONS[type] = makeAction(type);
});
nThen(function (w) {
setTimeout(w(), 3000);
// run scripted actions with assertions
script.forEach(function (scene) {
var type = scene[0];
var time = typeof(scene[1]) === 'number'? scene[1]: rand_time();
var action = ACTIONS[type](time);
console.log("Queuing action of type: %s(%s)", type, time);
var proceed = w();
switch (type) {
case 'APPEND':
return schedule.ordered(uid, w(function (next) {
parallel++;
var temp = action(function () {
parallel--;
total_run++;
proceed();
next();
});
if (temp !== (last_run_ordered + 1)) {
throw new Error("out of order");
}
last_run_ordered = temp;
}));
case 'WRITE':
return schedule.blocking(uid, w(function (next) {
parallel++;
action(function () {
parallel--;
total_run++;
proceed();
next();
});
if (parallel > 1) {
console.log("parallelism === %s", parallel);
throw new Error("too much parallel");
}
}));
case 'READ':
return schedule.unordered(uid, w(function (next) {
parallel++;
action(function () {
parallel--;
total_run++;
proceed();
next();
});
}));
default:
throw new Error("wut");
}
});
}).nThen(function () {
// make assertions about the whole script
if (total_run !== TO_RUN) {
console.log("Ran %s / %s", total_run, TO_RUN);
throw new Error("skipped tasks");
}
console.log("total_run === %s", total_run);
cb();
});
};
var randomScript = function () {
var len = rand(15) + 10;
var script = [];
while (len--) {
script.push([
chooseAction(),
rand_time(),
]);
}
return script;
};
var WRITE = function (t) {
return ['WRITE', t];
};
var READ = function (t) {
return ['READ', t];
};
var APPEND = function (t) {
return ['APPEND', t];
};
nThen(function (w) {
test([
['READ', 150],
['APPEND', 200],
['APPEND', 100],
['READ', 350],
['WRITE', 400],
['APPEND', 275],
['APPEND', 187],
['WRITE', 330],
['WRITE', 264],
['WRITE', 256],
], w(function () {
console.log("finished pre-scripted test\n");
}));
}).nThen(function (w) {
test([
WRITE(289),
APPEND(281),
READ(207),
WRITE(225),
READ(279),
WRITE(300),
READ(331),
APPEND(341),
APPEND(385),
READ(313),
WRITE(285),
READ(304),
APPEND(273),
APPEND(150),
WRITE(246),
READ(244),
WRITE(172),
APPEND(253),
READ(215),
READ(296),
APPEND(281),
APPEND(296),
WRITE(168),
], w(function () {
console.log("finished 2nd pre-scripted test\n");
}));
}).nThen(function () {
var totalTests = 50;
var randomTests = 1;
var last = nThen(function () {
console.log("beginning randomized tests");
});
var queueRandomTest = function (i) {
last = last.nThen(function (w) {
console.log("running random test script #%s\n", i);
test(randomScript(), w(function () {
console.log("finished random test #%s\n", i);
}));
});
};
while (randomTests <=totalTests) { queueRandomTest(randomTests++); }
last.nThen(function () {
console.log("finished %s random tests", totalTests);
});
});

@ -4,17 +4,12 @@
var Express = require('express');
var Http = require('http');
var Fs = require('fs');
var WebSocketServer = require('ws').Server;
var NetfluxSrv = require('chainpad-server/NetfluxWebsocketSrv');
var Package = require('./package.json');
var Path = require("path");
var nThen = require("nthen");
var config = require("./lib/load-config");
// support multiple storage back ends
var Storage = require('./storage/file');
var app = Express();
// mode can be FRESH (default), DEV, or PACKAGE
@ -69,11 +64,9 @@ var setHeaders = (function () {
if (Object.keys(headers).length) {
return function (req, res) {
const h = [
/^\/pad(2)?\/inner\.html.*/,
/^\/pad\/inner\.html.*/,
/^\/common\/onlyoffice\/.*\/index\.html.*/,
/^\/sheet\/inner\.html.*/,
/^\/ooslide\/inner\.html.*/,
/^\/oodoc\/inner\.html.*/,
/^\/(sheet|ooslide|oodoc)\/inner\.html.*/,
].some((regex) => {
return regex.test(req.url)
}) ? padHeaders : headers;
@ -117,11 +110,6 @@ app.use(function (req, res, next) {
app.use(Express.static(__dirname + '/www'));
Fs.exists(__dirname + "/customize", function (e) {
if (e) { return; }
console.log("Cryptpad is customizable, see customize.dist/readme.md for details");
});
// FIXME I think this is a regression caused by a recent PR
// correct this hack without breaking the contributor's intended behaviour.
@ -207,7 +195,13 @@ app.use(function (req, res, next) {
var httpServer = Http.createServer(app);
httpServer.listen(config.httpPort,config.httpAddress,function(){
nThen(function (w) {
Fs.exists(__dirname + "/customize", w(function (e) {
if (e) { return; }
console.log("Cryptpad is customizable, see customize.dist/readme.md for details");
}));
}).nThen(function (w) {
httpServer.listen(config.httpPort,config.httpAddress,function(){
var host = config.httpAddress;
var hostName = !host.indexOf(':') ? '[' + host + ']' : host;
@ -215,73 +209,22 @@ httpServer.listen(config.httpPort,config.httpAddress,function(){
var ps = port === 80? '': ':' + port;
console.log('[%s] server available http://%s%s', new Date().toISOString(), hostName, ps);
});
if (config.httpSafePort) {
Http.createServer(app).listen(config.httpSafePort, config.httpAddress);
}
var wsConfig = { server: httpServer };
});
var rpc;
var historyKeeper;
if (config.httpSafePort) {
Http.createServer(app).listen(config.httpSafePort, config.httpAddress, w());
}
}).nThen(function () {
var wsConfig = { server: httpServer };
var log;
// Initialize logging then start the API server
require("./lib/log").create(config, function (_log) {
config.log = _log;
config.httpServer = httpServer;
// Initialize logging, the the store, then tasks, then rpc, then history keeper and then start the server
var nt = nThen(function (w) {
// set up logger
var Logger = require("./lib/log");
//console.log("Loading logging module");
Logger.create(config, w(function (_log) {
log = config.log = _log;
}));
}).nThen(function (w) {
if (config.externalWebsocketURL) {
// if you plan to use an external websocket server
// then you don't need to load any API services other than the logger.
// Just abort.
w.abort();
return;
}
Storage.create(config, w(function (_store) {
config.store = _store;
}));
}).nThen(function (w) {
var Tasks = require("./storage/tasks");
Tasks.create(config, w(function (e, tasks) {
if (e) {
throw e;
}
config.tasks = tasks;
if (config.disableIntegratedTasks) { return; }
setInterval(function () {
tasks.runAll(function (err) {
if (err) {
// either TASK_CONCURRENCY or an error with tasks.list
// in either case it is already logged.
}
if (config.externalWebsocketURL) { return; }
require("./lib/api").create(config);
});
}, 1000 * 60 * 5); // run every five minutes
}));
}).nThen(function (w) {
require("./rpc").create(config, w(function (e, _rpc) {
if (e) {
w.abort();
throw e;
}
rpc = _rpc;
}));
}).nThen(function () {
var HK = require('./historyKeeper.js');
var hkConfig = {
tasks: config.tasks,
rpc: rpc,
store: config.store,
log: log,
retainData: Boolean(config.retainData),
};
historyKeeper = HK.create(hkConfig);
}).nThen(function () {
var wsSrv = new WebSocketServer(wsConfig);
NetfluxSrv.run(wsSrv, config, historyKeeper);
});

@ -7,6 +7,10 @@ var Path = require("path");
var nThen = require("nthen");
var Semaphore = require("saferphore");
var Util = require("../lib/common-util");
var Meta = require("../lib/metadata");
var Extras = require("../lib/hk-util");
const Schedule = require("../lib/schedule");
const Readline = require("readline");
const ToPull = require('stream-to-pull-stream');
const Pull = require('pull-stream');
@ -37,6 +41,10 @@ var mkArchiveMetadataPath = function (env, channelId) {
return Path.join(env.archiveRoot, 'datastore', channelId.slice(0, 2), channelId) + '.metadata.ndjson';
};
var mkTempPath = function (env, channelId) {
return mkPath(env, channelId) + '.temp';
};
// pass in the path so we can reuse the same function for archived files
var channelExists = function (filepath, cb) {
Fs.stat(filepath, function (err, stat) {
@ -553,9 +561,6 @@ var listChannels = function (root, handler, cb) {
// to an equivalent location in the cold storage directory
var archiveChannel = function (env, channelName, cb) {
// TODO close channels before archiving them?
if (!env.retainData) {
return void cb("ARCHIVES_DISABLED");
}
// ctime is the most reliable indicator of when a file was archived
// because it is used to indicate changes to the files metadata
@ -752,6 +757,8 @@ var getChannel = function (
}
if (env.openFiles >= env.openFileLimit) {
// FIXME warn if this is the case?
// alternatively use graceful-fs to handle lots of concurrent reads
// if you're running out of open files, asynchronously clean up expired files
// do it on a shorter timeframe, though (half of normal)
setTimeout(function () {
@ -867,40 +874,187 @@ var getMessages = function (env, chanName, handler, cb) {
});
};
/*::
export type ChainPadServer_MessageObj_t = { buff: Buffer, offset: number };
export type ChainPadServer_Storage_t = {
readMessagesBin: (
channelName:string,
start:number,
asyncMsgHandler:(msg:ChainPadServer_MessageObj_t, moreCb:()=>void, abortCb:()=>void)=>void,
cb:(err:?Error)=>void
)=>void,
message: (channelName:string, content:string, cb:(err:?Error)=>void)=>void,
messageBin: (channelName:string, content:Buffer, cb:(err:?Error)=>void)=>void,
getMessages: (channelName:string, msgHandler:(msg:string)=>void, cb:(err:?Error)=>void)=>void,
removeChannel: (channelName:string, cb:(err:?Error)=>void)=>void,
closeChannel: (channelName:string, cb:(err:?Error)=>void)=>void,
flushUnusedChannels: (cb:()=>void)=>void,
getChannelSize: (channelName:string, cb:(err:?Error, size:?number)=>void)=>void,
getChannelMetadata: (channelName:string, cb:(err:?Error|string, data:?any)=>void)=>void,
clearChannel: (channelName:string, (err:?Error)=>void)=>void
};
export type ChainPadServer_Config_t = {
verbose?: boolean,
filePath?: string,
channelExpirationMs?: number,
openFileLimit?: number
var trimChannel = function (env, channelName, hash, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
// this function is queued as a blocking action for the relevant channel
// derive temporary file paths for metadata and log buffers
var tempChannelPath = mkTempPath(env, channelName);
// derive production db paths
var channelPath = mkPath(env, channelName);
var metadataPath = mkMetadataPath(env, channelName);
// derive archive paths
var archiveChannelPath = mkArchivePath(env, channelName);
var archiveMetadataPath = mkArchiveMetadataPath(env, channelName);
var metadataReference = {};
var tempStream;
var ABORT;
var cleanUp = function (cb) {
if (tempStream && !tempStream.closed) {
try {
tempStream.close();
} catch (err) { }
}
Fse.unlink(tempChannelPath, function (err) {
// proceed if deleted or if there was nothing to delete
if (!err || err.code === 'ENOENT') { return cb(); }
// else abort and call back with the error
cb(err);
});
};
nThen(function (w) {
// close the file descriptor if it is open
closeChannel(env, channelName, w(function (err) {
if (err) {
w.abort();
return void cb(err);
}
}));
}).nThen(function (w) {
cleanUp(w(function (err) {
if (err) {
w.abort();
cb(err);
}
}));
}).nThen(function (w) {
// eat errors since loading the logger here would create a cyclical dependency
var lineHandler = Meta.createLineHandler(metadataReference, Util.noop);
readMetadata(env, channelName, lineHandler, w(function (err) {
if (err) {
w.abort();
return void cb(err);
}
// if there were no errors just fall through to the next block
}));
}).nThen(function (w) {
// create temp buffer writeStream
tempStream = Fs.createWriteStream(tempChannelPath, {
flags: 'a',
});
tempStream.on('open', w());
tempStream.on('error', function (err) {
w.abort();
ABORT = true;
cleanUp(function () {
cb(err);
});
});
}).nThen(function (w) {
var i = 0;
var retain = false;
var handler = function (msgObj, readMore, abort) {
if (ABORT) { return void abort(); }
// the first message might be metadata... ignore it if so
if (i++ === 0 && msgObj.buff.indexOf('{') === 0) {
return readMore();
}
var s_msg = msgObj.buff.toString('utf8');
if (retain) {
// if this flag is set then you've already found
// the message you were looking for.
// write it to your temp buffer and keep going
return void tempStream.write(s_msg + '\n', function () {
readMore();
});
}
var msg = Util.tryParse(s_msg);
var msgHash = Extras.getHash(msg[4]);
if (msgHash === hash) {
// everything from this point on should be retained
retain = true;
return void tempStream.write(msgObj.buff, function () {
readMore();
});
}
};
readMessagesBin(env, channelName, 0, handler, w(function (err) {
if (err) {
w.abort();
return void cleanUp(function () {
// intentionally call back with main error
// not the cleanup error
cb(err);
});
}
if (!retain) {
// you never found the message you were looking for
// this whole operation is invalid...
// clean up, abort, and call back with an error
w.abort();
cleanUp(function () {
// intentionally call back with main error
// not the cleanup error
cb('HASH_NOT_FOUND');
});
}
}));
}).nThen(function (w) {
// copy existing channel to the archive
Fse.copy(channelPath, archiveChannelPath, w(function (err) {
if (!err || err.code === 'ENOENT') { return; }
w.abort();
cleanUp(function () {
cb(err);
});
}));
// copy existing metadaata to the archive
Fse.copy(metadataPath, archiveMetadataPath, w(function (err) {
if (!err || err.code === 'ENOENT') { return; }
w.abort();
cleanUp(function () {
cb(err);
});
}));
}).nThen(function (w) {
// overwrite the existing metadata log with the current metadata state
Fs.writeFile(metadataPath, JSON.stringify(metadataReference.meta) + '\n', w(function (err) {
// this shouldn't happen, but if it does your channel might be messed up :(
if (err) {
w.abort();
cb(err);
}
}));
// overwrite the existing channel with the temp log
Fse.move(tempChannelPath, channelPath, {
overwrite: true,
}, w(function (err) {
// this shouldn't happen, but if it does your channel might be messed up :(
if (err) {
w.abort();
cb(err);
}
}));
}).nThen(function () {
// clean up and call back with no error
// triggering a historyKeeper index cache eviction...
cleanUp(function () {
cb();
});
});
};
*/
module.exports.create = function (
conf /*:ChainPadServer_Config_t*/,
cb /*:(store:ChainPadServer_Storage_t)=>void*/
) {
module.exports.create = function (conf, cb) {
var env = {
root: conf.filePath || './datastore',
archiveRoot: conf.archivePath || './data/archive',
retainData: conf.retainData,
channels: { },
channelExpirationMs: conf.channelExpirationMs || 30000,
verbose: conf.verbose,
@ -909,6 +1063,24 @@ module.exports.create = function (
};
var it;
/* our scheduler prioritizes and executes tasks with respect
to all other tasks invoked with an identical key
(typically the id of the concerned channel)
it assumes that all tasks can be categorized into three types
1. unordered tasks such as streaming reads which can take
a long time to complete.
2. ordered tasks such as appending to a file which does not
take very long, but where priority is important.
3. blocking tasks such as rewriting a file where it would be
dangerous to perform any other task concurrently.
*/
var schedule = env.schedule = Schedule();
nThen(function (w) {
// make sure the store's directory exists
Fse.mkdirp(env.root, PERMISSIVE, w(function (err) {
@ -928,43 +1100,80 @@ module.exports.create = function (
// write a new message to a log
message: function (channelName, content, cb) {
if (!isValidChannelId(channelName)) { return void cb(new Error('EINVAL')); }
message(env, channelName, content, cb);
schedule.ordered(channelName, function (next) {
message(env, channelName, content, Util.both(cb, next));
});
},
// iterate over all the messages in a log
getMessages: function (channelName, msgHandler, cb) {
if (!isValidChannelId(channelName)) { return void cb(new Error('EINVAL')); }
getMessages(env, channelName, msgHandler, cb);
schedule.unordered(channelName, function (next) {
getMessages(env, channelName, msgHandler, Util.both(cb, next));
});
},
// NEWER IMPLEMENTATIONS OF THE SAME THING
// write a new message to a log
messageBin: (channelName, content, cb) => {
if (!isValidChannelId(channelName)) { return void cb(new Error('EINVAL')); }
messageBin(env, channelName, content, cb);
schedule.ordered(channelName, function (next) {
messageBin(env, channelName, content, Util.both(cb, next));
});
},
// iterate over the messages in a log
readMessagesBin: (channelName, start, asyncMsgHandler, cb) => {
if (!isValidChannelId(channelName)) { return void cb(new Error('EINVAL')); }
readMessagesBin(env, channelName, start, asyncMsgHandler, cb);
// FIXME there is a race condition here
// historyKeeper reads the file to find the byte offset of the first interesting message
// then calls this function again to read from that point.
// If this task is in the queue already when the file is read again
// then that byte offset will have been invalidated
// and the resulting stream probably won't align with message boundaries.
// We can evict the cache in the callback but by that point it will be too late.
// Presumably we'll need to bury some of historyKeeper's logic into a filestore method
// in order to make index/read sequences atomic.
// Otherwise, we can add a new task type to the scheduler to take invalidation into account...
// either method introduces significant complexity.
schedule.unordered(channelName, function (next) {
readMessagesBin(env, channelName, start, asyncMsgHandler, Util.both(cb, next));
});
},
// METHODS for deleting data
// remove a channel and its associated metadata log if present
removeChannel: function (channelName, cb) {
if (!isValidChannelId(channelName)) { return void cb(new Error('EINVAL')); }
removeChannel(env, channelName, function (err) {
cb(err);
// FIXME there's another race condition here...
// when a remove and an append are scheduled in that order
// the remove will delete the channel's metadata (including its validateKey)
// then the append will recreate the channel and insert a message.
// clients that are connected to the channel via historyKeeper should be kicked out
// however, anyone that connects to that channel in the future will be able to read the
// signed message, but will not find its validate key...
// resulting in a junk/unusable document
schedule.ordered(channelName, function (next) {
removeChannel(env, channelName, Util.both(cb, next));
});
},
// remove a channel and its associated metadata log from the archive directory
removeArchivedChannel: function (channelName, cb) {
if (!isValidChannelId(channelName)) { return void cb(new Error('EINVAL')); }
removeArchivedChannel(env, channelName, cb);
schedule.ordered(channelName, function (next) {
removeArchivedChannel(env, channelName, Util.both(cb, next));
});
},
// clear all data for a channel but preserve its metadata
clearChannel: function (channelName, cb) {
if (!isValidChannelId(channelName)) { return void cb(new Error('EINVAL')); }
clearChannel(env, channelName, cb);
schedule.ordered(channelName, function (next) {
clearChannel(env, channelName, Util.both(cb, next));
});
},
trimChannel: function (channelName, hash, cb) {
if (!isValidChannelId(channelName)) { return void cb(new Error('EINVAL')); }
schedule.blocking(channelName, function (next) {
trimChannel(env, channelName, hash, Util.both(cb, next));
});
},
// check if a channel exists in the database
@ -972,47 +1181,85 @@ module.exports.create = function (
if (!isValidChannelId(channelName)) { return void cb(new Error('EINVAL')); }
// construct the path
var filepath = mkPath(env, channelName);
channelExists(filepath, cb);
// (ansuz) I'm uncertain whether this task should be unordered or ordered.
// there's a round trip to the client (and possibly the user) before they decide
// to act on the information of whether there is already content present in this channel.
// so it's practically impossible to avoid race conditions where someone else creates
// some content before you.
// if that's the case, it's basically impossible that you'd generate the same signing key,
// and thus historykeeper should reject the signed messages of whoever loses the race.
// thus 'unordered' seems appropriate.
schedule.unordered(channelName, function (next) {
channelExists(filepath, Util.both(cb, next));
});
},
// check if a channel exists in the archive
isChannelArchived: function (channelName, cb) {
if (!isValidChannelId(channelName)) { return void cb(new Error('EINVAL')); }
// construct the path
var filepath = mkArchivePath(env, channelName);
channelExists(filepath, cb);
// as with the method above, somebody might remove, restore, or overwrite an archive
// in the time that it takes to answer this query and to execute whatever follows.
// since it's impossible to win the race every time let's just make this 'unordered'
schedule.unordered(channelName, function (next) {
channelExists(filepath, Util.both(cb, next));
});
},
// move a channel from the database to the archive, along with its metadata
archiveChannel: function (channelName, cb) {
if (!isValidChannelId(channelName)) { return void cb(new Error('EINVAL')); }
archiveChannel(env, channelName, cb);
// again, the semantics around archiving and appending are really muddy.
// so I'm calling this 'unordered' again
schedule.unordered(channelName, function (next) {
archiveChannel(env, channelName, Util.both(cb, next));
});
},
// restore a channel from the archive to the database, along with its metadata
restoreArchivedChannel: function (channelName, cb) {
if (!isValidChannelId(channelName)) { return void cb(new Error('EINVAL')); }
unarchiveChannel(env, channelName, cb);
// archive restoration will fail if either a file or its metadata exists in the live db.
// so I'm calling this 'ordered' to give writes a chance to flush out.
// accidental conflicts are extremely unlikely since clients check the status
// of a previously known channel before joining.
schedule.ordered(channelName, function (next) {
unarchiveChannel(env, channelName, Util.both(cb, next));
});
},
// METADATA METHODS
// fetch the metadata for a channel
getChannelMetadata: function (channelName, cb) {
if (!isValidChannelId(channelName)) { return void cb(new Error('EINVAL')); }
getChannelMetadata(env, channelName, cb);
// The only thing that can invalid this method's results are channel archival, removal, or trimming.
// We want it to be fast, so let's make it unordered.
schedule.unordered(channelName, function (next) {
getChannelMetadata(env, channelName, Util.both(cb, next));
});
},
// iterate over lines of metadata changes from a dedicated log
readDedicatedMetadata: function (channelName, handler, cb) {
if (!isValidChannelId(channelName)) { return void cb(new Error('EINVAL')); }
getDedicatedMetadata(env, channelName, handler, cb);
// Everything that modifies metadata also updates clients, so this can be 'unordered'
schedule.unordered(channelName, function (next) {
getDedicatedMetadata(env, channelName, handler, Util.both(cb, next));
});
},
// iterate over multiple lines of metadata changes
readChannelMetadata: function (channelName, handler, cb) {
if (!isValidChannelId(channelName)) { return void cb(new Error('EINVAL')); }
readMetadata(env, channelName, handler, cb);
// same logic as 'readDedicatedMetadata
schedule.unordered(channelName, function (next) {
readMetadata(env, channelName, handler, Util.both(cb, next));
});
},
// write a new line to a metadata log
writeMetadata: function (channelName, data, cb) {
if (!isValidChannelId(channelName)) { return void cb(new Error('EINVAL')); }
writeMetadata(env, channelName, data, cb);
// metadata writes are fast and should be applied in order
schedule.ordered(channelName, function (next) {
writeMetadata(env, channelName, data, Util.both(cb, next));
});
},
// CHANNEL ITERATION
@ -1025,13 +1272,22 @@ module.exports.create = function (
getChannelSize: function (channelName, cb) {
if (!isValidChannelId(channelName)) { return void cb(new Error('EINVAL')); }
channelBytes(env, channelName, cb);
// this method should be really fast and it probably doesn't matter much
// if we get the size slightly before or after somebody writes a few hundred bytes to it.
schedule.ordered(channelName, function (next) {
channelBytes(env, channelName, Util.both(cb, next));
});
},
// OTHER DATABASE FUNCTIONALITY
// remove a particular channel from the cache
closeChannel: function (channelName, cb) {
if (!isValidChannelId(channelName)) { return void cb(new Error('EINVAL')); }
closeChannel(env, channelName, cb);
// It is most likely the case that the channel is inactive if we are trying to close it,
// thus it doesn't make much difference whether it's ordered or not.
// In any case, it will be re-opened if anyone tries to write to it.
schedule.ordered(channelName, function (next) {
closeChannel(env, channelName, Util.both(cb, next));
});
},
// iterate over open channels and close any that are not active
flushUnusedChannels: function (cb) {
@ -1039,7 +1295,10 @@ module.exports.create = function (
},
// write to a log file
log: function (channelName, content, cb) {
message(env, channelName, content, cb);
// you probably want the events in your log to be in the correct order.
schedule.ordered(channelName, function (next) {
message(env, channelName, content, Util.both(cb, next));
});
},
// shut down the database
shutdown: function () {

@ -202,22 +202,6 @@ var expire = function (env, task, cb) {
var Log = env.log;
var args = task.slice(2);
if (!env.retainData) {
Log.info('DELETION_SCHEDULED_EXPIRATION', {
task: task,
});
env.store.removeChannel(args[0], function (err) {
if (err) {
Log.error('DELETION_SCHEDULED_EXPIRATION_ERROR', {
task: task,
error: err,
});
}
cb();
});
return;
}
Log.info('ARCHIVAL_SCHEDULED_EXPIRATION', {
task: task,
});
@ -381,7 +365,6 @@ Tasks.create = function (config, cb) {
root: config.taskPath || './tasks',
log: config.log,
store: config.store,
retainData: Boolean(config.retainData),
};
// make sure the path exists...

@ -23,5 +23,29 @@
display: flex;
flex-flow: column;
}
.cp-support-list-actions {
margin: 10px 0px 10px 2px;
}
.cp-support-list-ticket:not(.cp-support-list-closed) {
.cp-support-list-message {
&:last-child:not(.cp-support-fromadmin) {
color: @colortheme_cp-red;
background-color: lighten(@colortheme_cp-red, 25%);
.cp-support-showdata {
background-color: lighten(@colortheme_cp-red, 30%);
}
}
}
}
.cp-support-fromadmin {
color: @colortheme_logo-2;
background-color: #FFF;
.cp-support-message-content {
color: @colortheme_logo-2;
}
}
}

@ -60,6 +60,23 @@ var factory = function (Util, Crypto, Nacl) {
return '/2/' + secret.type + '/view/' + Crypto.b64RemoveSlashes(data.viewKeyStr) + '/' + pass;
}
};
Hash.getHiddenHashFromKeys = function (type, secret, opts) {
opts = opts || {};
var canEdit = (secret.keys && secret.keys.editKeyStr) || secret.key;
var mode = (!opts.view && canEdit) ? 'edit/' : 'view/';
var pass = secret.password ? 'p/' : '';
if (secret.keys && secret.keys.fileKeyStr) { mode = ''; }
var hash = '/3/' + type + '/' + mode + secret.channel + '/' + pass;
var hashData = Hash.parseTypeHash(type, hash);
if (hashData && hashData.getHash) {
return hashData.getHash(opts || {});
}
return hash;
};
var getFileHashFromKeys = Hash.getFileHashFromKeys = function (secret) {
var version = secret.version;
var data = secret.keys;
@ -160,12 +177,28 @@ Version 1
};
var parseTypeHash = Hash.parseTypeHash = function (type, hash) {
if (!hash) { return; }
var options;
var options = [];
var parsed = {};
var hashArr = fixDuplicateSlashes(hash).split('/');
var addOptions = function () {
parsed.password = options.indexOf('p') !== -1;
parsed.present = options.indexOf('present') !== -1;
parsed.embed = options.indexOf('embed') !== -1;
parsed.ownerKey = getOwnerKey(options);
};
if (['media', 'file', 'user', 'invite'].indexOf(type) === -1) {
parsed.type = 'pad';
parsed.getHash = function () { return hash; };
parsed.getOptions = function () {
return {
embed: parsed.embed,
present: parsed.present,
ownerKey: parsed.ownerKey,
password: parsed.password
};
};
if (hash.slice(0,1) !== '/' && hash.length >= 56) { // Version 0
// Old hash
parsed.channel = hash.slice(0, 32);
@ -173,25 +206,27 @@ Version 1
parsed.version = 0;
return parsed;
}
if (hashArr[1] && hashArr[1] === '1') { // Version 1
parsed.version = 1;
parsed.mode = hashArr[2];
parsed.channel = hashArr[3];
parsed.key = Crypto.b64AddSlashes(hashArr[4]);
options = hashArr.slice(5);
parsed.present = options.indexOf('present') !== -1;
parsed.embed = options.indexOf('embed') !== -1;
parsed.ownerKey = getOwnerKey(options);
// Version >= 1: more hash options
parsed.getHash = function (opts) {
var hash = hashArr.slice(0, 5).join('/') + '/';
var owner = typeof(opts.ownerKey) !== "undefined" ? opts.ownerKey : parsed.ownerKey;
if (owner) { hash += owner + '/'; }
if (parsed.password || opts.password) { hash += 'p/'; }
if (opts.embed) { hash += 'embed/'; }
if (opts.present) { hash += 'present/'; }
return hash;
};
if (hashArr[1] && hashArr[1] === '1') { // Version 1
parsed.version = 1;
parsed.mode = hashArr[2];
parsed.channel = hashArr[3];
parsed.key = Crypto.b64AddSlashes(hashArr[4]);
options = hashArr.slice(5);
addOptions();
return parsed;
}
if (hashArr[1] && hashArr[1] === '2') { // Version 2
@ -201,20 +236,19 @@ Version 1
parsed.key = hashArr[4];
options = hashArr.slice(5);
parsed.password = options.indexOf('p') !== -1;
parsed.present = options.indexOf('present') !== -1;
parsed.embed = options.indexOf('embed') !== -1;
parsed.ownerKey = getOwnerKey(options);
addOptions();
return parsed;
}
if (hashArr[1] && hashArr[1] === '3') { // Version 3: hidden hash
parsed.version = 3;
parsed.app = hashArr[2];
parsed.mode = hashArr[3];
parsed.channel = hashArr[4];
options = hashArr.slice(5);
addOptions();
parsed.getHash = function (opts) {
var hash = hashArr.slice(0, 5).join('/') + '/';
var owner = typeof(opts.ownerKey) !== "undefined" ? opts.ownerKey : parsed.ownerKey;
if (owner) { hash += owner + '/'; }
if (parsed.password) { hash += 'p/'; }
if (opts.embed) { hash += 'embed/'; }
if (opts.present) { hash += 'present/'; }
return hash;
};
return parsed;
}
return parsed;
@ -222,34 +256,54 @@ Version 1
parsed.getHash = function () { return hashArr.join('/'); };
if (['media', 'file'].indexOf(type) !== -1) {
parsed.type = 'file';
parsed.getOptions = function () {
return {
embed: parsed.embed,
present: parsed.present,
ownerKey: parsed.ownerKey,
password: parsed.password
};
};
parsed.getHash = function (opts) {
var hash = hashArr.slice(0, 4).join('/') + '/';
var owner = typeof(opts.ownerKey) !== "undefined" ? opts.ownerKey : parsed.ownerKey;
if (owner) { hash += owner + '/'; }
if (parsed.password || opts.password) { hash += 'p/'; }
if (opts.embed) { hash += 'embed/'; }
if (opts.present) { hash += 'present/'; }
return hash;
};
if (hashArr[1] && hashArr[1] === '1') {
parsed.version = 1;
parsed.channel = hashArr[2].replace(/-/g, '/');
parsed.key = hashArr[3].replace(/-/g, '/');
options = hashArr.slice(4);
parsed.ownerKey = getOwnerKey(options);
addOptions();
return parsed;
}
if (hashArr[1] && hashArr[1] === '2') { // Version 2
parsed.version = 2;
parsed.app = hashArr[2];
parsed.key = hashArr[3];
options = hashArr.slice(4);
parsed.password = options.indexOf('p') !== -1;
parsed.present = options.indexOf('present') !== -1;
parsed.embed = options.indexOf('embed') !== -1;
parsed.ownerKey = getOwnerKey(options);
addOptions();
return parsed;
}
if (hashArr[1] && hashArr[1] === '3') { // Version 3: hidden hash
parsed.version = 3;
parsed.app = hashArr[2];
parsed.channel = hashArr[3];
options = hashArr.slice(4);
addOptions();
parsed.getHash = function (opts) {
var hash = hashArr.slice(0, 4).join('/') + '/';
var owner = typeof(opts.ownerKey) !== "undefined" ? opts.ownerKey : parsed.ownerKey;
if (owner) { hash += owner + '/'; }
if (parsed.password) { hash += 'p/'; }
if (opts.embed) { hash += 'embed/'; }
if (opts.present) { hash += 'present/'; }
return hash;
};
return parsed;
}
return parsed;
@ -303,6 +357,10 @@ Version 1
url += '#' + hash;
return url;
};
ret.getOptions = function () {
if (!ret.hashData || !ret.hashData.getOptions) { return {}; }
return ret.hashData.getOptions();
};
if (!/^https*:\/\//.test(href)) {
idx = href.indexOf('/#');
@ -325,6 +383,14 @@ Version 1
return ret;
};
Hash.hashToHref = function (hash, type) {
return '/' + type + '/#' + hash;
};
Hash.hrefToHash = function (href) {
var parsed = Hash.parsePadUrl(href);
return parsed.hash;
};
Hash.getRelativeHref = function (href) {
if (!href) { return; }
if (href.indexOf('#') === -1) { return; }
@ -345,7 +411,7 @@ Version 1
secret.version = 2;
secret.type = type;
};
if (!secretHash && !window.location.hash) { //!/#/.test(window.location.href)) {
if (!secretHash) {
generate();
return secret;
} else {
@ -355,12 +421,7 @@ Version 1
if (!type) { throw new Error("getSecrets with a hash requires a type parameter"); }
parsed = parseTypeHash(type, secretHash);
hash = secretHash;
} else {
var pHref = parsePadUrl(window.location.href);
parsed = pHref.hashData;
hash = pHref.hash;
}
//var hash = secretHash || window.location.hash.slice(1);
if (hash.length === 0) {
generate();
return secret;
@ -496,8 +557,8 @@ Version 1
if (typeof(parsed.hashData.version) === "undefined") { return; }
// pads and files should have a base64 (or hex) key
if (parsed.hashData.type === 'pad' || parsed.hashData.type === 'file') {
if (!parsed.hashData.key) { return; }
if (!/^[a-zA-Z0-9+-/=]+$/.test(parsed.hashData.key)) { return; }
if (!parsed.hashData.key && !parsed.hashData.channel) { return; }
if (parsed.hashData.key && !/^[a-zA-Z0-9+-/=]+$/.test(parsed.hashData.key)) { return; }
}
}
return true;

@ -70,6 +70,7 @@ define([
if (typeof(yes) === 'function') { yes(e); }
break;
}
$(el || window).off('keydown', handler);
};
$(el || window).keydown(handler);
@ -491,6 +492,11 @@ define([
$ok.focus();
Notifier.notify();
});
return {
element: frame,
delete: close
};
};
UI.prompt = function (msg, def, cb, opt, force) {
@ -582,7 +588,7 @@ define([
$ok.click();
}, function () {
$cancel.click();
}, ok);
}, frame);
document.body.appendChild(frame);
setTimeout(function () {
@ -1050,39 +1056,36 @@ define([
return radio;
};
var corner = {
queue: [],
state: false
};
UI.cornerPopup = function (text, actions, footer, opts) {
opts = opts || {};
var minimize = h('div.cp-corner-minimize.fa.fa-window-minimize');
var maximize = h('div.cp-corner-maximize.fa.fa-window-maximize');
var dontShowAgain = h('div.cp-corner-dontshow', [
h('span.fa.fa-times'),
Messages.dontShowAgain
]);
var popup = h('div.cp-corner-container', [
minimize,
maximize,
h('div.cp-corner-filler', { style: "width:110px;" }),
h('div.cp-corner-filler', { style: "width:80px;" }),
h('div.cp-corner-filler', { style: "width:60px;" }),
h('div.cp-corner-filler', { style: "width:40px;" }),
h('div.cp-corner-filler', { style: "width:20px;" }),
setHTML(h('div.cp-corner-text'), text),
h('div.cp-corner-actions', actions),
setHTML(h('div.cp-corner-footer'), footer)
setHTML(h('div.cp-corner-footer'), footer),
opts.dontShowAgain ? dontShowAgain : undefined
]);
var $popup = $(popup);
$(minimize).click(function () {
$popup.addClass('cp-minimized');
});
$(maximize).click(function () {
$popup.removeClass('cp-minimized');
});
if (opts.hidden) {
$popup.addClass('cp-minimized');
}
if (opts.big) {
$popup.addClass('cp-corner-big');
}
if (opts.alt) {
$popup.addClass('cp-corner-alt');
}
var hide = function () {
$popup.hide();
@ -1092,9 +1095,28 @@ define([
};
var deletePopup = function () {
$popup.remove();
if (!corner.queue.length) {
corner.state = false;
return;
}
setTimeout(function () {
$('body').append(corner.queue.pop());
}, 5000);
};
$(dontShowAgain).click(function () {
deletePopup();
if (typeof(opts.dontShowAgain) === "function") {
opts.dontShowAgain();
}
});
if (corner.state) {
corner.queue.push(popup);
} else {
corner.state = true;
$('body').append(popup);
}
return {
popup: popup,
@ -1104,5 +1126,36 @@ define([
};
};
UI.makeSpinner = function ($container) {
var $ok = $('<span>', {'class': 'fa fa-check', title: Messages.saved}).hide();
var $spinner = $('<span>', {'class': 'fa fa-spinner fa-pulse'}).hide();
var spin = function () {
$ok.hide();
$spinner.show();
};
var hide = function () {
$ok.hide();
$spinner.hide();
};
var done = function () {
$ok.show();
$spinner.hide();
};
if ($container && $container.append) {
$container.append($ok);
$container.append($spinner);
}
return {
ok: $ok[0],
spinner: $spinner[0],
spin: spin,
hide: hide,
done: done
};
};
return UI;
});

@ -53,10 +53,18 @@ define([
return list;
};
Msg.declineFriendRequest = function (store, data, cb) {
store.mailbox.sendTo('DECLINE_FRIEND_REQUEST', {}, {
channel: data.notifications,
curvePublic: data.curvePublic
}, function (obj) {
cb(obj);
});
};
Msg.acceptFriendRequest = function (store, data, cb) {
var friend = getFriend(store.proxy, data.curvePublic) || {};
var myData = createData(store.proxy, friend.channel || data.channel);
store.mailbox.sendTo('ACCEPT_FRIEND_REQUEST', myData, {
store.mailbox.sendTo('ACCEPT_FRIEND_REQUEST', { user: myData }, {
channel: data.notifications,
curvePublic: data.curvePublic
}, function (obj) {
@ -110,7 +118,7 @@ define([
var proxy = store.proxy;
var friend = proxy.friends[curvePublic];
if (!friend) { return void cb({error: 'ENOENT'}); }
if (!friend.notifications || !friend.channel) { return void cb({error: 'EINVAL'}); }
if (!friend.notifications) { return void cb({error: 'EINVAL'}); }
store.mailbox.sendTo('UNFRIEND', {
curvePublic: proxy.curvePublic

@ -56,6 +56,21 @@ define([
});
};
var dcAlert;
UIElements.disconnectAlert = function () {
if (dcAlert && $(dcAlert.element).length) { return; }
dcAlert = UI.alert(Messages.common_connectionLost, undefined, true);
};
UIElements.reconnectAlert = function () {
if (!dcAlert) { return; }
if (!dcAlert.delete) {
dcAlert = undefined;
return;
}
dcAlert.delete();
dcAlert = undefined;
};
var importContent = function (type, f, cfg) {
return function () {
var $files = $('<input>', {type:"file"});
@ -212,15 +227,7 @@ define([
common.mailbox.sendTo("RM_OWNER", {
channel: channel,
title: data.title,
pending: pending,
user: {
displayName: user.name,
avatar: user.avatar,
profile: user.profile,
notifications: user.notifications,
curvePublic: user.curvePublic,
edPublic: priv.edPublic
}
pending: pending
}, {
channel: friend.notifications,
curvePublic: friend.curvePublic
@ -363,15 +370,7 @@ define([
channel: channel,
href: data.href,
password: data.password,
title: data.title,
user: {
displayName: user.name,
avatar: user.avatar,
profile: user.profile,
notifications: user.notifications,
curvePublic: user.curvePublic,
edPublic: priv.edPublic
}
title: data.title
}, {
channel: friend.notifications,
curvePublic: friend.curvePublic
@ -548,21 +547,27 @@ define([
if (!data.noPassword) {
var hasPassword = data.password;
if (hasPassword) {
$('<label>', {'for': 'cp-app-prop-password'}).text(Messages.creation_passwordValue)
.appendTo($d);
var $pwLabel = $('<label>', {'for': 'cp-app-prop-password'}).text(Messages.creation_passwordValue)
.hide().appendTo($d);
var password = UI.passwordInput({
id: 'cp-app-prop-password',
readonly: 'readonly'
});
var $pwInput = $(password).find('.cp-password-input');
var $password = $(password).hide();
var $pwInput = $password.find('.cp-password-input');
$pwInput.val(data.password).click(function () {
$pwInput[0].select();
});
$d.append(password);
if (hasPassword) {
$pwLabel.show();
$password.css('display', 'flex');
}
if (!data.noEditPassword && owned) { // FIXME SHEET fix password change for sheets
// In the properties, we should have the edit href if we know it.
// We should know it because the pad is stored, but it's better to check...
if (!data.noEditPassword && owned && data.href) { // FIXME SHEET fix password change for sheets
var sframeChan = common.getSframeChannel();
var isOO = parsed.type === 'sheet';
@ -622,7 +627,7 @@ define([
sframeChan.query(q, {
teamId: typeof(owned) !== "boolean" ? owned : undefined,
href: data.href || data.roHref,
href: data.href,
password: newPass
}, function (err, data) {
$(passwordOk).text(Messages.properties_changePasswordButton);
@ -632,24 +637,41 @@ define([
return void UI.alert(Messages.properties_passwordError);
}
UI.findOKButton().click();
if (isFile) {
onProgress.stop();
$pwLabel.show();
$password.css('display', 'flex');
$pwInput.val(newPass);
// If the current document is a file or if we're changing the password from a drive,
// we don't have to reload the page at the end.
// Tell the user the password change was successful and abort
if (isFile || priv.app !== parsed.type) {
if (onProgress && onProgress.stop) { onProgress.stop(); }
$(passwordOk).text(Messages.properties_changePasswordButton);
var alertMsg = data.warning ? Messages.properties_passwordWarningFile
: Messages.properties_passwordSuccessFile;
return void UI.alert(alertMsg, undefined, {force: true});
}
// If we didn't have a password, we have to add the /p/
// If we had a password and we changed it to a new one, we just have to reload
// If we had a password and we removed it, we have to remove the /p/
// Pad password changed: update the href
// Use hidden hash if needed (we're an owner of this pad so we know it is stored)
var useUnsafe = Util.find(priv, ['settings', 'security', 'unsafeLinks']);
var href = (priv.readOnly && data.roHref) ? data.roHref : data.href;
if (useUnsafe === false) {
var newParsed = Hash.parsePadUrl(href);
var newSecret = Hash.getSecrets(newParsed.type, newParsed.hash, newPass);
var newHash = Hash.getHiddenHashFromKeys(parsed.type, newSecret, {});
href = Hash.hashToHref(newHash, parsed.type);
}
if (data.warning) {
return void UI.alert(Messages.properties_passwordWarning, function () {
common.gotoURL(hasPassword && newPass ? undefined : (data.href || data.roHref));
common.gotoURL(href);
}, {force: true});
}
return void UI.alert(Messages.properties_passwordSuccess, function () {
if (!isSharedFolder) {
common.gotoURL(hasPassword && newPass ? undefined : (data.href || data.roHref));
common.gotoURL(href);
}
}, {force: true});
});
@ -3737,7 +3759,7 @@ define([
]);
var settings = h('div.cp-creation-remember', [
UI.createCheckbox('cp-creation-remember', Messages.creation_saveSettings, false),
UI.createCheckbox('cp-creation-remember', Messages.dontShowAgain, false),
createHelper('/settings/#creation', Messages.creation_settings),
h('div.cp-creation-remember-help.cp-creation-slider', [
h('span.fa.fa-exclamation-circle.cp-creation-warning'),
@ -4103,27 +4125,32 @@ define([
};
var crowdfundingState = false;
UIElements.displayCrowdfunding = function (common) {
UIElements.displayCrowdfunding = function (common, force) {
if (crowdfundingState) { return; }
if (AppConfig.disableCrowdfundingMessages) { return; }
var priv = common.getMetadataMgr().getPrivateData();
if (priv.plan) { return; }
var todo = function () {
crowdfundingState = true;
setTimeout(function () {
common.getAttribute(['general', 'crowdfunding'], function (err, val) {
if (err || val === false) { return; }
common.getSframeChannel().query('Q_GET_PINNED_USAGE', null, function (err, obj) {
var quotaMb = obj.quota / (1024 * 1024);
if (quotaMb < 10) { return; }
// Display the popup
var text = Messages.crowdfunding_popup_text;
var yes = h('button.cp-corner-primary', Messages.crowdfunding_popup_yes);
var no = h('button.cp-corner-primary', Messages.crowdfunding_popup_no);
var never = h('button.cp-corner-cancel', Messages.crowdfunding_popup_never);
var actions = h('div', [yes, no, never]);
var yes = h('button.cp-corner-primary', [
h('span.fa.fa-external-link'),
'OpenCollective'
]);
var no = h('button.cp-corner-cancel', Messages.crowdfunding_popup_no);
var actions = h('div', [no, yes]);
var modal = UI.cornerPopup(text, actions, null, {big: true});
var dontShowAgain = function () {
common.setAttribute(['general', 'crowdfunding'], false);
Feedback.send('CROWDFUNDING_NEVER');
};
var modal = UI.cornerPopup(text, actions, null, {
big: true,
alt: true,
dontShowAgain: dontShowAgain
});
$(yes).click(function () {
modal.delete();
@ -4141,14 +4168,25 @@ define([
modal.delete();
Feedback.send('CROWDFUNDING_NO');
});
$(never).click(function () {
modal.delete();
common.setAttribute(['general', 'crowdfunding'], false);
Feedback.send('CROWDFUNDING_NEVER');
});
};
if (force) {
crowdfundingState = true;
return void todo();
}
if (AppConfig.disableCrowdfundingMessages) { return; }
if (priv.plan) { return; }
crowdfundingState = true;
common.getAttribute(['general', 'crowdfunding'], function (err, val) {
if (err || val === false) { return; }
common.getSframeChannel().query('Q_GET_PINNED_USAGE', null, function (err, obj) {
var quotaMb = obj.quota / (1024 * 1024);
if (quotaMb < 10) { return; }
todo();
});
});
}, 5000);
};
var storePopupState = false;
@ -4170,7 +4208,7 @@ define([
var hide = h('button.cp-corner-cancel', Messages.autostore_hide);
var store = h('button.cp-corner-primary', Messages.autostore_store);
var actions = h('div', [store, hide]);
var actions = h('div', [hide, store]);
var initialHide = data && data.autoStore && data.autoStore === -1;
var modal = UI.cornerPopup(text, actions, footer, {hidden: initialHide});
@ -4325,7 +4363,8 @@ define([
UIElements.displayFriendRequestModal = function (common, data) {
var msg = data.content.msg;
var text = Messages._getKey('contacts_request', [Util.fixHTML(msg.content.displayName)]);
var userData = msg.content.user;
var text = Messages._getKey('contacts_request', [Util.fixHTML(userData.displayName)]);
var todo = function (yes) {
common.getSframeChannel().query("Q_ANSWER_FRIEND_REQUEST", {
@ -4352,7 +4391,6 @@ define([
UIElements.displayAddOwnerModal = function (common, data) {
var priv = common.getMetadataMgr().getPrivateData();
var user = common.getMetadataMgr().getUserData();
var sframeChan = common.getSframeChannel();
var msg = data.content.msg;
@ -4387,15 +4425,7 @@ define([
href: msg.content.href,
password: msg.content.password,
title: msg.content.title,
answer: yes,
user: {
displayName: user.name,
avatar: user.avatar,
profile: user.profile,
notifications: user.notifications,
curvePublic: user.curvePublic,
edPublic: priv.edPublic
}
answer: yes
}, {
channel: msg.content.user.notifications,
curvePublic: msg.content.user.curvePublic
@ -4476,7 +4506,6 @@ define([
};
UIElements.displayAddTeamOwnerModal = function (common, data) {
var priv = common.getMetadataMgr().getPrivateData();
var user = common.getMetadataMgr().getUserData();
var sframeChan = common.getSframeChannel();
var msg = data.content.msg;
@ -4493,15 +4522,7 @@ define([
common.mailbox.sendTo("ADD_OWNER_ANSWER", {
teamChannel: msg.content.teamChannel,
title: msg.content.title,
answer: yes,
user: {
displayName: user.name,
avatar: user.avatar,
profile: user.profile,
notifications: user.notifications,
curvePublic: user.curvePublic,
edPublic: priv.edPublic
}
answer: yes
}, {
channel: msg.content.user.notifications,
curvePublic: msg.content.user.curvePublic
@ -4608,17 +4629,15 @@ define([
var f = priv.friends[curve];
$verified.append(h('span.fa.fa-certificate'));
var $avatar = $(h('span.cp-avatar')).appendTo($verified);
$verified.append(h('p', Messages._getKey('requestEdit_fromFriend', [f.displayName])));
$verified.append(h('p', Messages._getKey('isContact', [f.displayName])));
common.displayAvatar($avatar, f.avatar, f.displayName);
} else {
$verified.append(Messages._getKey('requestEdit_fromStranger', [name]));
$verified.append(Messages._getKey('isNotContact', [name]));
}
return verified;
};
UIElements.displayInviteTeamModal = function (common, data) {
var priv = common.getMetadataMgr().getPrivateData();
var user = common.getMetadataMgr().getUserData();
var msg = data.content.msg;
var name = Util.fixHTML(msg.content.user.displayName) || Messages.anonymous;
@ -4639,15 +4658,7 @@ define([
common.mailbox.sendTo("INVITE_TO_TEAM_ANSWER", {
answer: yes,
teamChannel: msg.content.team.channel,
teamName: teamName,
user: {
displayName: user.name,
avatar: user.avatar,
profile: user.profile,
notifications: user.notifications,
curvePublic: user.curvePublic,
edPublic: priv.edPublic
}
teamName: teamName
}, {
channel: msg.content.user.notifications,
curvePublic: msg.content.user.curvePublic

@ -34,6 +34,9 @@
};
Util.mkAsync = function (f) {
if (typeof(f) !== 'function') {
throw new Error('EXPECTED_FUNCTION');
}
return function () {
var args = Array.prototype.slice.call(arguments);
setTimeout(function () {

@ -49,6 +49,12 @@ define([
account: {},
};
// Store the href in memory
// This is a placeholder value overriden in common.ready from sframe-common-outer
var currentPad = common.currentPad = {
href: window.location.href
};
// COMMON
common.getLanguage = function () {
return Messages._languageUsed;
@ -374,7 +380,7 @@ define([
common.getMetadata = function (cb) {
var parsed = Hash.parsePadUrl(window.location.href);
var parsed = Hash.parsePadUrl(currentPad.href);
postMessage("GET_METADATA", parsed && parsed.type, function (obj) {
if (obj && obj.error) { return void cb(obj.error); }
cb(null, obj);
@ -394,7 +400,7 @@ define([
common.setPadAttribute = function (attr, value, cb, href) {
cb = cb || function () {};
href = Hash.getRelativeHref(href || window.location.href);
href = Hash.getRelativeHref(href || currentPad.href);
postMessage("SET_PAD_ATTRIBUTE", {
href: href,
attr: attr,
@ -405,7 +411,7 @@ define([
});
};
common.getPadAttribute = function (attr, cb, href) {
href = Hash.getRelativeHref(href || window.location.href);
href = Hash.getRelativeHref(href || currentPad.href);
if (!href) {
return void cb('E404');
}
@ -505,7 +511,7 @@ define([
};
common.saveAsTemplate = function (Cryptput, data, cb) {
var p = Hash.parsePadUrl(window.location.href);
var p = Hash.parsePadUrl(currentPad.href);
if (!p.type) { return; }
// PPP: password for the new template?
var hash = Hash.createRandomHash(p.type);
@ -543,7 +549,7 @@ define([
var href = data.href;
var parsed = Hash.parsePadUrl(href);
var parsed2 = Hash.parsePadUrl(window.location.href);
var parsed2 = Hash.parsePadUrl(currentPad.href);
if(!parsed) { throw new Error("Cannot get template hash"); }
postMessage("INCREMENT_TEMPLATE_USE", href);
@ -601,7 +607,7 @@ define([
var fileHost = Config.fileHost || window.location.origin;
var data = common.fromFileData;
var parsed = Hash.parsePadUrl(data.href);
var parsed2 = Hash.parsePadUrl(window.location.href);
var parsed2 = Hash.parsePadUrl(currentPad.href);
var hash = parsed.hash;
var name = data.title;
var secret = Hash.getSecrets('file', hash, data.password);
@ -660,7 +666,7 @@ define([
// Forget button
common.moveToTrash = function (cb, href) {
href = href || window.location.href;
href = href || currentPad.href;
postMessage("MOVE_TO_TRASH", { href: href }, cb);
};
@ -668,7 +674,7 @@ define([
common.setPadTitle = function (data, cb) {
if (!data || typeof (data) !== "object") { return cb ('Data is not an object'); }
var href = data.href || window.location.href;
var href = data.href || currentPad.href;
var parsed = Hash.parsePadUrl(href);
if (!parsed.hash) { return cb ('Invalid hash'); }
data.href = parsed.getUrl({present: parsed.present});
@ -698,7 +704,7 @@ define([
if (obj.error !== "EAUTH") { console.log("unable to set pad title"); }
return void cb(obj.error);
}
cb();
cb(null, obj);
});
};
@ -755,6 +761,13 @@ define([
cb(void 0, data);
});
};
// Get data about a given channel: use with hidden hashes
common.getPadDataFromChannel = function (obj, cb) {
if (!obj || !obj.channel) { return void cb('EINVAL'); }
postMessage("GET_PAD_DATA_FROM_CHANNEL", obj, function (data) {
cb(void 0, data);
});
};
// Admin
@ -832,6 +845,7 @@ define([
pad.onConnectEvent = Util.mkEvent();
pad.onErrorEvent = Util.mkEvent();
pad.onMetadataEvent = Util.mkEvent();
pad.onChannelDeleted = Util.mkEvent();
pad.requestAccess = function (data, cb) {
postMessage("REQUEST_PAD_ACCESS", data, cb);
@ -1023,11 +1037,12 @@ define([
}, waitFor());
}
}).nThen(function () {
common.drive.onChange.fire({path: ['drive', Constants.storageKey]});
cb({
warning: warning,
hash: newHash,
href: newHref,
roHref: newRoHref
roHref: newRoHref,
});
});
};
@ -1156,6 +1171,7 @@ define([
channel: newSecret.channel
}, waitFor());
}).nThen(function () {
common.drive.onChange.fire({path: ['drive', Constants.storageKey]});
cb({
warning: warning,
hash: newHash,
@ -1390,6 +1406,7 @@ define([
}, waitFor());
}));
}).nThen(function () {
common.drive.onChange.fire({path: ['drive', Constants.storageKey]});
cb({
warning: warning,
hash: newHash,
@ -1608,7 +1625,7 @@ define([
hashes = Hash.getHashes(secret);
return void cb(null, hashes);
}
var parsed = Hash.parsePadUrl(window.location.href);
var parsed = Hash.parsePadUrl(currentPad.href);
if (!parsed.type || !parsed.hashData) { return void cb('E_INVALID_HREF'); }
hashes = Hash.getHashes(secret);
@ -1679,7 +1696,7 @@ define([
LocalStore.logout();
// redirect them to log in, and come back when they're done.
sessionStorage.redirectTo = window.location.href;
sessionStorage.redirectTo = currentPad.href;
window.location.href = '/login/';
};
@ -1740,6 +1757,7 @@ define([
PAD_CONNECT: common.padRpc.onConnectEvent.fire,
PAD_ERROR: common.padRpc.onErrorEvent.fire,
PAD_METADATA: common.padRpc.onMetadataEvent.fire,
CHANNEL_DELETED: common.padRpc.onChannelDeleted.fire,
// Drive
DRIVE_LOG: common.drive.onLog.fire,
DRIVE_CHANGE: common.drive.onChange.fire,
@ -1780,6 +1798,11 @@ define([
return function (f, rdyCfg) {
rdyCfg = rdyCfg || {};
if (rdyCfg.currentPad) {
currentPad = common.currentPad = rdyCfg.currentPad;
}
if (initialized) {
return void setTimeout(function () { f(void 0, env); });
}
@ -1878,7 +1901,7 @@ define([
anonHash: LocalStore.getFSHash(),
localToken: tryParsing(localStorage.getItem(Constants.tokenKey)), // TODO move this to LocalStore ?
language: common.getLanguage(),
driveEvents: rdyCfg.driveEvents // Boolean
driveEvents: true //rdyCfg.driveEvents // Boolean
};
// if a pad is created from a file
if (sessionStorage[Constants.newPadFileData]) {
@ -2101,7 +2124,10 @@ define([
var parsedNew = Hash.parsePadUrl(newHref);
if (parsedOld.hashData && parsedNew.hashData &&
parsedOld.getUrl() !== parsedNew.getUrl()) {
if (!parsedOld.hashData.key) { oldHref = newHref; return; }
if (parsedOld.hashData.version !== 3 && !parsedOld.hashData.key) {
oldHref = newHref;
return;
}
// If different, reload
document.location.reload();
return;

@ -55,7 +55,6 @@ define([
var a = h('a.cp-md-toc-link', {
href: '#',
'data-href': obj.id,
title: obj.title
});
a.innerHTML = obj.title;
content.push(h('p.cp-md-toc-'+level, ['• ', a]));

@ -587,7 +587,7 @@ define([
var displayedCategories = [ROOT, TRASH, SEARCH, RECENT];
// PCS enabled: display owned pads
if (AppConfig.displayCreationScreen) { displayedCategories.push(OWNED); }
//if (AppConfig.displayCreationScreen) { displayedCategories.push(OWNED); }
// Templates enabled: display template category
if (AppConfig.enableTemplates) { displayedCategories.push(TEMPLATE); }
// Tags used: display Tags category
@ -1029,15 +1029,26 @@ define([
return ret;
};
var openFile = function (el, href) {
if (!href) {
var openFile = function (el, isRo) {
var data = manager.getFileData(el);
if (!data || (!data.href && !data.roHref)) {
return void logError("Missing data for the file", el, data);
}
href = data.href || data.roHref;
var href = isRo ? data.roHref : (data.href || data.roHref);
var priv = metadataMgr.getPrivateData();
var useUnsafe = Util.find(priv, ['settings', 'security', 'unsafeLinks']);
if (useUnsafe !== false) { // true of undefined: use unsafe links
return void window.open(APP.origin + href);
}
window.open(APP.origin + href);
// Get hidden hash
var parsed = Hash.parsePadUrl(href);
var secret = Hash.getSecrets(parsed.type, parsed.hash, data.password);
var opts = {};
if (isRo) { opts.view = true; }
var hash = Hash.getHiddenHashFromKeys(parsed.type, secret, opts);
var hiddenHref = Hash.hashToHref(hash, parsed.type);
window.open(APP.origin + hiddenHref);
};
var refresh = APP.refresh = function () {
@ -1164,14 +1175,10 @@ define([
} else if ($element.is('.cp-app-drive-element-noreadonly')) {
hide.push('openro'); // Remove open 'view' mode
}
// if it's not a plain text file
// XXX: there is a bug with this code in anon shared folder, so we disable it
if (APP.loggedIn || !APP.newSharedFolder) {
var metadata = manager.getFileData(manager.find(path));
if (!metadata || !Util.isPlainTextFile(metadata.fileType, metadata.title)) {
hide.push('openincode');
}
}
} else if ($element.is('.cp-app-drive-element-sharedf')) {
if (containsFolder) {
// More than 1 folder selected: cannot create a new subfolder
@ -1193,8 +1200,7 @@ define([
hide.push('collapseall');
}
containsFolder = true;
hide.push('share'); // XXX CONVERT
hide.push('savelocal'); // XXX CONVERT
hide.push('savelocal');
hide.push('openro');
hide.push('openincode');
hide.push('properties');
@ -1930,6 +1936,44 @@ define([
};
var getIcon = UI.getIcon;
var createShareButton = function (id, $container) {
var $shareBlock = $('<button>', {
'class': 'cp-toolbar-share-button',
title: Messages.shareButton
});
$sharedIcon.clone().appendTo($shareBlock);
$('<span>').text(Messages.shareButton).appendTo($shareBlock);
var data = manager.getSharedFolderData(id);
var parsed = (data.href && data.href.indexOf('#') !== -1) ? Hash.parsePadUrl(data.href) : {};
var roParsed = Hash.parsePadUrl(data.roHref) || {};
if (!parsed.hash && !roParsed.hash) { return void console.error("Invalid href: "+(data.href || data.roHref)); }
var friends = common.getFriends();
var ro = folders[id] && folders[id].version >= 2;
var modal = UIElements.createShareModal({
teamId: APP.team,
origin: APP.origin,
pathname: "/drive/",
friends: friends,
title: data.title,
password: data.password,
sharedFolder: true,
common: common,
hashes: {
editHash: parsed.hash,
viewHash: ro && roParsed.hash,
}
});
// If we're a viewer and this is an old shared folder (no read-only mode), we
// can't share the read-only URL and we don't have access to the edit one.
// We should hide the share button.
if (!modal) { return; }
$shareBlock.click(function () {
UI.openCustomModal(modal);
});
$container.append($shareBlock);
return $shareBlock;
};
// Create the "li" element corresponding to the file/folder located in "path"
var createElement = function (path, elPath, root, isFolder) {
// Forbid drag&drop inside the trash
@ -2010,6 +2054,15 @@ define([
});
delete APP.newFolder;
}
if (isSharedFolder && APP.convertedFolder === element) {
setTimeout(function () {
var $fakeButton = createShareButton(element, $('<div>'));
if (!$fakeButton) { return; }
$fakeButton.click();
}, 100);
}
return $element;
};
@ -2547,43 +2600,6 @@ define([
$container.append($block);
};
var createShareButton = function (id, $container) {
var $shareBlock = $('<button>', {
'class': 'cp-toolbar-share-button',
title: Messages.shareButton
});
$sharedIcon.clone().appendTo($shareBlock);
$('<span>').text(Messages.shareButton).appendTo($shareBlock);
var data = manager.getSharedFolderData(id);
var parsed = (data.href && data.href.indexOf('#') !== -1) ? Hash.parsePadUrl(data.href) : {};
var roParsed = Hash.parsePadUrl(data.roHref) || {};
if (!parsed.hash && !roParsed.hash) { return void console.error("Invalid href: "+(data.href || data.roHref)); }
var friends = common.getFriends();
var ro = folders[id] && folders[id].version >= 2;
var modal = UIElements.createShareModal({
teamId: APP.team,
origin: APP.origin,
pathname: "/drive/",
friends: friends,
title: data.title,
password: data.password,
sharedFolder: true,
common: common,
hashes: {
editHash: parsed.hash,
viewHash: ro && roParsed.hash,
}
});
// If we're a viewer and this is an old shared folder (no read-only mode), we
// can't share the read-only URL and we don't have access to the edit one.
// We should hide the share button.
if (!modal) { return; }
$shareBlock.click(function () {
UI.openCustomModal(modal);
});
$container.append($shareBlock);
};
var SORT_FOLDER_DESC = 'sortFoldersDesc';
var SORT_FILE_BY = 'sortFilesBy';
var SORT_FILE_DESC = 'sortFilesDesc';
@ -3034,7 +3050,7 @@ define([
$icon.append(getFileIcon(r.id));
$type.text(Messages.type[parsed.type] || parsed.type);
$title.click(function () {
openFile(null, r.data.href);
openFile(r.id);
});
$atimeName.text(Messages.fm_lastAccess);
$atime.text(new Date(r.data.atime).toLocaleString());
@ -3228,21 +3244,23 @@ define([
var path = currentPath.slice(1);
var root = Util.find(data, path);
var realPath = [ROOT, SHARED_FOLDER].concat(path);
if (manager.hasSubfolder(root)) { $list.append($folderHeader); }
// display sub directories
var keys = Object.keys(root);
var sortedFolders = sortElements(true, currentPath, keys, null, !getSortFolderDesc());
var sortedFiles = sortElements(false, currentPath, keys, APP.store[SORT_FILE_BY], !getSortFileDesc());
var sortedFolders = sortElements(true, realPath, keys, null, !getSortFolderDesc());
var sortedFiles = sortElements(false, realPath, keys, APP.store[SORT_FILE_BY], !getSortFileDesc());
sortedFolders.forEach(function (key) {
if (manager.isFile(root[key])) { return; }
var $element = createElement(currentPath, key, root, true);
var $element = createElement(realPath, key, root, true);
$element.appendTo($list);
});
if (manager.hasFile(root)) { $list.append($fileHeader); }
// display files
sortedFiles.forEach(function (key) {
if (manager.isFolder(root[key])) { return; }
var $element = createElement(currentPath, key, root, false);
var $element = createElement(realPath, key, root, false);
if (!$element) { return; }
$element.appendTo($list);
});
@ -3327,7 +3345,9 @@ define([
// in history mode we want to focus the version number input
if (!history.isHistoryMode && !APP.mobile()) {
var st = $tree.scrollTop() || 0;
if (!$('.alertify').length) {
$tree.find('#cp-app-drive-tree-search-input').focus();
}
$tree.scrollTop(st);
}
$tree.find('#cp-app-drive-tree-search-input')[0].selectionStart = getSearchCursor();
@ -3371,12 +3391,7 @@ define([
createNewButton(isInRoot, $toolbar.find('.cp-app-drive-toolbar-leftside'));
}
if (sfId) {
var sfData = manager.getSharedFolderData(sfId);
var parsed = Hash.parsePadUrl(sfData.href);
sframeChan.event('EV_DRIVE_SET_HASH', parsed.hash || '');
createShareButton(sfId, $toolbar.find('.cp-app-drive-toolbar-leftside'));
} else {
sframeChan.event('EV_DRIVE_SET_HASH', '');
}
@ -3482,6 +3497,9 @@ define([
} else {
$content.scrollTop(s);
}
delete APP.convertedFolder;
appStatus.ready(true);
};
var displayDirectory = APP.displayDirectory = function (path, force) {
@ -3944,15 +3962,12 @@ define([
// ANON_SHARED_FOLDER
el = manager.find(paths[0].path.slice(1), APP.newSharedFolder);
}
var href;
if (manager.isPathIn(p.path, [FILES_DATA])) {
href = el.roHref;
el = p.path[1];
} else {
if (!el || manager.isFolder(el)) { return; }
var data = manager.getFileData(el);
href = data.roHref;
}
openFile(null, href);
openFile(el, true);
});
}
else if ($this.hasClass('cp-app-drive-context-openincode')) {
@ -3972,6 +3987,14 @@ define([
common.sessionStorage.put(Constants.newPadTeamKey, APP.team, waitFor());
}).nThen(function () {
common.openURL('/code/');
// We need to restore sessionStorage for the next time we want to create a pad from this tab
// NOTE: the 100ms timeout is to fix a race condition in firefox where sessionStorage
// would be deleted before the new tab was created
setTimeout(function () {
common.sessionStorage.put(Constants.newPadFileData, '', function () {});
common.sessionStorage.put(Constants.newPadPathKey, '', function () {});
common.sessionStorage.put(Constants.newPadTeamKey, '', function () {});
}, 100);
});
}
@ -4051,8 +4074,7 @@ define([
if (manager.isFolder(el) && !manager.isSharedFolder(el)) { // Folder
// if folder is inside SF
return UI.warn('ERROR: Temporarily disabled'); // XXX CONVERT
/*if (manager.isInSharedFolder(paths[0].path)) {
if (manager.isInSharedFolder(paths[0].path)) {
return void UI.alert(Messages.convertFolderToSF_SFParent);
}
// if folder already contains SF
@ -4080,10 +4102,14 @@ define([
if (!res) { return; }
var password = $(convertContent).find('#cp-upload-password').val() || undefined;
var owned = Util.isChecked($(convertContent).find('#cp-upload-owned'));
manager.convertFolderToSharedFolder(paths[0].path, owned, password, refresh);
manager.convertFolderToSharedFolder(paths[0].path, owned, password, function (err, obj) {
if (err || obj && obj.error) { return void console.error(err || obj.error); }
if (obj && obj.fId) { APP.convertedFolder = obj.fId; }
refresh();
});
}*/
} else { // File
});
}
} else { // File or shared folder
var sf = manager.isSharedFolder(el);
data = sf ? manager.getSharedFolderData(el) : manager.getFileData(el);
parsed = (data.href && data.href.indexOf('#') !== -1) ? Hash.parsePadUrl(data.href) : {};

@ -29,7 +29,9 @@ define([
handlers['FRIEND_REQUEST'] = function (common, data) {
var content = data.content;
var msg = content.msg;
var name = Util.fixHTML(msg.content.displayName) || Messages.anonymous;
var userData = msg.content.user || msg.content;
var name = Util.fixHTML(userData.displayName) || Messages.anonymous;
msg.content = { user: userData };
// Display the notification
content.getFormatText = function () {
@ -37,7 +39,7 @@ define([
};
// Check authenticity
if (msg.author !== msg.content.curvePublic) { return; }
if (msg.author !== userData.curvePublic) { return; }
// if not archived, add handlers
if (!content.archived) {
@ -51,7 +53,11 @@ define([
handlers['FRIEND_REQUEST_ACCEPTED'] = function (common, data) {
var content = data.content;
var msg = content.msg;
var name = Util.fixHTML(msg.content.name) || Messages.anonymous;
var userData = typeof(msg.content.user) === "object" ? msg.content.user : {
displayName: msg.content.name,
curvePublic: msg.content.user
};
var name = Util.fixHTML(userData.displayName) || Messages.anonymous;
content.getFormatText = function () {
return Messages._getKey('friendRequest_accepted', [name]);
};
@ -63,7 +69,11 @@ define([
handlers['FRIEND_REQUEST_DECLINED'] = function (common, data) {
var content = data.content;
var msg = content.msg;
var name = Util.fixHTML(msg.content.name) || Messages.anonymous;
var userData = typeof(msg.content.user) === "object" ? msg.content.user : {
displayName: msg.content.name,
curvePublic: msg.content.user
};
var name = Util.fixHTML(userData.displayName) || Messages.anonymous;
content.getFormatText = function () {
return Messages._getKey('friendRequest_declined', [name]);
};

@ -62,6 +62,15 @@ body.cp-app-sheet, body.cp-app-oodoc, body.cp-app-ooslide {
background-color: lightgrey;
display: flex;
flex-flow: column;
position: relative;
}
#cp-app-oo-offline {
position: absolute;
top: 0;
bottom: 0;
right: 0;
left: 0;
background-color: rgba(255,255,255,0.5);
}
#ooframe {
flex: 1;

@ -52,10 +52,10 @@ define([
$: $
};
var CHECKPOINT_INTERVAL = 50;
var DISPLAY_RESTORE_BUTTON = false;
var NEW_VERSION = 2;
var PENDING_TIMEOUT = 30000;
var debug = function (x) {
if (!window.CP_DEV_MODE) { return; }
@ -76,6 +76,7 @@ define([
var privateData = metadataMgr.getPrivateData();
var readOnly = false;
var offline = false;
var pendingChanges = {};
var config = {};
var content = {
hashes: {},
@ -89,6 +90,7 @@ define([
var myUniqueOOId;
var myOOId;
var sessionId = Hash.createChannelId();
var cpNfInner;
// This structure is used for caching media data and blob urls for each media cryptpad url
var mediasData = {};
@ -102,6 +104,18 @@ define([
return metadataMgr.getNetfluxId() + '-' + privateData.clientId;
};
var setEditable = function (state) {
$('#cp-app-oo-editor').find('#cp-app-oo-offline').remove();
try {
window.frames[0].editor.asc_setViewMode(!state);
//window.frames[0].editor.setViewModeDisconnect(true);
} catch (e) {}
if (!state) {
$('#cp-app-oo-editor').append(h('div#cp-app-oo-offline'));
}
debug(state);
};
var deleteOffline = function () {
var ids = content.ids;
var users = Object.keys(metadataMgr.getMetadata().users);
@ -573,15 +587,23 @@ define([
var myId = getId();
content.locks[myId] = msg;
oldLocks = JSON.parse(JSON.stringify(content.locks));
// Remove old locks
deleteOfflineLocks();
// Prepare callback
if (cpNfInner) {
var onPatchSent = function () {
cpNfInner.offPatchSent(onPatchSent);
// Answer to our onlyoffice
send({
type: "getLock",
locks: getLock()
});
// Remove old locks
deleteOfflineLocks();
};
cpNfInner.onPatchSent(onPatchSent);
}
// Commit
APP.onLocal();
APP.realtime.sync();
};
var parseChanges = function (changes) {
@ -600,13 +622,30 @@ define([
};
});
};
var handleChanges = function (obj, send) {
// Allow the changes
send({
type: "unSaveLock",
index: ooChannel.cpIndex,
time: +new Date()
// Add a new entry to the pendingChanges object.
// If we can't send the patch within 30s, force a page reload
var uid = Util.uid();
pendingChanges[uid] = setTimeout(function () {
// If we're offline, force a reload on reconnect
if (offline) {
pendingChanges.force = true;
return;
}
// We're online: force a reload now
setEditable(false);
UI.alert(Messages.realtime_unrecoverableError, function () {
common.gotoURL();
});
}, PENDING_TIMEOUT);
if (offline) {
pendingChanges.force = true;
return;
}
// Send the changes
rtChannel.sendMsg({
type: "saveChanges",
@ -615,7 +654,22 @@ define([
locks: [content.locks[getId()]],
excelAdditionalInfo: null
}, null, function (err, hash) {
if (err) { return void console.error(err); }
if (err) {
return void console.error(err);
}
if (pendingChanges[uid]) {
clearTimeout(pendingChanges[uid]);
delete pendingChanges[uid];
}
// Call unSaveLock to tell onlyoffice that the patch was sent.
// It will allow you to make changes to another cell.
// If there is an error and unSaveLock is not called, onlyoffice
// will try to send the patch again
send({
type: "unSaveLock",
index: ooChannel.cpIndex,
time: +new Date()
});
// Increment index and update latest hash
ooChannel.cpIndex++;
ooChannel.lastHash = hash;
@ -659,10 +713,12 @@ define([
break;
case "isSaveLock":
// TODO ping the server to check if we're online first?
if (!offline) {
send({
type: "saveLock",
saveLock: false
});
}
break;
case "getLock":
handleLock(obj, send);
@ -748,7 +804,9 @@ define([
},
"events": {
"onAppReady": function(/*evt*/) {
var $tb = $('iframe[name="frameEditor"]').contents().find('head');
var $iframe = $('iframe[name="frameEditor"]').contents();
$iframe.prop('tabindex', '-1');
var $tb = $iframe.find('head');
var css = // Old OO
'#id-toolbar-full .toolbar-group:nth-child(2), #id-toolbar-full .separator:nth-child(3) { display: none; }' +
'#fm-btn-save { display: none !important; }' +
@ -1283,7 +1341,6 @@ define([
var initializing = true;
var $bar = $('#cp-toolbar');
var cpNfInner;
config = {
patchTransformer: ChainPad.SmartJSONTransformer,
@ -1300,15 +1357,6 @@ define([
}
};
var setEditable = function (state) {
if (!state) {
try {
window.frames[0].editor.setViewModeDisconnect(true);
} catch (e) {}
}
debug(state);
};
var stringifyInner = function () {
var obj = {
content: content,
@ -1398,11 +1446,20 @@ define([
var $exportXLSX = common.createButton('export', true, {}, exportXLSXFile);
$exportXLSX.appendTo($rightside);
var type = common.getMetadataMgr().getPrivateData().ooType;
var accept = [".bin", ".ods", ".xlsx"];
if (type === "ooslide") {
accept = ['.bin', '.odp', '.pptx'];
} else if (type === "oodoc") {
accept = ['.bin', '.odt', '.docx'];
}
if (typeof(Atomics) === "undefined") {
accept = ['.bin'];
}
var $importXLSX = common.createButton('import', true, { accept: accept, binary : ["ods", "xlsx"] }, importXLSXFile);
var $importXLSX = common.createButton('import', true, {
accept: accept,
binary : ["ods", "xlsx", "odt", "docx", "odp", "pptx"]
}, importXLSXFile);
$importXLSX.appendTo($rightside);
if (common.isLoggedIn()) {
@ -1549,25 +1606,23 @@ define([
pinImages();
};
config.onAbort = function () {
// inform of network disconnect
setEditable(false);
toolbar.failed();
UI.alert(Messages.common_connectionLost, undefined, true);
};
config.onConnectionChange = function (info) {
setEditable(info.state);
if (info.state) {
UI.findOKButton().click();
UI.confirm(Messages.oo_reconnect, function (yes) {
// If we tried to send changes while we were offline, force a page reload
UIElements.reconnectAlert();
if (Object.keys(pendingChanges).length) {
return void UI.confirm(Messages.oo_reconnect, function (yes) {
if (!yes) { return; }
common.gotoURL();
});
}
setEditable(true);
offline = false;
} else {
setEditable(false);
offline = true;
UI.findOKButton().click();
UI.alert(Messages.common_connectionLost, undefined, true);
UIElements.disconnectAlert();
}
};

@ -9,6 +9,7 @@ define([
var requireConfig = RequireConfig();
// Loaded in load #2
var hash, href;
nThen(function (waitFor) {
DomReady.onReady(waitFor());
}).nThen(function (waitFor) {
@ -19,6 +20,13 @@ define([
};
window.rc = requireConfig;
window.apiconf = ApiConfig;
// Hidden hash
hash = window.location.hash;
href = window.location.href;
if (window.history && window.history.replaceState && hash) {
window.history.replaceState({}, window.document.title, '#');
}
document.getElementById('sbox-iframe').setAttribute('src',
ApiConfig.httpSafeOrigin + window.location.pathname + 'inner.html?' +
requireConfig.urlArgs + '#' + encodeURIComponent(JSON.stringify(req)));
@ -144,6 +152,8 @@ define([
});
};
SFCommonO.start({
hash: hash,
href: href,
type: 'oo',
useCreationScreen: true,
addData: addData,

@ -32,6 +32,16 @@ define([
NetConfig, AppConfig,
Crypto, ChainPad, CpNetflux, Listmap, nThen, Saferphore) {
// Default settings for new users
var NEW_USER_SETTINGS = {
drive: {
hideDuplicate: true
},
general: {
allowUserFeedback: true
}
};
var create = function () {
var Store = window.Cryptpad_Store = {};
var postMessage = function () {};
@ -1016,8 +1026,12 @@ define([
if (title.trim() === "") { title = UserObject.getDefaultName(p); }
if (AppConfig.disableAnonymousStore && !store.loggedIn) { return void cb(); }
if (p.type === "debug") { return void cb(); }
if (AppConfig.disableAnonymousStore && !store.loggedIn) {
return void cb({ notStored: true });
}
if (p.type === "debug") {
return void cb({ notStored: true });
}
var channelData = Store.channels && Store.channels[channel];
@ -1108,7 +1122,7 @@ define([
postMessage(clientId, "AUTOSTORE_DISPLAY_POPUP", {
autoStore: autoStore
});
return void cb();
return void cb({ notStored: true });
} else {
var roHref;
if (h.mode === "view") {
@ -1187,7 +1201,9 @@ define([
});
cb(list);
};
// Get the first pad we can find in any of our managers and return its file data
// Get the first pad we can find in any of our drives and return its file data
// NOTE: This is currently only used for template: this won't search inside shared folders
Store.getPadData = function (clientId, id, cb) {
var res = {};
getAllStores().some(function (s) {
@ -1199,6 +1215,49 @@ define([
cb(res);
};
Store.getPadDataFromChannel = function (clientId, obj, cb) {
var channel = obj.channel;
var edit = obj.edit;
var isFile = obj.file;
var res;
var viewRes;
getAllStores().some(function (s) {
var chans = s.manager.findChannel(channel);
if (!Array.isArray(chans)) { return; }
return chans.some(function (pad) {
if (!pad || !pad.data) { return; }
var data = pad.data;
// We've found a match: return the value and stop the loops
if ((edit && data.href) || (!edit && data.roHref) || isFile) {
res = data;
return true;
}
// We've found a weaker match: store it for now
if (edit && !viewRes && data.roHref) {
viewRes = data;
}
});
});
// Call back with the best value we can get
cb(res || viewRes || {});
};
// Hidden hash: if a pad is deleted, we may have to switch back to full hash
// in some tabs
Store.checkDeletedPad = function (channel) {
if (!channel) { return; }
// Check if the pad is still stored in one of our drives
Store.getPadDataFromChannel(null, {
channel: channel,
isFile: true // we don't care if it's view or edit
}, function (res) {
// If it is stored, abort
if (Object.keys(res).length) { return; }
// Otherwise, tell all the tabs that this channel was deleted and give them the hrefs
broadcast([], "CHANNEL_DELETED", channel);
});
};
// Messaging (manage friends from the userlist)
Store.answerFriendRequest = function (clientId, obj, cb) {
@ -1218,15 +1277,15 @@ define([
// If we accept the request, add the friend to the list
if (value) {
Messaging.acceptFriendRequest(store, msg.content, function (obj) {
Messaging.acceptFriendRequest(store, msg.content.user, function (obj) {
if (obj && obj.error) { return void cb(obj); }
Messaging.addToFriendList({
proxy: store.proxy,
realtime: store.realtime,
pinPads: function (data, cb) { Store.pinPads(null, data, cb); },
}, msg.content, function (err) {
}, msg.content.user, function (err) {
if (store.messenger) {
store.messenger.onFriendAdded(msg.content);
store.messenger.onFriendAdded(msg.content.user);
}
broadcast([], "UPDATE_METADATA");
if (err) { return void cb({error: err}); }
@ -1236,12 +1295,7 @@ define([
return;
}
// Otherwise, just remove the notification
store.mailbox.sendTo('DECLINE_FRIEND_REQUEST', {
displayName: store.proxy['cryptpad.username']
}, {
channel: msg.content.notifications,
curvePublic: msg.content.curvePublic
}, function (obj) {
Messaging.declineFriendRequest(store, msg.content.user, function (obj) {
broadcast([], "UPDATE_METADATA");
cb(obj);
});
@ -1263,8 +1317,9 @@ define([
store.proxy.friends_pending[data.curvePublic] = +new Date();
broadcast([], "UPDATE_METADATA");
var myData = Messaging.createData(store.proxy);
store.mailbox.sendTo('FRIEND_REQUEST', myData, {
store.mailbox.sendTo('FRIEND_REQUEST', {
user: Messaging.createData(store.proxy)
}, {
channel: data.notifications,
curvePublic: data.curvePublic
}, function (obj) {
@ -1600,11 +1655,8 @@ define([
// If send is true, send the request to the owner.
if (owner) {
if (data.send) {
var myData = Messaging.createData(store.proxy);
delete myData.channel;
store.mailbox.sendTo('REQUEST_PAD_ACCESS', {
channel: data.channel,
user: myData
channel: data.channel
}, {
channel: owner.notifications,
curvePublic: owner.curvePublic
@ -1638,13 +1690,10 @@ define([
}
})) { return void cb({error: 'ENOTFOUND'}); }
var myData = Messaging.createData(store.proxy);
delete myData.channel;
store.mailbox.sendTo("GIVE_PAD_ACCESS", {
channel: channel,
href: href,
title: title,
user: myData
title: title
}, {
channel: data.user.notifications,
curvePublic: data.user.curvePublic
@ -1678,13 +1727,11 @@ define([
}
// Tell all the owners that the pad was deleted from the server
var curvePublic = store.proxy.curvePublic;
var myData = Messaging.createData(store.proxy, false);
m.forEach(function (obj) {
var mb = JSON.parse(obj);
if (mb.curvePublic === curvePublic) { return; }
store.mailbox.sendTo('OWNED_PAD_REMOVED', {
channel: channel,
user: myData
channel: channel
}, {
channel: mb.notifications,
curvePublic: mb.curvePublic
@ -2095,6 +2142,12 @@ define([
}
}
}
if (o && !n && Array.isArray(p) && (p[0] === UserObject.FILES_DATA ||
(p[0] === 'drive' && p[1] === UserObject.FILES_DATA))) {
setTimeout(function () {
Store.checkDeletedPad(o && o.channel);
});
}
sendDriveEvent('DRIVE_CHANGE', {
id: fId,
old: o,
@ -2227,7 +2280,8 @@ define([
if (!store.loggedIn) { return void cb(); }
Store.pinPads(null, data, cb);
};
if (!proxy.settings) { proxy.settings = {}; }
if (!proxy.settings) { proxy.settings = NEW_USER_SETTINGS; }
if (!proxy.friends_pending) { proxy.friends_pending = {}; }
var manager = store.manager = ProxyManager.create(proxy.drive, {
onSync: function (cb) { onSync(null, cb); },
edPublic: proxy.edPublic,
@ -2311,13 +2365,7 @@ define([
}
}
if (!proxy.settings || !proxy.settings.general ||
typeof(proxy.settings.general.allowUserFeedback) !== 'boolean') {
proxy.settings = proxy.settings || {};
proxy.settings.general = proxy.settings.general || {};
proxy.settings.general.allowUserFeedback = true;
}
returned.feedback = proxy.settings.general.allowUserFeedback;
returned.feedback = Util.find(proxy, ['settings', 'general', 'allowUserFeedback']);
Feedback.init(returned.feedback);
if (typeof(cb) === 'function') { cb(returned); }

@ -4,6 +4,7 @@ define([
'/common/common-util.js',
], function (Messaging, Hash, Util) {
// Random timeout between 10 and 30 times your sync time (lag + chainpad sync)
var getRandomTimeout = function (ctx) {
var lag = ctx.store.realtime.getLag().lag || 0;
return (Math.max(0, lag) + 300) * 20 * (0.5 + Math.random());
@ -22,9 +23,11 @@ define([
// Store the friend request displayed to avoid duplicates
var friendRequest = {};
handlers['FRIEND_REQUEST'] = function (ctx, box, data, cb) {
// Old format: data was stored directly in "content"
var userData = data.msg.content.user || data.msg.content;
// Check if the request is valid (send by the correct user)
if (data.msg.author !== data.msg.content.curvePublic) {
if (data.msg.author !== userData.curvePublic) {
return void cb(true);
}
@ -40,7 +43,8 @@ define([
if (Messaging.getFriend(ctx.store.proxy, data.msg.author) ||
ctx.store.proxy.friends_pending[data.msg.author]) {
delete ctx.store.proxy.friends_pending[data.msg.author];
Messaging.acceptFriendRequest(ctx.store, data.msg.content, function (obj) {
Messaging.acceptFriendRequest(ctx.store, userData, function (obj) {
if (obj && obj.error) {
return void cb();
}
@ -48,10 +52,10 @@ define([
proxy: ctx.store.proxy,
realtime: ctx.store.realtime,
pinPads: ctx.pinPads
}, data.msg.content, function (err) {
if (err) { console.error(err); }
}, userData, function (err) {
if (err) { return void console.error(err); }
if (ctx.store.messenger) {
ctx.store.messenger.onFriendAdded(data.msg.content);
ctx.store.messenger.onFriendAdded(userData);
}
});
ctx.updateMetadata();
@ -63,96 +67,110 @@ define([
cb();
};
removeHandlers['FRIEND_REQUEST'] = function (ctx, box, data) {
if (friendRequest[data.content.curvePublic]) {
delete friendRequest[data.content.curvePublic];
var userData = data.content.user || data.content;
if (friendRequest[userData.curvePublic]) {
delete friendRequest[userData.curvePublic];
}
};
// The DECLINE and ACCEPT messages act on the contacts data
// They are processed with a random timeout to avoid having
// multiple workers trying to add or remove the contacts at
// the same time. Once processed, they are dismissed.
// We must dismiss them and send another message to our own
// mailbox for the UI part otherwise it would automatically
// accept or decline future requests from the same user
// until the message is manually dismissed.
var friendRequestDeclined = {};
handlers['DECLINE_FRIEND_REQUEST'] = function (ctx, box, data, cb) {
setTimeout(function () {
// Old format: data was stored directly in "content"
var userData = data.msg.content.user || data.msg.content;
if (!userData.curvePublic) { userData.curvePublic = data.msg.author; }
// Our friend request was declined.
if (!ctx.store.proxy.friends_pending[data.msg.author]) { return; }
setTimeout(function () {
// Only dismissed once in the timeout to make sure we won't lose
// the data if we close the worker before adding the friend
cb(true);
// Make sure we really sent it
if (!ctx.store.proxy.friends_pending[data.msg.author]) { return; }
// Remove the pending message and display the "declined" state in the UI
delete ctx.store.proxy.friends_pending[data.msg.author];
ctx.updateMetadata();
if (friendRequestDeclined[data.msg.author]) { return; }
friendRequestDeclined[data.msg.author] = true;
box.sendMessage({
type: 'FRIEND_REQUEST_DECLINED',
content: {
user: data.msg.author,
name: data.msg.content.displayName
}
}, function () {
if (friendRequestDeclined[data.msg.author]) {
// TODO remove our message because another one was sent first?
}
friendRequestDeclined[data.msg.author] = true;
});
content: { user: userData }
}, function () {});
}, getRandomTimeout(ctx));
cb(true);
};
// UI for declined friend request
handlers['FRIEND_REQUEST_DECLINED'] = function (ctx, box, data, cb) {
ctx.updateMetadata();
if (friendRequestDeclined[data.msg.content.user]) { return void cb(true); }
friendRequestDeclined[data.msg.content.user] = true;
var curve = data.msg.content.user.curvePublic || data.msg.content.user;
if (friendRequestDeclined[curve]) { return void cb(true); }
friendRequestDeclined[curve] = true;
cb();
};
removeHandlers['FRIEND_REQUEST_DECLINED'] = function (ctx, box, data) {
if (friendRequestDeclined[data.content.user]) {
delete friendRequestDeclined[data.content.user];
}
var curve = data.content.user.curvePublic || data.content.user;
if (friendRequestDeclined[curve]) { delete friendRequestDeclined[curve]; }
};
var friendRequestAccepted = {};
handlers['ACCEPT_FRIEND_REQUEST'] = function (ctx, box, data, cb) {
// Old format: data was stored directly in "content"
var userData = data.msg.content.user || data.msg.content;
// Our friend request was accepted.
setTimeout(function () {
// Only dismissed once in the timeout to make sure we won't lose
// the data if we close the worker before adding the friend
cb(true);
// Make sure we really sent it
if (!ctx.store.proxy.friends_pending[data.msg.author]) { return; }
// Remove the pending state. It will also us to send a new request in case of error
delete ctx.store.proxy.friends_pending[data.msg.author];
// And add the friend
Messaging.addToFriendList({
proxy: ctx.store.proxy,
realtime: ctx.store.realtime,
pinPads: ctx.pinPads
}, data.msg.content, function (err) {
if (err) { console.error(err); }
delete ctx.store.proxy.friends_pending[data.msg.author];
if (ctx.store.messenger) {
ctx.store.messenger.onFriendAdded(data.msg.content);
}
}, userData, function (err) {
if (err) { return void console.error(err); }
// Load the chat if contacts app loaded
if (ctx.store.messenger) { ctx.store.messenger.onFriendAdded(userData); }
// Update the userlist
ctx.updateMetadata();
// If you have a profile page open, update it
if (ctx.store.modules['profile']) { ctx.store.modules['profile'].update(); }
if (friendRequestAccepted[data.msg.author]) { return; }
// Display the "accepted" state in the UI
if (friendRequestAccepted[data.msg.author]) { return; }
friendRequestAccepted[data.msg.author] = true;
box.sendMessage({
type: 'FRIEND_REQUEST_ACCEPTED',
content: {
user: data.msg.author,
name: data.msg.content.displayName
}
}, function () {
if (friendRequestAccepted[data.msg.author]) {
// TODO remove our message because another one was sent first?
}
friendRequestAccepted[data.msg.author] = true;
});
content: { user: userData }
}, function () {});
});
}, getRandomTimeout(ctx));
cb(true);
};
// UI for accepted friend request
handlers['FRIEND_REQUEST_ACCEPTED'] = function (ctx, box, data, cb) {
ctx.updateMetadata();
if (friendRequestAccepted[data.msg.content.user]) { return void cb(true); }
friendRequestAccepted[data.msg.content.user] = true;
var curve = data.msg.content.user.curvePublic || data.msg.content.user;
if (friendRequestAccepted[curve]) { return void cb(true); }
friendRequestAccepted[curve] = true;
cb();
};
removeHandlers['FRIEND_REQUEST_ACCEPTED'] = function (ctx, box, data) {
if (friendRequestAccepted[data.content.user]) {
delete friendRequestAccepted[data.content.user];
}
var curve = data.content.user.curvePublic || data.content.user;
if (friendRequestAccepted[curve]) { delete friendRequestAccepted[curve]; }
};
handlers['UNFRIEND'] = function (ctx, box, data, cb) {

@ -2,11 +2,12 @@ define([
'/common/common-util.js',
'/common/common-hash.js',
'/common/common-realtime.js',
'/common/common-messaging.js',
'/common/notify.js',
'/common/outer/mailbox-handlers.js',
'/bower_components/chainpad-netflux/chainpad-netflux.js',
'/bower_components/chainpad-crypto/crypto.js',
], function (Util, Hash, Realtime, Notify, Handlers, CpNetflux, Crypto) {
], function (Util, Hash, Realtime, Messaging, Notify, Handlers, CpNetflux, Crypto) {
var Mailbox = {};
var TYPES = [
@ -96,6 +97,12 @@ proxy.mailboxes = {
var crypto = Crypto.Mailbox.createEncryptor(keys);
// Always send your data
if (typeof(msg) === "object" && !msg.user) {
var myData = Messaging.createData(ctx.store.proxy, false);
msg.user = myData;
}
var text = JSON.stringify({
type: type,
content: msg
@ -187,6 +194,11 @@ proxy.mailboxes = {
history: [], // All the hashes loaded from the server in corretc order
content: {}, // Content of the messages that should be displayed
sendMessage: function (msg) { // To send a message to our box
// Always send your data
if (typeof(msg) === "object" && !msg.user) {
var myData = Messaging.createData(ctx.store.proxy, false);
msg.user = myData;
}
try {
msg = JSON.stringify(msg);
} catch (e) {

@ -893,7 +893,7 @@ define([
};
var clearOwnedChannel = function (ctx, id, cb) {
var channel = ctx.clients[id];
var channel = ctx.channels[id];
if (!channel) { return void cb({error: 'NO_CHANNEL'}); }
if (!ctx.store.rpc) { return void cb({error: 'RPC_NOT_READY'}); }
ctx.store.rpc.clearOwnedChannel(id, function (err) {

@ -53,7 +53,7 @@ define([
// all our client IDs.
if (chan.clients) {
chan.clients.forEach(function (cl) {
if (ctx.clients[cl] && !ctx.clients[cl].id) {
if (ctx.clients[cl]) {
ctx.clients[cl].id = wc.myID + '-' + cl;
}
});
@ -189,15 +189,22 @@ define([
if (!c) { return void cb({ error: 'NOT_IN_CHANNEL' }); }
var chan = ctx.channels[c.channel];
if (!chan) { return void cb({ error: 'INVALID_CHANNEL' }); }
if (data.isCp) {
return void chan.sendMsg(data.isCp, cb);
}
chan.sendMsg(data.msg, cb);
// Prepare the callback: broadcast the message to the other local tabs
// if the message is sent
var _cb = function (obj) {
if (obj && obj.error) { return void cb(obj); }
ctx.emit('MESSAGE', {
msg: data.msg
}, chan.clients.filter(function (cl) {
return cl !== clientId;
}));
cb();
};
// Send the message
if (data.isCp) {
return void chan.sendMsg(data.isCp, _cb);
}
chan.sendMsg(data.msg, _cb);
};
var reencrypt = function (ctx, data, cId, cb) {

@ -50,6 +50,7 @@ define([
GET_TEMPLATES: Store.getTemplates,
GET_SECURE_FILES_LIST: Store.getSecureFilesList,
GET_PAD_DATA: Store.getPadData,
GET_PAD_DATA_FROM_CHANNEL: Store.getPadDataFromChannel,
GET_STRONGER_HASH: Store.getStrongerHash,
INCREMENT_TEMPLATE_USE: Store.incrementTemplateUse,
GET_SHARED_FOLDER: Store.getSharedFolder,

@ -93,6 +93,12 @@ define([
}
}
}
if (o && !n && Array.isArray(p) && (p[0] === UserObject.FILES_DATA ||
(p[0] === 'drive' && p[1] === UserObject.FILES_DATA))) {
setTimeout(function () {
ctx.Store.checkDeletedPad(o && o.channel);
});
}
team.sendEvent('DRIVE_CHANGE', {
id: fId,
old: o,
@ -903,13 +909,11 @@ define([
}));
}).nThen(function (waitFor) {
// Send mailbox to offer ownership
var myData = Messaging.createData(ctx.store.proxy, false);
ctx.store.mailbox.sendTo("ADD_OWNER", {
teamChannel: teamData.channel,
chatChannel: Util.find(teamData, ['keys', 'chat', 'channel']),
rosterChannel: Util.find(teamData, ['keys', 'roster', 'channel']),
title: teamData.metadata.name,
user: myData
title: teamData.metadata.name
}, {
channel: user.notifications,
curvePublic: user.curvePublic
@ -963,12 +967,10 @@ define([
}));
}).nThen(function (waitFor) {
// Send mailbox to offer ownership
var myData = Messaging.createData(ctx.store.proxy, false);
ctx.store.mailbox.sendTo("RM_OWNER", {
teamChannel: teamData.channel,
title: teamData.metadata.name,
pending: isPendingOwner,
user: myData
pending: isPendingOwner
}, {
channel: user.notifications,
curvePublic: user.curvePublic
@ -1098,11 +1100,9 @@ define([
if (!team) { return void cb ({error: 'ENOENT'}); }
// Send mailbox to offer ownership
var myData = Messaging.createData(ctx.store.proxy, false);
ctx.store.mailbox.sendTo("TEAM_EDIT_RIGHTS", {
state: state,
teamData: getInviteData(ctx, teamId, state),
user: myData
teamData: getInviteData(ctx, teamId, state)
}, {
channel: user.notifications,
curvePublic: user.curvePublic
@ -1169,7 +1169,6 @@ define([
team.roster.add(obj, function (err) {
if (err && err !== 'NO_CHANGE') { return void cb({error: err}); }
ctx.store.mailbox.sendTo('INVITE_TO_TEAM', {
user: Messaging.createData(ctx.store.proxy, false),
team: getInviteData(ctx, teamId)
}, {
channel: user.notifications,
@ -1196,7 +1195,6 @@ define([
if (!userData || !userData.notifications) { return cb(); }
ctx.store.mailbox.sendTo('KICKED_FROM_TEAM', {
pending: data.pending,
user: Messaging.createData(ctx.store.proxy, false),
teamChannel: getInviteData(ctx, teamId).channel,
teamName: getInviteData(ctx, teamId).metadata.name
}, {

@ -438,14 +438,24 @@ define([
parentEl.push(id);
return;
}
// Add to root if path is ROOT or if no path
// Add to root if no path
var filesList = exp.getFiles([ROOT, TRASH, 'hrefArray']);
if (path && exp.isPathIn(newPath, [ROOT]) || filesList.indexOf(id) === -1) {
parentEl = exp.find(newPath || [ROOT]);
if (filesList.indexOf(id) === -1 && !newPath) {
newPath = [ROOT];
}
// Add to root
if (path && exp.isPathIn(newPath, [ROOT])) {
parentEl = exp.find(newPath);
if (parentEl) {
var newName = exp.getAvailableName(parentEl, Hash.createChannelId());
parentEl[newName] = id;
return;
} else {
parentEl = exp.find([ROOT]);
newPath.slice(1).forEach(function (folderName) {
parentEl = parentEl[folderName] = parentEl[folderName] || {};
});
parentEl[Hash.createChannelId()] = id;
}
}
};

@ -89,18 +89,6 @@ var factory = function (Util, Rpc) {
});
};
// get the total stored size of a channel's patches (in bytes)
exp.getFileSize = function (file, cb) {
rpc.send('GET_FILE_SIZE', file, function (e, response) {
if (e) { return void cb(e); }
if (response && response.length && typeof(response[0]) === 'number') {
return void cb(void 0, response[0]);
} else {
cb('INVALID_RESPONSE');
}
});
};
// get the combined size of all channels (in bytes) for all the
// channels which the server has pinned for your publicKey
exp.getFileListSize = function (cb) {

@ -587,14 +587,10 @@ define([
// convert a folder to a Shared Folder
var _convertFolderToSharedFolder = function (Env, data, cb) {
return void cb({
error: 'DISABLED'
}); // XXX CONVERT
/*var path = data.path;
var path = data.path;
var folderElement = Env.user.userObject.find(path);
// don't try to convert top-level elements (trash, root, etc) to shared-folders
// TODO also validate that you're in root (not templates, etc)
if (data.path.length <= 1) {
if (path.length <= 1 || path[0] !== UserObject.ROOT) {
return void cb({
error: 'E_INVAL_PATH',
});
@ -664,6 +660,21 @@ define([
newPath: newPath,
copy: false,
}, waitFor());
}).nThen(function (waitFor) {
// Move the owned pads from the old folder to root
var paths = [];
Object.keys(folderElement).forEach(function (el) {
if (!Env.user.userObject.isFile(folderElement[el])) { return; }
var data = Env.user.userObject.getFileData(folderElement[el]);
if (!data || !_ownedByMe(Env, data.owners)) { return; }
// This is an owned pad: move it to ROOT before deleting the initial folder
paths.push(path.concat(el));
});
_move(Env, {
paths: paths,
newPath: [UserObject.ROOT],
copy: false,
}, waitFor());
}).nThen(function () {
// migrate metadata
var sharedFolderElement = Env.user.proxy[UserObject.SHARED_FOLDERS][SFId];
@ -678,9 +689,11 @@ define([
// remove folder
Env.user.userObject.delete([path], function () {
cb();
cb({
fId: SFId
});
});
});
});*/
};
// Delete permanently some pads or folders
@ -771,6 +784,9 @@ define([
toUnpin.forEach(function (chan) {
if (toKeep.indexOf(chan) === -1) {
unpinList.push(chan);
// Check if need need to restore a full hash (hidden hash deleted from drive)
Env.Store.checkDeletedPad(chan);
}
});
@ -783,7 +799,16 @@ define([
};
// Empty the trash (main drive only)
var _emptyTrash = function (Env, data, cb) {
Env.user.userObject.emptyTrash(cb);
Env.user.userObject.emptyTrash(function (err, toClean) {
cb();
// Check if need need to restore a full hash (hidden hash deleted from drive)
if (!Array.isArray(toClean)) { return; }
var toCheck = Util.deduplicateString(toClean);
toCheck.forEach(function (chan) {
Env.Store.checkDeletedPad(chan);
});
});
};
// Rename files or folders
var _rename = function (Env, data, cb) {

@ -396,9 +396,9 @@ define([
if (state === STATE.DELETED) { return; }
stateChange(info.state ? STATE.INITIALIZING : STATE.DISCONNECTED, info.permanent);
/*if (info.state) {
UI.findOKButton().click();
UIElements.reconnectAlert();
} else {
UI.alert(Messages.common_connectionLost, undefined, true);
UIElements.disconnectAlert();
}*/
};

@ -8,6 +8,7 @@ define([
], function (nThen, ApiConfig, DomReady, RequireConfig, SFCommonO) {
var requireConfig = RequireConfig();
var hash, href;
nThen(function (waitFor) {
DomReady.onReady(waitFor());
}).nThen(function (waitFor) {
@ -18,6 +19,14 @@ define([
};
window.rc = requireConfig;
window.apiconf = ApiConfig;
// Hidden hash
hash = window.location.hash;
href = window.location.href;
if (window.history && window.history.replaceState && hash) {
window.history.replaceState({}, window.document.title, '#');
}
document.getElementById('sbox-iframe').setAttribute('src',
ApiConfig.httpSafeOrigin + window.location.pathname + 'inner.html?' +
requireConfig.urlArgs + '#' + encodeURIComponent(JSON.stringify(req)));
@ -36,6 +45,8 @@ define([
window.addEventListener('message', onMsg);
}).nThen(function (/*waitFor*/) {
SFCommonO.start({
hash: hash,
href: href,
useCreationScreen: true,
messaging: true
});

@ -48,6 +48,8 @@ define([
var updateLoadingProgress = config.updateLoadingProgress;
config = undefined;
var evPatchSent = Util.mkEvent();
var chainpad = ChainPad.create({
userName: userName,
initialState: initialState,
@ -57,7 +59,10 @@ define([
logLevel: logLevel
});
chainpad.onMessage(function(message, cb) {
sframeChan.query('Q_RT_MESSAGE', message, cb);
sframeChan.query('Q_RT_MESSAGE', message, function (err) {
if (!err) { evPatchSent.fire(); }
cb(err);
});
});
chainpad.onPatch(function () {
onRemote({ realtime: chainpad });
@ -149,6 +154,8 @@ define([
metadataMgr: metadataMgr,
whenRealtimeSyncs: whenRealtimeSyncs,
onInfiniteSpinner: evInfiniteSpinner.reg,
onPatchSent: evPatchSent.reg,
offPatchSent: evPatchSent.unreg,
chainpad: chainpad,
});
};

@ -62,15 +62,16 @@ define([
});
editor._noCursorUpdate = false;
editor.state.focused = true;
editor.scrollTo(scroll.left, scroll.top);
if (!editor.state.focused) { return; }
if(selects[0] === selects[1]) {
editor.setCursor(posToCursor(selects[0], remoteDoc));
}
else {
editor.setSelection(posToCursor(selects[0], remoteDoc), posToCursor(selects[1], remoteDoc));
}
editor.scrollTo(scroll.left, scroll.top);
};
module.getHeadingText = function (editor) {

@ -53,11 +53,17 @@ define([
var $table = File.$table = $('<table>', { id: 'cp-fileupload-table' });
var hover = false;
var createTableContainer = function ($body) {
File.$container = $('<div>', { id: 'cp-fileupload' }).append(tableHeader).append($table).appendTo($body);
$('.cp-fileupload-header-close').click(function () {
File.$container.fadeOut();
});
File.$container.mouseenter(function () {
hover = true;
}).mouseleave(function () {
hover = false;
});
return File.$container;
};
@ -209,6 +215,11 @@ define([
window.setTimeout(function () { File.$container.show(); });
var file = queue.queue.shift();
if (file.dl) { return void file.dl(file); }
if (file.$line && file.$line[0] && !hover) {
var line = file.$line[0];
line.scrollIntoView(false);
}
delete file.$line;
upload(file);
};
queue.push = function (obj) {
@ -224,10 +235,10 @@ define([
$('<div>', {'class':'cp-fileupload-table-progressbar'}).appendTo($progressContainer);
$('<span>', {'class':'cp-fileupload-table-progress-value'}).text(Messages.upload_pending).appendTo($progressContainer);
var $tr = $('<tr>', {id: id}).appendTo($table);
var $tr = obj.$line = $('<tr>', {id: id}).appendTo($table);
var $lines = $table.find('tr[id]');
if ($lines.length > 5) {
$lines.slice(0, $lines.length - 5).remove();
//$lines.slice(0, $lines.length - 5).remove();
}
var $cancel = $('<span>', {'class': 'cp-fileupload-table-cancel-button fa fa-times'}).click(function () {
@ -257,6 +268,13 @@ define([
// cancel
$('<td>', {'class': 'cp-fileupload-table-cancel'}).append($cancel).appendTo($tr);
var tw = $table.width();
var cw = File.$container.prop('clientWidth');
var diff = tw - cw;
if (diff && diff > 0) {
$table.css('margin-right', diff+'px');
}
queue.next();
};

@ -1,12 +1,13 @@
define([
'jquery',
'/common/common-util.js',
'/common/common-hash.js',
'/common/common-interface.js',
'/common/common-ui-elements.js',
'/common/notifications.js',
'/common/hyperscript.js',
'/customize/messages.js',
], function ($, Util, UI, UIElements, Notifications, h, Messages) {
], function ($, Util, Hash, UI, UIElements, Notifications, h, Messages) {
var Mailbox = {};
Mailbox.create = function (Common) {
@ -53,9 +54,23 @@ define([
};
var createElement = mailbox.createElement = function (data) {
var notif;
var avatar;
var userData = Util.find(data, ['content', 'msg', 'content', 'user']);
if (userData && typeof(userData) === "object" && userData.profile) {
avatar = h('span.cp-avatar');
Common.displayAvatar($(avatar), userData.avatar, userData.displayName || userData.name);
$(avatar).click(function (e) {
e.stopPropagation();
Common.openURL(Hash.hashToHref(userData.profile, 'profile'));
});
}
notif = h('div.cp-notification', {
'data-hash': data.content.hash
}, [h('div.cp-notification-content', h('p', formatData(data)))]);
}, [
avatar,
h('div.cp-notification-content',
h('p', formatData(data)))
]);
if (typeof(data.content.getFormatText) === "function") {
$(notif).find('.cp-notification-content p').html(data.content.getFormatText());

@ -30,6 +30,12 @@ define([
var password;
var initialPathInDrive;
var currentPad = window.CryptPad_location = {
app: '',
href: cfg.href || window.location.href,
hash: cfg.hash || window.location.hash
};
nThen(function (waitFor) {
// Load #2, the loading screen is up so grab whatever you need...
require([
@ -134,8 +140,15 @@ define([
});
}
}), {
driveEvents: cfg.driveEvents
driveEvents: cfg.driveEvents,
currentPad: currentPad
});
if (window.history && window.history.replaceState && currentPad.hash) {
var nHash = currentPad.hash;
if (!/^#/.test(nHash)) { nHash = '#' + nHash; }
window.history.replaceState({}, window.document.title, nHash);
}
}));
}).nThen(function (waitFor) {
if (!Utils.Hash.isValidHref(window.location.href)) {
@ -160,6 +173,8 @@ define([
});
});
var parsed = Utils.Hash.parsePadUrl(currentPad.href);
currentPad.app = parsed.type;
if (cfg.getSecrets) {
var w = waitFor();
// No password for drive, profile and todo
@ -171,15 +186,25 @@ define([
});
}));
} else {
var parsed = Utils.Hash.parsePadUrl(window.location.href);
var todo = function () {
secret = Utils.secret = Utils.Hash.getSecrets(parsed.type, void 0, password);
secret = Utils.secret = Utils.Hash.getSecrets(parsed.type, parsed.hash, password);
Cryptpad.getShareHashes(secret, waitFor(function (err, h) {
hashes = h;
// Update the rendered hash and the full hash with the "password" settings
if (password && !parsed.hashData.password) {
var opts = parsed.getOptions();
opts.password = true;
// Full hash
currentPad.href = parsed.getUrl(opts);
if (parsed.hashData) {
currentPad.hash = parsed.hashData.getHash(opts);
}
// Rendered (maybe hidden) hash
var renderedParsed = Utils.Hash.parsePadUrl(window.location.href);
var ohc = window.onhashchange;
window.onhashchange = function () {};
window.location.hash = h.fileHash || h.editHash || h.viewHash || window.location.hash;
window.location.href = renderedParsed.getUrl(opts);
window.onhashchange = ohc;
ohc({reset: true});
}
@ -241,13 +266,13 @@ define([
if (parsed.type === "file") {
// `isNewChannel` doesn't work for files (not a channel)
// `getFileSize` is not adapted to channels because of metadata
Cryptpad.getFileSize(window.location.href, password, function (e, size) {
Cryptpad.getFileSize(currentPad.href, password, function (e, size) {
next(e, size === 0);
});
return;
}
// Not a file, so we can use `isNewChannel`
Cryptpad.isNewChannel(window.location.href, password, next);
Cryptpad.isNewChannel(currentPad.href, password, next);
});
sframeChan.event("EV_PAD_PASSWORD", cfg);
};
@ -257,7 +282,47 @@ define([
var passwordCfg = {
value: ''
};
// Hidden hash: can't find the channel in our drives: abort
var noPadData = function (err) {
sframeChan.event("EV_PAD_NODATA", err);
};
var newHref;
nThen(function (w) {
if (parsed.hashData.key || !parsed.hashData.channel) { return; }
var edit = parsed.hashData.mode === 'edit';
Cryptpad.getPadDataFromChannel({
channel: parsed.hashData.channel,
edit: edit,
file: parsed.hashData.type === 'file'
}, w(function (err, res) {
// Error while getting data? abort
if (err || !res || res.error) {
w.abort();
return void noPadData(err || (!res ? 'EINVAL' : res.error));
}
// No data found? abort
if (!Object.keys(res).length) {
w.abort();
return void noPadData('NO_RESULT');
}
// Data found but weaker? warn
if (edit && !res.href) {
newHref = res.roHref;
}
// We have good data, keep the hash in memory
newHref = edit ? res.href : (res.roHref || res.href);
}));
}).nThen(function (w) {
if (newHref) {
// Get the options (embed, present, etc.) of the hidden hash
// Use the same options in the full hash
var opts = parsed.getOptions();
parsed = Utils.Hash.parsePadUrl(newHref);
currentPad.href = parsed.getUrl(opts);
currentPad.hash = parsed.hashData && parsed.hashData.getHash(opts);
}
Cryptpad.getPadAttribute('title', w(function (err, data) {
stored = (!err && typeof (data) === "string");
}));
@ -273,7 +338,7 @@ define([
if (parsed.type === "file") {
// `isNewChannel` doesn't work for files (not a channel)
// `getFileSize` is not adapted to channels because of metadata
Cryptpad.getFileSize(window.location.href, password, w(function (e, size) {
Cryptpad.getFileSize(currentPad.href, password, w(function (e, size) {
if (size !== 0) { return void todo(); }
// Wrong password or deleted file?
askPassword(true, passwordCfg);
@ -281,7 +346,7 @@ define([
return;
}
// Not a file, so we can use `isNewChannel`
Cryptpad.isNewChannel(window.location.href, password, w(function(e, isNew) {
Cryptpad.isNewChannel(currentPad.href, password, w(function(e, isNew) {
if (!isNew) { return void todo(); }
if (parsed.hashData.mode === 'view' && (password || !parsed.hashData.password)) {
// Error, wrong password stored, the view seed has changed with the password
@ -305,10 +370,12 @@ define([
}
}).nThen(function (waitFor) {
// Check if the pad exists on server
if (!window.location.hash) { isNewFile = true; return; }
if (!currentPad.hash) { isNewFile = true; return; }
if (realtime) {
Cryptpad.isNewChannel(window.location.href, password, waitFor(function (e, isNew) {
// TODO we probably don't need to check again for password-protected pads
// (we use isNewChannel to test the password...)
Cryptpad.isNewChannel(currentPad.href, password, waitFor(function (e, isNew) {
if (e) { return console.error(e); }
isNewFile = Boolean(isNew);
}));
@ -322,11 +389,12 @@ define([
readOnly = false;
}
Utils.crypto = Utils.Crypto.createEncryptor(Utils.secret.keys);
var parsed = Utils.Hash.parsePadUrl(window.location.href);
var parsed = Utils.Hash.parsePadUrl(currentPad.href);
var burnAfterReading = parsed && parsed.hashData && parsed.hashData.ownerKey;
if (!parsed.type) { throw new Error(); }
var defaultTitle = Utils.UserObject.getDefaultName(parsed);
var edPublic, curvePublic, notifications, isTemplate;
var settings = {};
var forceCreationScreen = cfg.useCreationScreen &&
sessionStorage[Utils.Constants.displayPadCreationScreen];
delete sessionStorage[Utils.Constants.displayPadCreationScreen];
@ -340,9 +408,10 @@ define([
edPublic = metaObj.priv.edPublic; // needed to create an owned pad
curvePublic = metaObj.user.curvePublic;
notifications = metaObj.user.notifications;
settings = metaObj.priv.settings;
}));
if (typeof(isTemplate) === "undefined") {
Cryptpad.isTemplate(window.location.href, waitFor(function (err, t) {
Cryptpad.isTemplate(currentPad.href, waitFor(function (err, t) {
if (err) { console.log(err); }
isTemplate = t;
}));
@ -368,7 +437,7 @@ define([
upgradeURL: Cryptpad.upgradeURL
},
isNewFile: isNewFile,
isDeleted: isNewFile && window.location.hash.length > 0,
isDeleted: isNewFile && currentPad.hash.length > 0,
forceCreationScreen: forceCreationScreen,
password: password,
channel: secret.channel,
@ -393,7 +462,7 @@ define([
additionalPriv.registeredOnly = true;
}
if (['debug', 'profile'].indexOf(parsed.type) !== -1) {
if (['debug', 'profile'].indexOf(currentPad.app) !== -1) {
additionalPriv.hashes = hashes;
}
@ -487,7 +556,7 @@ define([
});
sframeChan.on('Q_SET_LOGIN_REDIRECT', function (data, cb) {
sessionStorage.redirectTo = window.location.href;
sessionStorage.redirectTo = currentPad.href;
cb();
});
@ -570,7 +639,18 @@ define([
channel: secret.channel,
path: initialPathInDrive // Where to store the pad if we don't have it in our drive
};
Cryptpad.setPadTitle(data, function (err) {
Cryptpad.setPadTitle(data, function (err, obj) {
if (!err && !(obj && obj.notStored)) {
// No error and the pad was correctly stored
// hide the hash
var opts = parsed.getOptions();
var hash = Utils.Hash.getHiddenHashFromKeys(parsed.type, secret, opts);
var useUnsafe = Utils.Util.find(settings, ['security', 'unsafeLinks']);
if (useUnsafe === false && window.history && window.history.replaceState) {
if (!/^#/.test(hash)) { hash = '#' + hash; }
window.history.replaceState({}, window.document.title, hash);
}
}
cb({error: err});
});
});
@ -580,6 +660,9 @@ define([
});
sframeChan.on('EV_SET_HASH', function (hash) {
// In this case, we want to set the hash for the next page reload
// This hash is a category for the sidebar layout apps
// No need to store it in memory
window.location.hash = hash;
});
@ -595,6 +678,17 @@ define([
forceSave: true
};
Cryptpad.setPadTitle(data, function (err) {
if (!err && !(obj && obj.notStored)) {
// No error and the pad was correctly stored
// hide the hash
var opts = parsed.getOptions();
var hash = Utils.Hash.getHiddenHashFromKeys(parsed.type, secret, opts);
var useUnsafe = Utils.Util.find(settings, ['security', 'unsafeLinks']);
if (useUnsafe === false && window.history && window.history.replaceState) {
if (!/^#/.test(hash)) { hash = '#' + hash; }
window.history.replaceState({}, window.document.title, hash);
}
}
cb({error: err});
});
});
@ -801,15 +895,19 @@ define([
// Present mode URL
sframeChan.on('Q_PRESENT_URL_GET_VALUE', function (data, cb) {
var parsed = Utils.Hash.parsePadUrl(window.location.href);
var parsed = Utils.Hash.parsePadUrl(currentPad.href);
cb(parsed.hashData && parsed.hashData.present);
});
sframeChan.on('EV_PRESENT_URL_SET_VALUE', function (data) {
var parsed = Utils.Hash.parsePadUrl(window.location.href);
window.location.href = parsed.getUrl({
embed: parsed.hashData.embed,
present: data
});
// Update the rendered hash and the full hash with the "present" settings
var opts = parsed.getOptions();
opts.present = data;
// Full hash
currentPad.href = parsed.getUrl(opts);
if (parsed.hashData) { currentPad.hash = parsed.hashData.getHash(opts); }
// Rendered (maybe hidden) hash
var hiddenParsed = Utils.Hash.parsePadUrl(window.location.href);
window.location.href = hiddenParsed.getUrl(opts);
});
@ -1011,7 +1109,7 @@ define([
});
sframeChan.on('Q_BLOB_PASSWORD_CHANGE', function (data, cb) {
data.href = data.href || window.location.href;
data.href = data.href || currentPad.href;
var onPending = function (cb) {
sframeChan.query('Q_BLOB_PASSWORD_CHANGE_PENDING', null, function (err, obj) {
if (obj && obj.cancel) { cb(); }
@ -1027,12 +1125,12 @@ define([
});
sframeChan.on('Q_OO_PASSWORD_CHANGE', function (data, cb) {
data.href = data.href || window.location.href;
data.href = data.href;
Cryptpad.changeOOPassword(data, cb);
});
sframeChan.on('Q_PAD_PASSWORD_CHANGE', function (data, cb) {
data.href = data.href || window.location.href;
data.href = data.href;
Cryptpad.changePadPassword(Cryptget, Crypto, data, cb);
});
@ -1227,14 +1325,26 @@ define([
}
} catch (e) {}
// If our channel was deleted from all of our drives, sitch back to full hash
// in the address bar
Cryptpad.padRpc.onChannelDeleted.reg(function (channel) {
if (channel !== secret.channel) { return; }
var ohc = window.onhashchange;
window.onhashchange = function () {};
window.location.href = currentPad.href;
window.onhashchange = ohc;
ohc({reset: true});
});
// Join the netflux channel
var rtStarted = false;
var startRealtime = function (rtConfig) {
rtConfig = rtConfig || {};
rtStarted = true;
var replaceHash = function (hash) {
// The pad has just been created but is not stored yet. We'll switch
// to hidden hash once the pad is stored
if (window.history && window.history.replaceState) {
if (!/^#/.test(hash)) { hash = '#' + hash; }
window.history.replaceState({}, window.document.title, hash);
@ -1250,7 +1360,7 @@ define([
Cryptpad.padRpc.onReadyEvent.reg(function () {
Cryptpad.burnPad({
password: password,
href: window.location.href,
href: currentPad.href,
channel: secret.channel,
ownerKey: burnAfterReading
});
@ -1265,7 +1375,7 @@ define([
readOnly: readOnly,
crypto: Crypto.createEncryptor(secret.keys),
onConnect: function () {
if (window.location.hash && window.location.hash !== '#') {
if (currentPad.hash && currentPad.hash !== '#') {
/*window.location = parsed.getUrl({
present: parsed.hashData.present,
embed: parsed.hashData.embed
@ -1278,11 +1388,11 @@ define([
};
nThen(function (waitFor) {
if (isNewFile && cfg.owned && !window.location.hash) {
if (isNewFile && cfg.owned && !currentPad.hash) {
Cryptpad.getMetadata(waitFor(function (err, m) {
cpNfCfg.owners = [m.priv.edPublic];
}));
} else if (isNewFile && !cfg.useCreationScreen && window.location.hash) {
} else if (isNewFile && !cfg.useCreationScreen && currentPad.hash) {
console.log("new file with hash in the address bar in an app without pcs and which requires owners");
sframeChan.onReady(function () {
sframeChan.query("EV_LOADING_ERROR", "DELETED");
@ -1309,11 +1419,13 @@ define([
var ohc = window.onhashchange;
window.onhashchange = function () {};
window.location.hash = newHash;
currentPad.hash = newHash;
currentPad.href = '/' + parsed.type + '/#' + newHash;
window.onhashchange = ohc;
ohc({reset: true});
// Update metadata values and send new metadata inside
parsed = Utils.Hash.parsePadUrl(window.location.href);
parsed = Utils.Hash.parsePadUrl(currentPad.href);
defaultTitle = Utils.UserObject.getDefaultName(parsed);
hashes = Utils.Hash.getHashes(secret);
readOnly = false;

@ -83,6 +83,9 @@ define([
};
// UI
window.CryptPad_UI = UI;
window.CryptPad_UIElements = UIElements;
window.CryptPad_common = funcs;
funcs.createUserAdminMenu = callWithCommon(UIElements.createUserAdminMenu);
funcs.initFilePicker = callWithCommon(UIElements.initFilePicker);
funcs.openFilePicker = callWithCommon(UIElements.openFilePicker);
@ -139,7 +142,13 @@ define([
if (!$mt || !$mt.is('media-tag')) { return; }
var chanStr = $mt.attr('src');
var keyStr = $mt.attr('data-crypto-key');
var channel = chanStr.replace(/\/blob\/[0-9a-f]{2}\//i, '');
// Remove origin
var a = document.createElement('a');
a.href = chanStr;
var src = a.pathname;
// Get channel id
var channel = src.replace(/\/blob\/[0-9a-f]{2}\//i, '');
// Get key
var key = keyStr.replace(/cryptpad:/i, '');
var metadata = $mt[0]._mediaObject._blob.metadata;
ctx.sframeChan.query('Q_IMPORT_MEDIATAG', {
@ -603,6 +612,10 @@ define([
UI.addTooltips();
ctx.sframeChan.on("EV_PAD_NODATA", function () {
UI.errorLoadingScreen(Messages.safeLinks_error);
});
ctx.sframeChan.on("EV_PAD_PASSWORD", function (cfg) {
UIElements.displayPasswordPrompt(funcs, cfg);
});

@ -12,7 +12,7 @@
"media": "Multimèdia",
"todo": "Tasques",
"contacts": "Contactes",
"sheet": "Full (Beta)",
"sheet": "Full de càlcul",
"teams": "Equips"
},
"button_newpad": "Nou document",
@ -34,7 +34,7 @@
"inactiveError": "Donada la seva inactivitat, aquest document s'ha esborrat. Premeu Esc per crear un nou document.",
"chainpadError": "Hi ha hagut un error crític mentre s'actualitzava el vostre contingut. Aquesta pàgina es manté en mode només de lectura per assegurar que no perdreu el que ja heu fet.<br>Premeu <em>Esc</em> per continuar veient aquest document o torneu a carregar la pàgina per provar de continuar editant-lo.",
"invalidHashError": "El document que heu demanat té una adreça URL no vàlida.",
"errorCopy": " Encara podeu copiar el contingut en una altra ubicació prement <em>Esc</em>.<br>Un cop deixeu aquesta pàgina, desapareixerà per sempre!",
"errorCopy": " Encara podeu accedir al contingut prement <em>Esc</em>.<br>Un cop tanqueu aquesta finestra no hi podreu tornar a accedir.",
"errorRedirectToHome": "Premeu <em>Esc</em> per tornar al vostre CryptDrive.",
"newVersionError": "Hi ha una nova versió disponible de CryptPad.<br><a href='#'>Torneu a carregar</a> la pàgina per utilitzar la versió nova o premeu Esc per accedir al vostre contingut en mode <b>fora de línia</b>.",
"loading": "Carregant...",
@ -181,7 +181,6 @@
"okButton": "D'acord (Enter)",
"cancel": "Cancel·la",
"cancelButton": "Cancel·la (Esc)",
"doNotAskAgain": "No ho preguntis més (Esc)",
"show_help_button": "Mostra l'ajuda",
"hide_help_button": "Amaga l'ajuda",
"help_button": "Ajuda",
@ -299,7 +298,7 @@
"contacts_confirmRemoveHistory": "De debò voleu suprimir permanentment el vostre historial de xat? Les dades no es podran restaurar",
"contacts_removeHistoryServerError": "Hi ha hagut un error mentre es suprimia el vostre historial del xat. Torneu-ho a provar",
"contacts_fetchHistory": "Recupera l'historial antic",
"contacts_friends": "Amistats",
"contacts_friends": "Contactes",
"contacts_rooms": "Sales",
"contacts_leaveRoom": "Deixa aquesta sala",
"contacts_online": "En aquesta sala hi ha una altra persona en línia",
@ -531,5 +530,91 @@
"settings_padSpellcheckTitle": "Correcció ortogràfica",
"settings_padSpellcheckHint": "Aquesta opció us permet habilitar la correcció ortogràfica als documents de text. Les errades es subratllaran en vermell i haureu de mantenir apretada la tecla Ctrl o Meta mentre cliqueu el botó dret per veure les opcions correctes.",
"settings_padSpellcheckLabel": "Activa la correcció ortogràfica",
"settings_creationSkip": "Salta la pantalla de creació de document"
"settings_creationSkip": "Salta la pantalla de creació de document",
"settings_creationSkipHint": "La pantalla de creació de documents ofereix noves opcions, donant-vos més control sobre les vostres dades. Tot i això, pot alentir una mica la feina afegint un pas addicional i, per això, teniu l'opció de saltar aquesta pantalla i utilitzar les opcions per defecte que hi ha seleccionades.",
"settings_creationSkipTrue": "Salta",
"settings_creationSkipFalse": "Mostra",
"settings_templateSkip": "Salta la finestra de selecció de plantilla",
"settings_templateSkipHint": "Quan genereu un document nou buit, si teniu desades plantilles per aquest tipus de document, apareix una finestra preguntant-vos si voleu utilitzar una plantilla. Aquí podeu triar si no voleu veure mai més la finestra i no utilitzar una plantilla.",
"settings_ownDriveTitle": "Habilita les darreres funcionalitats del compte",
"settings_ownDriveHint": "Per raons tècniques, els comptes antics no tenen accés a totes les funcionalitats noves. Si feu una actualització a un compte nou, preparareu el vostre CryptDrive per les properes funcionalitats sense interrompre la vostra activitat habitual.",
"settings_ownDriveButton": "Milloreu el vostre compte",
"settings_ownDriveConfirm": "Millorar el vostre compte porta una estona. Necessitareu tornar-vos a connectar en tots els vostres dispositius. Segur que ho voleu fer?",
"settings_ownDrivePending": "El vostre compte s'està posant al dia. No tanqueu ni torneu a carregar aquesta pàgina fins que el procés hagi acabat.",
"settings_changePasswordTitle": "Canvieu la contrasenya",
"settings_changePasswordHint": "Canvieu la contrasenya del vostre compte. Introduïu la contrasenya actual i confirmeu la nova escrivint-la dos cops.<br><b>Si l'oblideu, no podem recuperar la vostra contrasenya, aneu amb molt de compte!</b>",
"settings_changePasswordButton": "Canvia la contrasenya",
"settings_changePasswordCurrent": "Contrasenya actual",
"settings_changePasswordNew": "Nova contrasenya",
"settings_changePasswordNewConfirm": "Confirma la nova contrasenya",
"settings_changePasswordConfirm": "Segur que voleu canviar la contrasenya? Necessitareu tornar-vos a connectar en tots els dispositius.",
"settings_changePasswordError": "Hi ha hagut una errada inesperada. Si no podeu iniciar la sessió o canviar la contrasenya, contacteu l'administració de CryptPad.",
"settings_changePasswordPending": "S'està actualitzant la contrasenya. Si us plau, no tanqueu ni carregueu de nou la pàgina fins que el procés s'hagi acabat.",
"settings_changePasswordNewPasswordSameAsOld": "La contrasenya nova cal que sigui diferent de l'actual.",
"settings_cursorColorTitle": "Color del cursor",
"settings_cursorColorHint": "Canvieu el color associat al vostre compte en els documents col·laboratius.",
"settings_cursorShareTitle": "Comparteix la posició del meu cursor",
"settings_cursorShareHint": "Podeu decidir si, als documents col·laboratius, voleu que la resta de persones vegin el vostre cursor.",
"settings_cursorShareLabel": "Comparteix la posició",
"settings_cursorShowTitle": "Mostra la posició del cursor de la resta",
"settings_cursorShowHint": "Podeu triar si, als documents col·laboratius, voleu veure el cursor de les altres persones.",
"settings_cursorShowLabel": "Mostra els cursors",
"upload_title": "Carrega fitxer",
"upload_type": "Tipus",
"upload_modal_title": "Opcions per carregar fitxers",
"upload_modal_filename": "Nom del fitxer (extensió <em>{0}</em> afegit automàticament)",
"upload_modal_owner": "Fitxer propi",
"uploadFolder_modal_title": "Opcions per carregar carpetes",
"uploadFolder_modal_filesPassword": "Fitxers de contrasenya",
"uploadFolder_modal_owner": "Fitxers propis",
"uploadFolder_modal_forceSave": "Deseu fitxers al vostre CryptDrive",
"upload_serverError": "Errada interna: ara mateix és impossible carregar el fitxer.",
"upload_uploadPending": "Ja teniu una càrrega en marxa. Voleu cancel·lar-la i carregar aquest altre fitxer?",
"upload_success": "El fitxer ({0}) ha estat carregat correctament i afegit al vostre CryptDrive.",
"upload_notEnoughSpace": "No hi ha prou espai al CryptDrive per aquest fitxer.",
"upload_notEnoughSpaceBrief": "No hi ha prou espai",
"upload_tooLarge": "Aquest fitxer supera la mida màxima permesa.",
"upload_tooLargeBrief": "El fitxer és massa gran",
"upload_choose": "Trieu un fitxer",
"upload_pending": "Pendent",
"upload_cancelled": "Cancel·lat",
"upload_name": "Nom del fitxer",
"upload_size": "Mida",
"upload_progress": "Procés",
"upload_mustLogin": "Cal que inicieu la sessió per carregar un fitxer",
"upload_up": "Carrega",
"download_button": "Desxifra i descarrega",
"download_mt_button": "Descarrega",
"home_ngi": "Guanyador del premi NGI",
"home_host_agpl": "CryptPad es distribueix sota els termes de la llicència de programari AGPL3",
"home_host": "Aquesta és una instància comunitària independent de CryptPad. El codi font està disponible<a href=\"https://github.com/xwiki-labs/cryptpad\" target=\"_blank\" rel=\"noreferrer noopener\">a GitHub</a>.",
"home_product": "CryptPad és una alternativa, respectuosa amb la privacitat, a les utilitats d'oficina i els serveis al núvol. Tot el contingut desat a CryptPad es xifra abans de ser enviat, això vol dir que ningú pot accedir a les vostres dades sense que li doneu les claus (fins i tot nosaltres).",
"mdToolbar_toc": "Taula de continguts",
"mdToolbar_code": "Codi",
"mdToolbar_check": "Llista de tasques",
"mdToolbar_list": "Llista de vinyetes",
"mdToolbar_nlist": "Llista ordenada",
"mdToolbar_quote": "Cita",
"mdToolbar_link": "Enllaç",
"mdToolbar_heading": "Capçalera",
"mdToolbar_strikethrough": "Tatxat",
"mdToolbar_italic": "Cursiva",
"mdToolbar_bold": "Negreta",
"mdToolbar_tutorial": "https://ca.wikipedia.org/wiki/Markdown",
"mdToolbar_help": "Suport",
"mdToolbar_defaultText": "El vostre text",
"mdToolbar_button": "Mostra o amaga la barra d'eines de Markdown",
"pad_base64": "Aquest document conté imatges emmagatzemades de forma ineficient. Aquestes imatges augmenten significativament la mida del document al CryptDrive i fa que la càrrega sigui més lenta. Podeu migrar les imatges a un format diferent perquè es guardin per separat al CryptDrive. Voleu migrar ara aquestes imatges?",
"todo_markAsIncompleteTitle": "Marca la tasca com incompleta",
"pad_hideToolbar": "Amaga la barra d'eines",
"pad_showToolbar": "Mostra la barra d'eines",
"todo_removeTaskTitle": "Elimina la tasca del llistat",
"todo_markAsCompleteTitle": "Marca la tasca com a completada",
"todo_newTodoNameTitle": "Afegiu la tasca al llistat",
"todo_newTodoNamePlaceholder": "Descriviu la tasca...",
"todo_title": "CryptTasques",
"download_step2": "Desxifrant",
"download_step1": "Descarregant",
"download_dl": "Descarrega",
"download_resourceNotAvailable": "El recurs sol·licitat no estava disponible... Premeu Esc per continuar."
}

@ -12,7 +12,7 @@
"media": "Medien",
"todo": "Aufgaben",
"contacts": "Kontakte",
"sheet": "Tabelle (Beta)",
"sheet": "Tabelle",
"teams": "Teams"
},
"button_newpad": "Neues Rich-Text-Pad",
@ -92,7 +92,7 @@
"exportButtonTitle": "Exportiere dieses Pad in eine lokale Datei",
"exportPrompt": "Wie möchtest du die Datei nennen?",
"changeNamePrompt": "Ändere deinen Namen (oder lasse dieses Feld leer, um anonym zu bleiben): ",
"user_rename": "Bearbeite deinen Anzeigename",
"user_rename": "Anzeigename ändern",
"user_displayName": "Anzeigename",
"user_accountName": "Kontoname",
"clickToEdit": "Zum Bearbeiten klicken",
@ -179,7 +179,6 @@
"okButton": "OK (Enter)",
"cancel": "Abbrechen",
"cancelButton": "Abbrechen (Esc)",
"doNotAskAgain": "Nicht mehr fragen (Esc)",
"show_help_button": "Hilfe anzeigen",
"hide_help_button": "Hilfe verbergen",
"help_button": "Hilfe",
@ -275,11 +274,11 @@
"profile_description": "Beschreibung",
"profile_fieldSaved": "Neuer Wert gespeichert: {0}",
"profile_viewMyProfile": "Mein Profil anzeigen",
"userlist_addAsFriendTitle": "Benutzer \"{0}\" eine Freundschaftsanfrage senden",
"userlist_addAsFriendTitle": "Benutzer \"{0}\" eine Kontaktanfrage senden",
"contacts_title": "Kontakte",
"contacts_addError": "Fehler bei dem Hinzufügen des Kontakts zur Liste",
"contacts_added": "Verbindungseinladung angenommen.",
"contacts_rejected": "Verbindungseinladung abgelehnt",
"contacts_added": "Kontaktanfrage akzeptiert.",
"contacts_rejected": "Kontaktanfrage abgelehnt",
"contacts_request": "Benutzer <em>{0}</em> möchte dich als Kontakt hinzufügen. <b>Annehmen<b>?",
"contacts_send": "Senden",
"contacts_remove": "Diesen Kontakt entfernen",
@ -515,8 +514,8 @@
"settings_creationSkipFalse": "Anzeigen",
"settings_templateSkip": "Wahl der Vorlage überspringen",
"settings_templateSkipHint": "Wenn du ein neues Pad erstellst und passende Vorlagen vorhanden sind, erscheint ein Dialog zur Auswahl einer Vorlage. Hier kannst du diesen Dialog überspringen und somit keine Vorlage verwenden.",
"settings_ownDriveTitle": "Aktiviere die neuesten Funktionen für dein Konto",
"settings_ownDriveHint": "Aus technischen Gründen sind nicht alle neue Funktionen für ältere Konten verfügbar. Ein kostenloses Upgrade wird dein CryptDrive für zukünftige Funktionen vorbereiten, ohne deine Arbeit zu stören.",
"settings_ownDriveTitle": "Account aktualisieren",
"settings_ownDriveHint": "Aus technischen Gründen sind nicht alle neue Funktionen für ältere Konten verfügbar. Eine kostenlose Aktualisierung wird die neuen Funktionen aktivieren und dein CryptDrive für zukünftige Aktualisierungen vorbereiten.",
"settings_ownDriveButton": "Upgrade deines Kontos",
"settings_ownDriveConfirm": "Das Upgrade deines Kontos kann einige Zeit dauern. Du wirst dich auf allen Geräten neu einloggen müssen. Bist du sicher?",
"settings_ownDrivePending": "Das Upgrade deines Kontos läuft. Bitte schließe die Seite nicht und lade sie nicht neu, bis dieser Vorgang abgeschlossen ist.",
@ -664,7 +663,7 @@
"features_f_social": "Soziale Anwendungen",
"features_f_social_note": "Ein Profil erstellen, ein Profilbild verwenden, mit Kontakten chatten",
"features_f_file1": "Dateien hochladen und teilen",
"features_f_file1_note": "Dateien mit Freunden teilen oder sie in Dokumenten einbetten",
"features_f_file1_note": "Dateien mit Kontakten teilen oder sie in Dokumenten einbetten",
"features_f_storage1": "Langfristige Speicherung (50 MB)",
"features_f_storage1_note": "Dateien in deinem CryptDrive werden nicht wegen Inaktivität gelöscht",
"features_f_register": "Registrieren (kostenlos)",
@ -767,7 +766,7 @@
"a": "Registrierte Benutzer können Funktionen verwenden, die anonyme Nutzer nicht verwenden können. Es gibt <a href='/features.html' target='_blank'>hier</a> eine entsprechende Übersicht."
},
"share": {
"q": "Wie kann ich den Zugang zu einem verschlüsselten Pad mit Freunden teilen?",
"q": "Wie kann ich den Zugang zu einem verschlüsselten Pad mit Kontakten teilen?",
"a": "CryptPad fügt den geheimen Schlüssel deines Pad nach dem Zeichen <em>#</em> zur URL hinzu. Alles, was nach diesem Zeichen kommt, wird nicht zum Server gesendet. Also haben wir nie Zugang zu deinen Schlüsseln. Wenn du den Link zu einem Pad teilst, teilst du auch die Fähigkeit zum Lesen und zum Bearbeiten."
},
"remove": {
@ -799,7 +798,7 @@
"title": "Andere Fragen",
"pay": {
"q": "Wieso soll ich zahlen, wenn so viele Funktionen sowieso kostenfrei sind?",
"a": "Wir geben Unterstützern zusätzlichen Speicherplatz sowie die Möglichkeit, die Speicherplatzbegrenzung ihrer Freunde zu erhöhen (<a href='https://accounts.cryptpad.fr/#/faq' target='_blank'>erfahre mehr</a>).<br><br> Über diese diese kurzfristigen Vorteile hinaus kannst du, wenn du ein Premiumangebot annimmst, die aktive Weiterentwicklung von CryptPad fördern. Das beinhaltet, Fehler zu beseitigen, neue Funktionen zu umzusetzen und Installationen von CryptPad auf eigenen Servern zu erleichtern. Zusätzlich hilfst du, anderen Anbietern zu beweisen, dass Leute datenschutzfreundliche Technologien unterstützen. Wir hoffen, dass Geschäftsmodelle, die auf dem Verkauf von Benutzerdaten basieren, letztendlich der Vergangenheit angehören werden.<br><br>Außerdem glauben wir, dass es gut ist, die Funktionen von CryptPad kostenfrei anzubieten. Denn jeder verdient persönlichen Datenschutz und nicht nur Personen mit hohem Einkommen. Durch deine Unterstützung hilfst du uns, zu ermöglichen, dass auch Menschen mit geringerem Einkommen diese grundlegenden Funktionen genießen können, ohne dass ein Preisetikett daran klebt."
"a": "Wir geben Unterstützern zusätzlichen Speicherplatz sowie die Möglichkeit, die Speicherplatzbegrenzung ihrer Kontakte zu erhöhen (<a href='https://accounts.cryptpad.fr/#/faq' target='_blank'>erfahre mehr</a>).<br><br>Über diese diese kurzfristigen Vorteile hinaus kannst du, wenn du ein Premiumangebot annimmst, die aktive Weiterentwicklung von CryptPad fördern. Das beinhaltet, Fehler zu beseitigen, neue Funktionen zu umzusetzen und Installationen von CryptPad auf eigenen Servern zu erleichtern. Zusätzlich hilfst du, anderen Anbietern zu beweisen, dass Leute datenschutzfreundliche Technologien unterstützen. Wir hoffen, dass Geschäftsmodelle, die auf dem Verkauf von Benutzerdaten basieren, letztendlich der Vergangenheit angehören werden.<br><br>Außerdem glauben wir, dass es gut ist, die Funktionen von CryptPad kostenfrei anzubieten. Denn jeder verdient persönlichen Datenschutz und nicht nur Personen mit hohem Einkommen. Durch deine Unterstützung hilfst du uns, zu ermöglichen, dass auch Menschen mit geringerem Einkommen diese grundlegenden Funktionen genießen können, ohne dass ein Preisetikett daran klebt."
},
"goal": {
"q": "Was ist euer Ziel?",
@ -860,7 +859,7 @@
"colors": "Ändere Text- und Hintergrundfarbe mit den Schaltflächen <span class=\"fa fa-i-cursor\"></span> und <span class=\"fa fa-square\"></span>"
},
"poll": {
"decisions": "Treffe Entscheidungen gemeinsam mit deinen Bekannten",
"decisions": "Treffe Entscheidungen gemeinsam mit deinen Kontakten",
"options": "Mache Vorschläge und teile deine Präferenzen mit",
"choices": "Klicke in die Zellen in deiner Spalte, um zwischen ja (<strong>✔</strong>), viellecht (<strong>~</strong>), oder nein (<strong>✖</strong>) zu wählen",
"submit": "Klicke auf <strong>Senden</strong>, damit deine Auswahl für andere sichtbar wird"
@ -878,7 +877,7 @@
},
"driveReadmeTitle": "Was ist CryptPad?",
"readme_welcome": "Willkommen zu CryptPad!",
"readme_p1": "Willkommen zu CryptPad, hier kannst du deine Notizen aufschreiben, allein oder mit Bekannten.",
"readme_p1": "Willkommen zu CryptPad, hier kannst du deine Notizen aufschreiben, allein oder mit Kontakten.",
"readme_p2": "Dieses Dokument gibt dir einen kurzen Überblick, wie du CryptPad verwenden kannst, um Notizen zu schreiben, sie zu organisieren und mit anderen zusammen zu arbeiten.",
"readme_cat1": "Lerne dein CryptDrive kennen",
"readme_cat1_l1": "Ein Pad erstellen: Klicke in deinem CryptDrive auf {0} und dann auf {1}.",
@ -911,7 +910,7 @@
"feedback_about": "Wenn du das liest, fragst du dich wahrscheinlich, weshalb dein Browser bei der der Ausführung mancher Aktionen Anfragen an Webseiten sendet",
"feedback_privacy": "Wir respektieren deine Datenschutz, aber gleichzeitig wollen wir, dass die Benutzung von CryptPad sehr leicht ist. Deshalb wollen wir erfahren, welche Funktion am wichtigsten für unsere Benutzer ist, indem wir diese mit einer genauen Parameterbeschreibung anfordern.",
"feedback_optout": "Wenn du das nicht möchtest, kannst du es in <a href='/settings/'>deinen Einstellungen</a> deaktivieren",
"creation_404": "Dieses Pad existiert nicht mehr. Benutze das folgende Formular, um ein neues Pad zu gestalten.",
"creation_404": "Dieses Pad existiert nicht mehr. Benutze das folgende Formular, um ein neues Pad zu erstellen.",
"creation_ownedTitle": "Pad-Typ",
"creation_owned": "Eigenes Pad",
"creation_ownedTrue": "Eigenes Pad",
@ -931,7 +930,6 @@
"creation_noTemplate": "Keine Vorlage",
"creation_newTemplate": "Neue Vorlage",
"creation_create": "Erstellen",
"creation_saveSettings": "Dieses Dialog nicht mehr anzeigen",
"creation_settings": "Mehr Einstellungen anzeigen",
"creation_rememberHelp": "Gehe zu deinen Einstellungen, um diese Auswahl zurückzusetzen",
"creation_owners": "Eigentümer",
@ -998,7 +996,6 @@
"crowdfunding_popup_text": "<h3>Wir brauchen deine Hilfe!</h3>Um sicherzustellen, dass CryptPad weiter aktiv entwickelt wird, unterstütze bitte das Projekt über die <a href=\"https://opencollective.com/cryptpad\">OpenCollective Seite</a>, wo du unsere <b>Roadmap</b> und <b>Funding-Ziele</b> lesen kannst.",
"crowdfunding_popup_yes": "OpenCollective besuchen",
"crowdfunding_popup_no": "Nicht jetzt",
"crowdfunding_popup_never": "Nicht mehr darum bitten",
"invalidHashError": "Das angeforderte Dokument hat eine ungültige URL.",
"oo_cantUpload": "Das Hochladen von Dateien ist nicht erlaubt, während andere Nutzer anwesend sind.",
"oo_uploaded": "Das Hochladen wurde abgeschlossen. Klicke auf OK zum Neuladen der Seite oder auf Abbrechen zum Fortfahren im schreibgeschützten Modus.",
@ -1053,18 +1050,17 @@
"friendRequest_later": "Später entscheiden",
"friendRequest_accept": "Akzeptieren (Enter)",
"friendRequest_decline": "Ablehnen",
"friendRequest_declined": "<b>{0}</b> hat deine Freundschaftsanfrage abgelehnt",
"friendRequest_accepted": "<b>{0}</b> hat deine Freundschaftsanfrage akzeptiert",
"friendRequest_received": "<b>{0}</b> möchte mit dir befreundet sein",
"friendRequest_notification": "<b>{0}</b> hat dir eine Freundschaftsanfrage geschickt",
"friendRequest_declined": "<b>{0}</b> hat deine Kontaktanfrage abgelehnt",
"friendRequest_accepted": "<b>{0}</b> hat deine Kontaktanfrage akzeptiert",
"friendRequest_received": "<b>{0}</b> möchte dein Kontakt sein",
"friendRequest_notification": "<b>{0}</b> hat dir eine Kontaktanfrage geschickt",
"notifications_empty": "Keine Benachrichtigungen verfügbar",
"notifications_title": "Du hast ungelesene Benachrichtigungen",
"profile_addDescription": "Beschreibung hinzufügen",
"profile_editDescription": "Deine Beschreibung bearbeiten",
"profile_addLink": "Link zu deiner Website hinzufügen",
"profile_info": "Andere Nutzer können dein Profil finden, indem sie auf deinen Avatar in der Benutzerliste eines Dokumentes klicken.",
"profile_friendRequestSent": "Freundschaftsanfrage gesendet...",
"profile_friend": "{0} ist mit dir befreundet",
"profile_friendRequestSent": "Kontaktanfrage gesendet...",
"notification_padShared": "{0} hat ein Pad mit dir geteilt: <b>{1}</b>",
"notification_fileShared": "{0} hat eine Datei mit dir geteilt: <b>{1}</b>",
"notification_folderShared": "{0} hat einen Ordner mit dir geteilt: <b>{1}</b>",
@ -1075,7 +1071,7 @@
"share_deselectAll": "Alle abwählen",
"notifications_dismiss": "Verbergen",
"fm_info_sharedFolderHistory": "Dies ist nur der Verlauf deines geteilten Ordners: <b>{0}</b><br/>Dein CryptDrive bleibt beim Navigieren im Nur-Lesen-Modus.",
"share_description": "Wähle aus, was du teilen möchtest. Dir wird dann ein entsprechender Link anzeigt. Du kannst es auch direkt an deine Freunde in CryptPad senden.",
"share_description": "Wähle aus, was du teilen möchtest. Dir wird dann ein entsprechender Link anzeigt. Du kannst es auch direkt an deine Kontakte in CryptPad senden.",
"fc_expandAll": "Alle ausklappen",
"fc_collapseAll": "Alle einklappen",
"fc_color": "Farbe ändern",
@ -1100,7 +1096,7 @@
"support_formMessage": "Gib deine Nachricht ein...",
"support_cat_tickets": "Vorhandene Tickets",
"support_listTitle": "Support-Tickets",
"support_listHint": "Hier ist die Liste der an die Administratoren gesendeten Tickets und der dazugehörigen Antworten. Ein geschlossenes Ticket kann nicht wieder geöffnet werden, du musst ein Ticket eröffnen. Du kannst geschlossene Tickets ausblenden, aber sie werden weiterhin für die Administratoren sichtbar sein.",
"support_listHint": "Hier ist die Liste der an die Administratoren gesendeten Tickets und der dazugehörigen Antworten. Ein geschlossenes Ticket kann nicht wieder geöffnet werden, aber du kannst ein neues Ticket eröffnen. Du kannst geschlossene Tickets ausblenden.",
"support_answer": "Antworten",
"support_close": "Ticket schließen",
"support_remove": "Ticket entfernen",
@ -1111,7 +1107,7 @@
"notificationsPage": "Benachrichtigungen",
"openNotificationsApp": "Benachrichtigungspanel öffnen",
"notifications_cat_all": "Alle",
"notifications_cat_friends": "Freundschaftsanfragen",
"notifications_cat_friends": "Kontaktanfragen",
"notifications_cat_pads": "Mit mir geteilt",
"notifications_cat_archived": "Verlauf",
"notifications_dismissAll": "Alle verbergen",
@ -1119,8 +1115,6 @@
"requestEdit_button": "Bearbeitungsrechte anfragen",
"requestEdit_dialog": "Bist du sicher, dass du den Eigentümer um Bearbeitungsrechte für das Pad bitten möchtest?",
"requestEdit_confirm": "{1} hat Bearbeitungsrechte für das Pad <b>{0}</b> angefragt. Möchtest du die Rechte vergeben?",
"requestEdit_fromFriend": "Du bist mit {0} befreundet",
"requestEdit_fromStranger": "Du bist <b>nicht</b> mit {0} befreundet",
"requestEdit_viewPad": "Pad in neuem Tab öffnen",
"later": "Später entscheiden",
"requestEdit_request": "{1} möchte das Pad <b>{0}</b> bearbeiten",
@ -1155,7 +1149,7 @@
"owner_add": "{0} möchte ein Eigentümer des Pads <b>{1}</b> sein. Bist du damit einverstanden?",
"owner_removeText": "Einen Eigentümer entfernen",
"owner_removePendingText": "Eine ausstehende Einladung zurückziehen",
"owner_addText": "Einen Freund zur Mit-Eigentümerschaft einladen",
"owner_addText": "Einen Kontakt zur Mit-Eigentümerschaft einladen",
"owner_removePendingButton": "Ausgewählte Einladungen zurückziehen",
"owner_addButton": "Zur Eigentümerschaft einladen",
"owner_addConfirm": "Mit-Eigentümer können den Inhalt bearbeiten und dich als Eigentümer entfernen. Bist du sicher?",
@ -1166,8 +1160,7 @@
"owner_removedPending": "{0} hat die Einladung zur Eigentümerschaft von <b>{1}</b> zurückgezogen",
"share_linkTeam": "Zu Team-Drive hinzufügen",
"team_inviteModalButton": "Einladen",
"team_pickFriends": "Freunde auswählen, um sie in dieses Team einzuladen",
"team_noFriend": "Du bist derzeit mit keinen Freunden auf CryptPad verbunden.",
"team_pickFriends": "Kontakte auswählen, um sie in dieses Team einzuladen",
"team_pcsSelectLabel": "Speichern in",
"team_pcsSelectHelp": "Die Erstellung eines eigenen Pads im Drive deines Teams gibt die Eigentümerschaft an das Team.",
"team_invitedToTeam": "{0} hat dich zum Team eingeladen: <b>{1}</b>",
@ -1189,7 +1182,7 @@
"team_rosterPromote": "Befördern",
"team_rosterDemote": "Degradieren",
"team_rosterKick": "Aus dem Team entfernen",
"team_inviteButton": "Freunde einladen",
"team_inviteButton": "Kontakte einladen",
"team_leaveButton": "Dieses Team verlassen",
"team_leaveConfirm": "Wenn du dieses Team verlässt, verlierst du den Zugriff auf das dazugehörige CryptDrive, den Chatverlauf und andere Inhalte. Bist du sicher?",
"team_owner": "Eigentümer",
@ -1294,5 +1287,15 @@
"oo_exportInProgress": "Export wird durchgeführt",
"oo_sheetMigration_loading": "Deine Tabelle wird auf die neueste Version aktualisiert",
"oo_sheetMigration_complete": "Eine aktualisierte Version ist verfügbar. Klicke auf OK, um neu zu laden.",
"oo_sheetMigration_anonymousEditor": "Die Bearbeitung dieser Tabelle ist für anonyme Benutzer deaktiviert, bis sie von einem registrierten Benutzer auf die neueste Version aktualisiert wird."
"oo_sheetMigration_anonymousEditor": "Die Bearbeitung dieser Tabelle ist für anonyme Benutzer deaktiviert, bis sie von einem registrierten Benutzer auf die neueste Version aktualisiert wird.",
"imprint": "Impressum",
"isContact": "{0} ist einer deiner Kontakte",
"isNotContact": "{0} ist <b>nicht</b> einer deiner Kontakte",
"settings_cat_security": "Vertraulichkeit",
"settings_safeLinksHint": "CryptPad fügt den Pad-Links die Schlüssel zum Entschlüsseln der Inhalte hinzu. Jeder, der Zugriff auf den Browserverlauf hat, kann möglicherweise die Daten lesen. Dazu gehören Browsererweiterungen und Browser, die den Verlauf geräteübergreifend synchronisieren. Die Aktivierung von \"sicheren Links\" verhindert, dass die Schlüssel in den Browserverlauf gelangen oder in der Adressleiste angezeigt werden, wann immer dies möglich ist. Wir empfehlen dringend, diese Funktion zu aktivieren und das Menü {0} Teilen zu verwenden.",
"dontShowAgain": "Nicht mehr anzeigen",
"profile_login": "Du musst dich einloggen, um diesen Benutzer zu deinen Kontakten hinzuzufügen",
"safeLinks_error": "Dieser Link gibt dir keinen Zugriff auf das Dokument",
"settings_safeLinksCheckbox": "Sichere Links aktivieren",
"settings_safeLinksTitle": "Sichere Links"
}

@ -151,7 +151,6 @@
"okButton": "OK (enter)",
"cancel": "Ακύρωση",
"cancelButton": "Ακύρωση (esc)",
"doNotAskAgain": "Να μην ρωτηθώ ξανά (Esc)",
"historyText": "Ιστορικό",
"historyButton": "Εμφάνιση ιστορικού του εγγράφου",
"history_next": "Μετάβαση στην επόμενη έκδοση",

@ -479,7 +479,6 @@
"slide_invalidLess": "Estilo personalizado no válido",
"fileShare": "Copiar link",
"ok": "OK",
"doNotAskAgain": "No preguntar nuevamente (Esc)",
"show_help_button": "Mostrar ayuda",
"hide_help_button": "Esconder ayuda",
"help_button": "Ayuda",

@ -11,7 +11,7 @@
"media": "Media",
"todo": "Tehtävälista",
"contacts": "Yhteystiedot",
"sheet": "Taulukko (Beta)",
"sheet": "Taulukko",
"teams": "Teams"
},
"button_newpad": "Uusi Teksti-padi",
@ -184,7 +184,6 @@
"okButton": "OK (Enter)",
"cancel": "Keskeytä",
"cancelButton": "Keskeytä (Esc)",
"doNotAskAgain": "Älä kysy uudestaan (Esc)",
"show_help_button": "Näytä ohje",
"hide_help_button": "Piilota ohje",
"help_button": "Ohje",
@ -619,7 +618,7 @@
"about_intro": "CryptPadia kehittää Pariisissa, Ranskassa ja Iasissa, Romaniassa toimiva<a href=\"http://xwiki.com\">XWiki SAS</a>-pienyrityksen tutkimusryhmä. CryptPadin parissa työskentelee kolme ryhmän ydinjäsentä ja lisäksi joitakin avustajia XWiki SAS:n sisältä ja ulkopuolelta.",
"about_core": "Ydinkehittäjät",
"about_contributors": "Tärkeät avustajat",
"main_info": "<h2>Luottamuksellista yhteistyötä</h2>Jaetut dokumentit mahdollistavat ideoiden jakamisen samalla kun <strong>nollatietoperiaate</strong>-teknologia suojaa yksityisyytesi - <strong>jopa meiltä</strong>.",
"main_info": "<h2>Luottamuksellista yhteistyötä</h2> Jaa ideoita yhdessä jaettujen dokumenttien avulla.<strong>Nollatieto</strong>-teknologia turvaa yksityisyytesi - <strong>jopa meiltä</strong>.",
"main_catch_phrase": "Pilvipalvelu nollatietoperiaatteella",
"main_footerText": "CryptPadin avulla voit nopeasti luoda kollaboratiivisia dokumentteja muistiinpanoja ja yhteistä ideointia varten.",
"footer_applications": "Sovellukset",
@ -665,8 +664,6 @@
"requestEdit_button": "Pyydä muokkausoikeutta",
"requestEdit_dialog": "Haluatko varmasti pyytää padin omistajalta muokkausoikeutta?",
"requestEdit_confirm": "{1} on pyytänyt oikeutta muokata padia <b>{0}</b>. Haluatko myöntää muokkausoikeuden?",
"requestEdit_fromFriend": "Olet kaveri käyttäjän {0} kanssa",
"requestEdit_fromStranger": "<b>Et ole</b> käyttäjän {0} kaveri",
"requestEdit_viewPad": "Avaa padi uudessa välilehdessä",
"later": "Päätä myöhemmin",
"requestEdit_request": "{1} haluaa muokata padia <b>{0}</b>",
@ -695,7 +692,6 @@
"share_linkTeam": "Lisää tiimin CryptDriveen",
"team_pickFriends": "Valitse tiimiin kutsuttavat kaverit",
"team_inviteModalButton": "Kutsu",
"team_noFriend": "Sinulla ei ole vielä kavereita CryptPadissa.",
"drive_sfPassword": "Jaettu kansiosi {0} ei ole enää saatavilla. Se on joko poistettu omistajansa toimesta tai sille on asetettu uusi salasana. Voit poistaa tämän kansion CryptDrivestasi tai palauttaa käyttöoikeuden käyttämällä uutta salasanaa.",
"drive_sfPasswordError": "Väärä salasana",
"password_error_seed": "Padia ei löytynyt!<br>Tämä virhe voi johtua kahdesta syystä: joko padiin on lisätty tai vaihdettu salasana, tai padi on poistettu palvelimelta.",
@ -876,8 +872,198 @@
"keywords": {
"title": "Avainsanat",
"pad": {
"q": "Mikä on padi?"
"q": "Mikä on padi?",
"a": "<em>Padi</em> on <a href='http://etherpad.org/' target='_blank'>Etherpad-projektin</a> popularisoima termi reaaliaikaiselle kollaboratiiviselle editorille.\nSe tarkoittaa selaimessa muokattavaa dokumenttia, jossa muiden käyttäjien tekemät muutokset näkyvät lähes välittömästi."
},
"owned": {
"q": "Mikä on omistettu padi?",
"a": "<em>Omistettu padi</em> on padi, jolla on erityisesti määritelty <em>omistaja</em>, jonka palvelin tunnistaa <em>julkisen salausavaimen</em> perusteella. Padin omistaja voi poistaa omistamansa padit palvelimelta, jolloin muut yhteiskäyttäjät eivät voi enää käyttää niitä riippumatta siitä, olivatko ne tallennettuna heidän henkilökohtaisiin CryptDriveihinsa."
},
"expiring": {
"q": "Mikä on vanheneva padi?",
"a": "<em>Vanheneva padi</em> on padi, jolle on määritelty vanhenemisajankohta, jolloin padi poistetaan automaattisesti palvelimelta. Vanhenevat padit voidaan määritellä säilymään minkä tahansa ajan yhdestä tunnista 100 kuukauteen. Vanheneva padi ja sen historia muuttuvat vanhenemishetkellä pysyvästi käyttökelvottomiksi, vaikka padia muokattaisiinkin silloin.<br><br>Jos padi on määritelty vanhenevaksi, voit tarkastaa sen vanhenemisajan padin <em>ominaisuuksista</em> joko CryptDrivessa padin kohdalla hiiren oikealla painikkeella aukeavasta valikosta tai käyttämällä <em>Ominaisuudet-valikkoa</em> sovelluksen työkalupalkista."
},
"tag": {
"q": "Miten voin käyttää tunnisteita?",
"a": "Voit lisätä padeihin ja ladattuihin tiedostoihin tunnisteita CryptDrivessa tai käyttää <em>Tunniste</em>-painiketta (<span class='fa fa-hashtag'></span>) minkä tahansa editorin työkalupalkista. Hae padeja ja tiedostoja CryptDriven hakupalkista käyttämällä ristikkomerkillä alkavaa hakusanaa (esimerkiksi <em>#crypto</em>)."
},
"template": {
"q": "Mikä on mallipohja?",
"a": "Mallipohja on padi, jolla voit määritellä luotavan padin oletussisällön luodessasi toista samantyyppistä padia. Voit muuttaa minkä tahansa olemassaolevan padin mallipohjaksi siirtämällä sen <em>Mallipohjat</em>-osastoon CryptDrivessasi. Voit myös tehdä padista mallipohjana käytettävän kopion klikkaamalla Mallipohja-painiketta (<span class='fa fa-bookmark'></span>) editorin työkalupalkista."
},
"abandoned": {
"q": "Mikä on hylätty padi?",
"a": "<em>Hylätty padi</em> on padi, jota ei ole kiinnitetty yhdenkään rekisteröityneen käyttäjän CryptDriveen ja jota ei ole muokattu kuuteen kuukauteen. Hylätyt dokumentit poistetaan palvelimelta automaattisesti."
}
},
"privacy": {
"title": "Yksityisyys",
"different": {
"q": "Miten CryptPad eroaa muista padeja tarjoavista palveluista?",
"a": "CryptPad salaa padeihin tekemäsi muutokset ennen niiden lähettämistä palvelimelle, joten emme voi lukea, mitä kirjoitat."
},
"me": {
"q": "Mitä palvelin tietää minusta?",
"a": "Palvelimen ylläpitäjät näkevät CryptPadia käyttävien ihmisten IP-osoitteet. Emme pidä kirjaa siitä, mitkä osoitteet vierailevat missäkin padeissa. Tämä olisi kuitenkin teknisesti mahdollista, vaikka emme pääsekään tarkastelemaan padien salaamatonta sisältöä. Jos pelkäät meidän analysoivan näitä tietoja, on parasta olettaa meidän keräävän niitä, sillä emme voi todistaa, ettemme tee niin.<br><br>Keräämme käyttäjiltämme joitakin perustason telemetriatietoja, kuten käytetyn laitteen näytön koon ja tietoja useimmin käytetyistä painikkeista. Nämä auttavat meitä parantamaan CryptPadia, mutta jos et halua lähettää telemetriatietoja CryptPadille, voit <strong>jättäytyä pois tietojen keräämisestä ottamalla rastin pois <em>Salli käyttäjäpalaute</em>-ruudusta</strong>.<br><br>Pidämme kirjaa siitä, mitä padeja käyttäjät säilyttävät CryptDriveissaan pystyäksemme asettamaan tallennustilarajoituksia. Emme kuitenkaan tiedä näiden padien tyyppiä tai sisältöä. Tallennustilakiintiöt määritellään käyttäjien julkisten salausavainten perusteella, mutta emme yhdistä käyttäjien nimiä tai sähköpostiosoitteita näihin avaimiin.<br><br>Saadaksesi lisätietoja aiheesta voit tutustua kirjoittamaamme <a href='https://blog.cryptpad.fr/2017/07/07/cryptpad-analytics-what-we-cant-know-what-we-must-know-what-we-want-to-know/' target='_blank'>blogikirjoitukseen</a>."
},
"register": {
"q": "Jos rekisteröidyn, tietääkö palvelin minusta enemmän?",
"a": "Emme vaadi käyttäjiltä sähköpostiosoitteen vahvistusta, eikä palvelin saa tietää rekisteröitymisen yhteydessä edes käyttäjänimeäsi tai salasanaasi. Sen sijaan rekisteröitymis- ja sisäänkirjautumislomakkeet luovat antamastasi syötteestä uniikin avainrenkaan, ja palvelin saa tietoonsa ainoastaan kryptografisen allekirjoituksesi. Käytämme tätä tietoa yksityiskohtien, kuten tallennustilan käytön valvomiseen ja siten tallennustilakiintiöiden ylläpitämiseen.<br><br>Käytämme <em>palaute</em>-toimintoa kertoaksemme palvelimelle, että IP-osoitteestasi on luotu käyttäjätili. Tämä auttaa meitä pitämään kirjaa CryptPadiin rekisteröityneiden käyttäjien määrästä ja maantieteellisestä sijainnista, jotta voimme paremmin arvioida, mitä kieliä palvelumme kannattaisi tukea.<br><br>Rekisteröityneet käyttäjät kertovat palvelimelle, mitä padeja he säilyttävät CryptDriveissaan. Tämä on tarpeen, että kyseisiä padeja ei todeta hylätyiksi ja siten poisteta käyttämättömyyden takia."
},
"other": {
"q": "Mitä yhteistyökumppanit saavat tietää minusta?",
"a": "Muokatessasi padia jonkun toisen kanssa kaikki yhteydet kulkevat palvelimen kautta, joten vain me saamme tietää IP-osoitteesi. Muut käyttäjät näkevät näyttönimesi, avatar-kuvasi, linkin profiiliisi (jos olet luonut sellaisen) ja <em>julkisen salausavaimesi</em> (jota käytetään yhteyksien salaamiseen)."
},
"anonymous": {
"q": "Tekeekö CryptPad minusta anonyymin?",
"a": "Vaikka CryptPad on suunniteltu tietämään sinusta niin vähän kuin mahdollista, se ei tarjoa vahvaa anonymiteettisuojaa. Palvelimemme tietävät IP-osoitteesi, mutta voit halutessasi piilottaa sen käyttämällä CryptPadia Tor-verkosta. Pelkkä Tor-verkon käyttäminen ilman muutoksia verkkokäyttäytymiseesi ei takaa anonymiteettiä, sillä palvelin tunnistaa käyttäjät uniikkien salaustunnisteiden perusteella. Jos käytät samaa käyttäjätunnusta Tor-verkosta ja sen ulkopuolelta, istuntosi voidaan yhdistää sinuun.<br><br>Käyttäjille, joiden yksityisyysvaatimukset ovat matalammat - toisin kuin monet muut palvelut, CryptPad ei vaadi käyttäjiä tunnistautumaan nimellä, puhelinnumerolla tai sähköpostiosoitteella."
},
"policy": {
"q": "Onko teillä tietosuojakäytäntö?",
"a": "Kyllä! Se löytyy <a href='/privacy.html' target='_blank'>täältä</a>."
}
},
"security": {
"pad_password": {
"q": "Mitä tapahtuu, kun suojaan padin tai kansion salasanalla?",
"a": "Voit suojata minkä tahansa padin tai jaetun kansion salasanalla luodessasi sen. Voit myös käyttää Ominaisuudet-valikkoa asettaaksesi, vaihtaaksesi tai poistaaksesi salasanan milloin tahansa.<br><br>Padien ja jaettujen kansioiden salasanat on tarkoitettu suojaamaan linkkiä jakaessasi sitä mahdollisesti turvattomien kanavien, kuten sähköpostin tai tekstiviestin kautta. Jos joku onnistuu kaappaamaan linkkisi, mutta ei tiedä sen salasanaa, ei hän pääse lukemaan dokumenttiasi.<br><br>Kun jaat sisältöä CryptPadin sisällä yhteystietojesi tai tiimiesi kanssa, tiedonsiirto on salattua ja oletamme, että haluat heidän pääsevän käyttämään dokumenttiasi. Siksi salasana tallennetaan ja lähetetään padin mukana jakaessasi sitä CryptPadin sisällä. Vastaanottajalta tai sinulta itseltäsi <b>ei</b> pyydetä salasanaa dokumenttia avatessa."
},
"title": "Turvallisuus",
"proof": {
"q": "Miten käytätte nollatietotodistuksia (Zero Knowledge Proofs)?",
"a": "Käyttäessämme termiä \"nollatieto\" (Zero Knowledge) emme viittaa <em>nollatietotodistuksiin</em> (Zero Knowledge Proofs) vaan <em>nollatieto-verkkopalveluihin</em> (Zero Knowledge Web Services). Nollatieto-verkkopalvelut salaavat käyttäjän datan tämän selaimessa niin, ettei palvelin pääse missään vaiheessa käsittelemään salaamatonta dataa tai salausavaimia.<br><br>Olemme keränneet listan muista nollatietopalveluista <a href='https://blog.cryptpad.fr/2017/02/20/Time-to-Encrypt-the-Cloud/#Other-Zero-Knowledge-Services'>tänne</a>."
},
"why": {
"q": "Miksi minun kannattaisi käyttää CryptPadia?",
"a": "Mielestämme pilvipalveluiden ei tarvitse päästä lukemaan dataasi, jotta voit jakaa sen ystäviesi ja kollegoidesi kanssa. Jos käytät yhteistyöhön jotakin muuta palvelua, eikä palvelu erikseen ilmoita, ettei se pääse käsiksi tietoihisi, on hyvin todennäköistä, että tietojasi käytetään kaupallisiin tarkoituksiin."
},
"compromised": {
"q": "Suojaako CryptPad minua, jos laitteeni tietoturva on vaarantunut?",
"a": "Jos laitteesi varastetaan, CryptPad voi kirjata sinut ulos kaikista muista laitteista, paitsi nykyisestä laitteestasi. Tehdäksesi niin mene <strong>Asetukset</strong>-sivulle ja valitse <strong>Kirjaudu ulos kaikkialta</strong>. Kaikki muut tilillesi kirjautuneet aktiiviset laitteet kirjautuvat välittömästi ulos. Ne laitteet, joilla CryptPadia on käytetty aiemmin kirjautuvat ulos seuraavan sivunlatauksen yhteydessä.<br><br>Tällä hetkellä <em>etäuloskirjautuminen</em> on toteutettu selainpohjaisesti palvelimen sijaan. Näin ollen se ei suojaa sinua valtiollisilta toimijoilta, mutta on riittävä, jos unohdit kirjautua ulos CryptPadista käytettyäsi jaettua tietokonetta."
},
"crypto": {
"q": "Mitä kryptografisia menetelmiä käytätte?",
"a": "CryptPad perustuu kahteen avoimen lähdekoodin kryptografiakirjastoon: <a href='https://github.com/dchest/tweetnacl-js' target='_blank'>tweetnacl.js:n</a> ja <a href='https://github.com/dchest/scrypt-async-js' target='_blank'>scrypt-async.js:n</a>. <br><br>Scrypt on <em>salasanapohjainen avaimenmuodostusalgoritmi</em>. Käytämme sitä muuntaaksemme käyttäjätunnuksesi ja salasanasi uniikiksi avainrenkaaksi, joka turvaa pääsyn CryptDriveesi niin, että ainoastaan sinä pääset käsiksi padilistaasi. <br><br>Käytämme vastaavasti tweetnacl:n tarjoamia <em>xsalsa20-poly1305</em>- ja <em>x25519-xsalsa20-poly1305</em>-salakirjoitusjärjestelmiä salaamaan padeja ja keskusteluhistoriaa."
}
},
"usability": {
"title": "Käytettävyys",
"register": {
"q": "Mitä hyötyä rekisteröitymisestä on minulle?",
"a": "Rekisteröityneille käyttäjille on tarjolla joitakin toimintoja, jotka eivät ole saatavilla rekisteröitymättömille käyttäjille. Löydät nämä toiminnot <a href='/features.html' target='_blank'>luomastamme kaaviosta</a>."
},
"share": {
"q": "Miten jaan salattuja padeja kavereideni kanssa?",
"a": "CryptPad laittaa URL-osoitteessa padisi salaisen salausavaimen <em>#</em>-merkin jälkeen. Tämän merkin jälkeen laitettuja tietoja ei lähetetä palvelimelle, joten emme pääse koskaan käyttämään salausavaimiasi. Jakaessasi linkin padiin jaat oikeuden lukea ja käyttää sitä."
},
"remove": {
"q": "Poistin padin tai tiedoston CryptDrivestani, mutta sen sisältö on yhä käytettävissä. Miten voin poistaa sen?",
"a": "Ainoastaan <em>omistettuja padeja</em> (otettu käyttöön helmikuussa 2018) voi poistaa. Lisäksi nämä padit voi poistaa ainoastaan niiden <em>omistaja</em> eli henkilö, joka alun perin loi kyseisen padin. Jos et ole luonut kyseistä padia, joudut pyytämään sen omistajaa poistamaan sen puolestasi. Omistamiesi padien poistaminen onnistuu CryptDrivessa <strong>klikkaamalla padia hiiren oikealla painikkeella</strong> ja valitsemalla <strong>Poista palvelimelta</strong>."
},
"forget": {
"q": "Mitä tapahtuu, jos unohdan salasanani?",
"a": "Valitettavasti se, että pystyisimme palauttamaan käyttöoikeuden salattuihin padeihisi tarkoittaisi myös sitä, että pääsisimme itse käsiksi niiden sisältöön. Jos et kirjoittanut käyttäjätunnustasi ja salasanaasi ylös etkä muista kumpaakaan, voit mahdollisesti palauttaa padisi selaimesi historiaa suodattamalla."
},
"change": {
"q": "Entä jos haluan vaihtaa salasanani?",
"a": "Voit vaihtaa CryptPad-salasanasi Tilin asetukset-sivulta."
},
"devices": {
"q": "Olen kirjautunut sisään kahdella laitteella, ja näen kaksi eri CryptDrivea. Miten tämä on mahdollista?",
"a": "On todennäköistä, että olet rekisteröitynyt samalla käyttäjänimellä kahdesti eri salasanoja käyttäen. CryptPad-palvelin tunnistaa sinut kryptografisen allekirjoituksesi perusteella käyttäjänimen sijaan, joten se ei voi estää muita rekisteröitymästä samalla käyttäjänimellä. Tästä johtuen jokaisella käyttäjätilillä on ainutlaatuinen käyttäjänimen ja salasanan yhdistelmä. Sisäänkirjautuneet käyttäjät voivat nähdä käyttäjänimensä Asetukset-sivun ylälaidassa."
},
"folder": {
"q": "Voinko jakaa kokonaisia kansioita CryptDrivestani?",
"a": "Kyllä, voit luoda <em>jaetun kansion</em> CryptDrivestasi ja jakaa kerralla kaikki sen sisältämät padit."
},
"feature": {
"q": "Voitteko lisätä CryptPadiin tarvitsemani ominaisuuden?",
"a": "Monet CryptPadin ominaisuuksista ovat olemassa, koska käyttäjämme ovat toivoneet niitä. <a href='https://cryptpad.fr/contact.html' target='_blank'>Yhteystiedot-sivumme</a> kertoo, millä tavoin meihin saa yhteyden.<br><br>Valitettavasti emme voi taata, että pystymme toteuttamaan kaikki käyttäjiemme ehdotukset. Jos jokin tietty ominaisuus on kriittinen organisaatiosi kannalta, voit sponsoroida kehitystä varmistaaksesi sen toteutumisen. Ota yhteyttä osoitteeseen <a href='mailto:sales@cryptpad.fr' target='_blank'>sales@cryptpad.fr</a> saadaksesi lisätietoja.<br><br>Vaikka kehitystyön sponsorointi ei olisikaan mahdollista, olemme silti kiinnostuneita palautteesta, joka auttaa meitä parantamaan CryptPadia. Ota meihin milloin tahansa yhteyttä yllä luetelluilla tavoilla."
}
},
"other": {
"title": "Muita kysymyksiä",
"pay": {
"q": "Miksi minun täytyisi maksaa, kun niin monet toiminnot ovat ilmaisia?",
"a": "Annamme tukijoillemme lisätallennustilaa ja mahdollisuuden kasvattaa kavereiden tallennustilakiintiöitä (<a href='https://accounts.cryptpad.fr/#/faq' target='_blank'>lue lisää</a>).<br><br>Näiden lyhytaikaisten etujen lisäksi premium-tilaus auttaa rahoittamaan CryptPadin jatkuvaa, aktiivista kehitystyötä. Tähän kuuluu bugien korjaamista, uusien ominaisuuksien lisäämistä ja CryptPad-instanssien pystyttämisen ja ylläpidon helpottamista. Lisäksi autat näyttämään muille palveluntarjoajille, että ihmiset ovat valmiita tukemaan yksityisyyttä parantavia teknologioita. Toivomme, että käyttäjätietojen myymiseen perustuvat liiketoimintamallit jäävät lopulta menneeseen.<br><br>Lopuksi, tarjoamme suurimman osan CryptPadin toiminnallisuudesta ilmaiseksi, koska uskomme yksityisyyden kuuluvan kaikille - ei vain niille, joilla on varaa maksaa siitä. Tukemalla meitä autat tarjoamaan heikommassa asemassa oleville väestöille pääsyn näihin peruspalveluihin."
},
"goal": {
"q": "Mitkä ovat tavoitteenne?",
"a": "Kehittämällä yksityisyyttä kunnioittavaa kollaboraatioteknologiaa toivomme nostavamme käyttäjien odotuksia pilvipalveluiden yksityisyyden suhteen. Toivomme, että työmme rohkaisee muita palveluntarjoajia pyrkimään samaan tai parempaan lopputulokseen. Optimismistamme huolimatta tiedämme, että suuri osa webistä rahoitetaan kohdistetulla mainonnalla. Tehtävää on paljon enemmän, kuin mihin pystymme yksin - arvostamme yhteisömme tarjoamaa mainostusta, tukea ja panosta tavoitteidemme saavuttamisessa."
},
"jobs": {
"q": "Etsittekö työntekijöitä?",
"a": "Kyllä! Esittäydy meille sähköpostilla osoitteeseen <a href='mailto:jobs@xwiki.com' target='_blank'>jobs@xwiki.com</a>."
},
"host": {
"q": "Voitteko auttaa minua perustamaan oman CryptPad-instanssini?",
"a": "Tarjoamme mielellämme tukea organisaatiosi sisäiselle CryptPad-instanssille. Ota yhteyttä osoitteeseen <a href='mailto:sales@cryptpad.fr' target='_blank'>sales@cryptpad.fr</a> saadaksesi lisätietoja."
},
"revenue": {
"q": "Kuinka voin osallistua tulojen jakamiseen?",
"a": "Jos ylläpidät omaa CryptPad-instanssiasi, haluaisit ottaa käyttöön maksulliset käyttäjätilit ja jakaa tulot CryptPadin kehittäjien kanssa, palvelimesi täytyy määritellä kumppanipalveluksi.<br><br>CryptPad-asennushakemistosi <em>config.example.js</em>-tiedostosta pitäisi löytyä ohjeet tämän palvelun käyttöönottoon. Sinun tulee myös ottaa yhteyttä osoitteeseen <a href='mailto:sales@cryptpad.fr'>sales@cryptpad.fr</a> varmistaaksesi, että palvelimesi HTTPS-määritykset ovat kunnossa ja sopiaksesi käytettävistä maksutavoista."
}
}
},
"policy_howweuse_p1": "Käytämme näitä tietoja suunnitellaksemme CryptPadin mainostusta ja arvioidaksemme aiempien kampanjoiden onnistumista. Sijaintitietosi puolestaan kertovat meille, mitä kieliä CryptPadin tulisi mahdollisesti tukea englannin lisäksi.",
"tos_title": "CryptPad-käyttöehdot",
"tos_legal": "Älä ole pahantahtoinen, väärinkäyttäjä tai tee mitään laitonta.",
"tos_availability": "Toivomme sinun pitävän tätä palvelua hyödyllisenä, mutta emme voi taata sen saatavuutta tai suorituskykyä. Viethän tietosi säännöllisesti muualle talteen.",
"tos_e2ee": "CryptPad-sisältöä voi lukea tai muokata kuka tahansa, joka pystyy arvaamaan tai muuten saamaan käsiinsä padin katkelmatunnisteen. Suosittelemme käyttämään päästä päähän salattuja viestintämenetelmiä linkkien jakamiseen, emmekä ota vastuuta tilanteissa, joissa sellainen linkki pääsee vuotamaan.",
"tos_logs": "Selaimesi palvelimelle tarjoama metadata voidaan kerätä palvelun ylläpitämistä varten.",
"tos_3rdparties": "Emme luovuta yksilöityä dataa kolmansille osapuolille, ellei meillä ole lakisääteistä velvollisuutta tehdä niin.",
"four04_pageNotFound": "Etsimääsi sivua ei löytynyt.",
"updated_0_header_logoTitle": "Siirry CryptDriveesi",
"header_logoTitle": "Siirry CryptDriveesi",
"header_homeTitle": "Siirry CryptPad-kotisivulle",
"help": {
"title": "Näin pääset alkuun",
"generic": {
"more": "Tutustu <a href=\"/faq.html\" target=\"_blank\">usein kysyttyihin kysymyksiin</a> saadaksesi lisätietoja CryptPadin toiminnallisuudesta",
"share": "Käytä Jaa-valikkoa (<span class=\"fa fa-shhare-alt\"></span>) luodaksesi linkin, jonka kautta yhteistyökumppanit pääsevät katselemaan tai muokkaamaan padia",
"save": "Kaikki tekemäsi muutokset synkronoidaan automaattisesti, joten sinun ei tarvitse koskaan tallentaa"
},
"text": {
"formatting": "Voit näyttää tai piilottaa Tekstin muotoilu-työkalupalkin klikkaamalla <span class=\"fa fa-caret-down\"></span> tai <span class=\"fa fa-caret-up\"></span>-painikkeita",
"embed": "Rekisteröityneet käyttäjät voivat upottaa kuvan tai CryptDriveen tallennetun tiedoston <span class=\"fa fa-image\"></span> avulla",
"history": "Voit käyttää <em>historiaa</em> <span class=\"fa fa-history\"></span> katsellaksesi tai palauttaaksesi aiempia versioita"
},
"pad": {
"export": "Voit viedä sisältösi PDF-tiedostoon Tekstin muotoilu-työkalupalkin <span class=\"fa fa-print\"></span> -painikkeella"
},
"code": {
"modes": "Käytä <span class=\"fa fa-ellipsis-h\"></span> -alavalikon pudotusvalikoita vaihtaaksesi syntaksin korostustilaa tai väriteemoja"
},
"beta": {
"warning": "Tämä editori on edelleen <strong>koekäytössä</strong>, voit ilmoittaa löytämäsi bugit <a href=\"https://github.com/xwiki-labs/cryptpad/issues/\" target=\"_blank\">asianhallintajärjestelmäämme</a>"
},
"oo": {
"access": "Käyttö on rajattu ainoastaan rekisteröityneille käyttäjille, yhteistyökumppanien tulee kirjautua sisään"
},
"slide": {
"markdown": "Kirjoita diat <a href=\"http://www.markdowntutorial.com/\">Markdown-kielellä</a> ja erota ne toisistaan <code>---</code> -rivillä",
"present": "Aloita esitys <span class=\"fa fa-play-circle\"></span> -painikkeella",
"settings": "Muuta dian asetuksia (taustakuvaa, siirtymiä, sivunumeroita jne.) <span class=\"fa fa-ellipsis-h\"></span> -alavalikon <span class=\"fa fa-cog\"></span> -painikkeella",
"colors": "Vaihda tekstin ja taustan väriä <span class=\"fa fa-i-cursor\"></span> ja <span class=\"fa fa-square\"></span> -painikkeilla"
},
"poll": {
"decisions": "Tee päätöksiä luotettujen ystävien kesken",
"options": "Ehdota vaihtoehtoja ja tuo ilmi mielipiteesi",
"choices": "Napsauta sarakkeesi soluja valitaksesi kyllä- (<strong>✔</strong>), ehkä- (<strong>~</strong>), tai ei (<strong>✖</strong>) -vaihtoehdon",
"submit": "Napsauta <strong>Lähetä</strong> tehdäksesi valintasi näkyviksi muille"
},
"whiteboard": {
"colors": "Kaksoisnapsauta värejä muokataksesi väripalettiasi",
"mode": "Ota piirtotila pois käytöstä vetääksesi ja venyttääksesi viivoja",
"embed": "Upota kuvia kovalevyltäsi <span class=\"fa fa-file-image-o\"></span> tai CryptDrivestasi <span class=\"fa fa-image\"></span> ja vie ne PNG-tiedostomuodossa kovalevyllesi <span class=\"fa fa-download\"></span> tai CryptDriveesi <span class=\"fa fa-cloud-upload\"></span>"
},
"kanban": {
"add": "Lisää uusia tauluja oikeassa yläkulmassa olevalla <span class=\"fa fa-plus\"></span> -painikkeella",
"task": "Siirrä kohtia raahaamalla ja pudottamalla ne yhdestä taulusta toiseen",
"color": "Vaihda värejä napsauttamalla taulun otsikon vieressä olevaa värillistä osaa"
}
},
"driveReadmeTitle": "Mikä on CryptPad?",
"readme_welcome": "Tervetuloa CryptPadiin!",
"readme_p1": "Tervetuloa CryptPadiin, täällä voit tehdä muistiinpanoja yksin tai ystäviesi kanssa."
}

@ -12,7 +12,7 @@
"media": "Média",
"todo": "Todo",
"contacts": "Contacts",
"sheet": "Tableur (Beta)",
"sheet": "Tableur",
"teams": "Équipes"
},
"button_newpad": "Nouveau document texte",
@ -181,7 +181,6 @@
"okButton": "OK (Entrée)",
"cancel": "Annuler",
"cancelButton": "Annuler (Échap)",
"doNotAskAgain": "Ne plus demander (Échap)",
"show_help_button": "Afficher l'aide",
"hide_help_button": "Cacher l'aide",
"help_button": "Aide",
@ -279,7 +278,7 @@
"profile_description": "Description",
"profile_fieldSaved": "Nouvelle valeur enregistrée : {0}",
"profile_viewMyProfile": "Voir mon profil",
"userlist_addAsFriendTitle": "Envoyer une demande d'ami à « {0} »",
"userlist_addAsFriendTitle": "Envoyer une demande de contact à « {0} »",
"contacts_title": "Contacts",
"contacts_addError": "Erreur lors de l'ajout de ce contact dans votre liste",
"contacts_added": "Invitation de contact acceptée.",
@ -299,7 +298,7 @@
"contacts_confirmRemoveHistory": "Êtes-vous sûr de vouloir supprimer définitivement l'historique de votre chat ? Les messages ne pourront pas être restaurés.",
"contacts_removeHistoryServerError": "Une erreur est survenue lors de la supprimer de l'historique du chat. Veuillez réessayer plus tard.",
"contacts_fetchHistory": "Récupérer l'historique plus ancien",
"contacts_friends": "Amis",
"contacts_friends": "Contacts",
"contacts_rooms": "Salons",
"contacts_leaveRoom": "Quitter ce salon",
"contacts_online": "Un autre utilisateur est en ligne dans ce salon",
@ -522,8 +521,8 @@
"settings_creationSkipFalse": "Afficher",
"settings_templateSkip": "Passer la fenêtre de choix d'un modèle",
"settings_templateSkipHint": "Quand vous créez un nouveau pad, et si vous possédez des modèles pour ce type de pad, une fenêtre peut apparaître pour demander si vous souhaitez importer un modèle. Ici vous pouvez choisir de ne jamais montrer cette fenêtre et donc de ne jamais utiliser de modèle.",
"settings_ownDriveTitle": "Activer les dernières fonctionnalités du compte",
"settings_ownDriveHint": "Pour des raisons techniques, les comptes utilisateurs les plus anciens n'ont pas accès à toutes les fonctionnalités. Une mise à niveau gratuite permet de préparer votre CryptDrive pour les nouveautés à venir sans perturber vos activités habituelles.",
"settings_ownDriveTitle": "Mise à jour du compte",
"settings_ownDriveHint": "Les comptes plus anciens n'ont pas accès aux dernières fonctionnalités, pour des raisons techniques. Une mise à niveau gratuite permet d'activer les fonctionnalités actuelles et de préparer votre CryptDrive pour les futures mises à jour.",
"settings_ownDriveButton": "Mettre à niveau votre compte",
"settings_ownDriveConfirm": "La mise à niveau peut prendre du temps. Vous devrez vous reconnecter sur tous vos appareils. Voulez-vous continuer ?",
"settings_ownDrivePending": "Votre compte est en train d'être mis à jour. Veuillez ne pas fermer ou recharger cette page avant que le traitement soit terminé.",
@ -671,7 +670,7 @@
"features_f_social": "Applications sociales",
"features_f_social_note": "Créer un profil, utiliser un avatar, chat avec les contacts",
"features_f_file1": "Importer et partager des fichiers",
"features_f_file1_note": "Partager des fichiers avec vos amis ou les intégrer dans vos pads",
"features_f_file1_note": "Partager des fichiers avec vos contacts ou les intégrer dans vos pads",
"features_f_storage1": "Stockage permanent (50Mo)",
"features_f_storage1_note": "Les pads stockés dans votre CryptDrive ne seront jamais supprimés pour cause d'inactivité",
"features_f_register": "S'enregistrer gratuitement",
@ -774,7 +773,7 @@
"a": "Les utilisateurs enregistrés ont accès à un certain nombre de nouvelles fonctionnalités inaccessibles aux utilisateurs non connectés. Un tableau récapitulatif est disponible <a href=\"/features.html\">ici</a>."
},
"share": {
"q": "Comment partager des pads chiffrés avec mes amis ?",
"q": "Comment partager des pads chiffrés avec mes contacts ?",
"a": "CryptPad stocke la clé secrète de chiffrement des pads après le symbole <em>#</em> dans l'URL. Tout ce qui se trouve après ce symbole n'est jamais envoyé au serveur, ainsi nous n'avons pas accès à vos clés de chiffrement. Partager le lien d'un pad revient donc à permettre la lecture ou la modification du contenu."
},
"remove": {
@ -806,7 +805,7 @@
"title": "Autres questions",
"pay": {
"q": "Pourquoi payer alors que toutes les fonctionnalités sont gratuites ?",
"a": "Un compte premium permet d'<b>augmenter la limite de stockage</b> dans le CryptDrive, ainsi que celle de ses amis (<a href=\"https://accounts.cryptpad.fr/#/faq\" target=\"_blank\">en savoir plus</a>).<br>En plus de ces avantages directs, l'abonnement premium permet aussi de <b>financer le développement</b> actif et de manière continue de CryptPad. Cela comprend la correction de bugs, l'ajout de nouvelles fonctionnalités et rendre plus facile l'hébergement de CryptPad par d'autres personnes.<br>Avec un abonnement, vous aidez aussi à prouver aux autres fournisseurs de services que les gens sont prêts à supporter les technologies améliorant le respect de leur vie privée. Nous espérons qu'un jour, les entreprises ayant pour revenu principal la revente de données des utilisateurs soient de l'histoire ancienne.<br>Enfin, nous offrons la plupart des fonctionnalités gratuitement parce que nous croyons que tout le monde mérite le respect de la vie privée. En souscrivant à un compte premium, vous nous aider à maintenir ces fonctionnalités basiques accessibles aux populations défavorisées."
"a": "Un compte premium permet d'<b>augmenter la limite de stockage</b> dans le CryptDrive, ainsi que celle de ses contacts (<a href=\"https://accounts.cryptpad.fr/#/faq\" target=\"_blank\">en savoir plus</a>).<br>En plus de ces avantages directs, l'abonnement premium permet aussi de <b>financer le développement</b> actif et de manière continue de CryptPad. Cela comprend la correction de bugs, l'ajout de nouvelles fonctionnalités et rendre plus facile l'hébergement de CryptPad par d'autres personnes.<br>Avec un abonnement, vous aidez aussi à prouver aux autres fournisseurs de services que les gens sont prêts à supporter les technologies améliorant le respect de leur vie privée. Nous espérons qu'un jour, les entreprises ayant pour revenu principal la revente de données des utilisateurs soient de l'histoire ancienne.<br>Enfin, nous offrons la plupart des fonctionnalités gratuitement parce que nous croyons que tout le monde mérite le respect de la vie privée. En souscrivant à un compte premium, vous nous aider à maintenir ces fonctionnalités basiques accessibles aux populations défavorisées."
},
"goal": {
"q": "Quel est votre objectif ?",
@ -885,7 +884,7 @@
},
"driveReadmeTitle": "Qu'est-ce que CryptPad ?",
"readme_welcome": "Bienvenue dans CryptPad !",
"readme_p1": "Bienvenue dans CryptPad, le lieu où vous pouvez prendre des notes seul ou avec des amis.",
"readme_p1": "Bienvenue dans CryptPad, le lieu où vous pouvez prendre des notes seul ou avec des contacts.",
"readme_p2": "Ce pad va vous donner un aperçu de la manière dont vous pouvez utiliser CryptPad pour prendre des notes, les organiser et travailler en groupe sur celles-ci.",
"readme_cat1": "Découvrez votre CryptDrive",
"readme_cat1_l1": "Créer un pad : Dans votre CryptDrive, cliquez sur {0} puis {1} et vous obtenez un nouveau pad.",
@ -938,7 +937,6 @@
"creation_noTemplate": "Pas de modèle",
"creation_newTemplate": "Nouveau modèle",
"creation_create": "Créer",
"creation_saveSettings": "Ne plus me demander",
"creation_settings": "Voir davantage de préférences",
"creation_rememberHelp": "Ouvrez votre page de Préférences pour voir ce formulaire à nouveau",
"creation_owners": "Propriétaires",
@ -1006,7 +1004,6 @@
"crowdfunding_popup_text": "<h3>Aider CryptPad</h3>Pour vous assurer que CryptPad soit activement développé, nous vous invitons à supporter le projet via la <a href=\"https://opencollective.com/cryptpad\">page OpenCollective</a>, où vous pouvez trouver notre <b>Roadmap</b> et nos <b>objectifs de financement</b>.",
"crowdfunding_popup_yes": "Voir la page",
"crowdfunding_popup_no": "Pas maintenant",
"crowdfunding_popup_never": "Ne plus demander",
"survey": "Enquête CryptPad",
"markdown_toc": "Sommaire",
"debug_getGraph": "Obtenir le code permettant de générer un graphe de ce document",
@ -1058,18 +1055,17 @@
"friendRequest_later": "Décider plus tard",
"friendRequest_accept": "Accepter (Entrée)",
"friendRequest_decline": "Décliner",
"friendRequest_declined": "<b>{0}</b> a décliné votre demande d'ami",
"friendRequest_accepted": "<b>{0}</b> a accepté votre demande d'ami",
"friendRequest_received": "<b>{0}</b> souhaite être votre ami",
"friendRequest_notification": "<b>{0}</b> vous a envoyé une demande d'ami",
"friendRequest_declined": "<b>{0}</b> a décliné votre demande de contact",
"friendRequest_accepted": "<b>{0}</b> a accepté votre demande de contact",
"friendRequest_received": "<b>{0}</b> souhaite être votre contact",
"friendRequest_notification": "<b>{0}</b> vous a envoyé une demande de contact",
"notifications_empty": "Pas de nouvelle notification",
"notifications_title": "Vous avez des notifications non lues",
"profile_addDescription": "Ajouter une description",
"profile_editDescription": "Modifier votre description",
"profile_addLink": "Ajouter un lien vers votre site web",
"profile_info": "Les autres utilisateurs peuvent trouver votre profil en cliquant sur votre nom dans la liste d'utilisateurs des documents.",
"profile_friendRequestSent": "Demande d'ami en attente...",
"profile_friend": "{0} est votre ami(e)",
"profile_friendRequestSent": "Demande de contact en attente...",
"notification_padShared": "{0} a partagé un pad avec vous : <b>{1}</b>",
"notification_fileShared": "{0} a partagé un fichier avec vous : <b>{1}</b>",
"notification_folderShared": "{0} a partagé un dossier avec vous : <b>{1}</b>",
@ -1080,7 +1076,7 @@
"share_withFriends": "Partager",
"notifications_dismiss": "Cacher",
"fm_info_sharedFolderHistory": "Vous regardez l'historique de votre dossier partagé <b>{0}</b><br/>Votre CryptDrive restera en lecture seule pendant la navigation.",
"share_description": "Choisissez ce que vous souhaitez partager puis obtenez le lien ou envoyez-le directement à vos amis CryptPad.",
"share_description": "Choisissez ce que vous souhaitez partager puis obtenez le lien ou envoyez-le directement à vos contacts CryptPad.",
"fc_color": "Changer la couleur",
"supportPage": "Support",
"admin_cat_support": "Support",
@ -1108,7 +1104,7 @@
"notificationsPage": "Notifications",
"openNotificationsApp": "Ouvrir le panneau de notifications",
"notifications_cat_all": "Toutes",
"notifications_cat_friends": "Demandes d'ami",
"notifications_cat_friends": "Demandes de contact",
"notifications_cat_pads": "Partagé avec moi",
"notifications_cat_archived": "Historique",
"notifications_dismissAll": "Tout cacher",
@ -1122,8 +1118,6 @@
"requestEdit_button": "Demander les droits d'édition",
"requestEdit_dialog": "Êtes-vous sûr de vouloir demander les droits d'édition de ce pad au propriétaire ?",
"requestEdit_confirm": "{1} a demandé les droits d'édition pour le pad <b>{0}</b>. Souhaitez-vous leur accorder les droits ?",
"requestEdit_fromFriend": "Vous êtes amis avec {0}",
"requestEdit_fromStranger": "Vous n'êtes <b>pas</b> amis avec {0}",
"requestEdit_viewPad": "Ouvrir le pad dans un nouvel onglet",
"later": "Décider plus tard",
"requestEdit_request": "{1} souhaite éditer le pad <b>{0}</b>",
@ -1150,7 +1144,7 @@
"register_emailWarning3": "Si vous souhaitez tout de même utiliser votre adresse email comme nom d'utilisateur, appuyez sur OK.",
"owner_removeText": "Supprimer un propriétaire existant",
"owner_removePendingText": "Annuler une offre en attente",
"owner_addText": "Proposer à un ami d'être co-propriétaire de ce document",
"owner_addText": "Proposer à un contact d'être co-propriétaire de ce document",
"owner_unknownUser": "Utilisateur inconnu",
"owner_removeButton": "Supprimer les propriétaires sélectionnés",
"owner_removePendingButton": "Annuler les offres sélectionnées",
@ -1167,9 +1161,8 @@
"owner_removedPending": "{0} a annulé l'offre de co-propriété reçue pour <b>{1}</b>",
"padNotPinnedVariable": "Ce pad va expirer après {4} jours d'inactivité, {0}connectez-vous{1} ou {2}enregistrez-vous{3} pour le préserver.",
"share_linkTeam": "Ajouter au CryptDrive d'une équipe",
"team_pickFriends": "Choisissez les amis à inviter dans cette équipe",
"team_pickFriends": "Choisissez les contacts à inviter dans cette équipe",
"team_inviteModalButton": "Inviter",
"team_noFriend": "Vous n'avez pas encore ajouté d'ami sur CryptPad.",
"team_pcsSelectLabel": "Sauver dans",
"team_pcsSelectHelp": "Créer un pad dans le drive d'une équipe rend cette équipe propriétaire du pad si l'option est cochée.",
"team_invitedToTeam": "{0} vous à inviter à rejoindre l'équipe : <b>{1}</b>",
@ -1191,7 +1184,7 @@
"team_rosterPromote": "Promouvoir",
"team_rosterDemote": "Rétrograder",
"team_rosterKick": "Expulser de l'équipe",
"team_inviteButton": "Inviter des amis",
"team_inviteButton": "Inviter des contacts",
"team_leaveButton": "Quitter cette équipe",
"team_leaveConfirm": "Si vous quittez cette équipe, vous perdrez l'accès à son CryptDrive, son chat et les autres contenus. Êtes-vous sûr ?",
"team_owner": "Propriétaires",
@ -1294,5 +1287,15 @@
"oo_exportInProgress": "Exportation en cours",
"oo_sheetMigration_loading": "Mise à jour de la feuille de calcul",
"oo_sheetMigration_complete": "Version mise à jour disponible, appuyez sur OK pour recharger.",
"oo_sheetMigration_anonymousEditor": "L'édition de cette feuille de calcul est désactivée pour les utilisateurs anonymes jusqu'à ce qu'elle soit mise à jour par un utilisateur enregistré."
"oo_sheetMigration_anonymousEditor": "L'édition de cette feuille de calcul est désactivée pour les utilisateurs anonymes jusqu'à ce qu'elle soit mise à jour par un utilisateur enregistré.",
"imprint": "Mentions légales",
"isContact": "{0} est dans vos contacts",
"isNotContact": "{0} n'est <b>pas</b> dans vos contacts",
"settings_safeLinksHint": "CryptPad inclut dans ses liens les clés permettant de déchiffrer vos pads. Toute personne ayant accès à votre historique de navigation peut potentiellement lire vos données. Cela inclut les extensions de navigateur intrusives et les navigateurs qui synchronisent votre historique entre les appareils. L'activation des \"liens sécurisés\" empêche les clés d'entrer dans votre historique de navigation ou d'être affichées dans votre barre d'adresse quand cela est possible. Nous vous recommandons vivement d'activer cette fonction et d'utiliser le menu {0} Partager.",
"profile_login": "Vous devez vous connecter pour ajouter cet utilisateur à vos contacts",
"dontShowAgain": "Ne plus demander",
"safeLinks_error": "Le lien utilisé ne permet pas d'ouvrir ce document",
"settings_safeLinksCheckbox": "Activer les liens sécurisés",
"settings_safeLinksTitle": "Liens Sécurisés",
"settings_cat_security": "Confidentialité"
}

@ -1,8 +1,8 @@
{
"main_title": "CryptPad: Editor collaborativo in tempo reale, zero knowledge",
"main_title": "CryptPad: Editor zero knowledge collaborativo in tempo reale",
"type": {
"pad": "Testo",
"code": "Code",
"code": "Codice",
"poll": "Sondaggio",
"kanban": "Kanban",
"slide": "Presentazione",
@ -10,13 +10,13 @@
"whiteboard": "Lavagna",
"file": "File",
"media": "Media",
"todo": "Todo",
"todo": "Promemoria",
"contacts": "Contatti",
"sheet": "Fogli (Beta)",
"sheet": "Fogli",
"teams": "Team"
},
"button_newpad": "Nuovo pad di Testo",
"button_newcode": "Nuovo pad di Code",
"button_newcode": "Nuovo pad di Codice",
"button_newpoll": "Nuovo Sondaggio",
"button_newslide": "Nuova Presentazione",
"button_newwhiteboard": "Nuova Lavagna",
@ -34,7 +34,7 @@
"inactiveError": "Questo pad è stato cancellato per inattività. Premi Esc per creare un nuovo pad.",
"chainpadError": "Si è verificato un errore critico nell'aggiornamento del tuo contenuto. Questa pagina è in modalità solo lettura per assicurarci che non perderai il tuo lavoro..<br>Premi <em>Esc</em> per continuare a visualizzare questo pad, o ricarica la pagina per provare a modificarlo di nuovo.",
"invalidHashError": "Il documento richiesto ha un URL non valido.",
"errorCopy": " Puoi ancora copiare il contenuto altrove premendo <em>Esc</em>.<br>Una volta abbandonata questa pagina, non sarà possibile recuperarlo!",
"errorCopy": " Puoi ancora accedere al contenuto premendo <em>Esc</em>.<br>Una volta chiusa questa finestra, non sarà possibile accedere di nuovo.",
"errorRedirectToHome": "Premi <em>Esc</em> per essere reindirizzato al tuo CryptDrive.",
"newVersionError": "Una nuova versione di CryptPad è disponibile. <br><a href='#'>Ricarica</a> per usare la nuova versione, o premi Esc per accedere al contenuto in <b>modalità offline</b>.",
"loading": "Caricamento...",
@ -181,7 +181,6 @@
"okButton": "OK (Enter)",
"cancel": "Cancella",
"cancelButton": "Cancella (Esc)",
"doNotAskAgain": "Non chiedere più (Esc)",
"show_help_button": "Mostra l'aiuto",
"hide_help_button": "Nascondi l'aiuto",
"help_button": "Aiuto",
@ -447,19 +446,19 @@
"settings_exportTitle": "Esporta il tuo CryptDrive",
"settings_exportDescription": "Per favore attendi mentre scarichiamo e decriptiamo i tuoi documenti. Potrebbe richiedere qualche minuto. Chiudere la finestra interromperà il processo.",
"settings_exportFailed": "Se il pad richiede più di un minuto per essere scaricato, non sarà incluso nell'export. Un link a qualsiasi pad non esportato sarà mostrato.",
"settings_exportWarning": "",
"settings_exportCancel": "",
"settings_export_reading": "",
"settings_export_download": "",
"settings_export_compressing": "",
"settings_export_done": "",
"settings_exportError": "",
"settings_exportErrorDescription": "",
"settings_exportErrorEmpty": "",
"settings_exportErrorMissing": "",
"settings_exportErrorOther": "",
"settings_exportWarning": "Nota bene: questo strumento è ancora in versione beta e può presentare problemi di scalabilità. Per migliorare le prestazioni, è consigliabile lasciare attiva questa tab.",
"settings_exportCancel": "Sei sicuro di voler cancellare l'export? Dovrai iniziare da capo la prossima volta.",
"settings_export_reading": "Lettura del tuo CryptDrive in corso...",
"settings_export_download": "Scaricamento e decriptazione dei tuoi documenti in corso...",
"settings_export_compressing": "Compressione dei dati in corso...",
"settings_export_done": "Il tuo download è pronto!",
"settings_exportError": "Visualizza errori",
"settings_exportErrorDescription": "Non siamo riusciti ad aggiungere i seguenti documenti all'export:",
"settings_exportErrorEmpty": "Questo documento non può essere esportato (contenuto vuoto o invalido).",
"settings_exportErrorMissing": "Questo documento non è stato trovato nei nostri server (scaduto o rimosso dal suo proprietario)",
"settings_exportErrorOther": "È accaduto un errore durante l'esportazione di questo documento: {0}",
"settings_resetNewTitle": "Pulisci CryptDrive",
"settings_resetButton": "",
"settings_resetButton": "Rimuovi",
"settings_reset": "Rimuovi tutti i file e le cartelle dal tuo CryptDrive",
"settings_resetPrompt": "",
"settings_resetDone": "",
@ -513,5 +512,9 @@
},
"readme_cat3_l1": "Con l'editor di codice di CryptPad, puoi collaborare su linguaggi di programmazione come Javascript e linguaggi di markup come HTML o Markdown",
"settings_codeSpellcheckLabel": "Abilita la revisione ortografica nell'editor di codice",
"team_inviteLinkError": "Si è verificato un errore durante la creazione del link."
"team_inviteLinkError": "Si è verificato un errore durante la creazione del link.",
"register_emailWarning1": "Puoi farlo se vuoi, ma non verrà inviato ai nostri server.",
"register_emailWarning2": "Non sarai in grado di resettare la tua password usando la tua email, a differenza di come puoi fare con molti altri servizi.",
"register_emailWarning3": "Se hai capito, ma intendi comunque usare la tua email come nome utente, clicca OK.",
"oo_sheetMigration_anonymousEditor": "Le modifiche da parte di utenti anonimi a questo foglio di calcolo sono disabilitate finchè un utente registrato non lo aggiorna all'ultima versione."
}

@ -184,7 +184,6 @@
"okButton": "OK (enter)",
"cancel": "Cancel",
"cancelButton": "Cancel (esc)",
"doNotAskAgain": "Don't ask me again (Esc)",
"show_help_button": "Show help",
"hide_help_button": "Hide help",
"help_button": "Help",
@ -282,7 +281,7 @@
"profile_description": "Description",
"profile_fieldSaved": "New value saved: {0}",
"profile_viewMyProfile": "View my profile",
"userlist_addAsFriendTitle": "Send \"{0}\" a friend request",
"userlist_addAsFriendTitle": "Send \"{0}\" a contact request",
"contacts_title": "Contacts",
"contacts_addError": "Error while adding that contact to the list",
"contacts_added": "Contact invite accepted.",
@ -302,7 +301,7 @@
"contacts_confirmRemoveHistory": "Are you sure you want to permanently remove your chat history? Data cannot be restored",
"contacts_removeHistoryServerError": "There was an error while removing your chat history. Try again later",
"contacts_fetchHistory": "Retrieve older history",
"contacts_friends": "Friends",
"contacts_friends": "Contacts",
"contacts_rooms": "Rooms",
"contacts_leaveRoom": "Leave this room",
"contacts_online": "Another user from this room is online",
@ -535,8 +534,8 @@
"settings_creationSkipFalse": "Display",
"settings_templateSkip": "Skip the template selection modal",
"settings_templateSkipHint": "When you create a new empty pad, if you have stored templates for this type of pad, a modal appears to ask if you want to use a template. Here you can choose to never show this modal and so to never use a template.",
"settings_ownDriveTitle": "Enable latest account features",
"settings_ownDriveHint": "For technical reasons, older accounts do not have access to all of our latest features. A free upgrade to a new account will prepare your CryptDrive for upcoming features without disrupting your usual activities.",
"settings_ownDriveTitle": "Update Account",
"settings_ownDriveHint": "Older accounts do not have access to the latest features, due to technical reasons. A free update will enable current features, and prepare your CryptDrive for future updates.",
"settings_ownDriveButton": "Upgrade your account",
"settings_ownDriveConfirm": "Upgrading your account may take some time. You will need to log back in on all your devices. Are you sure?",
"settings_ownDrivePending": "Your account is being upgraded. Please do not close or reload this page until the process has completed.",
@ -689,7 +688,7 @@
"features_f_social": "Social applications",
"features_f_social_note": "Create a profile, use an avatar, chat with contacts",
"features_f_file1": "Upload and share files",
"features_f_file1_note": "Share files with your friends or embed them in your pads",
"features_f_file1_note": "Share files with your contacts or embed them in your pads",
"features_f_storage1": "Permanent storage (50MB)",
"features_f_storage1_note": "Pads stored in your CryptDrive are never deleted for inactivity",
"features_f_register": "Register for free",
@ -792,7 +791,7 @@
"a": "Registered users have access to a number of features unavailable to unregistered users. There's a chart <a href='/features.html' target='_blank'>here</a>."
},
"share": {
"q": "How can I share encrypted pads with my friends?",
"q": "How can I share encrypted pads with my contacts?",
"a": "CryptPad puts the secret encryption key to your pad after the <em>#</em> character in the URL. Anything after this character is not sent to the server, so we never have access to your encryption keys. By sharing the link to a pad, you share the ability to read and access it."
},
"remove": {
@ -824,7 +823,7 @@
"title": "Other questions",
"pay": {
"q": "Why should I pay when so many features are free?",
"a": "We give supporters additional storage and the ability to increase their friends' quotas (<a href='https://accounts.cryptpad.fr/#/faq' target='_blank'>learn more</a>).<br><br>Beyond these short term benefits, by subscribing with a premium account you help to fund continued, active development of CryptPad. That includes fixing bugs, adding new features, and making it easier for others to help host CryptPad themselves. Additionally, you help to prove to other service providers that people are willing to support privacy enhancing technologies. It is our hope that eventually business models based on selling user data will become a thing of the past.<br><br>Finally, we offer most of CryptPad's functionality for free because we believe everyone deserves personal privacy, not just those with disposable income. By supporting us, you help us continue to make it possible for underprivileged populations to access these basic features without a price tag attached."
"a": "We give supporters additional storage and the ability to increase their contacts' quotas (<a href='https://accounts.cryptpad.fr/#/faq' target='_blank'>learn more</a>).<br><br>Beyond these short term benefits, by subscribing with a premium account you help to fund continued, active development of CryptPad. That includes fixing bugs, adding new features, and making it easier for others to help host CryptPad themselves. Additionally, you help to prove to other service providers that people are willing to support privacy enhancing technologies. It is our hope that eventually business models based on selling user data will become a thing of the past.<br><br>Finally, we offer most of CryptPad's functionality for free because we believe everyone deserves personal privacy, not just those with disposable income. By supporting us, you help us continue to make it possible for underprivileged populations to access these basic features without a price tag attached."
},
"goal": {
"q": "What is your goal?",
@ -885,7 +884,7 @@
"colors": "Change the text and background colors using the <span class=\"fa fa-i-cursor\"></span> and <span class=\"fa fa-square\"></span> buttons"
},
"poll": {
"decisions": "Make decisions in private among trusted friends",
"decisions": "Make decisions in private among trusted contacts",
"options": "Propose options, and express your preferences",
"choices": "Click cells in your column to cycle through yes (<strong>✔</strong>), maybe (<strong>~</strong>), or no (<strong>✖</strong>)",
"submit": "Click <strong>submit</strong> to make your choices visible to others"
@ -903,7 +902,7 @@
},
"driveReadmeTitle": "What is CryptPad?",
"readme_welcome": "Welcome to CryptPad !",
"readme_p1": "Welcome to CryptPad, this is where you can take note of things alone and with friends.",
"readme_p1": "Welcome to CryptPad, this is where you can take note of things alone and with contacts.",
"readme_p2": "This pad will give you a quick walk through of how you can use CryptPad to take notes, keep them organized and work together on them.",
"readme_cat1": "Get to know your CryptDrive",
"readme_cat1_l1": "Make a pad: In your CryptDrive, click {0} then {1} and you can make a pad.",
@ -936,7 +935,7 @@
"feedback_about": "If you're reading this, you were probably curious why CryptPad is requesting web pages when you perform certain actions",
"feedback_privacy": "We care about your privacy, and at the same time we want CryptPad to be very easy to use. We use this file to figure out which UI features matter to our users, by requesting it along with a parameter specifying which action was taken.",
"feedback_optout": "If you would like to opt out, visit <a href='/settings/'>your user settings page</a>, where you'll find a checkbox to enable or disable user feedback",
"creation_404": "This pad not longer exists. Use the following form to create a new pad.",
"creation_404": "This pad no longer exists. Use the following form to create a new pad.",
"creation_ownedTitle": "Type of pad",
"creation_owned": "Owned pad",
"creation_ownedTrue": "Owned pad",
@ -956,7 +955,6 @@
"creation_noTemplate": "No template",
"creation_newTemplate": "New template",
"creation_create": "Create",
"creation_saveSettings": "Don't show this again",
"creation_settings": "View more settings",
"creation_rememberHelp": "Visit your Settings page to reset this preference",
"creation_owners": "Owners",
@ -1028,7 +1026,6 @@
"crowdfunding_popup_text": "<h3>We need your help!</h3>To ensure that CryptPad is actively developed, consider supporting the project via the <a href=\"https://opencollective.com/cryptpad\">OpenCollective page</a>, where you can see our <b>Roadmap</b> and <b>Funding goals</b>.",
"crowdfunding_popup_yes": "Go to OpenCollective",
"crowdfunding_popup_no": "Not now",
"crowdfunding_popup_never": "Don't ask me again",
"survey": "CryptPad survey",
"markdown_toc": "Contents",
"fm_expirablePad": "Expires: {0}",
@ -1075,18 +1072,19 @@
"friendRequest_later": "Decide later",
"friendRequest_accept": "Accept (Enter)",
"friendRequest_decline": "Decline",
"friendRequest_declined": "<b>{0}</b> declined your friend request",
"friendRequest_accepted": "<b>{0}</b> accepted your friend request",
"friendRequest_received": "<b>{0}</b> would like to be your friend",
"friendRequest_notification": "<b>{0}</b> sent you a friend request",
"friendRequest_declined": "<b>{0}</b> declined your contact request",
"friendRequest_accepted": "<b>{0}</b> accepted your contact request",
"friendRequest_received": "<b>{0}</b> would like to be your contact",
"friendRequest_notification": "<b>{0}</b> sent you a contact request",
"notifications_empty": "No notifications available",
"notifications_title": "You have unread notifications",
"profile_addDescription": "Add a description",
"profile_editDescription": "Edit your description",
"profile_addLink": "Add a link to your website",
"profile_info": "Other users can find your profile through your avatar in document user lists.",
"profile_friendRequestSent": "Friend request pending...",
"profile_friend": "{0} is your friend",
"profile_friendRequestSent": "Contact request pending...",
"isContact": "{0} is one of your contacts",
"isNotContact": "{0} is <b>not</b> one of your contacts",
"notification_padShared": "{0} has shared a pad with you: <b>{1}</b>",
"notification_fileShared": "{0} has shared a file with you: <b>{1}</b>",
"notification_folderShared": "{0} has shared a folder with you: <b>{1}</b>",
@ -1097,7 +1095,7 @@
"share_withFriends": "Share",
"notifications_dismiss": "Dismiss",
"fm_info_sharedFolderHistory": "This is only the history of your shared folder: <b>{0}</b><br/>Your CryptDrive will stay in read-only mode while you navigate.",
"share_description": "Choose what you'd like to share and either get the link or send it directly to your CryptPad friends.",
"share_description": "Choose what you'd like to share and either get the link or send it directly to your CryptPad contacts.",
"supportPage": "Support",
"admin_cat_support": "Support",
"admin_supportInitHelp": "Your server is not yet configured to have a support mailbox. If you want a support mailbox to receive messages from your users, you should ask your server administrator to run the script located in \"./scripts/generate-admin-keys.js\", then store the public key in the \"config.js\" file and send you the private key.",
@ -1130,7 +1128,7 @@
"notificationsPage": "Notifications",
"openNotificationsApp": "Open notifications panel",
"notifications_cat_all": "All",
"notifications_cat_friends": "Friend requests",
"notifications_cat_friends": "Contact requests",
"notifications_cat_pads": "Shared with me",
"notifications_cat_archived": "History",
"notifications_dismissAll": "Dismiss all",
@ -1138,8 +1136,6 @@
"requestEdit_button": "Request edit rights",
"requestEdit_dialog": "Are you sure you'd like to ask the owner of this pad for the ability to edit?",
"requestEdit_confirm": "{1} has asked for the ability to edit the pad <b>{0}</b>. Would you like to grant them access?",
"requestEdit_fromFriend": "You are friends with {0}",
"requestEdit_fromStranger": "You are <b>not</b> friends with {0}",
"requestEdit_viewPad": "Open the pad in a new tab",
"later": "Decide later",
"requestEdit_request": "{1} wants to edit the pad <b>{0}</b>",
@ -1153,7 +1149,7 @@
"features_emailRequired": "Email address required",
"owner_removeText": "Remove an existing owner",
"owner_removePendingText": "Cancel a pending offer",
"owner_addText": "Offer co-ownership to a friend",
"owner_addText": "Offer co-ownership to a contact",
"owner_unknownUser": "Unknown user",
"owner_removeButton": "Remove selected owners",
"owner_removePendingButton": "Cancel selected offers",
@ -1169,9 +1165,8 @@
"owner_removed": "{0} has removed your ownership of <b>{1}</b>",
"owner_removedPending": "{0} has canceled your ownership offer for <b>{1}</b>",
"share_linkTeam": "Add to team drive",
"team_pickFriends": "Choose which friends to invite to this team",
"team_pickFriends": "Choose which contacts to invite to this team",
"team_inviteModalButton": "Invite",
"team_noFriend": "You haven't connected with any friends on CryptPad yet.",
"team_pcsSelectLabel": "Store in",
"team_pcsSelectHelp": "Creating an owned pad in your team's drive will give ownership to the team.",
"team_invitedToTeam": "{0} has invited you to join their team: <b>{1}</b>",
@ -1193,7 +1188,7 @@
"team_rosterPromote": "Promote",
"team_rosterDemote": "Demote",
"team_rosterKick": "Kick from the team",
"team_inviteButton": "Invite friends",
"team_inviteButton": "Invite contacts",
"team_leaveButton": "Leave this team",
"team_leaveConfirm": "If you leave this team you will lose access to its CryptDrive, chat history, and other contents. Are you sure?",
"team_owner": "Owners",
@ -1294,5 +1289,13 @@
"oo_exportInProgress": "Export in progress",
"oo_sheetMigration_loading": "Upgrading your spreadsheet to the latest version",
"oo_sheetMigration_complete": "Updated version available, press OK to reload.",
"oo_sheetMigration_anonymousEditor": "Editing this spreadsheet is disabled for anonymous users until it is upgraded to the latest version by a registered user."
"oo_sheetMigration_anonymousEditor": "Editing this spreadsheet is disabled for anonymous users until it is upgraded to the latest version by a registered user.",
"imprint": "Legal notice",
"settings_cat_security": "Confidentiality",
"settings_safeLinksTitle": "Safe Links",
"settings_safeLinksCheckbox": "Enable safe links",
"safeLinks_error": "This link does not give you access to the document",
"dontShowAgain": "Don't show again",
"profile_login": "You need to log in to add this user to your contacts",
"settings_safeLinksHint": "CryptPad includes the keys to decrypt your pads in their links. Anyone with access to your browsing history can potentially read your data. This includes intrusive browser extensions and browsers that sync your history across devices. Enabling \"safe links\" prevents the keys from entering your browsing history or being displayed in your address bar whenever possible. We strongly recommend that you enable this feature and use the {0} Share menu."
}

@ -193,7 +193,6 @@
"crowdfunding_button": "Støtt CryptPad",
"crowdfunding_popup_yes": "Gå til OpenCollective",
"crowdfunding_popup_no": "Ikke nå",
"crowdfunding_popup_never": "Ikke spør igjen takk",
"markdown_toc": "Innhold",
"fm_expirablePad": "Denne paden vill utgå på dato den {0}",
"admin_authError": "Kun admin-tilgang",

@ -409,7 +409,6 @@
"fileEmbedScript": "",
"fileEmbedTag": "",
"ok": "",
"doNotAskAgain": "",
"show_help_button": "",
"hide_help_button": "",
"help_button": "",

@ -395,7 +395,6 @@
"fileEmbedTitle": "Include fișierul într-o pagină externă",
"fileEmbedTag": "După care plasează această etichetă Media oriunde pe pagina unde vrei sa o plasezi",
"ok": "Ok",
"doNotAskAgain": "Nu mă întreba din nou (Esc)",
"show_help_button": "Arată ajutorul",
"hide_help_button": "Maschează ajutorul",
"help_button": "Ajutor",

@ -175,7 +175,6 @@
"okButton": "OK (Enter)",
"cancel": "Отмена",
"cancelButton": "Отмена (Esc)",
"doNotAskAgain": "Не спрашивать снова (Esc)",
"show_help_button": "Показать справку",
"hide_help_button": "Скрыть справку",
"help_button": "Справка",
@ -299,7 +298,6 @@
"fm_removeSeveralPermanentlyDialog": "Вы уверены, что хотите навсегда удалить {0} элементов из вашего Хранилища?",
"crowdfunding_button": "Поддержите CryptPad",
"crowdfunding_popup_no": "Не сейчас",
"crowdfunding_popup_never": "Не спрашивать меня снова",
"markdown_toc": "Содержимое",
"fm_expirablePad": "Этот блокнот истечет {0}",
"fileEmbedTitle": "Встроить файл во внешнюю страницу",

@ -5,6 +5,7 @@ define([
'/common/common-util.js',
'/common/common-hash.js',
'/common/common-interface.js',
'/common/common-ui-elements.js',
'/common/common-feedback.js',
'/bower_components/nthen/index.js',
'/common/sframe-common.js',
@ -22,6 +23,7 @@ define([
Util,
Hash,
UI,
UIElements,
Feedback,
nThen,
SFCommon,
@ -94,7 +96,11 @@ define([
var updateObject = function (sframeChan, obj, cb) {
sframeChan.query('Q_DRIVE_GETOBJECT', null, function (err, newObj) {
copyObjectValue(obj, newObj);
// If anon shared folder, make a virtual drive containing this folder
if (!APP.loggedIn && APP.newSharedFolder) {
obj.drive.root = {
sf: APP.newSharedFolder
};
obj.drive.sharedFolders = obj.drive.sharedFolders || {};
obj.drive.sharedFolders[APP.newSharedFolder] = {
href: APP.anonSFHref,
@ -272,13 +278,13 @@ define([
setEditable(false);
if (drive.refresh) { drive.refresh(); }
APP.toolbar.failed();
if (!noAlert) { UI.alert(Messages.common_connectionLost, undefined, true); }
if (!noAlert) { UIElements.disconnectAlert(); }
};
var onReconnect = function () {
setEditable(true);
if (drive.refresh) { drive.refresh(); }
APP.toolbar.reconnecting();
UI.findOKButton().click();
UIElements.reconnectAlert();
};
sframeChan.on('EV_DRIVE_LOG', function (msg) {

@ -9,6 +9,7 @@ define([
var requireConfig = RequireConfig();
// Loaded in load #2
var hash, href;
nThen(function (waitFor) {
DomReady.onReady(waitFor());
}).nThen(function (waitFor) {
@ -19,6 +20,14 @@ define([
};
window.rc = requireConfig;
window.apiconf = ApiConfig;
// Hidden hash
hash = window.location.hash;
href = window.location.href;
if (window.history && window.history.replaceState && hash) {
window.history.replaceState({}, window.document.title, '#');
}
document.getElementById('sbox-iframe').setAttribute('src',
ApiConfig.httpSafeOrigin + '/drive/inner.html?' + requireConfig.urlArgs +
'#' + encodeURIComponent(JSON.stringify(req)));
@ -37,19 +46,25 @@ define([
window.addEventListener('message', onMsg);
}).nThen(function (/*waitFor*/) {
var afterSecrets = function (Cryptpad, Utils, secret, cb) {
var hash = window.location.hash.slice(1);
if (hash && Utils.LocalStore.isLoggedIn()) {
var _hash = hash.slice(1);
if (_hash && Utils.LocalStore.isLoggedIn()) {
// Add a shared folder!
Cryptpad.addSharedFolder(null, secret, function (id) {
window.CryptPad_newSharedFolder = id;
// Clear the hash now that the secrets have been generated
if (window.history && window.history.replaceState && hash) {
window.history.replaceState({}, window.document.title, '#');
}
cb();
});
return;
} else if (hash) {
} else if (_hash) {
var id = Utils.Util.createRandomInteger();
window.CryptPad_newSharedFolder = id;
var data = {
href: Utils.Hash.getRelativeHref(window.location.href),
href: Utils.Hash.getRelativeHref(Cryptpad.currentPad.href),
password: secret.password
};
return void Cryptpad.loadSharedFolder(id, data, cb);
@ -82,15 +97,6 @@ define([
cb(obj);
});
});
sframeChan.on('EV_DRIVE_SET_HASH', function (hash) {
// Update the hash in the address bar
if (!Utils.LocalStore.isLoggedIn()) { return; }
var ohc = window.onhashchange;
window.onhashchange = function () {};
window.location.hash = hash || '';
window.onhashchange = ohc;
ohc({reset:true});
});
Cryptpad.onNetworkDisconnect.reg(function () {
sframeChan.event('EV_NETWORK_DISCONNECT');
});
@ -107,11 +113,13 @@ define([
sframeChan.event('EV_DRIVE_REMOVE', data);
});
};
var addData = function (meta) {
var addData = function (meta, Cryptpad) {
if (!window.CryptPad_newSharedFolder) { return; }
meta.anonSFHref = window.location.href;
meta.anonSFHref = Cryptpad.currentPad.href;
};
SFCommonO.start({
hash: hash,
href: href,
afterSecrets: afterSecrets,
noHash: true,
noRealtime: true,

@ -9,6 +9,7 @@ define([
var requireConfig = RequireConfig();
// Loaded in load #2
var hash, href;
nThen(function (waitFor) {
DomReady.onReady(waitFor());
}).nThen(function (waitFor) {
@ -19,6 +20,14 @@ define([
};
window.rc = requireConfig;
window.apiconf = ApiConfig;
// Hidden hash
hash = window.location.hash;
href = window.location.href;
if (window.history && window.history.replaceState && hash) {
window.history.replaceState({}, window.document.title, '#');
}
document.getElementById('sbox-iframe').setAttribute('src',
ApiConfig.httpSafeOrigin + '/file/inner.html?' + requireConfig.urlArgs +
'#' + encodeURIComponent(JSON.stringify(req)));
@ -36,10 +45,12 @@ define([
};
window.addEventListener('message', onMsg);
}).nThen(function (/*waitFor*/) {
var addData = function (meta) {
meta.filehash = window.location.hash;
var addData = function (meta, Cryptpad) {
meta.filehash = Cryptpad.currentPad.hash;
};
SFCommonO.start({
hash: hash,
href: href,
noRealtime: true,
addData: addData
});

@ -1,5 +1,6 @@
@import (reference) '../../customize/src/less2/include/framework.less';
@import (reference) '../../customize/src/less2/include/sidebar-layout.less';
@import (reference) '../../customize/src/less2/include/avatar.less';
&.cp-app-notifications {
@ -86,6 +87,14 @@
display: block;
}
}
.cp-avatar {
.avatar_main(48px);
padding: 0 10px;
cursor: pointer;
&:hover {
background-color: rgba(0,0,0,0.1);
}
}
&.cp-app-notification-archived {
background-color: #f1f1f1;
}

@ -101,7 +101,11 @@ define([
var time = new Date(data.content.time);
$(el).find(".cp-notification-content").append(h("span.notification-time", time.toLocaleString()));
$(el).addClass("cp-app-notification-archived");
$(el).toggle(!isDataUnread);
if (isDataUnread) {
$(el).hide();
} else {
$(el).css('display', 'flex');
}
$(notifsList).append(el);
}
};
@ -140,7 +144,7 @@ define([
addNotification(data, el);
},
onViewed: function (data) {
$('.cp-app-notification-archived[data-hash="' + data.hash + '"]').show();
$('.cp-app-notification-archived[data-hash="' + data.hash + '"]').css('display', 'flex');
}
});

@ -741,7 +741,7 @@ define([
if (b64images.length && framework._.sfCommon.isLoggedIn()) {
var no = h('button.cp-corner-cancel', Messages.cancel);
var yes = h('button.cp-corner-primary', Messages.ok);
var actions = h('div', [yes, no]);
var actions = h('div', [no, yes]);
var modal = UI.cornerPopup(Messages.pad_base64, actions, '', {big: true});
$(no).click(function () {
modal.delete();

@ -13,6 +13,7 @@ define([
'/common/sframe-common-codemirror.js',
'/common/common-thumbnail.js',
'/common/common-interface.js',
'/common/common-ui-elements.js',
'/common/hyperscript.js',
'/customize/messages.js',
'cm/lib/codemirror',
@ -42,6 +43,7 @@ define([
SframeCM,
Thumb,
UI,
UIElements,
h,
Messages,
CMeditor,
@ -1098,13 +1100,13 @@ define([
});
}
setEditable(false);
//UI.alert(Messages.common_connectionLost, undefined, true);
//UIElements.disconnectAlert();
};
var onReconnect = function () {
if (APP.unrecoverable) { return; }
setEditable(true);
//UI.findOKButton().click();
//UIElements.reconnectAlert();
};
var getHeadingText = function () {

@ -9,6 +9,7 @@ define([
var requireConfig = RequireConfig();
// Loaded in load #2
var hash, href;
nThen(function (waitFor) {
DomReady.onReady(waitFor());
}).nThen(function (waitFor) {
@ -19,6 +20,14 @@ define([
};
window.rc = requireConfig;
window.apiconf = ApiConfig;
// Hidden hash
hash = window.location.hash;
href = window.location.href;
if (window.history && window.history.replaceState && hash) {
window.history.replaceState({}, window.document.title, '#');
}
document.getElementById('sbox-iframe').setAttribute('src',
ApiConfig.httpSafeOrigin + '/poll/inner.html?' + requireConfig.urlArgs +
'#' + encodeURIComponent(JSON.stringify(req)));
@ -37,6 +46,8 @@ define([
window.addEventListener('message', onMsg);
}).nThen(function (/*waitFor*/) {
SFCommonO.start({
hash: hash,
href: href,
useCreationScreen: true,
messaging: true
});

@ -201,7 +201,7 @@ define([
// Add friend message
APP.$friend.append(h('p.cp-app-profile-friend', [
h('i.fa.fa-address-book'),
Messages._getKey('profile_friend', [name])
Messages._getKey('isContact', [name])
]));
if (!friends[data.curvePublic].notifications) { return; }
// Add unfriend button
@ -570,6 +570,29 @@ define([
return;
}
if (!common.isLoggedIn()) {
var login = h('button.cp-corner-primary', Messages.login_login);
var register = h('button.cp-corner-primary', Messages.login_register);
var cancel = h('button.cp-corner-cancel', Messages.cancel);
var actions = h('div', [cancel, register, login]);
var modal = UI.cornerPopup(Messages.profile_login, actions, '', {alt: true});
$(register).click(function () {
common.setLoginRedirect(function () {
common.gotoURL('/register/');
});
modal.delete();
});
$(login).click(function () {
common.setLoginRedirect(function () {
common.gotoURL('/login/');
});
modal.delete();
});
$(cancel).click(function () {
modal.delete();
});
}
var listmapConfig = {
data: {},
common: common,

@ -17,6 +17,10 @@
flex-flow: column;
font: @colortheme_app-font;
.cp-sidebarlayout-element {
max-width: 650px;
}
#cp-export-container {
font-size: 16px;
display: flex;
@ -121,8 +125,13 @@
border: 1px solid black;
}
.cp-settings-language-selector {
#cp-language-selector {
display: inline;
}
button.btn {
width: @sidebar_button-width;
max-width: 100%;
margin: 0 !important;
background-color: @colortheme_sidebar-button-alt-bg;
border-color: #adadad;
color: black;
@ -138,6 +147,7 @@
padding: 5px;
padding-left: 15px;
&[type="number"] {
height: @variables_input-height + 2px; // to avoid cropped numbers
border-right: 1px solid #adadad;
}
&[type="checkbox"] {
@ -148,6 +158,7 @@
.cp-settings-info-block {
[type="text"] {
width: @sidebar_button-width;
max-width: 100%;
}
}

@ -52,14 +52,16 @@ define([
'cp-settings-displayname',
'cp-settings-language-selector',
'cp-settings-resettips',
'cp-settings-logout-everywhere',
'cp-settings-autostore',
'cp-settings-userfeedback',
'cp-settings-change-password',
'cp-settings-migrate',
'cp-settings-backup',
'cp-settings-delete'
],
'security': [
'cp-settings-logout-everywhere',
'cp-settings-autostore',
'cp-settings-safe-links',
'cp-settings-userfeedback',
],
'creation': [
'cp-settings-creation-owned',
'cp-settings-creation-expire',
@ -115,6 +117,42 @@ define([
var create = {};
var SPECIAL_HINTS_HANDLER = {
safeLinks: function () {
return $('<span>', {'class': 'cp-sidebarlayout-description'})
.html(Messages._getKey('settings_safeLinksHint', ['<span class="fa fa-shhare-alt"></span>']));
},
};
var DEFAULT_HINT_HANDLER = function (safeKey) {
return $('<span>', {'class': 'cp-sidebarlayout-description'})
.text(Messages['settings_'+safeKey+'Hint'] || 'Coming soon...');
};
var makeBlock = function (key, getter, full) {
var safeKey = key.replace(/-([a-z])/g, function (g) { return g[1].toUpperCase(); });
create[key] = function () {
var $div = $('<div>', {'class': 'cp-settings-' + key + ' cp-sidebarlayout-element'});
if (full) {
$('<label>').text(Messages['settings_'+safeKey+'Title'] || key).appendTo($div);
// if this block's hint needs a special renderer, then create it in SPECIAL_HINTS_HANLDER
// otherwise the default will be used
var hintFunction = (typeof(SPECIAL_HINTS_HANDLER[safeKey]) === 'function')?
SPECIAL_HINTS_HANDLER[safeKey]:
DEFAULT_HINT_HANDLER;
hintFunction(safeKey).appendTo($div);
}
getter(function (content) {
$div.append(content);
}, $div);
return $div;
};
};
// Account settings
create['info-block'] = function () {
@ -547,6 +585,35 @@ define([
return $div;
};
// Security
makeBlock('safe-links', function (cb) {
var $cbox = $(UI.createCheckbox('cp-settings-safe-links',
Messages.settings_safeLinksCheckbox,
false, { label: {class: 'noTitle'} }));
var spinner = UI.makeSpinner($cbox);
// Checkbox: "Enable safe links"
var $checkbox = $cbox.find('input').on('change', function () {
spinner.spin();
var val = !$checkbox.is(':checked');
common.setAttribute(['security', 'unsafeLinks'], val, function () {
spinner.done();
});
});
common.getAttribute(['security', 'unsafeLinks'], function (e, val) {
if (e) { return void console.error(e); }
if (val === false) {
$checkbox.attr('checked', 'checked');
}
});
cb($cbox);
}, true);
// Pad Creation settings
var setHTML = function (e, html) {
@ -1578,6 +1645,7 @@ define([
if (key === 'code') { $category.append($('<span>', {'class': 'fa fa-file-code-o' })); }
if (key === 'pad') { $category.append($('<span>', {'class': 'fa fa-file-word-o' })); }
if (key === 'creation') { $category.append($('<span>', {'class': 'fa fa-plus-circle' })); }
if (key === 'security') { $category.append($('<span>', {'class': 'fa fa-lock' })); }
if (key === 'subscription') { $category.append($('<span>', {'class': 'fa fa-star-o' })); }
if (key === active) {
@ -1596,9 +1664,10 @@ define([
showCategories(categories[key]);
});
$category.append(Messages['settings_cat_'+key]);
$category.append(Messages['settings_cat_'+key] || key);
});
showCategories(categories[active]);
common.setHash(active);
};

@ -170,7 +170,9 @@ define([
var privateData = metadataMgr.getPrivateData();
// Check content.sender to see if it comes from us or from an admin
var fromMe = content.sender && content.sender.edPublic === privateData.edPublic;
var senderKey = content.sender && content.sender.edPublic;
var fromMe = senderKey === privateData.edPublic;
var fromAdmin = ctx.adminKeys.indexOf(senderKey) !== -1;
var userData = h('div.cp-support-showdata', [
Messages.support_showData,
@ -183,7 +185,7 @@ define([
});
var name = Util.fixHTML(content.sender.name) || Messages.anonymous;
return h('div.cp-support-list-message', {
return h('div.cp-support-list-message' + (fromAdmin? '.cp-support-fromadmin': ''), {
'data-hash': hash
}, [
h('div.cp-support-message-from' + (fromMe ? '.cp-support-fromme' : ''), [
@ -219,6 +221,7 @@ define([
common: common,
isAdmin: isAdmin,
pinUsage: pinUsage || false,
adminKeys: Array.isArray(ApiConfig.adminKeys)? ApiConfig.adminKeys.slice(): [],
};
ui.sendForm = function (id, form, dest) {

@ -42,6 +42,12 @@
.cp-app-contacts-container {
height: 100%;
}
.cp-app-contacts-input {
textarea {
border: 0px;
color: white;
}
}
}
& > .cp-team-drive {
display: flex;

@ -1379,13 +1379,13 @@ define([
setEditable(false);
if (APP.team && driveAPP.refresh) { driveAPP.refresh(); }
toolbar.failed();
if (!noAlert) { UI.alert(Messages.common_connectionLost, undefined, true); }
if (!noAlert) { UIElements.disconnectAlert(); }
};
var onReconnect = function () {
setEditable(true);
if (APP.team && driveAPP.refresh) { driveAPP.refresh(); }
toolbar.reconnecting();
UI.findOKButton().click();
UIElements.reconnectAlert();
};
sframeChan.on('EV_DRIVE_LOG', function (msg) {

@ -9,6 +9,7 @@ define([
var requireConfig = RequireConfig();
// Loaded in load #2
var hash, href;
nThen(function (waitFor) {
DomReady.onReady(waitFor());
}).nThen(function (waitFor) {
@ -19,6 +20,14 @@ define([
};
window.rc = requireConfig;
window.apiconf = ApiConfig;
// Hidden hash
hash = window.location.hash;
href = window.location.href;
if (window.history && window.history.replaceState && hash) {
window.history.replaceState({}, window.document.title, '#');
}
document.getElementById('sbox-iframe').setAttribute('src',
ApiConfig.httpSafeOrigin + '/teams/inner.html?' + requireConfig.urlArgs +
'#' + encodeURIComponent(JSON.stringify(req)));
@ -37,7 +46,6 @@ define([
window.addEventListener('message', onMsg);
}).nThen(function (/*waitFor*/) {
var teamId;
var hash = window.location.hash.slice(1);
var addRpc = function (sframeChan, Cryptpad) {
sframeChan.on('Q_SET_TEAM', function (data, cb) {
teamId = data;
@ -64,9 +72,6 @@ define([
cb(obj);
});
});
sframeChan.on('EV_DRIVE_SET_HASH', function () {
return;
});
Cryptpad.onNetworkDisconnect.reg(function () {
sframeChan.event('EV_NETWORK_DISCONNECT');
});
@ -95,7 +100,7 @@ define([
};
var addData = function (meta) {
if (!hash) { return; }
meta.teamInviteHash = hash;
meta.teamInviteHash = hash.slice(1);
};
SFCommonO.start({
getSecrets: getSecrets,

Loading…
Cancel
Save