Compare commits

...
22 Commits
Author SHA1 Message Date
Lauri Ojansivu 8b118c1939 v3.86 2020-03-24 21:01:14 +02:00
Lauri Ojansivu d7a0ab3c15 Update translations. 2020-03-24 20:48:58 +02:00
Lauri Ojansivu 8609e0e82d Update ChangeLog. 2020-03-24 20:45:25 +02:00
Lauri Ojansivu b9099a8b7e 1) Fix Pasting text into a card is adding a line before and after
(and multiplies by pasting more) by changing paste "p" to "br".
2) Fixes to summernote and markdown comment editors, related
   to keeping them open when adding comments, having
   @member mention not close card, and disabling clicking of
   @member mention.

Thanks to xet7 !

Closes #2890
2020-03-24 20:39:49 +02:00
Lauri Ojansivu 87a81789d1 Update ChangeLog. 2020-03-24 11:10:15 +02:00
Lauri Ojansivu 12ab8fac5d Fix Rich editor can not be disabled, regression from changes yesterday at Wekan v3.85.
Thanks to uusijani, vjrj and xet7 !

Closes #2967,
closes #104
2020-03-24 11:04:04 +02:00
Lauri Ojansivu 10b09b2da1 Fix typo. 2020-03-24 02:01:08 +02:00
Lauri Ojansivu 7c5f4a3849 v3.85 2020-03-23 23:01:58 +02:00
Lauri Ojansivu ec71849d84 Update ChangeLog. 2020-03-23 22:49:28 +02:00
Lauri Ojansivu 482682e500 SECURITY VULNERABILITY FIX: Fix XSS bug reported today 4 hours ago by Cyb3rjunky.
Logged in users could run javascript in input fields.
This affects Wekan versions v3.12-v3.84.
In [Wekan v3.12](https://github.com/wekan/wekan/blob/master/CHANGELOG.md#v312-2019-08-09-wekan-release)
there was [changes for XSS filter to allow inserting images, videos etc
on comment WYSIWYG editor](https://github.com/wekan/wekan/pull/2593)
so features related to that are now removed.
After this fix, Javascript in input fields is not executed.

Thanks to Cyb3rjunky and xet7 !
2020-03-23 22:29:20 +02:00
Lauri Ojansivu 3a6303e5c2 Update translations. 2020-03-23 22:25:08 +02:00
Lauri Ojansivu 81635d9217 Revert: Fix Pasting text into a card is adding a line before and after
(and multiplies by pasting more) by disabling WYSIWYG comment editor
for everyone.

Thanks to xet7.

TODO later: Fix wysiwyg editor paste bug.

Opens #2890
2020-03-23 22:23:31 +02:00
Lauri Ojansivu e8f89a8c4c Update translations. 2020-03-20 14:03:11 +02:00
Lauri Ojansivu 393482315f Fix typo. 2020-03-20 14:02:29 +02:00
Lauri Ojansivu cb67af02d7 Update ChangeLog. 2020-03-20 13:57:44 +02:00
Lauri Ojansivu a064b8d643 Fix Pasting text into a card is adding a line before and after
(and multiplies by pasting more) by disabling WYSIWYG comment editor
for everyone.

Closes #2890
2020-03-20 13:52:27 +02:00
Lauri Ojansivu 556621e60e Meteor 1.8 only in use at Sandstorm. 2020-03-17 14:04:50 +02:00
Lauri Ojansivu 0f95a513bf Meteor 1.8 only in use at Sandstorm. 2020-03-17 14:02:43 +02:00
Lauri Ojansivu dce624486f Merge branch 'master' of github.com:wekan/wekan 2020-03-17 13:35:00 +02:00
Lauri Ojansivu 64e76af8de Fix snap syntax. 2020-03-17 13:34:34 +02:00
Lauri Ojansivu acf2ede94e Fix typo. 2020-03-16 10:11:20 -04:00
Lauri Ojansivu b3a7c4dc88 For Sandstorm version of Wekan, leep stylus in 1.1.0, because building v2 takes extra 52 minutes.
Thanks to zodern !
2020-03-16 10:01:28 -04:00
31 changed files with 618 additions and 339 deletions

No files matched your search

File renamed without changes.
File renamed without changes.
@@ -85,7 +85,8 @@ msavin:usercache
wekan-scrollbar
mquandalle:perfect-scrollbar
mdg:meteor-apm-agent@3.2.0-rc.0!
coagmano:stylus
# Keep stylus in 1.1.0, because building v2 takes extra 52 minutes.
coagmano:stylus@1.1.0!
lucasantoniassi:accounts-lockout
meteorhacks:subs-manager
meteorhacks:picker
File renamed without changes.
File renamed without changes.
@@ -44,7 +44,7 @@ cfs:upload-http@0.0.20
cfs:worker@0.1.5
check@1.3.1
chuangbo:cookie@1.1.0
coagmano:stylus@2.0.0
coagmano:stylus@1.1.0
coffeescript@1.0.17
cottz:publish-relations@2.0.8
dburles:collection-helpers@1.1.0
File renamed without changes.
@@ -1,8 +1,7 @@
import ldapjs from 'ldapjs';
import util from 'util';
import Bunyan from 'bunyan';
import {log_debug, log_info, log_warn, log_error} from './logger';
import { log_debug, log_info, log_warn, log_error } from './logger';
export default class LDAP {
constructor() {
@@ -11,35 +10,66 @@ export default class LDAP {
this.connected = false;
this.options = {
host : this.constructor.settings_get('LDAP_HOST'),
port : this.constructor.settings_get('LDAP_PORT'),
Reconnect : this.constructor.settings_get('LDAP_RECONNECT'),
timeout : this.constructor.settings_get('LDAP_TIMEOUT'),
connect_timeout : this.constructor.settings_get('LDAP_CONNECT_TIMEOUT'),
idle_timeout : this.constructor.settings_get('LDAP_IDLE_TIMEOUT'),
encryption : this.constructor.settings_get('LDAP_ENCRYPTION'),
ca_cert : this.constructor.settings_get('LDAP_CA_CERT'),
reject_unauthorized : this.constructor.settings_get('LDAP_REJECT_UNAUTHORIZED') || false,
Authentication : this.constructor.settings_get('LDAP_AUTHENTIFICATION'),
Authentication_UserDN : this.constructor.settings_get('LDAP_AUTHENTIFICATION_USERDN'),
Authentication_Password : this.constructor.settings_get('LDAP_AUTHENTIFICATION_PASSWORD'),
Authentication_Fallback : this.constructor.settings_get('LDAP_LOGIN_FALLBACK'),
BaseDN : this.constructor.settings_get('LDAP_BASEDN'),
Internal_Log_Level : this.constructor.settings_get('INTERNAL_LOG_LEVEL'),
User_Authentication : this.constructor.settings_get('LDAP_USER_AUTHENTICATION'),
User_Authentication_Field : this.constructor.settings_get('LDAP_USER_AUTHENTICATION_FIELD'),
User_Attributes : this.constructor.settings_get('LDAP_USER_ATTRIBUTES'),
User_Search_Filter : this.constructor.settings_get('LDAP_USER_SEARCH_FILTER'),
User_Search_Scope : this.constructor.settings_get('LDAP_USER_SEARCH_SCOPE'),
User_Search_Field : this.constructor.settings_get('LDAP_USER_SEARCH_FIELD'),
Search_Page_Size : this.constructor.settings_get('LDAP_SEARCH_PAGE_SIZE'),
Search_Size_Limit : this.constructor.settings_get('LDAP_SEARCH_SIZE_LIMIT'),
group_filter_enabled : this.constructor.settings_get('LDAP_GROUP_FILTER_ENABLE'),
group_filter_object_class : this.constructor.settings_get('LDAP_GROUP_FILTER_OBJECTCLASS'),
group_filter_group_id_attribute : this.constructor.settings_get('LDAP_GROUP_FILTER_GROUP_ID_ATTRIBUTE'),
group_filter_group_member_attribute: this.constructor.settings_get('LDAP_GROUP_FILTER_GROUP_MEMBER_ATTRIBUTE'),
group_filter_group_member_format : this.constructor.settings_get('LDAP_GROUP_FILTER_GROUP_MEMBER_FORMAT'),
group_filter_group_name : this.constructor.settings_get('LDAP_GROUP_FILTER_GROUP_NAME'),
host: this.constructor.settings_get('LDAP_HOST'),
port: this.constructor.settings_get('LDAP_PORT'),
Reconnect: this.constructor.settings_get('LDAP_RECONNECT'),
timeout: this.constructor.settings_get('LDAP_TIMEOUT'),
connect_timeout: this.constructor.settings_get('LDAP_CONNECT_TIMEOUT'),
idle_timeout: this.constructor.settings_get('LDAP_IDLE_TIMEOUT'),
encryption: this.constructor.settings_get('LDAP_ENCRYPTION'),
ca_cert: this.constructor.settings_get('LDAP_CA_CERT'),
reject_unauthorized:
this.constructor.settings_get('LDAP_REJECT_UNAUTHORIZED') || false,
Authentication: this.constructor.settings_get('LDAP_AUTHENTIFICATION'),
Authentication_UserDN: this.constructor.settings_get(
'LDAP_AUTHENTIFICATION_USERDN',
),
Authentication_Password: this.constructor.settings_get(
'LDAP_AUTHENTIFICATION_PASSWORD',
),
Authentication_Fallback: this.constructor.settings_get(
'LDAP_LOGIN_FALLBACK',
),
BaseDN: this.constructor.settings_get('LDAP_BASEDN'),
Internal_Log_Level: this.constructor.settings_get('INTERNAL_LOG_LEVEL'),
User_Authentication: this.constructor.settings_get(
'LDAP_USER_AUTHENTICATION',
),
User_Authentication_Field: this.constructor.settings_get(
'LDAP_USER_AUTHENTICATION_FIELD',
),
User_Attributes: this.constructor.settings_get('LDAP_USER_ATTRIBUTES'),
User_Search_Filter: this.constructor.settings_get(
'LDAP_USER_SEARCH_FILTER',
),
User_Search_Scope: this.constructor.settings_get(
'LDAP_USER_SEARCH_SCOPE',
),
User_Search_Field: this.constructor.settings_get(
'LDAP_USER_SEARCH_FIELD',
),
Search_Page_Size: this.constructor.settings_get('LDAP_SEARCH_PAGE_SIZE'),
Search_Size_Limit: this.constructor.settings_get(
'LDAP_SEARCH_SIZE_LIMIT',
),
group_filter_enabled: this.constructor.settings_get(
'LDAP_GROUP_FILTER_ENABLE',
),
group_filter_object_class: this.constructor.settings_get(
'LDAP_GROUP_FILTER_OBJECTCLASS',
),
group_filter_group_id_attribute: this.constructor.settings_get(
'LDAP_GROUP_FILTER_GROUP_ID_ATTRIBUTE',
),
group_filter_group_member_attribute: this.constructor.settings_get(
'LDAP_GROUP_FILTER_GROUP_MEMBER_ATTRIBUTE',
),
group_filter_group_member_format: this.constructor.settings_get(
'LDAP_GROUP_FILTER_GROUP_MEMBER_FORMAT',
),
group_filter_group_name: this.constructor.settings_get(
'LDAP_GROUP_FILTER_GROUP_NAME',
),
};
}
@@ -58,14 +88,13 @@ export default class LDAP {
}
connectSync(...args) {
if (!this._connectSync) {
if (!this._connectSync) {
this._connectSync = Meteor.wrapAsync(this.connectAsync, this);
}
return this._connectSync(...args);
}
searchAllSync(...args) {
if (!this._searchAllSync) {
this._searchAllSync = Meteor.wrapAsync(this.searchAllAsync, this);
}
@@ -78,19 +107,19 @@ export default class LDAP {
let replied = false;
const connectionOptions = {
url : `${this.options.host}:${this.options.port}`,
timeout : this.options.timeout,
url: `${this.options.host}:${this.options.port}`,
timeout: this.options.timeout,
connectTimeout: this.options.connect_timeout,
idleTimeout : this.options.idle_timeout,
reconnect : this.options.Reconnect,
idleTimeout: this.options.idle_timeout,
reconnect: this.options.Reconnect,
};
if (this.options.Internal_Log_Level !== 'disabled') {
connectionOptions.log = new Bunyan({
name : 'ldapjs',
name: 'ldapjs',
component: 'client',
stream : process.stderr,
level : this.options.Internal_Log_Level,
stream: process.stderr,
level: this.options.Internal_Log_Level,
});
}
@@ -100,10 +129,12 @@ export default class LDAP {
if (this.options.ca_cert && this.options.ca_cert !== '') {
// Split CA cert into array of strings
const chainLines = this.constructor.settings_get('LDAP_CA_CERT').split('\n');
let cert = [];
const ca = [];
chainLines.forEach((line) => {
const chainLines = this.constructor
.settings_get('LDAP_CA_CERT')
.split('\n');
let cert = [];
const ca = [];
chainLines.forEach(line => {
cert.push(line);
if (line.match(/-END CERTIFICATE-/)) {
ca.push(cert.join('\n'));
@@ -114,7 +145,7 @@ export default class LDAP {
}
if (this.options.encryption === 'ssl') {
connectionOptions.url = `ldaps://${connectionOptions.url}`;
connectionOptions.url = `ldaps://${connectionOptions.url}`;
connectionOptions.tlsOptions = tlsOptions;
} else {
connectionOptions.url = `ldap://${connectionOptions.url}`;
@@ -127,7 +158,7 @@ export default class LDAP {
this.bindSync = Meteor.wrapAsync(this.client.bind, this.client);
this.client.on('error', (error) => {
this.client.on('error', error => {
log_error('connection', error);
if (replied === false) {
replied = true;
@@ -171,7 +202,7 @@ export default class LDAP {
}
});
} else {
this.client.on('connect', (response) => {
this.client.on('connect', response => {
log_info('LDAP connected');
this.connected = true;
if (replied === false) {
@@ -201,7 +232,9 @@ export default class LDAP {
}
}
const usernameFilter = this.options.User_Search_Field.split(',').map((item) => `(${item}=${username})`);
const usernameFilter = this.options.User_Search_Field.split(',').map(
item => `(${item}=${username})`,
);
if (usernameFilter.length === 0) {
log_error('LDAP_LDAP_User_Search_Field not defined');
@@ -215,7 +248,6 @@ export default class LDAP {
}
bindUserIfNecessary(username, password) {
if (this.domainBinded === true) {
return;
}
@@ -224,7 +256,6 @@ export default class LDAP {
return;
}
if (!this.options.BaseDN) throw new Error('BaseDN is not provided');
const userDn = `${this.options.User_Authentication_Field}=${username},${this.options.BaseDN}`;
@@ -244,23 +275,27 @@ export default class LDAP {
log_info('Binding UserDN', this.options.Authentication_UserDN);
this.bindSync(this.options.Authentication_UserDN, this.options.Authentication_Password);
this.bindSync(
this.options.Authentication_UserDN,
this.options.Authentication_Password,
);
this.domainBinded = true;
}
searchUsersSync(username, page) {
this.bindIfNecessary();
const searchOptions = {
filter : this.getUserFilter(username),
scope : this.options.User_Search_Scope || 'sub',
filter: this.getUserFilter(username),
scope: this.options.User_Search_Scope || 'sub',
sizeLimit: this.options.Search_Size_Limit,
};
if (!!this.options.User_Attributes) searchOptions.attributes = this.options.User_Attributes.split(',');
if (!!this.options.User_Attributes)
searchOptions.attributes = this.options.User_Attributes.split(',');
if (this.options.Search_Page_Size > 0) {
searchOptions.paged = {
pageSize : this.options.Search_Page_Size,
pageSize: this.options.Search_Page_Size,
pagePause: !!page,
};
}
@@ -279,7 +314,9 @@ export default class LDAP {
getUserByIdSync(id, attribute) {
this.bindIfNecessary();
const Unique_Identifier_Field = this.constructor.settings_get('LDAP_UNIQUE_IDENTIFIER_FIELD').split(',');
const Unique_Identifier_Field = this.constructor
.settings_get('LDAP_UNIQUE_IDENTIFIER_FIELD')
.split(',');
let filter;
@@ -290,11 +327,13 @@ export default class LDAP {
});
} else {
const filters = [];
Unique_Identifier_Field.forEach((item) => {
filters.push(new this.ldapjs.filters.EqualityFilter({
attribute: item,
value : new Buffer(id, 'hex'),
}));
Unique_Identifier_Field.forEach(item => {
filters.push(
new this.ldapjs.filters.EqualityFilter({
attribute: item,
value: new Buffer(id, 'hex'),
}),
);
});
filter = new this.ldapjs.filters.OrFilter({ filters });
@@ -327,7 +366,7 @@ export default class LDAP {
const searchOptions = {
filter: this.getUserFilter(username),
scope : this.options.User_Search_Scope || 'sub',
scope: this.options.User_Search_Scope || 'sub',
};
log_info('Searching user', username);
@@ -341,7 +380,13 @@ export default class LDAP {
}
if (result.length > 1) {
log_error('Search by username', username, 'returned', result.length, 'records');
log_error(
'Search by username',
username,
'returned',
result.length,
'records',
);
}
return result[0];
@@ -359,9 +404,12 @@ export default class LDAP {
}
if (this.options.group_filter_group_member_attribute !== '') {
const format_value = ldapUser[this.options.group_filter_group_member_format];
const format_value =
ldapUser[this.options.group_filter_group_member_format];
if (format_value) {
filter.push(`(${this.options.group_filter_group_member_attribute}=${format_value})`);
filter.push(
`(${this.options.group_filter_group_member_attribute}=${format_value})`,
);
}
}
@@ -369,7 +417,7 @@ export default class LDAP {
const searchOptions = {
filter: filter.join('').replace(/#{username}/g, username),
scope : 'sub',
scope: 'sub',
};
log_debug('Group list filter LDAP:', searchOptions.filter);
@@ -381,13 +429,12 @@ export default class LDAP {
}
const grp_identifier = this.options.group_filter_group_id_attribute || 'cn';
const groups = [];
result.map((item) => {
const groups = [];
result.map(item => {
groups.push(item[grp_identifier]);
});
log_debug(`Groups: ${groups.join(', ')}`);
return groups;
}
isUserInGroup(username, ldapUser) {
@@ -404,20 +451,25 @@ export default class LDAP {
}
if (this.options.group_filter_group_member_attribute !== '') {
const format_value = ldapUser[this.options.group_filter_group_member_format];
const format_value =
ldapUser[this.options.group_filter_group_member_format];
if (format_value) {
filter.push(`(${this.options.group_filter_group_member_attribute}=${format_value})`);
filter.push(
`(${this.options.group_filter_group_member_attribute}=${format_value})`,
);
}
}
if (this.options.group_filter_group_id_attribute !== '') {
filter.push(`(${this.options.group_filter_group_id_attribute}=${this.options.group_filter_group_name})`);
filter.push(
`(${this.options.group_filter_group_id_attribute}=${this.options.group_filter_group_name})`,
);
}
filter.push(')');
const searchOptions = {
filter: filter.join('').replace(/#{username}/g, username),
scope : 'sub',
scope: 'sub',
};
log_debug('Group filter LDAP:', searchOptions.filter);
@@ -435,7 +487,7 @@ export default class LDAP {
_raw: entry.raw,
};
Object.keys(values._raw).forEach((key) => {
Object.keys(values._raw).forEach(key => {
const value = values._raw[key];
if (!['thumbnailPhoto', 'jpegPhoto'].includes(key)) {
@@ -458,11 +510,12 @@ export default class LDAP {
// Force LDAP idle to wait the record processing
this.client._updateIdle(true);
page(null, entries, {
end, next: () => {
end,
next: () => {
// Reset idle timer
this.client._updateIdle();
next && next();
}
},
});
};
@@ -473,7 +526,7 @@ export default class LDAP {
return;
}
res.on('error', (error) => {
res.on('error', error => {
log_error(error);
page(error);
return;
@@ -481,16 +534,19 @@ export default class LDAP {
let entries = [];
const internalPageSize = options.paged && options.paged.pageSize > 0 ? options.paged.pageSize * 2 : 500;
const internalPageSize =
options.paged && options.paged.pageSize > 0
? options.paged.pageSize * 2
: 500;
res.on('searchEntry', (entry) => {
res.on('searchEntry', entry => {
entries.push(this.extractLdapEntryData(entry));
if (entries.length >= internalPageSize) {
processPage({
entries,
title: 'Internal Page',
end : false,
end: false,
});
entries = [];
}
@@ -502,14 +558,14 @@ export default class LDAP {
processPage({
entries,
title: 'Final Page',
end : true,
end: true,
});
} else if (entries.length) {
log_info('Page');
processPage({
entries,
title: 'Page',
end : false,
end: false,
next,
});
entries = [];
@@ -521,7 +577,7 @@ export default class LDAP {
processPage({
entries,
title: 'Final Page',
end : true,
end: true,
});
entries = [];
}
@@ -539,7 +595,7 @@ export default class LDAP {
return;
}
res.on('error', (error) => {
res.on('error', error => {
log_error(error);
callback(error);
return;
@@ -547,7 +603,7 @@ export default class LDAP {
const entries = [];
res.on('searchEntry', (entry) => {
res.on('searchEntry', entry => {
entries.push(this.extractLdapEntryData(entry));
});
@@ -576,7 +632,7 @@ export default class LDAP {
}
disconnect() {
this.connected = false;
this.connected = false;
this.domainBinded = false;
log_info('Disconecting');
this.client.unbind();
@@ -1,13 +1,12 @@
Oidc = {};
OAuth.registerService('oidc', 2, null, function (query) {
OAuth.registerService('oidc', 2, null, function(query) {
var debug = process.env.DEBUG || false;
var token = getToken(query);
if (debug) console.log('XXX: register token:', token);
var accessToken = token.access_token || token.id_token;
var expiresAt = (+new Date) + (1000 * parseInt(token.expires_in, 10));
var expiresAt = +new Date() + 1000 * parseInt(token.expires_in, 10);
var userinfo = getUserInfo(accessToken);
if (debug) console.log('XXX: userinfo:', userinfo);
@@ -22,12 +21,14 @@ OAuth.registerService('oidc', 2, null, function (query) {
if (accessToken) {
var tokenContent = getTokenContent(accessToken);
var fields = _.pick(tokenContent, getConfiguration().idTokenWhitelistFields);
var fields = _.pick(
tokenContent,
getConfiguration().idTokenWhitelistFields,
);
_.extend(serviceData, fields);
}
if (token.refresh_token)
serviceData.refreshToken = token.refresh_token;
if (token.refresh_token) serviceData.refreshToken = token.refresh_token;
if (debug) console.log('XXX: serviceData:', serviceData);
var profile = {};
@@ -37,88 +38,101 @@ OAuth.registerService('oidc', 2, null, function (query) {
return {
serviceData: serviceData,
options: { profile: profile }
options: { profile: profile },
};
});
var userAgent = "Meteor";
var userAgent = 'Meteor';
if (Meteor.release) {
userAgent += "/" + Meteor.release;
userAgent += '/' + Meteor.release;
}
var getToken = function (query) {
var getToken = function(query) {
var debug = process.env.DEBUG || false;
var config = getConfiguration();
if(config.tokenEndpoint.includes('https://')){
if (config.tokenEndpoint.includes('https://')) {
var serverTokenEndpoint = config.tokenEndpoint;
}else{
} else {
var serverTokenEndpoint = config.serverUrl + config.tokenEndpoint;
}
var requestPermissions = config.requestPermissions;
var response;
try {
response = HTTP.post(
serverTokenEndpoint,
{
headers: {
Accept: 'application/json',
"User-Agent": userAgent
},
params: {
code: query.code,
client_id: config.clientId,
client_secret: OAuth.openSecret(config.secret),
redirect_uri: OAuth._redirectUri('oidc', config),
grant_type: 'authorization_code',
scope: requestPermissions,
state: query.state
}
}
);
response = HTTP.post(serverTokenEndpoint, {
headers: {
Accept: 'application/json',
'User-Agent': userAgent,
},
params: {
code: query.code,
client_id: config.clientId,
client_secret: OAuth.openSecret(config.secret),
redirect_uri: OAuth._redirectUri('oidc', config),
grant_type: 'authorization_code',
scope: requestPermissions,
state: query.state,
},
});
} catch (err) {
throw _.extend(new Error("Failed to get token from OIDC " + serverTokenEndpoint + ": " + err.message),
{ response: err.response });
throw _.extend(
new Error(
'Failed to get token from OIDC ' +
serverTokenEndpoint +
': ' +
err.message,
),
{ response: err.response },
);
}
if (response.data.error) {
// if the http response was a json object with an error attribute
throw new Error("Failed to complete handshake with OIDC " + serverTokenEndpoint + ": " + response.data.error);
throw new Error(
'Failed to complete handshake with OIDC ' +
serverTokenEndpoint +
': ' +
response.data.error,
);
} else {
if (debug) console.log('XXX: getToken response: ', response.data);
return response.data;
}
};
var getUserInfo = function (accessToken) {
var getUserInfo = function(accessToken) {
var debug = process.env.DEBUG || false;
var config = getConfiguration();
// Some userinfo endpoints use a different base URL than the authorization or token endpoints.
// This logic allows the end user to override the setting by providing the full URL to userinfo in their config.
if (config.userinfoEndpoint.includes("https://")) {
if (config.userinfoEndpoint.includes('https://')) {
var serverUserinfoEndpoint = config.userinfoEndpoint;
} else {
var serverUserinfoEndpoint = config.serverUrl + config.userinfoEndpoint;
}
var response;
try {
response = HTTP.get(
serverUserinfoEndpoint,
{
headers: {
"User-Agent": userAgent,
"Authorization": "Bearer " + accessToken
}
}
);
response = HTTP.get(serverUserinfoEndpoint, {
headers: {
'User-Agent': userAgent,
Authorization: 'Bearer ' + accessToken,
},
});
} catch (err) {
throw _.extend(new Error("Failed to fetch userinfo from OIDC " + serverUserinfoEndpoint + ": " + err.message),
{response: err.response});
throw _.extend(
new Error(
'Failed to fetch userinfo from OIDC ' +
serverUserinfoEndpoint +
': ' +
err.message,
),
{ response: err.response },
);
}
if (debug) console.log('XXX: getUserInfo response: ', response.data);
return response.data;
};
var getConfiguration = function () {
var getConfiguration = function() {
var config = ServiceConfiguration.configurations.findOne({ service: 'oidc' });
if (!config) {
throw new ServiceConfiguration.ConfigError('Service oidc not configured.');
@@ -126,7 +140,7 @@ var getConfiguration = function () {
return config;
};
var getTokenContent = function (token) {
var getTokenContent = function(token) {
var content = null;
if (token) {
try {
@@ -137,13 +151,13 @@ var getTokenContent = function (token) {
var signed = parts[0] + '.' + parts[1];
} catch (err) {
this.content = {
exp: 0
exp: 0,
};
}
}
return content;
}
};
Oidc.retrieveCredential = function (credentialToken, credentialSecret) {
Oidc.retrieveCredential = function(credentialToken, credentialSecret) {
return OAuth.retrieveCredential(credentialToken, credentialSecret);
};
@@ -1,6 +1,6 @@
{
"name": "wekan",
"version": "v3.84.0",
"version": "v3.86.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -1,6 +1,6 @@
{
"name": "wekan",
"version": "v3.84.0",
"version": "v3.86.0",
"description": "Open-Source kanban",
"private": true,
"scripts": {
File renamed without changes.
File renamed without changes.
+33 -1
View File
@@ -1,3 +1,35 @@
# v3.86 2020-03-24 Wekan release
This release fixes the following bugs:
- [Fix Rich editor can not be disabled, regression from changes yesterday at Wekan v3.85](https://github.com/wekan/wekan/commit/12ab8fac5db9c5ac8069d0ca2bca340d6004a25b).
Thanks to uusijani, vjrj and xet7.
- [1) Fix Pasting text into a card is adding a line before and after
(and multiplies by pasting more) by changing paste "p" to "br".
2) Fixes to summernote and markdown comment editors, related
to keeping them open when adding comments, having
@member mention not close card, and disabling clicking of
@member mention](https://github.com/wekan/wekan/commit/b9099a8b7ea6f63c79bdcbb871cb993b2cb7e325).
Thanks to xet7 !
Thanks to above GitHub users for their contributions and translators for their translations.
# v3.85 2020-03-23 Wekan release
This release fixes the following CRITICAL SECURITY VULNERABILITIES:
- [Fix XSS bug reported today 4 hours ago by Cyb3rjunky](https://github.com/wekan/wekan/commit/482682e50079d70c5113169020d6834013b57c11).
Logged in users could run javascript in input fields.
This affects Wekan versions v3.12-v3.84.
In [Wekan v3.12](https://github.com/wekan/wekan/blob/master/CHANGELOG.md#v312-2019-08-09-wekan-release)
there was [changes for XSS filter to allow inserting images, videos etc
on comment WYSIWYG editor](https://github.com/wekan/wekan/pull/2593)
so features related to that are now removed.
After this fix, Javascript in input fields is not executed.
Thanks to Cyb3rjunky and xet7.
Thanks to above GitHub users for their contributions and translators for their translations.
# v3.84 2020-03-16 Wekan release
This release adds the following features:
@@ -5,7 +37,7 @@ This release adds the following features:
- Add settings for mouse wheel scroll inertia and scroll
amount [Part1](https://github.com/wekan/wekan/commit/9d13001b903f9ec50f5fa3a4bdbacae32b27ac65)
and [Part2](https://github.com/wekan/wekan/commit/aaecac091209e90c0c2123830728f5e7a835ccb4).
For example: sudo snap set scrollinertia='200' , sudo snap set scrollamount='200' .
For example: sudo snap set wekan scrollinertia='200' , sudo snap set wekan scrollamount='200' .
Thanks to danger89 and xet7.
and adds the following updates:
+1 -1
View File
@@ -1,5 +1,5 @@
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
appVersion: "v3.84.0"
appVersion: "v3.86.0"
files:
userUploads:
- README.md
+7
View File
@@ -33,6 +33,13 @@ BlazeComponent.extendComponent({
cardId,
});
resetCommentInput(input);
// With Richer editor is in use, and comment is submitted,
// clear comment form with JQuery. Id #summernote is defined
// at client/components/main/editor.jade where it previously was
// id=id, now it is id="summernote".
if (Meteor.settings.public.RICHER_CARD_COMMENT_EDITOR === 'true') {
$('#summernote').summernote('code', '');
}
Tracker.flush();
autosize.update(input);
input.trigger('submitted');
+6 -1
View File
@@ -1,8 +1,13 @@
template(name="editor")
// With Richer editor is in use, and comment is submitted,
// clear comment form with JQuery Comment at
// client/components/activities/comments.js . Id #summernote is defined
// here at client/components/main/editor.jade where it previously was
// id=id, now it is id="summernote".
textarea.editor(
dir="auto"
class="{{class}}"
id=id
id="summernote"
autofocus=autofocus
placeholder="{{_ 'comment-placeholder'}}")
+Template.contentBlock
+99 -139
View File
@@ -1,91 +1,3 @@
import _sanitizeXss from 'xss';
const ASIS = 'asis';
const sanitizeXss = (input, options) => {
const defaultAllowedIframeSrc = /^(https:){0,1}\/\/.*?(youtube|vimeo|dailymotion|youku)/i;
const allowedIframeSrcRegex = (function() {
let reg = defaultAllowedIframeSrc;
const SAFE_IFRAME_SRC_PATTERN =
Meteor.settings.public.SAFE_IFRAME_SRC_PATTERN;
try {
if (SAFE_IFRAME_SRC_PATTERN !== undefined) {
reg = new RegExp(SAFE_IFRAME_SRC_PATTERN, 'i');
}
} catch (e) {
/*eslint no-console: ["error", { allow: ["warn", "error"] }] */
console.error('Wrong pattern specified', SAFE_IFRAM_SRC_PATTERN, e);
}
return reg;
})();
const targetWindow = '_blank';
const getHtmlDOM = html => {
const i = document.createElement('i');
i.innerHTML = html;
return i.firstChild;
};
options = {
onTag(tag, html, options) {
const htmlDOM = getHtmlDOM(html);
const getAttr = attr => {
return htmlDOM && attr && htmlDOM.getAttribute(attr);
};
if (tag === 'iframe') {
const clipCls = 'note-vide-clip';
if (!options.isClosing) {
const iframeCls = getAttr('class');
let safe = iframeCls.indexOf(clipCls) > -1;
const src = getAttr('src');
if (allowedIframeSrcRegex.exec(src)) {
safe = true;
}
if (safe)
return `<iframe src='${src}' class="${clipCls}" width=100% height=auto allowfullscreen></iframe>`;
} else {
// remove </iframe> tag
return '';
}
} else if (tag === 'a') {
if (!options.isClosing) {
if (getAttr(ASIS) === 'true') {
// if has a ASIS attribute, don't do anything, it's a member id
return html;
} else {
const href = getAttr('href');
if (href.match(/^((http(s){0,1}:){0,1}\/\/|\/)/)) {
// a valid url
return `<a href=${href} target=${targetWindow}>`;
}
}
}
/* Don't use swipebox on markdown, so that img tag can now use width
* and height parameters. https://github.com/wekan/wekan/issues/2956
* Previously this was added at https://github.com/wekan/wekan/pull/2593
} else if (tag === 'img') {
if (!options.isClosing) {
const src = getAttr('src');
if (src) {
return `<a href='${src}' class='swipebox'><img src='${src}' class="attachment-image-preview mCS_img_loaded"></a>`;
}
}
*/
}
return undefined;
},
onTagAttr(tag, name, value) {
if (tag === 'img' && name === 'src') {
if (value && value.substr(0, 5) === 'data:') {
// allow image with dataURI src
return `${name}='${value}'`;
}
} else if (tag === 'a' && name === 'target') {
return `${name}='${targetWindow}'`; // always change a href target to a new window
}
return undefined;
},
...options,
};
return _sanitizeXss(input, options);
};
Template.editor.onRendered(() => {
const textareaSelector = 'textarea';
const mentions = [
@@ -98,13 +10,7 @@ Template.editor.onRendered(() => {
currentBoard
.activeMembers()
.map(member => {
const user = Users.findOne(member.userId);
if (user._id === Meteor.userId()) {
return null;
}
const value = user.username;
const username =
value && value.match(/\s+/) ? `"${value}"` : value;
const username = Users.findOne(member.userId).username;
return username.includes(term) ? username : null;
})
.filter(Boolean),
@@ -124,16 +30,15 @@ Template.editor.onRendered(() => {
autosize($textarea);
$textarea.escapeableTextComplete(mentions);
};
if (Meteor.settings.public.RICHER_CARD_COMMENT_EDITOR !== false) {
if (Meteor.settings.public.RICHER_CARD_COMMENT_EDITOR === 'true') {
const isSmall = Utils.isMiniScreen();
const toolbar = isSmall
? [
['view', ['fullscreen']],
['table', ['table']],
['font', ['bold']],
['color', ['color']],
['insert', ['video']], // iframe tag will be sanitized TODO if iframe[class=note-video-clip] can be added into safe list, insert video can be enabled
['font', ['bold', 'underline']],
//['fontsize', ['fontsize']],
['color', ['color']],
]
: [
['style', ['style']],
@@ -143,11 +48,47 @@ Template.editor.onRendered(() => {
['color', ['color']],
['para', ['ul', 'ol', 'paragraph']],
['table', ['table']],
['insert', ['link', 'picture', 'video']], // iframe tag will be sanitized TODO if iframe[class=note-video-clip] can be added into safe list, insert video can be enabled
//['insert', ['link', 'picture', 'video']], // iframe tag will be sanitized TODO if iframe[class=note-video-clip] can be added into safe list, insert video can be enabled
//['insert', ['link', 'picture']], // modal popup has issue somehow :(
['view', ['fullscreen', 'help']],
];
const cleanPastedHTML = sanitizeXss;
const cleanPastedHTML = function(input) {
const badTags = [
'style',
'script',
'applet',
'embed',
'noframes',
'noscript',
'meta',
'link',
'button',
'form',
].join('|');
const badPatterns = new RegExp(
`(?:${[
`<(${badTags})s*[^>][\\s\\S]*?<\\/\\1>`,
`<(${badTags})[^>]*?\\/>`,
].join('|')})`,
'gi',
);
let output = input;
// remove bad Tags
output = output.replace(badPatterns, '');
// remove attributes ' style="..."'
const badAttributes = new RegExp(
`(?:${[
'on\\S+=([\'"]?).*?\\1',
'href=([\'"]?)javascript:.*?\\2',
'style=([\'"]?).*?\\3',
'target=\\S+',
].join('|')})`,
'gi',
);
output = output.replace(badAttributes, '');
output = output.replace(/(<a )/gi, '$1target=_ '); // always to new target
return output;
};
const editor = '.editor';
const selectors = [
`.js-new-comment-form ${editor}`,
@@ -167,11 +108,27 @@ Template.editor.onRendered(() => {
}
return undefined;
};
// Prevent @member mentions on Add Comment input field
// from closing card, part 1.
let popupShown = false;
inputs.each(function(idx, input) {
mSummernotes[idx] = $(input).summernote({
placeholder,
// Prevent @member mentions on Add Comment input field
// from closing card, part 2.
onKeydown(e) {
if (popupShown) {
e.preventDefault();
}
},
onKeyup(e) {
if (popupShown) {
e.preventDefault();
}
},
callbacks: {
// Prevent @member mentions on Add Comment input field
// from closing card, part 3.
onKeydown(e) {
if (popupShown) {
e.preventDefault();
@@ -184,28 +141,19 @@ Template.editor.onRendered(() => {
},
onInit(object) {
const originalInput = this;
const setAutocomplete = function(jEditor) {
if (jEditor !== undefined) {
jEditor.escapeableTextComplete(mentions).on({
'textComplete:show'() {
popupShown = true;
},
'textComplete:hide'() {
popupShown = false;
},
});
}
};
$(originalInput).on('submitted', function() {
// resetCommentInput has been called
$(originalInput).on('input', function() {
// when comment is submitted, the original textarea will be set to '', so shall we
if (!this.value) {
const sn = getSummernote(this);
sn && sn.summernote('code', '');
sn && sn.summernote('reset');
object && object.editingArea.find('.note-placeholder').show();
}
});
const jEditor = object && object.editable;
const toolbar = object && object.toolbar;
setAutocomplete(jEditor);
if (jEditor !== undefined) {
jEditor.escapeableTextComplete(mentions);
}
if (toolbar !== undefined) {
const fBtn = toolbar.find('.btn-fullscreen');
fBtn.on('click', function() {
@@ -293,9 +241,15 @@ Template.editor.onRendered(() => {
const thisNote = this;
const updatePastedText = function(object) {
const someNote = getSummernote(object);
// Fix Pasting text into a card is adding a line before and after
// (and multiplies by pasting more) by changing paste "p" to "br".
// Fixes https://github.com/wekan/wekan/2890 .
// == Fix Start ==
someNote.execCommand('defaultParagraphSeparator', false, 'br');
// == Fix End ==
const original = someNote.summernote('code');
const cleaned = cleanPastedHTML(original); //this is where to call whatever clean function you want. I have mine in a different file, called CleanPastedHTML.
someNote.summernote('code', ''); //clear original
someNote.summernote('reset'); //clear original
someNote.summernote('pasteHTML', cleaned); //this sets the displayed content editor to the cleaned pasted code.
};
setTimeout(function() {
@@ -335,6 +289,8 @@ Template.editor.onRendered(() => {
}
});
import sanitizeXss from 'xss';
// XXX I believe we should compute a HTML rendered field on the server that
// would handle markdown and user mentions. We can simply have two
// fields, one source, and one compiled version (in HTML) and send only the
@@ -356,23 +312,28 @@ Blaze.Template.registerHelper(
}
return member;
});
const mentionRegex = /\B@(?:(?:"([\w.\s]*)")|([\w.]+))/gi; // including space in username
const mentionRegex = /\B@([\w.]*)/gi;
let currentMention;
while ((currentMention = mentionRegex.exec(content)) !== null) {
const [fullMention, quoteduser, simple] = currentMention;
const username = quoteduser || simple;
const [fullMention, username] = currentMention;
const knowedUser = _.findWhere(knowedUsers, { username });
if (!knowedUser) {
continue;
}
const linkValue = [' ', at, knowedUser.username];
let linkClass = 'atMention js-open-member';
//let linkClass = 'atMention js-open-member';
let linkClass = 'atMention';
if (knowedUser.userId === Meteor.userId()) {
linkClass += ' me';
}
const link = HTML.A(
// This @user mention link generation did open same Wekan
// window in new tab, so now A is changed to U so it's
// underlined and there is no link popup. This way also
// text can be selected more easily.
//const link = HTML.A(
const link = HTML.U(
{
class: linkClass,
// XXX Hack. Since we stringify this render function result below with
@@ -380,42 +341,41 @@ Blaze.Template.registerHelper(
// `userId` to the popup as usual, and we need to store it in the DOM
// using a data attribute.
'data-userId': knowedUser.userId,
[ASIS]: 'true',
},
linkValue,
);
content = content.replace(fullMention, Blaze.toHTML(link));
}
return HTML.Raw(sanitizeXss(content));
}),
);
Template.viewer.events({
// Viewer sometimes have click-able wrapper around them (for instance to edit
// the corresponding text). Clicking a link shouldn't fire these actions, stop
// we stop these event at the viewer component level.
'click a'(event, templateInstance) {
let prevent = true;
event.stopPropagation();
// XXX We hijack the build-in browser action because we currently don't have
// `_blank` attributes in viewer links, and the transformer function is
// handled by a third party package that we can't configure easily. Fix that
// by using directly `_blank` attribute in the rendered HTML.
event.preventDefault();
const userId = event.currentTarget.dataset.userid;
if (userId) {
Popup.open('member').call({ userId }, event, templateInstance);
// Prevent @member mentions on Add Comment input field
// from closing card, part 4.
PopupNoClose.open('member').call({ userId }, event, templateInstance);
event.preventDefault();
} else {
const href = event.currentTarget.href;
const child = event.currentTarget.firstElementChild;
if (child && child.tagName === 'IMG') {
prevent = false;
} else if (href) {
if (href) {
window.open(href, '_blank');
}
}
if (prevent) {
event.stopPropagation();
// XXX We hijack the build-in browser action because we currently don't have
// `_blank` attributes in viewer links, and the transformer function is
// handled by a third party package that we can't configure easily. Fix that
// by using directly `_blank` attribute in the rendered HTML.
event.preventDefault();
}
},
});
+204
View File
@@ -206,3 +206,207 @@ escapeActions.forEach(actionName => {
},
);
});
// Prevent @member mentions on Add Comment input field
// from closing card, part 5.
// This duplicate below of above popup function is needed, because at
// wekan/components/main/editor.js at bottom is popping up visible
// @member mention, and it seems to trigger closing also card popup,
// so in below closing popup is disabled.
window.PopupNoClose = new (class {
constructor() {
// The template we use to render popups
this.template = Template.popup;
// We only want to display one popup at a time and we keep the view object
// in this `Popup.current` variable. If there is no popup currently opened
// the value is `null`.
this.current = null;
// It's possible to open a sub-popup B from a popup A. In that case we keep
// the data of popup A so we can return back to it. Every time we open a new
// popup the stack grows, every time we go back the stack decrease, and if
// we close the popup the stack is reseted to the empty stack [].
this._stack = [];
// We invalidate this internal dependency every time the top of the stack
// has changed and we want to re-render a popup with the new top-stack data.
this._dep = new Tracker.Dependency();
}
/// This function returns a callback that can be used in an event map:
/// Template.tplName.events({
/// 'click .elementClass': Popup.open("popupName"),
/// });
/// The popup inherit the data context of its parent.
open(name) {
const self = this;
const popupName = `${name}Popup`;
function clickFromPopup(evt) {
return $(evt.target).closest('.js-pop-over').length !== 0;
}
return function(evt) {
// If a popup is already opened, clicking again on the opener element
// should close it -- and interrupt the current `open` function.
/*
if (self.isOpen()) {
const previousOpenerElement = self._getTopStack().openerElement;
if (previousOpenerElement === evt.currentTarget) {
self.close();
return;
} else {
$(previousOpenerElement).removeClass('is-active');
}
}
*/
// We determine the `openerElement` (the DOM element that is being clicked
// and the one we take in reference to position the popup) from the event
// if the popup has no parent, or from the parent `openerElement` if it
// has one. This allows us to position a sub-popup exactly at the same
// position than its parent.
let openerElement;
if (clickFromPopup(evt)) {
openerElement = self._getTopStack().openerElement;
} else {
self._stack = [];
openerElement = evt.currentTarget;
}
$(openerElement).addClass('is-active');
evt.preventDefault();
// We push our popup data to the stack. The top of the stack is always
// used as the data source for our current popup.
self._stack.push({
popupName,
openerElement,
hasPopupParent: clickFromPopup(evt),
title: self._getTitle(popupName),
depth: self._stack.length,
offset: self._getOffset(openerElement),
dataContext: (this && this.currentData && this.currentData()) || this,
});
// If there are no popup currently opened we use the Blaze API to render
// one into the DOM. We use a reactive function as the data parameter that
// return the complete along with its top element and depends on our
// internal dependency that is being invalidated every time the top
// element of the stack has changed and we want to update the popup.
//
// Otherwise if there is already a popup open we just need to invalidate
// our internal dependency, and since we just changed the top element of
// our internal stack, the popup will be updated with the new data.
if (!self.isOpen()) {
self.current = Blaze.renderWithData(
self.template,
() => {
self._dep.depend();
return { ...self._getTopStack(), stack: self._stack };
},
document.body,
);
} else {
self._dep.changed();
}
};
}
/// This function returns a callback that can be used in an event map:
/// Template.tplName.events({
/// 'click .elementClass': Popup.afterConfirm("popupName", function() {
/// // What to do after the user has confirmed the action
/// }),
/// });
afterConfirm(name, action) {
const self = this;
return function(evt, tpl) {
const context = (this.currentData && this.currentData()) || this;
context.__afterConfirmAction = action;
self.open(name).call(context, evt, tpl);
};
}
/// The public reactive state of the popup.
isOpen() {
this._dep.changed();
return Boolean(this.current);
}
/// In case the popup was opened from a parent popup we can get back to it
/// with this `Popup.back()` function. You can go back several steps at once
/// by providing a number to this function, e.g. `Popup.back(2)`. In this case
/// intermediate popup won't even be rendered on the DOM. If the number of
/// steps back is greater than the popup stack size, the popup will be closed.
back(n = 1) {
if (this._stack.length > n) {
_.times(n, () => this._stack.pop());
this._dep.changed();
}
// else {
// this.close();
//}
}
/// Close the current opened popup.
/*
close() {
if (this.isOpen()) {
Blaze.remove(this.current);
this.current = null;
const openerElement = this._getTopStack().openerElement;
$(openerElement).removeClass('is-active');
this._stack = [];
}
}
*/
getOpenerComponent() {
const { openerElement } = Template.parentData(4);
return BlazeComponent.getComponentForElement(openerElement);
}
// An utility fonction that returns the top element of the internal stack
_getTopStack() {
return this._stack[this._stack.length - 1];
}
// We automatically calculate the popup offset from the reference element
// position and dimensions. We also reactively use the window dimensions to
// ensure that the popup is always visible on the screen.
_getOffset(element) {
const $element = $(element);
return () => {
Utils.windowResizeDep.depend();
if (Utils.isMiniScreen()) return { left: 0, top: 0 };
const offset = $element.offset();
const popupWidth = 300 + 15;
return {
left: Math.min(offset.left, $(window).width() - popupWidth),
top: offset.top + $element.outerHeight(),
};
};
}
// We get the title from the translation files. Instead of returning the
// result, we return a function that compute the result and since `TAPi18n.__`
// is a reactive data source, the title will be changed reactively.
_getTitle(popupName) {
return () => {
const translationKey = `${popupName}-title`;
// XXX There is no public API to check if there is an available
// translation for a given key. So we try to translate the key and if the
// translation output equals the key input we deduce that no translation
// was available and returns `false`. There is a (small) risk a false
// positives.
const title = TAPi18n.__(translationKey);
// when popup showed as full of small screen, we need a default header to clearly see [X] button
const defaultTitle = Utils.isMiniScreen() ? '' : false;
return title !== translationKey ? title : defaultTitle;
};
}
})();
+45 -45
View File
@@ -1,5 +1,5 @@
{
"accept": "Accept",
"accept": "Aceptă",
"act-activity-notify": "Activity Notification",
"act-addAttachment": "added attachment __attachment__ to card __card__ at list __list__ at swimlane __swimlane__ at board __board__",
"act-deleteAttachment": "deleted attachment __attachment__ at card __card__ at list __list__ at swimlane __swimlane__ at board __board__",
@@ -42,23 +42,23 @@
"act-unjoinMember": "removed member __member__ from card __card__ at list __list__ at swimlane __swimlane__ at board __board__",
"act-withBoardTitle": "__board__",
"act-withCardTitle": "[__board__] __card__",
"actions": "Actions",
"activities": "Activities",
"activity": "Activity",
"activity-added": "added %s to %s",
"actions": "Acțiuni",
"activities": "Activități",
"activity": "Activitate",
"activity-added": "s-a adăugat %s la %s",
"activity-archived": "%s moved to Archive",
"activity-attached": "attached %s to %s",
"activity-created": "created %s",
"activity-attached": "s-a atașat %s la %s",
"activity-created": "s-a creat %s",
"activity-customfield-created": "created custom field %s",
"activity-excluded": "excluded %s from %s",
"activity-imported": "imported %s into %s from %s",
"activity-imported-board": "imported %s from %s",
"activity-joined": "joined %s",
"activity-moved": "moved %s from %s to %s",
"activity-excluded": "s-a exclus %s din %s",
"activity-imported": "s-a importat %s în %s din %s",
"activity-imported-board": "s-a importat %s din %s",
"activity-joined": "ai devenit membru %s",
"activity-moved": "s-a mutat %s din %s în%s",
"activity-on": "on %s",
"activity-removed": "removed %s from %s",
"activity-sent": "sent %s to %s",
"activity-unjoined": "unjoined %s",
"activity-removed": "s-a șters %s din %s",
"activity-sent": "s-a trimis %s către%s",
"activity-unjoined": "nu mai ești membru al %s",
"activity-subtask-added": "added subtask to %s",
"activity-checked-item": "checked %s in checklist %s of %s",
"activity-unchecked-item": "unchecked %s in checklist %s of %s",
@@ -68,7 +68,7 @@
"activity-checklist-uncompleted": "uncompleted the checklist %s of %s",
"activity-checklist-item-added": "added checklist item to '%s' in %s",
"activity-checklist-item-removed": "removed a checklist item from '%s' in %s",
"add": "Add",
"add": "Adaugă",
"activity-checked-item-card": "checked %s in checklist %s",
"activity-unchecked-item-card": "unchecked %s in checklist %s",
"activity-checklist-completed-card": "completed checklist __checklist__ at card __card__ at list __list__ at swimlane __swimlane__ at board __board__",
@@ -82,20 +82,20 @@
"add-subtask": "Add Subtask",
"add-checklist": "Add Checklist",
"add-checklist-item": "Add an item to checklist",
"add-cover": "Add Cover",
"add-cover": "Adaugă Decor",
"add-label": "Add Label",
"add-list": "Add List",
"add-members": "Add Members",
"added": "Added",
"addMemberPopup-title": "Members",
"add-members": "Adaugă Membrii",
"added": "S-a adăugat",
"addMemberPopup-title": "Membrii",
"admin": "Admin",
"admin-desc": "Can view and edit cards, remove members, and change settings for the board.",
"admin-desc": "Poate vedea și edita carduri, șterge membrii, și schimba setările tablei.",
"admin-announcement": "Announcement",
"admin-announcement-active": "Active System-Wide Announcement",
"admin-announcement-title": "Announcement from Administrator",
"all-boards": "All boards",
"and-n-other-card": "And __count__ other card",
"and-n-other-card_plural": "And __count__ other cards",
"all-boards": "Toate tablele",
"and-n-other-card": "Și __count__ alt card",
"and-n-other-card_plural": "Și __count__ alte carduri",
"apply": "Apply",
"app-is-offline": "Loading, please wait. Refreshing the page will cause data loss. If loading does not work, please check that server has not stopped.",
"archive": "Move to Archive",
@@ -106,54 +106,54 @@
"archive-swimlane": "Move Swimlane to Archive",
"archive-selection": "Move selection to Archive",
"archiveBoardPopup-title": "Move Board to Archive?",
"archived-items": "Archive",
"archived-items": "Arhivă",
"archived-boards": "Boards in Archive",
"restore-board": "Restore Board",
"no-archived-boards": "No Boards in Archive.",
"archives": "Archive",
"template": "Template",
"templates": "Templates",
"assign-member": "Assign member",
"attached": "attached",
"assign-member": "Atribuie unui membru",
"attached": "s-a atașat",
"attachment": "Ataşament",
"attachment-delete-pop": "Deleting an attachment is permanent. There is no undo.",
"attachmentDeletePopup-title": "Delete Attachment?",
"attachment-delete-pop": "Ștergerea unui atașament este permanentă. Nu există recuperare.",
"attachmentDeletePopup-title": "Șterge Atașament?",
"attachments": "Ataşamente",
"auto-watch": "Automatically watch boards when they are created",
"avatar-too-big": "The avatar is too large (70KB max)",
"back": "Înapoi",
"board-change-color": "Change color",
"board-nb-stars": "%s stars",
"board-not-found": "Board not found",
"board-private-info": "This board will be <strong>private</strong>.",
"board-public-info": "This board will be <strong>public</strong>.",
"boardChangeColorPopup-title": "Change Board Background",
"boardChangeTitlePopup-title": "Rename Board",
"boardChangeVisibilityPopup-title": "Change Visibility",
"board-change-color": "Schimbă culoare",
"board-nb-stars": "%s stele",
"board-not-found": "Tabla nu a fost găsită",
"board-private-info": "Această tabla va fi <strong>privată</strong>.",
"board-public-info": "Această tabla va fi <strong>publică</strong>.",
"boardChangeColorPopup-title": "Schimbă Fundalul Tablei",
"boardChangeTitlePopup-title": "Redenumește Tabla",
"boardChangeVisibilityPopup-title": "Schimbă Vizibilitatea",
"boardChangeWatchPopup-title": "Change Watch",
"boardMenuPopup-title": "Board Settings",
"boardChangeViewPopup-title": "Board View",
"boards": "Boards",
"boards": "Table",
"board-view": "Board View",
"board-view-cal": "Calendar",
"board-view-swimlanes": "Swimlanes",
"board-view-collapse": "Collapse",
"board-view-lists": "Liste",
"bucket-example": "Like “Bucket List” for example",
"cancel": "Cancel",
"cancel": "Anulează",
"card-archived": "This card is moved to Archive.",
"board-archived": "This board is moved to Archive.",
"card-comments-title": "This card has %s comment.",
"card-delete-notice": "Deleting is permanent. You will lose all actions associated with this card.",
"card-delete-pop": "All actions will be removed from the activity feed and you won't be able to re-open the card. There is no undo.",
"card-comments-title": "Acest card are %s commentariu.",
"card-delete-notice": "Ștergerea este permanentă. Se vor pierde toate acțiunile asociate acestui card.",
"card-delete-pop": "Toate acțiunile vor fi șterse din jurnalul de activități si nu vei mai putea redeschide cardul. Nu există recuperare.",
"card-delete-suggest-archive": "You can move a card to Archive to remove it from the board and preserve the activity.",
"card-due": "Due",
"card-due-on": "Due on",
"card-spent": "Spent Time",
"card-edit-attachments": "Edit attachments",
"card-edit-attachments": "Editează atașamente",
"card-edit-custom-fields": "Edit custom fields",
"card-edit-labels": "Edit labels",
"card-edit-members": "Edit members",
"card-edit-labels": "Editează etichete",
"card-edit-members": "Editează membrii",
"card-labels-title": "Change the labels for the card.",
"card-members-title": "Add or remove members of the board from the card.",
"card-start": "Start",
+3 -3
View File
@@ -1,8 +1,8 @@
{
"accept": "Chấp nhận",
"act-activity-notify": "Activity Notification",
"act-addAttachment": "added attachment __attachment__ to card __card__ at list __list__ at swimlane __swimlane__ at board __board__",
"act-deleteAttachment": "deleted attachment __attachment__ at card __card__ at list __list__ at swimlane __swimlane__ at board __board__",
"act-activity-notify": "Thông báo hoạt động",
"act-addAttachment": "thêm tập tin đính kèm __attachment__ vào thẻ __list__ tại danh sách __list__ tại đường bơi __swimlane__ tại bảng __board__",
"act-deleteAttachment": "xóa tập tin đính kèm __attachment__ tại thẻ __card__ tại danh sách __list__ tại đường bơi __swimlane__ tại bảng __board__ ",
"act-addSubtask": "added subtask __subtask__ to card __card__ at list __list__ at swimlane __swimlane__ at board __board__",
"act-addLabel": "Added label __label__ to card __card__ at list __list__ at swimlane __swimlane__ at board __board__",
"act-addedLabel": "Added label __label__ to card __card__ at list __list__ at swimlane __swimlane__ at board __board__",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wekan",
"version": "v3.84.0",
"version": "v3.86.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wekan",
"version": "v3.84.0",
"version": "v3.86.0",
"description": "Open-Source kanban",
"private": true,
"scripts": {
+2 -2
View File
@@ -1524,7 +1524,7 @@ var n=this.pipeline.run(e.tokenizer(t)),r=new e.Vector,i=[],o=this._fields.reduc
<ul class="toc-list-h1">
<li>
<a href="#wekan-rest-api" class="toc-h1 toc-link" data-title="Wekan REST API v3.84">Wekan REST API v3.84</a>
<a href="#wekan-rest-api" class="toc-h1 toc-link" data-title="Wekan REST API v3.86">Wekan REST API v3.86</a>
</li>
@@ -2017,7 +2017,7 @@ var n=this.pipeline.run(e.tokenizer(t)),r=new e.Vector,i=[],o=this._fields.reduc
<div class="page-wrapper">
<div class="dark-box"></div>
<div class="content">
<h1 id="wekan-rest-api">Wekan REST API v3.84</h1>
<h1 id="wekan-rest-api">Wekan REST API v3.86</h1>
<blockquote>
<p>Scroll down for code samples, example requests and responses. Select a language for code samples from the tabs above or the mobile navigation menu.</p>
</blockquote>
+1 -1
View File
@@ -1,7 +1,7 @@
swagger: '2.0'
info:
title: Wekan REST API
version: v3.84
version: v3.86
description: |
The REST API allows you to control and extend Wekan with ease.
+1 -1
View File
@@ -11,7 +11,7 @@
REPODIR=/home/wekan/repos
WEKANDIR=/home/wekan/repos/wekan
OLDDIR=/home/wekan/repos/sandstorm-build
METEDIR=/home/wekan/repos/wekan/.snap-meteor-1.8
METEDIR=/home/wekan/repos/wekan/.sandstorm-meteor-1.8
# Ensure sudo access
sudo echo .
+2 -2
View File
@@ -22,10 +22,10 @@ const pkgdef :Spk.PackageDefinition = (
appTitle = (defaultText = "Wekan"),
# The name of the app as it is displayed to the user.
appVersion = 384,
appVersion = 386,
# Increment this for every release.
appMarketingVersion = (defaultText = "3.84.0~2020-03-16"),
appMarketingVersion = (defaultText = "3.86.0~2020-03-24"),
# Human-readable presentation of the app version.
minUpgradableAppVersion = 0,