import Planka upstream

This commit is contained in:
xiaojibeier
2026-07-18 11:48:22 +08:00
commit c26fd3b190
1564 changed files with 214813 additions and 0 deletions
@@ -0,0 +1,26 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
const crypto = require('crypto');
module.exports = {
sync: true,
inputs: {
record: {
type: 'ref',
required: true,
},
},
fn(inputs) {
if (!sails.config.custom.gravatarBaseUrl) {
return null;
}
const hash = crypto.createHash('sha256').update(inputs.record.email).digest('hex');
return `${sails.config.custom.gravatarBaseUrl}${hash}?s=180&d=initials`;
},
};
@@ -0,0 +1,112 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
const bcrypt = require('bcrypt');
module.exports = {
inputs: {
values: {
type: 'json',
required: true,
},
actorUser: {
type: 'ref',
required: true,
},
request: {
type: 'ref',
},
},
exits: {
emailAlreadyInUse: {},
usernameAlreadyInUse: {},
activeLimitReached: {},
},
async fn(inputs) {
const { values } = inputs;
if (values.password) {
values.password = await bcrypt.hash(values.password, 10);
}
if (values.username) {
values.username = values.username.toLowerCase();
}
let user;
try {
user = await User.qm.createOne({
...values,
email: values.email.toLowerCase(),
});
} catch (error) {
if (error.code === 'E_UNIQUE') {
throw 'emailAlreadyInUse';
}
if (
error.name === 'AdapterError' &&
error.raw.constraint === 'user_account_username_unique'
) {
throw 'usernameAlreadyInUse';
}
if (error.message === 'activeLimitReached') {
throw 'activeLimitReached';
}
throw error;
}
const scoper = sails.helpers.users.makeScoper(user);
const privateUserRelatedUserIds = await scoper.getPrivateUserRelatedUserIds();
privateUserRelatedUserIds.forEach((userId) => {
sails.sockets.broadcast(
`user:${userId}`,
'userCreate',
{
// FIXME: hack
item: sails.helpers.users.presentOne(user, {
id: userId,
role: User.Roles.ADMIN,
}),
},
inputs.request,
);
});
const publicUserRelatedUserIds = await scoper.getPublicUserRelatedUserIds(true);
publicUserRelatedUserIds.forEach((userId) => {
sails.sockets.broadcast(
`user:${userId}`,
'userCreate',
{
// FIXME: hack
item: sails.helpers.users.presentOne(user, {
id: userId,
}),
},
inputs.request,
);
});
const webhooks = await Webhook.qm.getAll();
sails.helpers.utils.sendWebhooks.with({
webhooks,
event: Webhook.Events.USER_CREATE,
buildData: () => ({
item: sails.helpers.users.presentOne(user),
}),
user: inputs.actorUser,
});
return user;
},
};
@@ -0,0 +1,102 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
inputs: {
record: {
type: 'ref',
required: true,
},
actorUser: {
type: 'ref',
required: true,
},
request: {
type: 'ref',
},
},
async fn(inputs) {
const { projectManagers, boardMemberships } = await sails.helpers.users.deleteRelated(
inputs.record,
);
const { user, uploadedFile } = await User.qm.deleteOne(inputs.record.id);
if (user) {
if (uploadedFile) {
sails.helpers.utils.removeUnreferencedUploadedFiles(uploadedFile);
}
const scoper = sails.helpers.users.makeScoper(user);
scoper.boardMemberships = boardMemberships;
const privateUserRelatedUserIds = await scoper.getPrivateUserRelatedUserIds();
privateUserRelatedUserIds.forEach((userId) => {
sails.sockets.broadcast(
`user:${userId}`,
'userDelete',
{
// FIXME: hack
item: sails.helpers.users.presentOne(user, {
id: userId,
role: User.Roles.ADMIN,
}),
},
inputs.request,
);
});
const publicUserRelatedUserIds = await scoper.getPublicUserRelatedUserIds();
publicUserRelatedUserIds.forEach((userId) => {
sails.sockets.broadcast(
`user:${userId}`,
'userDelete',
{
// FIXME: hack
item: sails.helpers.users.presentOne(user, {
id: userId,
}),
},
inputs.request,
);
});
const webhooks = await Webhook.qm.getAll();
sails.helpers.utils.sendWebhooks.with({
webhooks,
event: Webhook.Events.USER_DELETE,
buildData: () => ({
item: sails.helpers.users.presentOne(user),
}),
user: inputs.actorUser,
});
sails.sockets.leaveAll(`@user:${user.id}`);
const projectIds = await sails.helpers.utils.mapRecords(projectManagers, 'projectId', true);
const lonelyProjects = await sails.helpers.projects.getLonelyByIds(projectIds);
await Promise.all(
lonelyProjects.map((project) =>
// TODO: optimize with scoper
sails.helpers.projectManagers.createOne.with({
webhooks,
values: {
project,
user: inputs.actorUser,
},
actorUser: inputs.actorUser,
}),
),
);
}
return user;
},
};
@@ -0,0 +1,127 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
inputs: {
recordOrRecords: {
type: 'ref',
required: true,
},
},
async fn(inputs) {
let userIdOrIds;
if (_.isPlainObject(inputs.recordOrRecords)) {
({
recordOrRecords: { id: userIdOrIds },
} = inputs);
} else if (_.every(inputs.recordOrRecords, _.isPlainObject)) {
userIdOrIds = sails.helpers.utils.mapRecords(inputs.recordOrRecords);
}
await IdentityProviderUser.qm.delete({
userId: userIdOrIds,
});
await Session.qm.delete({
userId: userIdOrIds,
});
await ProjectFavorite.qm.delete({
userId: userIdOrIds,
});
const projectManagers = await ProjectManager.qm.delete({
userId: userIdOrIds,
});
const projectManagerIds = sails.helpers.utils.mapRecords(projectManagers);
const projects = await Project.qm.delete({
ownerProjectManagerId: projectManagerIds,
});
await sails.helpers.projects.deleteRelated(projects);
const boardMemberships = await BoardMembership.qm.delete({
userId: userIdOrIds,
});
await Card.qm.update(
{
creatorUserId: userIdOrIds,
},
{
creatorUserId: null,
},
);
await CardSubscription.qm.delete({
userId: userIdOrIds,
});
await CardMembership.qm.delete({
userId: userIdOrIds,
});
await Task.qm.update(
{
assigneeUserId: userIdOrIds,
},
{
assigneeUserId: null,
},
);
await Attachment.qm.update(
{
creatorUserId: userIdOrIds,
},
{
creatorUserId: null,
},
);
await Comment.qm.update(
{
userId: userIdOrIds,
},
{
userId: null,
},
);
await Action.qm.update(
{
userId: userIdOrIds,
},
{
userId: null,
},
);
await Notification.qm.update(
{
creatorUserId: userIdOrIds,
},
{
creatorUserId: null,
},
);
await Notification.qm.delete({
userId: userIdOrIds,
});
await NotificationService.qm.delete({
userId: userIdOrIds,
});
return {
projectManagers,
boardMemberships,
};
},
};
@@ -0,0 +1,22 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
inputs: {
roleOrRoles: {
type: 'json',
required: true,
},
},
async fn(inputs) {
const users = await User.qm.getAll({
roleOrRoles: inputs.roleOrRoles,
isDeactivated: false,
});
return sails.helpers.utils.mapRecords(users);
},
};
@@ -0,0 +1,19 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
inputs: {
id: {
type: 'string',
required: true,
},
},
async fn(inputs) {
const projectManagers = await ProjectManager.qm.getByUserId(inputs.id);
return sails.helpers.utils.mapRecords(projectManagers, 'projectId');
},
};
@@ -0,0 +1,19 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
inputs: {
id: {
type: 'string',
required: true,
},
},
async fn(inputs) {
const notificationServices = await NotificationService.qm.getByUserId(inputs.id);
return notificationServices.length;
},
};
@@ -0,0 +1,200 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
inputs: {
code: {
type: 'string',
required: true,
},
nonce: {
type: 'string',
required: true,
},
},
exits: {
invalidOidcConfiguration: {},
invalidCodeOrNonce: {},
invalidUserinfoConfiguration: {},
missingValues: {},
emailAlreadyInUse: {},
usernameAlreadyInUse: {},
activeLimitReached: {},
},
async fn(inputs) {
const client = await sails.hooks.oidc.getClient();
if (!client) {
throw 'invalidOidcConfiguration';
}
let tokenSet;
try {
if (sails.config.custom.oidcUseOauthCallback) {
tokenSet = await client.oauthCallback(
sails.config.custom.oidcRedirectUri,
{
iss: sails.config.custom.oidcIssuer,
code: inputs.code,
},
{
nonce: inputs.nonce,
},
);
} else {
tokenSet = await client.callback(
sails.config.custom.oidcRedirectUri,
{
iss: sails.config.custom.oidcIssuer,
code: inputs.code,
},
{
nonce: inputs.nonce,
},
);
}
} catch (error) {
sails.log.warn(`Error while exchanging OIDC code: ${error}`);
throw 'invalidCodeOrNonce';
}
let claims;
if (sails.config.custom.oidcClaimsSource === 'id_token') {
claims = tokenSet.claims();
} else {
try {
claims = await client.userinfo(tokenSet);
} catch (error) {
let errorText;
if (
error instanceof SyntaxError &&
error.message.includes('Unexpected token e in JSON at position 0')
) {
errorText = 'response is signed';
} else {
errorText = error.toString();
}
sails.log.warn(`Error while fetching OIDC userinfo: ${errorText}`);
throw 'invalidUserinfoConfiguration';
}
}
const email = _.get(claims, sails.config.custom.oidcEmailAttribute);
const name = _.get(claims, sails.config.custom.oidcNameAttribute);
if (!email || !name) {
throw 'missingValues';
}
let role = User.Roles.BOARD_USER;
if (!sails.config.custom.oidcIgnoreRoles) {
const claimsRoles = _.get(claims, sails.config.custom.oidcRolesAttribute);
if (Array.isArray(claimsRoles)) {
// Use a Set here to avoid quadratic time complexity
const claimsRolesSet = new Set(claimsRoles);
const foundRole = [User.Roles.ADMIN, User.Roles.PROJECT_OWNER, User.Roles.BOARD_USER].find(
(roleItem) => {
const configRoles = sails.config.custom[`oidc${_.upperFirst(roleItem)}Roles`];
if (configRoles.includes('*')) {
return true;
}
return configRoles.some((configRole) => claimsRolesSet.has(configRole));
},
);
if (foundRole) {
role = foundRole;
}
}
}
const values = {
email,
role,
name,
isSsoUser: true,
};
if (!sails.config.custom.oidcIgnoreUsername) {
values.username = _.get(claims, sails.config.custom.oidcUsernameAttribute);
}
// This whole block technically needs to be executed in a transaction
// with SERIALIZABLE isolation level (but Waterline does not support
// that), so this will result in errors if for example users are deleted
// concurrently with logging in via OIDC.
let identityProviderUser = await IdentityProviderUser.qm.getOneByIssuerAndSub(
sails.config.custom.oidcIssuer,
claims.sub,
);
let user;
let isCreated = false;
if (identityProviderUser) {
user = await User.qm.getOneById(identityProviderUser.userId);
} else {
// If no IDP/User mapping exists, search for the user by email.
user = await User.qm.getOneByEmail(values.email);
// Otherwise, create a new user.
if (!user) {
user = await sails.helpers.users.createOne
.with({
values,
actorUser: User.OIDC,
})
.intercept('usernameAlreadyInUse', 'usernameAlreadyInUse')
.intercept('activeLimitReached', 'activeLimitReached');
isCreated = true;
}
identityProviderUser = await IdentityProviderUser.qm.createOne({
userId: user.id,
issuer: sails.config.custom.oidcIssuer,
sub: claims.sub || `${user.id}@${sails.config.custom.oidcIssuer}`,
});
}
if (!isCreated) {
values.isDeactivated = false;
const updateFieldKeys = ['email', 'name', 'isSsoUser', 'isDeactivated'];
if (!sails.config.custom.oidcIgnoreUsername) {
updateFieldKeys.push('username');
}
if (!sails.config.custom.oidcIgnoreRoles) {
updateFieldKeys.push('role');
}
const updateValues = {};
// eslint-disable-next-line no-restricted-syntax
for (const k of updateFieldKeys) {
if (values[k] !== user[k]) updateValues[k] = values[k];
}
if (Object.keys(updateValues).length > 0) {
user = await sails.helpers.users.updateOne
.with({
record: user,
values: updateValues,
actorUser: User.OIDC,
})
.intercept('emailAlreadyInUse', 'emailAlreadyInUse')
.intercept('usernameAlreadyInUse', 'usernameAlreadyInUse')
.intercept('activeLimitReached', 'activeLimitReached');
}
}
return user;
},
};
@@ -0,0 +1,19 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
inputs: {
id: {
type: 'string',
required: true,
},
},
async fn(inputs) {
const projectManagers = await ProjectManager.qm.getByUserId(inputs.id);
return projectManagers.length;
},
};
@@ -0,0 +1,19 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
sync: true,
inputs: {
record: {
type: 'ref',
required: true,
},
},
fn(inputs) {
return [User.Roles.ADMIN, User.Roles.PROJECT_OWNER].includes(inputs.record.role);
},
};
@@ -0,0 +1,26 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
inputs: {
id: {
type: 'string',
required: true,
},
boardId: {
type: 'string',
required: true,
},
},
async fn(inputs) {
const boardMembership = await BoardMembership.qm.getOneByBoardIdAndUserId(
inputs.boardId,
inputs.id,
);
return !!boardMembership;
},
};
@@ -0,0 +1,26 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
inputs: {
id: {
type: 'string',
required: true,
},
boardId: {
type: 'string',
required: true,
},
},
async fn(inputs) {
const boardSubscription = await BoardSubscription.qm.getOneByBoardIdAndUserId(
inputs.boardId,
inputs.id,
);
return !!boardSubscription;
},
};
@@ -0,0 +1,26 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
inputs: {
id: {
type: 'string',
required: true,
},
cardId: {
type: 'string',
required: true,
},
},
async fn(inputs) {
const cardSubscription = await CardSubscription.qm.getOneByCardIdAndUserId(
inputs.cardId,
inputs.id,
);
return !!cardSubscription;
},
};
@@ -0,0 +1,26 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
inputs: {
id: {
type: 'string',
required: true,
},
projectId: {
type: 'string',
required: true,
},
},
async fn(inputs) {
const projectFavorite = await ProjectFavorite.qm.getOneByProjectIdAndUserId(
inputs.projectId,
inputs.id,
);
return !!projectFavorite;
},
};
@@ -0,0 +1,26 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
inputs: {
id: {
type: 'string',
required: true,
},
projectId: {
type: 'string',
required: true,
},
},
async fn(inputs) {
const projectManager = await ProjectManager.qm.getOneByProjectIdAndUserId(
inputs.projectId,
inputs.id,
);
return !!projectManager;
},
};
@@ -0,0 +1,150 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
class Scoper {
constructor(user) {
this.user = user;
this.projectManagers = null;
this.separatedUserIds = null;
this.userRelatedProjectManagerAndBoardMemberUserIds = null;
this.privateUserRelatedUserIds = null;
this.publicUserRelatedUserIds = null;
}
async getProjectManagers() {
if (!this.projectManagers) {
this.projectManagers = await ProjectManager.qm.getByUserId(this.user.id);
}
return this.projectManagers;
}
async getSeparatedUserIds() {
if (!this.separatedUserIds) {
const users = await User.qm.getAll({
roleOrRoles: [User.Roles.ADMIN, User.Roles.PROJECT_OWNER],
isDeactivated: false,
});
const adminUserIds = [];
const projectOwnerUserIds = [];
users.forEach((user) => {
if (user.role === User.Roles.ADMIN) {
adminUserIds.push(user.id);
} else {
projectOwnerUserIds.push(user.id);
}
});
this.separatedUserIds = {
adminUserIds,
projectOwnerUserIds,
};
}
return this.separatedUserIds;
}
async getUserRelatedProjectManagerAndBoardMemberUserIds() {
if (!this.userRelatedProjectManagerAndBoardMemberUserIds) {
const projectManagers = await this.getProjectManagers();
const projectIds = sails.helpers.utils.mapRecords(projectManagers, 'projectId');
const relatedProjectManagers = await ProjectManager.qm.getByProjectIds(projectIds, {
exceptUserIdOrIds: this.user.id,
});
const relatedProjectManagerUserIds = sails.helpers.utils.mapRecords(
relatedProjectManagers,
'userId',
);
const relatedProjectBoardMemberships = await BoardMembership.qm.getByProjectIds(projectIds);
const relatedProjectBoardMemberUserIds = sails.helpers.utils.mapRecords(
relatedProjectBoardMemberships,
'userId',
);
const boardMemberships = await BoardMembership.qm.getByUserId(this.user.id, {
exceptProjectIdOrIds: projectIds,
});
const boardIds = sails.helpers.utils.mapRecords(boardMemberships, 'boardId');
const relatedBoardMemberships = await BoardMembership.qm.getByBoardIds(boardIds, {
exceptUserIdOrIds: this.user.id,
});
const relatedBoardMemberUserIds = sails.helpers.utils.mapRecords(
relatedBoardMemberships,
'userId',
);
this.userRelatedProjectManagerAndBoardMemberUserIds = _.union(
relatedProjectManagerUserIds,
relatedProjectBoardMemberUserIds,
relatedBoardMemberUserIds,
);
}
return this.userRelatedProjectManagerAndBoardMemberUserIds;
}
async getPrivateUserRelatedUserIds() {
if (!this.privateUserRelatedUserIds) {
const { adminUserIds } = await this.getSeparatedUserIds();
this.privateUserRelatedUserIds = _.union([this.user.id], adminUserIds);
}
return this.privateUserRelatedUserIds;
}
async getPublicUserRelatedUserIds(skipRelatedProjectManagerAndBoardMemberUserIds = false) {
if (!this.publicUserRelatedUserIds) {
const privateUserRelatedUserIds = await this.getPrivateUserRelatedUserIds();
const privateUserRelatedUserIdsSet = new Set(privateUserRelatedUserIds);
const { projectOwnerUserIds } = await this.getSeparatedUserIds();
let userRelatedProjectManagerAndBoardMemberUserIds;
if (!skipRelatedProjectManagerAndBoardMemberUserIds) {
userRelatedProjectManagerAndBoardMemberUserIds =
await this.getUserRelatedProjectManagerAndBoardMemberUserIds();
}
const externalPublicUserRelatedUserIds = _.union(
projectOwnerUserIds,
userRelatedProjectManagerAndBoardMemberUserIds,
);
this.publicUserRelatedUserIds = externalPublicUserRelatedUserIds.filter(
(userId) => !privateUserRelatedUserIdsSet.has(userId),
);
}
return this.publicUserRelatedUserIds;
}
}
module.exports = {
sync: true,
inputs: {
record: {
type: 'ref',
required: true,
},
},
fn(inputs) {
return new Scoper(inputs.record);
},
};
@@ -0,0 +1,23 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
sync: true,
inputs: {
records: {
type: 'ref',
required: true,
},
user: {
type: 'ref',
require: true,
},
},
fn(inputs) {
return inputs.records.map((record) => sails.helpers.users.presentOne(record, inputs.user));
},
};
@@ -0,0 +1,91 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
module.exports = {
sync: true,
inputs: {
record: {
type: 'ref',
required: true,
},
user: {
type: 'ref',
},
},
fn(inputs) {
const data = {
..._.omit(inputs.record, [
'password',
'avatar',
'apiKeyHash',
'termsSignature',
'passwordChangedAt',
'apiKeyCreatedAt',
'termsAcceptedAt',
]),
avatar: inputs.record.avatar && {
url: `${sails.config.custom.baseUrl}/user-avatars/${inputs.record.avatar.uploadedFileId}/original.${inputs.record.avatar.extension}`,
thumbnailUrls: {
cover180: `${sails.config.custom.baseUrl}/user-avatars/${inputs.record.avatar.uploadedFileId}/cover-180.${inputs.record.avatar.extension}`,
},
},
language: inputs.record.language || sails.config.i18n.defaultLocale,
};
const gravatarUrl = sails.helpers.users.buildGravatarUrl(inputs.record);
if (gravatarUrl) {
data.gravatarUrl = gravatarUrl;
}
if (inputs.user) {
const isForCurrentUser = inputs.record.id === inputs.user.id;
const isForAdmin = inputs.user.role === User.Roles.ADMIN;
if (isForCurrentUser || isForAdmin) {
const isDefaultAdmin = inputs.record.email === sails.config.custom.defaultAdminEmail;
const lockedFieldNames = [];
if (sails.config.custom.demoMode) {
lockedFieldNames.push('email', 'password', 'role', 'name', 'username');
} else if (isDefaultAdmin || inputs.record.isSsoUser) {
lockedFieldNames.push('email', 'password', 'name');
if (isDefaultAdmin) {
lockedFieldNames.push('role', 'username');
} else if (inputs.record.isSsoUser) {
if (!sails.config.custom.oidcIgnoreRoles) {
lockedFieldNames.push('role');
}
if (!sails.config.custom.oidcIgnoreUsername) {
lockedFieldNames.push('username');
}
}
}
if (sails.config.custom.oidcEnforced) {
lockedFieldNames.push('isSsoUser');
}
Object.assign(data, {
isDefaultAdmin,
lockedFieldNames,
});
if (isForCurrentUser) {
return data;
}
return _.omit(data, User.PERSONAL_FIELD_NAMES);
}
return _.omit(data, [...User.PRIVATE_FIELD_NAMES, ...User.PERSONAL_FIELD_NAMES]);
}
return data;
},
};
@@ -0,0 +1,96 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
const { v4: uuid } = require('uuid');
const { rimraf } = require('rimraf');
const { fileTypeFromFile } = require('file-type');
const sharp = require('sharp');
const { MAX_SIZE_TO_PROCESS_AS_IMAGE } = require('../../../constants');
module.exports = {
inputs: {
file: {
type: 'json',
required: true,
},
},
exits: {
fileIsNotImage: {},
},
async fn(inputs) {
const fileManager = sails.hooks['file-manager'].getInstance();
const fileType = await fileTypeFromFile(inputs.file.fd);
const { mime: mimeType = null } = fileType || {};
const { size } = inputs.file;
if (!mimeType || !mimeType.startsWith('image/') || size > MAX_SIZE_TO_PROCESS_AS_IMAGE) {
await rimraf(inputs.file.fd);
throw 'fileIsNotImage';
}
let image = sharp(inputs.file.fd, {
animated: true,
});
let metadata;
try {
metadata = await image.metadata();
} catch (error) {
await rimraf(inputs.file.fd);
throw 'fileIsNotImage';
}
if (metadata.orientation && metadata.orientation > 4) {
image = image.rotate();
}
const { id: uploadedFileId } = await UploadedFile.qm.createOne({
mimeType,
size,
id: uuid(),
type: UploadedFile.Types.USER_AVATAR,
});
const dirPathSegment = `${sails.config.custom.userAvatarsPathSegment}/${uploadedFileId}`;
const extension = metadata.format === 'jpeg' ? 'jpg' : metadata.format;
const cover180 = image
.clone()
.resize(180, 180, {
withoutEnlargement: true,
})
.png({
quality: 75,
force: false,
});
try {
await Promise.all([
fileManager.save(`${dirPathSegment}/original.${extension}`, image, inputs.file.type),
fileManager.save(`${dirPathSegment}/cover-180.${extension}`, cover180, inputs.file.type),
]);
} catch (error) {
sails.log.warn(error.stack);
await fileManager.deleteDir(dirPathSegment);
await rimraf(inputs.file.fd);
await UploadedFile.qm.deleteOne(uploadedFileId);
throw 'fileIsNotImage';
}
await rimraf(inputs.file.fd);
return {
uploadedFileId,
extension,
size,
};
},
};
@@ -0,0 +1,228 @@
/*!
* Copyright (c) 2024 PLANKA Software GmbH
* Licensed under the Fair Use License: https://github.com/plankanban/planka/blob/master/LICENSE.md
*/
const bcrypt = require('bcrypt');
const { v4: uuid } = require('uuid');
module.exports = {
inputs: {
record: {
type: 'ref',
required: true,
},
values: {
type: 'json',
required: true,
},
actorUser: {
type: 'ref',
required: true,
},
request: {
type: 'ref',
},
},
exits: {
emailAlreadyInUse: {},
usernameAlreadyInUse: {},
activeLimitReached: {},
},
async fn(inputs) {
const { values } = inputs;
if (!_.isUndefined(values.email)) {
values.email = values.email.toLowerCase();
}
if (_.isNull(values.apiKey)) {
Object.assign(values, {
apiKeyPrefix: null,
apiKeyHash: null,
apiKeyCreatedAt: null,
});
delete values.apiKey;
}
let isOnlyPrivateFieldsChange = false;
let isOnlyPersonalFieldsChange = false;
let isOnlyPasswordChange = false;
let isDeactivatedChangeToTrue = false;
if (_.difference(Object.keys(values), User.PRIVATE_FIELD_NAMES).length === 0) {
isOnlyPrivateFieldsChange = true;
}
if (_.difference(Object.keys(values), User.PERSONAL_FIELD_NAMES).length === 0) {
isOnlyPersonalFieldsChange = true;
}
if (!_.isUndefined(values.password)) {
if (Object.keys(values).length === 1) {
isOnlyPasswordChange = true;
}
values.password = await bcrypt.hash(values.password, 10);
values.passwordChangedAt = new Date().toUTCString(); // FIXME: hack
}
if (values.username) {
values.username = values.username.toLowerCase();
}
if (values.apiKeyHash) {
values.apiKeyCreatedAt = new Date().toISOString();
}
if (values.isDeactivated && values.isDeactivated !== inputs.record.isDeactivated) {
isDeactivatedChangeToTrue = true;
}
let user;
let uploadedFile;
try {
({ user, uploadedFile } = await User.qm.updateOne(inputs.record.id, values));
} catch (error) {
if (error.code === 'E_UNIQUE') {
throw 'emailAlreadyInUse';
}
if (
error.name === 'AdapterError' &&
error.raw.constraint === 'user_account_username_unique'
) {
throw 'usernameAlreadyInUse';
}
if (error.message === 'activeLimitReached') {
throw 'activeLimitReached';
}
throw error;
}
if (user) {
if (uploadedFile) {
sails.helpers.utils.removeUnreferencedUploadedFiles(uploadedFile);
}
if (!_.isUndefined(values.password) || isDeactivatedChangeToTrue) {
sails.sockets.broadcast(`user:${user.id}`, 'logout', undefined, inputs.request);
if (
!isDeactivatedChangeToTrue &&
user.id === inputs.actorUser.id &&
inputs.request &&
inputs.request.isSocket
) {
const tempRoom = uuid();
sails.sockets.addRoomMembersToRooms(`@user:${user.id}`, tempRoom, () => {
sails.sockets.leave(inputs.request, tempRoom, () => {
sails.sockets.leaveAll(tempRoom);
});
});
} else {
sails.sockets.leaveAll(`@user:${user.id}`);
}
}
if (!isOnlyPasswordChange) {
if (isOnlyPersonalFieldsChange) {
sails.sockets.broadcast(
`user:${user.id}`,
'userUpdate',
{
item: sails.helpers.users.presentOne(user, user),
},
inputs.request,
);
} else {
const scoper = sails.helpers.users.makeScoper(user);
const privateUserRelatedUserIds = await scoper.getPrivateUserRelatedUserIds();
privateUserRelatedUserIds.forEach((userId) => {
sails.sockets.broadcast(
`user:${userId}`,
'userUpdate',
{
// FIXME: hack
item: sails.helpers.users.presentOne(user, {
id: userId,
role: User.Roles.ADMIN,
}),
},
inputs.request,
);
});
if (!isOnlyPrivateFieldsChange) {
if (inputs.record.role === User.Roles.ADMIN && user.role !== User.Roles.ADMIN) {
const managerProjectIds = await sails.helpers.users.getManagerProjectIds(user.id);
const sharedProjects = await Project.qm.getShared({
exceptIdOrIds: managerProjectIds,
});
const projectIds = sails.helpers.utils.mapRecords(sharedProjects);
const boards = await Board.qm.getByProjectIds(projectIds);
const boardIds = sails.helpers.utils.mapRecords(boards);
const boardMemberships = await BoardMembership.qm.getByBoardIdsAndUserId(
boardIds,
user.id,
);
const missingBoardIds = _.difference(
boardIds,
sails.helpers.utils.mapRecords(boardMemberships, 'boardId'),
);
missingBoardIds.forEach((boardId) => {
sails.sockets.removeRoomMembersFromRooms(`@user:${user.id}`, `board:${boardId}`);
});
}
const publicUserRelatedUserIds = await scoper.getPublicUserRelatedUserIds();
publicUserRelatedUserIds.forEach((userId) => {
sails.sockets.broadcast(
`user:${userId}`,
'userUpdate',
{
// FIXME: hack
item: sails.helpers.users.presentOne(user, {
id: userId,
}),
},
inputs.request,
);
});
}
}
const webhooks = await Webhook.qm.getAll();
sails.helpers.utils.sendWebhooks.with({
webhooks,
event: Webhook.Events.USER_UPDATE,
buildData: () => ({
item: sails.helpers.users.presentOne(user),
}),
buildPrevData: () => ({
item: sails.helpers.users.presentOne(inputs.record),
}),
user: inputs.actorUser,
});
}
}
return user;
},
};