From afbcd388028f110557dc8aaaa8e958438b4130ab Mon Sep 17 00:00:00 2001 From: Futa Arai Date: Sun, 7 Dec 2025 19:06:27 +0900 Subject: [PATCH 1/4] configure biome for some service files --- apps/app/.eslintrc.js | 14 + .../access-token-deletion-cron.ts | 30 +- .../config-manager/config-definition.ts | 246 +++++++----- .../config-manager/config-loader.spec.ts | 27 +- .../service/config-manager/config-loader.ts | 33 +- .../config-manager/config-manager.integ.ts | 153 +++++--- .../config-manager/config-manager.spec.ts | 117 ++++-- .../service/config-manager/config-manager.ts | 92 +++-- .../page-listing/page-listing.integ.ts | 122 ++++-- .../service/page-listing/page-listing.ts | 109 +++-- ...rmalize-latest-revision-if-broken.integ.ts | 43 +- .../normalize-latest-revision-if-broken.ts | 36 +- .../src/server/service/s2s-messaging/base.ts | 10 +- .../server/service/s2s-messaging/handlable.ts | 2 - .../src/server/service/s2s-messaging/index.ts | 6 +- .../src/server/service/s2s-messaging/nchan.ts | 60 +-- .../src/server/service/s2s-messaging/redis.ts | 2 +- .../search-delegator/aggregate-to-index.ts | 10 +- .../service/search-delegator/bulk-write.d.ts | 45 +-- .../es7-client-delegator.ts | 90 +++-- .../es8-client-delegator.ts | 75 +++- .../es9-client-delegator.ts | 75 +++- .../get-client.ts | 75 ++-- .../interfaces.ts | 44 ++- .../service/search-delegator/elasticsearch.ts | 371 ++++++++++++------ .../search-delegator/private-legacy-pages.ts | 61 ++- .../reconnect-context.js | 11 +- .../create-page-service.js | 24 +- .../slack-command-handler/error-handler.ts | 50 ++- .../service/slack-command-handler/help.js | 12 +- .../service/slack-command-handler/keep.js | 218 +++++++--- .../service/slack-command-handler/note.js | 73 +++- .../service/slack-command-handler/search.js | 232 ++++++----- .../slack-command-handler.js | 15 +- .../service/slack-command-handler/togetter.js | 189 ++++++--- .../slack-event-handler/base-event-handler.ts | 14 +- .../slack-event-handler/link-shared.ts | 161 +++++--- .../src/server/service/socket-io/helper.ts | 5 +- .../src/server/service/socket-io/socket-io.ts | 49 +-- .../service/system-events/sync-page-status.ts | 40 +- .../server/service/user-notification/index.ts | 44 ++- .../src/server/service/yjs/create-indexes.ts | 6 +- .../service/yjs/create-mongodb-persistence.ts | 17 +- .../yjs/extended/mongodb-persistence.ts | 22 +- apps/app/src/server/service/yjs/sync-ydoc.ts | 58 +-- apps/app/src/server/service/yjs/yjs.integ.ts | 52 +-- apps/app/src/server/service/yjs/yjs.ts | 89 +++-- biome.json | 15 +- 48 files changed, 2171 insertions(+), 1173 deletions(-) diff --git a/apps/app/.eslintrc.js b/apps/app/.eslintrc.js index 5dc7194a38a..6e11bc197a7 100644 --- a/apps/app/.eslintrc.js +++ b/apps/app/.eslintrc.js @@ -74,6 +74,20 @@ module.exports = { 'src/server/routes/apiv3/*.ts', 'src/server/service/*.ts', 'src/server/service/*.js', + 'src/server/service/access-token/**', + 'src/server/service/config-manager/**', + 'src/server/service/page/**', + 'src/server/service/page-listing/**', + 'src/server/service/revision/**', + 'src/server/service/s2s-messaging/**', + 'src/server/service/search-delegator/**', + 'src/server/service/search-reconnect-context/**', + 'src/server/service/slack-command-handler/**', + 'src/server/service/slack-event-handler/**', + 'src/server/service/socket-io/**', + 'src/server/service/system-events/**', + 'src/server/service/user-notification/**', + 'src/server/service/yjs/**', ], settings: { // resolve path aliases by eslint-import-resolver-typescript diff --git a/apps/app/src/server/service/access-token/access-token-deletion-cron.ts b/apps/app/src/server/service/access-token/access-token-deletion-cron.ts index c236b7a3ef1..be954133bb9 100644 --- a/apps/app/src/server/service/access-token/access-token-deletion-cron.ts +++ b/apps/app/src/server/service/access-token/access-token-deletion-cron.ts @@ -7,14 +7,15 @@ import loggerFactory from '~/utils/logger'; const logger = loggerFactory('growi:service:access-token-deletion-cron'); export class AccessTokenDeletionCronService { - cronJob: nodeCron.ScheduledTask; // Default execution at midnight accessTokenDeletionCronExpression = '0 15 * * *'; startCron(): void { - const cronExp = configManager.getConfig('accessToken:deletionCronExpression'); + const cronExp = configManager.getConfig( + 'accessToken:deletionCronExpression', + ); if (cronExp != null) { this.accessTokenDeletionCronExpression = cronExp; } @@ -30,23 +31,26 @@ export class AccessTokenDeletionCronService { try { await AccessToken.deleteExpiredToken(); logger.info('Expired access tokens have been deleted'); - } - catch (e) { + } catch (e) { logger.error('Failed to delete expired access tokens:', e); } } private generateCronJob() { - return nodeCron.schedule(this.accessTokenDeletionCronExpression, async() => { - try { - await this.executeJob(); - } - catch (e) { - logger.error('Error occurred during access token deletion cron job:', e); - } - }); + return nodeCron.schedule( + this.accessTokenDeletionCronExpression, + async () => { + try { + await this.executeJob(); + } catch (e) { + logger.error( + 'Error occurred during access token deletion cron job:', + e, + ); + } + }, + ); } - } export const startCron = (): void => { diff --git a/apps/app/src/server/service/config-manager/config-definition.ts b/apps/app/src/server/service/config-manager/config-definition.ts index cae9d169da9..45b9ba6949f 100644 --- a/apps/app/src/server/service/config-manager/config-definition.ts +++ b/apps/app/src/server/service/config-manager/config-definition.ts @@ -1,14 +1,18 @@ import { GrowiDeploymentType, GrowiServiceType } from '@growi/core/dist/consts'; -import type { ConfigDefinition, Lang, NonBlankString } from '@growi/core/dist/interfaces'; -import { - toNonBlankString, - defineConfig, +import type { + ConfigDefinition, + Lang, + NonBlankString, } from '@growi/core/dist/interfaces'; +import { defineConfig, toNonBlankString } from '@growi/core/dist/interfaces'; import type OpenAI from 'openai'; import { ActionGroupSize } from '~/interfaces/activity'; import { AttachmentMethodType } from '~/interfaces/attachment'; -import type { IPageDeleteConfigValue, IPageDeleteConfigValueToProcessValidation } from '~/interfaces/page-delete-config'; +import type { + IPageDeleteConfigValue, + IPageDeleteConfigValueToProcessValidation, +} from '~/interfaces/page-delete-config'; import type { RegistrationMode } from '~/interfaces/registration-mode'; import { RehypeSanitizeType } from '~/interfaces/services/rehype-sanitize'; @@ -331,10 +335,8 @@ export const CONFIG_KEYS = [ 'accessToken:deletionCronExpression', ] as const; - export type ConfigKey = (typeof CONFIG_KEYS)[number]; - export const CONFIG_DEFINITIONS = { // Auto Install Settings 'autoInstall:adminUsername': defineConfig({ @@ -438,7 +440,7 @@ export const CONFIG_DEFINITIONS = { envVarName: 'FILE_UPLOAD_TOTAL_LIMIT', defaultValue: Infinity, }), - 'app:elasticsearchVersion': defineConfig<7|8|9>({ + 'app:elasticsearchVersion': defineConfig<7 | 8 | 9>({ envVarName: 'ELASTICSEARCH_VERSION', defaultValue: 9, }), @@ -522,10 +524,12 @@ export const CONFIG_DEFINITIONS = { envVarName: 'OPENAI_THREAD_DELETION_CRON_MAX_MINUTES_UNTIL_REQUEST', defaultValue: 30, }), - 'app:openaiVectorStoreFileDeletionCronMaxMinutesUntilRequest': defineConfig({ - envVarName: 'OPENAI_VECTOR_STORE_FILE_DELETION_CRON_MAX_MINUTES_UNTIL_REQUEST', - defaultValue: 30, - }), + 'app:openaiVectorStoreFileDeletionCronMaxMinutesUntilRequest': + defineConfig({ + envVarName: + 'OPENAI_VECTOR_STORE_FILE_DELETION_CRON_MAX_MINUTES_UNTIL_REQUEST', + defaultValue: 30, + }), // Security Settings 'security:wikiMode': defineConfig({ @@ -564,10 +568,12 @@ export const CONFIG_DEFINITIONS = { envVarName: 'LOCAL_STRATEGY_PASSWORD_RESET_ENABLED', defaultValue: true, }), - 'security:passport-local:isEmailAuthenticationEnabled': defineConfig({ - envVarName: 'LOCAL_STRATEGY_EMAIL_AUTHENTICATION_ENABLED', - defaultValue: false, - }), + 'security:passport-local:isEmailAuthenticationEnabled': defineConfig( + { + envVarName: 'LOCAL_STRATEGY_EMAIL_AUTHENTICATION_ENABLED', + defaultValue: false, + }, + ), 'security:passport-saml:isEnabled': defineConfig({ envVarName: 'SAML_ENABLED', defaultValue: false, @@ -646,27 +652,37 @@ export const CONFIG_DEFINITIONS = { 'security:list-policy:hideRestrictedByGroup': defineConfig({ defaultValue: false, }), - 'security:pageDeletionAuthority': defineConfig({ + 'security:pageDeletionAuthority': defineConfig< + IPageDeleteConfigValueToProcessValidation | undefined + >({ defaultValue: undefined, }), - 'security:pageCompleteDeletionAuthority': defineConfig({ + 'security:pageCompleteDeletionAuthority': defineConfig< + IPageDeleteConfigValueToProcessValidation | undefined + >({ defaultValue: undefined, }), - 'security:pageRecursiveDeletionAuthority': defineConfig({ + 'security:pageRecursiveDeletionAuthority': defineConfig< + IPageDeleteConfigValue | undefined + >({ defaultValue: undefined, }), - 'security:pageRecursiveCompleteDeletionAuthority': defineConfig({ + 'security:pageRecursiveCompleteDeletionAuthority': defineConfig< + IPageDeleteConfigValue | undefined + >({ defaultValue: undefined, }), - 'security:isAllGroupMembershipRequiredForPageCompleteDeletion': defineConfig({ - defaultValue: true, - }), + 'security:isAllGroupMembershipRequiredForPageCompleteDeletion': + defineConfig({ + defaultValue: true, + }), 'security:user-homepage-deletion:isEnabled': defineConfig({ defaultValue: false, }), - 'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion': defineConfig({ - defaultValue: false, - }), + 'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion': + defineConfig({ + defaultValue: false, + }), 'security:isRomUserAllowedToComment': defineConfig({ defaultValue: false, }), @@ -706,30 +722,39 @@ export const CONFIG_DEFINITIONS = { 'security:passport-ldap:groupDnProperty': defineConfig({ defaultValue: undefined, }), - 'security:passport-ldap:isSameUsernameTreatedAsIdenticalUser': defineConfig({ - defaultValue: false, - }), - 'security:passport-saml:isSameEmailTreatedAsIdenticalUser': defineConfig({ - defaultValue: false, - }), - 'security:passport-saml:isSameUsernameTreatedAsIdenticalUser': defineConfig({ - defaultValue: false, - }), + 'security:passport-ldap:isSameUsernameTreatedAsIdenticalUser': + defineConfig({ + defaultValue: false, + }), + 'security:passport-saml:isSameEmailTreatedAsIdenticalUser': + defineConfig({ + defaultValue: false, + }), + 'security:passport-saml:isSameUsernameTreatedAsIdenticalUser': + defineConfig({ + defaultValue: false, + }), 'security:passport-google:isEnabled': defineConfig({ defaultValue: false, }), - 'security:passport-google:clientId': defineConfig({ - defaultValue: undefined, - }), - 'security:passport-google:clientSecret': defineConfig({ - defaultValue: undefined, - }), - 'security:passport-google:isSameUsernameTreatedAsIdenticalUser': defineConfig({ - defaultValue: false, - }), - 'security:passport-google:isSameEmailTreatedAsIdenticalUser': defineConfig({ - defaultValue: false, - }), + 'security:passport-google:clientId': defineConfig( + { + defaultValue: undefined, + }, + ), + 'security:passport-google:clientSecret': defineConfig< + NonBlankString | undefined + >({ + defaultValue: undefined, + }), + 'security:passport-google:isSameUsernameTreatedAsIdenticalUser': + defineConfig({ + defaultValue: false, + }), + 'security:passport-google:isSameEmailTreatedAsIdenticalUser': + defineConfig({ + defaultValue: false, + }), 'security:passport-github:isEnabled': defineConfig({ defaultValue: false, }), @@ -739,12 +764,14 @@ export const CONFIG_DEFINITIONS = { 'security:passport-github:clientSecret': defineConfig({ defaultValue: undefined, }), - 'security:passport-github:isSameUsernameTreatedAsIdenticalUser': defineConfig({ - defaultValue: false, - }), - 'security:passport-github:isSameEmailTreatedAsIdenticalUser': defineConfig({ - defaultValue: false, - }), + 'security:passport-github:isSameUsernameTreatedAsIdenticalUser': + defineConfig({ + defaultValue: false, + }), + 'security:passport-github:isSameEmailTreatedAsIdenticalUser': + defineConfig({ + defaultValue: false, + }), 'security:passport-oidc:clientId': defineConfig({ defaultValue: undefined, }), @@ -757,36 +784,48 @@ export const CONFIG_DEFINITIONS = { 'security:passport-oidc:issuerHost': defineConfig({ defaultValue: undefined, }), - 'security:passport-oidc:authorizationEndpoint': defineConfig({ + 'security:passport-oidc:authorizationEndpoint': defineConfig< + string | undefined + >({ defaultValue: undefined, }), 'security:passport-oidc:tokenEndpoint': defineConfig({ defaultValue: undefined, }), - 'security:passport-oidc:revocationEndpoint': defineConfig({ - defaultValue: undefined, - }), - 'security:passport-oidc:introspectionEndpoint': defineConfig({ + 'security:passport-oidc:revocationEndpoint': defineConfig( + { + defaultValue: undefined, + }, + ), + 'security:passport-oidc:introspectionEndpoint': defineConfig< + string | undefined + >({ defaultValue: undefined, }), 'security:passport-oidc:userInfoEndpoint': defineConfig({ defaultValue: undefined, }), - 'security:passport-oidc:endSessionEndpoint': defineConfig({ - defaultValue: undefined, - }), - 'security:passport-oidc:registrationEndpoint': defineConfig({ + 'security:passport-oidc:endSessionEndpoint': defineConfig( + { + defaultValue: undefined, + }, + ), + 'security:passport-oidc:registrationEndpoint': defineConfig< + string | undefined + >({ defaultValue: undefined, }), 'security:passport-oidc:jwksUri': defineConfig({ defaultValue: undefined, }), - 'security:passport-oidc:isSameUsernameTreatedAsIdenticalUser': defineConfig({ - defaultValue: false, - }), - 'security:passport-oidc:isSameEmailTreatedAsIdenticalUser': defineConfig({ - defaultValue: false, - }), + 'security:passport-oidc:isSameUsernameTreatedAsIdenticalUser': + defineConfig({ + defaultValue: false, + }), + 'security:passport-oidc:isSameEmailTreatedAsIdenticalUser': + defineConfig({ + defaultValue: false, + }), // File Upload Settings 'fileUpload:local:useInternalRedirect': defineConfig({ @@ -1051,7 +1090,9 @@ export const CONFIG_DEFINITIONS = { envVarName: 'SLACKBOT_WITHOUT_PROXY_COMMAND_PERMISSION', defaultValue: undefined, }), - 'slackbot:withoutProxy:eventActionsPermission': defineConfig({ + 'slackbot:withoutProxy:eventActionsPermission': defineConfig< + string | undefined + >({ envVarName: 'SLACKBOT_WITHOUT_PROXY_EVENT_ACTIONS_PERMISSION', defaultValue: undefined, }), @@ -1186,28 +1227,40 @@ export const CONFIG_DEFINITIONS = { }), // External User Group Settings - 'external-user-group:ldap:groupMembershipAttributeType': defineConfig({ - defaultValue: 'DN', - }), + 'external-user-group:ldap:groupMembershipAttributeType': defineConfig( + { + defaultValue: 'DN', + }, + ), 'external-user-group:ldap:groupSearchBase': defineConfig({ defaultValue: undefined, }), - 'external-user-group:ldap:groupMembershipAttribute': defineConfig({ + 'external-user-group:ldap:groupMembershipAttribute': defineConfig< + string | undefined + >({ defaultValue: undefined, }), - 'external-user-group:ldap:groupChildGroupAttribute': defineConfig({ + 'external-user-group:ldap:groupChildGroupAttribute': defineConfig< + string | undefined + >({ defaultValue: undefined, }), - 'external-user-group:ldap:autoGenerateUserOnGroupSync': defineConfig({ - defaultValue: false, - }), + 'external-user-group:ldap:autoGenerateUserOnGroupSync': defineConfig( + { + defaultValue: false, + }, + ), 'external-user-group:ldap:preserveDeletedGroups': defineConfig({ defaultValue: false, }), - 'external-user-group:ldap:groupNameAttribute': defineConfig({ + 'external-user-group:ldap:groupNameAttribute': defineConfig< + string | undefined + >({ defaultValue: undefined, }), - 'external-user-group:ldap:groupDescriptionAttribute': defineConfig({ + 'external-user-group:ldap:groupDescriptionAttribute': defineConfig< + string | undefined + >({ defaultValue: undefined, }), 'external-user-group:keycloak:host': defineConfig({ @@ -1216,23 +1269,32 @@ export const CONFIG_DEFINITIONS = { 'external-user-group:keycloak:groupRealm': defineConfig({ defaultValue: undefined, }), - 'external-user-group:keycloak:groupSyncClientRealm': defineConfig({ + 'external-user-group:keycloak:groupSyncClientRealm': defineConfig< + string | undefined + >({ defaultValue: undefined, }), - 'external-user-group:keycloak:groupSyncClientID': defineConfig({ + 'external-user-group:keycloak:groupSyncClientID': defineConfig< + string | undefined + >({ defaultValue: undefined, }), - 'external-user-group:keycloak:groupSyncClientSecret': defineConfig({ + 'external-user-group:keycloak:groupSyncClientSecret': defineConfig< + string | undefined + >({ defaultValue: undefined, isSecret: true, }), - 'external-user-group:keycloak:autoGenerateUserOnGroupSync': defineConfig({ - defaultValue: false, - }), + 'external-user-group:keycloak:autoGenerateUserOnGroupSync': + defineConfig({ + defaultValue: false, + }), 'external-user-group:keycloak:preserveDeletedGroups': defineConfig({ defaultValue: false, }), - 'external-user-group:keycloak:groupDescriptionAttribute': defineConfig({ + 'external-user-group:keycloak:groupDescriptionAttribute': defineConfig< + string | undefined + >({ defaultValue: undefined, }), @@ -1306,7 +1368,11 @@ export const CONFIG_DEFINITIONS = { } as const; export type ConfigValues = { - [K in ConfigKey]: (typeof CONFIG_DEFINITIONS)[K] extends ConfigDefinition ? T : never; + [K in ConfigKey]: (typeof CONFIG_DEFINITIONS)[K] extends ConfigDefinition< + infer T + > + ? T + : never; }; // Define groups of settings that use only environment variables @@ -1339,11 +1405,7 @@ export const ENV_ONLY_GROUPS: EnvOnlyGroup[] = [ }, { controlKey: 'env:useOnlyEnvVars:gcs', - targetKeys: [ - 'gcs:apiKeyJsonPath', - 'gcs:bucket', - 'gcs:uploadNamespace', - ], + targetKeys: ['gcs:apiKeyJsonPath', 'gcs:bucket', 'gcs:uploadNamespace'], }, { controlKey: 'env:useOnlyEnvVars:azure', diff --git a/apps/app/src/server/service/config-manager/config-loader.spec.ts b/apps/app/src/server/service/config-manager/config-loader.spec.ts index 42c7d7d8463..fd263bbd310 100644 --- a/apps/app/src/server/service/config-manager/config-loader.spec.ts +++ b/apps/app/src/server/service/config-manager/config-loader.spec.ts @@ -16,7 +16,7 @@ vi.mock('../../models/config', () => ({ describe('ConfigLoader', () => { let configLoader: ConfigLoader; - beforeEach(async() => { + beforeEach(async () => { configLoader = new ConfigLoader(); vi.clearAllMocks(); }); @@ -30,8 +30,9 @@ describe('ConfigLoader', () => { mockExec.mockResolvedValue(mockDocs); }); - it('should return null for value', async() => { - const config: RawConfigData = await configLoader.loadFromDB(); + it('should return null for value', async () => { + const config: RawConfigData = + await configLoader.loadFromDB(); expect(config['app:referrerPolicy'].value).toBe(null); }); }); @@ -44,8 +45,9 @@ describe('ConfigLoader', () => { mockExec.mockResolvedValue(mockDocs); }); - it('should return null for value', async() => { - const config: RawConfigData = await configLoader.loadFromDB(); + it('should return null for value', async () => { + const config: RawConfigData = + await configLoader.loadFromDB(); expect(config['app:referrerPolicy'].value).toBe(null); }); }); @@ -54,13 +56,17 @@ describe('ConfigLoader', () => { const validJson = { key: 'value' }; beforeEach(() => { const mockDocs = [ - { key: 'app:referrerPolicy' as ConfigKey, value: JSON.stringify(validJson) }, + { + key: 'app:referrerPolicy' as ConfigKey, + value: JSON.stringify(validJson), + }, ]; mockExec.mockResolvedValue(mockDocs); }); - it('should return parsed value', async() => { - const config: RawConfigData = await configLoader.loadFromDB(); + it('should return parsed value', async () => { + const config: RawConfigData = + await configLoader.loadFromDB(); expect(config['app:referrerPolicy'].value).toEqual(validJson); }); }); @@ -73,8 +79,9 @@ describe('ConfigLoader', () => { mockExec.mockResolvedValue(mockDocs); }); - it('should return null for value', async() => { - const config: RawConfigData = await configLoader.loadFromDB(); + it('should return null for value', async () => { + const config: RawConfigData = + await configLoader.loadFromDB(); expect(config['app:referrerPolicy'].value).toBe(null); }); }); diff --git a/apps/app/src/server/service/config-manager/config-loader.ts b/apps/app/src/server/service/config-manager/config-loader.ts index 9f93c2e2230..d8071a77577 100644 --- a/apps/app/src/server/service/config-manager/config-loader.ts +++ b/apps/app/src/server/service/config-manager/config-loader.ts @@ -9,7 +9,6 @@ import { CONFIG_DEFINITIONS } from './config-definition'; const logger = loggerFactory('growi:service:ConfigLoader'); export class ConfigLoader implements IConfigLoader { - async loadFromEnv(): Promise> { const envConfig = {} as RawConfigData; @@ -19,7 +18,10 @@ export class ConfigLoader implements IConfigLoader { if (metadata.envVarName != null) { const envVarValue = process.env[metadata.envVarName]; if (envVarValue != null) { - configValue = this.parseEnvValue(envVarValue, typeof metadata.defaultValue) as ConfigValues[ConfigKey]; + configValue = this.parseEnvValue( + envVarValue, + typeof metadata.defaultValue, + ) as ConfigValues[ConfigKey]; } } @@ -43,15 +45,20 @@ export class ConfigLoader implements IConfigLoader { for (const doc of docs) { dbConfig[doc.key as ConfigKey] = { - definition: (doc.key in CONFIG_DEFINITIONS) ? CONFIG_DEFINITIONS[doc.key as ConfigKey] : undefined, - value: doc.value != null ? (() => { - try { - return JSON.parse(doc.value); - } - catch { - return null; - } - })() : null, + definition: + doc.key in CONFIG_DEFINITIONS + ? CONFIG_DEFINITIONS[doc.key as ConfigKey] + : undefined, + value: + doc.value != null + ? (() => { + try { + return JSON.parse(doc.value); + } catch { + return null; + } + })() + : null, }; } @@ -70,13 +77,11 @@ export class ConfigLoader implements IConfigLoader { case 'object': try { return JSON.parse(value); - } - catch { + } catch { return null; } default: return value; } } - } diff --git a/apps/app/src/server/service/config-manager/config-manager.integ.ts b/apps/app/src/server/service/config-manager/config-manager.integ.ts index 45ad3d3b1c1..fe66bd5075a 100644 --- a/apps/app/src/server/service/config-manager/config-manager.integ.ts +++ b/apps/app/src/server/service/config-manager/config-manager.integ.ts @@ -1,31 +1,26 @@ import { GrowiDeploymentType, GrowiServiceType } from '@growi/core/dist/consts'; import { mock } from 'vitest-mock-extended'; - import { Config } from '../../models/config'; import type { S2sMessagingService } from '../s2s-messaging/base'; - import { configManager } from './config-manager'; describe('ConfigManager', () => { - const s2sMessagingServiceMock = mock(); - beforeAll(async() => { + beforeAll(async () => { configManager.setS2sMessagingService(s2sMessagingServiceMock); }); - describe("getConfig('app:siteUrl')", () => { - - beforeEach(async() => { + beforeEach(async () => { process.env.APP_SITE_URL = 'http://localhost:3000'; // remove config from DB await Config.deleteOne({ key: 'app:siteUrl' }).exec(); }); - test('returns the env value"', async() => { + test('returns the env value"', async () => { // arrange await configManager.loadConfigs(); @@ -36,9 +31,12 @@ describe('ConfigManager', () => { expect(value).toEqual('http://localhost:3000'); }); - test('returns the db value"', async() => { + test('returns the db value"', async () => { // arrange - await Config.create({ key: 'app:siteUrl', value: JSON.stringify('https://example.com') }); + await Config.create({ + key: 'app:siteUrl', + value: JSON.stringify('https://example.com'), + }); await configManager.loadConfigs(); // act @@ -48,10 +46,13 @@ describe('ConfigManager', () => { expect(value).toStrictEqual('https://example.com'); }); - test('returns the env value when USES_ONLY_ENV_OPTION is set', async() => { + test('returns the env value when USES_ONLY_ENV_OPTION is set', async () => { // arrange process.env.APP_SITE_URL_USES_ONLY_ENV_VARS = 'true'; - await Config.create({ key: 'app:siteUrl', value: JSON.stringify('https://example.com') }); + await Config.create({ + key: 'app:siteUrl', + value: JSON.stringify('https://example.com'), + }); await configManager.loadConfigs(); // act @@ -60,17 +61,17 @@ describe('ConfigManager', () => { // assert expect(value).toEqual('http://localhost:3000'); }); - }); describe("getConfig('security:passport-saml:isEnabled')", () => { - - beforeEach(async() => { + beforeEach(async () => { // remove config from DB - await Config.deleteOne({ key: 'security:passport-saml:isEnabled' }).exec(); + await Config.deleteOne({ + key: 'security:passport-saml:isEnabled', + }).exec(); }); - test('returns the default value"', async() => { + test('returns the default value"', async () => { // arrange await configManager.loadConfigs(); @@ -81,7 +82,7 @@ describe('ConfigManager', () => { expect(value).toStrictEqual(false); }); - test('returns the env value"', async() => { + test('returns the env value"', async () => { // arrange process.env.SAML_ENABLED = 'true'; await configManager.loadConfigs(); @@ -93,10 +94,13 @@ describe('ConfigManager', () => { expect(value).toStrictEqual(true); }); - test('returns the preferred db value"', async() => { + test('returns the preferred db value"', async () => { // arrange process.env.SAML_ENABLED = 'true'; - await Config.create({ key: 'security:passport-saml:isEnabled', value: false }); + await Config.create({ + key: 'security:passport-saml:isEnabled', + value: false, + }); await configManager.loadConfigs(); // act @@ -108,12 +112,15 @@ describe('ConfigManager', () => { }); describe('updateConfig', () => { - beforeEach(async() => { + beforeEach(async () => { await Config.deleteMany({ key: /app.*/ }).exec(); - await Config.create({ key: 'app:siteUrl', value: JSON.stringify('initial value') }); + await Config.create({ + key: 'app:siteUrl', + value: JSON.stringify('initial value'), + }); }); - test('updates a single config', async() => { + test('updates a single config', async () => { // arrange await configManager.loadConfigs(); const config = await Config.findOne({ key: 'app:siteUrl' }).exec(); @@ -127,21 +134,23 @@ describe('ConfigManager', () => { expect(updatedConfig?.value).toEqual(JSON.stringify('updated value')); }); - test('removes config when value is undefined and removeIfUndefined is true', async() => { + test('removes config when value is undefined and removeIfUndefined is true', async () => { // arrange await configManager.loadConfigs(); const config = await Config.findOne({ key: 'app:siteUrl' }).exec(); expect(config?.value).toEqual(JSON.stringify('initial value')); // act - await configManager.updateConfig('app:siteUrl', undefined, { removeIfUndefined: true }); + await configManager.updateConfig('app:siteUrl', undefined, { + removeIfUndefined: true, + }); // assert const updatedConfig = await Config.findOne({ key: 'app:siteUrl' }).exec(); expect(updatedConfig).toBeNull(); // should be removed }); - test('does not update config when value is undefined and removeIfUndefined is false', async() => { + test('does not update config when value is undefined and removeIfUndefined is false', async () => { // arrange await configManager.loadConfigs(); const config = await Config.findOne({ key: 'app:siteUrl' }).exec(); @@ -157,16 +166,21 @@ describe('ConfigManager', () => { }); describe('updateConfigs', () => { - beforeEach(async() => { + beforeEach(async () => { await Config.deleteMany({ key: /app.*/ }).exec(); - await Config.create({ key: 'app:siteUrl', value: JSON.stringify('value1') }); + await Config.create({ + key: 'app:siteUrl', + value: JSON.stringify('value1'), + }); }); - test('updates configs in the same namespace', async() => { + test('updates configs in the same namespace', async () => { // arrange await configManager.loadConfigs(); const config1 = await Config.findOne({ key: 'app:siteUrl' }).exec(); - const config2 = await Config.findOne({ key: 'app:fileUploadType' }).exec(); + const config2 = await Config.findOne({ + key: 'app:fileUploadType', + }).exec(); expect(config1?.value).toEqual(JSON.stringify('value1')); expect(config2).toBeNull(); @@ -175,34 +189,45 @@ describe('ConfigManager', () => { 'app:siteUrl': 'new value1', 'app:fileUploadType': 'aws', }); - const updatedConfig1 = await Config.findOne({ key: 'app:siteUrl' }).exec(); - const updatedConfig2 = await Config.findOne({ key: 'app:fileUploadType' }).exec(); + const updatedConfig1 = await Config.findOne({ + key: 'app:siteUrl', + }).exec(); + const updatedConfig2 = await Config.findOne({ + key: 'app:fileUploadType', + }).exec(); // assert expect(updatedConfig1?.value).toEqual(JSON.stringify('new value1')); expect(updatedConfig2?.value).toEqual(JSON.stringify('aws')); }); - test('removes config when value is undefined and removeIfUndefined is true', async() => { + test('removes config when value is undefined and removeIfUndefined is true', async () => { // arrange await configManager.loadConfigs(); const config1 = await Config.findOne({ key: 'app:siteUrl' }).exec(); expect(config1?.value).toEqual(JSON.stringify('value1')); // act - await configManager.updateConfigs({ - 'app:siteUrl': undefined, - 'app:fileUploadType': 'aws', - }, { removeIfUndefined: true }); + await configManager.updateConfigs( + { + 'app:siteUrl': undefined, + 'app:fileUploadType': 'aws', + }, + { removeIfUndefined: true }, + ); // assert - const updatedConfig1 = await Config.findOne({ key: 'app:siteUrl' }).exec(); - const updatedConfig2 = await Config.findOne({ key: 'app:fileUploadType' }).exec(); + const updatedConfig1 = await Config.findOne({ + key: 'app:siteUrl', + }).exec(); + const updatedConfig2 = await Config.findOne({ + key: 'app:fileUploadType', + }).exec(); expect(updatedConfig1).toBeNull(); // should be removed expect(updatedConfig2?.value).toEqual(JSON.stringify('aws')); }); - test('does not update config when value is undefined and removeIfUndefined is false', async() => { + test('does not update config when value is undefined and removeIfUndefined is false', async () => { // arrange await configManager.loadConfigs(); const config1 = await Config.findOne({ key: 'app:siteUrl' }).exec(); @@ -215,37 +240,59 @@ describe('ConfigManager', () => { }); // assert - const updatedConfig1 = await Config.findOne({ key: 'app:siteUrl' }).exec(); - const updatedConfig2 = await Config.findOne({ key: 'app:fileUploadType' }).exec(); + const updatedConfig1 = await Config.findOne({ + key: 'app:siteUrl', + }).exec(); + const updatedConfig2 = await Config.findOne({ + key: 'app:fileUploadType', + }).exec(); expect(updatedConfig1?.value).toEqual(JSON.stringify('value1')); // should remain unchanged expect(updatedConfig2?.value).toEqual(JSON.stringify('aws')); }); }); describe('removeConfigs', () => { - beforeEach(async() => { + beforeEach(async () => { await Config.deleteMany({ key: /app.*/ }).exec(); - await Config.create({ key: 'app:serviceType', value: JSON.stringify(GrowiServiceType.onPremise) }); - await Config.create({ key: 'app:deploymentType', value: JSON.stringify(GrowiDeploymentType.growiDockerCompose) }); + await Config.create({ + key: 'app:serviceType', + value: JSON.stringify(GrowiServiceType.onPremise), + }); + await Config.create({ + key: 'app:deploymentType', + value: JSON.stringify(GrowiDeploymentType.growiDockerCompose), + }); }); - test('removes configs in the same namespace', async() => { + test('removes configs in the same namespace', async () => { // arrange await configManager.loadConfigs(); const config3 = await Config.findOne({ key: 'app:serviceType' }).exec(); - const config4 = await Config.findOne({ key: 'app:deploymentType' }).exec(); - expect(config3?.value).toEqual(JSON.stringify(GrowiServiceType.onPremise)); - expect(config4?.value).toEqual(JSON.stringify(GrowiDeploymentType.growiDockerCompose)); + const config4 = await Config.findOne({ + key: 'app:deploymentType', + }).exec(); + expect(config3?.value).toEqual( + JSON.stringify(GrowiServiceType.onPremise), + ); + expect(config4?.value).toEqual( + JSON.stringify(GrowiDeploymentType.growiDockerCompose), + ); // act - await configManager.removeConfigs(['app:serviceType', 'app:deploymentType']); - const removedConfig3 = await Config.findOne({ key: 'app:serviceType' }).exec(); - const removedConfig4 = await Config.findOne({ key: 'app:deploymentType' }).exec(); + await configManager.removeConfigs([ + 'app:serviceType', + 'app:deploymentType', + ]); + const removedConfig3 = await Config.findOne({ + key: 'app:serviceType', + }).exec(); + const removedConfig4 = await Config.findOne({ + key: 'app:deploymentType', + }).exec(); // assert expect(removedConfig3).toBeNull(); expect(removedConfig4).toBeNull(); }); }); - }); diff --git a/apps/app/src/server/service/config-manager/config-manager.spec.ts b/apps/app/src/server/service/config-manager/config-manager.spec.ts index 319ec50b463..27a2a505e9d 100644 --- a/apps/app/src/server/service/config-manager/config-manager.spec.ts +++ b/apps/app/src/server/service/config-manager/config-manager.spec.ts @@ -2,7 +2,6 @@ import type { RawConfigData } from '@growi/core/dist/interfaces'; import { mock } from 'vitest-mock-extended'; import type { S2sMessagingService } from '../s2s-messaging/base'; - import type { ConfigKey, ConfigValues } from './config-definition'; import { configManager } from './config-manager'; @@ -20,36 +19,34 @@ vi.mock('../../models/config', () => ({ Config: mocks.ConfigMock, })); - type ConfigManagerToGetLoader = { configLoader: { loadFromDB: () => void }; -} - +}; describe('ConfigManager test', () => { - const s2sMessagingServiceMock = mock(); - beforeAll(async() => { + beforeAll(async () => { process.env.CONFIG_PUBSUB_SERVER_TYPE = 'nchan'; configManager.setS2sMessagingService(s2sMessagingServiceMock); }); - describe('updateConfig()', () => { - let loadConfigsSpy; - beforeEach(async() => { + beforeEach(async () => { loadConfigsSpy = vi.spyOn(configManager, 'loadConfigs'); // Reset mocks mocks.ConfigMock.updateOne.mockClear(); mocks.ConfigMock.deleteOne.mockClear(); }); - test('invoke publishUpdateMessage()', async() => { + test('invoke publishUpdateMessage()', async () => { // arrenge configManager.publishUpdateMessage = vi.fn(); - vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn()); + vi.spyOn( + (configManager as unknown as ConfigManagerToGetLoader).configLoader, + 'loadFromDB', + ).mockImplementation(vi.fn()); // act await configManager.updateConfig('app:siteUrl', ''); @@ -60,10 +57,13 @@ describe('ConfigManager test', () => { expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1); }); - test('skip publishUpdateMessage()', async() => { + test('skip publishUpdateMessage()', async () => { // arrenge configManager.publishUpdateMessage = vi.fn(); - vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn()); + vi.spyOn( + (configManager as unknown as ConfigManagerToGetLoader).configLoader, + 'loadFromDB', + ).mockImplementation(vi.fn()); // act await configManager.updateConfig('app:siteUrl', '', { skipPubsub: true }); @@ -74,26 +74,36 @@ describe('ConfigManager test', () => { expect(configManager.publishUpdateMessage).not.toHaveBeenCalled(); }); - test('remove config when value is undefined and removeIfUndefined is true', async() => { + test('remove config when value is undefined and removeIfUndefined is true', async () => { // arrange configManager.publishUpdateMessage = vi.fn(); - vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn()); + vi.spyOn( + (configManager as unknown as ConfigManagerToGetLoader).configLoader, + 'loadFromDB', + ).mockImplementation(vi.fn()); // act - await configManager.updateConfig('app:siteUrl', undefined, { removeIfUndefined: true }); + await configManager.updateConfig('app:siteUrl', undefined, { + removeIfUndefined: true, + }); // assert expect(mocks.ConfigMock.deleteOne).toHaveBeenCalledTimes(1); - expect(mocks.ConfigMock.deleteOne).toHaveBeenCalledWith({ key: 'app:siteUrl' }); + expect(mocks.ConfigMock.deleteOne).toHaveBeenCalledWith({ + key: 'app:siteUrl', + }); expect(mocks.ConfigMock.updateOne).not.toHaveBeenCalled(); expect(loadConfigsSpy).toHaveBeenCalledTimes(1); expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1); }); - test('update config with undefined value when removeIfUndefined is false', async() => { + test('update config with undefined value when removeIfUndefined is false', async () => { // arrange configManager.publishUpdateMessage = vi.fn(); - vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn()); + vi.spyOn( + (configManager as unknown as ConfigManagerToGetLoader).configLoader, + 'loadFromDB', + ).mockImplementation(vi.fn()); // act await configManager.updateConfig('app:siteUrl', undefined); @@ -109,25 +119,28 @@ describe('ConfigManager test', () => { expect(loadConfigsSpy).toHaveBeenCalledTimes(1); expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1); }); - }); describe('updateConfigs()', () => { - let loadConfigsSpy; - beforeEach(async() => { + beforeEach(async () => { loadConfigsSpy = vi.spyOn(configManager, 'loadConfigs'); // Reset mocks mocks.ConfigMock.bulkWrite.mockClear(); }); - test('invoke publishUpdateMessage()', async() => { + test('invoke publishUpdateMessage()', async () => { // arrange configManager.publishUpdateMessage = vi.fn(); - vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn()); + vi.spyOn( + (configManager as unknown as ConfigManagerToGetLoader).configLoader, + 'loadFromDB', + ).mockImplementation(vi.fn()); // act - await configManager.updateConfigs({ 'app:siteUrl': 'https://example.com' }); + await configManager.updateConfigs({ + 'app:siteUrl': 'https://example.com', + }); // assert expect(mocks.ConfigMock.bulkWrite).toHaveBeenCalledTimes(1); @@ -135,13 +148,19 @@ describe('ConfigManager test', () => { expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1); }); - test('skip publishUpdateMessage()', async() => { + test('skip publishUpdateMessage()', async () => { // arrange configManager.publishUpdateMessage = vi.fn(); - vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn()); + vi.spyOn( + (configManager as unknown as ConfigManagerToGetLoader).configLoader, + 'loadFromDB', + ).mockImplementation(vi.fn()); // act - await configManager.updateConfigs({ 'app:siteUrl': '' }, { skipPubsub: true }); + await configManager.updateConfigs( + { 'app:siteUrl': '' }, + { skipPubsub: true }, + ); // assert expect(mocks.ConfigMock.bulkWrite).toHaveBeenCalledTimes(1); @@ -149,10 +168,13 @@ describe('ConfigManager test', () => { expect(configManager.publishUpdateMessage).not.toHaveBeenCalled(); }); - test('remove configs when values are undefined and removeIfUndefined is true', async() => { + test('remove configs when values are undefined and removeIfUndefined is true', async () => { // arrange configManager.publishUpdateMessage = vi.fn(); - vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn()); + vi.spyOn( + (configManager as unknown as ConfigManagerToGetLoader).configLoader, + 'loadFromDB', + ).mockImplementation(vi.fn()); // act await configManager.updateConfigs( @@ -164,7 +186,9 @@ describe('ConfigManager test', () => { expect(mocks.ConfigMock.bulkWrite).toHaveBeenCalledTimes(1); const operations = mocks.ConfigMock.bulkWrite.mock.calls[0][0]; expect(operations).toHaveLength(2); - expect(operations[0]).toEqual({ deleteOne: { filter: { key: 'app:siteUrl' } } }); + expect(operations[0]).toEqual({ + deleteOne: { filter: { key: 'app:siteUrl' } }, + }); expect(operations[1]).toEqual({ updateOne: { filter: { key: 'app:title' }, @@ -176,13 +200,19 @@ describe('ConfigManager test', () => { expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1); }); - test('update configs including undefined values when removeIfUndefined is false', async() => { + test('update configs including undefined values when removeIfUndefined is false', async () => { // arrange configManager.publishUpdateMessage = vi.fn(); - vi.spyOn((configManager as unknown as ConfigManagerToGetLoader).configLoader, 'loadFromDB').mockImplementation(vi.fn()); + vi.spyOn( + (configManager as unknown as ConfigManagerToGetLoader).configLoader, + 'loadFromDB', + ).mockImplementation(vi.fn()); // act - await configManager.updateConfigs({ 'app:siteUrl': undefined, 'app:title': 'GROWI' }); + await configManager.updateConfigs({ + 'app:siteUrl': undefined, + 'app:title': 'GROWI', + }); // assert expect(mocks.ConfigMock.bulkWrite).toHaveBeenCalledTimes(1); @@ -205,11 +235,10 @@ describe('ConfigManager test', () => { expect(loadConfigsSpy).toHaveBeenCalledTimes(1); expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1); }); - }); describe('getManagedEnvVars()', () => { - beforeAll(async() => { + beforeAll(async () => { process.env.AUTO_INSTALL_ADMIN_USERNAME = 'admin'; process.env.AUTO_INSTALL_ADMIN_PASSWORD = 'password'; @@ -237,19 +266,22 @@ describe('ConfigManager test', () => { describe('getConfig()', () => { // Helper function to set configs with proper typing - const setTestConfigs = (dbConfig: Partial, envConfig: Partial): void => { + const setTestConfigs = ( + dbConfig: Partial, + envConfig: Partial, + ): void => { Object.defineProperties(configManager, { dbConfig: { value: dbConfig, configurable: true }, envConfig: { value: envConfig, configurable: true }, }); }; - beforeEach(async() => { + beforeEach(async () => { // Reset configs before each test using properly typed empty objects setTestConfigs({}, {}); }); - test('should fallback to env value when dbConfig[key] exists but its value is undefined', async() => { + test('should fallback to env value when dbConfig[key] exists but its value is undefined', async () => { // Prepare test data that simulates the issue with proper typing const dbConfig: Partial = { 'app:title': { value: undefined }, @@ -266,7 +298,7 @@ describe('ConfigManager test', () => { expect(result).toBe('GROWI'); }); - test('should handle various edge case scenarios correctly', async() => { + test('should handle various edge case scenarios correctly', async () => { // Setup multiple test scenarios with proper typing const dbConfig: Partial = { 'app:title': { value: undefined }, // db value is explicitly undefined @@ -287,10 +319,11 @@ describe('ConfigManager test', () => { // Test each scenario expect(configManager.getConfig('app:title')).toBe('GROWI'); // Should fallback to env when db value is undefined - expect(configManager.getConfig('app:siteUrl')).toBe('https://example.com'); // Should fallback to env when db value is undefined + expect(configManager.getConfig('app:siteUrl')).toBe( + 'https://example.com', + ); // Should fallback to env when db value is undefined expect(configManager.getConfig('app:fileUpload')).toBe(true); // Should fallback to env when db config is undefined expect(configManager.getConfig('app:fileUploadType')).toBe('gridfs'); // Should use db value when valid }); }); - }); diff --git a/apps/app/src/server/service/config-manager/config-manager.ts b/apps/app/src/server/service/config-manager/config-manager.ts index 6e258a7625f..0664d59c2c7 100644 --- a/apps/app/src/server/service/config-manager/config-manager.ts +++ b/apps/app/src/server/service/config-manager/config-manager.ts @@ -1,4 +1,8 @@ -import type { IConfigManager, UpdateConfigOptions, RawConfigData } from '@growi/core/dist/interfaces'; +import type { + IConfigManager, + RawConfigData, + UpdateConfigOptions, +} from '@growi/core/dist/interfaces'; import { ConfigSource } from '@growi/core/dist/interfaces'; import { parseISO } from 'date-fns/parseISO'; @@ -7,17 +11,17 @@ import loggerFactory from '~/utils/logger'; import type S2sMessage from '../../models/vo/s2s-message'; import type { S2sMessagingService } from '../s2s-messaging/base'; import type { S2sMessageHandlable } from '../s2s-messaging/handlable'; - import type { ConfigKey, ConfigValues } from './config-definition'; import { ENV_ONLY_GROUPS } from './config-definition'; import { ConfigLoader } from './config-loader'; const logger = loggerFactory('growi:service:ConfigManager'); -export type IConfigManagerForApp = IConfigManager - -export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable { +export type IConfigManagerForApp = IConfigManager; +export class ConfigManager + implements IConfigManagerForApp, S2sMessageHandlable +{ private configLoader: ConfigLoader; private s2sMessagingService?: S2sMessagingService; @@ -48,11 +52,9 @@ export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable async loadConfigs(options?: { source?: ConfigSource }): Promise { if (options?.source === 'env') { this.envConfig = await this.configLoader.loadFromEnv(); - } - else if (options?.source === 'db') { + } else if (options?.source === 'db') { this.dbConfig = await this.configLoader.loadFromDB(); - } - else { + } else { this.envConfig = await this.configLoader.loadFromEnv(); this.dbConfig = await this.configLoader.loadFromDB(); } @@ -60,7 +62,10 @@ export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable this.lastLoadedAt = new Date(); } - getConfig(key: K, source?: ConfigSource): ConfigValues[K] { + getConfig( + key: K, + source?: ConfigSource, + ): ConfigValues[K] { if (source === ConfigSource.env) { if (!this.envConfig) { throw new Error('Config is not loaded'); @@ -81,7 +86,7 @@ export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable return ( this.shouldUseEnvOnly(key) ? this.envConfig[key]?.value - : this.dbConfig[key]?.value ?? this.envConfig[key]?.value + : (this.dbConfig[key]?.value ?? this.envConfig[key]?.value) ) as ConfigValues[K]; } @@ -107,15 +112,18 @@ export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable return this.envConfig[controlKey].value === true; } - async updateConfig(key: K, value: ConfigValues[K], options?: UpdateConfigOptions): Promise { + async updateConfig( + key: K, + value: ConfigValues[K], + options?: UpdateConfigOptions, + ): Promise { // Dynamic import to avoid loading database modules too early const { Config } = await import('../../models/config'); if (options?.removeIfUndefined && value === undefined) { // remove the config if the value is undefined and removeIfUndefined is true await Config.deleteOne({ key }); - } - else { + } else { await Config.updateOne( { key }, { value: JSON.stringify(value) }, @@ -130,22 +138,25 @@ export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable } } - async updateConfigs(updates: Partial<{ [K in ConfigKey]: ConfigValues[K] }>, options?: UpdateConfigOptions): Promise { + async updateConfigs( + updates: Partial<{ [K in ConfigKey]: ConfigValues[K] }>, + options?: UpdateConfigOptions, + ): Promise { // Dynamic import to avoid loading database modules too early const { Config } = await import('../../models/config'); const operations = Object.entries(updates).map(([key, value]) => { - return (options?.removeIfUndefined && value === undefined) - // remove the config if the value is undefined - ? { deleteOne: { filter: { key } } } - // update - : { - updateOne: { - filter: { key }, - update: { value: JSON.stringify(value) }, - upsert: true, - }, - }; + return options?.removeIfUndefined && value === undefined + ? // remove the config if the value is undefined + { deleteOne: { filter: { key } } } + : // update + { + updateOne: { + filter: { key }, + update: { value: JSON.stringify(value) }, + upsert: true, + }, + }; }); await Config.bulkWrite(operations); @@ -156,11 +167,14 @@ export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable } } - async removeConfigs(keys: ConfigKey[], options?: UpdateConfigOptions): Promise { + async removeConfigs( + keys: ConfigKey[], + options?: UpdateConfigOptions, + ): Promise { // Dynamic import to avoid loading database modules too early const { Config } = await import('../../models/config'); - const operations = keys.map(key => ({ + const operations = keys.map((key) => ({ deleteOne: { filter: { key }, }, @@ -214,12 +228,16 @@ export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable async publishUpdateMessage(): Promise { const { default: S2sMessage } = await import('../../models/vo/s2s-message'); - const s2sMessage = new S2sMessage('configUpdated', { updatedAt: new Date() }); + const s2sMessage = new S2sMessage('configUpdated', { + updatedAt: new Date(), + }); try { await this.s2sMessagingService?.publish(s2sMessage); - } - catch (e) { - logger.error('Failed to publish update message with S2sMessagingService: ', e.message); + } catch (e) { + logger.error( + 'Failed to publish update message with S2sMessagingService: ', + e.message, + ); } } @@ -231,9 +249,12 @@ export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable if (eventName !== 'configUpdated') { return false; } - return this.lastLoadedAt == null // loaded for the first time - || !('updatedAt' in s2sMessage) // updatedAt is not included in the message - || (typeof s2sMessage.updatedAt === 'string' && this.lastLoadedAt < parseISO(s2sMessage.updatedAt)); + return ( + this.lastLoadedAt == null || // loaded for the first time + !('updatedAt' in s2sMessage) || // updatedAt is not included in the message + (typeof s2sMessage.updatedAt === 'string' && + this.lastLoadedAt < parseISO(s2sMessage.updatedAt)) + ); } /** @@ -243,7 +264,6 @@ export class ConfigManager implements IConfigManagerForApp, S2sMessageHandlable logger.info('Reload configs by pubsub notification'); return this.loadConfigs(); } - } // Export singleton instance diff --git a/apps/app/src/server/service/page-listing/page-listing.integ.ts b/apps/app/src/server/service/page-listing/page-listing.integ.ts index 08fd4ace008..c394a2ef238 100644 --- a/apps/app/src/server/service/page-listing/page-listing.integ.ts +++ b/apps/app/src/server/service/page-listing/page-listing.integ.ts @@ -1,9 +1,9 @@ import type { IPage, IUser } from '@growi/core/dist/interfaces'; import { isValidObjectId } from '@growi/core/dist/utils/objectid-utils'; -import mongoose from 'mongoose'; import type { HydratedDocument, Model } from 'mongoose'; +import mongoose from 'mongoose'; -import { PageActionType, PageActionStage } from '~/interfaces/page-operation'; +import { PageActionStage, PageActionType } from '~/interfaces/page-operation'; import type { PageModel } from '~/server/models/page'; import type { IPageOperation } from '~/server/models/page-operation'; @@ -56,7 +56,7 @@ describe('page-listing store integration tests', () => { } }; - beforeAll(async() => { + beforeAll(async () => { // setup models const setupPage = (await import('~/server/models/page')).default; setupPage(null); @@ -69,7 +69,7 @@ describe('page-listing store integration tests', () => { PageOperation = (await import('~/server/models/page-operation')).default; }); - beforeEach(async() => { + beforeEach(async () => { // Clean up database await Page.deleteMany({}); await User.deleteMany({}); @@ -96,8 +96,9 @@ describe('page-listing store integration tests', () => { }); describe('pageListingService.findRootByViewer', () => { - test('should return root page successfully', async() => { - const rootPageResult = await pageListingService.findRootByViewer(testUser); + test('should return root page successfully', async () => { + const rootPageResult = + await pageListingService.findRootByViewer(testUser); expect(rootPageResult).toBeDefined(); expect(rootPageResult.path).toBe('/'); @@ -107,7 +108,7 @@ describe('page-listing store integration tests', () => { expect(rootPageResult.descendantCount).toBe(0); }); - test('should handle error when root page does not exist', async() => { + test('should handle error when root page does not exist', async () => { // Remove the root page await Page.deleteOne({ path: '/' }); @@ -115,14 +116,14 @@ describe('page-listing store integration tests', () => { await pageListingService.findRootByViewer(testUser); // Should not reach here expect(true).toBe(false); - } - catch (error) { + } catch (error) { expect(error).toBeDefined(); } }); - test('should return proper page structure that matches IPageForTreeItem type', async() => { - const rootPageResult = await pageListingService.findRootByViewer(testUser); + test('should return proper page structure that matches IPageForTreeItem type', async () => { + const rootPageResult = + await pageListingService.findRootByViewer(testUser); // Use helper function to validate type structure validatePageForTreeItem(rootPageResult); @@ -134,7 +135,7 @@ describe('page-listing store integration tests', () => { expect(rootPageResult.parent).toBeNull(); // Root page has no parent }); - test('should work without user (guest access) and return type-safe result', async() => { + test('should work without user (guest access) and return type-safe result', async () => { const rootPageResult = await pageListingService.findRootByViewer(); validatePageForTreeItem(rootPageResult); @@ -146,7 +147,7 @@ describe('page-listing store integration tests', () => { describe('pageListingService.findChildrenByParentPathOrIdAndViewer', () => { let childPage1: HydratedDocument; - beforeEach(async() => { + beforeEach(async () => { // Create child pages childPage1 = await Page.create({ path: '/child1', @@ -183,14 +184,15 @@ describe('page-listing store integration tests', () => { }); // Update root page descendant count - await Page.updateOne( - { _id: rootPage._id }, - { descendantCount: 2 }, - ); + await Page.updateOne({ _id: rootPage._id }, { descendantCount: 2 }); }); - test('should find children by parent path and return type-safe results', async() => { - const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser); + test('should find children by parent path and return type-safe results', async () => { + const children = + await pageListingService.findChildrenByParentPathOrIdAndViewer( + '/', + testUser, + ); expect(children).toHaveLength(2); children.forEach((child) => { @@ -200,8 +202,12 @@ describe('page-listing store integration tests', () => { }); }); - test('should find children by parent ID and return type-safe results', async() => { - const children = await pageListingService.findChildrenByParentPathOrIdAndViewer(rootPage._id.toString(), testUser); + test('should find children by parent ID and return type-safe results', async () => { + const children = + await pageListingService.findChildrenByParentPathOrIdAndViewer( + rootPage._id.toString(), + testUser, + ); expect(children).toHaveLength(2); children.forEach((child) => { @@ -210,8 +216,12 @@ describe('page-listing store integration tests', () => { }); }); - test('should handle nested children correctly', async() => { - const nestedChildren = await pageListingService.findChildrenByParentPathOrIdAndViewer('/child1', testUser); + test('should handle nested children correctly', async () => { + const nestedChildren = + await pageListingService.findChildrenByParentPathOrIdAndViewer( + '/child1', + testUser, + ); expect(nestedChildren).toHaveLength(1); const grandChild = nestedChildren[0]; @@ -220,15 +230,20 @@ describe('page-listing store integration tests', () => { expect(grandChild.parent?.toString()).toBe(childPage1._id.toString()); }); - test('should return empty array when no children exist', async() => { - const noChildren = await pageListingService.findChildrenByParentPathOrIdAndViewer('/child2', testUser); + test('should return empty array when no children exist', async () => { + const noChildren = + await pageListingService.findChildrenByParentPathOrIdAndViewer( + '/child2', + testUser, + ); expect(noChildren).toHaveLength(0); expect(Array.isArray(noChildren)).toBe(true); }); - test('should work without user (guest access)', async() => { - const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/'); + test('should work without user (guest access)', async () => { + const children = + await pageListingService.findChildrenByParentPathOrIdAndViewer('/'); expect(children).toHaveLength(2); children.forEach((child) => { @@ -236,8 +251,12 @@ describe('page-listing store integration tests', () => { }); }); - test('should sort children by path in ascending order', async() => { - const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser); + test('should sort children by path in ascending order', async () => { + const children = + await pageListingService.findChildrenByParentPathOrIdAndViewer( + '/', + testUser, + ); expect(children).toHaveLength(2); expect(children[0].path).toBe('/child1'); @@ -248,7 +267,7 @@ describe('page-listing store integration tests', () => { describe('pageListingService processData injection', () => { let operatingPage: HydratedDocument; - beforeEach(async() => { + beforeEach(async () => { // Create a page that will have operations operatingPage = await Page.create({ path: '/operating-page', @@ -282,11 +301,17 @@ describe('page-listing store integration tests', () => { }); }); - test('should inject processData for pages with operations', async() => { - const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser); + test('should inject processData for pages with operations', async () => { + const children = + await pageListingService.findChildrenByParentPathOrIdAndViewer( + '/', + testUser, + ); // Find the operating page in results - const operatingResult = children.find(child => child.path === '/operating-page'); + const operatingResult = children.find( + (child) => child.path === '/operating-page', + ); expect(operatingResult).toBeDefined(); // Validate type structure @@ -299,7 +324,7 @@ describe('page-listing store integration tests', () => { } }); - test('should set processData to undefined for pages without operations', async() => { + test('should set processData to undefined for pages without operations', async () => { // Create another page without operations await Page.create({ path: '/normal-page', @@ -312,8 +337,14 @@ describe('page-listing store integration tests', () => { parent: rootPage._id, }); - const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser); - const normalPage = children.find(child => child.path === '/normal-page'); + const children = + await pageListingService.findChildrenByParentPathOrIdAndViewer( + '/', + testUser, + ); + const normalPage = children.find( + (child) => child.path === '/normal-page', + ); expect(normalPage).toBeDefined(); if (normalPage) { @@ -322,7 +353,7 @@ describe('page-listing store integration tests', () => { } }); - test('should maintain type safety with mixed processData scenarios', async() => { + test('should maintain type safety with mixed processData scenarios', async () => { // Create pages with and without operations await Page.create({ path: '/mixed-test-1', @@ -346,7 +377,11 @@ describe('page-listing store integration tests', () => { parent: rootPage._id, }); - const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser); + const children = + await pageListingService.findChildrenByParentPathOrIdAndViewer( + '/', + testUser, + ); // All results should be type-safe regardless of processData presence children.forEach((child) => { @@ -361,7 +396,7 @@ describe('page-listing store integration tests', () => { }); describe('PageQueryBuilder exec() type safety tests', () => { - test('findRootByViewer should return object with correct _id type', async() => { + test('findRootByViewer should return object with correct _id type', async () => { const result = await pageListingService.findRootByViewer(testUser); // PageQueryBuilder.exec() returns any, but we expect ObjectId-like behavior @@ -371,7 +406,7 @@ describe('page-listing store integration tests', () => { expect(result._id.toString().length).toBe(24); // MongoDB ObjectId string length }); - test('findChildrenByParentPathOrIdAndViewer should return array with correct _id types', async() => { + test('findChildrenByParentPathOrIdAndViewer should return array with correct _id types', async () => { // Create test child page first await Page.create({ path: '/test-child', @@ -384,7 +419,11 @@ describe('page-listing store integration tests', () => { parent: rootPage._id, }); - const results = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser); + const results = + await pageListingService.findChildrenByParentPathOrIdAndViewer( + '/', + testUser, + ); expect(Array.isArray(results)).toBe(true); results.forEach((result) => { @@ -402,6 +441,5 @@ describe('page-listing store integration tests', () => { } }); }); - }); }); diff --git a/apps/app/src/server/service/page-listing/page-listing.ts b/apps/app/src/server/service/page-listing/page-listing.ts index c89e53568f5..86f168c30e3 100644 --- a/apps/app/src/server/service/page-listing/page-listing.ts +++ b/apps/app/src/server/service/page-listing/page-listing.ts @@ -3,38 +3,48 @@ import { pagePathUtils } from '@growi/core/dist/utils'; import mongoose, { type HydratedDocument } from 'mongoose'; import type { IPageForTreeItem } from '~/interfaces/page'; -import { PageActionType, type IPageOperationProcessInfo, type IPageOperationProcessData } from '~/interfaces/page-operation'; -import { PageQueryBuilder, type PageDocument, type PageModel } from '~/server/models/page'; +import { + type IPageOperationProcessData, + type IPageOperationProcessInfo, + PageActionType, +} from '~/interfaces/page-operation'; +import { + type PageDocument, + type PageModel, + PageQueryBuilder, +} from '~/server/models/page'; import PageOperation from '~/server/models/page-operation'; import type { IPageOperationService } from '../page-operation'; const { hasSlash, generateChildrenRegExp } = pagePathUtils; - export interface IPageListingService { - findRootByViewer(user: IUser): Promise, + findRootByViewer(user: IUser): Promise; findChildrenByParentPathOrIdAndViewer( parentPathOrId: string, user?: IUser, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean, - ): Promise, + ): Promise; } let pageOperationService: IPageOperationService; async function getPageOperationServiceInstance(): Promise { if (pageOperationService == null) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - pageOperationService = await import('../page-operation').then(mod => mod.pageOperationService!); + pageOperationService = await import('../page-operation').then( + (mod) => mod.pageOperationService!, + ); } return pageOperationService; } class PageListingService implements IPageListingService { - async findRootByViewer(user?: IUser): Promise { - const Page = mongoose.model, PageModel>('Page'); + const Page = mongoose.model, PageModel>( + 'Page', + ); const builder = new PageQueryBuilder(Page.findOne({ path: '/' })); await builder.addViewerCondition(user); @@ -46,38 +56,56 @@ class PageListingService implements IPageListingService { } async findChildrenByParentPathOrIdAndViewer( - parentPathOrId: string, - user?: IUser, - showPagesRestrictedByOwner = false, - showPagesRestrictedByGroup = false, + parentPathOrId: string, + user?: IUser, + showPagesRestrictedByOwner = false, + showPagesRestrictedByGroup = false, ): Promise { - const Page = mongoose.model, PageModel>('Page'); + const Page = mongoose.model, PageModel>( + 'Page', + ); let queryBuilder: PageQueryBuilder; if (hasSlash(parentPathOrId)) { const path = parentPathOrId; const regexp = generateChildrenRegExp(path); - queryBuilder = new PageQueryBuilder(Page.find({ path: { $regex: regexp } }), true); - } - else { + queryBuilder = new PageQueryBuilder( + Page.find({ path: { $regex: regexp } }), + true, + ); + } else { const parentId = parentPathOrId; // Use $eq for user-controlled sources. see: https://codeql.github.com/codeql-query-help/javascript/js-sql-injection/#recommendation - queryBuilder = new PageQueryBuilder(Page.find({ parent: { $eq: parentId } }), true); + queryBuilder = new PageQueryBuilder( + Page.find({ parent: { $eq: parentId } }), + true, + ); } - await queryBuilder.addViewerCondition(user, null, undefined, showPagesRestrictedByOwner, showPagesRestrictedByGroup); - - const pages: HydratedDocument>[] = await queryBuilder - .addConditionToSortPagesByAscPath() - .query - .select('_id path parent revision descendantCount grant isEmpty wip') - .lean() - .exec(); - - const injectedPages = await this.injectProcessDataIntoPagesByActionTypes(pages, [PageActionType.Rename]); + await queryBuilder.addViewerCondition( + user, + null, + undefined, + showPagesRestrictedByOwner, + showPagesRestrictedByGroup, + ); + + const pages: HydratedDocument>[] = + await queryBuilder + .addConditionToSortPagesByAscPath() + .query.select( + '_id path parent revision descendantCount grant isEmpty wip', + ) + .lean() + .exec(); + + const injectedPages = await this.injectProcessDataIntoPagesByActionTypes( + pages, + [PageActionType.Rename], + ); // Type-safe conversion to IPageForTreeItem - return injectedPages.map(page => ( - Object.assign(page, { _id: page._id.toString() }) - )); + return injectedPages.map((page) => + Object.assign(page, { _id: page._id.toString() }), + ); } /** @@ -85,17 +113,23 @@ class PageListingService implements IPageListingService { * The processData is a combination of actionType as a key and information on whether the action is processable as a value. */ private async injectProcessDataIntoPagesByActionTypes( - pages: HydratedDocument[], - actionTypes: PageActionType[], - ): Promise<(HydratedDocument & { processData?: IPageOperationProcessData })[]> { - - const pageOperations = await PageOperation.find({ actionType: { $in: actionTypes } }); + pages: HydratedDocument[], + actionTypes: PageActionType[], + ): Promise< + (HydratedDocument & { processData?: IPageOperationProcessData })[] + > { + const pageOperations = await PageOperation.find({ + actionType: { $in: actionTypes }, + }); if (pageOperations == null || pageOperations.length === 0) { - return pages.map(page => Object.assign(page, { processData: undefined })); + return pages.map((page) => + Object.assign(page, { processData: undefined }), + ); } const pageOperationService = await getPageOperationServiceInstance(); - const processInfo: IPageOperationProcessInfo = pageOperationService.generateProcessInfo(pageOperations); + const processInfo: IPageOperationProcessInfo = + pageOperationService.generateProcessInfo(pageOperations); const operatingPageIds: string[] = Object.keys(processInfo); // inject processData into pages @@ -108,7 +142,6 @@ class PageListingService implements IPageListingService { return Object.assign(page, { processData: undefined }); }); } - } export const pageListingService = new PageListingService(); diff --git a/apps/app/src/server/service/revision/normalize-latest-revision-if-broken.integ.ts b/apps/app/src/server/service/revision/normalize-latest-revision-if-broken.integ.ts index 2d92913c030..540a4e02c82 100644 --- a/apps/app/src/server/service/revision/normalize-latest-revision-if-broken.integ.ts +++ b/apps/app/src/server/service/revision/normalize-latest-revision-if-broken.integ.ts @@ -9,14 +9,14 @@ import { Revision } from '~/server/models/revision'; import { normalizeLatestRevisionIfBroken } from './normalize-latest-revision-if-broken'; describe('normalizeLatestRevisionIfBroken', () => { - - beforeAll(async() => { + beforeAll(async () => { await PageModelFactory(null); }); - - test('should update the latest revision', async() => { - const Page = mongoose.model, PageModel>('Page'); + test('should update the latest revision', async () => { + const Page = mongoose.model, PageModel>( + 'Page', + ); // == Arrange const page = await Page.create({ path: '/foo' }); @@ -25,7 +25,10 @@ describe('normalizeLatestRevisionIfBroken', () => { page.revision = revision._id; await page.save(); // break the revision - await Revision.updateOne({ _id: revision._id }, { pageId: new Types.ObjectId() }); + await Revision.updateOne( + { _id: revision._id }, + { pageId: new Types.ObjectId() }, + ); // spy const updateOneSpy = vi.spyOn(Revision, 'updateOne'); @@ -48,10 +51,11 @@ describe('normalizeLatestRevisionIfBroken', () => { expect(getIdStringForRef(revisionById.pageId)).toEqual(page._id.toString()); }); - describe('should returns without any operation', () => { - test('when the page has revisions at least one', async() => { - const Page = mongoose.model, PageModel>('Page'); + test('when the page has revisions at least one', async () => { + const Page = mongoose.model, PageModel>( + 'Page', + ); // Arrange const page = await Page.create({ path: '/foo' }); @@ -66,7 +70,7 @@ describe('normalizeLatestRevisionIfBroken', () => { expect(updateOneSpy).not.toHaveBeenCalled(); }); - test('when the page is not found', async() => { + test('when the page is not found', async () => { // Arrange const pageIdOfRevision = new Types.ObjectId(); // create an orphan revision @@ -82,8 +86,10 @@ describe('normalizeLatestRevisionIfBroken', () => { expect(updateOneSpy).not.toHaveBeenCalled(); }); - test('when the page.revision is null', async() => { - const Page = mongoose.model, PageModel>('Page'); + test('when the page.revision is null', async () => { + const Page = mongoose.model, PageModel>( + 'Page', + ); // Arrange const page = await Page.create({ path: '/foo' }); @@ -100,12 +106,17 @@ describe('normalizeLatestRevisionIfBroken', () => { expect(updateOneSpy).not.toHaveBeenCalled(); }); - test('when the page.revision does not exist', async() => { - const Page = mongoose.model, PageModel>('Page'); + test('when the page.revision does not exist', async () => { + const Page = mongoose.model, PageModel>( + 'Page', + ); // Arrange const revisionNonExistent = new Types.ObjectId(); - const page = await Page.create({ path: '/foo', revision: revisionNonExistent }); + const page = await Page.create({ + path: '/foo', + revision: revisionNonExistent, + }); // create an orphan revision await Revision.create({ pageId: page._id, body: '' }); @@ -118,7 +129,5 @@ describe('normalizeLatestRevisionIfBroken', () => { // Assert expect(updateOneSpy).not.toHaveBeenCalled(); }); - }); - }); diff --git a/apps/app/src/server/service/revision/normalize-latest-revision-if-broken.ts b/apps/app/src/server/service/revision/normalize-latest-revision-if-broken.ts index fd19a2c0810..57e10a384e2 100644 --- a/apps/app/src/server/service/revision/normalize-latest-revision-if-broken.ts +++ b/apps/app/src/server/service/revision/normalize-latest-revision-if-broken.ts @@ -5,31 +5,47 @@ import type { PageDocument, PageModel } from '~/server/models/page'; import { Revision } from '~/server/models/revision'; import loggerFactory from '~/utils/logger'; - -const logger = loggerFactory('growi:service:revision:normalize-latest-revision'); +const logger = loggerFactory( + 'growi:service:revision:normalize-latest-revision', +); /** * Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js' * * @ref https://github.com/growilabs/growi/pull/8998 */ -export const normalizeLatestRevisionIfBroken = async(pageId: string | Types.ObjectId): Promise => { - +export const normalizeLatestRevisionIfBroken = async ( + pageId: string | Types.ObjectId, +): Promise => { if (await Revision.exists({ pageId: { $eq: pageId } })) { return; } - logger.info(`The page ('${pageId}') does not have any revisions. Normalization of the latest revision will be started.`); + logger.info( + `The page ('${pageId}') does not have any revisions. Normalization of the latest revision will be started.`, + ); - const Page = mongoose.model, PageModel>('Page'); - const page = await Page.findOne({ _id: { $eq: pageId } }, { revision: 1 }).exec(); + const Page = mongoose.model, PageModel>( + 'Page', + ); + const page = await Page.findOne( + { _id: { $eq: pageId } }, + { revision: 1 }, + ).exec(); if (page == null) { - logger.warn(`Normalization has been canceled since the page ('${pageId}') could not be found.`); + logger.warn( + `Normalization has been canceled since the page ('${pageId}') could not be found.`, + ); return; } - if (page.revision == null || !(await Revision.exists({ _id: page.revision }))) { - logger.warn(`Normalization has been canceled since the Page.revision of the page ('${pageId}') could not be found.`); + if ( + page.revision == null || + !(await Revision.exists({ _id: page.revision })) + ) { + logger.warn( + `Normalization has been canceled since the Page.revision of the page ('${pageId}') could not be found.`, + ); return; } diff --git a/apps/app/src/server/service/s2s-messaging/base.ts b/apps/app/src/server/service/s2s-messaging/base.ts index cf856087990..9a36ef288c2 100644 --- a/apps/app/src/server/service/s2s-messaging/base.ts +++ b/apps/app/src/server/service/s2s-messaging/base.ts @@ -9,7 +9,6 @@ import type { S2sMessageHandlable } from './handlable'; const logger = loggerFactory('growi:service:s2s-messaging:base'); export interface S2sMessagingService { - uid: number; uri: string; @@ -37,11 +36,11 @@ export interface S2sMessagingService { * @param handlable */ removeMessageHandler(handlable: S2sMessageHandlable): void; - } -export abstract class AbstractS2sMessagingService implements S2sMessagingService { - +export abstract class AbstractS2sMessagingService + implements S2sMessagingService +{ uid: number; uri: string; @@ -84,7 +83,6 @@ export abstract class AbstractS2sMessagingService implements S2sMessagingService * @param handlable */ removeMessageHandler(handlable: S2sMessageHandlable): void { - this.handlableList = this.handlableList.filter(h => h !== handlable); + this.handlableList = this.handlableList.filter((h) => h !== handlable); } - } diff --git a/apps/app/src/server/service/s2s-messaging/handlable.ts b/apps/app/src/server/service/s2s-messaging/handlable.ts index 46d1a387459..7574211f67b 100644 --- a/apps/app/src/server/service/s2s-messaging/handlable.ts +++ b/apps/app/src/server/service/s2s-messaging/handlable.ts @@ -2,9 +2,7 @@ * The interface to handle server-to-server message */ export interface S2sMessageHandlable { - shouldHandleS2sMessage(s2sMessage): boolean; handleS2sMessage(s2sMessage): Promise; - } diff --git a/apps/app/src/server/service/s2s-messaging/index.ts b/apps/app/src/server/service/s2s-messaging/index.ts index 772dd4b1a6d..b0dd3dd1a76 100644 --- a/apps/app/src/server/service/s2s-messaging/index.ts +++ b/apps/app/src/server/service/s2s-messaging/index.ts @@ -3,7 +3,9 @@ import loggerFactory from '~/utils/logger'; import type { S2sMessagingService } from './base'; -const logger = loggerFactory('growi:service:s2s-messaging:S2sMessagingServiceFactory'); +const logger = loggerFactory( + 'growi:service:s2s-messaging:S2sMessagingServiceFactory', +); const envToModuleMappings = { redis: 'redis', @@ -40,7 +42,6 @@ const envToModuleMappings = { * Instanciate server-to-server messaging service */ class S2sMessagingServiceFactory { - delegator!: S2sMessagingService; initializeDelegator(crowi: Crowi) { @@ -70,7 +71,6 @@ class S2sMessagingServiceFactory { } return this.delegator; } - } const factory = new S2sMessagingServiceFactory(); diff --git a/apps/app/src/server/service/s2s-messaging/nchan.ts b/apps/app/src/server/service/s2s-messaging/nchan.ts index 770c3f92ce4..22954ad208d 100644 --- a/apps/app/src/server/service/s2s-messaging/nchan.ts +++ b/apps/app/src/server/service/s2s-messaging/nchan.ts @@ -1,7 +1,6 @@ -import path from 'path'; - // biome-ignore lint/style/noRestrictedImports: Direct axios usage for external S2S messaging import axios from 'axios'; +import path from 'path'; import ReconnectingWebSocket from 'reconnecting-websocket'; import WebSocket from 'ws'; @@ -9,14 +8,11 @@ import type Crowi from '~/server/crowi'; import loggerFactory from '~/utils/logger'; import S2sMessage from '../../models/vo/s2s-message'; - import { AbstractS2sMessagingService } from './base'; const logger = loggerFactory('growi:service:s2s-messaging:nchan'); - class NchanDelegator extends AbstractS2sMessagingService { - /** * A list of S2sMessageHandlable instance */ @@ -24,7 +20,12 @@ class NchanDelegator extends AbstractS2sMessagingService { socket: any = null; - constructor(uri, private publishPath: string, private subscribePath: string, private channelId: any) { + constructor( + uri, + private publishPath: string, + private subscribePath: string, + private channelId: any, + ) { super(uri); } @@ -41,9 +42,10 @@ class NchanDelegator extends AbstractS2sMessagingService { subscribe(forceReconnect = false) { if (forceReconnect) { logger.info('Force reconnecting is requested. Try to reconnect...'); - } - else if (this.socket != null && this.shouldResubscribe()) { - logger.info('The connection to config pubsub server is offline. Try to reconnect...'); + } else if (this.socket != null && this.shouldResubscribe()) { + logger.info( + 'The connection to config pubsub server is offline. Try to reconnect...', + ); } // init client @@ -111,9 +113,10 @@ class NchanDelegator extends AbstractS2sMessagingService { } constructUrl(basepath) { - const pathname = this.channelId == null - ? basepath // /pubsub - : path.join(basepath, this.channelId); // /pubsub/my-channel-id + const pathname = + this.channelId == null + ? basepath // /pubsub + : path.join(basepath, this.channelId); // /pubsub/my-channel-id return new URL(pathname, this.uri); } @@ -138,7 +141,9 @@ class NchanDelegator extends AbstractS2sMessagingService { logger.info('WebSocket client connected.'); }); - this.handlableList.forEach(handlable => this.registerMessageHandlerToSocket(handlable)); + this.handlableList.forEach((handlable) => + this.registerMessageHandlerToSocket(handlable), + ); this.socket = socket; } @@ -157,26 +162,31 @@ class NchanDelegator extends AbstractS2sMessagingService { // check uid if (s2sMessage.publisherUid === this.uid) { - logger.debug(`Skip processing by ${handlable.constructor.name} because this message is sent by the publisher itself:`, `from ${this.uid}`); + logger.debug( + `Skip processing by ${handlable.constructor.name} because this message is sent by the publisher itself:`, + `from ${this.uid}`, + ); return; } // check shouldHandleS2sMessage const shouldHandle = handlable.shouldHandleS2sMessage(s2sMessage); - logger.debug(`${handlable.constructor.name}.shouldHandleS2sMessage(`, s2sMessage, `) => ${shouldHandle}`); + logger.debug( + `${handlable.constructor.name}.shouldHandleS2sMessage(`, + s2sMessage, + `) => ${shouldHandle}`, + ); if (shouldHandle) { handlable.handleS2sMessage(s2sMessage); } - } - catch (err) { + } catch (err) { logger.warn('Could not handle a message: ', err.message); } } - } -module.exports = function(crowi: Crowi) { +module.exports = (crowi: Crowi) => { const { configManager } = crowi; const uri = configManager.getConfig('app:nchanUri'); @@ -187,9 +197,15 @@ module.exports = function(crowi: Crowi) { return; } - const publishPath = configManager.getConfig('s2sMessagingPubsub:nchan:publishPath'); - const subscribePath = configManager.getConfig('s2sMessagingPubsub:nchan:subscribePath'); - const channelId = configManager.getConfig('s2sMessagingPubsub:nchan:channelId'); + const publishPath = configManager.getConfig( + 's2sMessagingPubsub:nchan:publishPath', + ); + const subscribePath = configManager.getConfig( + 's2sMessagingPubsub:nchan:subscribePath', + ); + const channelId = configManager.getConfig( + 's2sMessagingPubsub:nchan:channelId', + ); return new NchanDelegator(uri, publishPath, subscribePath, channelId); }; diff --git a/apps/app/src/server/service/s2s-messaging/redis.ts b/apps/app/src/server/service/s2s-messaging/redis.ts index 8bfdaa969da..187adb280e0 100644 --- a/apps/app/src/server/service/s2s-messaging/redis.ts +++ b/apps/app/src/server/service/s2s-messaging/redis.ts @@ -3,6 +3,6 @@ import loggerFactory from '~/utils/logger'; const logger = loggerFactory('growi:service:s2s-messaging:redis'); -module.exports = function(crowi: Crowi) { +module.exports = (crowi: Crowi) => { logger.warn('Config pub/sub with Redis has not implemented yet.'); }; diff --git a/apps/app/src/server/service/search-delegator/aggregate-to-index.ts b/apps/app/src/server/service/search-delegator/aggregate-to-index.ts index aed12dd0c5a..f08380d74e1 100644 --- a/apps/app/src/server/service/search-delegator/aggregate-to-index.ts +++ b/apps/app/src/server/service/search-delegator/aggregate-to-index.ts @@ -3,11 +3,11 @@ import type { PipelineStage, Query } from 'mongoose'; import type { PageModel } from '~/server/models/page'; -export const aggregatePipelineToIndex = (maxBodyLengthToIndex: number, query?: Query): PipelineStage[] => { - - const basePipeline = query == null - ? [] - : [{ $match: query.getQuery() }]; +export const aggregatePipelineToIndex = ( + maxBodyLengthToIndex: number, + query?: Query, +): PipelineStage[] => { + const basePipeline = query == null ? [] : [{ $match: query.getQuery() }]; return [ ...basePipeline, diff --git a/apps/app/src/server/service/search-delegator/bulk-write.d.ts b/apps/app/src/server/service/search-delegator/bulk-write.d.ts index 81e11fa7c73..3df30dc8fd9 100644 --- a/apps/app/src/server/service/search-delegator/bulk-write.d.ts +++ b/apps/app/src/server/service/search-delegator/bulk-write.d.ts @@ -1,7 +1,8 @@ import type { IPageHasId, PageGrant } from '@growi/core'; -export type AggregatedPage = Pick & { - revision: { body: string }, - comments: string[], - commentsCount: number, - bookmarksCount: number, - likeCount: number, - seenUsersCount: number, + revision: { body: string }; + comments: string[]; + commentsCount: number; + bookmarksCount: number; + likeCount: number; + seenUsersCount: number; creator?: { - username: string, - email: string, - }, + username: string; + email: string; + }; } & { - tagNames: string[], - revisionBodyEmbedded?: number[], + tagNames: string[]; + revisionBodyEmbedded?: number[]; }; export type BulkWriteCommand = { index: { - _index: string, - _type: '_doc' | undefined, - _id: string, - }, -} + _index: string; + _type: '_doc' | undefined; + _id: string; + }; +}; export type BulkWriteBodyRestriction = { - grant: PageGrant, - granted_users?: string[], - granted_groups: string[], -} + grant: PageGrant; + granted_users?: string[]; + granted_groups: string[]; +}; export type BulkWriteBody = { path: string; diff --git a/apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/es7-client-delegator.ts b/apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/es7-client-delegator.ts index d2924c4799b..f89d9841be3 100644 --- a/apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/es7-client-delegator.ts +++ b/apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/es7-client-delegator.ts @@ -1,16 +1,15 @@ // TODO: https://redmine.weseek.co.jp/issues/168446 import { + type ApiResponse, Client, type ClientOptions, - type ApiResponse, - type RequestParams, type estypes, + type RequestParams, } from '@elastic/elasticsearch7'; import type { ES7SearchQuery } from './interfaces'; export class ES7ClientDelegator { - private client: Client; delegatorVersion = 7 as const; @@ -25,53 +24,98 @@ export class ES7ClientDelegator { } cat = { - aliases: (params: RequestParams.CatAliases): Promise> => this.client.cat.aliases(params), - indices: (params: RequestParams.CatIndices): Promise> => this.client.cat.indices(params), + aliases: ( + params: RequestParams.CatAliases, + ): Promise> => + this.client.cat.aliases(params), + indices: ( + params: RequestParams.CatIndices, + ): Promise> => + this.client.cat.indices(params), }; cluster = { - health: (): Promise> => this.client.cluster.health(), + health: (): Promise> => + this.client.cluster.health(), }; indices = { - create: (params: RequestParams.IndicesCreate): Promise> => this.client.indices.create(params), - delete: (params: RequestParams.IndicesDelete): Promise> => this.client.indices.delete(params), - exists: async(params: RequestParams.IndicesExists): Promise => { + create: ( + params: RequestParams.IndicesCreate, + ): Promise> => + this.client.indices.create(params), + delete: ( + params: RequestParams.IndicesDelete, + ): Promise> => + this.client.indices.delete(params), + exists: async ( + params: RequestParams.IndicesExists, + ): Promise => { return (await this.client.indices.exists(params)).body; }, - existsAlias: async(params: RequestParams.IndicesExistsAlias): Promise => { + existsAlias: async ( + params: RequestParams.IndicesExistsAlias, + ): Promise => { return (await this.client.indices.existsAlias(params)).body; }, - putAlias: (params: RequestParams.IndicesPutAlias): Promise> => this.client.indices.putAlias(params), - getAlias: async(params: RequestParams.IndicesGetAlias): Promise => { - return (await this.client.indices.getAlias(params)).body; + putAlias: ( + params: RequestParams.IndicesPutAlias, + ): Promise> => + this.client.indices.putAlias(params), + getAlias: async ( + params: RequestParams.IndicesGetAlias, + ): Promise => { + return ( + await this.client.indices.getAlias( + params, + ) + ).body; }, - updateAliases: (params: RequestParams.IndicesUpdateAliases['body']): Promise> => { + updateAliases: ( + params: RequestParams.IndicesUpdateAliases['body'], + ): Promise> => { return this.client.indices.updateAliases({ body: params }); }, - validateQuery: async(params: RequestParams.IndicesValidateQuery<{ query?: estypes.QueryDslQueryContainer }>) - : Promise => { - return (await this.client.indices.validateQuery(params)).body; + validateQuery: async ( + params: RequestParams.IndicesValidateQuery<{ + query?: estypes.QueryDslQueryContainer; + }>, + ): Promise => { + return ( + await this.client.indices.validateQuery( + params, + ) + ).body; }, - stats: async(params: RequestParams.IndicesStats): Promise => { - return (await this.client.indices.stats(params)).body; + stats: async ( + params: RequestParams.IndicesStats, + ): Promise => { + return ( + await this.client.indices.stats(params) + ).body; }, }; nodes = { - info: (): Promise> => this.client.nodes.info(), + info: (): Promise> => + this.client.nodes.info(), }; ping(): Promise> { return this.client.ping(); } - reindex(indexName: string, tmpIndexName: string): Promise> { - return this.client.reindex({ wait_for_completion: false, body: { source: { index: indexName }, dest: { index: tmpIndexName } } }); + reindex( + indexName: string, + tmpIndexName: string, + ): Promise> { + return this.client.reindex({ + wait_for_completion: false, + body: { source: { index: indexName }, dest: { index: tmpIndexName } }, + }); } async search(params: ES7SearchQuery): Promise { return (await this.client.search(params)).body; } - } diff --git a/apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/es8-client-delegator.ts b/apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/es8-client-delegator.ts index e0e38c58fab..cea24b96580 100644 --- a/apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/es8-client-delegator.ts +++ b/apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/es8-client-delegator.ts @@ -1,7 +1,10 @@ -import { Client, type ClientOptions, type estypes } from '@elastic/elasticsearch8'; +import { + Client, + type ClientOptions, + type estypes, +} from '@elastic/elasticsearch8'; export class ES8ClientDelegator { - private client: Client; delegatorVersion = 8 as const; @@ -15,24 +18,56 @@ export class ES8ClientDelegator { } cat = { - aliases: (params: estypes.CatAliasesRequest): Promise => this.client.cat.aliases(params), - indices: (params: estypes.CatIndicesRequest): Promise => this.client.cat.indices(params), + aliases: ( + params: estypes.CatAliasesRequest, + ): Promise => this.client.cat.aliases(params), + indices: ( + params: estypes.CatIndicesRequest, + ): Promise => this.client.cat.indices(params), }; cluster = { - health: (): Promise => this.client.cluster.health(), + health: (): Promise => + this.client.cluster.health(), }; indices = { - create: (params: estypes.IndicesCreateRequest): Promise => this.client.indices.create(params), - delete: (params: estypes.IndicesDeleteRequest): Promise => this.client.indices.delete(params), - exists: (params: estypes.IndicesExistsRequest): Promise => this.client.indices.exists(params), - existsAlias: (params: estypes.IndicesExistsAliasRequest): Promise => this.client.indices.existsAlias(params), - putAlias: (params: estypes.IndicesPutAliasRequest): Promise => this.client.indices.putAlias(params), - getAlias: (params: estypes.IndicesGetAliasRequest): Promise => this.client.indices.getAlias(params), - updateAliases: (params: estypes.IndicesUpdateAliasesRequest): Promise => this.client.indices.updateAliases(params), - validateQuery: (params: estypes.IndicesValidateQueryRequest): Promise => this.client.indices.validateQuery(params), - stats: (params: estypes.IndicesStatsRequest): Promise => this.client.indices.stats(params), + create: ( + params: estypes.IndicesCreateRequest, + ): Promise => + this.client.indices.create(params), + delete: ( + params: estypes.IndicesDeleteRequest, + ): Promise => + this.client.indices.delete(params), + exists: ( + params: estypes.IndicesExistsRequest, + ): Promise => + this.client.indices.exists(params), + existsAlias: ( + params: estypes.IndicesExistsAliasRequest, + ): Promise => + this.client.indices.existsAlias(params), + putAlias: ( + params: estypes.IndicesPutAliasRequest, + ): Promise => + this.client.indices.putAlias(params), + getAlias: ( + params: estypes.IndicesGetAliasRequest, + ): Promise => + this.client.indices.getAlias(params), + updateAliases: ( + params: estypes.IndicesUpdateAliasesRequest, + ): Promise => + this.client.indices.updateAliases(params), + validateQuery: ( + params: estypes.IndicesValidateQueryRequest, + ): Promise => + this.client.indices.validateQuery(params), + stats: ( + params: estypes.IndicesStatsRequest, + ): Promise => + this.client.indices.stats(params), }; nodes = { @@ -43,12 +78,18 @@ export class ES8ClientDelegator { return this.client.ping(); } - reindex(indexName: string, tmpIndexName: string): Promise { - return this.client.reindex({ wait_for_completion: false, source: { index: indexName }, dest: { index: tmpIndexName } }); + reindex( + indexName: string, + tmpIndexName: string, + ): Promise { + return this.client.reindex({ + wait_for_completion: false, + source: { index: indexName }, + dest: { index: tmpIndexName }, + }); } search(params: estypes.SearchRequest): Promise { return this.client.search(params); } - } diff --git a/apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/es9-client-delegator.ts b/apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/es9-client-delegator.ts index 1e825bab599..3fd8d929c13 100644 --- a/apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/es9-client-delegator.ts +++ b/apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/es9-client-delegator.ts @@ -1,7 +1,10 @@ -import { Client, type ClientOptions, type estypes } from '@elastic/elasticsearch9'; +import { + Client, + type ClientOptions, + type estypes, +} from '@elastic/elasticsearch9'; export class ES9ClientDelegator { - private client: Client; delegatorVersion = 9 as const; @@ -15,24 +18,56 @@ export class ES9ClientDelegator { } cat = { - aliases: (params: estypes.CatAliasesRequest): Promise => this.client.cat.aliases(params), - indices: (params: estypes.CatIndicesRequest): Promise => this.client.cat.indices(params), + aliases: ( + params: estypes.CatAliasesRequest, + ): Promise => this.client.cat.aliases(params), + indices: ( + params: estypes.CatIndicesRequest, + ): Promise => this.client.cat.indices(params), }; cluster = { - health: (): Promise => this.client.cluster.health(), + health: (): Promise => + this.client.cluster.health(), }; indices = { - create: (params: estypes.IndicesCreateRequest): Promise => this.client.indices.create(params), - delete: (params: estypes.IndicesDeleteRequest): Promise => this.client.indices.delete(params), - exists: (params: estypes.IndicesExistsRequest): Promise => this.client.indices.exists(params), - existsAlias: (params: estypes.IndicesExistsAliasRequest): Promise => this.client.indices.existsAlias(params), - putAlias: (params: estypes.IndicesPutAliasRequest): Promise => this.client.indices.putAlias(params), - getAlias: (params: estypes.IndicesGetAliasRequest): Promise => this.client.indices.getAlias(params), - updateAliases: (params: estypes.IndicesUpdateAliasesRequest): Promise => this.client.indices.updateAliases(params), - validateQuery: (params: estypes.IndicesValidateQueryRequest): Promise => this.client.indices.validateQuery(params), - stats: (params: estypes.IndicesStatsRequest): Promise => this.client.indices.stats(params), + create: ( + params: estypes.IndicesCreateRequest, + ): Promise => + this.client.indices.create(params), + delete: ( + params: estypes.IndicesDeleteRequest, + ): Promise => + this.client.indices.delete(params), + exists: ( + params: estypes.IndicesExistsRequest, + ): Promise => + this.client.indices.exists(params), + existsAlias: ( + params: estypes.IndicesExistsAliasRequest, + ): Promise => + this.client.indices.existsAlias(params), + putAlias: ( + params: estypes.IndicesPutAliasRequest, + ): Promise => + this.client.indices.putAlias(params), + getAlias: ( + params: estypes.IndicesGetAliasRequest, + ): Promise => + this.client.indices.getAlias(params), + updateAliases: ( + params: estypes.IndicesUpdateAliasesRequest, + ): Promise => + this.client.indices.updateAliases(params), + validateQuery: ( + params: estypes.IndicesValidateQueryRequest, + ): Promise => + this.client.indices.validateQuery(params), + stats: ( + params: estypes.IndicesStatsRequest, + ): Promise => + this.client.indices.stats(params), }; nodes = { @@ -43,12 +78,18 @@ export class ES9ClientDelegator { return this.client.ping(); } - reindex(indexName: string, tmpIndexName: string): Promise { - return this.client.reindex({ wait_for_completion: false, source: { index: indexName }, dest: { index: tmpIndexName } }); + reindex( + indexName: string, + tmpIndexName: string, + ): Promise { + return this.client.reindex({ + wait_for_completion: false, + source: { index: indexName }, + dest: { index: tmpIndexName }, + }); } search(params: estypes.SearchRequest): Promise { return this.client.search(params); } - } diff --git a/apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/get-client.ts b/apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/get-client.ts index ae233bb804f..8db6d45d132 100644 --- a/apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/get-client.ts +++ b/apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/get-client.ts @@ -2,55 +2,68 @@ import type { ClientOptions as ES7ClientOptions } from '@elastic/elasticsearch7' import type { ClientOptions as ES8ClientOptions } from '@elastic/elasticsearch8'; import type { ClientOptions as ES9ClientOptions } from '@elastic/elasticsearch9'; -import { type ES7ClientDelegator } from './es7-client-delegator'; -import { type ES8ClientDelegator } from './es8-client-delegator'; -import { type ES9ClientDelegator } from './es9-client-delegator'; +import type { ES7ClientDelegator } from './es7-client-delegator'; +import type { ES8ClientDelegator } from './es8-client-delegator'; +import type { ES9ClientDelegator } from './es9-client-delegator'; import type { ElasticsearchClientDelegator } from './interfaces'; -type GetDelegatorOptions = { - version: 7; - options: ES7ClientOptions; - rejectUnauthorized: boolean; -} | { - version: 8; - options: ES8ClientOptions; - rejectUnauthorized: boolean; -} | { - version: 9; - options: ES9ClientOptions; - rejectUnauthorized: boolean; -} +type GetDelegatorOptions = + | { + version: 7; + options: ES7ClientOptions; + rejectUnauthorized: boolean; + } + | { + version: 8; + options: ES8ClientOptions; + rejectUnauthorized: boolean; + } + | { + version: 9; + options: ES9ClientOptions; + rejectUnauthorized: boolean; + }; -type IsAny = 'dummy' extends (T & 'dummy') ? true : false; -type Delegator = - IsAny extends true - ? ElasticsearchClientDelegator - : Opts extends { version: 7 } - ? ES7ClientDelegator - : Opts extends { version: 8 } - ? ES8ClientDelegator - : Opts extends { version: 9 } - ? ES9ClientDelegator - : ElasticsearchClientDelegator +type IsAny = 'dummy' extends T & 'dummy' ? true : false; +type Delegator = IsAny extends true + ? ElasticsearchClientDelegator + : Opts extends { version: 7 } + ? ES7ClientDelegator + : Opts extends { version: 8 } + ? ES8ClientDelegator + : Opts extends { version: 9 } + ? ES9ClientDelegator + : ElasticsearchClientDelegator; let instance: ElasticsearchClientDelegator | null = null; -export const getClient = async(opts: Opts): Promise> => { +export const getClient = async ( + opts: Opts, +): Promise> => { if (instance == null) { if (opts.version === 7) { await import('./es7-client-delegator').then(({ ES7ClientDelegator }) => { - instance = new ES7ClientDelegator(opts.options, opts.rejectUnauthorized); + instance = new ES7ClientDelegator( + opts.options, + opts.rejectUnauthorized, + ); return instance; }); } if (opts.version === 8) { await import('./es8-client-delegator').then(({ ES8ClientDelegator }) => { - instance = new ES8ClientDelegator(opts.options, opts.rejectUnauthorized); + instance = new ES8ClientDelegator( + opts.options, + opts.rejectUnauthorized, + ); return instance; }); } if (opts.version === 9) { await import('./es9-client-delegator').then(({ ES9ClientDelegator }) => { - instance = new ES9ClientDelegator(opts.options, opts.rejectUnauthorized); + instance = new ES9ClientDelegator( + opts.options, + opts.rejectUnauthorized, + ); return instance; }); } diff --git a/apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/interfaces.ts b/apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/interfaces.ts index 7860f2a6b95..301af28c041 100644 --- a/apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/interfaces.ts +++ b/apps/app/src/server/service/search-delegator/elasticsearch-client-delegator/interfaces.ts @@ -1,4 +1,7 @@ -import type { estypes as ES7types, RequestParams } from '@elastic/elasticsearch7'; +import type { + estypes as ES7types, + RequestParams, +} from '@elastic/elasticsearch7'; import type { estypes as ES8types } from '@elastic/elasticsearch8'; import type { estypes as ES9types } from '@elastic/elasticsearch9'; @@ -6,52 +9,59 @@ import type { ES7ClientDelegator } from './es7-client-delegator'; import type { ES8ClientDelegator } from './es8-client-delegator'; import type { ES9ClientDelegator } from './es9-client-delegator'; -export type ElasticsearchClientDelegator = ES7ClientDelegator | ES8ClientDelegator | ES9ClientDelegator; - +export type ElasticsearchClientDelegator = + | ES7ClientDelegator + | ES8ClientDelegator + | ES9ClientDelegator; // type guard // TODO: https://redmine.weseek.co.jp/issues/168446 -export const isES7ClientDelegator = (delegator: ElasticsearchClientDelegator): delegator is ES7ClientDelegator => { +export const isES7ClientDelegator = ( + delegator: ElasticsearchClientDelegator, +): delegator is ES7ClientDelegator => { return delegator.delegatorVersion === 7; }; -export const isES8ClientDelegator = (delegator: ElasticsearchClientDelegator): delegator is ES8ClientDelegator => { +export const isES8ClientDelegator = ( + delegator: ElasticsearchClientDelegator, +): delegator is ES8ClientDelegator => { return delegator.delegatorVersion === 8; }; -export const isES9ClientDelegator = (delegator: ElasticsearchClientDelegator): delegator is ES9ClientDelegator => { +export const isES9ClientDelegator = ( + delegator: ElasticsearchClientDelegator, +): delegator is ES9ClientDelegator => { return delegator.delegatorVersion === 9; }; - // Official library-derived interface // TODO: https://redmine.weseek.co.jp/issues/168446 export type ES7SearchQuery = RequestParams.Search<{ - query: ES7types.QueryDslQueryContainer - sort?: ES7types.Sort - highlight?: ES7types.SearchHighlight -}> + query: ES7types.QueryDslQueryContainer; + sort?: ES7types.Sort; + highlight?: ES7types.SearchHighlight; +}>; export interface ES8SearchQuery { - index: ES8types.IndexName - _source: ES8types.Fields + index: ES8types.IndexName; + _source: ES8types.Fields; from?: number; size?: number; body: { query: ES8types.QueryDslQueryContainer; - sort?: ES8types.Sort + sort?: ES8types.Sort; highlight?: ES8types.SearchHighlight; }; } export interface ES9SearchQuery { - index: ES9types.IndexName - _source: ES9types.Fields + index: ES9types.IndexName; + _source: ES9types.Fields; from?: number; size?: number; body: { query: ES9types.QueryDslQueryContainer; - sort?: ES9types.Sort + sort?: ES9types.Sort; highlight?: ES9types.SearchHighlight; }; } diff --git a/apps/app/src/server/service/search-delegator/elasticsearch.ts b/apps/app/src/server/service/search-delegator/elasticsearch.ts index da4955d7188..3cd11c3b233 100644 --- a/apps/app/src/server/service/search-delegator/elasticsearch.ts +++ b/apps/app/src/server/service/search-delegator/elasticsearch.ts @@ -1,10 +1,9 @@ -import { Writable, Transform } from 'stream'; -import { pipeline } from 'stream/promises'; -import { URL } from 'url'; - import { getIdStringForRef, type IPage } from '@growi/core'; import gc from 'expose-gc/function'; import mongoose from 'mongoose'; +import { Transform, Writable } from 'stream'; +import { pipeline } from 'stream/promises'; +import { URL } from 'url'; import { SearchDelegatorName } from '~/interfaces/named-query'; import type { ISearchResult, ISearchResultData } from '~/interfaces/search'; @@ -15,27 +14,34 @@ import type { SocketIoService } from '~/server/service/socket-io'; import loggerFactory from '~/utils/logger'; import type { - SearchDelegator, SearchableData, QueryTerms, UnavailableTermsKey, ESQueryTerms, ESTermsKey, + ESQueryTerms, + ESTermsKey, + QueryTerms, + SearchableData, + SearchDelegator, + UnavailableTermsKey, } from '../../interfaces/search'; import type { PageModel } from '../../models/page'; import { createBatchStream } from '../../util/batch-stream'; import { configManager } from '../config-manager'; import type { UpdateOrInsertPagesOpts } from '../interfaces/search'; - import { aggregatePipelineToIndex } from './aggregate-to-index'; import type { - AggregatedPage, BulkWriteBody, BulkWriteCommand, BulkWriteBodyRestriction, + AggregatedPage, + BulkWriteBody, + BulkWriteBodyRestriction, + BulkWriteCommand, } from './bulk-write'; import { + type ElasticsearchClientDelegator, + type ES7SearchQuery, + type ES8SearchQuery, + type ES9SearchQuery, getClient, isES7ClientDelegator, isES8ClientDelegator, isES9ClientDelegator, type SearchQuery, - type ES7SearchQuery, - type ES8SearchQuery, - type ES9SearchQuery, - type ElasticsearchClientDelegator, } from './elasticsearch-client-delegator'; const logger = loggerFactory('growi:service:search-delegator:elasticsearch'); @@ -56,12 +62,22 @@ const ES_SORT_ORDER = { [ASC]: 'asc', } as const; -const AVAILABLE_KEYS = ['match', 'not_match', 'phrase', 'not_phrase', 'prefix', 'not_prefix', 'tag', 'not_tag']; +const AVAILABLE_KEYS = [ + 'match', + 'not_match', + 'phrase', + 'not_phrase', + 'prefix', + 'not_prefix', + 'tag', + 'not_tag', +]; type Data = any; -class ElasticsearchDelegator implements SearchDelegator { - +class ElasticsearchDelegator + implements SearchDelegator +{ name!: SearchDelegatorName.DEFAULT; private socketIoService!: SocketIoService; @@ -85,17 +101,27 @@ class ElasticsearchDelegator implements SearchDelegator { const { host, auth, indexName } = this.getConnectionInfo(); - const rejectUnauthorized = configManager.getConfig('app:elasticsearchRejectUnauthorized'); + const rejectUnauthorized = configManager.getConfig( + 'app:elasticsearchRejectUnauthorized', + ); const options = { node: host, auth, - requestTimeout: configManager.getConfig('app:elasticsearchRequestTimeout'), + requestTimeout: configManager.getConfig( + 'app:elasticsearchRequestTimeout', + ), }; - this.client = await getClient({ version: this.elasticsearchVersion, options, rejectUnauthorized }); + this.client = await getClient({ + version: this.elasticsearchVersion, + options, + rejectUnauthorized, + }); this.indexName = indexName; } @@ -178,8 +212,7 @@ class ElasticsearchDelegator implements SearchDelegator { @@ -329,7 +377,9 @@ class ElasticsearchDelegator implements SearchDelegator getIdStringForRef(user)); - const grantedGroupIds = page.grantedGroups.map(group => getIdStringForRef(group.item)); + generateDocContentsRelatedToRestriction( + page: AggregatedPage, + ): BulkWriteBodyRestriction { + const grantedUserIds = page.grantedUsers.map((user) => + getIdStringForRef(user), + ); + const grantedGroupIds = page.grantedGroups.map((group) => + getIdStringForRef(group.item), + ); return { grant: page.grant, @@ -396,8 +456,9 @@ class ElasticsearchDelegator implements SearchDelegator Page.find(), { shouldEmitProgress: true, invokeGarbageCollection: true }); + return this.updateOrInsertPages(() => Page.find(), { + shouldEmitProgress: true, + invokeGarbageCollection: true, + }); } updateOrInsertPageById(pageId) { @@ -462,13 +526,19 @@ class ElasticsearchDelegator implements SearchDelegator { - const { shouldEmitProgress = false, invokeGarbageCollection = false } = option; + async updateOrInsertPages( + queryFactory, + option: UpdateOrInsertPagesOpts = {}, + ): Promise { + const { shouldEmitProgress = false, invokeGarbageCollection = false } = + option; const Page = this.getPageModel(); const { PageQueryBuilder } = Page; - const socket = shouldEmitProgress ? this.socketIoService.getAdminSocket() : null; + const socket = shouldEmitProgress + ? this.socketIoService.getAdminSocket() + : null; // prepare functions invoked from custom streams const prepareBodyForCreate = this.prepareBodyForCreate.bind(this); @@ -479,26 +549,31 @@ class ElasticsearchDelegator implements SearchDelegator( aggregatePipelineToIndex(maxBodyLengthToIndex, matchQuery), ).cursor(); - const bulkSize: number = configManager.getConfig('app:elasticsearchReindexBulkSize'); + const bulkSize: number = configManager.getConfig( + 'app:elasticsearchReindexBulkSize', + ); const batchStream = createBatchStream(bulkSize); const appendTagNamesStream = new Transform({ objectMode: true, async transform(chunk, encoding, callback) { - const pageIds = chunk.map(doc => doc._id); + const pageIds = chunk.map((doc) => doc._id); - const idToTagNamesMap = await PageTagRelation.getIdToTagNamesMap(pageIds); + const idToTagNamesMap = + await PageTagRelation.getIdToTagNamesMap(pageIds); const idsHavingTagNames = Object.keys(idToTagNamesMap); // append tagNames chunk - .filter(doc => idsHavingTagNames.includes(doc._id.toString())) + .filter((doc) => idsHavingTagNames.includes(doc._id.toString())) .forEach((doc: AggregatedPage) => { // append tagName from idToTagNamesMap doc.tagNames = idToTagNamesMap[doc._id.toString()]; @@ -513,7 +588,7 @@ class ElasticsearchDelegator implements SearchDelegator { body.push(...prepareBodyForCreate(doc)); }); @@ -526,13 +601,17 @@ class ElasticsearchDelegator implements SearchDelegator this.prepareBodyForDelete(body, page)); + pages.forEach((page) => this.prepareBodyForDelete(body, page)); logger.debug('deletePages(): Sending Request to ES', body); return this.client.bulk({ @@ -585,14 +657,14 @@ class ElasticsearchDelegator implements SearchDelegator> { - + async searchKeyword( + query: SearchQuery, + ): Promise> { // for debug if (process.env.NODE_ENV === 'development') { logger.debug('query: ', JSON.stringify(query, null, 2)); - - const validateQueryResponse = await (async() => { + const validateQueryResponse = await (async () => { if (isES7ClientDelegator(this.client)) { const es7SearchQuery = query as ES7SearchQuery; return this.client.indices.validateQuery({ @@ -625,12 +697,11 @@ class ElasticsearchDelegator implements SearchDelegator { + const searchResponse = await (async () => { if (isES7ClientDelegator(this.client)) { return this.client.search(query as ES7SearchQuery); } @@ -682,7 +753,15 @@ class ElasticsearchDelegator implements SearchDelegator { return !!query && Array.isArray(query) }; + const isInitialized = (query) => { + return !!query && Array.isArray(query); + }; if (!isInitialized(query.body.query.bool.filter)) { query.body.query.bool.filter = []; @@ -738,22 +822,34 @@ class ElasticsearchDelegator implements SearchDelegator { - const showPagesRestrictedByOwner = !configManager.getConfig('security:list-policy:hideRestrictedByOwner'); - const showPagesRestrictedByGroup = !configManager.getConfig('security:list-policy:hideRestrictedByGroup'); + async filterPagesByViewer( + query: SearchQuery, + user, + userGroups, + ): Promise { + const showPagesRestrictedByOwner = !configManager.getConfig( + 'security:list-policy:hideRestrictedByOwner', + ); + const showPagesRestrictedByGroup = !configManager.getConfig( + 'security:list-policy:hideRestrictedByGroup', + ); query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign - if (query.body?.query?.bool?.filter == null || !Array.isArray(query.body?.query?.bool?.filter)) { + if ( + query.body?.query?.bool?.filter == null || + !Array.isArray(query.body?.query?.bool?.filter) + ) { throw new Error('query.body.query.bool is not initialized'); } const Page = this.getPageModel(); - const { - GRANT_PUBLIC, GRANT_SPECIFIED, GRANT_OWNER, GRANT_USER_GROUP, - } = Page; + const { GRANT_PUBLIC, GRANT_SPECIFIED, GRANT_OWNER, GRANT_USER_GROUP } = + Page; - const grantConditions: any[] = [ - { term: { grant: GRANT_PUBLIC } }, - ]; + const grantConditions: any[] = [{ term: { grant: GRANT_PUBLIC } }]; if (showPagesRestrictedByOwner) { grantConditions.push( { term: { grant: GRANT_SPECIFIED } }, { term: { grant: GRANT_OWNER } }, ); - } - else if (user != null) { + } else if (user != null) { grantConditions.push( { bool: { @@ -890,22 +1007,19 @@ class ElasticsearchDelegator implements SearchDelegator 0) { - const userGroupIds = userGroups.map((group) => { return group._id.toString() }); - grantConditions.push( - { - bool: { - must: [ - { term: { grant: GRANT_USER_GROUP } }, - { terms: { granted_groups: userGroupIds } }, - ], - }, + grantConditions.push({ term: { grant: GRANT_USER_GROUP } }); + } else if (userGroups != null && userGroups.length > 0) { + const userGroupIds = userGroups.map((group) => { + return group._id.toString(); + }); + grantConditions.push({ + bool: { + must: [ + { term: { grant: GRANT_USER_GROUP } }, + { terms: { granted_groups: userGroupIds } }, + ], }, - ); + }); } query.body.query.bool.filter.push({ bool: { should: grantConditions } }); @@ -913,7 +1027,7 @@ class ElasticsearchDelegator implements SearchDelegator { const User = this.getUserModel(); - const count = await User.count({}) || 1; + const count = (await User.count({})) || 1; const minScore = queryString.length * 0.1 - 1; // increase with length logger.debug('min_score: ', minScore); @@ -958,7 +1072,12 @@ class ElasticsearchDelegator implements SearchDelegator, user, userGroups, option?): Promise> { + async search( + data: SearchableData, + user, + userGroups, + option?, + ): Promise> { const { queryString, terms } = data; if (terms == null) { @@ -976,7 +1095,6 @@ class ElasticsearchDelegator implements SearchDelegator): terms is ESQueryTerms { const entries = Object.entries(terms); - return !entries.some(([key, val]) => !AVAILABLE_KEYS.includes(key) && typeof val?.length === 'number' && val.length > 0); + return !entries.some( + ([key, val]) => + !AVAILABLE_KEYS.includes(key) && + typeof val?.length === 'number' && + val.length > 0, + ); } validateTerms(terms: QueryTerms): UnavailableTermsKey[] { @@ -1014,8 +1137,7 @@ class ElasticsearchDelegator implements SearchDelegator { - +class PrivateLegacyPagesDelegator + implements SearchDelegator +{ name!: SearchDelegatorName.PRIVATE_LEGACY_PAGES; constructor() { this.name = SearchDelegatorName.PRIVATE_LEGACY_PAGES; } - async search(data: SearchableData, user, userGroups, option): Promise> { + async search( + data: SearchableData, + user, + userGroups, + option, + ): Promise> { const { terms } = data; const { offset, limit } = option; if (offset == null || limit == null) { - throw Error('PrivateLegacyPagesDelegator requires pagination options (offset, limit).'); + throw Error( + 'PrivateLegacyPagesDelegator requires pagination options (offset, limit).', + ); } if (user == null && userGroups == null) { throw Error('Either of user and userGroups must not be null.'); @@ -50,13 +65,12 @@ class PrivateLegacyPagesDelegator implements SearchDelegator serializePageSecurely(page)), + data: pages.map((page) => serializePageSecurely(page)), meta: { total, hitsCount: pages.length, @@ -64,22 +78,23 @@ class PrivateLegacyPagesDelegator implements SearchDelegator 0) { - match.forEach(m => builder.addConditionToListByMatch(m)); + match.forEach((m) => builder.addConditionToListByMatch(m)); } if (notMatch.length > 0) { - notMatch.forEach(nm => builder.addConditionToListByNotMatch(nm)); + notMatch.forEach((nm) => builder.addConditionToListByNotMatch(nm)); } if (prefix.length > 0) { - prefix.forEach(p => builder.addConditionToListByStartWith(p)); + prefix.forEach((p) => builder.addConditionToListByStartWith(p)); } if (notPrefix.length > 0) { - notPrefix.forEach(np => builder.addConditionToListByNotStartWith(np)); + notPrefix.forEach((np) => builder.addConditionToListByNotStartWith(np)); } return builder; @@ -88,7 +103,12 @@ class PrivateLegacyPagesDelegator implements SearchDelegator): terms is MongoQueryTerms { const entries = Object.entries(terms); - return !entries.some(([key, val]) => !AVAILABLE_KEYS.includes(key) && typeof val?.length === 'number' && val.length > 0); + return !entries.some( + ([key, val]) => + !AVAILABLE_KEYS.includes(key) && + typeof val?.length === 'number' && + val.length > 0, + ); } validateTerms(terms: QueryTerms): UnavailableTermsKey[] { @@ -98,7 +118,6 @@ class PrivateLegacyPagesDelegator implements SearchDelegator !AVAILABLE_KEYS.includes(key) && val.length > 0) .map(([key]) => key as UnavailableTermsKey); // use "as": https://github.com/microsoft/TypeScript/issues/41173 } - } export default PrivateLegacyPagesDelegator; diff --git a/apps/app/src/server/service/search-reconnect-context/reconnect-context.js b/apps/app/src/server/service/search-reconnect-context/reconnect-context.js index b2e288cac2b..50d84795394 100644 --- a/apps/app/src/server/service/search-reconnect-context/reconnect-context.js +++ b/apps/app/src/server/service/search-reconnect-context/reconnect-context.js @@ -1,12 +1,12 @@ import loggerFactory from '~/utils/logger'; -const logger = loggerFactory('growi:service:search-reconnect-context:reconnect-context'); - +const logger = loggerFactory( + 'growi:service:search-reconnect-context:reconnect-context', +); const RECONNECT_INTERVAL_SEC = 120; class ReconnectContext { - constructor() { this.lastEvalDate = null; @@ -39,7 +39,9 @@ class ReconnectContext { return true; } - const thres = this.lastEvalDate.setSeconds(this.lastEvalDate.getSeconds() + RECONNECT_INTERVAL_SEC); + const thres = this.lastEvalDate.setSeconds( + this.lastEvalDate.getSeconds() + RECONNECT_INTERVAL_SEC, + ); return thres < new Date(); } @@ -54,7 +56,6 @@ class ReconnectContext { } return false; } - } async function nextTick(context, reconnectHandler) { diff --git a/apps/app/src/server/service/slack-command-handler/create-page-service.js b/apps/app/src/server/service/slack-command-handler/create-page-service.js index 0b73516e66a..d6201fc6863 100644 --- a/apps/app/src/server/service/slack-command-handler/create-page-service.js +++ b/apps/app/src/server/service/slack-command-handler/create-page-service.js @@ -14,7 +14,6 @@ const { pathUtils } = require('@growi/core/dist/utils'); const mongoose = require('mongoose'); class CreatePageService { - /** @type {import('~/server/crowi').default} Crowi instance */ crowi; @@ -23,7 +22,13 @@ class CreatePageService { this.crowi = crowi; } - async createPageInGrowi(interactionPayloadAccessor, path, contentsBody, respondUtil, user) { + async createPageInGrowi( + interactionPayloadAccessor, + path, + contentsBody, + respondUtil, + user, + ) { const reshapedContentsBody = reshapeContentsBody(contentsBody); // sanitize path @@ -31,20 +36,27 @@ class CreatePageService { const normalizedPath = pathUtils.normalizePath(sanitizedPath); // Since an ObjectId is required for creating a page, if a user does not exist, a dummy user will be generated - const userOrDummyUser = user != null ? user : { _id: new mongoose.Types.ObjectId() }; + const userOrDummyUser = + user != null ? user : { _id: new mongoose.Types.ObjectId() }; - const page = await this.crowi.pageService.create(normalizedPath, reshapedContentsBody, userOrDummyUser, {}); + const page = await this.crowi.pageService.create( + normalizedPath, + reshapedContentsBody, + userOrDummyUser, + {}, + ); // Send a message when page creation is complete const growiUri = growiInfoService.getSiteUrl(); await respondUtil.respond({ text: 'Page has been created', blocks: [ - markdownSectionBlock(`The page <${decodeURI(`${growiUri}/${page._id} | ${decodeURI(growiUri + normalizedPath)}`)}> has been created.`), + markdownSectionBlock( + `The page <${decodeURI(`${growiUri}/${page._id} | ${decodeURI(growiUri + normalizedPath)}`)}> has been created.`, + ), ], }); } - } module.exports = CreatePageService; diff --git a/apps/app/src/server/service/slack-command-handler/error-handler.ts b/apps/app/src/server/service/slack-command-handler/error-handler.ts index d35c6fb7671..f552d225249 100644 --- a/apps/app/src/server/service/slack-command-handler/error-handler.ts +++ b/apps/app/src/server/service/slack-command-handler/error-handler.ts @@ -1,29 +1,36 @@ -import assert from 'assert'; - import type { RespondBodyForResponseUrl } from '@growi/slack'; import { markdownSectionBlock } from '@growi/slack/dist/utils/block-kit-builder'; import { respond } from '@growi/slack/dist/utils/response-url'; import { type ChatPostEphemeralResponse, WebClient } from '@slack/web-api'; - +import assert from 'assert'; import { SlackCommandHandlerError } from '../../models/vo/slack-command-handler-error'; -function generateRespondBodyForInternalServerError(message): RespondBodyForResponseUrl { +function generateRespondBodyForInternalServerError( + message, +): RespondBodyForResponseUrl { return { text: message, blocks: [ - markdownSectionBlock(`*GROWI Internal Server Error occured.*\n \`${message}\``), + markdownSectionBlock( + `*GROWI Internal Server Error occured.*\n \`${message}\``, + ), ], }; } // eslint-disable-next-line @typescript-eslint/no-explicit-any -async function handleErrorWithWebClient(error: Error, client: WebClient, body: any): Promise { - +async function handleErrorWithWebClient( + error: Error, + client: WebClient, + body: any, +): Promise { const isInteraction = !body.channel_id; // this method is expected to use when system couldn't response_url - assert(!(error instanceof SlackCommandHandlerError) || error.responseUrl == null); + assert( + !(error instanceof SlackCommandHandlerError) || error.responseUrl == null, + ); const payload = JSON.parse(body.payload); @@ -37,15 +44,23 @@ async function handleErrorWithWebClient(error: Error, client: WebClient, body: a }); } - -export async function handleError(error: SlackCommandHandlerError | Error, responseUrl?: string): Promise; +export async function handleError( + error: SlackCommandHandlerError | Error, + responseUrl?: string, +): Promise; // eslint-disable-next-line @typescript-eslint/no-explicit-any -export async function handleError(error: Error, client: WebClient, body: any): Promise; +export async function handleError( + error: Error, + client: WebClient, + body: any, +): Promise; // eslint-disable-next-line @typescript-eslint/no-explicit-any -export async function handleError(error: SlackCommandHandlerError | Error, ...args: any[]): Promise { - +export async function handleError( + error: SlackCommandHandlerError | Error, + ...args: any[] +): Promise { // handle a SlackCommandHandlerError if (error instanceof SlackCommandHandlerError) { const responseUrl = args[0] || error.responseUrl; @@ -56,11 +71,16 @@ export async function handleError(error: SlackCommandHandlerError | Error, ...ar } const secondArg = args[0]; - assert(secondArg != null, 'Couldn\'t handle Error without the second argument.'); + assert( + secondArg != null, + "Couldn't handle Error without the second argument.", + ); // handle a normal Error with response_url if (typeof secondArg === 'string') { - const respondBody = generateRespondBodyForInternalServerError(error.message); + const respondBody = generateRespondBodyForInternalServerError( + error.message, + ); return respond(secondArg, respondBody); } diff --git a/apps/app/src/server/service/slack-command-handler/help.js b/apps/app/src/server/service/slack-command-handler/help.js index 018d4504a79..d573c6e4126 100644 --- a/apps/app/src/server/service/slack-command-handler/help.js +++ b/apps/app/src/server/service/slack-command-handler/help.js @@ -12,22 +12,22 @@ module.exports = (crowi) => { const BaseSlackCommandHandler = require('./slack-command-handler'); const handler = new BaseSlackCommandHandler(); - handler.handleCommand = async(growiCommand, client, body, respondUtil) => { + handler.handleCommand = async (growiCommand, client, body, respondUtil) => { const appTitle = crowi.appService.getAppTitle(); const appSiteUrl = growiInfoService.getSiteUrl(); // adjust spacing let message = `*Help* (*${appTitle}* at ${appSiteUrl})\n\n`; message += 'Usage: `/growi [command] [args]`\n\n'; message += 'Commands:\n\n'; - message += '`/growi note` Take a note on GROWI\n\n'; + message += + '`/growi note` Take a note on GROWI\n\n'; message += '`/growi search [keyword]` Search pages\n\n'; - message += '`/growi keep` Create new page with existing slack conversations (Alpha)\n\n'; + message += + '`/growi keep` Create new page with existing slack conversations (Alpha)\n\n'; await respondUtil.respond({ text: 'Help', - blocks: [ - markdownSectionBlock(message), - ], + blocks: [markdownSectionBlock(message)], }); }; diff --git a/apps/app/src/server/service/slack-command-handler/keep.js b/apps/app/src/server/service/slack-command-handler/keep.js index aa6f6ca70de..eb0a553d5e4 100644 --- a/apps/app/src/server/service/slack-command-handler/keep.js +++ b/apps/app/src/server/service/slack-command-handler/keep.js @@ -1,5 +1,8 @@ import { - inputBlock, actionsBlock, buttonElement, markdownSectionBlock, + actionsBlock, + buttonElement, + inputBlock, + markdownSectionBlock, } from '@growi/slack/dist/utils/block-kit-builder'; import { format } from 'date-fns/format'; import { parse } from 'date-fns/parse'; @@ -17,7 +20,12 @@ module.exports = (crowi) => { const handler = new BaseSlackCommandHandler(); const { User } = crowi.models; - handler.handleCommand = async function(growiCommand, client, body, respondUtil) { + handler.handleCommand = async function ( + growiCommand, + client, + body, + respondUtil, + ) { await respondUtil.respond({ text: 'Select messages to use.', blocks: this.keepMessageBlocks(body.channel_name), @@ -25,21 +33,46 @@ module.exports = (crowi) => { return; }; - handler.handleInteractions = async function(client, interactionPayload, interactionPayloadAccessor, handlerMethodName, respondUtil) { - await this[handlerMethodName](client, interactionPayload, interactionPayloadAccessor, respondUtil); + handler.handleInteractions = async function ( + client, + interactionPayload, + interactionPayloadAccessor, + handlerMethodName, + respondUtil, + ) { + await this[handlerMethodName]( + client, + interactionPayload, + interactionPayloadAccessor, + respondUtil, + ); }; - handler.cancel = async function(client, payload, interactionPayloadAccessor, respondUtil) { + handler.cancel = async ( + client, + payload, + interactionPayloadAccessor, + respondUtil, + ) => { await respondUtil.deleteOriginal(); }; - handler.createPage = async function(client, payload, interactionPayloadAccessor, respondUtil) { + handler.createPage = async function ( + client, + payload, + interactionPayloadAccessor, + respondUtil, + ) { let result = []; const channelId = payload.channel.id; // this must exist since the type is always block_actions const user = await User.findUserBySlackMemberId(payload.user.id); // validate form - const { path, oldest, newest } = await this.keepValidateForm(client, payload, interactionPayloadAccessor); + const { path, oldest, newest } = await this.keepValidateForm( + client, + payload, + interactionPayloadAccessor, + ); // get messages result = await this.keepGetMessages(client, channelId, newest, oldest); // clean messages @@ -47,37 +80,66 @@ module.exports = (crowi) => { const contentsBody = cleanedContents.join(''); // create and send url message - await this.keepCreatePageAndSendPreview(client, interactionPayloadAccessor, path, user, contentsBody, respondUtil); + await this.keepCreatePageAndSendPreview( + client, + interactionPayloadAccessor, + path, + user, + contentsBody, + respondUtil, + ); }; - handler.keepValidateForm = async function(client, payload, interactionPayloadAccessor) { + handler.keepValidateForm = async ( + client, + payload, + interactionPayloadAccessor, + ) => { const grwTzoffset = crowi.appService.getTzoffset() * 60; - const path = interactionPayloadAccessor.getStateValues()?.page_path.page_path.value; - let oldest = interactionPayloadAccessor.getStateValues()?.oldest.oldest.value; - let newest = interactionPayloadAccessor.getStateValues()?.newest.newest.value; + const path = + interactionPayloadAccessor.getStateValues()?.page_path.page_path.value; + let oldest = + interactionPayloadAccessor.getStateValues()?.oldest.oldest.value; + let newest = + interactionPayloadAccessor.getStateValues()?.newest.newest.value; if (oldest == null || newest == null || path == null) { - throw new SlackCommandHandlerError('All parameters are required. (Oldest datetime, Newst datetime and Page path)'); + throw new SlackCommandHandlerError( + 'All parameters are required. (Oldest datetime, Newst datetime and Page path)', + ); } /** * RegExp for datetime yyyy/MM/dd-HH:mm * @see https://regex101.com/r/XbxdNo/1 */ - const regexpDatetime = new RegExp(/^[12]\d\d\d\/(0[1-9]|1[012])\/(0[1-9]|[12][0-9]|3[01])-([01][0-9]|2[0123]):[0-5][0-9]$/); + const regexpDatetime = new RegExp( + /^[12]\d\d\d\/(0[1-9]|1[012])\/(0[1-9]|[12][0-9]|3[01])-([01][0-9]|2[0123]):[0-5][0-9]$/, + ); if (!regexpDatetime.test(oldest.trim())) { - throw new SlackCommandHandlerError('Datetime format for oldest must be yyyy/MM/dd-HH:mm'); + throw new SlackCommandHandlerError( + 'Datetime format for oldest must be yyyy/MM/dd-HH:mm', + ); } if (!regexpDatetime.test(newest.trim())) { - throw new SlackCommandHandlerError('Datetime format for newest must be yyyy/MM/dd-HH:mm'); + throw new SlackCommandHandlerError( + 'Datetime format for newest must be yyyy/MM/dd-HH:mm', + ); } - oldest = parse(oldest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset; + oldest = + parse(oldest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + + grwTzoffset; // + 60s in order to include messages between hh:mm.00s and hh:mm.59s - newest = parse(newest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset + 60; + newest = + parse(newest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + + grwTzoffset + + 60; if (oldest > newest) { - throw new SlackCommandHandlerError('Oldest datetime must be older than the newest date time.'); + throw new SlackCommandHandlerError( + 'Oldest datetime must be older than the newest date time.', + ); } return { path, oldest, newest }; @@ -93,14 +155,13 @@ module.exports = (crowi) => { }); } - handler.keepGetMessages = async function(client, channelId, newest, oldest) { + handler.keepGetMessages = async (client, channelId, newest, oldest) => { let result; // first attempt try { result = await retrieveHistory(client, channelId, newest, oldest); - } - catch (err) { + } catch (err) { const errorCode = err.data?.errorCode; if (errorCode === 'not_in_channel') { @@ -109,12 +170,11 @@ module.exports = (crowi) => { channel: channelId, }); result = await retrieveHistory(client, channelId, newest, oldest); - } - else if (errorCode === 'channel_not_found') { - - const message = ':cry: GROWI Bot couldn\'t get history data because *this channel was private*.' - + '\nPlease add GROWI bot to this channel.' - + '\n'; + } else if (errorCode === 'channel_not_found') { + const message = + ":cry: GROWI Bot couldn't get history data because *this channel was private*." + + '\nPlease add GROWI bot to this channel.' + + '\n'; throw new SlackCommandHandlerError(message, { respondBody: { text: message, @@ -122,21 +182,23 @@ module.exports = (crowi) => { markdownSectionBlock(message), { type: 'image', - image_url: 'https://user-images.githubusercontent.com/1638767/135658794-a8d2dbc8-580f-4203-b368-e74e2f3c7b3a.png', + image_url: + 'https://user-images.githubusercontent.com/1638767/135658794-a8d2dbc8-580f-4203-b368-e74e2f3c7b3a.png', alt_text: 'Add app to this channel', }, ], }, }); - } - else { + } else { throw err; } } // return if no message found if (result.messages.length === 0) { - throw new SlackCommandHandlerError('No message found from keep command. Try different datetime.'); + throw new SlackCommandHandlerError( + 'No message found from keep command. Try different datetime.', + ); } return result; }; @@ -146,7 +208,7 @@ module.exports = (crowi) => { * @param {*} messages (array of messages) * @returns users object with matching Slack Member ID */ - handler.getGrowiUsersFromMessages = async function(messages) { + handler.getGrowiUsersFromMessages = async (messages) => { const users = messages.map((message) => { return message.user; }); @@ -157,21 +219,22 @@ module.exports = (crowi) => { * Convert slack member ID to growi user if slack member ID is found in messages * @param {*} messages */ - handler.injectGrowiUsernameToMessages = async function(messages) { + handler.injectGrowiUsernameToMessages = async function (messages) { const growiUsers = await this.getGrowiUsersFromMessages(messages); - messages.map(async(message) => { - const growiUser = growiUsers.find(user => user.slackMemberId === message.user); + messages.map(async (message) => { + const growiUser = growiUsers.find( + (user) => user.slackMemberId === message.user, + ); if (growiUser != null) { message.user = `${growiUser.name} (@${growiUser.username})`; - } - else { + } else { message.user = `This slack member ID is not registered (${message.user})`; } }); }; - handler.keepCleanMessages = async function(messages) { + handler.keepCleanMessages = async function (messages) { const cleanedContents = []; let lastMessage = {}; const grwTzoffset = crowi.appService.getTzoffset() * 60; @@ -199,8 +262,21 @@ module.exports = (crowi) => { return cleanedContents; }; - handler.keepCreatePageAndSendPreview = async function(client, interactionPayloadAccessor, path, user, contentsBody, respondUtil) { - await createPageService.createPageInGrowi(interactionPayloadAccessor, path, contentsBody, respondUtil, user); + handler.keepCreatePageAndSendPreview = async ( + client, + interactionPayloadAccessor, + path, + user, + contentsBody, + respondUtil, + ) => { + await createPageService.createPageInGrowi( + interactionPayloadAccessor, + path, + contentsBody, + respondUtil, + user, + ); // TODO: contentsBody text characters must be less than 3001 // send preview to dm @@ -219,7 +295,7 @@ module.exports = (crowi) => { await respondUtil.deleteOriginal(); }; - handler.keepMessageBlocks = function(channelName) { + handler.keepMessageBlocks = (channelName) => { const tzDateSec = new Date().getTime(); const grwTzoffset = crowi.appService.getTzoffset() * 60 * 1000; @@ -233,29 +309,47 @@ module.exports = (crowi) => { return [ markdownSectionBlock('*The keep command is in alpha.*'), - markdownSectionBlock('Select the oldest and newest datetime of the messages to use.'), - inputBlock({ - type: 'plain_text_input', - action_id: 'oldest', - initial_value: initialOldest, - }, 'oldest', 'Oldest datetime'), - inputBlock({ - type: 'plain_text_input', - action_id: 'newest', - initial_value: initialNewest, - }, 'newest', 'Newest datetime'), - inputBlock({ - type: 'plain_text_input', - placeholder: { - type: 'plain_text', - text: 'Input page path to create.', + markdownSectionBlock( + 'Select the oldest and newest datetime of the messages to use.', + ), + inputBlock( + { + type: 'plain_text_input', + action_id: 'oldest', + initial_value: initialOldest, }, - initial_value: initialPagePath, - action_id: 'page_path', - }, 'page_path', 'Page path'), + 'oldest', + 'Oldest datetime', + ), + inputBlock( + { + type: 'plain_text_input', + action_id: 'newest', + initial_value: initialNewest, + }, + 'newest', + 'Newest datetime', + ), + inputBlock( + { + type: 'plain_text_input', + placeholder: { + type: 'plain_text', + text: 'Input page path to create.', + }, + initial_value: initialPagePath, + action_id: 'page_path', + }, + 'page_path', + 'Page path', + ), actionsBlock( buttonElement({ text: 'Cancel', actionId: 'keep:cancel' }), - buttonElement({ text: 'Create page', actionId: 'keep:createPage', style: 'primary' }), + buttonElement({ + text: 'Create page', + actionId: 'keep:createPage', + style: 'primary', + }), ), ]; }; diff --git a/apps/app/src/server/service/slack-command-handler/note.js b/apps/app/src/server/service/slack-command-handler/note.js index 4846cc09b0e..d7b7a7ca0e6 100644 --- a/apps/app/src/server/service/slack-command-handler/note.js +++ b/apps/app/src/server/service/slack-command-handler/note.js @@ -1,5 +1,9 @@ import { - markdownHeaderBlock, inputSectionBlock, inputBlock, actionsBlock, buttonElement, + actionsBlock, + buttonElement, + inputBlock, + inputSectionBlock, + markdownHeaderBlock, } from '@growi/slack/dist/utils/block-kit-builder'; import { SlackCommandHandlerError } from '~/server/models/vo/slack-command-handler-error'; @@ -20,39 +24,82 @@ module.exports = (crowi) => { }; const { User } = crowi.models; - handler.handleCommand = async(growiCommand, client, body, respondUtil) => { + handler.handleCommand = async (growiCommand, client, body, respondUtil) => { await respondUtil.respond({ text: 'Take a note on GROWI', blocks: [ markdownHeaderBlock('Take a note on GROWI'), - inputBlock(conversationsSelectElement, 'conversation', 'Channel name to display in the page to be created'), + inputBlock( + conversationsSelectElement, + 'conversation', + 'Channel name to display in the page to be created', + ), inputSectionBlock('path', 'Page path', 'path_input', false, '/path'), - inputSectionBlock('contents', 'Contents', 'contents_input', true, 'Input with Markdown...'), + inputSectionBlock( + 'contents', + 'Contents', + 'contents_input', + true, + 'Input with Markdown...', + ), actionsBlock( buttonElement({ text: 'Cancel', actionId: 'note:cancel' }), - buttonElement({ text: 'Create page', actionId: 'note:createPage', style: 'primary' }), + buttonElement({ + text: 'Create page', + actionId: 'note:createPage', + style: 'primary', + }), ), - ], }); }; - handler.cancel = async function(client, interactionPayload, interactionPayloadAccessor, respondUtil) { + handler.cancel = async ( + client, + interactionPayload, + interactionPayloadAccessor, + respondUtil, + ) => { await respondUtil.deleteOriginal(); }; - handler.handleInteractions = async function(client, interactionPayload, interactionPayloadAccessor, handlerMethodName, respondUtil) { - await this[handlerMethodName](client, interactionPayload, interactionPayloadAccessor, respondUtil); + handler.handleInteractions = async function ( + client, + interactionPayload, + interactionPayloadAccessor, + handlerMethodName, + respondUtil, + ) { + await this[handlerMethodName]( + client, + interactionPayload, + interactionPayloadAccessor, + respondUtil, + ); }; - handler.createPage = async function(client, interactionPayload, interactionPayloadAccessor, respondUtil) { + handler.createPage = async ( + client, + interactionPayload, + interactionPayloadAccessor, + respondUtil, + ) => { const user = await User.findUserBySlackMemberId(interactionPayload.user.id); - const path = interactionPayloadAccessor.getStateValues()?.path.path_input.value; - const contentsBody = interactionPayloadAccessor.getStateValues()?.contents.contents_input.value; + const path = + interactionPayloadAccessor.getStateValues()?.path.path_input.value; + const contentsBody = + interactionPayloadAccessor.getStateValues()?.contents.contents_input + .value; if (path == null || contentsBody == null) { throw new SlackCommandHandlerError('All parameters are required.'); } - await createPageService.createPageInGrowi(interactionPayloadAccessor, path, contentsBody, respondUtil, user); + await createPageService.createPageInGrowi( + interactionPayloadAccessor, + path, + contentsBody, + respondUtil, + user, + ); await respondUtil.deleteOriginal(); }; diff --git a/apps/app/src/server/service/slack-command-handler/search.js b/apps/app/src/server/service/slack-command-handler/search.js index 6d8d3e32fb0..88349c2298e 100644 --- a/apps/app/src/server/service/slack-command-handler/search.js +++ b/apps/app/src/server/service/slack-command-handler/search.js @@ -1,5 +1,6 @@ import { - markdownSectionBlock, divider, + divider, + markdownSectionBlock, } from '@growi/slack/dist/utils/block-kit-builder'; import { generateLastUpdateMrkdwn } from '@growi/slack/dist/utils/generate-last-update-markdown'; @@ -7,32 +8,32 @@ import loggerFactory from '~/utils/logger'; import { growiInfoService } from '../growi-info'; - const logger = loggerFactory('growi:service:SlackCommandHandler:search'); const PAGINGLIMIT = 7; - /** @param {import('~/server/crowi').default} crowi Crowi instance */ module.exports = (crowi) => { const BaseSlackCommandHandler = require('./slack-command-handler'); const handler = new BaseSlackCommandHandler(crowi); - function getKeywords(growiCommandArgs) { const keywords = growiCommandArgs.join(' '); return keywords; } function appendSpeechBaloon(mrkdwn, commentCount) { - return (commentCount != null && commentCount > 0) + return commentCount != null && commentCount > 0 ? `${mrkdwn} :speech_balloon: ${commentCount}` : mrkdwn; } function generateSearchResultPageLinkMrkdwn(appUrl, growiCommandArgs) { const url = new URL('/_search', appUrl); - url.searchParams.append('q', growiCommandArgs.map(kwd => encodeURIComponent(kwd)).join('+')); + url.searchParams.append( + 'q', + growiCommandArgs.map((kwd) => encodeURIComponent(kwd)).join('+'), + ); return `<${url.href} | Results page>`; } @@ -45,16 +46,27 @@ module.exports = (crowi) => { const { searchService } = crowi; const options = { limit: PAGINGLIMIT, offset }; - const [results] = await searchService.searchKeyword(keywords, null, {}, options); + const [results] = await searchService.searchKeyword( + keywords, + null, + {}, + options, + ); const resultsTotal = results.meta.total; const pages = results.data.map((data) => { - const { path, updated_at: updatedAt, comment_count: commentCount } = data._source; + const { + path, + updated_at: updatedAt, + comment_count: commentCount, + } = data._source; return { path, updatedAt, commentCount }; }); return { - pages, offset, resultsTotal, + pages, + offset, + resultsTotal, }; } @@ -62,9 +74,7 @@ module.exports = (crowi) => { const appUrl = growiInfoService.getSiteUrl(); const appTitle = crowi.appService.getAppTitle(); - const { - pages, offset, resultsTotal, - } = searchResult; + const { pages, offset, resultsTotal } = searchResult; const keywords = getKeywords(growiCommandArgs); @@ -83,17 +93,20 @@ module.exports = (crowi) => { elements: [ { type: 'mrkdwn', - text: `keyword(s) : *"${keywords}"*` - + ` | Total ${resultsTotal} pages` - + ` | Current: ${offset + 1} - ${offset + pages.length}` - + ` | ${generateSearchResultPageLinkMrkdwn(appUrl, growiCommandArgs)}`, + text: + `keyword(s) : *"${keywords}"*` + + ` | Total ${resultsTotal} pages` + + ` | Current: ${offset + 1} - ${offset + pages.length}` + + ` | ${generateSearchResultPageLinkMrkdwn(appUrl, growiCommandArgs)}`, }, ], }; const now = new Date(); const blocks = [ - markdownSectionBlock(`:mag: <${decodeURI(appUrl)}|*${appTitle}*>\n${searchResultsDesc}`), + markdownSectionBlock( + `:mag: <${decodeURI(appUrl)}|*${appTitle}*>\n${searchResultsDesc}`, + ), contextBlock, { type: 'divider' }, // create an array by map and extract @@ -107,8 +120,9 @@ module.exports = (crowi) => { type: 'section', text: { type: 'mrkdwn', - text: `${appendSpeechBaloon(`*${generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}` - + ` \`${generateLastUpdateMrkdwn(updatedAt, now)}\``, + text: + `${appendSpeechBaloon(`*${generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}` + + ` \`${generateLastUpdateMrkdwn(updatedAt, now)}\``, }, accessory: { type: 'button', @@ -130,45 +144,39 @@ module.exports = (crowi) => { elements: [], }; // add "Dismiss" button - actionBlocks.elements.push( - { - type: 'button', - text: { - type: 'plain_text', - text: 'Dismiss', - }, - style: 'danger', - action_id: 'search:dismissSearchResults', + actionBlocks.elements.push({ + type: 'button', + text: { + type: 'plain_text', + text: 'Dismiss', }, - ); + style: 'danger', + action_id: 'search:dismissSearchResults', + }); // show "Prev" button if previous page exists // eslint-disable-next-line yoda if (0 < offset) { - actionBlocks.elements.push( - { - type: 'button', - text: { - type: 'plain_text', - text: '< Prev', - }, - action_id: 'search:showPrevResults', - value: JSON.stringify({ offset, growiCommandArgs }), + actionBlocks.elements.push({ + type: 'button', + text: { + type: 'plain_text', + text: '< Prev', }, - ); + action_id: 'search:showPrevResults', + value: JSON.stringify({ offset, growiCommandArgs }), + }); } // show "Next" button if next page exists if (offset + PAGINGLIMIT < resultsTotal) { - actionBlocks.elements.push( - { - type: 'button', - text: { - type: 'plain_text', - text: 'Next >', - }, - action_id: 'search:showNextResults', - value: JSON.stringify({ offset, growiCommandArgs }), + actionBlocks.elements.push({ + type: 'button', + text: { + type: 'plain_text', + text: 'Next >', }, - ); + action_id: 'search:showNextResults', + value: JSON.stringify({ offset, growiCommandArgs }), + }); } blocks.push(actionBlocks); @@ -178,7 +186,6 @@ module.exports = (crowi) => { }; } - async function buildRespondBody(growiCommandArgs) { const firstKeyword = growiCommandArgs[0]; @@ -187,7 +194,9 @@ module.exports = (crowi) => { return { text: 'Input keywords', blocks: [ - markdownSectionBlock('*Input keywords.*\n Hint\n `/growi search [keyword]`'), + markdownSectionBlock( + '*Input keywords.*\n Hint\n `/growi search [keyword]`', + ), ], }; } @@ -202,18 +211,30 @@ module.exports = (crowi) => { return { text: `No page found with "${keywords}"`, blocks: [ - markdownSectionBlock(`*No page matches your keyword(s) "${keywords}".*`), + markdownSectionBlock( + `*No page matches your keyword(s) "${keywords}".*`, + ), markdownSectionBlock(':mag: *Help: Searching*'), divider(), - markdownSectionBlock('`word1` `word2` (divide with space) \n Search pages that include both word1, word2 in the title or body'), + markdownSectionBlock( + '`word1` `word2` (divide with space) \n Search pages that include both word1, word2 in the title or body', + ), divider(), - markdownSectionBlock('`"This is GROWI"` (surround with double quotes) \n Search pages that include the phrase "This is GROWI"'), + markdownSectionBlock( + '`"This is GROWI"` (surround with double quotes) \n Search pages that include the phrase "This is GROWI"', + ), divider(), - markdownSectionBlock('`-keyword` \n Exclude pages that include keyword in the title or body'), + markdownSectionBlock( + '`-keyword` \n Exclude pages that include keyword in the title or body', + ), divider(), - markdownSectionBlock('`prefix:/user/` \n Search only the pages that the title start with /user/'), + markdownSectionBlock( + '`prefix:/user/` \n Search only the pages that the title start with /user/', + ), divider(), - markdownSectionBlock('`-prefix:/user/` \n Exclude the pages that the title start with /user/'), + markdownSectionBlock( + '`-prefix:/user/` \n Exclude the pages that the title start with /user/', + ), divider(), markdownSectionBlock('`tag:wiki` \n Search for pages with wiki tag'), divider(), @@ -225,19 +246,34 @@ module.exports = (crowi) => { return buildRespondBodyForSearchResult(searchResult, growiCommandArgs); } - - handler.handleCommand = async function(growiCommand, client, body, respondUtil) { + handler.handleCommand = async (growiCommand, client, body, respondUtil) => { const { growiCommandArgs } = growiCommand; const respondBody = await buildRespondBody(growiCommandArgs); await respondUtil.respond(respondBody); }; - handler.handleInteractions = async function(client, interactionPayload, interactionPayloadAccessor, handlerMethodName, respondUtil) { - await this[handlerMethodName](client, interactionPayload, interactionPayloadAccessor, respondUtil); + handler.handleInteractions = async function ( + client, + interactionPayload, + interactionPayloadAccessor, + handlerMethodName, + respondUtil, + ) { + await this[handlerMethodName]( + client, + interactionPayload, + interactionPayloadAccessor, + respondUtil, + ); }; - handler.shareSinglePageResult = async function(client, payload, interactionPayloadAccessor, respondUtil) { + handler.shareSinglePageResult = async ( + client, + payload, + interactionPayloadAccessor, + respondUtil, + ) => { const { user } = payload; const appUrl = growiInfoService.getSiteUrl(); @@ -247,14 +283,13 @@ module.exports = (crowi) => { if (value == null) { await respondUtil.respond({ text: 'Error occurred', - blocks: [ - markdownSectionBlock('Failed to share the result.'), - ], + blocks: [markdownSectionBlock('Failed to share the result.')], }); return; } - const parsedValue = interactionPayloadAccessor.getOriginalData() || JSON.parse(value); + const parsedValue = + interactionPayloadAccessor.getOriginalData() || JSON.parse(value); // restore page data from value const { page, href, pathname } = parsedValue; @@ -265,15 +300,18 @@ module.exports = (crowi) => { return respondUtil.respondInChannel({ blocks: [ { type: 'divider' }, - markdownSectionBlock(`${appendSpeechBaloon(`*${generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`), + markdownSectionBlock( + `${appendSpeechBaloon(`*${generatePageLinkMrkdwn(pathname, href)}*`, commentCount)}`, + ), { type: 'context', elements: [ { type: 'mrkdwn', - text: `<${decodeURI(appUrl)}|*${appTitle}*>` - + ` | Last updated: \`${generateLastUpdateMrkdwn(updatedAt, now)}\`` - + ` | Shared by *${user.username}*`, + text: + `<${decodeURI(appUrl)}|*${appTitle}*>` + + ` | Last updated: \`${generateLastUpdateMrkdwn(updatedAt, now)}\`` + + ` | Shared by *${user.username}*`, }, ], }, @@ -281,42 +319,58 @@ module.exports = (crowi) => { }); }; - async function showPrevOrNextResults(interactionPayloadAccessor, isNext = true, respondUtil) { - + async function showPrevOrNextResults( + interactionPayloadAccessor, + isNext = true, + respondUtil, + ) { const value = interactionPayloadAccessor.firstAction()?.value; if (value == null) { await respondUtil.respond({ text: 'Error occurred', - blocks: [ - markdownSectionBlock('Failed to show the next results.'), - ], + blocks: [markdownSectionBlock('Failed to show the next results.')], }); return; } - const parsedValue = interactionPayloadAccessor.getOriginalData() || JSON.parse(value); + const parsedValue = + interactionPayloadAccessor.getOriginalData() || JSON.parse(value); const { growiCommandArgs, offset: offsetNum } = parsedValue; const newOffsetNum = isNext ? offsetNum + PAGINGLIMIT : offsetNum - PAGINGLIMIT; - const searchResult = await retrieveSearchResults(growiCommandArgs, newOffsetNum); + const searchResult = await retrieveSearchResults( + growiCommandArgs, + newOffsetNum, + ); - await respondUtil.replaceOriginal(buildRespondBodyForSearchResult(searchResult, growiCommandArgs)); + await respondUtil.replaceOriginal( + buildRespondBodyForSearchResult(searchResult, growiCommandArgs), + ); } - handler.showPrevResults = async function(client, payload, interactionPayloadAccessor, respondUtil) { - return showPrevOrNextResults(interactionPayloadAccessor, false, respondUtil); - }; - - handler.showNextResults = async function(client, payload, interactionPayloadAccessor, respondUtil) { - return showPrevOrNextResults(interactionPayloadAccessor, true, respondUtil); - }; - - handler.dismissSearchResults = async function(client, payload, interactionPayloadAccessor, respondUtil) { - return respondUtil.deleteOriginal(); - }; + handler.showPrevResults = async ( + client, + payload, + interactionPayloadAccessor, + respondUtil, + ) => showPrevOrNextResults(interactionPayloadAccessor, false, respondUtil); + + handler.showNextResults = async ( + client, + payload, + interactionPayloadAccessor, + respondUtil, + ) => showPrevOrNextResults(interactionPayloadAccessor, true, respondUtil); + + handler.dismissSearchResults = async ( + client, + payload, + interactionPayloadAccessor, + respondUtil, + ) => respondUtil.deleteOriginal(); return handler; }; diff --git a/apps/app/src/server/service/slack-command-handler/slack-command-handler.js b/apps/app/src/server/service/slack-command-handler/slack-command-handler.js index 447c444c679..1c7b3fc533e 100644 --- a/apps/app/src/server/service/slack-command-handler/slack-command-handler.js +++ b/apps/app/src/server/service/slack-command-handler/slack-command-handler.js @@ -1,6 +1,5 @@ // Any slack command handler should inherit BaseSlackCommandHandler class BaseSlackCommandHandler { - /** @type {import('~/server/crowi').default} Crowi instance */ crowi; @@ -12,13 +11,21 @@ class BaseSlackCommandHandler { /** * Handle /commands endpoint */ - handleCommand(growiCommand, client, body) { throw new Error('Implement this') } + handleCommand(growiCommand, client, body) { + throw new Error('Implement this'); + } /** * Handle interactions */ - handleInteractions(client, interactionPayload, interactionPayloadAccessor, handlerMethodName) { throw new Error('Implement this') } - + handleInteractions( + client, + interactionPayload, + interactionPayloadAccessor, + handlerMethodName, + ) { + throw new Error('Implement this'); + } } module.exports = BaseSlackCommandHandler; diff --git a/apps/app/src/server/service/slack-command-handler/togetter.js b/apps/app/src/server/service/slack-command-handler/togetter.js index 164515ff5f3..44c36b3505f 100644 --- a/apps/app/src/server/service/slack-command-handler/togetter.js +++ b/apps/app/src/server/service/slack-command-handler/togetter.js @@ -1,7 +1,11 @@ import { - inputBlock, actionsBlock, buttonElement, markdownSectionBlock, divider, + actionsBlock, + buttonElement, + divider, + inputBlock, + markdownSectionBlock, } from '@growi/slack/dist/utils/block-kit-builder'; -import { respond, deleteOriginal } from '@growi/slack/dist/utils/response-url'; +import { deleteOriginal, respond } from '@growi/slack/dist/utils/response-url'; import { format, formatDate } from 'date-fns/format'; import { parse } from 'date-fns/parse'; @@ -18,7 +22,7 @@ module.exports = (crowi) => { const BaseSlackCommandHandler = require('./slack-command-handler'); const handler = new BaseSlackCommandHandler(); - handler.handleCommand = async function(growiCommand, client, body) { + handler.handleCommand = async function (growiCommand, client, body) { await respond(growiCommand.responseUrl, { text: 'Select messages to use.', blocks: this.togetterMessageBlocks(), @@ -26,23 +30,40 @@ module.exports = (crowi) => { return; }; - handler.handleInteractions = async function(client, interactionPayload, interactionPayloadAccessor, handlerMethodName) { - await this[handlerMethodName](client, interactionPayload, interactionPayloadAccessor); + handler.handleInteractions = async function ( + client, + interactionPayload, + interactionPayloadAccessor, + handlerMethodName, + ) { + await this[handlerMethodName]( + client, + interactionPayload, + interactionPayloadAccessor, + ); }; - handler.cancel = async function(client, payload, interactionPayloadAccessor) { + handler.cancel = async (client, payload, interactionPayloadAccessor) => { await deleteOriginal(interactionPayloadAccessor.getResponseUrl(), { delete_original: true, }); }; - handler.createPage = async function(client, payload, interactionPayloadAccessor) { + handler.createPage = async function ( + client, + payload, + interactionPayloadAccessor, + ) { let result = []; const channelId = payload.channel.id; // this must exist since the type is always block_actions const userChannelId = payload.user.id; // validate form - const { path, oldest, newest } = await this.togetterValidateForm(client, payload, interactionPayloadAccessor); + const { path, oldest, newest } = await this.togetterValidateForm( + client, + payload, + interactionPayloadAccessor, + ); // get messages result = await this.togetterGetMessages(client, channelId, newest, oldest); // clean messages @@ -50,37 +71,65 @@ module.exports = (crowi) => { const contentsBody = cleanedContents.join(''); // create and send url message - await this.togetterCreatePageAndSendPreview(client, interactionPayloadAccessor, path, userChannelId, contentsBody); + await this.togetterCreatePageAndSendPreview( + client, + interactionPayloadAccessor, + path, + userChannelId, + contentsBody, + ); }; - handler.togetterValidateForm = async function(client, payload, interactionPayloadAccessor) { + handler.togetterValidateForm = async ( + client, + payload, + interactionPayloadAccessor, + ) => { const grwTzoffset = crowi.appService.getTzoffset() * 60; - const path = interactionPayloadAccessor.getStateValues()?.page_path.page_path.value; - let oldest = interactionPayloadAccessor.getStateValues()?.oldest.oldest.value; - let newest = interactionPayloadAccessor.getStateValues()?.newest.newest.value; + const path = + interactionPayloadAccessor.getStateValues()?.page_path.page_path.value; + let oldest = + interactionPayloadAccessor.getStateValues()?.oldest.oldest.value; + let newest = + interactionPayloadAccessor.getStateValues()?.newest.newest.value; if (oldest == null || newest == null || path == null) { - throw new SlackCommandHandlerError('All parameters are required. (Oldest datetime, Newst datetime and Page path)'); + throw new SlackCommandHandlerError( + 'All parameters are required. (Oldest datetime, Newst datetime and Page path)', + ); } /** * RegExp for datetime yyyy/MM/dd-HH:mm * @see https://regex101.com/r/XbxdNo/1 */ - const regexpDatetime = new RegExp(/^[12]\d\d\d\/(0[1-9]|1[012])\/(0[1-9]|[12][0-9]|3[01])-([01][0-9]|2[0123]):[0-5][0-9]$/); + const regexpDatetime = new RegExp( + /^[12]\d\d\d\/(0[1-9]|1[012])\/(0[1-9]|[12][0-9]|3[01])-([01][0-9]|2[0123]):[0-5][0-9]$/, + ); if (!regexpDatetime.test(oldest.trim())) { - throw new SlackCommandHandlerError('Datetime format for oldest must be yyyy/MM/dd-HH:mm'); + throw new SlackCommandHandlerError( + 'Datetime format for oldest must be yyyy/MM/dd-HH:mm', + ); } if (!regexpDatetime.test(newest.trim())) { - throw new SlackCommandHandlerError('Datetime format for newest must be yyyy/MM/dd-HH:mm'); + throw new SlackCommandHandlerError( + 'Datetime format for newest must be yyyy/MM/dd-HH:mm', + ); } - oldest = parse(oldest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset; + oldest = + parse(oldest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + + grwTzoffset; // + 60s in order to include messages between hh:mm.00s and hh:mm.59s - newest = parse(newest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + grwTzoffset + 60; + newest = + parse(newest, 'yyyy/MM/dd-HH:mm', new Date()).getTime() / 1000 + + grwTzoffset + + 60; if (oldest > newest) { - throw new SlackCommandHandlerError('Oldest datetime must be older than the newest date time.'); + throw new SlackCommandHandlerError( + 'Oldest datetime must be older than the newest date time.', + ); } return { path, oldest, newest }; @@ -96,14 +145,13 @@ module.exports = (crowi) => { }); } - handler.togetterGetMessages = async function(client, channelId, newest, oldest) { + handler.togetterGetMessages = async (client, channelId, newest, oldest) => { let result; // first attempt try { result = await retrieveHistory(client, channelId, newest, oldest); - } - catch (err) { + } catch (err) { const errorCode = err.data?.errorCode; if (errorCode === 'not_in_channel') { @@ -112,12 +160,11 @@ module.exports = (crowi) => { channel: channelId, }); result = await retrieveHistory(client, channelId, newest, oldest); - } - else if (errorCode === 'channel_not_found') { - - const message = ':cry: GROWI Bot couldn\'t get history data because *this channel was private*.' - + '\nPlease add GROWI bot to this channel.' - + '\n'; + } else if (errorCode === 'channel_not_found') { + const message = + ":cry: GROWI Bot couldn't get history data because *this channel was private*." + + '\nPlease add GROWI bot to this channel.' + + '\n'; throw new SlackCommandHandlerError(message, { respondBody: { text: message, @@ -125,26 +172,28 @@ module.exports = (crowi) => { markdownSectionBlock(message), { type: 'image', - image_url: 'https://user-images.githubusercontent.com/1638767/135834548-2f6b8ce6-30a7-4d47-9fdc-a58ddd692b7e.png', + image_url: + 'https://user-images.githubusercontent.com/1638767/135834548-2f6b8ce6-30a7-4d47-9fdc-a58ddd692b7e.png', alt_text: 'Add app to this channel', }, ], }, }); - } - else { + } else { throw err; } } // return if no message found if (result.messages.length === 0) { - throw new SlackCommandHandlerError('No message found from togetter command. Try different datetime.'); + throw new SlackCommandHandlerError( + 'No message found from togetter command. Try different datetime.', + ); } return result; }; - handler.togetterCleanMessages = async function(messages) { + handler.togetterCleanMessages = async (messages) => { const cleanedContents = []; let lastMessage = {}; const grwTzoffset = crowi.appService.getTzoffset() * 60; @@ -171,8 +220,18 @@ module.exports = (crowi) => { return cleanedContents; }; - handler.togetterCreatePageAndSendPreview = async function(client, interactionPayloadAccessor, path, userChannelId, contentsBody) { - await createPageService.createPageInGrowi(interactionPayloadAccessor, path, contentsBody); + handler.togetterCreatePageAndSendPreview = async ( + client, + interactionPayloadAccessor, + path, + userChannelId, + contentsBody, + ) => { + await createPageService.createPageInGrowi( + interactionPayloadAccessor, + path, + contentsBody, + ); // send preview to dm await client.chat.postMessage({ @@ -191,15 +250,33 @@ module.exports = (crowi) => { }); }; - handler.togetterMessageBlocks = function() { + handler.togetterMessageBlocks = function () { return [ - markdownSectionBlock('Select the oldest and newest datetime of the messages to use.'), - inputBlock(this.plainTextInputElementWithInitialTime('oldest'), 'oldest', 'Oldest datetime'), - inputBlock(this.plainTextInputElementWithInitialTime('newest'), 'newest', 'Newest datetime'), - inputBlock(this.togetterInputBlockElement('page_path', '/'), 'page_path', 'Page path'), + markdownSectionBlock( + 'Select the oldest and newest datetime of the messages to use.', + ), + inputBlock( + this.plainTextInputElementWithInitialTime('oldest'), + 'oldest', + 'Oldest datetime', + ), + inputBlock( + this.plainTextInputElementWithInitialTime('newest'), + 'newest', + 'Newest datetime', + ), + inputBlock( + this.togetterInputBlockElement('page_path', '/'), + 'page_path', + 'Page path', + ), actionsBlock( buttonElement({ text: 'Cancel', actionId: 'togetter:cancel' }), - buttonElement({ text: 'Create page', actionId: 'togetter:createPage', style: 'primary' }), + buttonElement({ + text: 'Create page', + actionId: 'togetter:createPage', + style: 'primary', + }), ), ]; }; @@ -208,21 +285,25 @@ module.exports = (crowi) => { * Plain-text input element * https://api.slack.com/reference/block-kit/block-elements#input */ - handler.togetterInputBlockElement = function(actionId, placeholderText = 'Write something ...') { - return { - type: 'plain_text_input', - placeholder: { - type: 'plain_text', - text: placeholderText, - }, - action_id: actionId, - }; - }; + handler.togetterInputBlockElement = ( + actionId, + placeholderText = 'Write something ...', + ) => ({ + type: 'plain_text_input', + placeholder: { + type: 'plain_text', + text: placeholderText, + }, + action_id: actionId, + }); - handler.plainTextInputElementWithInitialTime = function(actionId) { + handler.plainTextInputElementWithInitialTime = (actionId) => { const tzDateSec = new Date().getTime(); const grwTzoffset = crowi.appService.getTzoffset() * 60 * 1000; - const initialDateTime = format(new Date(tzDateSec - grwTzoffset), 'yyyy/MM/dd-HH:mm'); + const initialDateTime = format( + new Date(tzDateSec - grwTzoffset), + 'yyyy/MM/dd-HH:mm', + ); return { type: 'plain_text_input', action_id: actionId, diff --git a/apps/app/src/server/service/slack-event-handler/base-event-handler.ts b/apps/app/src/server/service/slack-event-handler/base-event-handler.ts index e85f2470e53..e0a7819fc6c 100644 --- a/apps/app/src/server/service/slack-event-handler/base-event-handler.ts +++ b/apps/app/src/server/service/slack-event-handler/base-event-handler.ts @@ -4,9 +4,15 @@ import type { WebClient } from '@slack/web-api'; import type { EventActionsPermission } from '../../interfaces/slack-integration/events'; export interface SlackEventHandler { + shouldHandle( + eventType: string, + permission: EventActionsPermission, + channel?: string, + ): boolean; - shouldHandle(eventType: string, permission: EventActionsPermission, channel?: string): boolean - - handleEvent(client: WebClient, growiBotEvent: GrowiBotEvent, data?: any): Promise - + handleEvent( + client: WebClient, + growiBotEvent: GrowiBotEvent, + data?: any, + ): Promise; } diff --git a/apps/app/src/server/service/slack-event-handler/link-shared.ts b/apps/app/src/server/service/slack-event-handler/link-shared.ts index f126d3d5577..ff7143928db 100644 --- a/apps/app/src/server/service/slack-event-handler/link-shared.ts +++ b/apps/app/src/server/service/slack-event-handler/link-shared.ts @@ -1,9 +1,7 @@ -import { PageGrant, type IPage } from '@growi/core'; +import { type IPage, PageGrant } from '@growi/core'; import type { GrowiBotEvent } from '@growi/slack'; import { generateLastUpdateMrkdwn } from '@growi/slack/dist/utils/generate-last-update-markdown'; -import type { - MessageAttachment, LinkUnfurls, WebClient, -} from '@slack/web-api'; +import type { LinkUnfurls, MessageAttachment, WebClient } from '@slack/web-api'; import mongoose from 'mongoose'; import urljoin from 'url-join'; @@ -13,23 +11,30 @@ import type { PageModel } from '~/server/models/page'; import loggerFactory from '~/utils/logger'; import type { - DataForUnfurl, PublicData, UnfurlEventLink, UnfurlRequestEvent, + DataForUnfurl, + PublicData, + UnfurlEventLink, + UnfurlRequestEvent, } from '../../interfaces/slack-integration/link-shared-unfurl'; import { growiInfoService } from '../growi-info'; - import type { SlackEventHandler } from './base-event-handler'; const logger = loggerFactory('growi:service:SlackEventHandler:link-shared'); -export class LinkSharedEventHandler implements SlackEventHandler { - +export class LinkSharedEventHandler + implements SlackEventHandler +{ crowi: Crowi; constructor(crowi: Crowi) { this.crowi = crowi; } - shouldHandle(eventType: string, permission: EventActionsPermission, channel: string): boolean { + shouldHandle( + eventType: string, + permission: EventActionsPermission, + channel: string, + ): boolean { if (eventType !== 'link_shared') return false; const unfurlPermission = permission.get('unfurl'); @@ -41,7 +46,11 @@ export class LinkSharedEventHandler implements SlackEventHandler, data?: {origin: string}): Promise { + async handleEvent( + client: WebClient, + growiBotEvent: GrowiBotEvent, + data?: { origin: string }, + ): Promise { const { event } = growiBotEvent; const origin = data?.origin || growiInfoService.getSiteUrl(); const { channel, message_ts: ts, links } = event; @@ -49,49 +58,56 @@ export class LinkSharedEventHandler implements SlackEventHandler { - const toUrl = urljoin(origin, data.id); - - let targetUrl; - if (data.isPermalink) { - targetUrl = urljoin(origin, data.id); - } - else { - targetUrl = urljoin(origin, data.path); - } - - let unfurls: LinkUnfurls; - - if (data.isPublic === false) { - unfurls = { - [targetUrl]: { - text: 'Page is not public.', - }, - }; - } - else { - unfurls = this.generateLinkUnfurls(data as PublicData, targetUrl, toUrl); - } - - await client.chat.unfurl({ - channel, - ts, - unfurls, - }); - })); + const unfurlResults = await Promise.allSettled( + unfurlData.map(async (data: DataForUnfurl) => { + const toUrl = urljoin(origin, data.id); + + let targetUrl; + if (data.isPermalink) { + targetUrl = urljoin(origin, data.id); + } else { + targetUrl = urljoin(origin, data.path); + } + + let unfurls: LinkUnfurls; + + if (data.isPublic === false) { + unfurls = { + [targetUrl]: { + text: 'Page is not public.', + }, + }; + } else { + unfurls = this.generateLinkUnfurls( + data as PublicData, + targetUrl, + toUrl, + ); + } + + await client.chat.unfurl({ + channel, + ts, + unfurls, + }); + }), + ); this.logErrorRejectedResults(unfurlResults); } // builder method for unfurl parameter - generateLinkUnfurls(body: PublicData, growiTargetUrl: string, toUrl: string): LinkUnfurls { + generateLinkUnfurls( + body: PublicData, + growiTargetUrl: string, + toUrl: string, + ): LinkUnfurls { const { pageBody: text, updatedAt } = body; const appTitle = this.crowi.appService.getAppTitle(); @@ -101,8 +117,9 @@ export class LinkSharedEventHandler implements SlackEventHandler` - + ` | Last updated: \`${generateLastUpdateMrkdwn(updatedAt, new Date())}\``, + footer: + `<${decodeURI(siteUrl)}|*${appTitle}*>` + + ` | Last updated: \`${generateLastUpdateMrkdwn(updatedAt, new Date())}\``, }; const unfurls: LinkUnfurls = { @@ -111,7 +128,9 @@ export class LinkSharedEventHandler implements SlackEventHandler { + async generateUnfurlsObject( + links: UnfurlEventLink[], + ): Promise { // generate paths array const pathOrIds: string[] = links.map((link) => { const { url: growiTargetUrl } = link; @@ -121,8 +140,10 @@ export class LinkSharedEventHandler implements SlackEventHandler !idRegExp.test(pathOrId)); - const ids = pathOrIds.filter(pathOrId => idRegExp.test(pathOrId)).map(id => id.replace('/', '')); // remove a slash + const paths = pathOrIds.filter((pathOrId) => !idRegExp.test(pathOrId)); + const ids = pathOrIds + .filter((pathOrId) => idRegExp.test(pathOrId)) + .map((id) => id.replace('/', '')); // remove a slash // get pages with revision const Page = mongoose.model('Page'); @@ -131,27 +152,34 @@ export class LinkSharedEventHandler implements SlackEventHandler(results: PromiseSettledResult[]): void { - const rejectedResults: PromiseRejectedResult[] = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected'); + const rejectedResults: PromiseRejectedResult[] = results.filter( + (result): result is PromiseRejectedResult => result.status === 'rejected', + ); rejectedResults.forEach((rejected, i) => { - logger.error(`Error occurred (count: ${i}): `, rejected.reason.toString()); + logger.error( + `Error occurred (count: ${i}): `, + rejected.reason.toString(), + ); }); } - } diff --git a/apps/app/src/server/service/socket-io/helper.ts b/apps/app/src/server/service/socket-io/helper.ts index 167e3a255f9..9f8791f7aa5 100644 --- a/apps/app/src/server/service/socket-io/helper.ts +++ b/apps/app/src/server/service/socket-io/helper.ts @@ -3,6 +3,9 @@ export const RoomPrefix = { PAGE: 'page', }; -export const getRoomNameWithId = (roomPrefix: string, roomId: string): string => { +export const getRoomNameWithId = ( + roomPrefix: string, + roomId: string, +): string => { return `${roomPrefix}:${roomId}`; }; diff --git a/apps/app/src/server/service/socket-io/socket-io.ts b/apps/app/src/server/service/socket-io/socket-io.ts index e5ab1245621..f243d2520fe 100644 --- a/apps/app/src/server/service/socket-io/socket-io.ts +++ b/apps/app/src/server/service/socket-io/socket-io.ts @@ -1,7 +1,6 @@ -import type { IncomingMessage } from 'http'; - import type { IUserHasId } from '@growi/core/dist/interfaces'; import expressSession from 'express-session'; +import type { IncomingMessage } from 'http'; import passport from 'passport'; import type { Namespace } from 'socket.io'; import { Server } from 'socket.io'; @@ -11,20 +10,16 @@ import loggerFactory from '~/utils/logger'; import type Crowi from '../../crowi'; import { configManager } from '../config-manager'; - -import { RoomPrefix, getRoomNameWithId } from './helper'; - +import { getRoomNameWithId, RoomPrefix } from './helper'; const logger = loggerFactory('growi:service:socket-io'); - type RequestWithUser = IncomingMessage & { user: IUserHasId }; /** * Serve socket.io for server-to-client messaging */ export class SocketIoService { - crowi: Crowi; guestClients: Set; @@ -33,14 +28,13 @@ export class SocketIoService { adminNamespace: Namespace; - constructor(crowi: Crowi) { this.crowi = crowi; this.guestClients = new Set(); } get isInitialized(): boolean { - return (this.io != null); + return this.io != null; } // Since the Order is important, attachServer() should be async @@ -95,9 +89,13 @@ export class SocketIoService { * use loginRequired middleware */ setupLoginRequiredMiddleware() { - const loginRequired = require('../../middlewares/login-required')(this.crowi, true, (req, res, next) => { - next(new Error('Login is required to connect.')); - }); + const loginRequired = require('../../middlewares/login-required')( + this.crowi, + true, + (req, res, next) => { + next(new Error('Login is required to connect.')); + }, + ); // convert Connect/Express middleware to Socket.io middleware this.io.use((socket, next) => { @@ -109,9 +107,12 @@ export class SocketIoService { * use adminRequired middleware */ setupAdminRequiredMiddleware() { - const adminRequired = require('../../middlewares/admin-required')(this.crowi, (req, res, next) => { - next(new Error('Admin priviledge is required to connect.')); - }); + const adminRequired = require('../../middlewares/admin-required')( + this.crowi, + (req, res, next) => { + next(new Error('Admin priviledge is required to connect.')); + }, + ); // convert Connect/Express middleware to Socket.io middleware this.getAdminSocket().use((socket, next) => { @@ -175,9 +176,11 @@ export class SocketIoService { const clients = await this.getAdminSocket().fetchSockets(); const clientsCount = clients.length; - logger.debug('Current count of clients for \'/admin\':', clientsCount); + logger.debug("Current count of clients for '/admin':", clientsCount); - const limit = configManager.getConfig('s2cMessagingPubsub:connectionsLimitForAdmin'); + const limit = configManager.getConfig( + 's2cMessagingPubsub:connectionsLimitForAdmin', + ); if (limit <= clientsCount) { const msg = `The connection was refused because the current count of clients for '/admin' is ${clientsCount} and exceeds the limit`; logger.warn(msg); @@ -190,13 +193,14 @@ export class SocketIoService { } async checkConnectionLimitsForGuest(socket, next) { - if (socket.request.user == null) { const clientsCount = this.guestClients.size; logger.debug('Current count of clients for guests:', clientsCount); - const limit = configManager.getConfig('s2cMessagingPubsub:connectionsLimitForGuest'); + const limit = configManager.getConfig( + 's2cMessagingPubsub:connectionsLimitForGuest', + ); if (limit <= clientsCount) { const msg = `The connection was refused because the current count of clients for guests is ${clientsCount} and exceeds the limit`; logger.warn(msg); @@ -221,9 +225,11 @@ export class SocketIoService { const clients = await this.getDefaultSocket().fetchSockets(); const clientsCount = clients.length; - logger.debug('Current count of clients for \'/\':', clientsCount); + logger.debug("Current count of clients for '/':", clientsCount); - const limit = configManager.getConfig('s2cMessagingPubsub:connectionsLimit'); + const limit = configManager.getConfig( + 's2cMessagingPubsub:connectionsLimit', + ); if (limit <= clientsCount) { const msg = `The connection was refused because the current count of clients for '/' is ${clientsCount} and exceeds the limit`; logger.warn(msg); @@ -233,5 +239,4 @@ export class SocketIoService { next(); } - } diff --git a/apps/app/src/server/service/system-events/sync-page-status.ts b/apps/app/src/server/service/system-events/sync-page-status.ts index c7f8bc2e54c..e1779b3dfe7 100644 --- a/apps/app/src/server/service/system-events/sync-page-status.ts +++ b/apps/app/src/server/service/system-events/sync-page-status.ts @@ -5,9 +5,11 @@ import { S2cMessagePageUpdated } from '../../models/vo/s2c-message'; import S2sMessage from '../../models/vo/s2s-message'; import type { S2sMessagingService } from '../s2s-messaging/base'; import type { S2sMessageHandlable } from '../s2s-messaging/handlable'; -import { RoomPrefix, getRoomNameWithId } from '../socket-io/helper'; +import { getRoomNameWithId, RoomPrefix } from '../socket-io/helper'; -const logger = loggerFactory('growi:service:system-events:SyncPageStatusService'); +const logger = loggerFactory( + 'growi:service:system-events:SyncPageStatusService', +); /** * This service notify page status @@ -23,7 +25,6 @@ const logger = loggerFactory('growi:service:system-events:SyncPageStatusService' * */ class SyncPageStatusService implements S2sMessageHandlable { - crowi: Crowi; s2sMessagingService!: S2sMessagingService; @@ -63,7 +64,9 @@ class SyncPageStatusService implements S2sMessageHandlable { // emit the updated information to clients if (socketIoService.isInitialized) { - socketIoService.getDefaultSocket().emit(socketIoEventName, s2cMessageBody); + socketIoService + .getDefaultSocket() + .emit(socketIoEventName, s2cMessageBody); } } @@ -71,13 +74,18 @@ class SyncPageStatusService implements S2sMessageHandlable { const { s2sMessagingService } = this; if (s2sMessagingService != null) { - const s2sMessage = new S2sMessage('pageStatusUpdated', { socketIoEventName, s2cMessageBody }); + const s2sMessage = new S2sMessage('pageStatusUpdated', { + socketIoEventName, + s2cMessageBody, + }); try { await s2sMessagingService.publish(s2sMessage); - } - catch (e) { - logger.error('Failed to publish update message with S2sMessagingService: ', e.message); + } catch (e) { + logger.error( + 'Failed to publish update message with S2sMessagingService: ', + e.message, + ); } } } @@ -87,12 +95,13 @@ class SyncPageStatusService implements S2sMessageHandlable { // register events this.emitter.on('create', (page, user) => { - logger.debug('\'create\' event emitted.'); + logger.debug("'create' event emitted."); const s2cMessagePageUpdated = new S2cMessagePageUpdated(page, user); // emit to the room for each page - socketIoService.getDefaultSocket() + socketIoService + .getDefaultSocket() .in(getRoomNameWithId(RoomPrefix.PAGE, page._id)) .except(getRoomNameWithId(RoomPrefix.USER, user._id)) .emit('page:create', { s2cMessagePageUpdated }); @@ -100,12 +109,13 @@ class SyncPageStatusService implements S2sMessageHandlable { this.publishToOtherServers('page:create', { s2cMessagePageUpdated }); }); this.emitter.on('update', (page, user) => { - logger.debug('\'update\' event emitted.'); + logger.debug("'update' event emitted."); const s2cMessagePageUpdated = new S2cMessagePageUpdated(page, user); // emit to the room for each page - socketIoService.getDefaultSocket() + socketIoService + .getDefaultSocket() .in(getRoomNameWithId(RoomPrefix.PAGE, page._id)) .except(getRoomNameWithId(RoomPrefix.USER, user._id)) .emit('page:update', { s2cMessagePageUpdated }); @@ -113,12 +123,13 @@ class SyncPageStatusService implements S2sMessageHandlable { this.publishToOtherServers('page:update', { s2cMessagePageUpdated }); }); this.emitter.on('delete', (page, user) => { - logger.debug('\'delete\' event emitted.'); + logger.debug("'delete' event emitted."); const s2cMessagePageUpdated = new S2cMessagePageUpdated(page, user); // emit to the room for each page - socketIoService.getDefaultSocket() + socketIoService + .getDefaultSocket() .in(getRoomNameWithId(RoomPrefix.PAGE, page._id)) .except(getRoomNameWithId(RoomPrefix.USER, user._id)) .emit('page:delete', { s2cMessagePageUpdated }); @@ -126,7 +137,6 @@ class SyncPageStatusService implements S2sMessageHandlable { this.publishToOtherServers('page:delete', { s2cMessagePageUpdated }); }); } - } module.exports = SyncPageStatusService; diff --git a/apps/app/src/server/service/user-notification/index.ts b/apps/app/src/server/service/user-notification/index.ts index 3846c8392c5..43adad9ab96 100644 --- a/apps/app/src/server/service/user-notification/index.ts +++ b/apps/app/src/server/service/user-notification/index.ts @@ -3,10 +3,9 @@ import type { IRevisionHasId } from '@growi/core'; import type Crowi from '~/server/crowi'; import { toArrayFromCsv } from '~/utils/to-array-from-csv'; - import { - prepareSlackMessageForPage, prepareSlackMessageForComment, + prepareSlackMessageForPage, } from '../../util/slack'; import { growiInfoService } from '../growi-info'; @@ -14,7 +13,6 @@ import { growiInfoService } from '../growi-info'; * service class of UserNotification */ export class UserNotificationService { - crowi: Crowi; constructor(crowi: Crowi) { @@ -34,10 +32,15 @@ export class UserNotificationService { * @param {Comment} comment */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - async fire(page, user, slackChannelsStr, mode, option?: { previousRevision: IRevisionHasId }, comment = {}): Promise[]> { - const { - appService, slackIntegrationService, - } = this.crowi; + async fire( + page, + user, + slackChannelsStr, + mode, + option?: { previousRevision: IRevisionHasId }, + comment = {}, + ): Promise[]> { + const { appService, slackIntegrationService } = this.crowi; if (!slackIntegrationService.isSlackConfigured) { throw new Error('slackIntegrationService has not been set up'); @@ -49,18 +52,32 @@ export class UserNotificationService { const { previousRevision } = option ?? {}; // "dev,slacktest" => [dev,slacktest] - const slackChannels: (string|null)[] = toArrayFromCsv(slackChannelsStr); + const slackChannels: (string | null)[] = toArrayFromCsv(slackChannelsStr); const appTitle = appService.getAppTitle(); const siteUrl = growiInfoService.getSiteUrl(); - const promises = slackChannels.map(async(chan) => { + const promises = slackChannels.map(async (chan) => { let messageObj; if (mode === 'comment') { - messageObj = prepareSlackMessageForComment(comment, user, appTitle, siteUrl, chan, page.path); - } - else { - messageObj = prepareSlackMessageForPage(page, user, appTitle, siteUrl, chan, mode, previousRevision); + messageObj = prepareSlackMessageForComment( + comment, + user, + appTitle, + siteUrl, + chan, + page.path, + ); + } else { + messageObj = prepareSlackMessageForPage( + page, + user, + appTitle, + siteUrl, + chan, + mode, + previousRevision, + ); } return slackIntegrationService.postMessage(messageObj); @@ -68,5 +85,4 @@ export class UserNotificationService { return Promise.allSettled(promises); } - } diff --git a/apps/app/src/server/service/yjs/create-indexes.ts b/apps/app/src/server/service/yjs/create-indexes.ts index 9f3d2f45fcc..351d0a73b08 100644 --- a/apps/app/src/server/service/yjs/create-indexes.ts +++ b/apps/app/src/server/service/yjs/create-indexes.ts @@ -4,8 +4,7 @@ import loggerFactory from '~/utils/logger'; const logger = loggerFactory('growi:service:yjs:create-indexes'); -export const createIndexes = async(collectionName: string): Promise => { - +export const createIndexes = async (collectionName: string): Promise => { const collection = mongoose.connection.collection(collectionName); try { @@ -35,8 +34,7 @@ export const createIndexes = async(collectionName: string): Promise => { }, }, ]); - } - catch (err) { + } catch (err) { logger.error('Failed to create Index', err); throw err; } diff --git a/apps/app/src/server/service/yjs/create-mongodb-persistence.ts b/apps/app/src/server/service/yjs/create-mongodb-persistence.ts index 57aa71abf06..6ca735149dc 100644 --- a/apps/app/src/server/service/yjs/create-mongodb-persistence.ts +++ b/apps/app/src/server/service/yjs/create-mongodb-persistence.ts @@ -12,10 +12,12 @@ const logger = loggerFactory('growi:service:yjs:create-mongodb-persistence'); * @param mdb * @returns */ -export const createMongoDBPersistence = (mdb: MongodbPersistence): Persistence => { +export const createMongoDBPersistence = ( + mdb: MongodbPersistence, +): Persistence => { const persistece: Persistence = { provider: mdb, - bindState: async(docName, ydoc) => { + bindState: async (docName, ydoc) => { logger.debug('bindState', { docName }); const persistedYdoc = await mdb.getYDoc(docName); @@ -25,7 +27,12 @@ export const createMongoDBPersistence = (mdb: MongodbPersistence): Persistence = const diff = Y.encodeStateAsUpdate(ydoc, persistedStateVector); // store the new data in db (if there is any: empty update is an array of 0s) - if (diff.reduce((previousValue, currentValue) => previousValue + currentValue, 0) > 0) { + if ( + diff.reduce( + (previousValue, currentValue) => previousValue + currentValue, + 0, + ) > 0 + ) { mdb.storeUpdate(docName, diff); mdb.setTypedMeta(docName, 'updatedAt', Date.now()); } @@ -34,7 +41,7 @@ export const createMongoDBPersistence = (mdb: MongodbPersistence): Persistence = Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(persistedYdoc)); // store updates of the document in db - ydoc.on('update', async(update) => { + ydoc.on('update', async (update) => { mdb.storeUpdate(docName, update); mdb.setTypedMeta(docName, 'updatedAt', Date.now()); }); @@ -42,7 +49,7 @@ export const createMongoDBPersistence = (mdb: MongodbPersistence): Persistence = // cleanup some memory persistedYdoc.destroy(); }, - writeState: async(docName) => { + writeState: async (docName) => { logger.debug('writeState', { docName }); // This is called when all connections to the document are closed. diff --git a/apps/app/src/server/service/yjs/extended/mongodb-persistence.ts b/apps/app/src/server/service/yjs/extended/mongodb-persistence.ts index 4bd44a85d26..28995b4cd92 100644 --- a/apps/app/src/server/service/yjs/extended/mongodb-persistence.ts +++ b/apps/app/src/server/service/yjs/extended/mongodb-persistence.ts @@ -1,19 +1,25 @@ import { MongodbPersistence as Original } from 'y-mongodb-provider'; export type MetadataTypesMap = { - updatedAt: number, -} + updatedAt: number; +}; type MetadataKeys = keyof MetadataTypesMap; - export class MongodbPersistence extends Original { - - async setTypedMeta(docName: string, key: K, value: MetadataTypesMap[K]): Promise { + async setTypedMeta( + docName: string, + key: K, + value: MetadataTypesMap[K], + ): Promise { return this.setMeta(docName, key, value); } - async getTypedMeta(docName: string, key: K): Promise { - return await this.getMeta(docName, key) as MetadataTypesMap[K] | undefined; + async getTypedMeta( + docName: string, + key: K, + ): Promise { + return (await this.getMeta(docName, key)) as + | MetadataTypesMap[K] + | undefined; } - } diff --git a/apps/app/src/server/service/yjs/sync-ydoc.ts b/apps/app/src/server/service/yjs/sync-ydoc.ts index b6dd822797d..d3da2ce037c 100644 --- a/apps/app/src/server/service/yjs/sync-ydoc.ts +++ b/apps/app/src/server/service/yjs/sync-ydoc.ts @@ -1,20 +1,18 @@ import { Origin, YDocStatus } from '@growi/core'; -import { type Delta } from '@growi/editor'; +import type { Delta } from '@growi/editor'; import type { Document } from 'y-socket.io/dist/server'; import loggerFactory from '~/utils/logger'; import { Revision } from '../../models/revision'; import { normalizeLatestRevisionIfBroken } from '../revision/normalize-latest-revision-if-broken'; - import type { MongodbPersistence } from './extended/mongodb-persistence'; const logger = loggerFactory('growi:service:yjs:sync-ydoc'); - type Context = { - ydocStatus: YDocStatus, -} + ydocStatus: YDocStatus; +}; /** * Sync the text and the meta data with the latest revision body @@ -22,30 +20,35 @@ type Context = { * @param doc * @param context true to force sync */ -export const syncYDoc = async(mdb: MongodbPersistence, doc: Document, context: true | Context): Promise => { +export const syncYDoc = async ( + mdb: MongodbPersistence, + doc: Document, + context: true | Context, +): Promise => { const pageId = doc.name; // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js' await normalizeLatestRevisionIfBroken(pageId); - const revision = await Revision - .findOne( - // filter - { pageId }, - // projection - { body: 1, createdAt: 1, origin: 1 }, - // options - { sort: { createdAt: -1 } }, - ) - .lean(); + const revision = await Revision.findOne( + // filter + { pageId }, + // projection + { body: 1, createdAt: 1, origin: 1 }, + // options + { sort: { createdAt: -1 } }, + ).lean(); if (revision == null) { - logger.warn(`Synchronization has been canceled since the revision of the page ('${pageId}') could not be found`); + logger.warn( + `Synchronization has been canceled since the revision of the page ('${pageId}') could not be found`, + ); return; } - const shouldSync = context === true - || (() => { + const shouldSync = + context === true || + (() => { switch (context.ydocStatus) { case YDocStatus.NEW: return true; @@ -58,7 +61,9 @@ export const syncYDoc = async(mdb: MongodbPersistence, doc: Document, context: t })(); if (shouldSync) { - logger.debug(`YDoc for the page ('${pageId}') is synced with the latest revision body`); + logger.debug( + `YDoc for the page ('${pageId}') is synced with the latest revision body`, + ); const ytext = doc.getText('codemirror'); const delta: Delta = []; @@ -73,11 +78,16 @@ export const syncYDoc = async(mdb: MongodbPersistence, doc: Document, context: t ytext.applyDelta(delta, { sanitize: false }); } - const shouldSyncMeta = context === true - || context.ydocStatus === YDocStatus.NEW - || context.ydocStatus === YDocStatus.OUTDATED; + const shouldSyncMeta = + context === true || + context.ydocStatus === YDocStatus.NEW || + context.ydocStatus === YDocStatus.OUTDATED; if (shouldSyncMeta) { - mdb.setMeta(doc.name, 'updatedAt', revision.createdAt.getTime() ?? Date.now()); + mdb.setMeta( + doc.name, + 'updatedAt', + revision.createdAt.getTime() ?? Date.now(), + ); } }; diff --git a/apps/app/src/server/service/yjs/yjs.integ.ts b/apps/app/src/server/service/yjs/yjs.integ.ts index f49a84f9185..2e6e786d7d9 100644 --- a/apps/app/src/server/service/yjs/yjs.integ.ts +++ b/apps/app/src/server/service/yjs/yjs.integ.ts @@ -4,12 +4,10 @@ import type { Server } from 'socket.io'; import { mock } from 'vitest-mock-extended'; import { Revision } from '../../models/revision'; - import type { MongodbPersistence } from './extended/mongodb-persistence'; import type { IYjsService } from './yjs'; import { getYjsService, initializeYjsService } from './yjs'; - vi.mock('y-socket.io/dist/server', () => { const YSocketIO = vi.fn(); YSocketIO.prototype.on = vi.fn(); @@ -23,24 +21,21 @@ vi.mock('../revision/normalize-latest-revision-if-broken', () => ({ const ObjectId = Types.ObjectId; - const getPrivateMdbInstance = (yjsService: IYjsService): MongodbPersistence => { // eslint-disable-next-line dot-notation return yjsService['mdb']; }; describe('YjsService', () => { - describe('getYDocStatus()', () => { - - beforeAll(async() => { + beforeAll(async () => { const ioMock = mock(); // initialize initializeYjsService(ioMock); }); - afterAll(async() => { + afterAll(async () => { // flush revisions await Revision.deleteMany({}); @@ -50,7 +45,7 @@ describe('YjsService', () => { await privateMdb.flushDB(); }); - it('returns ISOLATED when neither revisions nor YDocs exists', async() => { + it('returns ISOLATED when neither revisions nor YDocs exists', async () => { // arrange const yjsService = getYjsService(); @@ -63,7 +58,7 @@ describe('YjsService', () => { expect(result).toBe(YDocStatus.ISOLATED); }); - it('returns ISOLATED when no revisions exist', async() => { + it('returns ISOLATED when no revisions exist', async () => { // arrange const yjsService = getYjsService(); @@ -79,15 +74,13 @@ describe('YjsService', () => { expect(result).toBe(YDocStatus.ISOLATED); }); - it('returns NEW when no YDocs exist', async() => { + it('returns NEW when no YDocs exist', async () => { // arrange const yjsService = getYjsService(); const pageId = new ObjectId(); - await Revision.insertMany([ - { pageId, body: '' }, - ]); + await Revision.insertMany([{ pageId, body: '' }]); // act const result = await yjsService.getYDocStatus(pageId.toString()); @@ -96,18 +89,20 @@ describe('YjsService', () => { expect(result).toBe(YDocStatus.NEW); }); - it('returns DRAFT when the newer YDocs exist', async() => { + it('returns DRAFT when the newer YDocs exist', async () => { // arrange const yjsService = getYjsService(); const pageId = new ObjectId(); - await Revision.insertMany([ - { pageId, body: '' }, - ]); + await Revision.insertMany([{ pageId, body: '' }]); const privateMdb = getPrivateMdbInstance(yjsService); - await privateMdb.setTypedMeta(pageId.toString(), 'updatedAt', (new Date(2034, 1, 1)).getTime()); + await privateMdb.setTypedMeta( + pageId.toString(), + 'updatedAt', + new Date(2034, 1, 1).getTime(), + ); // act const result = await yjsService.getYDocStatus(pageId.toString()); @@ -116,7 +111,7 @@ describe('YjsService', () => { expect(result).toBe(YDocStatus.DRAFT); }); - it('returns SYNCED', async() => { + it('returns SYNCED', async () => { // arrange const yjsService = getYjsService(); @@ -127,7 +122,11 @@ describe('YjsService', () => { ]); const privateMdb = getPrivateMdbInstance(yjsService); - await privateMdb.setTypedMeta(pageId.toString(), 'updatedAt', (new Date(2025, 1, 1)).getTime()); + await privateMdb.setTypedMeta( + pageId.toString(), + 'updatedAt', + new Date(2025, 1, 1).getTime(), + ); // act const result = await yjsService.getYDocStatus(pageId.toString()); @@ -136,18 +135,20 @@ describe('YjsService', () => { expect(result).toBe(YDocStatus.SYNCED); }); - it('returns OUTDATED when the latest revision is newer than meta data', async() => { + it('returns OUTDATED when the latest revision is newer than meta data', async () => { // arrange const yjsService = getYjsService(); const pageId = new ObjectId(); - await Revision.insertMany([ - { pageId, body: '' }, - ]); + await Revision.insertMany([{ pageId, body: '' }]); const privateMdb = getPrivateMdbInstance(yjsService); - await privateMdb.setTypedMeta(pageId.toString(), 'updatedAt', (new Date(2024, 1, 1)).getTime()); + await privateMdb.setTypedMeta( + pageId.toString(), + 'updatedAt', + new Date(2024, 1, 1).getTime(), + ); // act const result = await yjsService.getYDocStatus(pageId.toString()); @@ -155,6 +156,5 @@ describe('YjsService', () => { // assert expect(result).toBe(YDocStatus.OUTDATED); }); - }); }); diff --git a/apps/app/src/server/service/yjs/yjs.ts b/apps/app/src/server/service/yjs/yjs.ts index 4f2846591c5..1080e0dc12c 100644 --- a/apps/app/src/server/service/yjs/yjs.ts +++ b/apps/app/src/server/service/yjs/yjs.ts @@ -1,57 +1,53 @@ -import type { IncomingMessage } from 'http'; - - import type { IPage, IUserHasId } from '@growi/core'; import { YDocStatus } from '@growi/core/dist/consts'; +import type { IncomingMessage } from 'http'; import mongoose from 'mongoose'; import type { Server } from 'socket.io'; import type { Document } from 'y-socket.io/dist/server'; -import { YSocketIO, type Document as Ydoc } from 'y-socket.io/dist/server'; +import { type Document as Ydoc, YSocketIO } from 'y-socket.io/dist/server'; import { SocketEventName } from '~/interfaces/websocket'; import type { SyncLatestRevisionBody } from '~/interfaces/yjs'; -import { RoomPrefix, getRoomNameWithId } from '~/server/service/socket-io/helper'; +import { + getRoomNameWithId, + RoomPrefix, +} from '~/server/service/socket-io/helper'; import loggerFactory from '~/utils/logger'; import type { PageModel } from '../../models/page'; import { Revision } from '../../models/revision'; import { normalizeLatestRevisionIfBroken } from '../revision/normalize-latest-revision-if-broken'; - import { createIndexes } from './create-indexes'; import { createMongoDBPersistence } from './create-mongodb-persistence'; import { MongodbPersistence } from './extended/mongodb-persistence'; import { syncYDoc } from './sync-ydoc'; - const MONGODB_PERSISTENCE_COLLECTION_NAME = 'yjs-writings'; const MONGODB_PERSISTENCE_FLUSH_SIZE = 100; - const logger = loggerFactory('growi:service:yjs'); - type RequestWithUser = IncomingMessage & { user: IUserHasId }; - export interface IYjsService { getYDocStatus(pageId: string): Promise; - syncWithTheLatestRevisionForce(pageId: string, editingMarkdownLength?: number): Promise + syncWithTheLatestRevisionForce( + pageId: string, + editingMarkdownLength?: number, + ): Promise; getCurrentYdoc(pageId: string): Ydoc | undefined; } - class YjsService implements IYjsService { - private ysocketio: YSocketIO; private mdb: MongodbPersistence; constructor(io: Server) { - const mdb = new MongodbPersistence( // ignore TS2345: Argument of type '{ client: any; db: any; }' is not assignable to parameter of type 'string'. // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore + // @ts-expect-error { // TODO: Required upgrading mongoose and unifying the versions of mongodb to omit 'as any' // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -78,7 +74,7 @@ class YjsService implements IYjsService { // register middlewares this.registerAccessiblePageChecker(ysocketio); - ysocketio.on('document-loaded', async(doc: Document) => { + ysocketio.on('document-loaded', async (doc: Document) => { const pageId = doc.name; const ydocStatus = await this.getYDocStatus(pageId); @@ -86,7 +82,7 @@ class YjsService implements IYjsService { syncYDoc(mdb, doc, { ydocStatus }); }); - ysocketio.on('awareness-update', async(doc: Document) => { + ysocketio.on('awareness-update', async (doc: Document) => { const pageId = doc.name; if (pageId == null) return; @@ -94,24 +90,29 @@ class YjsService implements IYjsService { const awarenessStateSize = doc.awareness.states.size; // Triggered when awareness changes - io - .in(getRoomNameWithId(RoomPrefix.PAGE, pageId)) - .emit(SocketEventName.YjsAwarenessStateSizeUpdated, awarenessStateSize); + io.in(getRoomNameWithId(RoomPrefix.PAGE, pageId)).emit( + SocketEventName.YjsAwarenessStateSizeUpdated, + awarenessStateSize, + ); // Triggered when the last user leaves the editor if (awarenessStateSize === 0) { const ydocStatus = await this.getYDocStatus(pageId); - const hasYdocsNewerThanLatestRevision = ydocStatus === YDocStatus.DRAFT || ydocStatus === YDocStatus.ISOLATED; + const hasYdocsNewerThanLatestRevision = + ydocStatus === YDocStatus.DRAFT || ydocStatus === YDocStatus.ISOLATED; - io - .in(getRoomNameWithId(RoomPrefix.PAGE, pageId)) - .emit(SocketEventName.YjsHasYdocsNewerThanLatestRevisionUpdated, hasYdocsNewerThanLatestRevision); + io.in(getRoomNameWithId(RoomPrefix.PAGE, pageId)).emit( + SocketEventName.YjsHasYdocsNewerThanLatestRevisionUpdated, + hasYdocsNewerThanLatestRevision, + ); } }); - } - private injectPersistence(ysocketio: YSocketIO, mdb: MongodbPersistence): void { + private injectPersistence( + ysocketio: YSocketIO, + mdb: MongodbPersistence, + ): void { const persistece = createMongoDBPersistence(mdb); // foce set to private property @@ -121,7 +122,7 @@ class YjsService implements IYjsService { private registerAccessiblePageChecker(ysocketio: YSocketIO): void { // check accessible page - ysocketio.nsp?.use(async(socket, next) => { + ysocketio.nsp?.use(async (socket, next) => { // extract page id from namespace const pageId = socket.nsp.name.replace(/\/yjs\|/, ''); const user = (socket.request as RequestWithUser).user; // should be injected by SocketIOService @@ -139,22 +140,23 @@ class YjsService implements IYjsService { public async getYDocStatus(pageId: string): Promise { const dumpLog = (status: YDocStatus, args?: { [key: string]: unknown }) => { - logger.debug(`getYDocStatus('${pageId}') detected '${status}'`, args ?? {}); + logger.debug( + `getYDocStatus('${pageId}') detected '${status}'`, + args ?? {}, + ); }; // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js' await normalizeLatestRevisionIfBroken(pageId); // get the latest revision createdAt - const result = await Revision - .findOne( - // filter - { pageId }, - // projection - { createdAt: 1 }, - { sort: { createdAt: -1 } }, - ) - .lean(); + const result = await Revision.findOne( + // filter + { pageId }, + // projection + { createdAt: 1 }, + { sort: { createdAt: -1 } }, + ).lean(); if (result == null) { dumpLog(YDocStatus.ISOLATED, { result }); @@ -186,7 +188,10 @@ class YjsService implements IYjsService { return YDocStatus.OUTDATED; } - public async syncWithTheLatestRevisionForce(pageId: string, editingMarkdownLength?: number): Promise { + public async syncWithTheLatestRevisionForce( + pageId: string, + editingMarkdownLength?: number, + ): Promise { const doc = this.ysocketio.documents.get(pageId); if (doc == null) { @@ -198,9 +203,10 @@ class YjsService implements IYjsService { return { synced: true, - isYjsDataBroken: editingMarkdownLength != null - ? editingMarkdownLength !== ytextLength - : undefined, + isYjsDataBroken: + editingMarkdownLength != null + ? editingMarkdownLength !== ytextLength + : undefined, }; } @@ -208,7 +214,6 @@ class YjsService implements IYjsService { const currentYdoc = this.ysocketio.documents.get(pageId); return currentYdoc; } - } let _instance: YjsService; diff --git a/biome.json b/biome.json index f5b9cd5a9e2..fc4ba4842bf 100644 --- a/biome.json +++ b/biome.json @@ -31,8 +31,6 @@ "!apps/app/src/client", "!apps/app/src/server/middlewares", "!apps/app/src/server/routes/apiv3/*.js", - "!apps/app/src/server/service/access-token", - "!apps/app/src/server/service/config-manager", "!apps/app/src/server/service/file-uploader", "!apps/app/src/server/service/global-notification", "!apps/app/src/server/service/growi-bridge", @@ -41,18 +39,7 @@ "!apps/app/src/server/service/in-app-notification", "!apps/app/src/server/service/interfaces", "!apps/app/src/server/service/normalize-data", - "!apps/app/src/server/service/page", - "!apps/app/src/server/service/page-listing", - "!apps/app/src/server/service/revision", - "!apps/app/src/server/service/s2s-messaging", - "!apps/app/src/server/service/search-delegator", - "!apps/app/src/server/service/search-reconnect-context", - "!apps/app/src/server/service/slack-command-handler", - "!apps/app/src/server/service/slack-event-handler", - "!apps/app/src/server/service/socket-io", - "!apps/app/src/server/service/system-events", - "!apps/app/src/server/service/user-notification", - "!apps/app/src/server/service/yjs" + "!apps/app/src/server/service/page" ] }, "formatter": { From f3dcd1e5291ea2b5e6d4fe3462425fc202be5ad8 Mon Sep 17 00:00:00 2001 From: Futa Arai Date: Sun, 7 Dec 2025 22:13:41 +0900 Subject: [PATCH 2/4] fix non-autofixable biome errors --- .../config-manager/config-manager.spec.ts | 4 ++-- .../src/server/service/s2s-messaging/nchan.ts | 6 +++--- .../service/search-delegator/elasticsearch.ts | 6 ++++-- .../search-delegator/private-legacy-pages.ts | 16 ++++++++++++---- .../service/slack-event-handler/link-shared.ts | 9 +++++---- 5 files changed, 26 insertions(+), 15 deletions(-) diff --git a/apps/app/src/server/service/config-manager/config-manager.spec.ts b/apps/app/src/server/service/config-manager/config-manager.spec.ts index 27a2a505e9d..56a317666fa 100644 --- a/apps/app/src/server/service/config-manager/config-manager.spec.ts +++ b/apps/app/src/server/service/config-manager/config-manager.spec.ts @@ -32,7 +32,7 @@ describe('ConfigManager test', () => { }); describe('updateConfig()', () => { - let loadConfigsSpy; + let loadConfigsSpy: ReturnType; beforeEach(async () => { loadConfigsSpy = vi.spyOn(configManager, 'loadConfigs'); // Reset mocks @@ -122,7 +122,7 @@ describe('ConfigManager test', () => { }); describe('updateConfigs()', () => { - let loadConfigsSpy; + let loadConfigsSpy: ReturnType; beforeEach(async () => { loadConfigsSpy = vi.spyOn(configManager, 'loadConfigs'); // Reset mocks diff --git a/apps/app/src/server/service/s2s-messaging/nchan.ts b/apps/app/src/server/service/s2s-messaging/nchan.ts index 22954ad208d..8937bce03ba 100644 --- a/apps/app/src/server/service/s2s-messaging/nchan.ts +++ b/apps/app/src/server/service/s2s-messaging/nchan.ts @@ -141,9 +141,9 @@ class NchanDelegator extends AbstractS2sMessagingService { logger.info('WebSocket client connected.'); }); - this.handlableList.forEach((handlable) => - this.registerMessageHandlerToSocket(handlable), - ); + this.handlableList.forEach((handlable) => { + this.registerMessageHandlerToSocket(handlable); + }); this.socket = socket; } diff --git a/apps/app/src/server/service/search-delegator/elasticsearch.ts b/apps/app/src/server/service/search-delegator/elasticsearch.ts index 3cd11c3b233..50580b8c05d 100644 --- a/apps/app/src/server/service/search-delegator/elasticsearch.ts +++ b/apps/app/src/server/service/search-delegator/elasticsearch.ts @@ -182,7 +182,7 @@ class ElasticsearchDelegator getConnectionInfo() { let indexName = 'crowi'; let host: string | undefined; - let auth; + let auth: { username: string; password: string } | undefined; const elasticsearchUri = configManager.getConfig('app:elasticsearchUri'); @@ -642,7 +642,9 @@ class ElasticsearchDelegator deletePages(pages) { const body = []; - pages.forEach((page) => this.prepareBodyForDelete(body, page)); + pages.forEach((page) => { + this.prepareBodyForDelete(body, page); + }); logger.debug('deletePages(): Sending Request to ES', body); return this.client.bulk({ diff --git a/apps/app/src/server/service/search-delegator/private-legacy-pages.ts b/apps/app/src/server/service/search-delegator/private-legacy-pages.ts index 3421ad03c39..3f21c91c63b 100644 --- a/apps/app/src/server/service/search-delegator/private-legacy-pages.ts +++ b/apps/app/src/server/service/search-delegator/private-legacy-pages.ts @@ -85,16 +85,24 @@ class PrivateLegacyPagesDelegator const { match, not_match: notMatch, prefix, not_prefix: notPrefix } = terms; if (match.length > 0) { - match.forEach((m) => builder.addConditionToListByMatch(m)); + for (const m of match) { + builder.addConditionToListByMatch(m); + } } if (notMatch.length > 0) { - notMatch.forEach((nm) => builder.addConditionToListByNotMatch(nm)); + for (const nm of notMatch) { + builder.addConditionToListByNotMatch(nm); + } } if (prefix.length > 0) { - prefix.forEach((p) => builder.addConditionToListByStartWith(p)); + for (const p of prefix) { + builder.addConditionToListByStartWith(p); + } } if (notPrefix.length > 0) { - notPrefix.forEach((np) => builder.addConditionToListByNotStartWith(np)); + for (const np of notPrefix) { + builder.addConditionToListByNotStartWith(np); + } } return builder; diff --git a/apps/app/src/server/service/slack-event-handler/link-shared.ts b/apps/app/src/server/service/slack-event-handler/link-shared.ts index ff7143928db..ec7dfef9abe 100644 --- a/apps/app/src/server/service/slack-event-handler/link-shared.ts +++ b/apps/app/src/server/service/slack-event-handler/link-shared.ts @@ -68,7 +68,7 @@ export class LinkSharedEventHandler unfurlData.map(async (data: DataForUnfurl) => { const toUrl = urljoin(origin, data.id); - let targetUrl; + let targetUrl: string; if (data.isPermalink) { targetUrl = urljoin(origin, data.id); } else { @@ -183,15 +183,16 @@ export class LinkSharedEventHandler const Page = this.crowi.model('Page'); const unfurlData: DataForUnfurl[] = []; - pages.forEach((page) => { + for (const page of pages) { // not send non-public page if (page.grant !== PageGrant.GRANT_PUBLIC) { - return unfurlData.push({ + unfurlData.push({ isPublic: false, isPermalink, id: page._id.toString(), path: page.path, }); + continue; } // public page @@ -206,7 +207,7 @@ export class LinkSharedEventHandler updatedAt, commentCount, }); - }); + } return unfurlData; } From 633cfb6e43cce9b4f33b1598ccfa8dafb19b71fd Mon Sep 17 00:00:00 2001 From: Futa Arai Date: Sun, 7 Dec 2025 22:20:37 +0900 Subject: [PATCH 3/4] ignore non-fixable biome errors --- .../src/server/service/search-delegator/aggregate-to-index.ts | 2 ++ apps/app/src/server/service/user-notification/index.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/apps/app/src/server/service/search-delegator/aggregate-to-index.ts b/apps/app/src/server/service/search-delegator/aggregate-to-index.ts index f08380d74e1..40c8a3e8891 100644 --- a/apps/app/src/server/service/search-delegator/aggregate-to-index.ts +++ b/apps/app/src/server/service/search-delegator/aggregate-to-index.ts @@ -110,6 +110,7 @@ export const aggregatePipelineToIndex = ( 'revision.body': { $cond: { if: { $lte: ['$bodyLength', maxBodyLengthToIndex] }, + // biome-ignore lint/suspicious/noThenProperty: ignore then: '$revision.body', else: '', }, @@ -121,6 +122,7 @@ export const aggregatePipelineToIndex = ( in: { $cond: { if: { $lte: ['$$comment.commentLength', maxBodyLengthToIndex] }, + // biome-ignore lint/suspicious/noThenProperty: ignore then: '$$comment.comment', else: '', }, diff --git a/apps/app/src/server/service/user-notification/index.ts b/apps/app/src/server/service/user-notification/index.ts index 43adad9ab96..cbb3a7a468e 100644 --- a/apps/app/src/server/service/user-notification/index.ts +++ b/apps/app/src/server/service/user-notification/index.ts @@ -58,6 +58,7 @@ export class UserNotificationService { const siteUrl = growiInfoService.getSiteUrl(); const promises = slackChannels.map(async (chan) => { + // biome-ignore lint/suspicious/noImplicitAnyLet: ignore let messageObj; if (mode === 'comment') { messageObj = prepareSlackMessageForComment( From 9306b8d2817784c6f4fceda8e68afda1326d10c8 Mon Sep 17 00:00:00 2001 From: Futa Arai Date: Sun, 7 Dec 2025 23:04:56 +0900 Subject: [PATCH 4/4] remove unnecessary ignore comment --- apps/app/src/server/service/yjs/yjs.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/app/src/server/service/yjs/yjs.ts b/apps/app/src/server/service/yjs/yjs.ts index 1080e0dc12c..755105bbaf0 100644 --- a/apps/app/src/server/service/yjs/yjs.ts +++ b/apps/app/src/server/service/yjs/yjs.ts @@ -45,9 +45,6 @@ class YjsService implements IYjsService { constructor(io: Server) { const mdb = new MongodbPersistence( - // ignore TS2345: Argument of type '{ client: any; db: any; }' is not assignable to parameter of type 'string'. - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error { // TODO: Required upgrading mongoose and unifying the versions of mongodb to omit 'as any' // eslint-disable-next-line @typescript-eslint/no-explicit-any