Merge branch 'staging' into fix-onlyoffice-checkup

This commit is contained in:
yflory 2025-03-17 14:56:27 +01:00
commit 37f0bdae9e
95 changed files with 3666 additions and 1268 deletions

View File

@ -3,14 +3,18 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
.dockerignore
.editorconfig
.git
.gitignore
.gitmodules
.github
.reuse
.stylelintrc.js
.eslint.config.js
docker-compose.yml
traefik2.yml
Dockerfile*
*.png
*.md
/onlyoffice-builds.git/
/www/common/onlyoffice/dist/
blob
@ -18,3 +22,4 @@ block
customize
data
datastore
docs

View File

@ -4,6 +4,71 @@ SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and cont
SPDX-License-Identifier: AGPL-3.0-or-later
-->
# ❄️ Winter release (2024.12.0)
## Goals
This version delivers fixes and improvements across CryptPad. We are particularly happy to release a fix of our OnlyOffice integration that could address long-standing issues with documents becoming corrupted. If confirmed at scale, this fix could dramatically improve the use of OnlyOffice apps in CryptPad.
## Improvements
- OnlyOffice integration
- Fix bug resulting in corrupted documents [#1736](https://github.com/cryptpad/cryptpad/pull/1736)
- Drive
- Links included in Drive exports [#1695](https://github.com/cryptpad/cryptpad/pull/1695)
- Rich Text
- Formatted tables and strikethrough text in Pad .md exports [#1720](https://github.com/cryptpad/cryptpad/pull/1720)
- Forms
- Form password warning [#1690](https://github.com/cryptpad/cryptpad/pull/1690)
- Performance improvements (1/3) to example-advanced.nginx.conf [#1709](https://github.com/cryptpad/cryptpad/pull/1709)
- Enable toggle in and out of calendars on small screens [#1584](https://github.com/cryptpad/cryptpad/pull/1584)
## Fixes
- Accessibility
- Kanban Focus order fix [#1708](https://github.com/cryptpad/cryptpad/pull/1708)
- Change iframe title [#1706](https://github.com/cryptpad/cryptpad/pull/1706)
- Fix keyboard trap inside Form description [#1672](https://github.com/cryptpad/cryptpad/pull/1672)
- Disable arrow key navigation in the drive while modal is active [#1669](https://github.com/cryptpad/cryptpad/pull/1669)
- Simulate click action for keyboard users in Ctrl+E modal [#1726](https://github.com/cryptpad/cryptpad/pull/1726)
- Drive
- Prevent links in trash from disappearing after drive reload [#1697](https://github.com/cryptpad/cryptpad/pull/1697)
- Restore multiple files/directories [#1692](https://github.com/cryptpad/cryptpad/pull/1692)
- Stop selection of all other trashed files when restoring single file [#1681](https://github.com/cryptpad/cryptpad/pull/1681)
- Notifications
- Calendar reminders in notification panel [#1721](https://github.com/cryptpad/cryptpad/pull/1721)
- Add Notifications padding [#1688](https://github.com/cryptpad/cryptpad/pull/1688)
- Notification fixes [#1674](https://github.com/cryptpad/cryptpad/pull/1674)
- Forms
- Fix some form storage-related bugs [#1723](https://github.com/cryptpad/cryptpad/pull/1723)
- Fix spacing issues in Forms [#1682](https://github.com/cryptpad/cryptpad/pull/1682)
- Fix padding in form questions [#1670](https://github.com/cryptpad/cryptpad/pull/1670)
- Helpdesk
- Fix "Closed" support tickets remaining in Inbox [#1719](https://github.com/cryptpad/cryptpad/pull/1719)
- 'Request edit' button [#1680](https://github.com/cryptpad/cryptpad/pull/1680)
- Fix example-code-typo [#1703](https://github.com/cryptpad/cryptpad/pull/1703)
## Upgrade notes
If you are upgrading from a version older than `2024.9.1` please read the upgrade notes of all versions between yours and `2024.9.1` to avoid configuration issues.
To upgrade:
1. Stop your server
2. Get the latest code with git
```bash
git fetch origin --tags
git checkout 2024.12.0
npm ci
npm run install:components
```
3. Restart your server
4. Review your instance's checkup page to ensure that you are passing all tests
# Autumn release (2024.9.0)
## Goals

View File

@ -38,11 +38,10 @@ var getStoredLanguage = function () { return localStorage && localStorage.getIte
var getBrowserLanguage = function () { return navigator.language || navigator.userLanguage || ''; };
var getLanguage = Messages._getLanguage = function () {
if (window.cryptpadLanguage) { return window.cryptpadLanguage; }
try {
if (getStoredLanguage()) { return getStoredLanguage(); }
} catch (e) { console.log(e); }
var l = getBrowserLanguage();
// Edge returns 'fr-FR' --> transform it to 'fr' and check again
try {
l = getStoredLanguage() || getBrowserLanguage();
} catch (e) { console.log(e); }
return map[l] ? l :
(map[l.split('-')[0]] ? l.split('-')[0] :
(map[l.split('_')[0]] ? l.split('_')[0] : 'en'));
@ -118,7 +117,6 @@ define(req, function(AppConfig, Default, Language) {
Messages._languages = map;
Messages._languageUsed = language;
// Get keys with parameters
Messages._getKey = function (key, argArray) {
if (!Messages[key]) { return '?'; }
@ -136,6 +134,26 @@ define(req, function(AppConfig, Default, Language) {
}
};
// XXX
Messages.admin_cat_admins = "Administrators";
Messages.admin_admin = "Admin";
Messages.admin_listAdminsTitle = "Current administrators";
Messages.admin_listAdminsHint = "View and remove administrators";
Messages.admin_addAdminsTitle = "Add administrators";
Messages.admin_addAdminsHint = "Add administrators from their public key or from your contacts list";
Messages.admin_addAdminsAdd = "Promote a contact to admin";
Messages.admin_addKeyLabel = "Add an admin using their public key";
Messages.admin_listName = "Admin name";
Messages.admin_listKey = "Admin key";
Messages.admin_listAction = "Remove admin rights";
Messages.admin_listHardcoded = "Admin added into config.js. Can only be removed by editing the config file.";
Messages.admin_listConfirm = "Are you sure you want to remove the admin rights of this user?";
Messages.fm_link_invalid = "Please provide a valid URL"; // XXX
Messages.skipLink = "Skip to main content"; // XXX
return Messages;
});

View File

@ -164,6 +164,7 @@
@cp_buttons-hover: @cryptpad_color_brand_fadest;
@cp_buttons-default: @cryptpad_color_grey_700;
@cp_buttons-default-color: @cryptpad_text_col;
@cp_buttons-default-alt-color: @cryptpad_color_grey_700;
@cp_buttons-default-border: @cryptpad_text_col;
@cp_buttons-red: #E55236;
@cp_buttons-red-text: @cryptpad_color_light_red;

View File

@ -164,6 +164,7 @@
@cp_buttons-default: #CCC;
@cp_buttons-default-color: @cryptpad_text_col;
@cp_buttons-default-border: @cryptpad_text_col;
@cp_buttons-default-alt-color: @cryptpad_color_grey_50;
@cp_buttons-red: #E55236;
@cp_buttons-red-text: @cp_buttons-red;
@cp_buttons-red-color: #FFF;

View File

@ -69,7 +69,7 @@
outline: none;
width: 700px;
max-width: 90vw;
height: 500px;
//height: 500px;
border-radius: @variables_radius_L;
max-height: ~"calc(100vh - 20px)";
margin: 0px;
@ -85,6 +85,7 @@
}
.cp-creation-checkboxes {
min-width: 300px;
flex-flow: column;
align-items: baseline !important;
max-height: 150px;
@ -156,11 +157,10 @@
flex: 1 0 auto;
justify-content: space-around;
& > div {
width: 300px;
//width: 300px;
max-width: 100%;
display: flex;
align-items: center;
flex-wrap: wrap;
font-size: 16px;
//margin: 10px 0;
min-height: 28px;
@ -293,6 +293,11 @@
}
}
}
.cp-creation-password-warning {
font-size: 0.75em;
line-height: 120%;
margin: 0.4rem 1rem calc(0.4rem + 6px);
}
.cp-creation-settings {
button {
margin: 0;
@ -421,7 +426,6 @@
#cp-creation-form {
& > div {
width: 95%;
margin: 0 auto;
}
.cp-creation-expire {
&.active {
@ -431,7 +435,6 @@
.cp-creation-slider {
flex: none;
order: 10;
width: 100%;
}
}
}
@ -440,7 +443,7 @@
}
@media screen and (max-width: 800px) {
#cp-creation {
height: 550px;
//height: 550px;
#cp-creation-form {
div.cp-creation-template {
flex-flow: column;

View File

@ -160,12 +160,12 @@
li[role="menuitem"] {
border-radius: @variables_radius;
white-space: nowrap;
&:hover {
background-color: @cp_dropdown-bg-hover !important;
}
&:focus-visible {
outline-color: @cp_dropdown-fg;
}
&:hover {
background-color: @cp_dropdown-bg-hover;
}
}
&> span {
box-sizing: border-box;

View File

@ -199,6 +199,16 @@
background-color: @cp_buttons-default;
}
}
&.btn-default-alt {
border-color: @cp_buttons-default-alt-color;
color: @cp_buttons-default-alt-color;
background-color: @cp_buttons-default-color;
&:hover, &:not(:disabled):active, &:focus {
border-color: @cp_buttons-default-color;
color: @cp_buttons-default-color;
background-color: @cp_toolbar-fade3;
}
}
&.danger, &.btn-danger {
background-color: @cp_buttons-red;

View File

@ -97,6 +97,26 @@
margin: 0;
}
}
.cp-dropdown-container .cp-dropdown-content {
li[role="menuitem"]{
.cp-notification-dismiss:hover {
border-radius: @variables_radius;
background-color: @cp_dropdown-bg-hover;
}
}
li[role="menuitem"].cp-notification-avatar {
&:hover {
background-color: transparent;
}
.cp-avatar:hover {
background-color: @cp_dropdown-bg-hover;
}
.cp-notification-content:hover {
border-radius: @variables_radius;
background-color: @cp_dropdown-bg-hover;
}
}
}
}

View File

@ -7,6 +7,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/browser.less";
@import (reference) "/customize/src/less2/include/variables.less";
@sidebar_block-width: 25rem;;
@sidebar_base-margin: 0.5rem;
@ -121,6 +122,12 @@
flex-flow: column;
align-items: baseline;
}
.cp-usergrid-container {
margin-bottom: 0px !important;
.cp-usergrid-grid {
margin-bottom: -3px;
}
}
label {
margin-bottom: 0;
}
@ -128,6 +135,9 @@
font-family: inherit;
max-width: @sidebar_block-width;
}
input:invalid {
border: 1px solid red;
}
[type="color"] {
width: @sidebar_block-width/5;
padding: 3px;
@ -214,7 +224,7 @@
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
border-left: 0px;
height: 40px;
height: @variables_input-height;
margin: 0 !important;
}
}
@ -236,6 +246,12 @@
margin-bottom: 0;
}
}
.cp-sidebarlayout-description-item {
display: block;
color: @cp_sidebar-hint;
margin-top: @sidebar_base-margin;
margin-bottom: 0;
}
label.noTitle {
display: inline-flex;
.fa {

View File

@ -445,6 +445,23 @@
display: none !important;
}
.cp-toolbar-skip-link {
position: absolute;
top: -100px;
left: 45%;
background-color: @cp_buttons-primary;
color: @cp_buttons-primary-text;
padding: 0.3rem 0.5rem;
font-size: 1rem;
text-decoration: none;
border-radius: @variables_radius;
z-index: 1000;
transition: top 0.3s ease;
}
.cp-toolbar-skip-link:focus {
top: 10px;
}
@media screen and (max-width: @browser_media-medium-screen),
screen and (max-height: 500px) {
flex-wrap: wrap;

View File

@ -5,7 +5,7 @@
---
services:
cryptpad:
image: "cryptpad/cryptpad:version-2024.12.0"
image: "cryptpad/cryptpad:version-2025.3.0"
hostname: cryptpad
environment:

View File

@ -65,6 +65,7 @@ module.exports = [{
"linebreak-style": ["off", "unix"],
quotes: ["off", "single"],
semi: ["error", "always"],
eqeqeq: ["error", "always"],
"no-irregular-whitespace": ["off"],
"no-self-assign": ["off"],
"no-empty": ["off"],

View File

@ -27,6 +27,31 @@ nThen(function (w) {
console.error(err);
}
}));
}).nThen(function () {
if (Env.proofsMigrated) { return; }
const { Worker } = require('node:worker_threads');
const Admin = require("./commands/admin-rpc");
const worker = new Worker('./scripts/migrations/migrate-blob-proofs.js');
worker.on('message', message => {
if (message === 'READY') {
log.info('BLOB_PROOFS_MIGRATION');
return void worker.postMessage({
start: 1,
});
}
if (message === 'MIGRATED') {
return void log.info('BLOB_PROOFS_DELETION');
}
if (message === 'CLEANED') {
log.info('BLOB_PROOFS_MIGRATED');
Admin.sendDecree(Env, null, function (err) {
if (err) { return void log.error('BLOB_PROOF', err); }
Env.flushCache();
}, ['PROOFS_MIGRATED', ['PROOFS_MIGRATED', 1]], 'server');
}
});
}).nThen(function (w) {
let admins = Env.admins || [];

View File

@ -13,6 +13,7 @@ const Metadata = require("./commands/metadata");
const Meta = require("./metadata");
const Logger = require("./log");
const plugins = require("./plugin-manager");
const HK = require('./hk-util');
let SSOUtils = plugins.SSO && plugins.SSO.utils;
@ -53,7 +54,13 @@ const init = (cb) => {
Env.computeMetadata = function (channel, cb) {
const ref = {};
const lineHandler = Meta.createLineHandler(ref, (err) => { console.log(err); });
return void Env.store.readChannelMetadata(channel, lineHandler, function (err) {
let f = Env.store.readChannelMetadata;
if (channel.length === HK.BLOB_ID_LENGTH) {
f = Env.blobStore.readMetadata;
}
return void f(channel, lineHandler, function (err) {
if (err) {
// stream errors?
return void cb(err);
@ -132,9 +139,12 @@ COMMANDS.start = (edPublic, blockId, reason) => {
n = n((w) => {
// Blobs
if (Env.blobStore.isFileId(chanId)) {
return void Env.blobStore.isOwnedBy(safeKey, chanId, w((err, owned) => {
if (err || !owned) { return; }
blobsToArchive.push(chanId);
return Env.computeMetadata(chanId, w((e, md) => {
if (e || !md) { return; }
if (md && md.owners
&& md.owners.includes(edPublic)) {
blobsToArchive.push(chanId);
}
}));
}
// Pads

View File

@ -18,9 +18,11 @@ const MFA = require("../storage/mfa");
const ArchiveAccount = require('../archive-account');
const { Worker } = require('node:worker_threads');
const Fse = require("fs-extra");
const Fs = require("fs");
const config = require("../load-config");
const Keys = require("../keys");
var Admin = module.exports;
var getFileDescriptorCount = function (Env, server, cb) {
@ -409,7 +411,7 @@ var getChannelMetadata = function (Env, Server, cb, data) {
};
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['RESTRICT_REGISTRATION', [true]]], console.log)
var adminDecree = function (Env, Server, cb, data, unsafeKey) {
var adminDecree = Admin.sendDecree = function (Env, Server, cb, data, unsafeKey) {
var value = data[1];
if (!Array.isArray(value)) { return void cb('INVALID_DECREE'); }
@ -468,7 +470,29 @@ var setLastEviction = function (Env, Server, cb, data, unsafeKey) {
};
// CryptPad_AsyncStore.rpc.send('ADMIN', ['INSTANCE_STATUS], console.log)
const getAdminsData = (Env) => {
return Env.adminsData.map(str => {
// str is either a full public key or just the ed part
const edPublic = Keys.canonicalize(str);
const hardcoded = Array.isArray(config?.adminKeys) &&
config.adminKeys.some(key => {
return Keys.canonicalize(key) === edPublic;
});
if (str.length === 44) {
return { edPublic, first: true, hardcoded };
}
let name;
try {
const parsed = Keys.parseUser(str);
name = parsed.user;
} catch (e) {}
return {
edPublic, hardcoded, name
};
});
};
var instanceStatus = function (Env, Server, cb) {
cb(void 0, {
appsToDisable: Env.appsToDisable,
@ -514,6 +538,8 @@ var instanceStatus = function (Env, Server, cb) {
instanceName: Env.instanceName,
instanceNotice: Env.instanceNotice,
enforceMFA: Env.enforceMFA,
admins: getAdminsData(Env)
});
};
@ -1138,6 +1164,19 @@ Admin.command = function (Env, safeKey, data, _cb, Server) {
var command = commands[data[0]];
Object.keys(Env.plugins || {}).forEach(name => {
let plugin = Env.plugins[name];
if (!plugin.addAdminCommands) { return; }
try {
let c = plugin.addAdminCommands(Env);
Object.keys(c || {}).forEach(cmd => {
if (typeof(c[cmd]) !== "function") { return; }
if (commands[cmd]) { return; }
commands[cmd] = c[cmd];
});
} catch (e) {}
});
if (typeof(command) === 'function') {
return void command(Env, Server, cb, data, unsafeKey);
}

View File

@ -13,7 +13,8 @@ Data.getMetadataRaw = function (Env, channel /* channelName */, _cb) {
const cb = Util.once(Util.mkAsync(_cb));
if (!Core.isValidId(channel)) { return void cb('INVALID_CHAN'); }
if (channel.length !== HK.STANDARD_CHANNEL_LENGTH &&
channel.length !== HK.ADMIN_CHANNEL_LENGTH) { return cb("INVALID_CHAN_LENGTH"); }
channel.length !== HK.ADMIN_CHANNEL_LENGTH &&
channel.length !== HK.BLOB_ID_LENGTH) { return cb("INVALID_CHAN_LENGTH"); }
// return synthetic metadata for admin broadcast channels as a safety net
// in case anybody manages to write metadata
@ -79,6 +80,7 @@ Data.setMetadata = function (Env, safeKey, data, cb, Server) {
var channel = data.channel;
var command = data.command;
// XXX BLOBMD allow blobs
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 cb('UNSUPPORTED_COMMAND'); }
@ -140,6 +142,7 @@ Data.setMetadata = function (Env, safeKey, data, cb, Server) {
cb(void 0, metadata);
return void next();
}
// XXX BLOBMD use correct store for blobs
Env.msgStore.writeMetadata(channel, JSON.stringify(line), function (e) {
if (e) {
cb(e);
@ -155,6 +158,7 @@ Data.setMetadata = function (Env, safeKey, data, cb, Server) {
// update the cached metadata
metadata_cache[channel] = metadata;
Env.checkCache(channel); // XXX ???
// it's easy to check if the channel is restricted
const isRestricted = metadata.restricted;

140
lib/decrees-core.js Normal file
View File

@ -0,0 +1,140 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
var Decrees = module.exports;
var Util = require("./common-util");
var Fs = require("fs");
var Path = require("path");
var readFileBin = require("./stream-file").readFileBin;
var Schedule = require("./schedule");
var Fse = require("fs-extra");
var nThen = require("nthen");
const Utils = Decrees.Utils = {};
var isString = (str) => {
return typeof(str) === "string";
};
var isInteger = function (n) {
return !(typeof(n) !== 'number' || isNaN(n) || (n % 1) !== 0);
};
Utils.args_isBoolean = function (args) {
return !(!Array.isArray(args) || typeof(args[0]) !== 'boolean');
};
Utils.args_isString = function (args) {
return !(!Array.isArray(args) || !isString(args[0]));
};
Utils.args_isInteger = function (args) {
return !(!Array.isArray(args) || !isInteger(args[0]));
};
Utils.args_isPositiveInteger = function (args) {
return Array.isArray(args) && isInteger(args[0]) && args[0] > 0;
};
Decrees.create = (name, commands) => {
// [<command>, <args>, <author>, <time>]
const handleCommand = function (Env, line) {
var command = line[0];
var args = line[1];
if (typeof(commands[command]) !== 'function') {
throw new Error("DECREE_UNSUPPORTED_COMMAND");
}
var outcome = commands[command](Env, args);
if (outcome) {
// trigger Env change event...
Env.envUpdated.fire();
}
return outcome;
};
const createLineHandler = function (Env) {
var Log = Env.Log;
var index = -1;
return function (err, line) {
index++;
if (err) {
// Log the error and bail out
return void Log.error("DECREE_LINE_ERR", {
error: err.message,
index: index,
line: line,
});
}
if (Array.isArray(line)) {
try {
return void handleCommand(Env, line);
} catch (err2) {
return void Log.error("DECREE_COMMAND_ERR", {
error: err2.message,
index: index,
line: line,
});
}
}
Log.error("DECREE_HANDLER_WEIRD_LINE", {
line: line,
index: index,
});
};
};
const load = function (Env, _cb) {
Env.scheduleDecree = Env.scheduleDecree || Schedule();
var cb = Util.once(Util.mkAsync(function (err) {
if (err && err.code !== 'ENOENT') {
return void _cb(err);
}
_cb();
}));
Env.scheduleDecree.blocking('', function (unblock) {
var done = Util.once(Util.both(cb, unblock));
nThen(function (w) {
// ensure that the path to the decree log exists
Fse.mkdirp(Env.paths.decree, w(function (err) {
if (!err) { return; }
w.abort();
done(err);
}));
}).nThen(function () {
var decreeName = Path.join(Env.paths.decree, name);
var stream = Fs.createReadStream(decreeName, {start: 0});
var handler = createLineHandler(Env);
readFileBin(stream, function (msgObj, next) {
var text = msgObj.buff.toString('utf8');
try {
handler(void 0, JSON.parse(text));
} catch (err) {
handler(err, text);
}
next();
}, function (err) {
done(err);
});
});
});
};
const write = function (Env, decree, _cb) {
var path = Path.join(Env.paths.decree, name);
Env.scheduleDecree.ordered('', function (next) {
var cb = Util.both(Util.mkAsync(_cb), next);
Fs.appendFile(path, JSON.stringify(decree) + '\n', cb);
});
};
return {
handleCommand,
load,
write
};
};

View File

@ -1,9 +1,91 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
// SPDX-FileCopyrightText: 2025 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
var Decrees = module.exports;
var Core = require("./commands/core");
const Core = require("./commands/core");
const DecreesCore = require("./decrees-core");
const config = require('./load-config');
const Quota = require("./commands/quota");
const Keys = require("./keys");
const DECREE_NAME = 'decree.ndjson';
const {
args_isBoolean,
args_isString,
args_isInteger,
args_isPositiveInteger
} = DecreesCore.Utils;
// Toggles a simple boolean
const makeBooleanSetter = function (attr) {
return function (Env, args) {
if (!args_isBoolean(args)) {
throw new Error('INVALID_ARGS');
}
var bool = args[0];
if (bool === Env[attr]) { return false; }
Env[attr] = bool;
return true;
};
};
const default_validator = function () { return true; };
const makeGenericSetter = function (attr, validator) {
validator = validator || default_validator;
return function (Env, args) {
if (!validator(args)) {
throw new Error("INVALID_ARGS");
}
var value = args[0];
if (value === Env[attr]) { return false; }
Env[attr] = value;
return true;
};
};
const makeIntegerSetter = function (attr) {
return makeGenericSetter(attr, args_isInteger);
};
const makeTranslation = function (attr) {
return function (Env, args) {
if (!Array.isArray(args)) { throw new Error("INVALID_ARGS"); }
var value = args[0];
var state = Env[attr];
if (typeof(value) === 'string') {
if (state.default === value) { return false; }
state.default = value;
return true;
}
if (value && typeof(value) === 'object') {
var changed = false;
Object.keys(value).forEach(function (lang) {
if (state[lang] === value[lang]) { return; }
state[lang] = value[lang];
changed = true;
});
return changed;
}
return false;
};
};
/* commands have a simple API:
* they receive the global Env and the arguments to be applied
* if the arguments are invalid the operation will not be applied
* the command throws
* if the arguments are valid but do not result in a change, the operation is redundant.
* return false
* if the arguments are valid and will result in a change, the operation should be applied
* apply it
* return true to indicate that it was applied
*/
const commands = {};
/* Admin decrees which modify global server state
@ -75,33 +157,26 @@ RM_ADMIN_KEY
*/
var commands = {};
/* commands have a simple API:
* they receive the global Env and the arguments to be applied
* if the arguments are invalid the operation will not be applied
* the command throws
* if the arguments are valid but do not result in a change, the operation is redundant.
* return false
* if the arguments are valid and will result in a change, the operation should be applied
* apply it
* return true to indicate that it was applied
*/
var args_isBoolean = function (args) {
return !(!Array.isArray(args) || typeof(args[0]) !== 'boolean');
// Maintenance: Empty string or an object with a start and end time
const isNumber = function (value) {
return typeof(value) === "number" && !isNaN(value);
};
const args_isMaintenance = function (args) {
return Array.isArray(args) && args[0] &&
(args[0] === "" || (isNumber(args[0].end) && isNumber(args[0].start)));
};
// Toggles a simple boolean
var makeBooleanSetter = function (attr) {
// we anticipate that we'll add language-specific surveys in the future
// whenever that happens we can relax validation a bit to support more formats
const makeBroadcastSetter = function (attr, validation) {
return function (Env, args) {
if (!args_isBoolean(args)) {
if ((validation && !validation(args)) && !args_isString(args)) {
throw new Error('INVALID_ARGS');
}
var bool = args[0];
if (bool === Env[attr]) { return false; }
Env[attr] = bool;
var str = args[0];
if (str === Env[attr]) { return false; }
Env[attr] = str;
Env.broadcastCache = {};
return true;
};
};
@ -139,49 +214,6 @@ commands.REMOVE_DONATE_BUTTON = makeBooleanSetter('removeDonateButton');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['BLOCK_DAILY_CHECK', [true]]], console.log)
commands.BLOCK_DAILY_CHECK = makeBooleanSetter('blockDailyCheck');
/*
var isNonNegativeNumber = function (n) {
return !(typeof(n) !== 'number' || isNaN(n) || n < 0);
};
*/
var default_validator = function () { return true; };
var makeGenericSetter = function (attr, validator) {
validator = validator || default_validator;
return function (Env, args) {
if (!validator(args)) {
throw new Error("INVALID_ARGS");
}
var value = args[0];
if (value === Env[attr]) { return false; }
Env[attr] = value;
return true;
};
};
var isString = (str) => {
return typeof(str) === "string";
};
var isInteger = function (n) {
return !(typeof(n) !== 'number' || isNaN(n) || (n % 1) !== 0);
};
var args_isString = function (args) {
return !(!Array.isArray(args) || !isString(args[0]));
};
var args_isInteger = function (args) {
return !(!Array.isArray(args) || !isInteger(args[0]));
};
var makeIntegerSetter = function (attr) {
return makeGenericSetter(attr, args_isInteger);
};
var arg_isPositiveInteger = function (args) {
return Array.isArray(args) && isInteger(args[0]) && args[0] > 0;
};
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_LOGO_MIME', ['image/png']]], console.log)
commands.SET_LOGO_MIME = makeGenericSetter('logoMimeType', args_isString);
@ -192,7 +224,7 @@ commands.SET_ACCENT_COLOR = makeGenericSetter('accentColor', args_isString);
commands.ENABLE_PROFILING = makeBooleanSetter('enableProfiling');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_PROFILING_WINDOW', [10000]]], console.log)
commands.SET_PROFILING_WINDOW = makeGenericSetter('profilingWindow', arg_isPositiveInteger);
commands.SET_PROFILING_WINDOW = makeGenericSetter('profilingWindow', args_isPositiveInteger);
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_MAX_UPLOAD_SIZE', [50 * 1024 * 1024]]], console.log)
commands.SET_MAX_UPLOAD_SIZE = makeIntegerSetter('maxUploadSize');
@ -246,30 +278,6 @@ commands.SET_SUPPORT_KEYS = function (Env, args) {
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_INSTANCE_PURPOSE', ["development"]]], console.log)
commands.SET_INSTANCE_PURPOSE = makeGenericSetter('instancePurpose', args_isString);
var makeTranslation = function (attr) {
return function (Env, args) {
if (!Array.isArray(args)) { throw new Error("INVALID_ARGS"); }
var value = args[0];
var state = Env[attr];
if (typeof(value) === 'string') {
if (state.default === value) { return false; }
state.default = value;
return true;
}
if (value && typeof(value) === 'object') {
var changed = false;
Object.keys(value).forEach(function (lang) {
if (state[lang] === value[lang]) { return; }
state[lang] = value[lang];
changed = true;
});
return changed;
}
return false;
};
};
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_INSTANCE_JURISDICTION', ['France']]], console.log)
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_INSTANCE_JURISDICTION', [{default:'France',de:'Frankreich'}]]], console.log)
commands.SET_INSTANCE_JURISDICTION = makeTranslation('instanceJurisdiction');
@ -286,30 +294,6 @@ commands.SET_INSTANCE_DESCRIPTION = makeTranslation('instanceDescription');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_INSTANCE_NOTICE', [{default:'Our hosting costs have increased during the pandemic. Please consider donating!',fr:'Nos coûts d'hébergement ont augmenté pendant la pandémie. Veuillez envisager de faire un don !']]], console.log)
commands.SET_INSTANCE_NOTICE = makeTranslation('instanceNotice');
// Maintenance: Empty string or an object with a start and end time
var isNumber = function (value) {
return typeof(value) === "number" && !isNaN(value);
};
var args_isMaintenance = function (args) {
return Array.isArray(args) && args[0] &&
(args[0] === "" || (isNumber(args[0].end) && isNumber(args[0].start)));
};
// we anticipate that we'll add language-specific surveys in the future
// whenever that happens we can relax validation a bit to support more formats
var makeBroadcastSetter = function (attr, validation) {
return function (Env, args) {
if ((validation && !validation(args)) && !args_isString(args)) {
throw new Error('INVALID_ARGS');
}
var str = args[0];
if (str === Env[attr]) { return false; }
Env[attr] = str;
Env.broadcastCache = {};
return true;
};
};
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_LAST_BROADCAST_HASH', [hash]]], console.log)
commands.SET_LAST_BROADCAST_HASH = makeBroadcastSetter('lastBroadcastHash');
@ -320,10 +304,6 @@ commands.SET_SURVEY_URL = makeBroadcastSetter('surveyURL');
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_MAINTENANCE', [""]]], console.log)
commands.SET_MAINTENANCE = makeBroadcastSetter('maintenance', args_isMaintenance);
var Quota = require("./commands/quota");
var Keys = require("./keys");
var Util = require("./common-util");
// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['SET_QUOTA', ['[user@box:3000/VzeS4vP1DF+tXGuq1i50DKYuBL+09Yqy8kGxoUKRzhA=]', { limit: 2 * 1024 * 1024 * 1024, plan: 'buddy', note: "you're welcome" } ] ] ], console.log)
commands.SET_QUOTA = function (Env, args) {
if (!Array.isArray(args) || args.length !== 2) {
@ -387,11 +367,53 @@ commands.ADD_ADMIN_KEY = function (Env, args) {
Env.admins = Env.admins || [];
var key = Keys.canonicalize(args[0]);
if (!key) { throw new Error("INVALID_KEY"); }
Env.admins.push(key);
if (Env.admins.includes(key)) { // Nothing to change
return false;
}
Env.admins.push(key);
Env.adminsData.push(args[0]);
return true;
};
commands.RM_ADMIN_KEY = function (Env, args) {
if (!Array.isArray(args) || args.length !== 1 || !args[0]) {
throw new Error("INVALID_ARGS");
}
const key = Keys.canonicalize(args[0]);
if (!key) { throw new Error("INVALID_KEY"); }
Env.admins = Env.admins || [];
if (!Env.admins.includes(key)) { // Nothing to change
return false;
}
// NOTE prevent removing config.js hardcoded admin keys
if (Array.isArray(config?.adminKeys) && config.adminKeys.includes(key)) {
throw new Error("CANT_REMOVE_CONFIG");
}
let idx = Env.admins.indexOf(key);
if (idx < 0) { return false; } // should never happen
if (Env.admins.length === 1) { throw new Error("CANT_REMOVE_LAST_ADMIN"); }
Env.admins.splice(idx, 1);
Env.adminsData = Env.adminsData.filter(str => {
const ed = Keys.canonicalize(str);
if (!ed) { return true; }
return ed !== key;
});
return true;
};
commands.PROOFS_MIGRATED = function (Env, args) {
if (args !== 1) {
throw new Error("INVALID_ARGS");
}
Env.proofsMigrated = true;
return true;
};
@ -406,107 +428,6 @@ commands.SET_BEARER_SECRET = function (Env, args) {
return true;
};
// [<command>, <args>, <author>, <time>]
var handleCommand = Decrees.handleCommand = function (Env, line) {
var command = line[0];
var args = line[1];
if (typeof(commands[command]) !== 'function') {
throw new Error("DECREE_UNSUPPORTED_COMMAND");
}
module.exports = DecreesCore.create(DECREE_NAME, commands);
var outcome = commands[command](Env, args);
if (outcome) {
// trigger Env change event...
Env.envUpdated.fire();
}
return outcome;
};
Decrees.createLineHandler = function (Env) {
var Log = Env.Log;
var index = -1;
return function (err, line) {
index++;
if (err) {
// Log the error and bail out
return void Log.error("DECREE_LINE_ERR", {
error: err.message,
index: index,
line: line,
});
}
if (Array.isArray(line)) {
try {
return void handleCommand(Env, line);
} catch (err2) {
return void Log.error("DECREE_COMMAND_ERR", {
error: err2.message,
index: index,
line: line,
});
}
}
Log.error("DECREE_HANDLER_WEIRD_LINE", {
line: line,
index: index,
});
};
};
var Fs = require("fs");
var Path = require("path");
var readFileBin = require("./stream-file").readFileBin;
var Schedule = require("./schedule");
var Fse = require("fs-extra");
var nThen = require("nthen");
Decrees.load = function (Env, _cb) {
Env.scheduleDecree = Env.scheduleDecree || Schedule();
var cb = Util.once(Util.mkAsync(function (err) {
if (err && err.code !== 'ENOENT') {
return void _cb(err);
}
_cb();
}));
Env.scheduleDecree.blocking('', function (unblock) {
var done = Util.once(Util.both(cb, unblock));
nThen(function (w) {
// ensure that the path to the decree log exists
Fse.mkdirp(Env.paths.decree, w(function (err) {
if (!err) { return; }
w.abort();
done(err);
}));
}).nThen(function () {
var decreeName = Path.join(Env.paths.decree, 'decree.ndjson');
var stream = Fs.createReadStream(decreeName, {start: 0});
var handler = Decrees.createLineHandler(Env);
readFileBin(stream, function (msgObj, next) {
var text = msgObj.buff.toString('utf8');
try {
handler(void 0, JSON.parse(text));
} catch (err) {
handler(err, text);
}
next();
}, function (err) {
done(err);
});
});
});
};
Decrees.write = function (Env, decree, _cb) {
var path = Path.join(Env.paths.decree, 'decree.ndjson');
Env.scheduleDecree.ordered('', function (next) {
var cb = Util.both(Util.mkAsync(_cb), next);
Fs.appendFile(path, JSON.stringify(decree) + '\n', cb);
});
};

View File

@ -239,7 +239,7 @@ module.exports.create = function (config) {
evictionReport: {},
commandTimers: {},
sso: config.sso,
sso: plugins?.SSO?.config || {},
enforceMFA: config.enforceMFA,
...(getInstalledOOVersions().length > 0
@ -377,6 +377,7 @@ module.exports.create = function (config) {
Core.DEFAULT_LIMIT;
try {
Env.adminsData = (config.adminKeys || []).slice();
Env.admins = (config.adminKeys || []).map(function (k) {
try {
return Keys.canonicalize(k);
@ -420,6 +421,7 @@ const BAD = [
'limits',
'customLimits',
'scheduleDecree',
'plugins',
'httpServer',

View File

@ -51,7 +51,6 @@ var evictArchived = function (Env, cb) {
var report = {
// archivedChannelsRemoved,
// archivedAccountsRemoved,
// archivedBlobProofsRemoved,
// archivedBlobsRemoved,
// totalChannels,
@ -237,37 +236,6 @@ var evictArchived = function (Env, cb) {
store.listArchivedChannels(handler, w(done));
};
var removeArchivedBlobProofs = function (w) {
if (typeof(Env.archiveRetentionTime) !== "number") { return; }
// Iterate over archive blob ownership proofs and remove them
// if they are older than the specified retention time
var removed = 0;
blobs.list.archived.proofs(function (err, item, next) {
next = Util.mkAsync(next, THROTTLE_FACTOR);
if (err) {
Log.error("EVICT_BLOB_LIST_ARCHIVED_PROOF_ERROR", err);
return void next();
}
if (item && item.ctime > retentionTime) { return void next(); }
if (Env.DRY_RUN) {
removed++;
return void Log.info("EVICT_ARCHIVED_BLOB_PROOF_DRY_RUN", item, next);
}
blobs.remove.archived.proof(item.safeKey, item.blobId, (function (err) {
if (err) {
Log.error("EVICT_ARCHIVED_BLOB_PROOF_ERROR", item);
return void next();
}
Log.info("EVICT_ARCHIVED_BLOB_PROOF", item);
removed++;
next();
}));
}, w(function () {
report.archivedBlobProofsRemoved = removed;
Log.info('EVICT_ARCHIVED_BLOB_PROOFS_REMOVED', removed);
}));
};
var removeArchivedBlobs = function (w) {
if (typeof(Env.archiveRetentionTime) !== "number") { return; }
// Iterate over archived blobs and remove them
@ -303,7 +271,6 @@ var evictArchived = function (Env, cb) {
nThen(loadStorage)
.nThen(migrateIncorrectBlobs)
.nThen(removeArchivedChannels)
.nThen(removeArchivedBlobProofs)
.nThen(removeArchivedBlobs)
.nThen(function () {
cb(void 0, report);
@ -315,7 +282,6 @@ module.exports = function (Env, cb) {
var report = {
// archivedChannelsRemoved,
// archivedAccountsRemoved,
// archivedBlobProofsRemoved,
// archivedBlobsRemoved,
// totalChannels,
@ -612,91 +578,45 @@ module.exports = function (Env, cb) {
if (pinnedDocs.test(item.blobId)) { return void next(); }
if (activeDocs.test(item.blobId)) { return void next(); }
// This seems redundant because we're already checking the bloom filter
// but we can't implement a 'fast mode' for the iterator
// unless we address this race condition with this last-minute double-check
if (item.mtime > inactiveTime) { return void next(); }
if (Env.DRY_RUN) {
removed++;
return void Log.info("EVICT_ARCHIVE_BLOB_DRY_RUN", {
item: item,
}, next);
}
blobs.archive.blob(item.blobId, 'INACTIVE', function (err) {
if (err) {
return Log.error("EVICT_ARCHIVE_BLOB_ERROR", {
error: err,
// NOTE: fast mode allows us to skip getStats for
// the pinned and active channels
nThen(function (w) {
// double check that the channel really is inactive before archiving it
// because it might have been created after the initial activity scan
blobs.getStats(item.blobId, w(function (err, newerItem) {
if (err) { return; }
if (newerItem && getNewestTime(newerItem) > retentionTime) {
// it's actually active, so don't archive it.
w.abort();
cb();
}
// else fall through to the archival
}));
}).nThen(function () {
if (Env.DRY_RUN) {
removed++;
return void Log.info("EVICT_ARCHIVE_BLOB_DRY_RUN", {
item: item,
}, next);
}
removed++;
Log.info("EVICT_ARCHIVE_BLOB", {
item: item,
}, next);
blobs.archive.blob(item.blobId, 'INACTIVE', function (err) {
if (err) {
return Log.error("EVICT_ARCHIVE_BLOB_ERROR", {
error: err,
item: item,
}, next);
}
removed++;
Log.info("EVICT_ARCHIVE_BLOB", {
item: item,
}, next);
});
});
}, w(function () {
report.totalBlobs = total;
report.activeBlobs = total - removed;
Log.info('EVICT_BLOBS_REMOVED', removed, w());
}));
};
var archiveInactiveBlobProofs = function (w) {
// iterate over blob proofs and remove them
// if they don't correspond to a pinned or active file
var removed = 0;
var total = 0;
Log.info("EVICT_ARCHIVE_INACTIVE_BLOB_PROOFS_START", {});
blobs.list.proofs(function (err, item, next) {
next = Util.mkAsync(next, THROTTLE_FACTOR);
if (err) {
return void Log.error("EVICT_BLOB_LIST_PROOFS_ERROR", err, next);
}
if (!item) {
return void Log.error('EVICT_BLOB_LIST_PROOFS_NO_ITEM', item, next);
}
total++;
if (total % PROGRESS_FACTOR === 0) {
Log.info('EVICT_BLOB_PROOF_PROGRESS', {
proofs: total,
});
}
if (pinnedDocs.test(item.blobId)) { return void next(); }
if (item.mtime > inactiveTime) { return void next(); }
nThen(function (w) {
blobs.size(item.blobId, w(function (err, size) {
if (err) {
w.abort();
return void Log.error("EVICT_BLOB_LIST_PROOFS_ERROR", err, next);
}
if (size !== 0) {
w.abort();
next();
}
}));
}).nThen(function () {
if (Env.DRY_RUN) {
removed++;
return void Log.info("EVICT_BLOB_PROOF_LONELY_DRY_RUN", item, next);
}
blobs.remove.proof(item.safeKey, item.blobId, function (err) {
if (err) {
return Log.error("EVICT_BLOB_PROOF_LONELY_ERROR", item, next);
}
removed++;
return Log.info("EVICT_BLOB_PROOF_LONELY", item, next);
});
});
}, w(function () {
Log.info("EVICT_BLOB_PROOFS_REMOVED", {
removed,
total,
}, w());
}));
}), true);
};
var archiveInactiveChannels = function (w) {
@ -801,7 +721,6 @@ module.exports = function (Env, cb) {
// (documents which are not in either bloom filter)
.nThen(archiveInactiveBlobs)
.nThen(archiveInactiveBlobProofs)
.nThen(archiveInactiveChannels)
.nThen(function () {
var runningTime = report.runningTime = msSinceStart();

View File

@ -67,7 +67,7 @@ module.exports.create = function (Env, cb) {
}
if (metadata && metadata.selfdestruct && metadata.selfdestruct !== Env.id) {
HK.expireChannel(Env, channelName);
HK.removeChannel(Env, channelName);
return void cb('ESELFDESTRUCT');
}

View File

@ -42,6 +42,8 @@ const ADMIN_CHANNEL_LENGTH = HK.ADMIN_CHANNEL_LENGTH = 33;
// with a 34 character id
const EPHEMERAL_CHANNEL_LENGTH = HK.EPHEMERAL_CHANNEL_LENGTH = 34;
HK.BLOB_ID_LENGTH = 48;
// Temporary channels are archived X ms after everyone has left them
const TEMPORARY_CHANNEL_LIFETIME = 30 * 1000;
@ -134,6 +136,13 @@ const expireChannel = HK.expireChannel = function (Env, channel) {
});
};
const removeChannel = HK.removeChannel = function (Env, channel) {
if (!Env.store) { return; }
Env.store.archiveChannel(channel, void 0, () => {});
delete Env.metadata_cache[channel];
delete Env.channel_cache[channel];
};
/* dropChannel
* cleans up memory structures which are managed entirely by the historyKeeper
*/
@ -143,7 +152,7 @@ const dropChannel = HK.dropChannel = function (Env, chanName) {
delete Env.channel_cache[chanName];
if (meta && meta.selfdestruct && Env.selfDestructTo) {
Env.selfDestructTo[chanName] = setTimeout(function () {
expireChannel(Env, chanName);
removeChannel(Env, chanName);
}, TEMPORARY_CHANNEL_LIFETIME);
}
if (Env.store) { Env.store.closeChannel(chanName, function () {}); }
@ -696,6 +705,7 @@ const handleGetHistory = function (Env, Server, seq, userId, parsed) {
}, (err, reason) => {
// Any error but ENOENT: abort
// ENOENT is allowed in case we want to create a new pad
if (err && err.error) { err = err.error; }
if (err && err.code !== 'ENOENT') {
if (err.message === "EUNKNOWN") {
Log.error("HK_GET_HISTORY", {
@ -711,7 +721,7 @@ const handleGetHistory = function (Env, Server, seq, userId, parsed) {
stack: err && err.stack,
}); }
// FIXME err.message isn't useful for users
const parsedMsg = {error:err.message, channel: channelName, txid: txid};
const parsedMsg = {error:err.message || 'ERROR', channel: channelName, txid: txid};
Server.send(userId, [0, HISTORY_KEEPER_ID, 'MSG', userId, JSON.stringify(parsedMsg)]);
return;
}
@ -755,15 +765,16 @@ const handleGetHistoryRange = function (Env, Server, seq, userId, parsed) {
var channelName = parsed[1];
var map = parsed[2];
const HISTORY_KEEPER_ID = Env.id;
const store = Env.store;
if (!(map && typeof(map) === 'object')) {
return void Server.send(userId, [seq, 'ERROR', 'INVALID_ARGS', HISTORY_KEEPER_ID]);
}
var oldestKnownHash = map.from;
var untilHash = map.to;
var desiredMessages = map.count;
var desiredCheckpoint = map.cpCount;
var oldestKnownHash = map.from; // last known hash
var untilHash = map.to; // oldest hash (unknown), start point if defined
var desiredMessages = map.count; // nb messages before lkh
var desiredCheckpoint = map.cpCount; // nb cp before lkh
var txid = map.txid;
if (typeof(desiredMessages) !== 'number' && typeof(desiredCheckpoint) !== 'number' && !untilHash) {
return void Server.send(userId, [seq, 'ERROR', 'UNSPECIFIED_COUNT', HISTORY_KEEPER_ID]);
@ -774,6 +785,43 @@ const handleGetHistoryRange = function (Env, Server, seq, userId, parsed) {
}
Server.send(userId, [seq, 'ACK']);
if (untilHash) {
// Get all messages between untilHash (oldest but unknown)
// and oldestKnownHesh (last known hash) (or until the end if undefined)
// Messages can be streamed since we instantly know the start point
let found = false;
store.readMessagesBin(channelName, 0, (msgObj, readMore, abort) => {
const parsed = tryParse(Env, msgObj.buff.toString('utf8'));
if (!parsed) { return void readMore(); }
if (isMetadataMessage(parsed)) { return void readMore(); }
const content = parsed[4];
if (typeof(content) !== 'string') { return void readMore(); }
const hash = getHash(content);
if (hash === untilHash || untilHash === 'NONE') { found = true; }
let then = hash === oldestKnownHash ? abort : readMore;
if (found) {
Server.send(userId, [0, HISTORY_KEEPER_ID, 'MSG', userId,
JSON.stringify(['HISTORY_RANGE', txid, parsed])], then);
}
return void readMore();
}, function (err, reason) {
if (err) {
Env.Log.error("HK_GET_OLDER_HISTORY", channelName, err, reason);
Server.send(userId, [0, HISTORY_KEEPER_ID, 'MSG', userId,
JSON.stringify(['HISTORY_RANGE_ERROR', txid, err])
]);
return;
}
Server.send(userId, [0, HISTORY_KEEPER_ID, 'MSG', userId,
JSON.stringify(['HISTORY_RANGE_END', txid, channelName])
]);
});
return;
}
// If desiredCp or desiredMsg are defined, we can't stream and must
// get a list of messages to send from a worker
Env.getOlderHistory(channelName, oldestKnownHash, untilHash, desiredMessages, desiredCheckpoint, function (err, toSend) {
if (err && err.code !== 'ENOENT') {
Env.Log.error("HK_GET_OLDER_HISTORY", err);

View File

@ -584,14 +584,15 @@ var makeRouteCache = function (template, cacheName) {
};
};
const ssoList = Env.sso && Env.sso.enabled && Array.isArray(Env.sso.list) &&
Env.sso.list.map(function (obj) { return obj.name; }) || [];
const ssoCfg = (SSOUtils && ssoList.length) ? {
force: (Env.sso && Env.sso.enforced && 1) || 0,
password: (Env.sso && Env.sso.cpPassword && (Env.sso.forceCpPassword ? 2 : 1)) || 0,
list: ssoList
} : false;
var serveConfig = makeRouteCache(function () {
const ssoList = Env.sso && Env.sso.enabled && Array.isArray(Env.sso.list) &&
Env.sso.list.map(function (obj) { return obj.name; }) || [];
const ssoCfg = (SSOUtils && ssoList.length) ? {
force: (Env.sso && Env.sso.enforced && 1) || 0,
password: (Env.sso && Env.sso.cpPassword && (Env.sso.forceCpPassword ? 2 : 1)) || 0,
list: ssoList
} : false;
return [
'define(function(){',
'return ' + JSON.stringify({

View File

@ -46,11 +46,4 @@ if (!isPositiveNumber(config.premiumUploadSize) || config.premiumUploadSize < co
delete config.premiumUploadSize;
}
config.sso = {};
try {
config.sso = require("../config/sso");
} catch (e) {
//console.log("SSO config not found");
}
module.exports = config;

View File

@ -33,6 +33,7 @@ var createLineHandler = Pins.createLineHandler = function (ref, errorHandler) {
// it's a weird API but it's faster than unpinning manually
var pins = ref.pins = {};
ref.index = 0;
ref.first = 0;
ref.latest = 0; // the latest message (timestamp in ms)
ref.surplus = 0; // how many lines exist behind a reset
@ -58,7 +59,7 @@ var createLineHandler = Pins.createLineHandler = function (ref, errorHandler) {
return sanitized;
};
return function (line) {
return function (line, i) {
ref.index++;
if (!Boolean(line)) { return; }
@ -74,6 +75,7 @@ var createLineHandler = Pins.createLineHandler = function (ref, errorHandler) {
}
if (typeof(l[2]) === 'number') {
if (!ref.first) { ref.first = l[2]; }
ref.latest = l[2]; // date
}
@ -109,6 +111,11 @@ var createLineHandler = Pins.createLineHandler = function (ref, errorHandler) {
default:
errorHandler("PIN_LINE_UNSUPPORTED_COMMAND", l);
}
if (i === 0) { // First line when using Pins.load
if (l[0] === 'PIN' || ref.block) { ref.user = true; } // teams always start with RESET
}
};
};

View File

@ -10,6 +10,9 @@ var BlobStore = module.exports;
var nThen = require("nthen");
var Semaphore = require("saferphore");
var Util = require("../common-util");
const PERMISSIVE = 511;
const readFileBin = require("../stream-file").readFileBin;
const BLOB_LENGTH = 48;
@ -31,37 +34,30 @@ var prependArchive = function (Env, path) {
return Path.join(Env.archivePath, 'blob', relativePathToBlob);
};
// /blob/<safeKeyPrefix>/<safeKey>/<blobPrefix>/<blobId>
// /blob/<blobPrefix>/<blobId>
var makeBlobPath = function (Env, blobId) {
return Path.join(Env.blobPath, blobId.slice(0, 2), blobId);
};
var makeActivityPath = function (Env, blobId) {
return makeBlobPath(Env, blobId) + '.activity';
};
// /blob/<blobPrefix>/<blobId>.metadata.ndjson
var mkMetadataPath = function (Env, blobId) {
return Path.join(Env.blobPath, blobId.slice(0, 2), blobId) + '.metadata.ndjson';
};
// /blobstate/<safeKeyPrefix>/<safeKey>
var makeStagePath = function (Env, safeKey) {
return Path.join(Env.blobStagingPath, safeKey.slice(0, 2), safeKey);
};
// /blob/<safeKeyPrefix>/<safeKey>/<blobPrefix>/<blobId>
var makeProofPath = function (Env, safeKey, blobId) {
return Path.join(Env.blobPath, safeKey.slice(0, 3), safeKey, blobId.slice(0, 2), blobId);
};
var mkPlaceholderPath = function (Env, blobId) {
return makeBlobPath(Env, blobId) + '.placeholder';
};
var parseProofPath = function (path) {
var parts = path.split('/');
return {
blobId: parts[parts.length -1],
safeKey: parts[parts.length - 3],
};
};
// Placeholder for deleted files
var addPlaceholder = function (Env, blobId, reason, cb) {
if (!reason) { return cb(); }
@ -120,6 +116,18 @@ var isFile = function (filePath, cb) {
});
};
// PROOFS
// DEPRECATED, keep for compatibility
// /blob/<safeKeyPrefix>/<safeKey>/<blobPrefix>/<blobId>
var makeProofPath = function (Env, safeKey, blobId) {
return Path.join(Env.blobPath, safeKey.slice(0, 3), safeKey, blobId.slice(0, 2), blobId);
};
// isOwnedBy(id, safeKey)
var isOwnedBy = function (Env, safeKey, blobId, cb) {
var proofPath = makeProofPath(Env, safeKey, blobId);
isFile(proofPath, cb);
};
var makeFileStream = function (full, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
Fse.mkdirp(Path.dirname(full), function (e) {
@ -190,6 +198,76 @@ var getActivity = function (Env, blobId, cb) {
});
};
// destroyStream && createIdleStreamCollector
// copied from lib/storage/file.js
// see comments there
const STREAM_CLOSE_TIMEOUT = 120000;
const STREAM_DESTROY_TIMEOUT = 30000;
const destroyStream = function (stream) {
if (!stream) { return; }
try {
stream.close();
if (stream.closed && stream.fd === null) { return; }
} catch (err) {
console.error(err);
}
setTimeout(function () {
try { stream.destroy(); } catch (err) { console.error(err); }
}, STREAM_DESTROY_TIMEOUT);
};
const createIdleStreamCollector = function (stream) {
var collector = Util.once(Util.mkAsync(Util.bake(destroyStream, [stream])));
collector.keepAlive = Util.throttle(collector, STREAM_CLOSE_TIMEOUT);
collector.keepAlive();
return collector;
};
// writeMetadata appends to the dedicated log of metadata amendments
var writeMetadata = function (env, channelId, data, cb) {
var path = mkMetadataPath(env, channelId);
Fse.mkdirp(Path.dirname(path), PERMISSIVE, function (err) {
if (err && err.code !== 'EEXIST') { return void cb(err); }
Fs.appendFile(path, data + '\n', cb);
});
};
var archiveMetadata = (Env, blobId, cb) => {
var path = mkMetadataPath(Env, blobId);
var archivePath = prependArchive(Env, path);
// XXX eviction clean lone md files
// if we fail to delete the metadata file, it can still be removed later by the eviction script
Fse.move(path, archivePath, { overwrite: true }, cb);
};
var restoreMetadata = function (Env, blobId, cb) {
var path = mkMetadataPath(Env, blobId);
var archivePath = prependArchive(Env, path);
Fse.move(archivePath, path, cb);
};
var readBlobMetadata = function (env, blobId, handler, _cb) {
var metadataPath = mkMetadataPath(env, blobId);
var stream = Fs.createReadStream(metadataPath, {start: 0});
const collector = createIdleStreamCollector(stream);
var cb = Util.both(_cb, collector);
readFileBin(stream, function (msgObj, readMore) {
collector.keepAlive();
var line = msgObj.buff.toString('utf8');
try {
var parsed = JSON.parse(line);
handler(null, parsed);
} catch (err) {
handler(err, line);
}
readMore();
}, function (err) {
// ENOENT => there is no metadata log
if (!err || err.code === 'ENOENT') { return void cb(); }
// otherwise stream errors?
cb(err);
});
};
/********** METHODS **************/
var upload = function (Env, safeKey, content, cb) {
@ -313,6 +391,9 @@ var tryId = function (path, cb) {
};
// owned_upload_complete
let unescapeKeyCharacters = function (key) {
return key.replace(/\-/g, '/');
};
var owned_upload_complete = function (Env, safeKey, id, cb) {
closeBlobstage(Env, safeKey);
if (!isValidId(id)) {
@ -325,12 +406,9 @@ var owned_upload_complete = function (Env, safeKey, id, cb) {
}
var finalPath = makeBlobPath(Env, id);
let unsafeKey = unescapeKeyCharacters(safeKey);
var finalOwnPath = makeProofPath(Env, safeKey, id);
// the user wants to move it into blob and create a empty file with the same id
// in their own space:
// /blob/safeKeyPrefix/safeKey/blobPrefix/blobID
// the user wants to move it into blob and create a metadata log with an owner
nThen(function (w) {
// make the requisite directory structure using Mkdirp
@ -340,12 +418,6 @@ var owned_upload_complete = function (Env, safeKey, id, cb) {
return void cb(e.code);
}
}));
Fse.mkdirp(Path.dirname(finalOwnPath), w(function (e /*, path */) {
if (e) { // does not throw error if the directory already existed
w.abort();
return void cb(e.code);
}
}));
}).nThen(function (w) {
// make sure the id does not collide with another
tryId(finalPath, w(function (e) {
@ -355,8 +427,11 @@ var owned_upload_complete = function (Env, safeKey, id, cb) {
}
}));
}).nThen(function (w) {
// Create the empty file proving ownership
Fs.writeFile(finalOwnPath, '', w(function (e) {
// Write the metadata
let md = JSON.stringify({
owners: [unsafeKey]
});
writeMetadata(Env, id, md, w((e) => {
if (e) {
w.abort();
return void cb(e.code);
@ -367,12 +442,6 @@ var owned_upload_complete = function (Env, safeKey, id, cb) {
// move the existing file to its new path
Fse.move(oldPath, finalPath, w(function (e) {
if (e) {
// if there's an error putting the file into its final location...
// ... you should remove the ownership file
Fs.unlink(finalOwnPath, function () {
// but if you can't, it's not catestrophic
// we can clean it up later
});
w.abort();
return void cb(e.code);
}
@ -393,24 +462,12 @@ var remove = function (Env, blobId, cb) {
clearActivity(Env, blobId, () => {});
};
// removeProof
var removeProof = function (Env, safeKey, blobId, cb) {
var proofPath = makeProofPath(Env, safeKey, blobId);
Fs.unlink(proofPath, cb);
};
// isOwnedBy(id, safeKey)
var isOwnedBy = function (Env, safeKey, blobId, cb) {
var proofPath = makeProofPath(Env, safeKey, blobId);
isFile(proofPath, cb);
};
// archiveBlob
var archiveBlob = function (Env, blobId, reason, cb) {
var blobPath = makeBlobPath(Env, blobId);
var archivePath = prependArchive(Env, blobPath);
Fse.move(blobPath, archivePath, { overwrite: true }, cb);
archiveMetadata(Env, blobId, () => {});
archiveActivity(Env, blobId, () => {});
addPlaceholder(Env, blobId, reason, () => {});
};
@ -426,29 +483,11 @@ var restoreBlob = function (Env, blobId, cb) {
var blobPath = makeBlobPath(Env, blobId);
var archivePath = prependArchive(Env, blobPath);
Fse.move(archivePath, blobPath, cb);
restoreMetadata(Env, blobId, () => {});
restoreActivity(Env, blobId, () => {});
clearPlaceholder(Env, blobId, () => {});
};
// archiveProof
var archiveProof = function (Env, safeKey, blobId, cb) {
var proofPath = makeProofPath(Env, safeKey, blobId);
var archivePath = prependArchive(Env, proofPath);
Fse.move(proofPath, archivePath, { overwrite: true }, cb);
};
var removeArchivedProof = function (Env, safeKey, blobId, cb) {
var archivedPath = prependArchive(Env, makeProofPath(Env, safeKey, blobId));
Fs.unlink(archivedPath, cb);
};
// restoreProof
var restoreProof = function (Env, safeKey, blobId, cb) {
var proofPath = makeProofPath(Env, safeKey, blobId);
var archivePath = prependArchive(Env, proofPath);
Fse.move(archivePath, proofPath, cb);
};
var makeWalker = function (n, handleChild, done) {
if (!n || typeof(n) !== 'number' || n < 2) { n = 2; }
@ -480,7 +519,7 @@ var makeWalker = function (n, handleChild, done) {
}
if (!stats.isDirectory()) {
w.abort();
if (/\.activity$/.test(path)) {
if (/\.activity$/.test(path)) {
// NOTE: some activity files were created for deleted blobs due to
// a bug. We're going to detect them here in order to be able to clean
// them.
@ -513,46 +552,6 @@ var makeWalker = function (n, handleChild, done) {
return recurse;
};
var listProofs = function (root, handler, cb) {
Fs.readdir(root, function (err, dir) {
if (err) { return void cb(err); }
var walk = makeWalker(20, function (err, path, next, loneActivity) {
if (loneActivity) { return void next(); }
// path is the path to a child node on the filesystem
// next handles the next job in a queue
// iterate over proofs
// check for presence of corresponding files
Fs.stat(path, function (err, stats) {
if (err) {
return void handler(err, void 0, next);
}
var parsed = parseProofPath(path);
handler(void 0, {
path: path,
blobId: parsed.blobId,
safeKey: parsed.safeKey,
atime: stats.atime,
ctime: stats.ctime,
mtime: stats.mtime,
}, next);
});
}, function () {
// called when there are no more directories or children to process
cb();
});
dir.forEach(function (d) {
// ignore directories that aren't 3 characters long...
if (d.length !== 3) { return; }
walk(Path.join(root, d));
});
});
};
var getActivityStat = function (path, base, cb) {
var suffix = base ? '' : '.activity';
Fs.stat(path+suffix, function (err, stats) {
@ -560,32 +559,92 @@ var getActivityStat = function (path, base, cb) {
cb(err, stats);
});
};
var listBlobs = function (root, handler, cb) {
// iterate over files
Fs.readdir(root, function (err, dir) {
if (err) { return void cb(err); }
var walk = makeWalker(20, function (err, path, next, loneActivity) {
if (loneActivity) { return void next(); }
getActivityStat(path, false, function (err, stats) {
if (err) {
return void handler(err, void 0, next);
}
var getStats = function (Env, blobId, cb) {
var path = makeBlobPath(Env, blobId);
getActivityStat(path, false, cb);
};
handler(void 0, {
blobId: Path.basename(path),
atime: stats.atime,
ctime: stats.ctime,
mtime: stats.mtime,
}, next);
});
}, function () {
cb();
});
let blobRegex = /^[0-9a-fA-F]{48}(\.metadata)*(\.ndjson)*$/;
var listBlobs = function (root, handler, fast, cb) {
var dirList = [];
dir.forEach(function (d) {
if (d.length !== 2) { return; }
walk(Path.join(root, d));
nThen(function (w) {
// the root of your datastore contains nested directories...
Fs.readdir(root, w(function (err, list) {
if (err) {
w.abort();
// TODO check if we normally return strings or errors
return void cb(err);
}
dirList = list;
}));
}).nThen(function (waitFor) {
// search inside the nested directories
// stream it so you don't put unnecessary data in memory
var n = nThen;
dirList.forEach(function (dir) {
if (dir.length !== 2) { return; }
// Handle one directory at a time to save some memory
n = n(function (w) {
// do twenty things at a time
var sema = Semaphore.create(20);
var nestedDirPath = Path.join(root, dir);
Fs.readdir(nestedDirPath, w(function (err, list) {
if (err) { return void handler(err); } // Is this correct?
list.forEach(function (item) {
// ignore hidden files
if (/^\./.test(item)) { return; }
// ignore anything that isn't channel or metadata
if (!blobRegex.test(item)) { return; }
var isLonelyMetadata = false;
var blobName;
// if the current file is not the channel data, then it must be metadata
if (!/^[0-9a-fA-F]{48}$/.test(item)) {
blobName = item.replace(/\.metadata\.ndjson/, '');
// check if blob already exists
if (list.indexOf(blobName) !== -1) { return; }
// otherwise set a flag indicating that we should
// handle the metadata on its own
isLonelyMetadata = true;
} else {
blobName = item;
}
if (blobName.length !== 48) { return; }
sema.take(function (give) {
var next = w(give());
if (fast) {
return void handler(void 0, {
blobId: blobName
}, next);
}
var filePath = Path.join(nestedDirPath, blobName);
if (isLonelyMetadata) {
// Set time to 0 to delete this
// lonely metadata file
return void handler(void 0, {
blobId: blobName,
mtime: 0,
atime: 0,
ctime: 0
}, next);
}
return void getActivityStat(filePath, false, (err, data) => {
data.blobId = blobName;
handler(err, data, next);
});
});
});
}));
}).nThen;
});
n(waitFor());
}).nThen(function () {
cb();
});
};
@ -673,6 +732,20 @@ BlobStore.create = function (config, _cb) {
if (!isValidSafeKey(safeKey)) { return void cb('INVALID_SAFEKEY'); }
isOwnedBy(Env, safeKey, blobId, cb);
},
readMetadata: (blobId, handler, cb) => {
if (!isValidId(blobId)) { return void cb("INVALID_ID"); }
readBlobMetadata(Env, blobId, handler, cb);
},
writeMetadata: (blobId, data, cb) => {
if (!isValidId(blobId)) { return void cb("INVALID_ID"); }
writeMetadata(Env, blobId, data, cb);
},
hasMetadata: (blobId, _cb) => {
var cb = Util.once(Util.mkAsync(_cb));
if (!isValidId(blobId)) { return void cb("INVALID_ID"); }
var path = mkMetadataPath(Env, blobId);
isFile(path, cb);
},
remove: {
blob: function (blobId, _cb) {
@ -680,24 +753,12 @@ BlobStore.create = function (config, _cb) {
if (!isValidId(blobId)) { return void cb("INVALID_ID"); }
remove(Env, blobId, cb);
},
proof: function (safeKey, blobId, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
if (!isValidSafeKey(safeKey)) { return void cb('INVALID_SAFEKEY'); }
if (!isValidId(blobId)) { return void cb("INVALID_ID"); }
removeProof(Env, safeKey, blobId, cb);
},
archived: {
blob: function (blobId, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
if (!isValidId(blobId)) { return void cb("INVALID_ID"); }
removeArchivedBlob(Env, blobId, cb);
},
proof: function (safeKey, blobId, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
if (!isValidSafeKey(safeKey)) { return void cb('INVALID_SAFEKEY'); }
if (!isValidId(blobId)) { return void cb("INVALID_ID"); }
removeArchivedProof(Env, safeKey, blobId, cb);
},
},
loneActivity: function (_cb) {
var cb = Util.once(Util.mkAsync(_cb));
@ -711,12 +772,6 @@ BlobStore.create = function (config, _cb) {
if (!isValidId(blobId)) { return void cb("INVALID_ID"); }
archiveBlob(Env, blobId, reason, cb);
},
proof: function (safeKey, blobId, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
if (!isValidSafeKey(safeKey)) { return void cb('INVALID_SAFEKEY'); }
if (!isValidId(blobId)) { return void cb("INVALID_ID"); }
archiveProof(Env, safeKey, blobId, cb);
},
},
restore: {
@ -725,12 +780,6 @@ BlobStore.create = function (config, _cb) {
if (!isValidId(blobId)) { return void cb("INVALID_ID"); }
restoreBlob(Env, blobId, cb);
},
proof: function (safeKey, blobId, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
if (!isValidSafeKey(safeKey)) { return void cb('INVALID_SAFEKEY'); }
if (!isValidId(blobId)) { return void cb("INVALID_ID"); }
restoreProof(Env, safeKey, blobId, cb);
},
},
isBlobAvailable: function (blobId, _cb) {
@ -781,24 +830,21 @@ BlobStore.create = function (config, _cb) {
if (!isValidId(id)) { return void cb("INVALID_ID"); }
getActivity(Env, id, cb);
},
getStats: function (id, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
if (!isValidId(id)) { return void cb("INVALID_ID"); }
getStats(Env, id, cb);
},
list: {
blobs: function (handler, _cb) {
blobs: function (handler, _cb, fast) {
var cb = Util.once(Util.mkAsync(_cb));
listBlobs(Env.blobPath, handler, cb);
},
proofs: function (handler, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
listProofs(Env.blobPath, handler, cb);
listBlobs(Env.blobPath, handler, fast, cb);
},
archived: {
proofs: function (handler, _cb) {
blobs: function (handler, _cb, fast) {
var cb = Util.once(Util.mkAsync(_cb));
listProofs(prependArchive(Env, Env.blobPath), handler, cb);
},
blobs: function (handler, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
listBlobs(prependArchive(Env, Env.blobPath), handler, cb);
listBlobs(prependArchive(Env, Env.blobPath), handler, fast, cb);
},
}
},

View File

@ -366,7 +366,7 @@ var readMessages = function (path, msgHandler, _cb) {
return readFileBin(stream, function (msgObj, readMore) {
collector.keepAlive();
msgHandler(msgObj.buff.toString('utf8'));
readMore();
setTimeout(readMore);
}, function (err) {
cb(err);
});

View File

@ -40,7 +40,7 @@ Logger.levels.forEach(function (level) {
};
});
const HISTORY_SIZE_LIMIT = 1024 * 1024 * 1024; // XXX 1GB
//const HISTORY_SIZE_LIMIT = 1024 * 1024 * 1024; // XXX 1GB
var DETAIL = 1000;
var round = function (n) {
@ -158,6 +158,12 @@ const isValidOffsetNumber = function (n) {
return typeof(n) === 'number' && n >= 0;
};
const updateEnv = data => {
const {value} = data;
let env = Util.tryParse(value) || {};
Env.proofsMigrated = env?.proofsMigrated;
};
const computeIndexFromOffset = function (channelName, offset, cb) {
let cpIndex = [];
let messageBuf = [];
@ -338,7 +344,13 @@ const computeMetadata = function (data, cb) {
const ref = {};
const lineHandler = Meta.createLineHandler(ref, Env.Log.error);
monitoringIncrement('computeMetadata');
return void store.readChannelMetadata(data.channel, lineHandler, function (err) {
let f = store.readChannelMetadata;
if (data.channel.length === HK.BLOB_ID_LENGTH) {
f = blobStore.readMetadata;
}
return void f(data.channel, lineHandler, function (err) {
if (err) {
// stream errors?
return void cb(err);
@ -384,11 +396,50 @@ const getFileSize = function (data, cb) {
const getOlderHistory = function (data, cb) {
const oldestKnownHash = data.hash;
const untilHash = data.toHash;
const channelName = data.channel;
const desiredMessages = data.desiredMessages;
const desiredCheckpoint = data.desiredCheckpoint;
let messages = [];
store.readMessagesBin(channelName, 0, (msgObj, readMore, abort) => {
const parsed = HK.tryParse(Env, msgObj.buff.toString('utf8'));
if (!parsed) { return void readMore(); }
if (HK.isMetadataMessage(parsed)) { return void readMore(); }
const content = parsed[4];
if (typeof(content) !== 'string') { return void readMore(); }
const hash = HK.getHash(content);
messages.push(parsed);
// "X" messages before oldestKnownHash
if (typeof (desiredMessages) === "number") {
messages = messages.slice(-desiredMessages);
if (hash === oldestKnownHash) { return void abort(); }
return void readMore();
}
// "X" checkpoints before oldestKnownHash
if (hash === oldestKnownHash) { return void abort(); }
if (/^cp\|/.test(content)) { // clean whenever we push a cp
let foundCp = 0;
const idx = messages.findLastIndex(parsed => {
let isCp = /^cp\|/.test(parsed[4]);
if (!isCp) { return; }
foundCp++;
return foundCp >= desiredCheckpoint;
});
if (idx > 0) {
messages = messages.slice(idx);
}
}
readMore();
}, function (err, reason) {
if (err) { return void cb(err, reason); }
cb(void 0, messages);
});
/*
const untilHash = data.toHash;
var next = () => {
var messages = [];
var found = false;
@ -444,6 +495,7 @@ const getOlderHistory = function (data, cb) {
}
next();
});
*/
};
const getPinState = function (data, cb) {
@ -558,16 +610,33 @@ const removeOwnedBlob = function (data, cb) {
if (typeof(data.safeKey) !== 'string') { return void cb("INVALID_KEY"); }
const blobId = data.blobId;
const safeKey = Util.escapeKeyCharacters(data.safeKey);
const unsafeKey = Util.unescapeKeyCharacters(data.safeKey);
const reason = data.reason || 'ARCHIVE_OWNED';
nThen(function (w) {
// check if you have permissions
blobStore.isOwnedBy(safeKey, blobId, w(function (err, owned) {
if (err || !owned) {
computeMetadata({channel: blobId}, w((err, meta) => {
if (err || !meta) {
w.abort();
return void cb("INSUFFICIENT_PERMISSIONS");
}
let owners = meta.owners;
if (!owners && !Env.proofsMigrated) {
// Check old proofs during migration
blobStore.isOwnedBy(safeKey, blobId, w((e, owned) => {
if (e || !owned) {
w.abort();
return void cb("INSUFFICIENT_PERMISSIONS");
}
}));
return;
}
if (!owners || !owners.includes(unsafeKey)) {
w.abort();
return void cb("INSUFFICIENT_PERMISSIONS");
}
// Owned, continue
}));
}).nThen(function (w) {
// remove the blob
@ -581,20 +650,8 @@ const removeOwnedBlob = function (data, cb) {
w.abort();
return void cb(err);
}
}));
}).nThen(function () {
// archive the proof
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');
});
}));
});
};
@ -692,6 +749,7 @@ const getLastChannelTime = function (data, cb) {
};
const COMMANDS = {
ENV_UPDATE: updateEnv,
COMPUTE_INDEX: computeIndex,
COMPUTE_METADATA: computeMetadata,
GET_OLDER_HISTORY: getOlderHistory,
@ -847,6 +905,9 @@ process.on('message', function (data) {
};
if (!ready) {
if (data.env) {
updateEnv({value:data.env});
}
return void init(data.config, function (err) {
if (err) { return void cb(Util.serializeError(err)); }
ready = true;

View File

@ -9,6 +9,7 @@ const { fork } = require('child_process');
const Workers = module.exports;
const PID = process.pid;
const Block = require("../storage/block");
const Environment = require('../env');
const DB_PATH = 'lib/workers/db-worker';
const MAX_JOBS = 16;
@ -52,16 +53,20 @@ Workers.initialize = function (Env, config, _cb) {
//return Object.keys(workers[index].tasks || {}).length;
};
const WORKER_TASK_LIMIT = 1000; // XXX
//const WORKER_TASK_LIMIT = 100; // XXX
var workerOffset = -1;
var queue = [];
var getAvailableWorkerIndex = function () {
var getAvailableWorkerIndex = function (isQueue) {
// If there is already a backlog of tasks you can avoid some work
// by going to the end of the line
if (queue.length) { return -1; }
// by going to the end of the line (unless we're trying to
// empty the queue)
if (queue.length && !isQueue) { return -1; }
var L = workers.length;
if (L === 0) {
Log.error('NO_WORKERS_AVAILABLE', {
Log.warn('NO_WORKERS_AVAILABLE', {
queue: queue.length,
});
return -1;
@ -93,7 +98,7 @@ Workers.initialize = function (Env, config, _cb) {
};
var drained = true;
var sendCommand = function (msg, _cb, opt) {
var sendCommand = function (msg, _cb, opt, isQueue) {
if (!_cb) {
return void Log.error('WORKER_COMMAND_MISSING_CB', {
msg: msg,
@ -102,7 +107,7 @@ Workers.initialize = function (Env, config, _cb) {
}
opt = opt || {};
var index = getAvailableWorkerIndex();
var index = getAvailableWorkerIndex(isQueue);
var state = workers[index];
// if there is no worker available:
@ -114,7 +119,7 @@ Workers.initialize = function (Env, config, _cb) {
});
if (drained) {
drained = false;
Log.error('WORKER_QUEUE_BACKLOG', {
Log.warn('WORKER_QUEUE_BACKLOG', {
workers: workers.length,
});
}
@ -127,13 +132,14 @@ Workers.initialize = function (Env, config, _cb) {
var cb = Util.once(Util.mkAsync(Util.both(_cb, function (err /*, value */) {
incrementTime(msg && msg.command, start);
if (err !== 'TIMEOUT') { return; }
Log.debug("WORKER_TIMEOUT_CAUSE", msg);
Log.warn("WORKER_TIMEOUT_CAUSE", msg);
// in the event of a timeout the user will receive an error
// but the state used to resend a query in the event of a worker crash
// won't be cleared. This also leaks a slot that could be used to keep
// an upper bound on the amount of parallelism for any given worker.
// if you run out of slots then the worker locks up.
delete state.tasks[txid];
state.checkTasks();
})));
if (!msg) {
@ -164,6 +170,12 @@ Workers.initialize = function (Env, config, _cb) {
msg._cb = _cb;
msg._opt = opt;
});
state.count++;
if (state.count > WORKER_TASK_LIMIT) {
// Remove from list and spawn new one
if (state.replaceWorker) { state.replaceWorker(); }
}
};
const pluginsResponses = {};
@ -207,6 +219,8 @@ Workers.initialize = function (Env, config, _cb) {
if (!res.txid) { return; }
response.handle(res.txid, [res.error, res.value]);
delete state.tasks[res.txid];
state.checkTasks();
if (!queue.length) {
if (!drained) {
drained = true;
@ -234,7 +248,7 @@ Workers.initialize = function (Env, config, _cb) {
to the back because the following msg took its place. OR, in an
even worse scenario, we cycle through the queue but don't run anything.
*/
sendCommand(nextMsg.msg, nextMsg.cb);
sendCommand(nextMsg.msg, nextMsg.cb, {}, true);
};
const initWorker = function (worker, cb) {
@ -243,19 +257,67 @@ Workers.initialize = function (Env, config, _cb) {
const state = {
worker: worker,
tasks: {},
count: Math.floor(Math.random()*(WORKER_TASK_LIMIT/10)),
pid: worker.pid, // store the child process's id in an easily accessible location
};
state.replaceWorker = () => {
let index = workers.indexOf(state);
if (index === -1) { return; }
// Remove old
workers.splice(index, 1);
// Create new
state.complete = true;
const w = fork(DB_PATH);
Log.info('WORKER_REPLACE_START', {
from: state.worker.pid,
to: w.pid
});
initWorker(w, function (err) {
if (err) {
throw new Error(err);
}
});
};
// If we've reached the limit, kill the worker once
// all the tasks are complete or timed out
state.checkTasks = () => {
// Check limit
if (!state.complete || !state.worker) { return; }
// Check remaining tasks
if (Object.keys(state.tasks).length) { return; }
// Kill
Log.info('WORKER_KILL', {
worker: state.worker.pid,
count: state.count
});
delete state.worker;
worker.kill();
};
response.expect(txid, function (err) {
if (err) { return void cb(err); }
workers.push(state);
cb(void 0, state);
// We just pushed a new worker, available to receive
// a task, so we can empty the queue if necessary
if (queue.length) {
const nextMsg = queue.shift();
if (!nextMsg || !nextMsg.msg) {
return Log.error('WORKER_QUEUE_EMPTY_MESSAGE', {
item: nextMsg,
});
}
sendCommand(nextMsg.msg, nextMsg.cb, {}, true);
}
}, 15000);
worker.send({
pid: PID,
txid: txid,
config: config,
env: Environment.serialize(Env)
});
worker.on('message', function (res) {
@ -296,18 +358,21 @@ Workers.initialize = function (Env, config, _cb) {
});
worker.on('exit', function () {
if (!state.worker) { return; } // Manually killed
substituteWorker();
Env.Log.error("DB_WORKER_EXIT", {
pid: state.pid,
});
});
worker.on('close', function () {
if (!state.worker) { return; } // Manually killed
substituteWorker();
Env.Log.error("DB_WORKER_CLOSE", {
pid: state.pid,
});
});
worker.on('error', function (err) {
if (!state.worker) { return; } // Manually killed
substituteWorker();
Env.Log.error("DB_WORKER_ERROR", {
pid: state.pid,
@ -342,7 +407,8 @@ Workers.initialize = function (Env, config, _cb) {
type: 'broadcast',
pid: PID,
command: data.command,
txid: data.txid
txid: data.txid,
value: data.value
});
});
return workers;

62
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "cryptpad",
"version": "2024.12.0",
"version": "2025.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cryptpad",
"version": "2024.12.0",
"version": "2025.3.0",
"license": "AGPL-3.0+",
"dependencies": {
"@mcrowe/minibloom": "^0.2.0",
@ -18,7 +18,7 @@
"chainpad": "^5.2.6",
"chainpad-crypto": "^0.2.5",
"chainpad-listmap": "^1.1.0",
"chainpad-netflux": "^1.2.0",
"chainpad-netflux": "^1.2.2",
"chainpad-server": "^5.2.4",
"ckeditor": "npm:ckeditor4@~4.22.1",
"codemirror": "^5.19.0",
@ -27,7 +27,7 @@
"croppie": "^2.5.0",
"dragula": "3.7.2",
"drawio": "github:cryptpad/drawio-npm#npm-21.8.2+5",
"express": "~4.21.1",
"express": "~4.21.2",
"file-saver": "1.3.1",
"fs-extra": "^7.0.0",
"get-folder-size": "^2.0.1",
@ -45,7 +45,7 @@
"notp": "^2.0.3",
"nthen": "0.1.8",
"open-sans-fontface": "^1.4.0",
"openid-client": "^5.4.2",
"openid-client": "^5.7.1",
"pako": "^2.1.0",
"prompt-confirm": "^2.0.4",
"pull-stream": "^3.6.1",
@ -1443,9 +1443,10 @@
}
},
"node_modules/chainpad-netflux": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/chainpad-netflux/-/chainpad-netflux-1.2.0.tgz",
"integrity": "sha512-j3qzrL/tugpTNQTk1I7VMZuQJHAYRNmFaiAxLbEFre/gLIPPJqM4gHXuyTU2OFzIFSd5m5i4G3+GwZ32jLV3cA==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/chainpad-netflux/-/chainpad-netflux-1.2.2.tgz",
"integrity": "sha512-fcKugW29BE4wo4l3WYLc56yeFznu15bGi6tU3uPtsLGSExXGqNwS+3kmpzI0AFAsuoI0OKPeT4uLX4YzKT/D+A==",
"license": "LGPL-2.1",
"dependencies": {
"netflux-websocket": "^1.2.0"
}
@ -2199,9 +2200,9 @@
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
},
"node_modules/express": {
"version": "4.21.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz",
"integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==",
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@ -2222,7 +2223,7 @@
"methods": "~1.1.2",
"on-finished": "2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "0.1.10",
"path-to-regexp": "0.1.12",
"proxy-addr": "~2.0.7",
"qs": "6.13.0",
"range-parser": "~1.2.1",
@ -2237,6 +2238,10 @@
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/express/node_modules/encodeurl": {
@ -2976,9 +2981,9 @@
}
},
"node_modules/jose": {
"version": "4.15.5",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.5.tgz",
"integrity": "sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg==",
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"funding": {
"url": "https://github.com/sponsors/panva"
}
@ -3560,9 +3565,9 @@
"integrity": "sha512-r65nCZhrbXXb6dXOACihYApHw2Q6pV0M3V0PSxd74N0+D8nzAdEAITq2oAjA1jVnKI+tGvEBUpqiMh0+rW6zDQ=="
},
"node_modules/nanoid": {
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
"version": "3.3.8",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
"integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
"dev": true,
"funding": [
{
@ -3723,11 +3728,11 @@
"integrity": "sha512-d1VXrt1qPScsZnDHbZTOf1SmUnanr3KQgQM6+ye6KoFgrLo8a8mkX/J/ZJ2+w7vf0sCC02lRia5SAiaz0JPEog=="
},
"node_modules/openid-client": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.6.1.tgz",
"integrity": "sha512-PtrWsY+dXg6y8mtMPyL/namZSYVz8pjXz3yJiBNZsEdCnu9miHLB4ELVC85WvneMKo2Rg62Ay7NkuCpM0bgiLQ==",
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
"dependencies": {
"jose": "^4.15.1",
"jose": "^4.15.9",
"lru-cache": "^6.0.0",
"object-hash": "^2.2.0",
"oidc-token-hash": "^5.0.3"
@ -3845,9 +3850,9 @@
}
},
"node_modules/path-to-regexp": {
"version": "0.1.10",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz",
"integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w=="
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
},
"node_modules/picocolors": {
"version": "1.1.0",
@ -5663,9 +5668,10 @@
}
},
"node_modules/xml-crypto": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-3.2.0.tgz",
"integrity": "sha512-qVurBUOQrmvlgmZqIVBqmb06TD2a/PpEUfFPgD7BuBfjmoH4zgkqaWSIJrnymlCvM2GGt9x+XtJFA+ttoAufqg==",
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-3.2.1.tgz",
"integrity": "sha512-0GUNbPtQt+PLMsC5HoZRONX+K6NBJEqpXe/lsvrFj0EqfpGPpVfJKGE7a5jCg8s2+Wkrf/2U1G41kIH+zC9eyQ==",
"license": "MIT",
"dependencies": {
"@xmldom/xmldom": "^0.8.8",
"xpath": "0.0.32"

View File

@ -1,7 +1,7 @@
{
"name": "cryptpad",
"description": "a collaborative office suite that is end-to-end encrypted and open-source",
"version": "2024.12.0",
"version": "2025.3.0",
"license": "AGPL-3.0+",
"repository": {
"type": "git",
@ -21,7 +21,7 @@
"chainpad": "^5.2.6",
"chainpad-crypto": "^0.2.5",
"chainpad-listmap": "^1.1.0",
"chainpad-netflux": "^1.2.0",
"chainpad-netflux": "^1.2.2",
"chainpad-server": "^5.2.4",
"ckeditor": "npm:ckeditor4@~4.22.1",
"codemirror": "^5.19.0",
@ -30,7 +30,7 @@
"croppie": "^2.5.0",
"dragula": "3.7.2",
"drawio": "github:cryptpad/drawio-npm#npm-21.8.2+5",
"express": "~4.21.1",
"express": "~4.21.2",
"file-saver": "1.3.1",
"fs-extra": "^7.0.0",
"get-folder-size": "^2.0.1",
@ -48,7 +48,7 @@
"notp": "^2.0.3",
"nthen": "0.1.8",
"open-sans-fontface": "^1.4.0",
"openid-client": "^5.4.2",
"openid-client": "^5.7.1",
"pako": "^2.1.0",
"prompt-confirm": "^2.0.4",
"pull-stream": "^3.6.1",

View File

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later
# CryptPad
CryptPad is a collaboration suite that is end-to-end-encrypted and open-source. It is built to enable collaboration, synchronizing changes to documents in real time. Because all data are encrypted, in the eventuality of a breach, attackers have no way of seeing the stored content. Moreover, if the administrators dont alter the code, they and the service also cannot infer any piece of information about the users' content.
CryptPad is a collaboration suite that is end-to-end encrypted and open-source. It is designed to facilitate collaboration by synchronizing changes to documents in real time. Since all the user data is encrypted, in the event of a breach, attackers have no way of accessing the stored content. Furthermore, if the administrators do not modify the code, they and the service also cannot access any information about the users' content.
![Drive screenshot](screenshot.png "preview of the CryptDrive")
@ -20,7 +20,7 @@ Our [developer guide](https://docs.cryptpad.org/en/dev_guide/setup.html) provide
## For production
Configuring CryptPad for production requires a little more work, but the process is described in our [admin installation guide](https://docs.cryptpad.org/en/admin_guide/installation.html). From there you can find more information about customization and maintenance.
Configuring CryptPad for production requires additional steps. Refer to our [admin installation guide](https://docs.cryptpad.org/en/admin_guide/installation.html) for production-related instructions, customization, and maintenance details.
## Current version

View File

@ -0,0 +1,198 @@
// SPDX-FileCopyrightText: 2025 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const { parentPort } = require('node:worker_threads');
const Path = require('node:path');
const Fs = require('node:fs');
const nThen = require("nthen");
const Semaphore = require("saferphore");
const Logger = require("../../lib/log");
const BlobStorage = require("../../lib/storage/blob");
let config = require("../../lib/load-config");
const blobPath = config.blobPath || './blob';
let Log = {};
// NOTE: in cleaning mode, we DON'T migrate
// (we suppose data has already been migrated)
const start = (clean, dry, cb) => {
const DRY_RUN = dry;
let dirList = [];
let blobStore;
nThen(w => {
Logger.create(config, w(function (_log) {
Log = _log;
}));
}).nThen(w => {
config.getSession = function () {};
BlobStorage.create(config, w(function (err, _store) {
if (err) {
w.abort();
return void Log.error("ERR_BLOB_STORE", err);
}
blobStore = _store;
}));
}).nThen(w => {
Fs.readdir(blobPath, w((err, list) => {
if (err) {
w.abort();
return void Log.error("ERR_READING_ROOT", err);
}
dirList = list;
}));
}).nThen(() => {
let n = nThen;
dirList.forEach(dir => {
if (dir.length !== 3) { return; }
// ./blob/abc
const nestedDirPath = Path.join(blobPath, dir);
if (clean) {
n = n(ww => {
Log.info("REMOVING_DIR", nestedDirPath);
if (DRY_RUN) { return; }
Fs.rm(nestedDirPath, {
recursive: true, force: true
}, ww(err => {
if (err) {
Log.error("ERR_REMOVE_DIR", {
path: nestedDirPath,
err
});
}
}));
}).nThen;
return;
}
n = n(w => {
// One user at a time
const sema = Semaphore.create(1);
let nestedDirList = [];
nThen(ww => {
Fs.readdir(nestedDirPath, ww((err, list) => {
if (err) {
w.abort();
ww.abort();
return Log.error("ERR_READING_DIR", {
path: nestedDirPath,
err
});
}
nestedDirList = list;
}));
}).nThen(ww => {
nestedDirList.forEach(key => {
// ./blob/abc/abcdefg...
const keyPath = Path.join(nestedDirPath, key);
sema.take(give => {
let edPublic = key.replace(/\-/g, '/');
let md = JSON.stringify({ owners: [edPublic] });
Log.info("START_USER", edPublic);
Fs.readdir(keyPath, ww((err, list) => {
if (err) {
w.abort();
ww.abort();
return Log.error("ERR_READING_DIR", {
path: keyPath,
err
});
}
let blobs = [];
nThen(www => {
list.forEach(dir => {
// ./blob/abc/abcdefg.../01
const path = Path.join(keyPath, dir);
Fs.readdir(path, www((err, blobsList) => {
if (err) {
w.abort();
ww.abort();
www.abort();
return Log.error("ERR_READING_DIR", {
path, err
});
}
Array.prototype.push.apply(blobs, blobsList);
}));
});
}).nThen(www => {
// migrate 20 blobs at a time for a given user
const sema = Semaphore.create(20);
blobs.forEach(blobId => {
sema.take(ggive => {
blobStore.isBlobAvailable(blobId, www((err, blobExists) => {
blobStore.hasMetadata(blobId, www((err, exists) => {
// If blob is not available or metadata already
// exists, don't write md file
if (!blobExists || exists) { return void ggive(); }
Log.info('WRITE_METADATA', blobId);
if (DRY_RUN) { return void ggive(); }
blobStore.writeMetadata(blobId, md, www(e => {
if (e) {
w.abort();
ww.abort();
www.abort();
return Log.error("ERR_WRITING_MD", { blobId });
}
ggive();
}));
}));
}));
});
});
}).nThen(ww(give(() => {
Log.info("END_USER", edPublic);
})));
}));
});
});
}).nThen(w());
}).nThen;
});
n(() => {
Log.info("DONE");
cb();
});
});
};
if (parentPort) {
// Loaded as worker script
config = JSON.parse(JSON.stringify(config));
config.logToStdout = false;
parentPort.on('message', (message) => {
let parsed = message; //JSON.parse(message);
if (!parsed?.start) { return; }
// Migrate
start(false, false, () => {
parentPort.postMessage('MIGRATED');
// If success, clean
start(true, false, () => {
parentPort.postMessage('CLEANED');
});
});
});
parentPort.postMessage('READY');
} else if (require.main === module) {
// Loaded from command-line
let dry = false;
let clean = false;
process.argv.forEach(key => {
if (key === '--dry') {
dry = true;
return;
}
if (key === '--clean') {
clean = true;
return;
}
});
start(clean, dry, () => {
process.exit(0);
});
}

132
scripts/user-statistics.js Normal file
View File

@ -0,0 +1,132 @@
// SPDX-FileCopyrightText: 2025 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
const nThen = require("nthen");
const Semaphore = require("saferphore");
const Logger = require("../lib/log");
const Pins = require("../lib/pins");
const config = require("../lib/load-config");
const BlobStorage = require("../lib/storage/blob");
const Store = require("../lib/storage/file");
const Fs = require('node:fs');
const Quota = require("../lib/commands/quota");
const Environment = require('../lib/env');
const Env = Environment.create(config);
const CSV = true;
config.logPath = false;
config.logToStdout = true;
const start = () => {
let time = +new Date();
let Log = {};
let all = {};
let blobStore, store;
nThen(w => {
Logger.create(config, w(function (_log) {
Env.Log = Log = _log;
}));
}).nThen(w => {
config.getSession = function () {};
Store.create(config, w(function (err, _store) {
if (err) {
w.abort();
return void Log.error("ERR_PAD_STORE", err);
}
store = _store;
}));
BlobStorage.create(config, w(function (err, _store) {
if (err) {
w.abort();
return void Log.error("ERR_BLOB_STORE", err);
}
blobStore = _store;
}));
}).nThen(w => {
Quota.updateCachedLimits(Env, w((err) => {
if (err) {
return Env.Log.warn('UPDATE_QUOTA_ERR', err);
}
Env.Log.info('QUOTA_UPDATED', {});
}));
}).nThen(w => {
Env.Log.info('START_LOADING_PINS');
const handlePinLog = (content, id, next) => {
const sema = Semaphore.create(20);
const data = all[id] = {
size: 0,
n_pads: 0,
n_blobs: 0,
n_total: 0,
first: content.first,
last: content.latest
};
if (!content.user) {
data.maybeTeam = true;
}
nThen(ww => {
Object.keys(content.pins).forEach(id => {
sema.take(give => {
let addSize = ww(give((e, s) => {
if (typeof(s) !== "number") {
return; // XXX
}
data.size += s;
data.n_total++;
if (id.length === 32) {
data.n_pads++;
} else {
data.n_blobs++;
}
}));
if (id.length === 32) { // PAD
return store.getChannelSize(id, addSize);
}
blobStore.size(id, addSize);
});
});
}).nThen(() => {
let key = id.replace(/-/g, '/');
if (Env.limits[key]) {
let sub = Env.limits[key];
data.premium = sub?.plan;
}
Env.Log.info('PIN_LOG_HANDLED', key);
next();
});
};
Pins.load(w(() => {
let duration = +new Date() - time;
Env.Log.info('ALL_PINS_LOADED', duration);
}), {
pinPath: config.pinPath,
handler: handlePinLog,
});
}).nThen(() => {
if (!CSV) { return console.log(all); }
let csv = `"User key","Premium plan","Bytes","Number pads","Number blobs","First activity","Last activity","May be a team"\n`;
Object.keys(all).sort((a,b) => {
return all[b].size - all[a].size;
}).forEach(k => {
const data = all[k];
k = k.replace(/-/g, '/');
let first = new Date(data.first).toISOString().slice(0,10);
let last = new Date(data.last).toISOString().slice(0,10);
let plan = data.premium || '';
let t = String(!!data.maybeTeam);
csv += `"${k}","${plan}","${data.size}","${data.n_pads}","${data.n_blobs}","${first}","${last}","${t}"\n`;
});
let filename = `../${new Date().toISOString().slice(0,10)}-stats.csv`;
Fs.writeFile(filename, csv, err => {
if (err) {
console.error(err);
} else {
console.log('CSV available at', filename);
}
});
});
};
start();

View File

@ -190,7 +190,15 @@ nThen(function (w) {
var throttledEnvChange = Util.throttle(function () {
Env.Log.info('WORKER_ENV_UPDATE', 'Updating HTTP workers with latest state');
broadcast('ENV_UPDATE', Environment.serialize(Env));
let serialized = Environment.serialize(Env);
broadcast('ENV_UPDATE', serialized);
if (Env.broadcastWorkerCommand) {
Env.broadcastWorkerCommand({
command: 'ENV_UPDATE',
value: serialized,
txid: Util.uid()
});
}
}, 250); // NOTE: changing this value will impact lib/commands/admin-rpc.js#adminDecree callback
var throttledCacheFlush = Util.throttle(function () {

View File

@ -61,6 +61,11 @@
}
}
}
&[data-item="add-admins"] {
.cp-sidebar-form:not(:last-child) {
margin-bottom: 1em;
}
}
}
}
}

View File

@ -67,7 +67,7 @@ define([
'description',
'email',
'jurisdiction',
'flush-cache'
'flush-cache',
]
},
'customize': { // Msg.admin_cat_customize
@ -77,6 +77,13 @@ define([
'color',
]
},
'admins': {
icon: 'fa fa-users',
content: [
'list-admins',
'add-admins'
]
},
'broadcast' : { // Msg.admin_cat_broadcast
icon: 'fa fa-bullhorn',
content : [
@ -94,7 +101,7 @@ define([
]
},
'apps': { // Msg.admin_cat_apps
icon: 'fa fa-wrench',
icon: 'fa fa-wrench',
content: [
'apps',
]
@ -226,6 +233,193 @@ define([
cb(button);
});
const evRefreshAdmins = Util.mkEvent();
sidebar.addItem('list-admins', cb => {
const removeAdmin = (edPublic, _cb) => {
const cb = Util.mkAsync(_cb);
sFrameChan.query('Q_ADMIN_RPC', {
cmd: 'ADMIN_DECREE',
data: ['RM_ADMIN_KEY', [edPublic]]
}, function (e, response) {
if (e || response.error) {
UI.warn(Messages.error);
console.error(e, response);
return void cb('ERROR');
}
if (typeof(cb) === "function") { cb(); }
});
};
const header = [
Messages.admin_listName,
Messages.admin_listKey,
Messages.admin_listAction
];
var list = blocks.table(header, []);
list.setAttribute('id', 'cp-admin-table');
let div = blocks.block([list]);
div.setAttribute('id', 'cp-admin-table-container');
const refreshTable = () => {
const admins = APP.instanceStatus.admins || [];
const newRows = admins.map(obj => {
let { name, edPublic, hardcoded } = obj;
name = name || Messages.admin_admin;
let button = blocks.button('danger','fa-ban', Messages.admin_usersRemove);
let $b = $(button);
Util.onClickEnter($b, () => {
$b.prop('disabled', 'disabled');
UI.confirm(Messages.admin_listConfirm, yes => {
if (!yes) { return $b.prop('disabled', false); }
removeAdmin(edPublic, err => {
$b.prop('disabled', false);
if (err) { return; }
APP.updateStatus(function () {
flushCache();
evRefreshAdmins.fire();
});
});
});
});
let note = Messages.admin_listHardcoded;
let action = hardcoded ? note : button;
return [name, edPublic, action];
});
list.updateContent(newRows);
};
refreshTable();
evRefreshAdmins.reg(() => {
refreshTable();
});
cb(div);
});
sidebar.addItem('add-admins', cb => {
const content = blocks.block();
const metadataMgr = common.getMetadataMgr();
const privateData = metadataMgr.getPrivateData();
const $div = $(content);
const addAdmin = (data, _cb) => {
const cb = Util.mkAsync(_cb);
const { ed, name } = data;
const key = Hash.getPublicSigningKeyString(privateData.origin, name, ed);
sFrameChan.query('Q_ADMIN_RPC', {
cmd: 'ADMIN_DECREE',
data: ['ADD_ADMIN_KEY', [key]]
}, function (e, response) {
if (e || response.error) {
UI.warn(Messages.error);
console.error(e, response);
return void cb('ERROR');
}
if (typeof(cb) === "function") { cb(); }
flushCache();
});
};
const keyInput = blocks.input({
placeholder: Messages.admin_accountMetadataPlaceholder
});
const keyLabel = blocks.labelledInput(Messages.admin_addKeyLabel, keyInput);
const keyButton = blocks.button('primary', 'fa-plus', Messages.tag_add);
const keyForm = blocks.form([keyLabel], blocks.nav([keyButton]));
const $keyInput = $(keyInput).on('input', () => {
let val = $keyInput.val().trim();
if (!val) {
keyInput.setCustomValidity('');
return;
}
let key = Keys.canonicalize(val);
if (keyInput.setCustomValidity) {
if (!key) {
const msg = Messages.admin_invalKey;
keyInput.setCustomValidity(msg);
} else {
keyInput.setCustomValidity('');
}
}
});
const $keyBtn = $(keyButton);
Util.onClickEnter($keyBtn, () => {
let val = $keyInput.val().trim();
let key = Keys.canonicalize(val);
if (!key) { return; }
// We have a valid key
let name = Messages.admin_admin;
try {
let parsed = Keys.parseUser(val);
name = parsed.user;
} catch (e) {}
$keyBtn.prop('disabled', 'disabled');
addAdmin({ ed:key, name }, (err) => {
$keyBtn.prop('disabled', false);
if (!err) { $keyInput.val(''); }
// refresh
APP.updateStatus(function () {
evRefreshAdmins.fire();
});
});
});
const drawContacts = () => {
$div.empty();
const members = {};
const admins = APP.instanceStatus.admins || [];
admins.forEach(obj => {
const { edPublic, name, hardcoded, first } = obj;
members[edPublic] = { name, hardcoded, first };
});
// Remove admins from contacts list
const friends = Util.clone(common.getFriends(false));
Object.keys(friends).forEach((curve) => {
const ed = friends[curve]?.edPublic;
if (members[ed]) { delete friends[curve]; }
});
let contactsGrid = UIElements.getUserGrid(Messages.admin_addAdminsAdd, {
common: common,
list: true,
large: true,
data: friends
}, function () {});
let addBtn = blocks.button('primary', 'fa-plus', Messages.tag_add);
Util.onClickEnter($(addBtn), () => {
var $sel = $(contactsGrid.div).find('.cp-usergrid-user.cp-selected');
nThen((waitFor) => {
$sel.each((i, el) => {
const $el = $(el);
let ed = $el.attr('data-ed');
let name = $el.attr('data-name');
if (!ed || !name) {
console.error('Missing data on selected user', el);
return void UI.warn(Messages.error);
}
addAdmin({ed, name}, waitFor());
});
}).nThen(() => {
APP.updateStatus(function () {
evRefreshAdmins.fire();
drawContacts();
});
});
});
evRefreshAdmins.reg(() => {
drawContacts();
});
const list = blocks.form([
//currentList.div,
contactsGrid.div,
], blocks.nav([addBtn]));
$div.append([keyForm, list]);
};
drawContacts();
cb(content);
});
var isHex = s => !/[^0-9a-f]/.test(s);
var sframeCommand = function (command, data, cb) {
@ -3928,7 +4122,7 @@ define([
// EXTENSION_POINT:ADMIN_ITEM
let utils = {
h, Util, Hash, UIElements
$, h, Util, Hash, UIElements, UI, APP
};
common.getExtensionsSync('ADMIN_ITEM').forEach(ext => {
if (!ext || !ext.id || typeof(ext.getContent) !== "function") {
@ -3952,13 +4146,19 @@ define([
sidebar.makeLeftside(categories);
};
var updateStatus = APP.updateStatus = function (cb) {
sFrameChan.query('Q_ADMIN_RPC', {
cmd: 'INSTANCE_STATUS',
}, function (e, data) {
if (e) { console.error(e); return void cb(e); }
if (!Array.isArray(data)) { return void cb('EINVAL'); }
APP.instanceStatus = data[0];
console.log("Status", APP.instanceStatus);
nThen(w => {
sFrameChan.query('Q_ADMIN_RPC', {
cmd: 'INSTANCE_STATUS',
}, w(function (e, data) {
if (e) { console.error(e); return void cb(e); }
if (!Array.isArray(data)) { return void cb('EINVAL'); }
APP.instanceStatus = data[0];
console.log("Status", APP.instanceStatus);
}));
require([`/api/config?${+new Date()}`], w(ApiConfig => {
APP.instanceConfig = ApiConfig;
}));
}).nThen(() => {
cb();
});
};
@ -3971,6 +4171,7 @@ define([
$container: APP.$toolbar,
pageTitle: Messages.adminPage || 'Admin',
metadataMgr: common.getMetadataMgr(),
skipLink: '#cp-sidebarlayout-container'
};
APP.toolbar = Toolbar.create(configTb);
APP.toolbar.$rightside.hide();

View File

@ -599,6 +599,18 @@
margin-top: 30px;
}
}
@media screen and (max-width: @browser_media-medium-screen) {
.cp-calendar-entries {
display: none;
}
.cp-calendar-entries.visible {
display: block;
}
}
.cp-calendar-entries {
margin-bottom: 10px;
}
.cp-calendar-entry {
display: flex;
align-items: center;
@ -628,6 +640,7 @@
}
&.cp-ghost {
padding: 0;
margin-top: 1rem;
button {
.tools_unselectable();
cursor: pointer;

View File

@ -775,7 +775,7 @@ define([
var data = APP.calendars[id];
var edit;
if (data.loading) {
edit = h('i.fa.fa-spinner.fa-spin');
edit = h('i.fa.fa-spinner.fa-spin', {'aria-hidden': 'true'});
} else {
edit = makeEditDropdown(id, teamId);
}
@ -796,8 +796,8 @@ define([
h('i.cp-calendar-inactive.fa.fa-calendar-o')
]),
h('span.cp-calendar-title', md.title),
data.restricted ? h('i.fa.fa-ban', {title: Messages.fm_restricted}) :
(isReadOnly(id, teamId) ? h('i.fa.fa-eye', {title: Messages.readonly}) : undefined),
data.restricted ? h('i.fa.fa-ban', {title: Messages.fm_restricted, 'aria-hidden': 'true'}) :
(isReadOnly(id, teamId) ? h('i.fa.fa-eye', {title: Messages.readonly, 'aria-hidden': 'true'}) : undefined),
edit
]);
var $calendar = $(calendar).click(function () {
@ -820,10 +820,14 @@ define([
if (APP.$calendars) { APP.$calendars.append(calendar); }
return calendar;
};
var makeLeftside = function (calendar, $container) {
// Show calendars
var calendars = h('div.cp-calendar-list');
var $calendars = APP.$calendars = $(calendars).appendTo($container);
var isMobileView = window.innerWidth <= 600;
var visible = !isMobileView; // Initialize 'visible' state: true for large screens, false for mobile
onCalendarsUpdate.reg(function () {
$calendars.empty();
var privateData = metadataMgr.getPrivateData();
@ -831,7 +835,7 @@ define([
var LOOKUP = {};
return Object.keys(APP.calendars || {}).filter(function (id) {
var cal = APP.calendars[id] || {};
var teams = (cal.teams || []).map(function (tId) { return Number(tId); });
var teams = (cal.teams || []).map(function (tId) { return Number(tId); });
return teams.indexOf(typeof(teamId) !== "undefined" ? Number(teamId) : 1) !== -1;
}).map(function (k) {
// nearly constant-time pre-sort
@ -872,26 +876,33 @@ define([
}
return;
}
var myCalendars = filter(1);
var totalCalendars = myCalendars.length + Object.keys(privateData.teams).reduce((sum, teamId) => {
return sum + filter(teamId).length;
}, 0);
var $contentContainer = $(h('div.cp-calendar-content')).appendTo($calendars);
if (myCalendars.length) {
var user = metadataMgr.getUserData();
var avatar = h('span.cp-avatar');
var uid = user.uid;
var name = user.name || Messages.anonymous;
common.displayAvatar($(avatar), user.avatar, name, function(){}, uid);
APP.$calendars.append(h('div.cp-calendar-team', [
$contentContainer.append(h('div.cp-calendar-team', [
avatar,
h('span.cp-name', {title: name}, name)
]));
myCalendars.forEach((id) => {
var calendarEntry = makeCalendarEntry(id, 1);
$contentContainer.append(calendarEntry);
});
}
myCalendars.forEach(function (id) {
makeCalendarEntry(id, 1);
});
// Add new button
var $newContainer = $(h('div.cp-calendar-entry.cp-ghost')).appendTo($calendars);
// Add the new calendar button
var $newContainer = $(h('div.cp-calendar-entry.cp-ghost')).appendTo($contentContainer);
var newButton = h('button', [
h('i.fa.fa-calendar-plus-o'),
h('i.fa.fa-calendar-plus-o', {'aria-hidden': 'true'}),
h('span', Messages.calendar_new),
h('span')
]);
@ -905,19 +916,61 @@ define([
var team = privateData.teams[teamId];
var avatar = h('span.cp-avatar');
common.displayAvatar($(avatar), team.avatar, team.displayName || team.name);
APP.$calendars.append(h('div.cp-calendar-team', [
var $teamContainer = h('div.cp-calendar-team', [
avatar,
h('span.cp-name', {title: team.name}, team.name)
]));
calendars.forEach(function (id) {
makeCalendarEntry(id, teamId);
h('span.cp-name', {title: team.name}, team.name),
h('span')
]);
$contentContainer.append($teamContainer);
calendars.forEach((id) => {
var calendarEntry = makeCalendarEntry(id, teamId);
$contentContainer.append(calendarEntry);
});
});
if(isMobileView) {
// If initial number of calendars or current number > 2,
// hide the calendars list and display a "show" button
if (APP.numberCalendars > 2 || totalCalendars > 2) {
var $showContainer = $(h('div.cp-calendar-entry.cp-ghost')).appendTo($calendars);
var iconClass = visible ? 'fa-eye-slash' : 'fa-eye';
var buttonText = visible ? Messages.calendar_hide : Messages.calendar_show;
var showCalendarsBtn = h('button', [
h('i.fa.' + iconClass, {'aria-hidden': "true"}),
h('span.cp-calendar-title', buttonText),
h('span')
]);
$(showCalendarsBtn).click(() => {
visible = !visible;
$contentContainer.toggle(visible);
iconClass = visible ? 'fa-eye-slash' : 'fa-eye';
buttonText = visible ? Messages.calendar_hide : Messages.calendar_show;
$(showCalendarsBtn).find('i').attr('class', 'fa ' + iconClass).attr('aria-hidden', "true");
$(showCalendarsBtn).find('span').first().text(visible ? Messages.calendar_hide : Messages.calendar_show);
}).appendTo($showContainer);
}
else {visible = true;}
}
$contentContainer.toggle(visible);
$(window).resize(function () {
var newIsMobileView = window.innerWidth <= 600;
if (!newIsMobileView) {
visible = true;
$contentContainer.show();
}
if (newIsMobileView !== isMobileView) {
isMobileView = newIsMobileView;
if (isMobileView) {
visible = false;
$contentContainer.hide();
}
onCalendarsUpdate.fire();
}
});
});
onCalendarsUpdate.fire();
};
var _updateRecurring = function () {
var cal = APP.calendar;
if (!cal) { return; }
@ -1275,17 +1328,15 @@ ICS ==> create a new event with the same UID and a RECURRENCE-ID field (with a v
store.put('calendarView', mode, function () {});
});
APP.toolbar.$bottomR.append($block);
// New event button
var newEventBtn = h('button.cp-calendar-newevent', [
h('i.fa.fa-plus'),
h('i.fa.fa-plus', {'aria-hidden': 'true'}),
h('span', Messages.calendar_newEvent)
]);
$(newEventBtn).click(function (e) {
e.preventDefault();
cal.openCreationPopup({isAllDay:false});
}).appendTo(APP.toolbar.$bottomL);
// Change page
var goLeft = h('button.fa.fa-chevron-left',{'aria-label': Messages.goLeft});
var goRight = h('button.fa.fa-chevron-right', {'aria-label': Messages.goRight});
@ -2094,6 +2145,7 @@ APP.recurrenceRule = {
$container: APP.$toolbar,
pageTitle: Messages.calendar,
metadataMgr: common.getMetadataMgr(),
skipLink: '#cp-sidebarlayout-container'
};
APP.toolbar = Toolbar.create(configTb);
APP.toolbar.$rightside.hide();
@ -2152,7 +2204,7 @@ APP.recurrenceRule = {
// Customize creation/update popup
var onCalendarPopup = function (el) {
var $el = $(el);
$el.find('.tui-full-calendar-confirm').addClass('btn btn-primary').prepend(h('i.fa.fa-floppy-o'));
$el.find('.tui-full-calendar-confirm').addClass('btn btn-primary').prepend(h('i.fa.fa-floppy-o', {'aria-hidden': 'true'}));
$el.find('input').attr('autocomplete', 'off');
$el.find('.tui-full-calendar-dropdown-button').addClass('btn btn-secondary');
$el.find('.tui-full-calendar-popup-close').addClass('btn btn-cancel fa fa-times cp-calendar-close').empty();
@ -2234,7 +2286,7 @@ APP.recurrenceRule = {
$el.find('.tui-full-calendar-content').removeClass('tui-full-calendar-content');
var delButton = h('button.btn.btn-danger', [
h('i.fa.fa-trash'),
h('i.fa.fa-trash', {'aria-hidden': 'true'}),
h('span', Messages.kanban_delete)
]);
var $del = $el.find('.tui-full-calendar-popup-delete').hide();
@ -2267,7 +2319,7 @@ APP.recurrenceRule = {
// This is a recurring event, add button to stop recurrence now
var $b = $(h('button.btn.btn-default', [
h('i.fa.fa-times'),
h('i.fa.fa-times', {'aria-hidden': 'true'}),
h('span', Messages.calendar_rec_stop)
])).insertBefore($section);
UI.confirmButton($b[0], { classes: 'btn-default' }, function () {
@ -2340,7 +2392,9 @@ APP.recurrenceRule = {
});
var store = window.cryptpadStore;
APP.module.execCommand('SUBSCRIBE', null, function (obj) {
if (obj.empty && !privateData.calendarHash) {
let empty = !obj.length;
APP.numberCalendars = obj.length;
if (empty && !privateData.calendarHash) {
if (!privateData.loggedIn) {
return void UI.errorLoadingScreen(Messages.mustLogin, false, function () {
common.setLoginRedirect('login');

View File

@ -610,6 +610,7 @@ define([
Framework.create({
toolbarContainer: '#cme_toolbox',
contentContainer: '#cp-app-code-editor',
skipLink: '.CodeMirror',
thumbnail: {
getContainer: getThumbnailContainer,
filter: function (el, before) {

View File

@ -27,6 +27,6 @@ define(['/customize/application_config.js'], function (AppConfig) {
MAX_PREMIUM_TEAMS_OWNED: Math.max(AppConfig.maxTeamsOwned || 0, AppConfig.maxPremiumTeamsOwned || 0) || 5,
// Apps
criticalApps: ['profile', 'settings', 'debug', 'admin', 'support', 'notifications', 'calendar', 'moderation', 'oldadmin'], // XXX oldadmin
earlyAccessApps: ['doc', 'presentation']
earlyAccessApps: []
};
});

View File

@ -1237,6 +1237,7 @@ define([
var addTippy = function (i, el) {
if (el._tippy) { return; }
if (!el.getAttribute('title')) { return; }
if (el.getAttribute('data-notippy')) { return; }
if (el.nodeName === 'IFRAME') { return; }
var opts = {
distance: 15

View File

@ -2771,7 +2771,11 @@ define([
]);
// Password
var password = h('div.cp-creation-password', [
let text;
if (type === 'form') {
text = h('div.cp-creation-password-warning.alert.alert-info.dismissable', h('span.cp-inline-alert-text', Messages.form_passwordWarning));
}
var password = h('div.cp-creation-password', [
UI.createCheckbox('cp-creation-password', Messages.properties_addPassword, false),
h('span.cp-creation-password-picker.cp-creation-slider', [
UI.passwordInput({id: 'cp-creation-password-val'})
@ -2809,6 +2813,7 @@ define([
expire,
password,
]),
text,
templates,
createDiv
])).appendTo($creation);

View File

@ -13,6 +13,18 @@
return Array.prototype.slice.call(A, start, end);
};
Util.u8ToBase64 = (u8, cb) => {
const reader = new FileReader();
reader.onload = () => {
let res = reader.result;
let trim = res.slice(res.indexOf(',') + 1);
cb(trim);
};
reader.readAsDataURL(new Blob([u8]));
};
Util.shuffleArray = function (a) {
for (var i = a.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
@ -74,6 +86,12 @@
Util.mkEvent = function (once) {
var handlers = [];
var fired = false;
let promiseResolve;
const promise = new Promise(resolve => {
promiseResolve = resolve;
});
return {
reg: function (cb) {
if (once && fired) { return void setTimeout(cb); }
@ -87,10 +105,13 @@
},
fire: function () {
if (once && fired) { return; }
fired = true;
var args = Array.prototype.slice.call(arguments);
if (!fired) { promiseResolve.apply(null, args); }
fired = true;
handlers.forEach(function (h) { h.apply(null, args); });
}
},
// Since a promise can only resolve once only the 1st call to fire() is reflected here. Even is `once` is `false`.
promise
};
};

View File

@ -928,13 +928,15 @@ define([
delete meta.cursor;
if (meta.type === "form") {
// Keep anonymous and makeAnonymous values from templates
// Keep anonymous, makeAnonymous and submit message values from templates
var anonymous = parsed.answers.anonymous || false;
var makeAnonymous = parsed.answers.makeAnonymous || false;
var msg = parsed.answers.msg || undefined;
delete parsed.answers;
parsed.answers = {
anonymous: anonymous,
makeAnonymous: makeAnonymous
makeAnonymous: makeAnonymous,
msg: msg
};
}
}

View File

@ -314,20 +314,29 @@ define([
};
APP.selectedFiles = [];
var findElementId = function ($element) {
var isTrashed = $element.data("path")[0] === TRASH;
let elementId;
if (isTrashed) {
elementId = $element.data("path").join(',');
} else {
elementId = $element.data("path").slice(-1)[0];
}
return elementId;
};
var isElementSelected = function ($element) {
var elementId = $element.data("path").slice(-1)[0];
var elementId = findElementId($element);
return APP.selectedFiles.indexOf(elementId) !== -1;
};
var selectElement = function ($element) {
var elementId = $element.data("path").slice(-1)[0];
var elementId = findElementId($element);
if (APP.selectedFiles.indexOf(elementId) === -1) {
APP.selectedFiles.push(elementId);
}
$element.addClass("cp-app-drive-element-selected");
};
var unselectElement = function ($element) {
var elementId = $element.data("path").slice(-1)[0];
var elementId = findElementId($element);
var index = APP.selectedFiles.indexOf(elementId);
if (index !== -1) {
APP.selectedFiles.splice(index, 1);
@ -1462,7 +1471,6 @@ define([
}
});
if (paths.length > 1) {
hide.push('restore');
hide.push('properties', 'access');
hide.push('rename');
hide.push('openparent');
@ -2911,9 +2919,9 @@ define([
]);
var content = h('p', [
h('label', {for: 'cp-app-drive-link-name'}, Messages.fm_link_name),
name = h('input#cp-app-drive-link-name', { autocomplete: 'off', placeholder: Messages.fm_link_name_placeholder, tabindex:'1'}),
name = h('input#cp-app-drive-link-name', { autocomplete: 'off', placeholder: Messages.fm_link_name_placeholder}),
h('label', {for: 'cp-app-drive-link-url'}, Messages.fm_link_url),
url = h('input#cp-app-drive-link-url', { type: 'url', autocomplete: 'off', placeholder: Messages.form_input_ph_url,tabindex:'1'}),
url = h('input#cp-app-drive-link-url', { type: 'url', autocomplete: 'off', placeholder: Messages.form_input_ph_url}),
warning,
]);
@ -2928,9 +2936,13 @@ define([
};
var $warning = $(warning).hide();
var $url = $(url).on('change keypress keyup keydown', function () {
var $url = $(url).on('change keypress keydown', function () {
var v = $url.val().trim();
$url.toggleClass('cp-input-invalid', !Util.isValidURL(v));
if (Util.isValidURL(v)) {
$url.removeClass('cp-input-invalid');
} else {
$url.addClass('cp-input-invalid');
}
if (v.length > 200) {
$warning.show();
return;
@ -2953,8 +2965,7 @@ define([
var $name = $(name);
var n = $name.val().trim() || $name.attr('placeholder');
var u = $url.val().trim();
if (!n || !u) { return true; }
if (!Util.isValidURL(u)) {
if (!n || !u || !Util.isValidURL(u)) {
UI.warn(Messages.fm_link_invalid);
return true;
}
@ -3445,6 +3456,58 @@ define([
return $fihElement;
};
var lexicographicCompare = function(a, b) {
if (!Array.isArray(a)) {
a = [a];
}
if (!Array.isArray(b)) {
b = [b];
}
if(a.length === 0 && b.length === 0) {
return 0;
} else if (a.length === 0) {
return -1;
} else if (b.length === 0) {
return 1;
} else if(a[0] < b[0]) {
return -1;
} else if(a[0] > b[0]) {
return 1;
} else {
// This means `a[0] == b[0]`. Chop off the first elements and compare the rest.
return lexicographicCompare(a.slice(1), b.slice(1));
}
};
var splitStringToTextAndNumbers = function(s) {
var textOrDigitsRe = /(?<text>\D+)?(?<digits>\d+)?/g;
var split = [];
for (var match of s.matchAll(textOrDigitsRe)) {
if (match.groups.text !== undefined) {
split.push(match.groups.text);
}
if (match.groups.digits !== undefined) {
split.push(parseInt(match.groups.digits));
}
}
return split;
};
var naturalSort = function(a, b) {
if (typeof(a) === "string") {
a = splitStringToTextAndNumbers(a);
}
if (typeof(b) === "string") {
b = splitStringToTextAndNumbers(b);
}
var comp = lexicographicCompare(a, b);
return comp;
};
var sortElements = function (folder, path, oldkeys, prop, asc, useId) {
var root = path && manager.find(path);
if (path[0] === SHARED_FOLDER) {
@ -3489,9 +3552,8 @@ define([
keys.sort(function(a, b) {
var _a = props[(a && a.uid) || a];
var _b = props[(b && b.uid) || b];
if (_a < _b) { return mult * -1; }
if (_b < _a) { return mult; }
return 0;
return mult * naturalSort(_a, _b);
});
return keys;
};
@ -4505,8 +4567,7 @@ define([
manager.getSharedFolderData(root[a]).title : a;
var newB = manager.isSharedFolder(root[b]) ?
manager.getSharedFolderData(root[b]).title : b;
return newA < newB ? -1 :
(newA === newB ? 0 : 1);
return naturalSort(newA, newB);
});
keys.forEach(function (key) {
// Do not display files in the menu
@ -4734,6 +4795,7 @@ define([
data.sharedFolderId = sfId;
data.name = Util.fixFileName(folderName);
data.folderName = Util.fixFileName(folderName) + '.zip';
data.common = common;
var uo = manager.user.userObject;
if (sfId && manager.folders[sfId]) {
@ -4915,7 +4977,7 @@ define([
else if ($this.hasClass('cp-app-drive-context-download')) {
if (paths.length !== 1) { return; }
var path = paths[0];
el = manager.find(path.path);
el = $(path.element).data('element') || manager.find(path.path);
// folder
if (manager.isFolder(el)) {
// folder
@ -5189,24 +5251,45 @@ define([
return void deletePaths(paths);
}
else if ($this.hasClass("cp-app-drive-context-restore")) {
if (paths.length !== 1) { return; }
var restorePath = paths[0].path;
var restoreName = paths[0].path[paths[0].path.length - 1];
if (restorePath.length === 4) {
var rEl = manager.find(restorePath);
if (manager.isFile(rEl)) {
restoreName = manager.getTitle(rEl);
} else if (manager.isSharedFolder(rEl)) {
var sfData = manager.getSharedFolderData(rEl);
restoreName = sfData.title || sfData.lastTitle || Messages.fm_deletedFolder;
} else {
restoreName = restorePath[1];
let getRestoreProperties = (path) => {
let restorePath = path;
let restoreName = path.at(-1);
if (restorePath.length === 4) {
let rEl = manager.find(restorePath);
if (manager.isFile(rEl)) {
restoreName = manager.getTitle(rEl);
} else if (manager.isSharedFolder(rEl)) {
let sfData = manager.getSharedFolderData(rEl);
restoreName = sfData.title || sfData.lastTitle || Messages.fm_deletedFolder;
} else {
restoreName = restorePath[1];
}
}
return [restorePath, restoreName];
};
let restoreNumber = paths.length;
if (restoreNumber === 0) { return; }
if (restoreNumber === 1) { // single file restoration
let [restorePath, restoreName] = getRestoreProperties(paths[0].path);
UI.confirm(Messages._getKey("fm_restoreDialog", [restoreName]), res => {
if (!res) { return; }
manager.restore(restorePath, refresh);
});
} else { // multiple files restoration
UI.confirm(Messages._getKey("fm_restoreMultipleDialog", [restoreNumber]), res => {
if (!res) { return; }
nThen(waitFor => {
paths.forEach(path => {
if (!path) { // We met an error
console.error("Error while restoring files: no path");
return;
}
let restorePath = getRestoreProperties(path.path)[0];
setTimeout(manager.restore(restorePath, waitFor()), 10);
});
}).nThen(refresh);
});
}
UI.confirm(Messages._getKey("fm_restoreDialog", [restoreName]), function(res) {
if (!res) { return; }
manager.restore(restorePath, refresh);
});
}
else if ($this.hasClass("cp-app-drive-context-openparent")) {
if (paths.length !== 1) { return; }

View File

@ -1108,7 +1108,7 @@ define([
var owned = Modal.isOwned(Env, data);
// Request edit access
if (common.isLoggedIn() && data.roHref && !owned && !opts.calendar && priv.app !== 'form') {
if (common.isLoggedIn() && data.roHref && !owned && !opts.calendar && priv.app !== 'form' && !data.href) {
var requestButton = h('button.btn.btn-secondary.no-margin.cp-access-margin-right',
Messages.requestEdit_button);
var requestBlock = h('p', requestButton);

View File

@ -49,7 +49,13 @@ define([
// Access modal and the pad is not stored: get the hashes from outer
var hashes = priv.hashes || {};
// For calendars, individual href is passed via opts
data.href = ((priv.app === 'calendar') && opts.href) || Hash.hashToHref(hashes.editHash || hashes.fileHash, priv.app);
if (priv.app === 'calendar') {
data.href = opts.href;
} else if (hashes.editHash || hashes.fileHash) {
data.href = Hash.hashToHref(hashes.editHash || hashes.fileHash, priv.app);
} else {
data.href = undefined;
}
if (hashes.viewHash) {
data.roHref = Hash.hashToHref(hashes.viewHash, priv.app);
}

View File

@ -156,6 +156,35 @@ define([
return box;
};
// opts.values = { key1:label1, key2:label2 }
blocks.radio = (key, state, opts, onChange) => {
if (!opts?.values) {
return void console.error('NO_VALUES');
}
let all = Object.keys(opts.values).map(k => {
let v = opts.values[k];
let r = UI.createRadio(
`cp-${app}-${key}`,
`cp-${app}-${key}-${k}`,
v, state === k, {
input: { value: k },
label: { class: 'noTitle' }
}
);
if (typeof(onChange) === "function"){
$(r).find('input').on('change', function() {
onChange(k);
});
}
return r;
});
let block = h('div.cp-sidebar-flex-block', all);
if (opts && opts.spinner) {
block.spinner = UI.makeSpinner($(block));
}
return block;
};
blocks.table = function (header, entries) {
const table = h('table.cp-sidebar-table');
if (header) {
@ -255,6 +284,13 @@ define([
return box;
};
blocks.hintItem = (hint, item) => {
return blocks.form([
h('span.cp-sidebarlayout-description-item', hint),
item
]);
};
return blocks;
};

View File

@ -169,10 +169,17 @@ define([
data: fData
});
}
var href = (fData.href && fData.href.indexOf('#') !== -1) ? fData.href : fData.roHref;
var parsed = Hash.parsePadUrl(href);
if (['pad', 'file'].indexOf(parsed.hashData.type) === -1) { return; }
var href;
var parsed;
if (!fData.channel) {
href = fData.href;
parsed = {};
parsed['hashData'] = {type: 'link'};
} else {
href = (fData.href && fData.href.indexOf('#') !== -1) ? fData.href : fData.roHref;
parsed = Hash.parsePadUrl(href);
}
if (['pad', 'file', 'link'].indexOf(parsed.hashData.type) === -1) { return; }
// waitFor is used to make sure all the pads and files are process before downloading the zip.
var w = ctx.waitFor();
@ -220,7 +227,7 @@ define([
var opts = {
password: fData.password
};
var rawName = fData.filename || fData.title || 'File';
var rawName = fData.filename || fData.title || fData.name || 'File';
console.log(rawName);
// Pads (pad,code,slide,kanban,poll,...)
@ -276,8 +283,21 @@ define([
}
}, 50);
};
var todoLink = function () {
var opts = {
binary: true,
};
var fileName = getUnique(sanitize(rawName), '.txt', existingNames);
existingNames.push(fileName.toLowerCase());
var content = new Blob([fData.href, '\n'], { type: "text/plain;charset=utf-8" });
zip.file(fileName, content, opts);
console.log('DONE ---- ' + fileName);
setTimeout(done, 1000);
};
if (parsed.hashData.type === 'file') {
return void todoFile();
} else if (parsed.hashData.type === 'link') {
return void todoLink();
}
todoPad();
});
@ -286,7 +306,7 @@ define([
};
// Add folders and their content recursively in the zip
var makeFolder = function (ctx, root, zip, fd) {
var makeFolder = function (ctx, root, zip, fd, sd) {
if (typeof (root) !== "object") { return; }
var existingNames = [];
Object.keys(root).forEach(function (k) {
@ -294,19 +314,20 @@ define([
if (typeof el === "object" && el.metadata !== true) { // if folder
var fName = getUnique(sanitize(k), '', existingNames);
existingNames.push(fName.toLowerCase());
return void makeFolder(ctx, el, zip.folder(fName), fd);
return void makeFolder(ctx, el, zip.folder(fName), fd, sd);
}
if (ctx.data.sharedFolders[el]) { // if shared folder
let staticData = ctx.sf[el].static;
var sfData = ctx.sf[el].metadata;
var sfName = getUnique(sanitize((sfData && sfData.title) || 'Folder'), '', existingNames);
existingNames.push(sfName.toLowerCase());
return void makeFolder(ctx, ctx.sf[el].root, zip.folder(sfName), ctx.sf[el].filesData);
return void makeFolder(ctx, ctx.sf[el].root, zip.folder(sfName), ctx.sf[el].filesData, staticData);
}
var fData = fd[el];
var fData = fd[el] || (sd && sd[el]);
if (fData) {
addFile(ctx, zip, fData, existingNames);
return;
}
}
});
};
@ -327,14 +348,27 @@ define([
max: 0,
done: 0,
cache: cache,
sframeChan: sframeChan
sframeChan: sframeChan,
common: data.common,
};
var filesData = data.sharedFolderId && ctx.sf[data.sharedFolderId] ? ctx.sf[data.sharedFolderId].filesData : ctx.data.filesData;
var links = ctx.sf[data.sharedFolderId] && ctx.sf[data.sharedFolderId].static ? ctx.data.static && ctx.sf[data.sharedFolderId].static : ctx.data.static;
if (ctx.common && !ctx.common.isLoggedIn()) {
// Anonymous Drive
ctx.data.root = {};
let index = 0;
Object.keys(ctx.data.filesData).forEach(file => {
ctx.data.root[index] = file;
index += 1;
});
}
progress('reading', -1); // Msg.settings_export_reading
nThen(function (waitFor) {
ctx.waitFor = waitFor;
var zipRoot = ctx.zip.folder(data.name || Messages.fm_rootName);
makeFolder(ctx, ctx.folder || ctx.data.root, zipRoot, filesData);
makeFolder(ctx, ctx.folder || ctx.data.root, zipRoot, filesData, links);
progress('download', {}); // Msg.settings_export_download
}).nThen(function () {
console.log(ctx.zip);

View File

@ -68,6 +68,8 @@ define([
var NEW_VERSION = 7; // version of the .bin, patches and ChainPad formats
var PENDING_TIMEOUT = 30000;
var CURRENT_VERSION = X2T.CURRENT_VERSION;
const HISTORY_KEEPER_INDEX_USER = 1;
const READ_ONLY_INDEX_USER = 2;
//var READONLY_REFRESH_TO = 15000;
@ -181,17 +183,17 @@ define([
});
};
const getNewUserIndex = function () {
const ids = content.ids || {};
const indexes = Object.values(ids).map((user) => user.index);
const maxIndex = Math.max(...indexes);
return maxIndex === -Infinity ? 1 : maxIndex+1;
const getNextUserIndex = function () {
let nextUserIndex;
do {
nextUserIndex = Util.createRandomInteger();
} while (nextUserIndex === HISTORY_KEEPER_INDEX_USER || nextUserIndex === READ_ONLY_INDEX_USER);
return nextUserIndex;
};
var setMyId = function () {
// Remove ids for users that have left the channel
deleteOffline();
var ids = content.ids;
deleteOffline(); // Remove ids for users that have left the channel
const ids = content.ids;
if (!myOOId) {
myOOId = Util.createRandomInteger();
// f: function used in .some(f) but defined outside of the while
@ -202,13 +204,21 @@ define([
myOOId = Util.createRandomInteger();
}
}
var myId = getId();
const myId = getId();
const myIndex = getNextUserIndex();
ids[myId] = {
ooid: myOOId,
index: getNewUserIndex(),
index: myIndex,
netflux: metadataMgr.getNetfluxId()
};
oldIds = JSON.parse(JSON.stringify(ids));
if (!myUniqueOOId) {
myUniqueOOId = String(myOOId) + myIndex;
}
oldIds = structuredClone(ids);
APP.onLocal();
};
@ -388,6 +398,10 @@ define([
};
var onUploaded = function (ev, data, err) {
if (!ev && err) {
console.error(err);
return void UI.warn(Messages.error);
}
if (ev.newTemplate) {
if (err) {
console.error(err);
@ -545,7 +559,7 @@ define([
var saveToServer = function (blob, title) {
if (APP.cantCheckpoint) { return; } // TOO_LARGE
var text = getContent();
var text = !blob && getContent();
if (!text && !blob) {
setEditable(false, true);
sframeChan.query('Q_CLEAR_CACHE_CHANNELS', [
@ -920,56 +934,44 @@ define([
const getMyOOIndex = function() {
const user = findUserByOOId(myOOId);
return user
? user.index
: content.ids.length; // Assign an unused id to read-only users
return user ? user.index : READ_ONLY_INDEX_USER;
};
var getParticipants = function () {
var users = metadataMgr.getMetadata().users;
var i = 1;
var p = Object.keys(content.ids || {}).map(function (id) {
var nId = id.slice(0,32);
if (!users[nId]) { return; }
var ooId = content.ids[id].ooid;
var idx = content.ids[id].index;
if (!ooId || ooId === myOOId) { return; }
if (idx >= i) { i = idx + 1; }
return {
id: String(ooId) + idx,
idOriginal: String(ooId),
username: (users[nId] || {}).name || Messages.anonymous,
indexUser: idx,
connectionId: content.ids[id].netflux || Hash.createChannelId(),
isCloseCoAuthoring:false,
view: false
};
});
const getParticipants = function () {
const users = metadataMgr.getMetadata().users;
// Add an history keeper user to show that we're never alone
var hkId = Util.createRandomInteger();
p.push({
id: hkId,
const historyKeeper = [{
id: String(hkId),
idOriginal: String(hkId),
username: "History",
indexUser: i,
indexUser: HISTORY_KEEPER_INDEX_USER,
connectionId: Hash.createChannelId(),
isCloseCoAuthoring:false,
view: false
}];
const realParticipants = Object.entries(content.ids).map(([id, user]) => {
const nId = id.slice(0,32);
const username = Util.find(privateData, ['integrationConfig', 'user', 'name']) ||
(users[nId] || {}).name || Messages.anonymous;
return {
id: String(user.ooid) + user.index,
idOriginal: String(user.ooid),
username,
indexUser: user.index,
connectionId: user.netflux || Hash.createChannelId(),
isCloseCoAuthoring: false,
view: false
};
});
const myOOIndex = getMyOOIndex();
if (!myUniqueOOId) { myUniqueOOId = String(myOOId) + myOOIndex; }
p.push({
id: String(myOOId),
idOriginal: String(myOOId),
username: metadataMgr.getUserData().name || Messages.anonymous,
indexUser: myOOIndex,
connectionId: metadataMgr.getNetfluxId() || Hash.createChannelId(),
isCloseCoAuthoring:false,
view: false
});
const participants = historyKeeper.concat(realParticipants);
return {
index: myOOIndex,
list: p.filter(Boolean)
index: getMyOOIndex(),
list: participants,
};
};
@ -1473,6 +1475,20 @@ define([
});
}
break;
case "forceSaveStart":
if (APP.integrationSave) {
APP.integrationSave(obj => {
if (obj?.error) {
console.error(obj.error);
return void UI.warn(Messages.error);
}
content.integrationSave = `${myUniqueOOId}-${+new Date()}`;
APP.integrationSaved = content.integrationSave;
APP.onLocal();
UI.log(Messages.saved);
});
}
break;
case "getLock":
handleLock(obj, send);
break;
@ -1681,31 +1697,42 @@ define([
var lang = (window.cryptpadLanguage || navigator.language || navigator.userLanguage || '').slice(0,2);
let username = Util.find(privateData, ['integrationConfig', 'user', 'name'])
|| metadataMgr.getUserData().name
|| Messages.anonymous;
let integrationConfig = privateData?.integrationConfig?._;
//let ec = integrationConfig?.editorConfig;
let dc = integrationConfig?.document;
// Config
APP.ooconfig = {
"document": {
"fileType": file.type,
"key": "fresh",
"title": file.title,
"url": url,
"permissions": {
"download": false,
"print": true,
document: {
fileType: file.type,
key: "fresh",
title: dc?.title || file.title,
url: url,
permissions: {
download: dc?.permissions?.download || false,
print: dc?.permissions?.print || true,
}
},
"documentType": file.doc,
"editorConfig": {
customization: {
compactHeader: true,
chat: false,
logo: {
url: "/bounce/#" + encodeURIComponent('https://www.onlyoffice.com')
},
comments: !lock && !readOnly
comments: !lock && !readOnly,
hideRightMenu: true,
uiTheme: window.CryptPad_theme === "dark" ? "theme-dark" : "theme-classic-light"
},
"user": {
"id": String(myOOId), //"c0c3bf82-20d7-4663-bf6d-7fa39c598b1d",
"firstname": metadataMgr.getUserData().name || Messages.anonymous,
"name": metadataMgr.getUserData().name || Messages.anonymous,
"firstname": username,
"name": username
},
"mode": "edit",
"lang": lang
@ -2136,6 +2163,35 @@ Uncaught TypeError: Cannot read property 'calculatedType' of null
}, void 0, common.getCache());
};
let copy = (a, b) => {
Object.keys(b).forEach(k => {
if (k === "user") { return; } // Don't change user values
if (a[k]) {
if (typeof(a[k]) === "object" && typeof(b[k]) === "object") {
copy(a[k], b[k]);
}
return;
}
a[k] = b[k];
});
};
if (integrationConfig) {
let ec = integrationConfig.editorConfig;
let c = APP.ooconfig.editorConfig.customization;
copy(APP.ooconfig.editorConfig, ec);
// Open "goback" in new tabs because of csp and
// iframes
if (ec.editorConfig?.customization?.goback) {
c.goback.blank = true;
}
c.forcesave = true;
}
// Always hide right menu
localStorage?.original.removeItem('sse-hide-right-settings');
localStorage?.original.removeItem('de-hide-right-settings');
localStorage?.original.removeItem('pe-hide-right-settings');
APP.docEditor = new window.DocsAPI.DocEditor("cp-app-oo-placeholder-a", APP.ooconfig);
ooLoaded = true;
makeChannel();
@ -2670,7 +2726,8 @@ Uncaught TypeError: Cannot read property 'calculatedType' of null
},
sfCommon: common,
$container: $bar,
$contentContainer: $('#cp-app-oo-container')
$contentContainer: $('#cp-app-oo-container'),
skipLink: 'iframe[name="frameEditor"]|#editor_sdk'
};
toolbar = APP.toolbar = Toolbar.create(configTb);
toolbar.showColors();
@ -3176,9 +3233,9 @@ Uncaught TypeError: Cannot read property 'calculatedType' of null
return void UI.errorLoadingScreen(Messages.error);
}
var blob = new Blob([bin], {type: 'text/plain'});
var file = getFileType();
resetData(blob, file);
//saveToServer(blob, title);
//var file = getFileType();
//resetData(blob, file);
saveToServer(blob, title);
Title.updateTitle(title);
UI.removeLoadingScreen();
});
@ -3189,13 +3246,17 @@ Uncaught TypeError: Cannot read property 'calculatedType' of null
let cfg = privateData.integrationConfig || {};
common.openIntegrationChannel(APP.onLocal);
integrationChannel = common.getSframeChannel();
let hasUnsavedChanges = false;
var integrationSave = function (cb) {
var ext = cfg.fileType;
var upload = Util.once(function (_blob) {
integrationChannel.query('Q_INTEGRATION_SAVE', {
blob: _blob
}, cb, {
}, obj => {
if (!obj?.error) { hasUnsavedChanges = false; }
cb(obj);
}, {
raw: true
});
});
@ -3216,21 +3277,46 @@ Uncaught TypeError: Cannot read property 'calculatedType' of null
};
var inte = common.createIntegration(integrationSave,
integrationHasUnsavedChanges);
if (inte) {
if (inte && cfg.autosave) {
evIntegrationSave.reg(function () {
inte.changed();
});
} else {
APP.integrationSave = integrationSave;
APP.integrationSetSaved = () => {
hasUnsavedChanges = false;
};
evIntegrationSave.reg(function () {
hasUnsavedChanges = true;
});
}
$(window).on('beforeunload', function (ev) {
if (hasUnsavedChanges) { return false; }
ev.returnValue = '';
});
integrationChannel.on('Q_INTEGRATION_NEEDSAVE', function (data, cb) {
if (!cfg.autosave) { return; }
integrationSave(function (obj) {
if (obj && obj.error) { console.error(obj.error); }
cb();
});
});
if (privateData.initialState) {
/* test button
if (!cfg.autosave) {
let $save = common.createButton('save', true, {}, function () {
$save.attr('disabled', 'disabled');
integrationSave(() => {
$save.removeAttr('disabled');
});
});
$('body').prepend($save);
}
*/
if (privateData.initialState && (!content || !content.hashes || !Object.keys(content.hashes).length)) {
var blob = privateData.initialState;
let title = `document.${cfg.fileType}`;
console.error(blob, title);
return convertImportBlob(blob, title);
}
}
@ -3365,6 +3451,7 @@ Uncaught TypeError: Cannot read property 'calculatedType' of null
var wasMigrating = content.migration;
var myLocks = getUserLock(getId(), true);
//var integrationSave = content.integrationSave;
content = json.content;
@ -3375,6 +3462,15 @@ Uncaught TypeError: Cannot read property 'calculatedType' of null
checkCheckpoint();
}
// Integration: mark the current content as saved
// if manually saved by someone else
if (content.integrationSave !== APP.integrationSaved) {
APP.integrationSaved = content.integrationSave;
if (APP.integrationSetSaved) {
APP.integrationSetSaved();
}
}
var editor = getEditor();
if (content.hashes) {
var latest = getLastCp(true);
@ -3409,7 +3505,7 @@ Uncaught TypeError: Cannot read property 'calculatedType' of null
if (content.ids) {
handleNewIds(oldIds, content.ids);
oldIds = JSON.parse(JSON.stringify(content.ids));
oldIds = structuredClone(content.ids);
}
if (content.locks) {
handleNewLocks(oldLocks, content.locks);

View File

@ -43,6 +43,7 @@ define([
SF, Cursor, Support, Integration, OnlyOffice, Mailbox, Profile, Team, Messenger, History,
Calendar, Block, NetConfig, AppConfig,
Crypto, ChainPad, CpNetflux, Listmap, Netflux, nThen, Saferphore) {
const Nacl = window.nacl;
var onReadyEvt = Util.mkEvent(true);
var onCacheReadyEvt = Util.mkEvent(true);
@ -467,6 +468,19 @@ define([
});
};
var initTempRpc = (clientId, cb) => {
if (store.rpc) { return void cb(store.rpc); }
var kp = Crypto.Nacl.sign.keyPair();
var keys = {
edPublic: Crypto.Nacl.util.encodeBase64(kp.publicKey),
edPrivate: Crypto.Nacl.util.encodeBase64(kp.secretKey)
};
Pinpad.create(store.network, keys, function (e, call) {
if (e) { return void cb({error: e}); }
store.rpc = call;
cb(call);
});
};
var initRpc = function (clientId, data, cb) {
if (!store.loggedIn) { return cb(); }
if (store.rpc) { return void cb(account); }
@ -474,6 +488,7 @@ define([
if (e) { return void cb({error: e}); }
store.rpc = call;
store.onRpcReadyEvt.fire();
Store.getPinLimit(null, null, function (obj) {
@ -1830,7 +1845,7 @@ define([
Store.leavePad(null, data, function () {});
};
var conf = {
Cache: Cache, // ICE pad cache
Cache: store.neverCache ? undefined : Cache,
onCacheStart: function () {
postMessage(clientId, "PAD_CACHE");
},
@ -3160,7 +3175,7 @@ define([
// If we load CryptPad for the first time from an existing pad, don't create a
// drive automatically.
var onNoDrive = function (clientId, cb) {
var onNoDrive = function (clientId, cb, initRpc) {
var andThen = function () {
// To be able to use all the features inside the pad, we need to
// initialize the chat (messenger) and the cursor modules.
@ -3171,9 +3186,16 @@ define([
store.messenger = store.modules['messenger'];
// And now we're ready
initAnonRpc(null, null, function () {
cb({});
});
let getAnon = () => {
initAnonRpc(null, null, function () {
cb({});
});
};
if (initRpc) {
return initTempRpc(clientId, getAnon);
}
getAnon();
};
// We need an anonymous RPC to be able to check if the pad exists and to get
@ -3256,7 +3278,8 @@ define([
// First tab, no user hash, no anon hash and this app doesn't need a drive
// ==> don't create a drive
// Or "neverDrive" (integration into another platform?)
// ==> don't create a drive
// ==> don't create a drive BUT create temp RPC (we may need to upload)
if (data.neverDrive) { store.neverCache = true; }
if (data.neverDrive || (data.noDrive && !data.userHash && !data.anonHash)) {
return void onNoDrive(clientId, function (obj) {
if (obj && obj.error) {
@ -3270,7 +3293,7 @@ define([
}
Feedback.send("NO_DRIVE", true);
callback(obj);
});
}, !!data.neverDrive);
}
initialized = true;

View File

@ -599,7 +599,7 @@ define([
ctx.clients.push(cId);
}
cb({
empty: !Object.keys(ctx.calendars).length
length: Object.keys(ctx.calendars).length
});
Object.keys(ctx.calendars).forEach(function (channel) {
var c = ctx.calendars[channel] || {};

View File

@ -104,6 +104,7 @@ define([
msg = JSON.parse(msg);
} catch (e) {
console.error(e);
return; // Don't show "undefined" messages
}
msg.time = time;
if (author) { msg.author = author; }
@ -232,6 +233,7 @@ define([
var anonRpc = Util.find(ctx, [ 'store', 'anon_rpc' ]);
if (!mailbox) { return void cb('E_NOT_READY'); }
if (!anonRpc) { return void cb("anonymous rpc session not ready"); }
if (!data?.ticket) { return void cb('E_NO_DATA'); }
var theirPublic, myCurve, notifKey;
var time;
nThen((waitFor) => {

View File

@ -976,7 +976,8 @@ define([
realtime: cpNfInner.chainpad,
sfCommon: common,
$container: $(toolbarContainer),
$contentContainer: $(contentContainer)
$contentContainer: $(contentContainer),
skipLink: options.skipLink,
};
toolbar = Toolbar.create(configTb);
title.setToolbar(toolbar);

View File

@ -17,7 +17,9 @@ define([
nThen(function (waitFor) {
DomReady.onReady(waitFor());
}).nThen(function (waitFor) {
var obj = SFCommonO.initIframe(waitFor, true, integration.pathname);
let lang = integration && integration.config && integration.config.editorConfig
&& integration.config.editorConfig.lang;
var obj = SFCommonO.initIframe(waitFor, true, integration.pathname, lang);
href = obj.href;
hash = obj.hash;
if (isIntegration) {

View File

@ -25,16 +25,19 @@ define([
delete ls[k];
});
};
var mkFakeStore = function () {
var mkFakeStore = function (original) {
var fakeStorage = {
getItem: function (k) { return fakeStorage[k]; },
setItem: function (k, v) { fakeStorage[k] = v; return v; },
removeItem: function (k) { delete fakeStorage[k]; }
removeItem: function (k) { delete fakeStorage[k]; },
original
};
return fakeStorage;
};
window.__defineGetter__('localStorage', function () { return mkFakeStore(); });
window.__defineGetter__('sessionStorage', function () { return mkFakeStore(); });
let loc = localStorage;
let ses = sessionStorage;
window.__defineGetter__('localStorage', function () { return mkFakeStore(loc); });
window.__defineGetter__('sessionStorage', function () { return mkFakeStore(ses); });
window.CRYPTPAD_INSIDE = true;

View File

@ -19,9 +19,7 @@ define([
'/common/media-tag.js',
'/components/file-saver/FileSaver.min.js',
'/components/tweetnacl/nacl-fast.min.js',
], function ($, ApiConfig, FileCrypto, MakeBackup, Thumb, UI, UIElements, Util, Hash, h, Messages, Pages, nThen, MT) {
var Nacl = window.nacl;
var module = {};
var blobToArrayBuffer = function (blob, cb) {
@ -221,10 +219,12 @@ define([
file.noStore = config.noStore;
try {
file.blob = Nacl.util.encodeBase64(u8);
file.teamId = teamId;
common.uploadFile(file, function () {
console.log('Upload started...');
Util.u8ToBase64(u8, b64 => {
file.blob = b64;
file.teamId = teamId;
common.uploadFile(file, function () {
console.log('Upload started...');
});
});
} catch (e) {
UI.alert(Messages.upload_serverError);

View File

@ -105,7 +105,9 @@ define([
h('p', data.content.msg.type + ' - ' +formatData(data))
])
]);
if ($(notif).find('.cp-avatar').length) {
$(notif).addClass('cp-notification-avatar');
}
if (typeof(data.content.getFormatText) === "function") {
$(notif).find('.cp-notification-content p').html(data.content.getFormatText());
if (data.content.autorefresh) {

View File

@ -842,6 +842,13 @@ define([
additionalPriv.initialState = cfg.initialState instanceof Blob ?
cfg.initialState : undefined;
if (cfg.integrationConfig) {
if (metaObj?.user && !metaObj.user.name) {
metaObj.user.name = cfg.integrationConfig?.user?.name ||
cfg.integrationConfig?.user?.firstname;
}
}
// Early access
var priv = metaObj.priv;
var _plan = typeof(priv.plan) === "undefined" ? Utils.LocalStore.getPremium() : priv.plan;
@ -2242,7 +2249,7 @@ define([
}
};
// on server crash, try to save to Nextcloud
// on server crash, try to save to the outer platform
if (ready) { return integrationSave(reload); }
// if error during loading, reload without saving
@ -2447,4 +2454,3 @@ define([
return common;
});

View File

@ -863,6 +863,38 @@ MessengerUI, Messages, Pages, PadTypes) {
};
};
Bar.createSkipLink = function (toolbar, config) {
if (config.readOnly === 1) {return;}
const targetId = config.skipLink;
const $skipLink = $('<a>', {
'class': 'cp-toolbar-skip-link',
'href': targetId,
'tabindex': 0,
'text': Messages.skipLink
});
toolbar.$top.append($skipLink);
$skipLink.on('click', function (event) {
event.preventDefault();
let split = targetId.split('|'); // split for iframes
let $container = $('body');
split.some(selector => {
let $targetElement = $container.find(selector);
if ($targetElement.is('iframe')) {
$container = $targetElement.contents();
return;
}
const $firstFocusable = $targetElement.find('a, button, input, select, textarea, [tabindex]:not([tabindex="-1"]), [contenteditable="true"]').first();
if ($firstFocusable.length) {
$firstFocusable.trigger('focus');
} else {
$skipLink.hide();
}
return true;
});
});
return $skipLink;
};
var createLinkToMain = function (toolbar, config) {
var $linkContainer = $('<span>', {
'class': LINK_CLS
@ -1198,7 +1230,9 @@ MessengerUI, Messages, Pages, PadTypes) {
$('body').find('.cp-dropdown-content li').first().focus();
return $(el).find('.cp-notification-dismiss').click();
}
$(el).find('.cp-notification-content').click();
setTimeout(function () {
$(el).find('.cp-notification-content').click();
}, 0);
});
refresh();
},
@ -1455,6 +1489,7 @@ MessengerUI, Messages, Pages, PadTypes) {
toolbar['linkToMain'] = createLinkToMain(toolbar, config);
toolbar['skipLink'] = Bar.createSkipLink(toolbar, config);
if (!config.realtime) { toolbar.connected = true; }

View File

@ -467,5 +467,137 @@
"upload_modal_title": "Опции за качване на файлове",
"upload_tooLarge": "Този файл надвишава максималния разрешен размер на качване за вашия акаунт.",
"upload_serverError": "Грешка в сървъра: файлът ви не може да бъде качен в момента.",
"upload_success": "Вашият файл ({0}) бе успешно качен и е добавен към устройството ви."
"upload_success": "Вашият файл ({0}) бе успешно качен и е добавен към устройството ви.",
"todo_markAsCompleteTitle": "Маркирайте тази задача като завършена",
"todo_markAsIncompleteTitle": "Маркирайте тази задача като незавършена",
"upload_tooLargeBrief": "Файлът надвишава ограничението от {0}MB за това устройство",
"upload_choose": "Избиране на файл",
"upload_pending": "Изчакване",
"upload_cancelled": "Отменено",
"upload_size": "Размер",
"upload_mustLogin": "Трябва да сте влезли, за да качвате файлове",
"upload_up": "Качване",
"download_mt_button": "Изтегляне",
"download_dl": "Изтегляне",
"download_step1": "Изтегля се",
"download_step2": "Декриптиране",
"todo_title": "Crypt Todo",
"todo_removeTaskTitle": "Премахнете тази задача от вашия списък със задачи",
"pad_base64": "Този документ съдържа изображения, съхранени по неефективен начин. Тези изображения значително ще увеличат размера на документа във вашия CryptDrive и ще направят зареждането му по-бавно. Можете да промените тези файлове в нов формат, който ще се съхранява отделно във вашия CryptDrive. Искате ли да промените тези изображения сега?",
"mdToolbar_button": "Показване или скриване на лентата с инструменти Markdown",
"mdToolbar_defaultText": "Вашият текст тук",
"mdToolbar_help": "Помощ",
"mdToolbar_tutorial": "https://www.markdowntutorial.com/",
"mdToolbar_bold": "Удебелен",
"mdToolbar_italic": "Курсив",
"mdToolbar_strikethrough": "Зачертано",
"mdToolbar_heading": "Заглавие",
"mdToolbar_nlist": "Подреден списък",
"mdToolbar_list": "Неподреден списък",
"mdToolbar_check": "Списък със задачи",
"mdToolbar_code": "Код",
"home_host": "Това е отделен екземпляр от общността на CryptPad.",
"about": "Относно",
"privacy": "Политика за поверителност",
"contact": "Контакт",
"terms": "Условия за ползване",
"features": "Характеристики",
"features_title": "Характеристики",
"features_anon": "Гост",
"features_registered": "Регистриран",
"features_premium": "Премиум",
"features_f_core": "Общи характеристики",
"features_f_file0": "Отваряне на документи",
"mdToolbar_quote": "Цитат",
"features_f_apps": "Достъп до всички приложения",
"features_f_core_note": "Редактиране, импортиране и експортиране, история, потребителски списък, чат",
"mdToolbar_toc": "Съдържание",
"mdToolbar_link": "Връзка",
"main_catch_phrase": "Пакет за сътрудничество<br>криптиран от край до край и с отворен код",
"features_f_file0_note": "Преглеждане и изтегляне на документи, споделени от другите потребители",
"features_f_cryptdrive0": "Ограничен достъп до CryptDrive",
"features_f_cryptdrive0_note": "Възможност за съхраняване на последно използваните документи във вашия браузър, за да можете да ги отворите по-късно",
"features_f_storage0": "Ограничено време за съхранение",
"features_f_storage0_note": "Документите се изтриват след {0} дни неактивност",
"features_f_anon": "Всички потребителски функции за гости",
"features_f_anon_note": "С допълнителна функционалност",
"features_f_cryptdrive1": "Пълна функционалност на CryptDrive",
"features_f_cryptdrive1_note": "Папки, споделени папки, шаблони, тагове",
"features_f_devices": "Вашите документи във всичките ви устройства",
"features_f_devices_note": "Достъп до вашия CryptDrive отвсякъде чрез акаунта ви",
"features_f_social": "Социални характеристики",
"features_f_social_note": "Добавете контакти за сигурно сътрудничество, създайте профил, прецизни контроли за достъп",
"features_f_file1": "Качване и споделяне на файлове",
"features_f_file1_note": "Съхранявайте файлове във вашия CryptDrive: изображения, PDF файлове, видеоклипове и др. Споделете ги с вашите контакти или ги вградете във вашите документи. (до {0}MB)",
"features_f_storage1": "Лично хранилище ({0})",
"features_f_storage1_note": "Документите, съхранявани във вашия CryptDrive, не се изтриват при неактивност",
"features_f_register": "Безплатно регистриране",
"features_f_reg": "Всички регистрирани потребителски функции",
"features_f_reg_note": "С допълнителни предимства",
"features_f_storage2": "Допълнително място за съхранение",
"features_f_storage2_note": "От 5 GB на 50 GB в зависимост от плана, увеличен лимит от {0} MB за качване на файлове",
"features_f_support": "По-бърза поддръжка",
"features_f_support_note": "Приоритетен отговор от административния екип чрез имейл и вградена билетна система",
"features_f_supporter": "Поверителност при поддръжка",
"features_f_supporter_note": "Помогнете на CryptPad да стане финансово устойчив и покажете, че софтуерът за подобряване на поверителността, доброволно финансиран от потребителите, трябва да бъде норма",
"four04_pageNotFound": "Не успяхме да намерим страницата, която търсите.",
"help_genericMore": "Научете повече за това как CryptPad може да работи за вас, като прочетете нашата <a>Документация</a>",
"feedback_about": "Ако четете това, вероятно сте били любопитни защо CryptPad търси уеб страници, когато извършвате определени действия.",
"creation_owned1": "<b>Притежаван</b> документ може да бъде унищожен, когато собственикът поиска. Унищожаването на притежаван документ го прави недостъпен чрез CryptDrives за другите потребители.",
"feedback_privacy": "Грижим се за вашата поверителност и в същото време искаме CryptPad да бъде много лесен за използване. Използваме този файл, за да разберем кои функции на потребителския интерфейс имат значение за нашите потребители, като го изискваме заедно с параметър, указващ кое действие е предприето.",
"creation_newPadModalDescription": "Кликнете върху приложението, за да създадете нов документ. Можете също да натиснете <b>Tab</b>, за да изберете приложението, и да натиснете <b>Enter</b>, за да потвърдите.",
"features_f_subscribe": "Абониране",
"features_f_subscribe_note": "За абониране е необходим акаунт",
"header_logoTitle": "Към вашия CryptDrive",
"header_homeTitle": "Към началната страница на CryptPad",
"edit": "редактиране",
"view": "преглед",
"feedback_optout": "Ако искате да се откажете, посетете <a>страницата си с потребителски настройки</a>, където ще намерите отметка, за да активирате или деактивирате обратната връзка с потребителя.",
"creation_404": "Този документ вече не съществува. Използвайте следната форма, за да създадете нов документ.",
"creation_owned": "Собственост на документа",
"creation_expire": "Изтичащ документ",
"creation_expireFalse": "Неограничен",
"creation_expireHours": "Час(ове)",
"creation_expireDays": "Ден(дни)",
"creation_expireMonths": "Месец(и)",
"creation_password": "Парола\n",
"creation_noTemplate": "Празен документ",
"creation_newTemplate": "Нов шаблон",
"creation_create": "Създаване",
"creation_owners": "Собственици",
"creation_noOwner": "Без собственик",
"creation_expiration": "Дата на унищожаване",
"creation_passwordValue": "Парола",
"password_info": "Документът, който се опитвате да отворите, вече не съществува или е защитен с нова парола. Въведете правилната парола за достъп до съдържанието.",
"properties_changePasswordButton": "Изпращане",
"sharedFolders_forget": "Този документ се съхранява само в споделена папка, не можете да го преместите в кошчето. Можете да използвате своя CryptDrive, ако искате да го изтриете.",
"share_linkEmbed": "Режим на вграждане (скриване на лентата с инструментите и потребителския списък)",
"share_mediatagCopy": "Копиране на медийния маркер в клипборда",
"sharedFolders_share": "Споделете тази връзка с други регистрирани потребители, за да им дадете достъп до споделената папка. След като отворят тази връзка, споделената папка ще бъде добавена към техния CryptDrive.",
"convertFolderToSF_SFChildren": "Тази папка не може да бъде преобразувана в споделена папка, защото вече съдържа споделени папки. Преместете тези споделени папки другаде, за да продължите.",
"password_error": "Документът не е намерен<br>Тази грешка може да бъде причинена от две причини: или паролата е невалидна, или документът е унищожен.",
"password_placeholder": "Въведете паролата тук...",
"password_submit": "Изпращане",
"properties_addPassword": "Добавяне на парола",
"properties_changePassword": "Промяна на паролата",
"properties_confirmNew": "Сигурен ли си? Добавянето на парола ще промени адреса на този документ и ще премахне неговата история. Потребителите без паролата ще загубят достъпа до този документ",
"properties_confirmChange": "Сигурен ли си? Промяната на паролата ще премахне нейната история. Потребителите без новата парола ще загубят достъпа до този документ",
"properties_passwordSame": "Новите пароли трябва да се различават от текущата.",
"properties_passwordError": "Възникна грешка при опит за промяна на паролата. Моля, опитайте отново.",
"properties_passwordWarning": "Паролата беше променена успешно, но не успяхме да актуализираме вашия CryptDrive с новите данни. Може да се наложи да премахнете старата версия на документа ръчно.<br>Натиснете OK, за да презаредите и актуализирате правата си за достъп.",
"properties_passwordSuccess": "Паролата бе променена успешно.<br>Натиснете OK, за да презаредите и актуализирате правата си за достъп.",
"share_linkCategory": "Връзка",
"share_linkAccess": "Права за достъп",
"share_linkEdit": "Редактиране",
"share_linkView": "Преглед",
"share_linkPresent": "Текущ",
"share_linkOpen": "Отваряне на връзка",
"share_linkCopy": "Копиране на връзка",
"share_contactCategory": "Контакти",
"share_embedCategory": "Вграждане",
"sharedFolders_duplicate": "Някои от документите, които се опитвахте да преместите, вече бяха споделени в целевата папка.",
"sharedFolders_create": "Създаване на споделена папка",
"sharedFolders_create_name": "Име на папка",
"sharedFolders_create_owned": "Собствена папка",
"convertFolderToSF_SFParent": "Тази папка не може да бъде преобразувана в споделена папка в текущото си местоположение. Преместете го извън споделената папка, за да продължите."
}

View File

@ -1783,5 +1783,9 @@
"admin_onboardingNameHint": "Bitte wähle einen Namen, eine Beschreibung, eine Akzentfarbe und ein Logo (alle Angaben sind optional)",
"team_autoTrim": "Verlauf des Team-Drives wird gelöscht... Bitte warten.",
"admin_mfa_confirm_enable": "Bist du sicher, dass du die Multi-Faktor-Authentifizierung aktivieren möchtest?",
"admin_mfa_confirm_disable": "Bist du sicher, dass du die Multi-Faktor-Authentifizierung deaktivieren möchtest?"
"admin_mfa_confirm_disable": "Bist du sicher, dass du die Multi-Faktor-Authentifizierung deaktivieren möchtest?",
"fm_restoreMultipleDialog": "Bist du sicher, dass du {0} Dateien/Ordner zurück in den ursprünglichen Ordner verschieben möchtest?",
"calendar_show": "Kalender anzeigen",
"form_passwordWarning": "Bitte beachte, dass ein Formularpasswort nur zum Zeitpunkt der Erstellung festgelegt und später nicht mehr geändert werden kann.",
"calendar_hide": "Kalender verbergen"
}

View File

@ -1442,8 +1442,8 @@
"admin_uptimeHint": "Date et heure auxquelles le serveur a été démarré",
"admin_cat_database": "Base de données",
"admin_generatedAt": "Horodatage du rapport",
"ui_true": "acti",
"ui_false": "désactivé",
"ui_true": "vrai",
"ui_false": "faux",
"ui_none": "aucun",
"ui_generateReport": "Générer un rapport",
"ui_success": "Succès",
@ -1783,5 +1783,9 @@
"install_notes": "<ul class=\"cp-notes-list\"><li>Créez votre premier compte administrateur·ice sur cette page. Les administrateur·ices peuvent paramétrer l'instance, ceci incluant les quotas de stockage, et ont accès aux outils de modération.</li><li>Votre mot de passe est la clé secrète qui chiffre l'ensemble de vos documents et vos privilèges d'administration sur l'instance. <span class=\"red\">Si vous le perdez il n'est pas possible de récupérer vos données.</span></li><li>Si vous utilisez un ordinateur partagé, <span class=\"red\">n'oubliez pas de vous déconnecter</span> quand vous aurez terminé. Simplement fermer la fenêtre du navigateur web laisse votre compte exposé à des risques de sécurité. </li></ul>",
"team_autoTrim": "Suppression de l'historique du drive d'équipe... Veuillez patienter.",
"admin_mfa_confirm_disable": "Êtes-vous sûr de vouloir désactiver l'authentification multi-facteur ?",
"admin_mfa_confirm_enable": "Êtes-vous sûr de vouloir activer l'authentification multi-facteur ?"
"admin_mfa_confirm_enable": "Êtes-vous sûr de vouloir activer l'authentification multi-facteur ?",
"fm_restoreMultipleDialog": "Êtes-vous sûr·e de vouloir restaurer {0} fichiers et/ou dossiers à leurs emplacements précédents?",
"calendar_hide": "Cacher les calendriers",
"calendar_show": "Afficher les calendriers",
"form_passwordWarning": "Veuillez noter qu'un mot de passe pour Formulaire peut uniquement être spécifié lors de la création du document et ne peut pas être changé plus tard."
}

View File

@ -421,5 +421,35 @@
"settings_import": "Importálás",
"upload_pending": "Függöben lévő",
"upload_up": "Feltöltés",
"features_f_subscribe": "Feliratkozás"
"features_f_subscribe": "Feliratkozás",
"toolbar_savetodrive": "Mentés képként",
"comments_comment": "Hozzászólás",
"drive_treeButton": "Fájlok",
"fm_noResult": "Keresés eredménytelen",
"settings_cat_style": "Megjelenés",
"logoutButton": "Kijelentkezés",
"login_noSuchUser": "Hibás felhasználónév vagy jelszó",
"login_invalUser": "Felhasználónév szükséges",
"login_unhandledError": "Váratlan hiba történt :(",
"register_passwordsDontMatch": "A jelszavak nem egyeznek!",
"register_alreadyRegistered": "Ez a felhasználónév már létezik! Szeretnél bejelentkezni?",
"settings_changePasswordNew": "Új jelszó",
"settings_changePasswordCurrent": "Aktuális jelszó",
"settings_codeSpellcheckTitle": "Helyesírás-ellenőrzés",
"comments_submit": "Beküldés",
"toolbar_insert": "Beszúrás",
"toolbar_tools": "Eszközök",
"slide_backCol": "Háttérszín",
"slide_textCol": "Szövegszín",
"toolbar_file": "Fájl",
"support_cat_bug": "Hibajelentés",
"support_attachments": "Csatolmányok",
"settings_kanbanTagsOr": "VAGY",
"history_close": "Bezár",
"history_restore": "Visszaállít",
"fm_restricted": "Nincs hozzáférése",
"support_cat_all": "Összes",
"support_addAttachment": "Csatolmány hozzáadása",
"oo_refresh": "Frissítés",
"support_formCategoryError": "Hiba: üres kategória"
}

View File

@ -648,7 +648,7 @@
"contact_dev": "Contatta la quadra di sviluppo",
"contact_admin": "Contatta gli amministratori e le amministratrici per: {0}",
"footer_donate": "Dona",
"admin_registeredTitle": "Utenti registrati",
"admin_registeredTitle": "Drive di utenti e gruppi",
"admin_activePadsTitle": "Documenti attivi",
"admin_activeSessionsTitle": "Connessioni attive",
"adminPage": "Amministrazione",
@ -843,7 +843,7 @@
"support_disabledHint": "Questa istanza di CryptPad non è ancora configurata per utilizzare un modulo di assistenza.",
"sharedFolders_share": "Condividi questo link con altri utenti registrati o altre utenti registrate per dare loro accesso alla cartella condivisa. Una volta che aprono questo link, la cartella condivisa sarà aggiunta al loro CryptDrive.",
"autostore_notAvailable": "Devi salvare questo documento nel tuo CryptDrive prima di poter utilizzare questa funzionalità.",
"admin_registeredHint": "Numero di utenti registrati/e nella tua istanza",
"admin_registeredHint": "Numero di drive attivi sulla tua istanza",
"admin_updateLimitDone": "Aggiornamento completato con successo",
"requestEdit_button": "Richiedi i diritti di modifica",
"requestEdit_accepted": "{1} ti ha permesso di modificare il documento <b>{0}</b>",
@ -1768,5 +1768,8 @@
"admin_appsHint": "Scegli le app da abilitare su questa istanza.",
"admin_cat_apps": "ApplicazIoni",
"admin_onboardingOptionsTitle": "Opzioni dellistanza",
"admin_onboardingOptionsHint": "Scegli lopzione appropriata per la tua istanza.<br> Queste configurazioni possono essere cambiate successivamente nel pannello di amministrazione."
"admin_onboardingOptionsHint": "Scegli lopzione appropriata per la tua istanza.<br> Queste configurazioni possono essere cambiate successivamente nel pannello di amministrazione.",
"team_autoTrim": "Eliminazione della cronologia del drive del gruppo... Si prega di attendere.",
"admin_mfa_confirm_enable": "Sei sicuro/a di voler attivare l'autenticazione a più fattori?",
"admin_mfa_confirm_disable": "Sei sicuro/a di voler disattivare l'autenticazione a più fattori?"
}

View File

@ -1783,5 +1783,9 @@
"admin_onboardingDescPlaceholder": "Instance description text",
"team_autoTrim": "Trimming team drive history... Please wait.",
"admin_mfa_confirm_enable": "Are you sure you want to enable Multi-Factor Authentication?",
"admin_mfa_confirm_disable": "Are you sure you want to disable Multi-Factor Authentication?"
"admin_mfa_confirm_disable": "Are you sure you want to disable Multi-Factor Authentication?",
"form_passwordWarning": "Please note that a Form password can only be set now at creation time and cannot be changed later.",
"fm_restoreMultipleDialog": "Are you sure you want to restore {0} files and/or folders to their previous locations?",
"calendar_show": "Show calendars",
"calendar_hide": "Hide calendars"
}

View File

@ -663,5 +663,9 @@
"admin_broadcastTitle": "Broadcast bericht",
"pad_settings_hide": "Verbergen",
"importError": "Importeren mislukt (verkeerd formaat)",
"terms": "Servicevoorwaarden"
"terms": "Servicevoorwaarden",
"creation_404": "Dit document bestaat niet meer. Gebruik het volgende formulier om een nieuw document te maken.",
"feedback_about": "Als je dit leest was je waarschijnlijk nieuwsgierig waarom CryptPad om webpagina's vraagt wanneer je bepaalde acties uitvoert.",
"features_f_social_note": "Voeg contacten toe voor veilige samenwerking, creëer een profiel, fijnmazige toegangscontroles",
"feedback_optout": "Als u zich wilt afmelden, ga dan naar <a>uw gebruikersinstellingenpagina</a>, waar u een selectievakje vindt om gebruikersfeedback in of uit te schakelen."
}

File diff suppressed because it is too large Load Diff

View File

@ -11,8 +11,8 @@
"slide": "Markdown Sunumları",
"poll": "Anket",
"code": "Kod",
"pad": "Zengin metin biçimi",
"doc": "Doküman",
"pad": "Zengin Metin",
"doc": "Belge",
"presentation": "Sunum",
"diagram": "Diyagram",
"sheet": "Sheet",
@ -45,12 +45,12 @@
"MB": "MB",
"GB": "GB",
"formattedGB": "{0} GB",
"formattedKB": "{0} KB",
"formattedKB": "{0} kB",
"importButton": "İçe aktar",
"clickToEdit": "Düzenlemek için tıkla",
"forgetButton": "Sil",
"shareButton": "Paylaş",
"uploadFolderButton": "Klasör yükle",
"uploadFolderButton": "Dizini karşıya yükle",
"saveTemplatePrompt": "Şablon için bir başlık seçin",
"templateSaved": "Şablon kaydedildi!",
"selectTemplate": "Bir şablon seçin veya escape tuşuna basın",
@ -66,15 +66,15 @@
"mustLogin": "Bu sayfaya erişmek için giriş yapmalısınız",
"forgotten": "Çöp kutusuna taşındı",
"errorState": "Kritik hata: {0}",
"KB": "KB",
"KB": "kB",
"formattedMB": "{0} MB",
"typeError": "Bu doküman seçilmiş uygulama ile uyumlu değil",
"typeError": "Bu belge seçilen uygulama ile uyumlu değil",
"disconnected": "Bağlantı kesildi",
"forgetPrompt": "Tamam'ı tıkladığınızda bu belge çöp kutunuza taşınacaktır. Emin misin?",
"uploadButtonTitle": "CryptDrive'ınıza yeni bir dosya yükleyin",
"userAccountButton": "Kullanıcı menüsü",
"deletedError": "Bu belge silindi ve artık mevcut değil.",
"inactiveError": "Bu belge, işlem yapılmaması nedeniyle silinmiştir. Yeni bir belge oluşturmak için Esc tuşuna basın.",
"inactiveError": "Bu belge, işlem yapılmaması nedeniyle silindi. Yeni bir belge oluşturmak için Esc tuşuna basın.",
"chainpadError": "İçeriğiniz güncellenirken kritik bir hata oluştu. Çalışmanızı kaybetmemeniz için bu sayfa salt okunur modundadır.<br>Bu belgeyi görüntülemeye devam etmek için Esc tuşuna basın veya yeniden düzenlemeyi denemek için yeniden yükleyin.",
"errorRedirectToHome": "CryptDrive'ınıza yönlendirilmek için Esc tuşuna basın.",
"newVersionError": "CryptPad'in yeni bir sürümü mevcut. Yeni sürümü kullanmak için <br><a href='#'>yeniden yükleyin</a> veya <b>çevrimdışı modda</b> içeriğinize erişmek için escape tuşuna basın.",
@ -84,5 +84,453 @@
"pinLimitReachedAlert": "Depolama sınırınıza ulaştınız. Yeni belgeler CryptDrive'ınızda saklanmaz.<br>Sınırınızı artırmak için belgeleri CryptDrive'ınızdan kaldırabilir veya <a>premium bir teklife abone olabilirsiniz</a>.",
"pinLimitNotPinned": "Depolama sınırınıza ulaştınız.<br>Bu belge CryptDrive'ınızda saklanmıyor.",
"movedToTrash": "Bu doküman çöp kutusuna taşındı.<br><a>Drive'ıma erişin</a>",
"saveTemplateButton": "Şablon olarak kaydet"
"saveTemplateButton": "Şablon olarak kaydet",
"propertiesButton": "Özellikler",
"filePicker_close": "Kapat",
"poll_unlocked": "Kilidi açık",
"pad_mediatagPreview": "Ön izleme",
"printText": "Yazdır",
"slideOptionsText": "Seçenekler",
"languageButton": "Dil",
"themeButton": "Tema",
"ok": "Tamam",
"cancel": "İptal",
"help_button": "Yardım",
"historyText": "Geçmiş",
"kanban_done": "Tamamlandı",
"poll_publish_button": "Yayınla",
"poll_commit": "Gönder",
"poll_optionPlaceholder": "Seçenek",
"poll_remove": "Kaldır",
"poll_edit": "Düzenle",
"poll_locked": "Kilitli",
"exportButton": "Dışarı Aktar",
"canvas_clear": "Temizle",
"settings_import": "İçeri Aktar",
"upload_pending": "Bekleniyor",
"fm_templateName": "Şablonlar",
"settingsButton": "Ayarlar",
"fm_searchPlaceholder": "Ara...",
"fm_prop_tagsList": "Etiketler",
"fc_rename": "Yeniden Adlandır",
"settings_resetButton": "Kaldır",
"settings_cat_account": "Hesap",
"upload_size": "Size",
"notificationsPage": "Bildirimler",
"settings_thumbnails": "Küçük Resimler",
"settings_autostoreYes": "Otomatik",
"upload_cancelled": "İptal Edildi",
"upload_up": "Karşıya Yükle",
"properties_changePasswordButton": "Gönder",
"share_withFriends": "Paylaş",
"mdToolbar_tutorial": "https://www.markdowntutorial.com/",
"edit": "düzenle",
"features": "Özellikler",
"share_linkEdit": "Düzenle",
"share_linkPresent": "Present",
"contact_chat": "Sohbet",
"settings_codeSpellcheckTitle": "Yazım denetimi",
"support_formButton": "Gönder",
"team_cat_list": "Takımlar",
"team_cat_chat": "Sohbet",
"poll_total": "TOPLAM",
"poll_comment_list": "Yorumlar",
"poll_comment_submit": "Gönder",
"canvas_width": "Genişlik",
"canvas_opacity": "Opaklık",
"profileButton": "Profil",
"contacts_title": "Kişiler",
"contacts_send": "Gönder",
"contacts_padTitle": "Sohbet",
"contacts_rooms": "Odalar",
"fm_rootName": "Drive",
"fm_trashName": "Çöp",
"fm_searchName": "Ara",
"fm_recentPadsName": "Son",
"fm_ownedPadsName": "Sahiplenmiş",
"fm_tagsName": "Etiketler",
"fm_newButton": "Yeni",
"fm_folder": "Dizin",
"fm_type": "Tür",
"fm_creation": "Oluşturma",
"fc_open": "Aç",
"fc_delete_owned": "Yok Et",
"fc_restore": "Geri Getir",
"fc_remove": "Kaldır",
"fc_remove_sharedfolder": "Kaldır",
"fc_prop": "Özellikler",
"fc_hashtag": "Etiketler",
"login_username": "Kullanıcı Adı",
"login_password": "Parola",
"register_header": "Kayıt Ol",
"register_cancel": "İptal",
"register_warning": "Uyarı",
"settings_cat_drive": "CryptDrive",
"settings_cat_cursor": "İmleç",
"settings_cat_code": "Kod",
"settings_cat_subscription": "Abonelik",
"settings_title": "Ayarlar",
"settings_save": "Kaydet",
"settings_backupCategory": "Yedekle",
"settings_backup": "Yedekle",
"settings_restore": "Geri Getir",
"settings_resetTipsAction": "Sıfırla",
"settings_resetTips": "İpuçları",
"settings_resetThumbnailsAction": "Temizle",
"settings_userFeedbackTitle": "Geri Bildirim",
"settings_padSpellcheckTitle": "Yazım Denetimi",
"download_mt_button": "İndir",
"download_step1": "İndiriliyor",
"download_dl": "İndir",
"download_step2": "Şifre çözülüyor",
"todo_title": "CryptTodo",
"mdToolbar_help": "Yardım",
"mdToolbar_bold": "Kalın",
"mdToolbar_italic": "İtalik",
"mdToolbar_strikethrough": "Üstü çizili",
"mdToolbar_heading": "Başlık",
"mdToolbar_link": "Bağlantı",
"mdToolbar_quote": "Quote",
"mdToolbar_code": "Kod",
"about": "Hakkında",
"contact": "İletişim",
"features_title": "Özellikler",
"features_anon": "Misafir",
"features_registered": "Kayıt olundu",
"features_premium": "Premium",
"features_f_subscribe": "Abone ol",
"view": "görüntüle",
"creation_expireFalse": "Sınırsız",
"creation_expireHours": "Saat",
"creation_expireDays": "Gün",
"creation_expireMonths": "Ay",
"creation_password": "Parola\n",
"creation_create": "Oluştur",
"creation_owners": "Sahipler",
"creation_passwordValue": "Parola",
"password_submit": "Gönder",
"share_linkCategory": "Bağlantı",
"share_linkView": "Görüntüle",
"share_contactCategory": "Contacts",
"share_embedCategory": "Gömülü",
"autostore_file": "dosya",
"autostore_sf": "dizin",
"autostore_pad": "pad",
"autostore_store": "Depola",
"crowdfunding_button2": "Bağış Yap",
"markdown_toc": "İçerikler",
"admin_cat_general": "Genel",
"admin_cat_stats": "İstatistikler",
"adminPage": "Yönetim",
"footer_donate": "Bağış",
"contact_email": "E-posta",
"friendRequest_decline": "Reddet",
"notifications_dismiss": "Anımsatma",
"supportPage": "Destek",
"admin_cat_support": "Destek",
"support_answer": "Yanıtla",
"notifications_cat_all": "Tüm",
"notifications_cat_archived": "Geçmiş",
"pricing": "Fiyatlandırma",
"owner_removeText": "Sahipler",
"owner_removePendingText": "Bekleniyor",
"owner_unknownUser": "bilinmiyor",
"team_inviteModalButton": "Davet et",
"team_cat_general": "Hakkında",
"team_cat_create": "Yeni",
"team_cat_members": "Üyeler",
"team_cat_drive": "Drive",
"team_cat_admin": "Yönetim",
"team_rosterPromote": "Promote",
"team_rosterDemote": "Demote",
"team_owner": "Sahipler",
"team_admins": "Yöneticiler",
"team_members": "Üyeler",
"broadcast_start": "Başlat",
"broadcast_end": "Bitir",
"kanban_delete": "Sil",
"toolbar_tools": "Araçlar",
"drive_treeButton": "Dosyalar",
"support_cat_other": "Diğer",
"admin_cat_performance": "Performans",
"settings_colortheme_dark": "Koyu",
"ui_ms": "milisaniye",
"form_editor": "Düzenleyici",
"team_inviteFrom": "From:",
"snapshots_delete": "Sil",
"Offline": "Çevrim dışı",
"admin_performanceKeyHeading": "Komut",
"expiredError": "Bu belgenin geçerlilik süresi doldu ve artık mevcut değil.",
"team_viewers": "İzleyiciler",
"oo_refresh": "Yenile",
"access_main": "Erişim",
"calendar_notifications": "Hatırlatıcılar",
"form_poll_day": "Gün",
"form_pollTotal": "Toplam",
"form_text_number": "Sayı",
"form_input_ph_email": "eposta@ornek.com",
"calendar_rec_freq_daily": "gün",
"admin_limitSetNote": "Not",
"tag_edit": "Düzenle",
"form_preview_button": "Ön izleme",
"download_step3": "Dönüştürülüyor...",
"initializing": "Initializing...",
"admin_documentConflict": "Arşiv/geri getir",
"upgradeAccount": "Hesabı yükselt",
"error_incorrectAccess": "Bu sayfaya yalnızca {0} üzerinden erişilebilir.",
"toolbar_preview": "Ön izleme",
"calendar_rec_daily": "Günlük",
"calendar_str_filter": "Filtreler:",
"form_editable_str": "Başvuru",
"admin_cat_network": "Ağ bağlantısı",
"fm_link_type": "Bağlantı",
"ui_archive": "Arşivle",
"admin_totpDisableButton": "Devre dışı bırak",
"printButton": "Yazdır (enter)",
"invalidHashError": "İstediğiniz belgenin bağlantısı geçersiz.",
"deletedFromServer": "Belge yok edildi",
"user_displayName": "Görünen ad",
"printOptions": "Tasarım seçenekleri",
"editShare": "Düzenleme bağlantısı",
"viewShare": "salt-okunur bağlantı",
"okButton": "Tamam (enter)",
"cancelButton": "İptal (esc)",
"show_help_button": "Yardımı göster",
"history_next": "Sonraki sürüm",
"history_prev": "Önceki sürüm",
"history_restoreDone": "Belge geri getirildi",
"pad_mediatagTitle": "Medya-Etiket ayarları",
"pad_mediatagWidth": "Genişlik (px)",
"pad_mediatagHeight": "Yükseklik (px)",
"pad_mediatagRatio": "Oranı koru",
"pad_mediatagOptions": "Resim özellikleri",
"kanban_newBoard": "Yeni Tahta",
"kanban_item": "Öge {0}",
"kanban_todo": "Yapılacaklar",
"kanban_working": "Üzerinden çalışılıyor",
"poll_userPlaceholder": "İsminiz",
"poll_comment_placeholder": "Yorumunuz",
"canvas_delete": "Seçili kısmı sil",
"canvas_opacityLabel": "Opaklık: {0}",
"canvas_widthLabel": "Genişlik: {0}",
"canvas_currentBrush": "Geçerli fırça",
"fm_filesDataName": "Tüm dosyalar",
"ui_more": "Daha fazla",
"fm_newFile": "Yeni belge",
"fm_sharedFolder": "Paylaşılmış dizinler",
"fm_folderName": "Dizin ismi",
"fm_fileName": "Dosya ismi",
"fm_lastAccess": "Son erişim",
"fm_forbidden": "Yasaklı eylem",
"fm_newFolder": "Yeni dizin",
"fm_sharedFolderName": "Paylaşılmış dizinle",
"team_pending": "Davet Edildi",
"team_deleteButton": "Sil",
"team_pendingOwner": "(bekleniyor)",
"teams_table": "Roller",
"teams_table_specific": "İstisnalar",
"teams_table_role": "Rol",
"contacts_mute": "Sessize Al",
"contacts_unmute": "Sesi Aç",
"allow_disabled": "devre dışı",
"access_allow": "Liste",
"accessButton": "Erişim",
"contacts": "Contacts",
"allow_enabled": "etkinleştirildi",
"teams": "Takımlar",
"kanban_title": "Başlık",
"kanban_body": "İçerik",
"kanban_color": "Renk",
"canvas_brush": "Fırça",
"canvas_select": "Seç",
"cba_enable": "Etkinleştir",
"comments_edited": "Düzenlendi",
"comments_submit": "Gönder",
"comments_reply": "Yanıtla",
"comments_resolve": "Çözümle",
"comments_comment": "Yorum",
"fm_sort": "Sırala",
"toolbar_theme": "Tema",
"toolbar_insert": "Insert",
"toolbar_file": "Dosya",
"support_cat_all": "Tüm",
"support_attachments": "Ekler",
"pad_tocHide": "Taslak",
"settings_kanbanTagsAnd": "VE",
"settings_kanbanTagsOr": "VEYA",
"settings_cat_kanban": "Kanban",
"history_restore": "Geri Getir",
"history_close": "Kapat",
"snaphot_title": "Anlık Görüntü Al",
"snapshots_button": "Anlık Görüntüler",
"snapshots_open": "Aç",
"snapshots_restore": "Geri Getir",
"snapshots_close": "Kapat",
"oo_version_latest": "En son",
"oo_version": "Versiyon: ",
"team_exportButton": "İndir",
"tag_add": "Ekle",
"admin_archiveButton": "Arşivle",
"admin_unarchiveButton": "Geri Getir",
"mediatag_saveButton": "Kaydet",
"admin_support_open": "Göster",
"admin_support_collapse": "Daralt",
"docs_link": "Belgeleme (Dokümantasyon)",
"settings_cacheTitle": "Önbellek",
"undo": "Geri Al",
"redo": "Yeniden Yap",
"admin_performanceProfilingTitle": "Performans",
"admin_performancePercentHeading": "Yüzde",
"settings_cat_style": "Görünüm",
"settings_colortheme_light": "Açık",
"pad_settings_hide": "Gizle",
"pad_settings_show": "Göster",
"settings_colortheme_custom": "Özel",
"admin_cat_broadcast": "Broadcast",
"admin_maintenanceTitle": "Bakım",
"admin_surveyTitle": "Anket",
"admin_surveyCancel": "Kaldır",
"admin_broadcastButton": "Gönder",
"broadcast_translations": "Çeviriler",
"footer_roadmap": "Roadmap",
"calendar_before": "önceki",
"calendar": "Takvim",
"calendar_day": "Gün",
"calendar_week": "Hafta",
"calendar_month": "Ay",
"calendar_today": "Bugün",
"calendar_update": "Güncelle",
"calendar_title": "Başlık",
"calendar_loc": "Konum",
"form_required_answer": "Cevap: ",
"form_required_on": "Gerekli",
"form_required_off": "Opsiyonel",
"admin_usersRemove": "Kaldır",
"calendar_minutes": "Dakika",
"calendar_hours": "Saat",
"calendar_days": "Gün",
"calendar_noNotification": "None",
"mediatag_defaultImageName": "resim",
"share_formEdit": "Yazar",
"share_formAuditor": "Denetçi",
"share_formView": "Katılımcı",
"form_editBlock": "Düzenle",
"form_poll_time": "Zaman",
"form_text_url": "Bağlantı",
"form_text_text": "Metin",
"form_poll_text": "Metin",
"form_text_email": "E-posta",
"form_type_input": "Metin",
"form_type_textarea": "Paragraf",
"form_type_radio": "Seçim",
"form_type_checkbox": "Checkbox",
"form_type_poll": "Anket",
"form_type_md": "Açıklama",
"form_submit": "Gönder",
"form_update": "Güncelle",
"form_reset": "Sıfırla",
"form_delete": "Sil",
"form_viewButton": "Görüntüle",
"form_backButton": "Geri",
"form_input_ph_url": "https://ornek.com",
"form_open": "Aç",
"form_anonymous_on": "İzin verildi",
"form_anonymous_off": "Blocked",
"form_clear": "Temizle",
"fm_link_url": "Bağlantı",
"ui_collapse": "Daralt",
"ui_expand": "Genişlet",
"form_condition_is": "dır",
"form_condition_has": "sahiptir",
"admin_archiveNote": "Not",
"support_cat_document": "Belge",
"admin_noticeTitle": "Ana sayfa bildirimi",
"home_morestorage": "Daha fazla depolama alanı için:",
"admin_cat_database": "Veri tabanı",
"ui_true": "doğru",
"ui_false": "yanlış",
"ui_undefined": "bilinmiyor",
"ui_none": "none",
"ui_success": "Başarılı",
"ui_restore": "Geri Getir",
"ui_fetch": "Fetch",
"ui_confirm": "Onayla",
"admin_documentCreationTime": "Oluşturuldu",
"admin_channelAvailable": "Uygun",
"admin_channelArchived": "Arşivlendi",
"fm_filterBy": "Filtre",
"calendar_rec": "Tekrarla",
"calendar_rec_custom": "Özel",
"calendar_rec_freq_weekly": "hafta",
"calendar_rec_freq_monthly": "ay",
"calendar_rec_freq_yearly": "yıl",
"calendar_rec_until_no": "Asla",
"calendar_rec_until_count": "Sonra",
"calendar_rec_until_count2": "kere",
"calendar_nth_1": "ilk",
"calendar_nth_2": "ikinci",
"calendar_nth_3": "üçüncü",
"calendar_nth_4": "dördüncü",
"calendar_nth_last": "en son",
"calendar_nth_5": "beşinci",
"form_condorcetSchulze": "Schulze",
"form_showCondorcetWinner": "kazanan: ",
"form_showDetails": "Detaylar",
"form_type_date": "Tarih",
"done": "Tamamlandı",
"continue": "Devam et",
"goLeft": "Sol",
"goRight": "Sağ",
"date": "Tarih",
"calendar_desc": "Açıklama",
"calendar_description": "Açıklama:{0}{1}",
"duplicate": "Kopya",
"admin_cat_security": "Güvenlik",
"admin_cat_customize": "Özelleştir",
"support_cat_open": "Gelen kutusu",
"support_cat_search": "Ara",
"support_cat_closed": "Kapatıldı",
"support_cat_settings": "Ayarlar",
"support_cat_legacy": "Eskiden kalma",
"support_pending_tag": "Arşivlendi",
"support_active_tag": "Gelen Kutusu",
"support_closed_tag": "Kapatıldı",
"support_recordedTitle": "Kod parçacıkları",
"support_recordedContent": "İçerik",
"moderationPage": "Yardım-masası",
"install_header": "Yükleme",
"admin_cat_apps": "Uygulamalar",
"fm_noname": "Başlıksız Belge",
"fm_originalPath": "Orijinal yol",
"fm_viewListButton": "Liste görünümü",
"fm_viewGridButton": "Tablo görünümü",
"fm_tags_name": "Etiket ismi",
"fm_passwordProtected": "Parola korundu",
"fc_newfolder": "Yeni dizin",
"fc_color": "Rengi değiştir",
"fc_open_ro": "Aç (salt-okunur)",
"fc_expandAll": "Hepsini genişlet",
"fc_collapseAll": "Hepsini daralt",
"login_login": "Giriş",
"login_register": "Kayıt ol",
"logoutButton": ıkış",
"login_invalUser": "Kullanıcı adı gerekli",
"login_invalPass": "Parola gerekli",
"settings_cat_pad": "Zengin metin",
"settings_export_compressing": "Veri sıkıştırılıyor...",
"settings_exportError": "Hataları görüntüle",
"settings_resetNewTitle": "CryptDrive'ı Temizle",
"settings_importDone": "İçeri aktarma tamamlandı",
"settings_deleteTitle": "Hesap silme",
"settings_logoutEverywhereButton": ıkış",
"settings_driveDuplicateLabel": "Kopyaları gizle",
"settings_ownDriveTitle": "Hesabı Güncelle",
"settings_changePasswordButton": "Parola değiştir",
"settings_changePasswordCurrent": "Şuanki parola",
"settings_changePasswordNew": "Yeni parola",
"settings_cursorColorTitle": "İmleç rengi",
"settings_cursorShowLabel": "İmleçleri göster",
"upload_title": "Dosya yükleme",
"upload_modal_owner": "Sahip olunan dosya",
"uploadFolder_modal_filesPassword": "Dosya parolası"
}

View File

@ -20,7 +20,7 @@
"diagram": "图表"
},
"common_connectionLost": "<b>伺服器連線中斷</b><br>現在是唯讀狀態,直到連線恢復正常。",
"typeError": "此文档与所选应用进程不兼容",
"typeError": "此文档与所选应用不兼容",
"onLogout": "您已退出登录,{0}点击此处{1}登录<br>或按 Esc 以只读模式访问您的文档。",
"loading": "載入中...",
"error": "錯誤",
@ -1783,5 +1783,9 @@
"dph_tmp_pw": "此模板受新密码保护。从您的云盘打开它以输入新密码。",
"duplicate": "重复",
"admin_logoButton": "上传新的",
"install_notes": "<ul class=\"cp-notes-list\"><li>在此页面上创建您的第一个管理员账号。管理员管理实例设置,包括存储配额,并有权访问审核工具。</li><li>您的密码是加密此实例上所有文档和管理员权限的密钥。<span class=\"red\">如果您丢失了密码,我们将无法恢复您的数据。</span></li><li>如果您使用的是共享计算机,<span class=\"red\">请记住在使用完后登出</span>。只关闭浏览器窗口会暴露您的账号。</li></ul>"
"install_notes": "<ul class=\"cp-notes-list\"><li>在此页面上创建您的第一个管理员账号。管理员管理实例设置,包括存储配额,并有权访问审核工具。</li><li>您的密码是加密此实例上所有文档和管理员权限的密钥。<span class=\"red\">如果您丢失了密码,我们将无法恢复您的数据。</span></li><li>如果您使用的是共享计算机,<span class=\"red\">请记住在使用完后登出</span>。只关闭浏览器窗口会暴露您的账号。</li></ul>",
"fm_restoreMultipleDialog": "是否确定要将 {0} 个文件和/或文件夹还原到其以前的位置?",
"calendar_hide": "隐藏日历",
"form_passwordWarning": "请注意,表单密码只能在创建时设置,以后不能更改。",
"calendar_show": "显示日历"
}

View File

@ -248,7 +248,7 @@ define([
var isFolder = exp.isFolder = function (element) {
if (isFolderData(element)) { return false; }
return typeof(element) === "object" || isSharedFolder(element);
return (typeof(element) === "object" && !element.channel) || isSharedFolder(element);
};
exp.isFolderEmpty = function (element) {
if (!isFolder(element)) { return false; }

View File

@ -18,7 +18,9 @@
var scripts = document.getElementsByTagName('script');
for (var i = scripts.length - 1; i >= 0; i--) {
var match = scripts[i].src.match(/(.*)web-apps\/apps\/api\/documents\/api.js/i);
var match2 = scripts[i].src.match(/(.*)\/cryptpad-api.js/i);
if (match) { return match[1]; }
else if (match2) { return match2[1]; }
}
};
@ -86,6 +88,7 @@
var start = function (config, chan) {
return new Promise(function (resolve, reject) {
setTimeout(function () {
var docID = config.document.key;
var key = config.document.key;
var blob;
@ -109,14 +112,31 @@
xhr.send();
};
let serializedConfig = () => {
let _config = {};
_config.editorConfig = config.editorConfig;
_config.document = {
permissions: config.document?.permissions,
title: config.document?.title,
info: config.document?.info,
referenceData: config.document?.referenceData
};
return _config;
};
var start = function () {
config.document.key = key;
//config.document.key = key;
chan.send('START', {
key: key,
application: config.documentType,
name: config.document.title,
url: config.document.url,
documentKey: docID,
document: blob,
ext: config.document.fileType,
autosave: config.autosave || 10
autosave: config.events.onSave && (config.autosave || 10),
editorConfig: config.editorConfig || {},
_config: serializedConfig()
}, function (obj) {
if (obj && obj.error) { reject(obj.error); return console.error(obj.error); }
resolve({});
@ -130,8 +150,17 @@
blob = config.document.blob;
return start();
}
// NOTE: Nextcloud will log us out if we try from the client
// TODO: make sure the server plugin is installed if we don't
// call getBlob()
if (!config.events?.onSave) {
return void start();
}
getBlob(function (err, _blob) {
if (err) { reject(err); return console.error(err); }
if (err) { // Can't get blob from client, try from server
console.warn(err);
return void start();
}
_blob.name = `document.${config.document.fileType}`;
blob = _blob;
start();
@ -188,6 +217,7 @@
chan.on('ON_DOWNLOADAS', blob => {
let url = URL.createObjectURL(blob);
if (!config.events.onDownloadAs) { return; }
config.events.onDownloadAs({
data: {
fileType: config.document && config.document.fileType,
@ -198,6 +228,7 @@
chan.on('SAVE', function (data, cb) {
blob = data;
if (!config.events.onSave) { return void cb(); }
config.events.onSave(data, cb);
});
chan.on('RELOAD', function () {
@ -242,22 +273,28 @@
*/
var init = function (cryptpadURL, containerId, config) {
// OnlyOffice shim: don't provide a URL
if (!config && typeof(containerId) === "object") {
if (typeof(config) !== "object" && typeof(containerId) === "object") {
config = containerId;
containerId = cryptpadURL;
cryptpadURL = getInstanceURL();
}
config.events = config.events || {};
// OnlyOffice shim
let url = config.document.url;
if (/^http:\/\/localhost\/cache\/files\//.test(url)) {
url = url.replace(/(http:\/\/localhost\/cache\/files\/)/, getInstanceURL() + 'ooapi/');
}
config.document.url = url;
if (config.documentType === "spreadsheet") {
if (config.documentType === "spreadsheet" || config.documentType === "cell") {
config.documentType = "sheet";
}
if (config.documentType === "text") {
if (config.documentType === "slide") {
config.documentType = "presentation";
}
if (config.documentType === "word" || config.documentType === "text") {
config.documentType = "doc";
}
@ -308,8 +345,8 @@
iframe.setAttribute('name', 'frameEditor');
iframe.setAttribute('align', 'top');
iframe.setAttribute("src", url);
iframe.setAttribute("width", config.width);
iframe.setAttribute("height", config.height);
iframe.setAttribute("width", config.width || '100%');
iframe.setAttribute("height", config.height || '100%');
if (config.editorConfig) { // OnlyOffice
container.replaceWith(iframe);
container = iframe;

View File

@ -7,28 +7,6 @@ define([
], function (
DiagramUtil
) {
const parseDrawioStyle = (styleAttrValue) => {
if (!styleAttrValue) {
return;
}
const result = {};
for (const part of styleAttrValue.split(';')) {
const s = part.split(/=(.*)/);
result[s[0]] = s[1];
}
return result;
};
const stringifyDrawioStyle = (styleAttrValue) => {
const parts = [];
for (const [key, value] of Object.entries(styleAttrValue)) {
parts.push(`${key}=${value}`);
}
return parts.join(';');
};
const blobToImage = (blob) => {
return new Promise((resolve) => {
const reader = new FileReader();
@ -44,28 +22,18 @@ define([
};
const loadCryptPadImages = (doc) => {
return Array.from(doc .querySelectorAll('mxCell'))
.map((element) => [element, parseDrawioStyle(element.getAttribute('style'))])
.filter(([, style]) => style && style.image && style.image.startsWith('cryptpad://'))
return Array.from(doc.querySelectorAll('mxCell'))
.map((element) => [element, DiagramUtil.parseDrawioStyle(element.getAttribute('style'))])
.filter(([, style]) => style.image && style.image.startsWith('cryptpad://'))
.map(([element, style]) => {
return loadImage(style.image)
.then((dataUrl) => {
style.image = dataUrl.replace(';base64', ''); // ';' breaks draw.ios style format
element.setAttribute('style', stringifyDrawioStyle(style));
element.setAttribute('style', DiagramUtil.stringifyDrawioStyle(style));
});
});
};
const parseXML = (xmlStr) => {
const parser = new DOMParser();
const doc = parser.parseFromString(xmlStr, "application/xml");
const errorNode = doc.querySelector("parsererror");
if (errorNode) {
throw Error("error while parsing " + errorNode);
}
return doc;
};
return {
main: function(userDoc, cb) {
delete userDoc.metadata;
@ -74,7 +42,7 @@ define([
let doc;
try {
doc = parseXML(xml);
doc = DiagramUtil.parseXML(xml);
} catch(e) {
console.error(e);
return;

65
www/diagram/import.js Normal file
View File

@ -0,0 +1,65 @@
// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team <contact@cryptpad.org> and contributors
//
// SPDX-License-Identifier: AGPL-3.0-or-later
define([
'/diagram/util.js',
], function (
DiagramUtil,
) {
const Nacl = window.nacl;
const splitAt = function (str, char) {
const pos = str.indexOf(char);
if (pos <= 0) {
return [str, ''];
}
return [str.substring(0, pos), str.substring(pos + 1)];
};
const parseDataUrl = function (url) {
const [prefix, data] = splitAt(url, ',');
const [, metadata] = splitAt(prefix, ':');
const [mimeType, ] = splitAt(metadata, ';');
const u8 = Nacl.util.decodeBase64(data);
return new Blob([u8], { type: mimeType });
};
const saveImagesToCryptPad = async (fileManager, doc) => {
const images = Array.from(doc.querySelectorAll('mxCell'))
.map((element) => ({
element,
style: DiagramUtil.parseDrawioStyle(element.getAttribute('style')),
}))
.filter(({ style }) => style.image && style.image.startsWith('data:'));
for(const image of images) {
const blob = parseDataUrl(image.style.image);
const cryptPadUrl = await DiagramUtil.uploadFile(fileManager, blob);
image.style.image = cryptPadUrl;
image.element.setAttribute('style', DiagramUtil.stringifyDrawioStyle(image.style));
}
};
const importDiagram = async (common, content) => {
let doc;
try {
doc = DiagramUtil.parseXML(content);
} catch(e) {
console.error(e);
return;
}
const fileManager = DiagramUtil.createSimpleFileManager(common);
await saveImagesToCryptPad(fileManager, doc);
return DiagramUtil.xmlAsJsonContent(new XMLSerializer().serializeToString(doc));
};
return {
importDiagram
};
});

View File

@ -7,7 +7,6 @@ define([
'jquery',
'/common/sframe-app-framework.js',
'/customize/messages.js', // translation keys
'/components/pako/dist/pako.min.js',
'/components/x2js/x2js.js',
'/diagram/util.js',
'/common/common-ui-elements.js',
@ -18,48 +17,12 @@ define([
$,
Framework,
Messages,
pako,
X2JS,
DiagramUtil,
UIElements
) {
const Nacl = window.nacl;
const APP = window.APP = {};
// As described here: https://drawio-app.com/extracting-the-xml-from-mxfiles/
const decompressDrawioXml = function(xmlDocStr) {
var TEXT_NODE = 3;
var parser = new DOMParser();
var doc = parser.parseFromString(xmlDocStr, "application/xml");
var errorNode = doc.querySelector("parsererror");
if (errorNode) {
console.error("error while parsing", errorNode);
return xmlDocStr;
}
doc.firstChild.removeAttribute('modified');
doc.firstChild.removeAttribute('agent');
doc.firstChild.removeAttribute('etag');
var diagrams = doc.querySelectorAll('diagram');
diagrams.forEach(function(diagram) {
if (diagram.childNodes.length === 1 && diagram.firstChild && diagram.firstChild.nodeType === TEXT_NODE) {
const innerText = diagram.firstChild.nodeValue;
const bin = Nacl.util.decodeBase64(innerText);
const xmlUrlStr = pako.inflateRaw(bin, {to: 'string'});
const xmlStr = decodeURIComponent(xmlUrlStr);
const diagramDoc = parser.parseFromString(xmlStr, "application/xml");
diagram.replaceChild(diagramDoc.firstChild, diagram.firstChild);
}
});
var result = new XMLSerializer().serializeToString(doc);
return result;
};
const deepEqual = function(o1, o2) {
return JSON.stringify(o1) === JSON.stringify(o2);
@ -105,28 +68,8 @@ define([
});
};
const numbersToNumbers = function(o) {
const type = typeof o;
if (type === "object") {
for (const key in o) {
o[key] = numbersToNumbers(o[key]);
}
return o;
} else if (type === 'string' && o.match(/^[+-]?(0|(([1-9]\d*)(\.\d+)?))$/)) {
return parseFloat(o, 10);
} else {
return o;
}
};
const xmlAsJsonContent = (xml) => {
var decompressedXml = decompressDrawioXml(xml);
return numbersToNumbers(x2js.xml2js(decompressedXml));
};
var onDrawioChange = function(newXml) {
var newJson = xmlAsJsonContent(newXml);
var newJson = DiagramUtil.xmlAsJsonContent(newXml);
if (!deepEqual(lastContent, newJson)) {
lastContent = newJson;
framework.localChange();
@ -150,7 +93,10 @@ define([
return new Promise((resolve) => {
framework.insertImage({}, (imageData) => {
if (imageData.blob) {
resolve(imageData.blob);
const fileManager = DiagramUtil.createSimpleFileManager(framework._.sfCommon);
DiagramUtil.uploadFile(fileManager, imageData.blob)
.then(url => resolve(url))
.catch(e => console.error(e));
} else if (imageData.url) {
resolve(imageData.url);
} else {
@ -179,9 +125,12 @@ define([
framework.setFileImporter(
{accept: ['.drawio', 'application/x-drawio']},
(content) => {
return xmlAsJsonContent(content);
}
(content, file, cb) => {
require(['/diagram/import.js'], (importer) => {
importer.importDiagram(framework._.sfCommon, content, file).then(cb);
});
},
true
);
framework.setFileExporter(
@ -250,6 +199,7 @@ define([
Framework.create({
toolbarContainer: '#cme_toolbox',
contentContainer: '#cp-app-diagram-editor',
skipLink: '#cp-app-diagram-content|body .geSearchSidebar',
// validateContent: validateXml,
}, function (framework) {
onFrameworkReady(framework);

View File

@ -7,11 +7,19 @@ define([
'/file/file-crypto.js',
'/common/outer/cache-store.js',
'/components/x2js/x2js.js',
'/components/pako/dist/pako.min.js',
'/common/common-hash.js',
'/api/config',
'jquery',
], function (
Util,
FileCrypto,
Cache,
X2JS,
pako,
Hash,
ApiConfig,
$,
) {
const Nacl = window.nacl;
const x2js = new X2JS();
@ -49,10 +57,139 @@ define([
return x2js.js2xml(cleaned);
};
const parseXML = (xmlStr) => {
const parser = new DOMParser();
const doc = parser.parseFromString(xmlStr, "application/xml");
const errorNode = doc.querySelector("parsererror");
if (errorNode) {
throw Error("error while parsing " + errorNode);
}
return doc;
};
const numbersToNumbers = function(o) {
const type = typeof o;
if (type === "object") {
for (const key in o) {
o[key] = numbersToNumbers(o[key]);
}
return o;
} else if (type === 'string' && o.match(/^[+-]?(0|(([1-9]\d*)(\.\d+)?))$/)) {
return parseFloat(o, 10);
} else {
return o;
}
};
const xmlAsJsonContent = (xml) => {
var decompressedXml = decompressDrawioXml(xml);
return numbersToNumbers(x2js.xml2js(decompressedXml));
};
// As described here: https://drawio-app.com/extracting-the-xml-from-mxfiles/
const decompressDrawioXml = function(xmlDocStr) {
var TEXT_NODE = 3;
var parser = new DOMParser();
var doc = parser.parseFromString(xmlDocStr, "application/xml");
var errorNode = doc.querySelector("parsererror");
if (errorNode) {
console.error("error while parsing", errorNode);
return xmlDocStr;
}
doc.firstChild.removeAttribute('modified');
doc.firstChild.removeAttribute('agent');
doc.firstChild.removeAttribute('etag');
var diagrams = doc.querySelectorAll('diagram');
diagrams.forEach(function(diagram) {
if (diagram.childNodes.length === 1 && diagram.firstChild && diagram.firstChild.nodeType === TEXT_NODE) {
const innerText = diagram.firstChild.nodeValue;
const bin = Nacl.util.decodeBase64(innerText);
const xmlUrlStr = pako.inflateRaw(bin, {to: 'string'});
const xmlStr = decodeURIComponent(xmlUrlStr);
const diagramDoc = parser.parseFromString(xmlStr, "application/xml");
diagram.replaceChild(diagramDoc.firstChild, diagram.firstChild);
}
});
var result = new XMLSerializer().serializeToString(doc);
return result;
};
const parseDrawioStyle = (styleAttrValue) => {
if (!styleAttrValue) {
return {};
}
const result = {};
for (const part of styleAttrValue.split(';')) {
const s = part.split(/=(.*)/);
result[s[0]] = s[1];
}
return result;
};
const stringifyDrawioStyle = (styleAttrValue) => {
const parts = [];
for (const [key, value] of Object.entries(styleAttrValue)) {
parts.push(`${key}=${value}`);
}
return parts.join(';');
};
const getCryptPadUrlForUploadData = (data) => {
const urlHash = data.url.split('#')[1];
const secret = Hash.getSecrets('file', urlHash);
const fileHost = ApiConfig.fileHost || window.location.origin;
const hexFileName = secret.channel;
const src = fileHost + Hash.getBlobPathFromHex(hexFileName);
const key = secret.keys && secret.keys.cryptKey;
const cryptKey = Nacl.util.encodeBase64(key);
return getCryptPadUrl(src, cryptKey, data.fileType);
};
const uploadFile = async (fileManager, blob) => {
return new Promise((resolve) => {
fileManager.handleFile(blob, {
callback: (data) => {
const cryptPadUrl = getCryptPadUrlForUploadData(data);
resolve(cryptPadUrl);
}
});
});
};
const createSimpleFileManager = (common) => {
const fmConfigImages = {
noHandlers: true,
noStore: true,
onUploaded: function (ev, data) {
if (!ev.callback) { return; }
ev.callback(data);
}
};
return common.createFileManager(fmConfigImages);
};
return {
parseCryptPadUrl,
getCryptPadUrl,
jsonContentAsXML,
parseXML,
xmlAsJsonContent,
decompressDrawioXml,
parseDrawioStyle,
stringifyDrawioStyle,
uploadFile,
createSimpleFileManager,
loadImage: function(href) {
return new Promise((resolve, reject) => {

View File

@ -217,7 +217,8 @@ define([
metadataMgr: metadataMgr,
readOnly: privateData.readOnly,
sfCommon: common,
$container: APP.$bar
$container: APP.$bar,
skipLink: '#cp-app-drive-tree'
};
var toolbar = Toolbar.create(configTb);

View File

@ -5664,5 +5664,6 @@ define([
Framework.create({
toolbarContainer: '#cp-toolbar',
contentContainer: '#cp-app-form-editor',
skipLink: '#cp-app-form-editor'
}, andThen);
});

View File

@ -44,7 +44,10 @@ define([
}
}
},
order: ["1", "2"]
order: ["1", "2"],
metadata: {
title: Messages.form_template_poll
}
}
}];
});

View File

@ -3,10 +3,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
define([
'/api/config',
'/common/sframe-common-outer.js',
'/common/common-hash.js',
], function (SCO, Hash) {
'/components/tweetnacl/nacl-fast.min.js'
], function (Config, SCO, Hash) {
let Nacl = window.nacl;
var getTxid = function () {
return Math.random().toString(16).replace('0.', '');
};
@ -95,9 +98,17 @@ define([
};
http.send();
};
let sanitizeKey = key => {
try {
Nacl.util.decodeBase64(key);
return key;
} catch (e) {
return Nacl.util.encodeBase64(Nacl.util.decodeUTF8(key)).replaceAll('=', '');
}
};
chan.on('GET_SESSION', function (data, cb) {
if (data.keepOld) {
var key = data.key + "000000000000000000000000000000000";
if (data.keepOld) { // they provide their own key, we must turn it into a hash
var key = sanitizeKey(data.key) + "000000000000000000000000000000000";
console.warn('KEY', key);
return void cb({
key: `/2/integration/edit/${key.slice(0,24)}/`
@ -118,12 +129,6 @@ define([
});
});
var save = function (obj, cb) {
chan.send('SAVE', obj.blob, function (err) {
if (err) { return cb({error: err}); }
cb();
});
};
var reload = function (data) {
chan.send('RELOAD', data);
};
@ -152,36 +157,122 @@ define([
chan.send('ON_DOWNLOADAS', blob);
};
chan.on('START', function (data) {
console.warn('INNER START', data);
var href = Hash.hashToHref(data.key, data.application);
console.error(Hash.hrefToHexChannelId(href));
window.CP_integration_outer = {
pathname: `/${data.application}/`,
hash: data.key,
href: href,
initialState: data.document,
config: {
fileType: data.ext,
autosave: data.autosave
},
utils: {
onReady: onReady,
onDownloadAs,
setDownloadAs,
save: save,
reload: reload,
onHasUnsavedChanges: onHasUnsavedChanges,
onInsertImage: onInsertImage
let getInstanceURL = function () {
return Config.httpUnsafeOrigin;
};
let getBlobServer = function (documentURL, cb) {
let xhr = new XMLHttpRequest();
let data = encodeURIComponent(documentURL);
let url = getInstanceURL() + '/ooapidl?url=' + data;
xhr.open('GET', url, true);
xhr.responseType = 'blob';
//xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
if (this.status === 200) {
var blob = this.response;
// myBlob is now the blob that the object URL pointed to.
cb(null, blob);
} else {
cb(this.status);
}
};
let path = "/common/sframe-app-outer.js";
if (['sheet', 'doc', 'presentation'].includes(data.application)) {
path = '/common/onlyoffice/main.js';
xhr.onerror = function (e) {
cb(e.message);
};
xhr.send();
};
let saveBlobServer = function (cfg, blob, cb) {
let {callbackUrl, name, key} = cfg;
let xhr = new XMLHttpRequest();
name = encodeURIComponent(name);
callbackUrl = encodeURIComponent(callbackUrl);
key = encodeURIComponent(key);
let query = `name=${name}&cb=${callbackUrl}&key=${key}`;
let url = getInstanceURL() + `/oosave?${query}`;
xhr.open('POST', url, true);
xhr.responseType = 'blob';
//xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
console.error(this.status);
if (this.status === 200) {
cb();
} else {
cb(this.status);
}
};
xhr.onerror = function (e) {
cb(e.message);
};
xhr.send(blob);
};
chan.on('START', function (data, cb) {
console.warn('INNER START', data);
// data.key is a hash
var href = Hash.hashToHref(data.key, data.application);
if (data.editorConfig.lang) {
var LS_LANG = "CRYPTPAD_LANG";
localStorage.setItem(LS_LANG, data.editorConfig.lang);
}
require([path], function () {
console.warn('SAO REQUIRED');
delete window.CP_integration_outer;
let fileName = data.name || `document.${data.ext}`;
var save = function (obj, cb) {
let cbUrl = data.editorConfig.callbackUrl;
if (!data.autosave && cbUrl) {
saveBlobServer({
callbackUrl: cbUrl,
name: fileName,
key: data.documentKey
}, obj.blob, cb);
return;
}
chan.send('SAVE', obj.blob, function (err) {
if (err) { return cb({error: err}); }
cb();
});
};
console.error(Hash.hrefToHexChannelId(href));
let startApp = function (blob) {
window.CP_integration_outer = {
pathname: `/${data.application}/`,
hash: data.key,
href: href,
initialState: blob,
config: {
fileName: data.name,
fileType: data.ext,
autosave: data.autosave,
user: data.editorConfig.user,
_: data._config
},
utils: {
onReady: onReady,
onDownloadAs,
setDownloadAs,
save: save,
reload: reload,
onHasUnsavedChanges: onHasUnsavedChanges,
onInsertImage: onInsertImage
}
};
let path = "/common/sframe-app-outer.js";
if (['sheet', 'doc', 'presentation'].includes(data.application)) {
path = '/common/onlyoffice/main.js';
}
require([path], function () {
console.warn('SAO REQUIRED');
delete window.CP_integration_outer;
cb();
});
};
if (data.document) { return void startApp(data.document); }
getBlobServer(data.url, (err, blob) => {
if (err) {
return void cb({error: err});
}
startApp(blob);
});
});

View File

@ -145,6 +145,17 @@
margin-bottom: 15px;
}
.cp-kanban-toggle-container.cp-kanban-container-flex {
flex: 1;
}
.cp-kanban-toggle-tags {
text-transform: unset;
margin-right: 0.5rem;
padding: 3px 10px;
span {
font: @colortheme_app-font;
}
}
#cp-kanban-edit-tags {
.tokenfield {
margin: 0;
@ -152,10 +163,6 @@
}
margin-bottom: 15px;
}
.kanban-tag-btn-toggle {
margin-top: 10px;
margin-left: 10px
}
#cp-app-kanban-container {
flex: 1;
display: flex;
@ -430,12 +437,10 @@
justify-content: space-between;
position: relative;
min-height: 50px;
align-items: center;
.cp-kanban-filterTags {
@media (min-width: 505px) {
display: inline-flex;
.kanban-tag-btn-toggle {
margin-right: 10px;
}
}
align-items: center;
flex: 1;
@ -449,16 +454,8 @@
}
flex-flow: column;
flex-shrink: 0;
& > * {
visibility: hidden;
}
& > span {
display: inline-block;
height: 38px;
line-height: 38px;
}
& > button {
margin-top: -38px;
}
}
button.cp-kanban-filterTags-reset {
@ -571,7 +568,7 @@
display: flex;
min-height: 0;
.kanban-container {
padding: 30px 5px;
padding: 0px 5px;
flex: 1;
display: flex;
max-height: 100%;
@ -700,6 +697,15 @@
}
}
@media (pointer: none), (pointer:coarse) {
.kanban-container-outer {
.kanban-container {
padding: 30px 5px;
}
}
}
&.cp-app-readonly {
.kanban-item, .kanban-title-board {
cursor: default !important;

View File

@ -65,6 +65,9 @@ define([
var onCursorUpdate = Util.mkEvent();
var remoteCursors = {};
let getCursor = () => {};
let restoreCursor = () => {};
var setValueAndCursor = function (input, val, _cursor) {
if (!input) { return; }
var $input = $(input);
@ -149,13 +152,17 @@ define([
var addEditItemButton = function () {};
var onRemoteChange = Util.mkEvent();
var now = function () { return +new Date(); };
var _lastUpdate = 0;
var _updateBoards = function (framework, kanban, boards) {
_lastUpdate = now();
var cursor = getCursor();
kanban.setBoards(Util.clone(boards));
kanban.inEditMode = false;
addEditItemButton(framework, kanban);
restoreCursor(cursor);
onRemoteChange.fire();
};
var _updateBoardsThrottle = Util.throttle(_updateBoards, 1000);
var updateBoards = function (framework, kanban, boards) {
@ -166,7 +173,6 @@ define([
_updateBoardsThrottle(framework, kanban, boards);
};
var onRemoteChange = Util.mkEvent();
var editModal;
var PROPERTIES = ['title', 'body', 'tags', 'color'];
var BOARD_PROPERTIES = ['title', 'color'];
@ -700,8 +706,11 @@ define([
var item = kanban.getItemJSON(eid);
item.title = name;
kanban.onChange();
// Unlock edit mode
kanban.inEditMode = false;
// Unlock edit mode unless we're already editing
// something else
if (kanban.inEditMode === eid) {
kanban.inEditMode = false;
}
onCursorUpdate.fire({});
};
$input.blur(save);
@ -764,7 +773,9 @@ define([
kanban.getBoardJSON(boardId).title = name;
kanban.onChange();
// Unlock edit mode
kanban.inEditMode = false;
if (kanban.inEditMode === boardId) {
kanban.inEditMode = false;
}
onCursorUpdate.fire({});
};
$input.blur(save);
@ -819,7 +830,9 @@ define([
});
var save = function () {
$item.remove();
kanban.inEditMode = false;
if (kanban.inEditMode === "new") {
kanban.inEditMode = false;
}
onCursorUpdate.fire({});
if (!$input.val()) { return; }
var id = Util.createRandomInteger();
@ -930,18 +943,15 @@ define([
//framework._.sfCommon.setPadAttribute('quickMode', false);
});
var toggleTagsButton = h('button.btn.btn-default.kanban-tag-btn-toggle', Messages.kanban_showTags);
// Tags filter
var existing = getExistingTags(kanban.options.boards);
var list = h('div.cp-kanban-filterTags-list');
var reset = h('button.btn.btn-cancel.cp-kanban-filterTags-reset', [
var reset = h('button.btn.btn-cancel.cp-kanban-filterTags-reset.cp-kanban-toggle-tags', [
h('i.fa.fa-times'),
Messages.kanban_clearFilter
h('span', Messages.kanban_clearFilter)
]);
var hint = h('span.cp-kanban-filterTags-name', Messages.kanban_tags);
var tags = h('div.cp-kanban-filterTags', [
h('span.cp-kanban-filterTags-toggle', [
hint,
reset,
@ -954,8 +964,16 @@ define([
var $hint = $(hint);
var setTagFilterState = function (bool) {
//$hint.toggle(!bool);
//$reset.toggle(!!bool);
$hint.css('visibility', bool? 'hidden': 'visible');
$hint.css('height', bool ? 0 : '');
$hint.css('padding-top', bool ? 0 : '');
$hint.css('padding-bottom', bool ? 0 : '');
$reset.css('visibility', bool? 'visible': 'hidden');
$reset.css('height', !bool ? 0 : '');
$reset.css('padding-top', !bool ? 0 : '');
$reset.css('padding-bottom', !bool ? 0 : '');
};
setTagFilterState();
@ -1022,41 +1040,48 @@ define([
commitTags();
});
let toggleTagsButton = h('button.btn.btn-default.cp-kanban-toggle-tags', [
h('i.fa.fa-tags'),
h('span', Messages.fm_tagsName)
]);
let toggleContainer = h('div.cp-kanban-toggle-container', toggleTagsButton);
if ($(window).width() < 500) {
let toggleClicked = false;
let $tags = $(tags);
let $toggleBtn = $(toggleTagsButton);
let toggle = () => {
$tags.toggle();
let visible = $tags.is(':visible');
$(toggleContainer).toggleClass('cp-kanban-container-flex', !visible);
$toggleBtn.toggleClass('btn-default', visible);
$toggleBtn.toggleClass('btn-default-alt', !visible);
};
$toggleBtn.click(function() {
toggleClicked = true;
toggle();
});
$(tags).append(toggleTagsButton);
var hideTags = function () {
for (var tag of list.children) {
if (existing.indexOf(tag.innerHTML) > 10) {
$(tag).hide();
}
const resizeTags = () => {
if (toggleClicked) { return; }
let visible = $tags.is(':visible');
// Small screen and visible: hide
if ($(window).width() < 600) {
if (visible) {
$(tags).show();
toggle();
}
};
hideTags();
var toggleTags = function () {
for (var tag of list.children) {
if (existing.indexOf(tag.innerHTML) > 10 && kanban.options.tags.indexOf(tag.innerHTML) === -1) {
if ($(tag).is(":visible")) {
$(tag).hide();
$(toggleTagsButton).text(Messages.kanban_showTags);
} else {
$(tag).show();
$(toggleTagsButton).text(Messages.kanban_hideTags);
}
}
}
};
$(toggleTagsButton).click(function() {
toggleTags();
});
}
return;
}
// Large screen: make visible by default
if (visible) { return; }
$(tags).hide();
toggle();
};
$(window).on('resize', resizeTags);
var container = h('div#cp-kanban-controls', [
toggleContainer,
tags,
h('div.cp-kanban-changeView', [
small,
@ -1065,18 +1090,6 @@ define([
]);
$container.before(container);
var common = framework._.sfCommon;
var $button = common.createButton('toggle', true, {
element: $(container),
icon: 'fa-tags',
text: Messages.fm_tagsName,
}, function () {
$button.toggleClass('cp-toolbar-button-active');
});
$button.addClass('cp-toolbar-button-active');
framework._.toolbar.$bottomL.append($button);
onRedraw.reg(function () {
// Redraw if new tags have been added to items
var old = Sortify(existing);
@ -1191,7 +1204,7 @@ define([
$container.find('.kanban-edit-item').remove();
});
var getCursor = function () {
getCursor = function () {
if (!kanban || !kanban.inEditMode) { return; }
try {
var id = kanban.inEditMode;
@ -1232,7 +1245,7 @@ define([
return {};
}
};
var restoreCursor = function (data) {
restoreCursor = function (data) {
if (!data) { return; }
try {
var id = data.id;
@ -1296,12 +1309,9 @@ define([
var remoteContent = newContent.content;
if (Sortify(currentContent) !== Sortify(remoteContent)) {
var cursor = getCursor();
verbose("Content is different.. Applying content");
kanban.options.boards = remoteContent;
updateBoards(framework, kanban, remoteContent);
restoreCursor(cursor);
onRemoteChange.fire();
}
});
@ -1391,6 +1401,7 @@ define([
Framework.create({
toolbarContainer: '#cme_toolbox',
contentContainer: '#cp-app-kanban-editor',
skipLink: '#cp-app-kanban-content'
}, waitFor(function (framework) {
andThen2(framework);
}));

View File

@ -110,6 +110,7 @@ define([
var boardContainerOuter = document.createElement('div');
boardContainerOuter.classList.add('kanban-container-outer');
var boardContainer = document.createElement('div');
boardContainer.setAttribute('id', 'kanban-container');
boardContainer.classList.add('kanban-container');
boardContainerOuter.appendChild(boardContainer);
self.container = boardContainer;
@ -737,6 +738,15 @@ define([
return boardNode;
};
let reorder = () => {
// Push "add" button to the end of the list
let add = document.getElementById('kanban-addboard');
let list = document.getElementById('kanban-container');
if (!add || !list) { return; }
list.appendChild(add);
};
this.addBoard = function (board) {
if (!board || !board.id) { return; }
// We need to store all the columns in _boards too because it's used to
@ -757,6 +767,7 @@ define([
_boards.list.push(board.id);
var boardNode = getBoardNode(board);
self.container.appendChild(boardNode);
reorder();
};
this.addBoards = function() {
@ -824,6 +835,7 @@ define([
$('.kanban-board[data-id="'+id+'"] .kanban-drag').scrollTop(scroll[id]);
});
$el.scrollLeft(scrollLeft);
reorder();
};
// If the tab is not focused, redraw on focus

View File

@ -78,6 +78,7 @@
justify-content: flex-start;
align-items: center;
background-color: @cp_notif-bg;
border-top: 1px solid @cp_notif-table-border;
&.no-notifications {
display: none;
padding: 1rem 1rem;
@ -95,12 +96,14 @@
background-color: @cp_notif-hover;
}
}
.cp-avatar-calendar {
font-size: 45px;
padding: 0 12px;
overflow: hidden;
}
&.cp-app-notification-archived {
background-color: @cp_notif-bg;
}
&:not(:first-child) {
border-top: 1px solid @cp_notif-table-border;
}
&.dismissed {
display: none;
}

View File

@ -87,6 +87,8 @@ define([
// if the type of notification correspond
if (filterTypes.indexOf(data.content.msg.type) !== -1) {
notifsData.push(data);
var icon = $(el).find(".cp-reminder");
$(icon).addClass('cp-avatar-calendar');
$(notifsList).prepend(el);
}
};
@ -143,7 +145,7 @@ define([
$(loadmore).click();
}
common.mailbox.subscribe(["notifications"], {
common.mailbox.subscribe(["notifications", "reminders"], {
onMessage: function (data, el) {
addNotification(data, el);
},
@ -238,6 +240,7 @@ define([
$container: APP.$toolbar,
pageTitle: Messages.notificationsPage || 'Notifications',
metadataMgr: common.getMetadataMgr(),
skipLink: '#cp-sidebarlayout-container',
};
APP.toolbar = Toolbar.create(configTb);
APP.toolbar.$rightside.hide();

View File

@ -94,9 +94,59 @@ define([
return void cb(blob);
}
if (ext === ".md") {
let strikethrough = {
filter: ['s', 'del', 'strike'],
replacement: function (content) {
return '~' + content + '~';
}
};
let underline = {
filter: ['u'],
replacement: function (content) {
return '<u>' + content + '</u>';
}
};
var md = Turndown({
headingStyle: 'atx'
}).turndown(toExport);
}).addRule('table', {
filter: ['table'],
replacement: function (content, node) {
var childNodeArr = Array.from(node.childNodes);
var table = '';
childNodeArr.forEach(function(rowNode) {
rowNode.childNodes.forEach(function(childNode) {
var rowContent = Array.from(childNode.childNodes);
var indexOf = Array.prototype.indexOf;
var index = childNodeArr.length > 1 ? indexOf.call(node.childNodes, rowNode) : indexOf.call(rowNode.childNodes, childNode);
var row = '|';
var rowLength = rowContent.filter(Boolean).length;
for (var i =0; i < rowLength; i++) {
var cell = rowContent[i];
var cellContent = Array.from(cell.childNodes);
if ((cellContent.length === 1 && cellContent[0].nodeName === "BR") || !cellContent.length) {
row += '|';
} else if (cellContent.length >= 1) {
row += Turndown({
headingStyle: 'atx'
}).addRule('strikethrough', strikethrough)
.addRule('underline', underline)
.turndown(cell.innerHTML).replaceAll('\n', '<br>');
row += '|';
}
}
var newRow = row.concat('\n');
if (index === 0) {
var separator = '|-';
newRow += `${separator.repeat(rowLength)}|\n`;
}
table += newRow;
return newRow;
});
});
return table;
}}).addRule('strikethrough', strikethrough)
.addRule('underline', underline)
.turndown(toExport);
var mdBlob = new Blob([md], {
type: 'text/markdown;charset=utf-8'
});

View File

@ -1335,6 +1335,7 @@ define([
Framework.create({
toolbarContainer: '#cp-app-pad-toolbar',
contentContainer: '#cp-app-pad-editor',
skipLink: '#cke_1_contents .cke_wysiwyg_frame|html',
patchTransformer: ChainPad.NaiveJSONTransformer,
/*thumbnail: {
getContainer: function () { return $('iframe').contents().find('html')[0]; },

View File

@ -603,6 +603,7 @@ define([
$container: APP.$toolbar,
pageTitle: Messages.profileButton,
metadataMgr: common.getMetadataMgr(),
skipLink: '#cp-sidebarlayout-container'
};
APP.toolbar = Toolbar.create(configTb);
APP.toolbar.$rightside.hide();

View File

@ -1196,6 +1196,7 @@ define([
Feedback.send('FULL_DRIVE_EXPORT_START');
var todo = function(data, filename) {
var ui = Backup.createExportUI(privateData.origin);
data.common = common;
var bu = Backup.create(data, common.getPad, privateData.fileHost, function(blob, errors) {
saveAs(blob, filename);
@ -1978,6 +1979,7 @@ define([
$container: APP.$toolbar,
pageTitle: Messages.settings_title,
metadataMgr: common.getMetadataMgr(),
skipLink: '#cp-sidebarlayout-leftside'
};
APP.toolbar = Toolbar.create(configTb);
APP.toolbar.$rightside.hide();

View File

@ -619,7 +619,8 @@ define([
}
$(el).css('background-color', '');
}
}
},
skipLink: '.CodeMirror',
}, waitFor(function (fw) { framework = fw; }));
nThen(function (waitFor) {

View File

@ -162,6 +162,7 @@ define([
var makeForm = function (ctx, opts, cb) {
let { oldData, recorded, title, hideNotice } = opts || {};
var button;
cb = Util.once(cb);
if (typeof(cb) === "function") {
button = h('button.btn.btn-primary.cp-support-list-send', Messages.contacts_send);

View File

@ -1564,7 +1564,8 @@ define([
metadataMgr: metadataMgr,
readOnly: privateData.readOnly,
sfCommon: common,
$container: $bar
$container: $bar,
skipLink: '#cp-sidebarlayout-leftside'
};
var toolbar = APP.toolbar = Toolbar.create(configTb);
// Update the name in the user menu

View File

@ -636,6 +636,7 @@ define([
patchTransformer: ChainPad.NaiveJSONTransformer,
toolbarContainer: '#cp-toolbar',
contentContainer: '#cp-app-whiteboard-canvas-area',
skipLink: '#cp-app-whiteboard-controls'
}, waitFor(function (framework) {
andThen2(framework);
}));