mirror of
https://github.com/cryptpad/cryptpad.git
synced 2026-09-14 11:05:41 +05:00
Merge branch 'staging' into fix-onlyoffice-checkup
This commit is contained in:
commit
37f0bdae9e
@ -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
|
||||
|
||||
65
CHANGELOG.md
65
CHANGELOG.md
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
---
|
||||
services:
|
||||
cryptpad:
|
||||
image: "cryptpad/cryptpad:version-2024.12.0"
|
||||
image: "cryptpad/cryptpad:version-2025.3.0"
|
||||
hostname: cryptpad
|
||||
|
||||
environment:
|
||||
|
||||
@ -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"],
|
||||
|
||||
25
lib/api.js
25
lib/api.js
@ -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 || [];
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
140
lib/decrees-core.js
Normal 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
|
||||
};
|
||||
};
|
||||
371
lib/decrees.js
371
lib/decrees.js
@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
@ -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',
|
||||
|
||||
|
||||
143
lib/eviction.js
143
lib/eviction.js
@ -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();
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -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);
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
62
package-lock.json
generated
@ -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"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 don’t 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.
|
||||
|
||||

|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
198
scripts/migrations/migrate-blob-proofs.js
Normal file
198
scripts/migrations/migrate-blob-proofs.js
Normal 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
132
scripts/user-statistics.js
Normal 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();
|
||||
10
server.js
10
server.js
@ -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 () {
|
||||
|
||||
@ -61,6 +61,11 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
&[data-item="add-admins"] {
|
||||
.cp-sidebar-form:not(:last-child) {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -610,6 +610,7 @@ define([
|
||||
Framework.create({
|
||||
toolbarContainer: '#cme_toolbox',
|
||||
contentContainer: '#cp-app-code-editor',
|
||||
skipLink: '.CodeMirror',
|
||||
thumbnail: {
|
||||
getContainer: getThumbnailContainer,
|
||||
filter: function (el, before) {
|
||||
|
||||
@ -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: []
|
||||
};
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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] || {};
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
});
|
||||
|
||||
|
||||
@ -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; }
|
||||
|
||||
|
||||
@ -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": "Тази папка не може да бъде преобразувана в споделена папка в текущото си местоположение. Преместете го извън споделената папка, за да продължите."
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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": "activé",
|
||||
"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."
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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 dell’istanza",
|
||||
"admin_onboardingOptionsHint": "Scegli l’opzione appropriata per la tua istanza.<br> Queste configurazioni possono essere cambiate successivamente nel pannello di amministrazione."
|
||||
"admin_onboardingOptionsHint": "Scegli l’opzione 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?"
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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
@ -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ı"
|
||||
}
|
||||
|
||||
@ -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": "显示日历"
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
65
www/diagram/import.js
Normal 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
|
||||
};
|
||||
});
|
||||
@ -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);
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -5664,5 +5664,6 @@ define([
|
||||
Framework.create({
|
||||
toolbarContainer: '#cp-toolbar',
|
||||
contentContainer: '#cp-app-form-editor',
|
||||
skipLink: '#cp-app-form-editor'
|
||||
}, andThen);
|
||||
});
|
||||
|
||||
@ -44,7 +44,10 @@ define([
|
||||
}
|
||||
}
|
||||
},
|
||||
order: ["1", "2"]
|
||||
order: ["1", "2"],
|
||||
metadata: {
|
||||
title: Messages.form_template_poll
|
||||
}
|
||||
}
|
||||
}];
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}));
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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'
|
||||
});
|
||||
|
||||
@ -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]; },
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -619,7 +619,8 @@ define([
|
||||
}
|
||||
$(el).css('background-color', '');
|
||||
}
|
||||
}
|
||||
},
|
||||
skipLink: '.CodeMirror',
|
||||
}, waitFor(function (fw) { framework = fw; }));
|
||||
|
||||
nThen(function (waitFor) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user