Browse Source

Merge branch 'support/apply-nextjs-2' into imprv/99687-show-admin-navigation

kaori 3 years ago
parent
commit
9fe3d53bcf
100 changed files with 3186 additions and 545 deletions
  1. 10 1
      .github/workflows/reusable-app-prod.yml
  2. 5 0
      packages/app/.env.development
  3. 83 0
      packages/app/config/rate-limiter.ts
  4. 0 3
      packages/app/docker/Dockerfile
  5. 3 2
      packages/app/package.json
  6. 16 0
      packages/app/public/static/locales/en_US/admin/admin.json
  7. 2 0
      packages/app/public/static/locales/en_US/translation.json
  8. 16 0
      packages/app/public/static/locales/ja_JP/admin/admin.json
  9. 2 0
      packages/app/public/static/locales/ja_JP/translation.json
  10. 16 0
      packages/app/public/static/locales/zh_CN/admin/admin.json
  11. 2 0
      packages/app/public/static/locales/zh_CN/translation.json
  12. 2 0
      packages/app/src/client/admin.jsx
  13. 5 1
      packages/app/src/client/services/ContextExtractor.tsx
  14. 47 0
      packages/app/src/components/Admin/AuditLog/ActivityTable.tsx
  15. 26 0
      packages/app/src/components/Admin/AuditLog/AuditLogDisableMode.tsx
  16. 54 0
      packages/app/src/components/Admin/AuditLog/AuditLogSettings.tsx
  17. 67 0
      packages/app/src/components/Admin/AuditLog/DateRangePicker.tsx
  18. 122 0
      packages/app/src/components/Admin/AuditLog/SearchUsernameTypeahead.tsx
  19. 123 0
      packages/app/src/components/Admin/AuditLog/SelectActionDropdown.tsx
  20. 181 0
      packages/app/src/components/Admin/AuditLogManagement.tsx
  21. 5 1
      packages/app/src/components/Admin/Common/AdminNavigation.jsx
  22. 7 2
      packages/app/src/components/BookmarkButtons.tsx
  23. 2 1
      packages/app/src/components/CompleteUserRegistrationForm.tsx
  24. 1 0
      packages/app/src/components/DescendantsPageListModal.tsx
  25. 1 1
      packages/app/src/components/InstallerForm.jsx
  26. 7 2
      packages/app/src/components/LikeButtons.tsx
  27. 17 2
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  28. 1 2
      packages/app/src/components/Navbar/GrowiSubNavigation.tsx
  29. 4 6
      packages/app/src/components/Navbar/SubNavButtons.tsx
  30. 1 0
      packages/app/src/components/Page/DisplaySwitcher.tsx
  31. 33 25
      packages/app/src/components/PagePathHierarchicalLink.tsx
  32. 1 9
      packages/app/src/components/Sidebar.tsx
  33. 6 3
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  34. 28 20
      packages/app/src/components/Sidebar/RecentChanges.tsx
  35. 3 5
      packages/app/src/components/SubscribeButton.tsx
  36. 1 1
      packages/app/src/components/User/SeenUserInfo.tsx
  37. 479 9
      packages/app/src/interfaces/activity.ts
  38. 13 0
      packages/app/src/interfaces/mongoose-utils.ts
  39. 2 0
      packages/app/src/server/crowi/index.js
  40. 10 13
      packages/app/src/server/events/activity.ts
  41. 2 2
      packages/app/src/server/events/comment.ts
  42. 40 0
      packages/app/src/server/middlewares/add-activity.ts
  43. 138 0
      packages/app/src/server/middlewares/rate-limiter.ts
  44. 88 27
      packages/app/src/server/models/activity.ts
  45. 2 2
      packages/app/src/server/models/comment.js
  46. 3 0
      packages/app/src/server/models/config.ts
  47. 2 2
      packages/app/src/server/models/in-app-notification-settings.ts
  48. 9 9
      packages/app/src/server/models/in-app-notification.ts
  49. 5 5
      packages/app/src/server/models/subscription.ts
  50. 22 2
      packages/app/src/server/routes/admin.js
  51. 105 0
      packages/app/src/server/routes/apiv3/activity.ts
  52. 39 11
      packages/app/src/server/routes/apiv3/app-settings.js
  53. 17 6
      packages/app/src/server/routes/apiv3/bookmarks.js
  54. 32 9
      packages/app/src/server/routes/apiv3/customize-setting.js
  55. 9 1
      packages/app/src/server/routes/apiv3/export.js
  56. 1 9
      packages/app/src/server/routes/apiv3/forgot-password.js
  57. 17 3
      packages/app/src/server/routes/apiv3/import.js
  58. 13 1
      packages/app/src/server/routes/apiv3/in-app-notification.ts
  59. 2 1
      packages/app/src/server/routes/apiv3/index.js
  60. 12 1
      packages/app/src/server/routes/apiv3/logout.js
  61. 26 4
      packages/app/src/server/routes/apiv3/markdown-setting.js
  62. 51 9
      packages/app/src/server/routes/apiv3/notification-setting.js
  63. 39 7
      packages/app/src/server/routes/apiv3/page.js
  64. 42 20
      packages/app/src/server/routes/apiv3/pages.js
  65. 47 9
      packages/app/src/server/routes/apiv3/personal-setting.js
  66. 13 1
      packages/app/src/server/routes/apiv3/search.js
  67. 101 13
      packages/app/src/server/routes/apiv3/security-setting.js
  68. 23 5
      packages/app/src/server/routes/apiv3/share-links.js
  69. 11 1
      packages/app/src/server/routes/apiv3/slack-integration-legacy-settings.js
  70. 16 6
      packages/app/src/server/routes/apiv3/slack-integration-settings.js
  71. 21 4
      packages/app/src/server/routes/apiv3/user-group.js
  72. 21 4
      packages/app/src/server/routes/apiv3/users.js
  73. 19 0
      packages/app/src/server/routes/attachment.js
  74. 19 1
      packages/app/src/server/routes/comment.js
  75. 35 35
      packages/app/src/server/routes/index.js
  76. 7 0
      packages/app/src/server/routes/installer.js
  77. 78 6
      packages/app/src/server/routes/login-passport.js
  78. 6 1
      packages/app/src/server/routes/login.js
  79. 0 11
      packages/app/src/server/routes/logout.js
  80. 108 0
      packages/app/src/server/routes/page.js
  81. 27 1
      packages/app/src/server/routes/search.ts
  82. 5 0
      packages/app/src/server/routes/tag.js
  83. 127 16
      packages/app/src/server/service/activity.ts
  84. 4 45
      packages/app/src/server/service/comment.ts
  85. 30 0
      packages/app/src/server/service/config-loader.ts
  86. 51 9
      packages/app/src/server/service/in-app-notification.ts
  87. 2 109
      packages/app/src/server/service/page.ts
  88. 84 0
      packages/app/src/server/util/rate-limiter.ts
  89. 11 0
      packages/app/src/server/views/admin/audit-log.html
  90. 1 1
      packages/app/src/server/views/invited.html
  91. 1 1
      packages/app/src/server/views/login.html
  92. 18 0
      packages/app/src/stores/activity.ts
  93. 16 2
      packages/app/src/stores/context.tsx
  94. 18 8
      packages/app/src/stores/page.tsx
  95. 8 7
      packages/app/src/stores/ui.tsx
  96. 31 2
      packages/app/src/stores/user.tsx
  97. 7 0
      packages/app/src/styles/_admin.scss
  98. 1 3
      packages/app/src/styles/atoms/_buttons.scss
  99. 95 0
      packages/app/test/cypress/integration/20-basic-features/access-to-pagelist.spec.ts
  100. 4 4
      packages/app/test/integration/service/page.test.js

+ 10 - 1
.github/workflows/reusable-app-prod.yml

@@ -59,7 +59,16 @@ jobs:
     - name: Archive production files
     - name: Archive production files
       id: archive-prod-files
       id: archive-prod-files
       run: |
       run: |
-        tar -cf production.tar packages/**/dist packages/app/public
+        tar -cf production.tar \
+          package.json \
+          packages/app/config \
+          packages/app/public \
+          packages/app/resource \
+          packages/app/tmp \
+          packages/app/migrate-mongo-config.js \
+          packages/app/.env.production* \
+          packages/*/package.json \
+          packages/*/dist
         echo ::set-output name=file::production.tar
         echo ::set-output name=file::production.tar
 
 
     - name: Upload production files as artifact
     - name: Upload production files as artifact

+ 5 - 0
packages/app/.env.development

@@ -29,3 +29,8 @@ OGP_URI="http://ogp:8088"
 # SLACKBOT_WITHOUT_PROXY_BOT_TOKEN=''
 # SLACKBOT_WITHOUT_PROXY_BOT_TOKEN=''
 # GROWI_CLOUD_URI='http://growi.cloud'
 # GROWI_CLOUD_URI='http://growi.cloud'
 # GROWI_APP_ID_FOR_GROWI_CLOUD=012345
 # GROWI_APP_ID_FOR_GROWI_CLOUD=012345
+# AUDIT_LOG_ENABLED=false
+# ACTIVITY_EXPIRATION_SECONDS=2592000
+# AUDIT_LOG_ACTION_GROUP_SIZE=SMALL
+# AUDIT_LOG_ADDITIONAL_ACTIONS=
+# AUDIT_LOG_EXCLUDE_ACTIONS=

+ 83 - 0
packages/app/config/rate-limiter.ts

@@ -0,0 +1,83 @@
+export type IApiRateLimitConfig = {
+  method: string,
+  maxRequests: number,
+  usersPerIpProspection?: number,
+}
+export type IApiRateLimitEndpointMap = {
+  [endpoint: string]: IApiRateLimitConfig
+}
+
+export const DEFAULT_MAX_REQUESTS = 500;
+export const DEFAULT_DURATION_SEC = 60;
+export const DEFAULT_USERS_PER_IP_PROSPECTION = 5;
+
+const MAX_REQUESTS_TIER_1 = 5;
+const MAX_REQUESTS_TIER_2 = 20;
+const MAX_REQUESTS_TIER_3 = 50;
+const MAX_REQUESTS_TIER_4 = 100;
+
+// default config without reg exp
+export const defaultConfig: IApiRateLimitEndpointMap = {
+  '/_api/v3/healthcheck': {
+    method: 'GET',
+    maxRequests: 60,
+    usersPerIpProspection: 1,
+  },
+  '/installer': {
+    method: 'POST',
+    maxRequests: MAX_REQUESTS_TIER_1,
+    usersPerIpProspection: 1,
+  },
+  '/login': {
+    method: 'POST',
+    maxRequests: MAX_REQUESTS_TIER_1,
+    usersPerIpProspection: 100,
+  },
+  '/login/activateInvited': {
+    method: 'POST',
+    maxRequests: MAX_REQUESTS_TIER_2,
+  },
+  '/register': {
+    method: 'POST',
+    maxRequests: MAX_REQUESTS_TIER_1,
+    usersPerIpProspection: 20,
+  },
+  '/user-activation/register': {
+    method: 'POST',
+    maxRequests: MAX_REQUESTS_TIER_1,
+    usersPerIpProspection: 20,
+  },
+  '/_api/login/testLdap': {
+    method: 'POST',
+    maxRequests: MAX_REQUESTS_TIER_2,
+    usersPerIpProspection: 1,
+  },
+  '/_api/check_username': {
+    method: 'GET',
+    maxRequests: MAX_REQUESTS_TIER_3,
+  },
+};
+
+// default config with reg exp
+export const defaultConfigWithRegExp = {
+  '/forgot-password/.*': {
+    method: 'ALL',
+    maxRequests: MAX_REQUESTS_TIER_1,
+  },
+  '/user-activation/.*': {
+    method: 'GET',
+    maxRequests: MAX_REQUESTS_TIER_1,
+  },
+  '/attachment/[0-9a-z]{24}': {
+    method: 'GET',
+    maxRequests: MAX_REQUESTS_TIER_4,
+  },
+  '/download/[0-9a-z]{24}': {
+    method: 'GET',
+    maxRequests: MAX_REQUESTS_TIER_4,
+  },
+  '/share/[0-9a-z]{24}': {
+    method: 'GET',
+    maxRequests: MAX_REQUESTS_TIER_4,
+  },
+};

+ 0 - 3
packages/app/docker/Dockerfile

@@ -111,15 +111,12 @@ RUN yarn lerna run build
 # make artifacts
 # make artifacts
 RUN tar -cf packages.tar \
 RUN tar -cf packages.tar \
   package.json \
   package.json \
-  tsconfig.base.json \
   packages/app/config \
   packages/app/config \
   packages/app/public \
   packages/app/public \
   packages/app/resource \
   packages/app/resource \
   packages/app/tmp \
   packages/app/tmp \
   packages/app/migrate-mongo-config.js \
   packages/app/migrate-mongo-config.js \
   packages/app/.env.production* \
   packages/app/.env.production* \
-  packages/app/tsconfig.base.json \
-  packages/app/tsconfig.json \
   packages/*/package.json \
   packages/*/package.json \
   packages/*/dist
   packages/*/dist
 
 

+ 3 - 2
packages/app/package.json

@@ -13,7 +13,7 @@
     "build:server": "yarn cross-env NODE_ENV=production tsc -p tsconfig.build.server.json && tsc-alias -p tsconfig.build.server.json",
     "build:server": "yarn cross-env NODE_ENV=production tsc -p tsconfig.build.server.json && tsc-alias -p tsconfig.build.server.json",
     "clean": "npx -y shx rm -rf dist transpiled",
     "clean": "npx -y shx rm -rf dist transpiled",
     "prebuild": "yarn cross-env NODE_ENV=production run-p clean resources:*",
     "prebuild": "yarn cross-env NODE_ENV=production run-p clean resources:*",
-    "postbuild": "npx -y shx mv transpiled/src dist && npx -y shx cp -r src/server/views dist/server/ && npx -y shx rm -rf transpiled",
+    "postbuild": "npx -y shx mv transpiled/src dist && npx -y shx cp -r transpiled/config/* config && npx -y shx cp -r src/server/views dist/server/ && npx -y shx rm -rf transpiled",
     "server": "yarn cross-env NODE_ENV=production node -r dotenv-flow/config dist/server/app.js",
     "server": "yarn cross-env NODE_ENV=production node -r dotenv-flow/config dist/server/app.js",
     "server:ci": "yarn server --ci",
     "server:ci": "yarn server --ci",
     "preserver": "yarn cross-env NODE_ENV=production yarn migrate",
     "preserver": "yarn cross-env NODE_ENV=production yarn migrate",
@@ -102,7 +102,6 @@
     "express": "^4.16.1",
     "express": "^4.16.1",
     "express-bunyan-logger": "^1.3.3",
     "express-bunyan-logger": "^1.3.3",
     "express-mongo-sanitize": "^2.1.0",
     "express-mongo-sanitize": "^2.1.0",
-    "express-rate-limit": "^5.3.0",
     "express-session": "^1.16.1",
     "express-session": "^1.16.1",
     "express-validator": "^6.14.0",
     "express-validator": "^6.14.0",
     "express-webpack-assets": "^0.1.0",
     "express-webpack-assets": "^0.1.0",
@@ -142,8 +141,10 @@
     "passport-saml": "^3.2.0",
     "passport-saml": "^3.2.0",
     "passport-twitter": "^1.0.4",
     "passport-twitter": "^1.0.4",
     "prom-client": "^13.0.0",
     "prom-client": "^13.0.0",
+    "rate-limiter-flexible": "^2.3.7",
     "react": "^18.2.0",
     "react": "^18.2.0",
     "react-card-flip": "^1.0.10",
     "react-card-flip": "^1.0.10",
+    "react-datepicker": "^4.7.0",
     "react-dnd": "^14.0.5",
     "react-dnd": "^14.0.5",
     "react-dnd-html5-backend": "^14.1.0",
     "react-dnd-html5-backend": "^14.1.0",
     "react-dom": "^18.2.0",
     "react-dom": "^18.2.0",

+ 16 - 0
packages/app/public/static/locales/en_US/admin/admin.json

@@ -521,5 +521,21 @@
       "force_update_parents_label": "Forcibly add missing users",
       "force_update_parents_label": "Forcibly add missing users",
       "force_update_parents_description": "Enable this option to force the addition of missing users to the ancestor groups if they exist after changing a parent group."
       "force_update_parents_description": "Enable this option to force the addition of missing users to the ancestor groups if they exist after changing a parent group."
     }
     }
+  },
+  "audit_log_management": {
+    "username": "Username",
+    "date": "Date",
+    "action": "Action",
+    "ip": "IP Address",
+    "url": "URL",
+    "settings": "Settings",
+    "return": "Return",
+    "activity_expiration_date": "Audit Log expiration date",
+    "activity_expiration_date_explain": "Created Audit Log are automatically deleted after the number of seconds set in the environment variable from the creation time",
+    "fixed_by_env_var": "This is fixed by the env var <code>{{key}}={{value}}</code>.",
+    "available_action_list": "Search / View All Available Actions",
+    "available_action_list_explain": "List of actions that can be search / view in the Audit Log",
+    "action_list": "Action List",
+    "disable_mode_explain": "Audit log is currently disabled. To enable it, set the environment variable <code>AUDIT_LOG_ENABLED</code> to true."
   }
   }
 }
 }

+ 2 - 0
packages/app/public/static/locales/en_US/translation.json

@@ -126,6 +126,8 @@
   "UserGroup": "UserGroup",
   "UserGroup": "UserGroup",
   "ChildUserGroup": "ChildUserGroup",
   "ChildUserGroup": "ChildUserGroup",
   "UserGroup Management": "UserGroup Management",
   "UserGroup Management": "UserGroup Management",
+  "AuditLog": "Audit Log",
+  "AuditLog Settings": "Audit Log Settings",
   "Full Text Search Management": "Full Text Search Management",
   "Full Text Search Management": "Full Text Search Management",
   "Import Data": "Import Data",
   "Import Data": "Import Data",
   "Export Archive Data": "Export Archive Data",
   "Export Archive Data": "Export Archive Data",

+ 16 - 0
packages/app/public/static/locales/ja_JP/admin/admin.json

@@ -520,5 +520,21 @@
       "force_update_parents_label": "強制的に足りないユーザーを追加する",
       "force_update_parents_label": "強制的に足りないユーザーを追加する",
       "force_update_parents_description": "このオプションを有効化すると、親グループ変更後に祖先グループに足りないユーザーが存在した場合にそれらのユーザーを強制的に追加することができます"
       "force_update_parents_description": "このオプションを有効化すると、親グループ変更後に祖先グループに足りないユーザーが存在した場合にそれらのユーザーを強制的に追加することができます"
     }
     }
+  },
+  "audit_log_management": {
+    "username": "ユーザー名",
+    "date": "日付",
+    "action": "アクション",
+    "ip": "IPアドレス",
+    "url": "URL",
+    "settings": "設定",
+    "return": "戻る",
+    "activity_expiration_date": "監査ログの有効期限",
+    "activity_expiration_date_explain": "作成された監査ログは、作成時間から環境変数に設定した秒数後に自動的に削除されます",
+    "fixed_by_env_var": "環境変数により固定されています <code>{{key}}={{value}}</code>.",
+    "available_action_list": "検索 / 表示 可能なアクション一覧",
+    "available_action_list_explain": "監査ログで 検索 / 表示 可能なアクション一覧です",
+    "action_list": "アクション一覧",
+    "disable_mode_explain": "現在、監査ログは無効になっています。有効にする場合は環境変数 <code>AUDIT_LOG_ENABLED</code> を true に設定してください。"
   }
   }
 }
 }

+ 2 - 0
packages/app/public/static/locales/ja_JP/translation.json

@@ -126,6 +126,8 @@
   "UserGroup": "グループ",
   "UserGroup": "グループ",
   "ChildUserGroup": "子グループ",
   "ChildUserGroup": "子グループ",
   "UserGroup Management": "グループ管理",
   "UserGroup Management": "グループ管理",
+  "AuditLog": "監査ログ",
+  "AuditLog Settings": "監査ログ設定",
   "Full Text Search Management": "全文検索管理",
   "Full Text Search Management": "全文検索管理",
   "Import Data": "データインポート",
   "Import Data": "データインポート",
   "Export Archive Data": "データアーカイブ",
   "Export Archive Data": "データアーカイブ",

+ 16 - 0
packages/app/public/static/locales/zh_CN/admin/admin.json

@@ -530,5 +530,21 @@
       "force_update_parents_label": "强行添加失踪的用户",
       "force_update_parents_label": "强行添加失踪的用户",
       "force_update_parents_description": "激活这个选项,如果在父组改变后,在祖先组中有缺失的用户,可以强制添加这些用户"
       "force_update_parents_description": "激活这个选项,如果在父组改变后,在祖先组中有缺失的用户,可以强制添加这些用户"
     }
     }
+  },
+  "audit_log_management": {
+    "username": "帐号",
+    "date": "日期",
+    "action": "行动",
+    "ip": "IP地址",
+    "url": "URL",
+    "settings": "设置",
+    "return": "返回",
+    "activity_expiration_date": "审计日志的到期日",
+    "activity_expiration_date_explain": "创建的审计日志会在环境变量中设置的从创建时间算起的秒数后自动删除",
+    "fixed_by_env_var": "这是由env var 修复的 <code>{{key}}={{value}}</code>.",
+    "available_action_list": "搜索/查看 所有可用的行动",
+    "available_action_list_explain": "可以在审计日志中 搜索/查看 的行动列表",
+    "action_list": "行动清单",
+    "disable_mode_explain": "审计日志当前已禁用。 要启用它,请将环境变量 <code>AUDIT_LOG_ENABLED</code> 设置为 true。"
   }
   }
 }
 }

+ 2 - 0
packages/app/public/static/locales/zh_CN/translation.json

@@ -134,6 +134,8 @@
   "UserGroup": "用户组",
   "UserGroup": "用户组",
   "ChildUserGroup": "儿童用户组",
   "ChildUserGroup": "儿童用户组",
 	"UserGroup Management": "用户组管理",
 	"UserGroup Management": "用户组管理",
+  "AuditLog": "审计日志",
+  "AuditLog Settings": "审计日志设置",
 	"Full Text Search Management": "全文搜索管理",
 	"Full Text Search Management": "全文搜索管理",
 	"Import Data": "导入数据",
 	"Import Data": "导入数据",
 	"Export Archive Data": "导出主题数据",
 	"Export Archive Data": "导出主题数据",

+ 2 - 0
packages/app/src/client/admin.jsx

@@ -31,6 +31,7 @@ import { swrGlobalConfiguration } from '~/utils/swr-utils';
 
 
 import AdminHome from '../components/Admin/AdminHome/AdminHome';
 import AdminHome from '../components/Admin/AdminHome/AdminHome';
 import AppSettingsPage from '../components/Admin/App/AppSettingsPage';
 import AppSettingsPage from '../components/Admin/App/AppSettingsPage';
+import { AuditLogManagement } from '../components/Admin/AuditLogManagement';
 import AdminNavigation from '../components/Admin/Common/AdminNavigation';
 import AdminNavigation from '../components/Admin/Common/AdminNavigation';
 import Customize from '../components/Admin/Customize/Customize';
 import Customize from '../components/Admin/Customize/Customize';
 import ExportArchiveDataPage from '../components/Admin/ExportArchiveDataPage';
 import ExportArchiveDataPage from '../components/Admin/ExportArchiveDataPage';
@@ -107,6 +108,7 @@ Object.assign(componentMappings, {
   'admin-user-group-detail': <UserGroupDetailPage />,
   'admin-user-group-detail': <UserGroupDetailPage />,
   'admin-full-text-search-management': <FullTextSearchManagement />,
   'admin-full-text-search-management': <FullTextSearchManagement />,
   'admin-user-group-page': <UserGroupPage />,
   'admin-user-group-page': <UserGroupPage />,
+  'admin-audit-log': <AuditLogManagement />,
   'admin-navigation': <AdminNavigation />,
   'admin-navigation': <AdminNavigation />,
 });
 });
 
 

+ 5 - 1
packages/app/src/client/services/ContextExtractor.tsx

@@ -19,7 +19,8 @@ import {
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUser, useTargetAndAncestors,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUser, useTargetAndAncestors,
   useIsSearchPage, useIsForbidden, useIsIdenticalPath, useHasParent,
   useIsSearchPage, useIsForbidden, useIsIdenticalPath, useHasParent,
   useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader,
   useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader,
-  useDefaultIndentSize, useIsIndentSizeForced, useCsrfToken, useGrowiVersion,
+  useDefaultIndentSize, useIsIndentSizeForced, useCsrfToken, useGrowiVersion, useAuditLogEnabled,
+  useActivityExpirationSeconds, useAuditLogAvailableActions,
 } from '../../stores/context';
 } from '../../stores/context';
 
 
 const { isTrashPage: _isTrashPage } = pagePathUtils;
 const { isTrashPage: _isTrashPage } = pagePathUtils;
@@ -112,6 +113,9 @@ const ContextExtractorOnce: FC = () => {
   useIsEnabledAttachTitleHeader(configByContextHydrate.isEnabledAttachTitleHeader);
   useIsEnabledAttachTitleHeader(configByContextHydrate.isEnabledAttachTitleHeader);
   useIsIndentSizeForced(configByContextHydrate.isIndentSizeForced);
   useIsIndentSizeForced(configByContextHydrate.isIndentSizeForced);
   useDefaultIndentSize(configByContextHydrate.adminPreferredIndentSize);
   useDefaultIndentSize(configByContextHydrate.adminPreferredIndentSize);
+  useAuditLogEnabled(configByContextHydrate.auditLogEnabled);
+  useActivityExpirationSeconds(configByContextHydrate.activityExpirationSeconds);
+  useAuditLogAvailableActions(configByContextHydrate.auditLogAvailableActions);
   useGrowiVersion(configByContextHydrate.crowi.version);
   useGrowiVersion(configByContextHydrate.crowi.version);
 
 
   // Page
   // Page

+ 47 - 0
packages/app/src/components/Admin/AuditLog/ActivityTable.tsx

@@ -0,0 +1,47 @@
+import React, { FC } from 'react';
+
+import { format } from 'date-fns';
+import { useTranslation } from 'react-i18next';
+
+import { IActivityHasId } from '~/interfaces/activity';
+
+type Props = {
+  activityList: IActivityHasId[]
+}
+
+const formatDate = (date) => {
+  return format(new Date(date), 'yyyy/MM/dd HH:mm:ss');
+};
+
+export const ActivityTable : FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
+
+  return (
+    <div className="table-responsive text-nowrap h-100">
+      <table className="table table-default table-bordered table-user-list">
+        <thead>
+          <tr>
+            <th scope="col">{t('admin:audit_log_management.username')}</th>
+            <th scope="col">{t('admin:audit_log_management.date')}</th>
+            <th scope="col">{t('admin:audit_log_management.action')}</th>
+            <th scope="col">{t('admin:audit_log_management.ip')}</th>
+            <th scope="col">{t('admin:audit_log_management.url')}</th>
+          </tr>
+        </thead>
+        <tbody>
+          {props.activityList.map((activity) => {
+            return (
+              <tr data-testid="activity-table" key={activity._id}>
+                <td>{activity.snapshot?.username}</td>
+                <td>{formatDate(activity.createdAt)}</td>
+                <td>{activity.action}</td>
+                <td>{activity.ip}</td>
+                <td>{activity.endpoint}</td>
+              </tr>
+            );
+          })}
+        </tbody>
+      </table>
+    </div>
+  );
+};

+ 26 - 0
packages/app/src/components/Admin/AuditLog/AuditLogDisableMode.tsx

@@ -0,0 +1,26 @@
+import React, { FC } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+export const AuditLogDisableMode: FC = () => {
+  const { t } = useTranslation();
+
+  return (
+    <div id="content-main" className="content-main container-lg">
+      <div className="container">
+        <div className="row justify-content-md-center">
+          <div className="col-md-6 mt-5">
+            <div className="text-center">
+              <h1><i className="icon-exclamation large"></i></h1>
+              <h1 className="text-center">{t('AuditLog')}</h1>
+              <h3
+                // eslint-disable-next-line react/no-danger
+                dangerouslySetInnerHTML={{ __html: t('admin:audit_log_management.disable_mode_explain') }}
+              />
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+};

+ 54 - 0
packages/app/src/components/Admin/AuditLog/AuditLogSettings.tsx

@@ -0,0 +1,54 @@
+import React, { FC, useState } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import { Collapse } from 'reactstrap';
+
+import { useActivityExpirationSeconds, useAuditLogAvailableActions } from '~/stores/context';
+
+export const AuditLogSettings: FC = () => {
+  const { t } = useTranslation();
+
+  const [isExpandActionList, setIsExpandActionList] = useState(false);
+
+  const { data: activityExpirationSecondsData } = useActivityExpirationSeconds();
+  const activityExpirationSeconds = activityExpirationSecondsData != null ? activityExpirationSecondsData : 2592000;
+
+  const { data: availableActionsData } = useAuditLogAvailableActions();
+  const availableActions = availableActionsData != null ? availableActionsData : [];
+
+  return (
+    <>
+      <h4 className="mt-4">{t('admin:audit_log_management.activity_expiration_date')}</h4>
+      <p className="form-text text-muted">
+        {t('admin:audit_log_management.activity_expiration_date_explain')}
+      </p>
+      <p className="alert alert-warning col-6">
+        <i className="icon-exclamation icon-fw">
+        </i><b>FIXED</b><br />
+        <b
+          // eslint-disable-next-line react/no-danger
+          dangerouslySetInnerHTML={{
+            __html: t('admin:audit_log_management.fixed_by_env_var',
+              { key: 'ACTIVITY_EXPIRATION_SECONDS', value: activityExpirationSeconds }),
+          }}
+        />
+      </p>
+
+      <h4 className="mt-4">{t('admin:audit_log_management.available_action_list')}</h4>
+      <p className="form-text text-muted">{t('admin:audit_log_management.available_action_list_explain')}</p>
+      <p className="mt-1">
+        <button type="button" className="btn btn-link p-0" aria-expanded="false" onClick={() => setIsExpandActionList(!isExpandActionList)}>
+          <i className={`fa fa-fw fa-arrow-right ${isExpandActionList ? 'fa-rotate-90' : ''}`}></i>
+          { t('admin:audit_log_management.action_list') }
+        </button>
+      </p>
+      <Collapse isOpen={isExpandActionList}>
+        <ul className="list-group">
+          { availableActions.map(action => (
+            <li key={action} className="list-group-item">{ action }</li>
+          )) }
+        </ul>
+      </Collapse>
+    </>
+  );
+};

+ 67 - 0
packages/app/src/components/Admin/AuditLog/DateRangePicker.tsx

@@ -0,0 +1,67 @@
+import React, {
+  FC, useRef, forwardRef, useCallback,
+} from 'react';
+
+import DatePicker from 'react-datepicker';
+import 'react-datepicker/dist/react-datepicker.css';
+
+import { useTranslation } from 'react-i18next';
+
+
+type CustomInputProps = {
+  buttonRef: React.Ref<HTMLButtonElement>
+  onClick?: () => void;
+}
+
+const CustomInput = forwardRef<HTMLButtonElement, CustomInputProps>((props: CustomInputProps) => {
+  const { t } = useTranslation();
+  return (
+    <button
+      type="button"
+      className="btn btn-outline-secondary dropdown-toggle"
+      ref={props.buttonRef}
+      onClick={props.onClick}
+    >
+      <i className="fa fa-fw fa-calendar" /> {t('admin:audit_log_management.date')}
+    </button>
+  );
+});
+
+CustomInput.displayName = 'CustomInput';
+
+type DateRangePickerProps = {
+  startDate: Date | null
+  endDate: Date | null
+  onChange: (dateList: Date[] | null[]) => void
+}
+
+export const DateRangePicker: FC<DateRangePickerProps> = (props: DateRangePickerProps) => {
+  const { startDate, endDate, onChange } = props;
+
+  const buttonRef = useRef(null);
+
+  const changeHandler = useCallback((dateList: Date[] | null[]) => {
+    if (onChange != null) {
+      const [start, end] = dateList;
+      const isSameTime = (start != null && end != null) && (start.getTime() === end.getTime());
+      if (isSameTime) {
+        onChange([null, null]);
+      }
+      else {
+        onChange(dateList);
+      }
+    }
+  }, [onChange]);
+
+  return (
+    <div className="btn-group mr-2">
+      <DatePicker
+        selectsRange
+        startDate={startDate}
+        endDate={endDate}
+        onChange={changeHandler}
+        customInput={<CustomInput buttonRef={buttonRef} />}
+      />
+    </div>
+  );
+};

+ 122 - 0
packages/app/src/components/Admin/AuditLog/SearchUsernameTypeahead.tsx

@@ -0,0 +1,122 @@
+import React, {
+  FC, Fragment, useState, useCallback,
+} from 'react';
+
+import { AsyncTypeahead, Menu, MenuItem } from 'react-bootstrap-typeahead';
+import { useTranslation } from 'react-i18next';
+
+import { useSWRxUsernames } from '~/stores/user';
+
+
+const Categories = {
+  activeUser: 'Active User',
+  inactiveUser: 'Inactive User',
+  activitySnapshotUser: 'Activity Snapshot User',
+} as const;
+
+type CategoryType = typeof Categories[keyof typeof Categories]
+
+type UserDataType = {
+  username: string
+  category: CategoryType
+}
+
+type Props = {
+  onChange: (text: string[]) => void
+}
+
+export const SearchUsernameTypeahead: FC<Props> = (props: Props) => {
+  const { onChange } = props;
+  const { t } = useTranslation();
+
+  /*
+   * State
+   */
+  const [searchKeyword, setSearchKeyword] = useState<string>('');
+
+  /*
+   * Fetch
+   */
+  const requestOptions = { isIncludeActiveUser: true, isIncludeInactiveUser: true, isIncludeActivitySnapshotUser: true };
+  const { data: usernameData, error } = useSWRxUsernames(searchKeyword, 0, 5, requestOptions);
+  const activeUsernames = usernameData?.activeUser?.usernames != null ? usernameData.activeUser.usernames : [];
+  const inactiveUsernames = usernameData?.inactiveUser?.usernames != null ? usernameData.inactiveUser.usernames : [];
+  const activitySnapshotUsernames = usernameData?.activitySnapshotUser?.usernames != null ? usernameData.activitySnapshotUser.usernames : [];
+  const isLoading = usernameData === undefined && error == null;
+
+  const allUser: UserDataType[] = [];
+  const pushToAllUser = (usernames: string[], category: CategoryType) => {
+    usernames.forEach(username => allUser.push({ username, category }));
+  };
+  pushToAllUser(activeUsernames, Categories.activeUser);
+  pushToAllUser(inactiveUsernames, Categories.inactiveUser);
+  pushToAllUser(activitySnapshotUsernames, Categories.activitySnapshotUser);
+
+  /*
+   * Functions
+   */
+  const changeHandler = useCallback((userData: UserDataType[]) => {
+    if (onChange != null) {
+      const usernames = userData.map(user => user.username);
+      onChange(usernames);
+    }
+  }, [onChange]);
+
+  const searchHandler = useCallback((text: string) => {
+    setSearchKeyword(text);
+  }, []);
+
+  const renderMenu = useCallback((allUser: UserDataType[], menuProps) => {
+    if (allUser == null || allUser.length === 0) {
+      return <></>;
+    }
+
+    let index = 0;
+    const items = Object.values(Categories).map((category) => {
+      const userData = allUser.filter(user => user.category === category);
+      return (
+        <Fragment key={category}>
+          {index !== 0 && <Menu.Divider />}
+          <Menu.Header>{category}</Menu.Header>
+          {userData.map((user) => {
+            const item = (
+              <MenuItem key={index} option={user} position={index}>
+                {user.username}
+              </MenuItem>
+            );
+            index++;
+            return item;
+          })}
+        </Fragment>
+      );
+    });
+
+    return (
+      <Menu {...menuProps}>{items}</Menu>
+    );
+  }, []);
+
+  return (
+    <div className="input-group mr-2">
+      <div className="input-group-prepend">
+        <span className="input-group-text">
+          <i className="icon-people" />
+        </span>
+      </div>
+      <AsyncTypeahead
+        id="search-username-typeahead-asynctypeahead"
+        multiple
+        delay={400}
+        minLength={0}
+        placeholder={t('admin:audit_log_management.username')}
+        caseSensitive={false}
+        isLoading={isLoading}
+        options={allUser}
+        onSearch={searchHandler}
+        onChange={changeHandler}
+        renderMenu={renderMenu}
+        labelKey={(option: UserDataType) => `${option.username}`}
+      />
+    </div>
+  );
+};

+ 123 - 0
packages/app/src/components/Admin/AuditLog/SelectActionDropdown.tsx

@@ -0,0 +1,123 @@
+import React, { FC, useMemo, useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import {
+  SupportedActionType, SupportedActionCategoryType, SupportedActionCategory,
+  PageActions, CommentActions, TagActions, ShareLinkActions, AttachmentActions, InAppNotificationActions, SearchActions, UserActions, AdminActions,
+} from '~/interfaces/activity';
+
+type Props = {
+  actionMap: Map<SupportedActionType, boolean>
+  availableActions: SupportedActionType[]
+  onChangeAction: (action: SupportedActionType) => void
+  onChangeMultipleAction: (actions: SupportedActionType[], isChecked: boolean) => void
+}
+
+export const SelectActionDropdown: FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
+  const {
+    actionMap, availableActions, onChangeAction, onChangeMultipleAction,
+  } = props;
+
+  const dropdownItems = useMemo<Array<{actionCategory: SupportedActionCategoryType, actions: SupportedActionType[]}>>(() => {
+    return (
+      [
+        {
+          actionCategory: SupportedActionCategory.PAGE,
+          actions: PageActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.COMMENT,
+          actions: CommentActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.TAG,
+          actions: TagActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.ATTACHMENT,
+          actions: AttachmentActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.SHARE_LINK,
+          actions: ShareLinkActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.IN_APP_NOTIFICATION,
+          actions: InAppNotificationActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.SEARCH,
+          actions: SearchActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.USER,
+          actions: UserActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.ADMIN,
+          actions: AdminActions.filter(action => availableActions.includes(action)),
+        },
+      ]
+    );
+  }, [availableActions]).filter(item => item.actions.length !== 0);
+
+  const actionCheckboxChangedHandler = useCallback((action) => {
+    if (onChangeAction != null) {
+      onChangeAction(action);
+    }
+  }, [onChangeAction]);
+
+  const multipleActionCheckboxChangedHandler = useCallback((actions, isChecked) => {
+    if (onChangeMultipleAction != null) {
+      onChangeMultipleAction(actions, isChecked);
+    }
+  }, [onChangeMultipleAction]);
+
+  return (
+    <div className="btn-group mr-2 admin-audit-log">
+      <button className="btn btn-outline-secondary dropdown-toggle" type="button" id="dropdownMenuButton" data-toggle="dropdown">
+        <i className="fa fa-fw fa-bolt" />{t('admin:audit_log_management.action')}
+      </button>
+      <ul className="dropdown-menu select-action-dropdown" aria-labelledby="dropdownMenuButton">
+        {dropdownItems.map(item => (
+          <div key={item.actionCategory}>
+            <div className="dropdown-item">
+              <div className="form-group px-2 m-0">
+                <input
+                  type="checkbox"
+                  className="form-check-input"
+                  defaultChecked
+                  onChange={(e) => { multipleActionCheckboxChangedHandler(item.actions, e.target.checked) }}
+                />
+                <label className="form-check-label">{item.actionCategory}</label>
+              </div>
+            </div>
+            {
+              item.actions.map(action => (
+                <div className="dropdown-item" key={action}>
+                  <div className="form-group px-4 m-0">
+                    <input
+                      type="checkbox"
+                      className="form-check-input"
+                      id={`checkbox${action}`}
+                      onChange={() => { actionCheckboxChangedHandler(action) }}
+                      checked={actionMap.get(action)}
+                    />
+                    <label
+                      className="form-check-label"
+                      htmlFor={`checkbox${action}`}
+                    >
+                      {action}
+                    </label>
+                  </div>
+                </div>
+              ))
+            }
+          </div>
+        ))}
+      </ul>
+    </div>
+  );
+};

+ 181 - 0
packages/app/src/components/Admin/AuditLogManagement.tsx

@@ -0,0 +1,181 @@
+import React, { FC, useState, useCallback } from 'react';
+
+import { format } from 'date-fns';
+import { useTranslation } from 'react-i18next';
+
+import { toastError } from '~/client/util/apiNotification';
+import { SupportedActionType } from '~/interfaces/activity';
+import { useSWRxActivity } from '~/stores/activity';
+import { useAuditLogEnabled, useAuditLogAvailableActions } from '~/stores/context';
+
+import PaginationWrapper from '../PaginationWrapper';
+
+import { ActivityTable } from './AuditLog/ActivityTable';
+import { AuditLogDisableMode } from './AuditLog/AuditLogDisableMode';
+import { AuditLogSettings } from './AuditLog/AuditLogSettings';
+import { DateRangePicker } from './AuditLog/DateRangePicker';
+import { SearchUsernameTypeahead } from './AuditLog/SearchUsernameTypeahead';
+import { SelectActionDropdown } from './AuditLog/SelectActionDropdown';
+
+
+const formatDate = (date: Date | null) => {
+  if (date == null) {
+    return '';
+  }
+  return format(new Date(date), 'yyyy-MM-dd');
+};
+
+const PAGING_LIMIT = 10;
+
+export const AuditLogManagement: FC = () => {
+  const { t } = useTranslation();
+
+  const { data: auditLogAvailableActionsData } = useAuditLogAvailableActions();
+  const auditLogAvailableActions = auditLogAvailableActionsData != null ? auditLogAvailableActionsData : [];
+
+  /*
+   * State
+   */
+  const [isSettingPage, setIsSettingPage] = useState<boolean>(false);
+  const [activePage, setActivePage] = useState<number>(1);
+  const offset = (activePage - 1) * PAGING_LIMIT;
+  const [startDate, setStartDate] = useState<Date | null>(null);
+  const [endDate, setEndDate] = useState<Date | null>(null);
+  const [selectedUsernames, setSelectedUsernames] = useState<string[]>([]);
+  const [actionMap, setActionMap] = useState(
+    new Map<SupportedActionType, boolean>(auditLogAvailableActions.map(action => [action, true])),
+  );
+
+  /*
+   * Fetch
+   */
+  const selectedDate = { startDate: formatDate(startDate), endDate: formatDate(endDate) };
+  const selectedActionList = Array.from(actionMap.entries()).filter(v => v[1]).map(v => v[0]);
+  const searchFilter = { actions: selectedActionList, dates: selectedDate, usernames: selectedUsernames };
+
+  const { data: activityData, mutate: mutateActivity, error } = useSWRxActivity(PAGING_LIMIT, offset, searchFilter);
+  const activityList = activityData?.docs != null ? activityData.docs : [];
+  const totalActivityNum = activityData?.totalDocs != null ? activityData.totalDocs : 0;
+  const isLoading = activityData === undefined && error == null;
+
+  if (error != null) {
+    toastError('Failed to get Audit Log');
+  }
+
+  const { data: auditLogEnabled } = useAuditLogEnabled();
+
+  /*
+   * Functions
+   */
+  const setActivePageHandler = useCallback((selectedPageNum: number) => {
+    setActivePage(selectedPageNum);
+  }, []);
+
+  const datePickerChangedHandler = useCallback((dateList: Date[] | null[]) => {
+    setActivePage(1);
+    setStartDate(dateList[0]);
+    setEndDate(dateList[1]);
+  }, []);
+
+  const actionCheckboxChangedHandler = useCallback((action: SupportedActionType) => {
+    setActivePage(1);
+    actionMap.set(action, !actionMap.get(action));
+    setActionMap(new Map(actionMap.entries()));
+  }, [actionMap, setActionMap]);
+
+  const multipleActionCheckboxChangedHandler = useCallback((actions: SupportedActionType[], isChecked) => {
+    setActivePage(1);
+    actions.forEach(action => actionMap.set(action, isChecked));
+    setActionMap(new Map(actionMap.entries()));
+  }, [actionMap, setActionMap]);
+
+  const setUsernamesHandler = useCallback((usernames: string[]) => {
+    setActivePage(1);
+    setSelectedUsernames(usernames);
+  }, []);
+
+  const reloadButtonPushedHandler = useCallback(() => {
+    setActivePage(1);
+    mutateActivity();
+  }, [mutateActivity]);
+
+  // eslint-disable-next-line max-len
+  const activityCounter = `<b>${activityList.length === 0 ? 0 : offset + 1}</b> - <b>${(PAGING_LIMIT * activePage) - (PAGING_LIMIT - activityList.length)}</b> of <b>${totalActivityNum}<b/>`;
+
+  if (!auditLogEnabled) {
+    return <AuditLogDisableMode />;
+  }
+
+  return (
+    <div data-testid="admin-auditlog">
+      <button type="button" className="btn btn-outline-secondary mb-4" onClick={() => setIsSettingPage(!isSettingPage)}>
+        {
+          isSettingPage
+            ? <><i className="fa fa-hand-o-left mr-1" />{t('admin:audit_log_management.return')}</>
+            : <><i className="fa icon-settings mr-1" />{t('admin:audit_log_management.settings')}</>
+        }
+      </button>
+
+      <h2 className="admin-setting-header mb-3">
+        <span>
+          {isSettingPage ? t('AuditLog Settings') : t('AuditLog')}
+        </span>
+      </h2>
+
+      {isSettingPage ? (
+        <AuditLogSettings />
+      ) : (
+        <>
+          <div className="form-inline mb-3">
+            <SearchUsernameTypeahead
+              onChange={setUsernamesHandler}
+            />
+
+            <DateRangePicker
+              startDate={startDate}
+              endDate={endDate}
+              onChange={datePickerChangedHandler}
+            />
+
+            <SelectActionDropdown
+              actionMap={actionMap}
+              availableActions={auditLogAvailableActions}
+              onChangeAction={actionCheckboxChangedHandler}
+              onChangeMultipleAction={multipleActionCheckboxChangedHandler}
+            />
+
+            <button type="button" className="btn ml-auto grw-btn-reload" onClick={reloadButtonPushedHandler}>
+              <i className="icon icon-reload" />
+            </button>
+          </div>
+
+          <p
+            className="ml-2"
+            // eslint-disable-next-line react/no-danger
+            dangerouslySetInnerHTML={{ __html: activityCounter }}
+          />
+
+          { isLoading
+            ? (
+              <div className="text-muted text-center mb-5">
+                <i className="fa fa-2x fa-spinner fa-pulse mr-1" />
+              </div>
+            )
+            : (
+              <ActivityTable activityList={activityList} />
+            )
+          }
+
+          <PaginationWrapper
+            activePage={activePage}
+            changePage={setActivePageHandler}
+            totalItemsCount={totalActivityNum}
+            pagingLimit={PAGING_LIMIT}
+            align="center"
+            size="sm"
+          />
+        </>
+      )}
+    </div>
+  );
+};

+ 5 - 1
packages/app/src/components/Admin/Common/AdminNavigation.jsx

@@ -5,8 +5,8 @@
 import React from 'react';
 import React from 'react';
 
 
 import { pathUtils } from '@growi/core';
 import { pathUtils } from '@growi/core';
-import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
 
 
@@ -37,6 +37,8 @@ const AdminNavigation = (props) => {
       case 'users':                    return <><i className="icon-fw icon-user"></i>            { t('User_Management') }</>;
       case 'users':                    return <><i className="icon-fw icon-user"></i>            { t('User_Management') }</>;
       case 'user-groups':              return <><i className="icon-fw icon-people"></i>          { t('UserGroup Management') }</>;
       case 'user-groups':              return <><i className="icon-fw icon-people"></i>          { t('UserGroup Management') }</>;
       case 'search':                   return <><i className="icon-fw icon-magnifier"></i>       { t('Full Text Search Management') }</>;
       case 'search':                   return <><i className="icon-fw icon-magnifier"></i>       { t('Full Text Search Management') }</>;
+      // TODO: Consider where to place the "AuditLog"
+      case 'audit-log':                return <><i className="icon-fw icon-feed"></i>            { t('AuditLog')}</>;
       case 'cloud':                    return <><i className="icon-fw icon-share-alt"></i>       { t('to_cloud_settings')} </>;
       case 'cloud':                    return <><i className="icon-fw icon-share-alt"></i>       { t('to_cloud_settings')} </>;
       default:                         return <><i className="icon-fw icon-home"></i>            { t('Wiki Management Home Page') }</>;
       default:                         return <><i className="icon-fw icon-home"></i>            { t('Wiki Management Home Page') }</>;
     }
     }
@@ -86,6 +88,7 @@ const AdminNavigation = (props) => {
         <MenuLink menu="users"        isListGroupItems isActive={isActiveMenu('/users')} />
         <MenuLink menu="users"        isListGroupItems isActive={isActiveMenu('/users')} />
         <MenuLink menu="user-groups"  isListGroupItems isActive={isActiveMenu('/user-groups')} />
         <MenuLink menu="user-groups"  isListGroupItems isActive={isActiveMenu('/user-groups')} />
         <MenuLink menu="search"       isListGroupItems isActive={isActiveMenu('/search')} />
         <MenuLink menu="search"       isListGroupItems isActive={isActiveMenu('/search')} />
+        <MenuLink menu="audit-log"    isListGroupItems isActive={isActiveMenu('/audit-log')} />
         {/* {growiCloudUri != null && growiAppIdForGrowiCloud != null
         {/* {growiCloudUri != null && growiAppIdForGrowiCloud != null
           && (
           && (
             <a
             <a
@@ -131,6 +134,7 @@ const AdminNavigation = (props) => {
             {isActiveMenu('/users') &&             <MenuLabel menu="users" />}
             {isActiveMenu('/users') &&             <MenuLabel menu="users" />}
             {isActiveMenu('/user-groups') &&       <MenuLabel menu="user-groups" />}
             {isActiveMenu('/user-groups') &&       <MenuLabel menu="user-groups" />}
             {isActiveMenu('/search') &&            <MenuLabel menu="search" />}
             {isActiveMenu('/search') &&            <MenuLabel menu="search" />}
+            {isActiveMenu('/audit-log') &&         <MenuLabel menu="audit-log" />}
           </span>
           </span>
         </button>
         </button>
         <div className="dropdown-menu" aria-labelledby="dropdown-admin-navigation">
         <div className="dropdown-menu" aria-labelledby="dropdown-admin-navigation">

+ 7 - 2
packages/app/src/components/BookmarkButtons.tsx

@@ -55,7 +55,7 @@ const BookmarkButtons: FC<Props> = (props: Props) => {
         type="button"
         type="button"
         id="bookmark-button"
         id="bookmark-button"
         onClick={handleClick}
         onClick={handleClick}
-        className={`btn btn-bookmark border-0
+        className={`shadow-none btn btn-bookmark border-0
           ${isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
           ${isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
       >
       >
         <i className={`fa ${isBookmarked ? 'fa-bookmark' : 'fa-bookmark-o'}`}></i>
         <i className={`fa ${isBookmarked ? 'fa-bookmark' : 'fa-bookmark-o'}`}></i>
@@ -67,7 +67,12 @@ const BookmarkButtons: FC<Props> = (props: Props) => {
 
 
       { !hideTotalNumber && (
       { !hideTotalNumber && (
         <>
         <>
-          <button type="button" id="po-total-bookmarks" className={`btn btn-bookmark border-0 total-bookmarks ${props.isBookmarked ? 'active' : ''}`}>
+          <button
+            type="button"
+            id="po-total-bookmarks"
+            className={`shadow-none btn btn-bookmark border-0
+              total-bookmarks ${props.isBookmarked ? 'active' : ''}`}
+          >
             {bookmarkCount ?? 0}
             {bookmarkCount ?? 0}
           </button>
           </button>
           { bookmarkedUsers != null && (
           { bookmarkedUsers != null && (

+ 2 - 1
packages/app/src/components/CompleteUserRegistrationForm.tsx

@@ -1,6 +1,7 @@
 import React, { useState, useEffect } from 'react';
 import React, { useState, useEffect } from 'react';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
 import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
+
 import { toastSuccess, toastError } from '../client/util/apiNotification';
 import { toastSuccess, toastError } from '../client/util/apiNotification';
 
 
 interface Props {
 interface Props {
@@ -28,7 +29,7 @@ const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
   useEffect(() => {
   useEffect(() => {
     const delayDebounceFn = setTimeout(async() => {
     const delayDebounceFn = setTimeout(async() => {
       try {
       try {
-        const { data } = await apiv3Get('/check_username', { username });
+        const { data } = await apiv3Get('/check-username', { username });
         if (data.ok) {
         if (data.ok) {
           setUsernameAvailable(data.valid);
           setUsernameAvailable(data.valid);
         }
         }

+ 1 - 0
packages/app/src/components/DescendantsPageListModal.tsx

@@ -77,6 +77,7 @@ export const DescendantsPageListModal = (): JSX.Element => {
       size="xl"
       size="xl"
       isOpen={isOpened}
       isOpen={isOpened}
       toggle={close}
       toggle={close}
+      data-testid="page-accessories-modal"
       className={`grw-page-accessories-modal ${isWindowExpanded ? 'grw-modal-expanded' : ''} `}
       className={`grw-page-accessories-modal ${isWindowExpanded ? 'grw-modal-expanded' : ''} `}
     >
     >
       <ModalHeader className="p-0" toggle={close} close={buttons}>
       <ModalHeader className="p-0" toggle={close} close={buttons}>

+ 1 - 1
packages/app/src/components/InstallerForm.jsx

@@ -38,7 +38,7 @@ class InstallerForm extends React.Component {
   //     },
   //     },
   //     responseType: 'json',
   //     responseType: 'json',
   //   });
   //   });
-  //   axios.get('/_api/check_username', { params: { username: event.target.value } })
+  //   axios.get('/_api/v3/check-username', { params: { username: event.target.value } })
   //     .then((res) => { return this.setState({ isValidUserName: res.data.valid }) });
   //     .then((res) => { return this.setState({ isValidUserName: res.data.valid }) });
   // }
   // }
 
 

+ 7 - 2
packages/app/src/components/LikeButtons.tsx

@@ -51,7 +51,7 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
         type="button"
         type="button"
         id="like-button"
         id="like-button"
         onClick={onLikeClicked}
         onClick={onLikeClicked}
-        className={`btn btn-like border-0
+        className={`shadow-none btn btn-like border-0
             ${isLiked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
             ${isLiked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
       >
       >
         <i className={`fa ${isLiked ? 'fa-heart' : 'fa-heart-o'}`}></i>
         <i className={`fa ${isLiked ? 'fa-heart' : 'fa-heart-o'}`}></i>
@@ -63,7 +63,12 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
 
 
       { !hideTotalNumber && (
       { !hideTotalNumber && (
         <>
         <>
-          <button type="button" id="po-total-likes" className={`btn btn-like border-0 total-likes ${isLiked ? 'active' : ''}`}>
+          <button
+            type="button"
+            id="po-total-likes"
+            className={`shadow-none btn btn-like border-0
+              total-likes ${isLiked ? 'active' : ''}`}
+          >
             {sumOfLikers}
             {sumOfLikers}
           </button>
           </button>
           <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-likes" toggle={togglePopover} trigger="legacy">
           <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-likes" toggle={togglePopover} trigger="legacy">

+ 17 - 2
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -16,7 +16,8 @@ import {
 import { IResTagsUpdateApiv1 } from '~/interfaces/tag';
 import { IResTagsUpdateApiv1 } from '~/interfaces/tag';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import {
 import {
-  useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId,
+  useCurrentPageId,
+  useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId, useTemplateTagData,
 } from '~/stores/context';
 } from '~/stores/context';
 import { usePageTagsForEditors } from '~/stores/editor';
 import { usePageTagsForEditors } from '~/stores/editor';
 import {
 import {
@@ -153,6 +154,7 @@ const GrowiContextualSubNavigation = (props) => {
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
   const { data: isDrawerMode } = useDrawerMode();
   const { data: isDrawerMode } = useDrawerMode();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
+  const { data: pageId } = useCurrentPageId();
   const { data: currentPage } = useSWRxCurrentPage();
   const { data: currentPage } = useSWRxCurrentPage();
   const { data: currentUser } = useCurrentUser();
   const { data: currentUser } = useCurrentUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
@@ -170,13 +172,26 @@ const GrowiContextualSubNavigation = (props) => {
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openDeleteModal } = usePageDeleteModal();
+  const { data: templateTagData } = useTemplateTagData();
+
 
 
   useEffect(() => {
   useEffect(() => {
     // Run only when tagsInfoData has been updated
     // Run only when tagsInfoData has been updated
-    syncPageTagsForEditors();
+    if (templateTagData == null) {
+      syncPageTagsForEditors();
+    }
     // eslint-disable-next-line react-hooks/exhaustive-deps
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [tagsInfoData?.tags]);
   }, [tagsInfoData?.tags]);
 
 
+  useEffect(() => {
+    if (pageId === null && templateTagData != null) {
+      const tags = templateTagData.split(',').filter((str: string) => {
+        return str !== ''; // filter empty values
+      });
+      mutatePageTagsForEditors(tags);
+    }
+  }, [pageId, mutatePageTagsForEditors, templateTagData, mutateSWRTagsInfo]);
+
   const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
   const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
 
 
   const {
   const {

+ 1 - 2
packages/app/src/components/Navbar/GrowiSubNavigation.tsx

@@ -71,8 +71,7 @@ export const GrowiSubNavigation = (props: Props): JSX.Element => {
         ) }
         ) }
 
 
         <div className="grw-path-nav-container">
         <div className="grw-path-nav-container">
-          {/* "/trash" page does not exist on page collection and unable to add tags  */}
-          { showTagLabel && !isCompactMode && path !== '/trash' && (
+          { showTagLabel && !isCompactMode && (
             <div className="grw-taglabels-container">
             <div className="grw-taglabels-container">
               <TagLabels tags={tags} isGuestUser={isGuestUser ?? false} tagsUpdateInvoked={tagsUpdatedHandler} />
               <TagLabels tags={tags} isGuestUser={isGuestUser ?? false} tagsUpdateInvoked={tagsUpdatedHandler} />
             </div>
             </div>

+ 4 - 6
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -155,12 +155,10 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
   return (
   return (
     <div className="d-flex" style={{ gap: '2px' }}>
     <div className="d-flex" style={{ gap: '2px' }}>
       {revisionId != null && (
       {revisionId != null && (
-        <span>
-          <SubscribeButton
-            status={pageInfo.subscriptionStatus}
-            onClick={subscribeClickhandler}
-          />
-        </span>
+        <SubscribeButton
+          status={pageInfo.subscriptionStatus}
+          onClick={subscribeClickhandler}
+        />
       )}
       )}
       {revisionId != null && (
       {revisionId != null && (
         <LikeButtons
         <LikeButtons

+ 1 - 0
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -74,6 +74,7 @@ const DisplaySwitcher = (): JSX.Element => {
                         type="button"
                         type="button"
                         className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
                         className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
                         onClick={() => openDescendantPageListModal(currentPagePath)}
                         onClick={() => openDescendantPageListModal(currentPagePath)}
+                        data-testid="pageListButton"
                       >
                       >
                         <div className="grw-page-accessories-control-icon">
                         <div className="grw-page-accessories-control-icon">
                           <PageListIcon />
                           <PageListIcon />

+ 33 - 25
packages/app/src/components/PagePathHierarchicalLink.jsx → packages/app/src/components/PagePathHierarchicalLink.tsx

@@ -1,27 +1,39 @@
-import React from 'react';
-import PropTypes from 'prop-types';
+import React, { memo } from 'react';
 
 
+import Link from 'next/link';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
 import LinkedPagePath from '../models/linked-page-path';
 import LinkedPagePath from '../models/linked-page-path';
 
 
 
 
+type PagePathHierarchicalLinkProps = {
+  linkedPagePath: LinkedPagePath,
+  linkedPagePathByHtml?: LinkedPagePath,
+  basePath?: string,
+  isInTrash?: boolean,
+
+  // !!INTERNAL USE ONLY!!
+  isInnerElem?: boolean,
+};
+
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-const PagePathHierarchicalLink = (props) => {
+const PagePathHierarchicalLink = memo((props: PagePathHierarchicalLinkProps): JSX.Element => {
   const {
   const {
     linkedPagePath, linkedPagePathByHtml, basePath, isInTrash,
     linkedPagePath, linkedPagePathByHtml, basePath, isInTrash,
   } = props;
   } = props;
   // render root element
   // render root element
   if (linkedPagePath.isRoot) {
   if (linkedPagePath.isRoot) {
     if (basePath != null) {
     if (basePath != null) {
-      return null;
+      return <></>;
     }
     }
 
 
     return isInTrash
     return isInTrash
       ? (
       ? (
         <>
         <>
           <span className="path-segment">
           <span className="path-segment">
-            <a href="/trash"><i className="icon-trash"></i></a>
+            <Link href="/trash">
+              <a ><i className="icon-trash"></i></a>
+            </Link>
           </span>
           </span>
           <span className="separator"><a href="/">/</a></span>
           <span className="separator"><a href="/">/</a></span>
         </>
         </>
@@ -29,10 +41,12 @@ const PagePathHierarchicalLink = (props) => {
       : (
       : (
         <>
         <>
           <span className="path-segment">
           <span className="path-segment">
-            <a href="/">
-              <i className="icon-home"></i>
-              <span className="separator">/</span>
-            </a>
+            <Link href="/">
+              <a >
+                <i className="icon-home"></i>
+                <span className="separator">/</span>
+              </a>
+            </Link>
           </span>
           </span>
         </>
         </>
       );
       );
@@ -68,25 +82,19 @@ const PagePathHierarchicalLink = (props) => {
         <span className="separator">/</span>
         <span className="separator">/</span>
       ) }
       ) }
 
 
-      {
-        shouldDangerouslySetInnerHTML
-          // eslint-disable-next-line react/no-danger
-          ? <a className="page-segment" href={href} dangerouslySetInnerHTML={{ __html: linkedPagePathByHtml.pathName }}></a>
-          : <a className="page-segment" href={href}>{linkedPagePath.pathName}</a>
-      }
+      <Link href={href}>
+        {
+          shouldDangerouslySetInnerHTML
+            // eslint-disable-next-line react/no-danger
+            ? <a className="page-segment" dangerouslySetInnerHTML={{ __html: linkedPagePathByHtml.pathName }}></a>
+            : <a className="page-segment" >{linkedPagePath.pathName}</a>
+        }
+      </Link>
 
 
     </RootElm>
     </RootElm>
   );
   );
-};
-
-PagePathHierarchicalLink.propTypes = {
-  linkedPagePath: PropTypes.instanceOf(LinkedPagePath).isRequired,
-  linkedPagePathByHtml: PropTypes.instanceOf(LinkedPagePath), // Not required
-  basePath: PropTypes.string,
-  isInTrash: PropTypes.bool,
+});
+PagePathHierarchicalLink.displayName = 'PagePathHierarchicalLink';
 
 
-  // !!INTERNAL USE ONLY!!
-  isInnerElem: PropTypes.bool,
-};
 
 
 export default PagePathHierarchicalLink;
 export default PagePathHierarchicalLink;

+ 1 - 9
packages/app/src/components/Sidebar.tsx

@@ -97,8 +97,6 @@ const Sidebar = (): JSX.Element => {
 
 
   const { scheduleToPut } = useUserUISettings();
   const { scheduleToPut } = useUserUISettings();
 
 
-  const [isTransitionEnabled, setTransitionEnabled] = useState(false);
-
   const [isHover, setHover] = useState(false);
   const [isHover, setHover] = useState(false);
   const [isHoverOnResizableContainer, setHoverOnResizableContainer] = useState(false);
   const [isHoverOnResizableContainer, setHoverOnResizableContainer] = useState(false);
   const [isDragging, setDrag] = useState(false);
   const [isDragging, setDrag] = useState(false);
@@ -242,12 +240,6 @@ const Sidebar = (): JSX.Element => {
 
 
   }, [dragableAreaMouseUpHandler, draggableAreaMoveHandler, isResizableByDrag]);
   }, [dragableAreaMouseUpHandler, draggableAreaMoveHandler, isResizableByDrag]);
 
 
-  useEffect(() => {
-    setTimeout(() => {
-      setTransitionEnabled(true);
-    }, 1000);
-  }, []);
-
   useEffect(() => {
   useEffect(() => {
     toggleDrawerMode(isDrawerMode);
     toggleDrawerMode(isDrawerMode);
   }, [isDrawerMode, toggleDrawerMode]);
   }, [isDrawerMode, toggleDrawerMode]);
@@ -301,7 +293,7 @@ const Sidebar = (): JSX.Element => {
         <div className={`d-print-none ${isDrawerMode ? 'grw-sidebar-drawer' : 'grw-sidebar-dock'} ${isDrawerOpened ? 'open' : ''}`}>
         <div className={`d-print-none ${isDrawerMode ? 'grw-sidebar-drawer' : 'grw-sidebar-dock'} ${isDrawerOpened ? 'open' : ''}`}>
           <div className="data-layout-container">
           <div className="data-layout-container">
             <div
             <div
-              className={`navigation ${isTransitionEnabled ? 'transition-enabled' : ''}`}
+              className='navigation transition-enabled'
               onMouseEnter={hoverOnHandler}
               onMouseEnter={hoverOnHandler}
               onMouseLeave={hoverOutHandler}
               onMouseLeave={hoverOutHandler}
             >
             >

+ 6 - 3
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -6,6 +6,7 @@ import nodePath from 'path';
 
 
 import { pathUtils, pagePathUtils } from '@growi/core';
 import { pathUtils, pagePathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
 import { useDrag, useDrop } from 'react-dnd';
 import { useDrag, useDrop } from 'react-dnd';
 import { UncontrolledTooltip, DropdownToggle } from 'reactstrap';
 import { UncontrolledTooltip, DropdownToggle } from 'reactstrap';
 
 
@@ -459,9 +460,11 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
                 </>
                 </>
               )}
               )}
 
 
-              <a href={`/${page._id}`} className="grw-pagetree-title-anchor flex-grow-1">
-                <p className={`text-truncate m-auto ${page.isEmpty && 'grw-sidebar-text-muted'}`}>{nodePath.basename(page.path ?? '') || '/'}</p>
-              </a>
+              <Link href={`/${page._id}`}>
+                <a className="grw-pagetree-title-anchor flex-grow-1">
+                  <p className={`text-truncate m-auto ${page.isEmpty && 'grw-sidebar-text-muted'}`}>{nodePath.basename(page.path ?? '') || '/'}</p>
+                </a>
+              </Link>
             </>
             </>
           )}
           )}
         {descendantCount > 0 && !isRenameInputShown && (
         {descendantCount > 0 && !isRenameInputShown && (

+ 28 - 20
packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -1,15 +1,17 @@
 import React, {
 import React, {
-  FC,
-  useCallback, useEffect, useState,
+  memo, useCallback, useEffect, useState,
 } from 'react';
 } from 'react';
 
 
 import { DevidedPagePath } from '@growi/core';
 import { DevidedPagePath } from '@growi/core';
 import { UserPicture, FootstampIcon } from '@growi/ui';
 import { UserPicture, FootstampIcon } from '@growi/ui';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 
 
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
+import { isPopulated } from '~/interfaces/common';
+import { IPageHasId } from '~/interfaces/page';
 import LinkedPagePath from '~/models/linked-page-path';
 import LinkedPagePath from '~/models/linked-page-path';
 import { useSWRInifinitexRecentlyUpdated } from '~/stores/page-listing';
 import { useSWRInifinitexRecentlyUpdated } from '~/stores/page-listing';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -21,7 +23,11 @@ import InfiniteScroll from './InfiniteScroll';
 
 
 const logger = loggerFactory('growi:History');
 const logger = loggerFactory('growi:History');
 
 
-function PageItemLower({ page }) {
+type PageItemProps = {
+  page: IPageHasId,
+}
+
+const PageItemLower = memo(({ page }: PageItemProps): JSX.Element => {
   return (
   return (
     <div className="d-flex justify-content-between grw-recent-changes-item-lower pt-1">
     <div className="d-flex justify-content-between grw-recent-changes-item-lower pt-1">
       <div className="d-flex">
       <div className="d-flex">
@@ -35,11 +41,11 @@ function PageItemLower({ page }) {
       </div>
       </div>
     </div>
     </div>
   );
   );
-}
-PageItemLower.propTypes = {
-  page: PropTypes.any,
-};
-function LargePageItem({ page }) {
+});
+PageItemLower.displayName = 'PageItemLower';
+
+
+const LargePageItem = memo(({ page }: PageItemProps): JSX.Element => {
   const dPagePath = new DevidedPagePath(page.path, false, true);
   const dPagePath = new DevidedPagePath(page.path, false, true);
   const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
   const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
   const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
   const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
@@ -56,10 +62,15 @@ function LargePageItem({ page }) {
 
 
   const tags = page.tags;
   const tags = page.tags;
   const tagElements = tags.map((tag) => {
   const tagElements = tags.map((tag) => {
+    if (!isPopulated(tag)) {
+      return <></>;
+    }
     return (
     return (
-      <a key={tag.name} href={`/_search?q=tag:${tag.name}`} className="grw-tag-label badge badge-secondary mr-2 small">
-        {tag.name}
-      </a>
+      <Link key={tag.name} href={`/_search?q=tag:${tag.name}`}>
+        <a className="grw-tag-label badge badge-secondary mr-2 small">
+          {tag.name}
+        </a>
+      </Link>
     );
     );
   });
   });
 
 
@@ -81,12 +92,11 @@ function LargePageItem({ page }) {
       </div>
       </div>
     </li>
     </li>
   );
   );
-}
-LargePageItem.propTypes = {
-  page: PropTypes.any,
-};
+});
+LargePageItem.displayName = 'LargePageItem';
 
 
-function SmallPageItem({ page }) {
+
+const SmallPageItem = memo(({ page }: PageItemProps): JSX.Element => {
   const dPagePath = new DevidedPagePath(page.path, false, true);
   const dPagePath = new DevidedPagePath(page.path, false, true);
   const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
   const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
   const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
   const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
@@ -116,10 +126,8 @@ function SmallPageItem({ page }) {
       </div>
       </div>
     </li>
     </li>
   );
   );
-}
-SmallPageItem.propTypes = {
-  page: PropTypes.any,
-};
+});
+SmallPageItem.displayName = 'SmallPageItem';
 
 
 const RecentChanges = (): JSX.Element => {
 const RecentChanges = (): JSX.Element => {
   const PER_PAGE = 20;
   const PER_PAGE = 20;

+ 3 - 5
packages/app/src/components/SubscribeButton.tsx

@@ -18,9 +18,6 @@ const SubscribeButton: FC<Props> = (props: Props) => {
 
 
   const isSubscribing = status === SubscriptionStatusType.SUBSCRIBE;
   const isSubscribing = status === SubscriptionStatusType.SUBSCRIBE;
 
 
-  const buttonClass = `${isSubscribing ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`;
-  const iconClass = isSubscribing === false ? 'fa fa-eye-slash' : 'fa fa-eye';
-
   const getTooltipMessage = useCallback(() => {
   const getTooltipMessage = useCallback(() => {
     if (isGuestUser) {
     if (isGuestUser) {
       return 'Not available for guest';
       return 'Not available for guest';
@@ -38,9 +35,10 @@ const SubscribeButton: FC<Props> = (props: Props) => {
         type="button"
         type="button"
         id="subscribe-button"
         id="subscribe-button"
         onClick={props.onClick}
         onClick={props.onClick}
-        className={`btn btn-subscribe border-0 ${buttonClass}`}
+        className={`shadow-none btn btn-subscribe border-0
+          ${isSubscribing ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
       >
       >
-        <i className={iconClass}></i>
+        <i className={`fa ${isSubscribing ? 'fa-bell' : 'fa-bell-slash-o'}`}></i>
       </button>
       </button>
 
 
       <UncontrolledTooltip placement="top" target="subscribe-button" fade={false}>
       <UncontrolledTooltip placement="top" target="subscribe-button" fade={false}>

+ 1 - 1
packages/app/src/components/User/SeenUserInfo.tsx

@@ -24,7 +24,7 @@ const SeenUserInfo: FC<Props> = (props: Props) => {
 
 
   return (
   return (
     <div className="grw-seen-user-info">
     <div className="grw-seen-user-info">
-      <button type="button" id="btn-seen-user" className="btn btn-seen-user border-0">
+      <button type="button" id="btn-seen-user" className="shadow-none btn btn-seen-user border-0">
         <span className="mr-1 footstamp-icon">
         <span className="mr-1 footstamp-icon">
           <FootstampIcon />
           <FootstampIcon />
         </span>
         </span>

+ 479 - 9
packages/app/src/interfaces/activity.ts

@@ -1,10 +1,41 @@
+import { Ref } from './common';
+import { HasObjectId } from './has-object-id';
+import { IUser } from './user';
+
 // Model
 // Model
 const MODEL_PAGE = 'Page';
 const MODEL_PAGE = 'Page';
 const MODEL_COMMENT = 'Comment';
 const MODEL_COMMENT = 'Comment';
 
 
 // Action
 // Action
+const ACTION_UNSETTLED = 'UNSETTLED';
+const ACTION_USER_REGISTRATION_SUCCESS = 'USER_REGISTRATION_SUCCESS';
+const ACTION_USER_LOGIN_WITH_LOCAL = 'USER_LOGIN_WITH_LOCAL';
+const ACTION_USER_LOGIN_WITH_LDAP = 'USER_LOGIN_WITH_LDAP';
+const ACTION_USER_LOGIN_WITH_GOOGLE = 'USER_LOGIN_WITH_GOOGLE';
+const ACTION_USER_LOGIN_WITH_GITHUB = 'USER_LOGIN_WITH_GITHUB';
+const ACTION_USER_LOGIN_WITH_TWITTER = 'USER_LOGIN_WITH_TWITTER';
+const ACTION_USER_LOGIN_WITH_OIDC = 'USER_LOGIN_WITH_OIDC';
+const ACTION_USER_LOGIN_WITH_SAML = 'USER_LOGIN_WITH_SAML';
+const ACTION_USER_LOGIN_WITH_BASIC = 'USER_LOGIN_WITH_BASIC';
+const ACTION_USER_LOGIN_FAILURE = 'USER_LOGIN_FAILURE';
+const ACTION_USER_LOGOUT = 'USER_LOGOUT';
+const ACTION_USER_PERSONAL_SETTINGS_UPDATE = 'USER_PERSONAL_SETTINGS_UPDATE';
+const ACTION_USER_IMAGE_TYPE_UPDATE = 'USER_IMAGE_TYPE_UPDATE';
+const ACTION_USER_LDAP_ACCOUNT_ASSOCIATE = 'USER_LDAP_ACCOUNT_ASSOCIATE';
+const ACTION_USER_LDAP_ACCOUNT_DISCONNECT = 'USER_LDAP_ACCOUNT_DISCONNECT';
+const ACTION_USER_PASSWORD_UPDATE = 'USER_PASSWORD_UPDATE';
+const ACTION_USER_API_TOKEN_UPDATE = 'USER_API_TOKEN_UPDATE';
+const ACTION_USER_EDITOR_SETTINGS_UPDATE = 'USER_EDITOR_SETTINGS_UPDATE';
+const ACTION_USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE = 'USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE';
+const ACTION_PAGE_VIEW = 'PAGE_VIEW';
+const ACTION_PAGE_USER_HOME_VIEW = 'PAGE_USER_HOME_VIEW';
+const ACTION_PAGE_NOT_FOUND = 'PAGE_NOT_FOUND';
+const ACTION_PAGE_FORBIDDEN = 'PAGE_FORBIDDEN';
+const ACTION_PAGE_NOT_CREATABLE = 'PAGE_NOT_CREATABLE';
 const ACTION_PAGE_LIKE = 'PAGE_LIKE';
 const ACTION_PAGE_LIKE = 'PAGE_LIKE';
+const ACTION_PAGE_UNLIKE = 'PAGE_UNLIKE';
 const ACTION_PAGE_BOOKMARK = 'PAGE_BOOKMARK';
 const ACTION_PAGE_BOOKMARK = 'PAGE_BOOKMARK';
+const ACTION_PAGE_UNBOOKMARK = 'PAGE_UNBOOKMARK';
 const ACTION_PAGE_CREATE = 'PAGE_CREATE';
 const ACTION_PAGE_CREATE = 'PAGE_CREATE';
 const ACTION_PAGE_UPDATE = 'PAGE_UPDATE';
 const ACTION_PAGE_UPDATE = 'PAGE_UPDATE';
 const ACTION_PAGE_RENAME = 'PAGE_RENAME';
 const ACTION_PAGE_RENAME = 'PAGE_RENAME';
@@ -12,21 +43,154 @@ const ACTION_PAGE_DUPLICATE = 'PAGE_DUPLICATE';
 const ACTION_PAGE_DELETE = 'PAGE_DELETE';
 const ACTION_PAGE_DELETE = 'PAGE_DELETE';
 const ACTION_PAGE_DELETE_COMPLETELY = 'PAGE_DELETE_COMPLETELY';
 const ACTION_PAGE_DELETE_COMPLETELY = 'PAGE_DELETE_COMPLETELY';
 const ACTION_PAGE_REVERT = 'PAGE_REVERT';
 const ACTION_PAGE_REVERT = 'PAGE_REVERT';
+const ACTION_PAGE_EMPTY_TRASH = 'PAGE_EMPTY_TRASH';
+const ACTION_PAGE_SUBSCRIBE = 'PAGE_SUBSCRIBE';
+const ACTION_PAGE_UNSUBSCRIBE = 'PAGE_UNSUBSCRIBE';
+const ACTION_PAGE_EXPORT = 'PAGE_EXPORT';
+const ACTION_TAG_UPDATE = 'TAG_UPDATE';
+const ACTION_IN_APP_NOTIFICATION_ALL_STATUSES_OPEN = 'IN_APP_NOTIFICATION_ALL_STATUSES_OPEN';
 const ACTION_COMMENT_CREATE = 'COMMENT_CREATE';
 const ACTION_COMMENT_CREATE = 'COMMENT_CREATE';
 const ACTION_COMMENT_UPDATE = 'COMMENT_UPDATE';
 const ACTION_COMMENT_UPDATE = 'COMMENT_UPDATE';
+const ACTION_COMMENT_REMOVE = 'COMMENT_REMOVE';
+const ACTION_SHARE_LINK_CREATE = 'SHARE_LINK_CREATE';
+const ACTION_SHARE_LINK_DELETE = 'SHARE_LINK_DELETE';
+const ACTION_SHARE_LINK_DELETE_BY_PAGE = 'SHARE_LINK_DELETE_BY_PAGE';
+const ACTION_SHARE_LINK_ALL_DELETE = 'SHARE_LINK_ALL_DELETE';
+const ACTION_SHARE_LINK_PAGE_VIEW = 'SHARE_LINK_PAGE_VIEW';
+const ACTION_SHARE_LINK_EXPIRED_PAGE_VIEW = 'SHARE_LINK_EXPIRED_PAGE_VIEW';
+const ACTION_SHARE_LINK_NOT_FOUND = 'SHARE_LINK_NOT_FOUND';
+const ACTION_ATTACHMENT_ADD = 'ATTACHMENT_ADD';
+const ACTION_ATTACHMENT_REMOVE = 'ATTACHMENT_REMOVE';
+const ACTION_ATTACHMENT_DOWNLOAD = 'ACTION_ATTACHMENT_DOWNLOAD';
+const ACTION_SEARCH_PAGE = 'SEARCH_PAGE';
+const ACTION_SEARCH_PAGE_VIEW = 'SEARCH_PAGE_VIEW';
+const ACTION_ADMIN_APP_SETTINGS_UPDATE = 'ADMIN_APP_SETTING_UPDATE';
+const ACTION_ADMIN_SITE_URL_UPDATE = 'ADMIN_SITE_URL_UPDATE';
+const ACTION_ADMIN_MAIL_SMTP_UPDATE = 'ADMIN_MAIL_SMTP_UPDATE';
+const ACTION_ADMIN_MAIL_SES_UPDATE = 'ADMIN_MAIL_SES_UPDATE';
+const ACTION_ADMIN_MAIL_TEST_SUBMIT = 'ADMIN_MAIL_TEST_SUBMIT ';
+const ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE = 'ADMIN_FILE_UPLOAD_CONFIG_UPDATE';
+const ACTION_ADMIN_PLUGIN_UPDATE = 'ADMIN_PLUGIN_UPDATE';
+const ACTION_ADMIN_MAINTENANCEMODE_ENABLED = 'ADMIN_MAINTENANCEMODE_ENABLED';
+const ACTION_ADMIN_MAINTENANCEMODE_DISABLED = 'ADMIN_MAINTENANCEMODE_DISABLED';
+const ACTION_ADMIN_SECURITY_SETTINGS_UPDATE = 'ADMIN_SECURITY_SETTINGS_UPDATE';
+const ACTION_ADMIN_PERMIT_SHARE_LINK = 'ADMIN_PERMIT_SHARE_LINK';
+const ACTION_ADMIN_REJECT_SHARE_LINK = 'ADMIN_REJECT_SHARE_LINK';
+const ACTION_ADMIN_AUTH_ID_PASS_ENABLED = 'ADMIN_AUTH_ID_PASS_ENABLED';
+const ACTION_ADMIN_AUTH_ID_PASS_DISABLED = 'ADMIN_AUTH_ID_PASS_DISABLED';
+const ACTION_ADMIN_AUTH_ID_PASS_UPDATE = 'ADMIN_AUTH_ID_PASS_UPDATE';
+const ACTION_ADMIN_AUTH_LDAP_ENABLED = 'ADMIN_AUTH_LDAP_ENABLED';
+const ACTION_ADMIN_AUTH_LDAP_DISABLED = 'ADMIN_AUTH_LDAP_DISABLED';
+const ACTION_ADMIN_AUTH_LDAP_UPDATE = 'ADMIN_AUTH_LDAP_UPDATE';
+const ACTION_ADMIN_AUTH_SAML_ENABLED = 'ADMIN_AUTH_SAML_ENABLED';
+const ACTION_ADMIN_AUTH_SAML_DISABLED = 'ADMIN_AUTH_SAML_DISABLED';
+const ACTION_ADMIN_AUTH_SAML_UPDATE = 'ADMIN_AUTH_SAML_UPDATE';
+const ACTION_ADMIN_AUTH_OIDC_ENABLED = 'ADMIN_AUTH_OIDC_ENABLED';
+const ACTION_ADMIN_AUTH_OIDC_DISABLED = 'ADMIN_AUTH_OIDC_DISABLED';
+const ACTION_ADMIN_AUTH_OIDC_UPDATE = 'ADMIN_AUTH_OIDC_UPDATE';
+const ACTION_ADMIN_AUTH_BASIC_ENABLED = 'ADMIN_AUTH_BASIC_ENABLED';
+const ACTION_ADMIN_AUTH_BASIC_DISABLED = 'ADMIN_AUTH_BASIC_DISABLED';
+const ACTION_ADMIN_AUTH_BASIC_UPDATE = 'ADMIN_AUTH_BASIC_UPDATE';
+const ACTION_ADMIN_AUTH_GOOGLE_ENABLED = 'ADMIN_AUTH_GOOGLE_ENABLED';
+const ACTION_ADMIN_AUTH_GOOGLE_DISABLED = 'ADMIN_AUTH_GOOGLE_DISABLED';
+const ACTION_ADMIN_AUTH_GOOGLE_UPDATE = 'ADMIN_AUTH_GOOGLE_UPDATE';
+const ACTION_ADMIN_AUTH_GITHUB_ENABLED = 'ADMIN_AUTH_GITHUB_ENABLED';
+const ACTION_ADMIN_AUTH_GITHUB_DISABLED = 'ADMIN_AUTH_GITHUB_DISABLED';
+const ACTION_ADMIN_AUTH_GITHUB_UPDATE = 'ADMIN_AUTH_GITHUB_UPDATE';
+const ACTION_ADMIN_AUTH_TWITTER_ENABLED = 'ADMIN_AUTH_TWITTER_ENABLED';
+const ACTION_ADMIN_AUTH_TWITTER_DISABLED = 'ADMIN_AUTH_TWITTER_DISABLED';
+const ACTION_ADMIN_AUTH_TWITTER_UPDATE = 'ADMIN_AUTH_TWITTER_UPDATE';
+const ACTION_ADMIN_MARKDOWN_LINE_BREAK_UPDATE = 'ADMIN_MARKDOWN_LINE_BREAK_UPDATE';
+const ACTION_ADMIN_MARKDOWN_INDENT_UPDATE = 'ADMIN_MARKDOWN_INDENT_UPDATE';
+const ACTION_ADMIN_MARKDOWN_PRESENTATION_UPDATE = 'ADMIN_MARKDOWN_PRESENTATION_UPDATE';
+const ACTION_ADMIN_MARKDOWN_XSS_UPDATE = 'ADMIN_MARKDOWN_XSS_UPDATE';
+const ACTION_ADMIN_LAYOUT_UPDATE = 'ADMIN_LAYOUT_UPDATE';
+const ACTION_ADMIN_THEME_UPDATE = 'ADMIN_THEME_UPDATE';
+const ACTION_ADMIN_FUNCTION_UPDATE = 'ADMIN_FUNCTION_UPDATE';
+const ACTION_ADMIN_CODE_HIGHLIGHT_UPDATE = 'ADMIN_CODE_HIGHLIGHT_UPDATE';
+const ACTION_ADMIN_CUSTOM_TITLE_UPDATE = 'ADMIN_CUSTOM_TITLE_UPDATE';
+const ACTION_ADMIN_CUSTOM_HTML_HEADER_UPDATE = 'ADMIN_CUSTOM_HTML_HEADER_UPDATE';
+const ACTION_ADMIN_CUSTOM_CSS_UPDATE = 'ADMIN_CUSTOM_CSS_UPDATE';
+const ACTION_ADMIN_CUSTOM_SCRIPT_UPDATE = 'ADMIN_CUSTOM_SCRIPT_UPDATE';
+const ACTION_ADMIN_ARCHIVE_DATA_UPLOAD = 'ADMIN_ARCHIVE_DATA_UPLOAD';
+const ACTION_ADMIN_ARCHIVE_DATA_CREATE = 'ADMIN_ARCHIVE_DATA_CREATE';
+const ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_ADD = 'ADMIN_USER_NOTIFICATION_SETTINGS_ADD';
+const ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_DELETE = 'ADMIN_USER_NOTIFICATION_SETTINGS_DELETE';
+const ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ADD = 'ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ADD';
+const ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_UPDATE = 'ADMIN_GLOBAL_NOTIFICATION_SETTINGS_UPDATE';
+const ACTION_ADMIN_NOTIFICATION_GRANT_SETTINGS_UPDATE = 'ADMIN_NOTIFICATION_GRANT_SETTINGS_UPDATE';
+const ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ENABLED = 'ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ENABLED';
+const ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DISABLED = 'ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DISABLED';
+const ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DELETE = 'ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DELETE';
+const ACTION_ADMIN_SLACK_WORKSPACE_CREATE = 'ADMIN_SLACK_WORKSPACE_CREATE';
+const ACTION_ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE = 'ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE';
+const ACTION_ADMIN_USERS_INVITE = 'ADMIN_USERS_INVITE';
+const ACTION_ADMIN_USER_GROUP_CREATE = 'ADMIN_USER_GROUP_CREATE';
+const ACTION_ADMIN_USER_GROUP_UPDATE = 'ADMIN_USER_GROUP_UPDATE';
+const ACTION_ADMIN_USER_GROUP_DELETE = 'ADMIN_USER_GROUP_DELETE';
+const ACTION_ADMIN_USER_GROUP_ADD_USER = 'ADMIN_USER_GROUP_ADD_USER';
+const ACTION_ADMIN_SEARCH_INDICES_NORMALIZE = 'ADMIN_SEARCH_INDICES_NORMALIZE';
+const ACTION_ADMIN_SEARCH_INDICES_REBUILD = 'ADMIN_SEARCH_INDICES_REBUILD';
+const ACTION_ADMIN_GROWI_DATA_IMPORTED = 'ADMIN_GROWI_DATA_IMPORTED';
+const ACTION_ADMIN_ESA_DATA_IMPORTED = 'ADMIN_ESA_DATA_IMPORTED';
+const ACTION_ADMIN_QIITA_DATA_IMPORTED = 'ADMIN_QIITA_DATA_IMPORTED';
+const ACTION_ADMIN_UPLOADED_GROWI_DATA_DISCARDED = 'ADMIN_UPLOADED_GROWI_DATA_DISCARDED';
+const ACTION_ADMIN_ESA_DATA_UPDATED = 'ADMIN_ESA_DATA_UPDATED';
+const ACTION_ADMIN_CONNECTION_TEST_OF_ESA_DATA = 'ADMIN_CONNECTION_TEST_OF_ESA_DATA';
+const ACTION_ADMIN_QIITA_DATA_UPDATED = 'ADMIN_QIITA_DATA_UPDATED';
+const ACTION_ADMIN_CONNECTION_TEST_OF_QIITA_DATA = 'ADMIN_CONNECTION_TEST_OF_QIITA_DATA';
 
 
 
 
-export const SUPPORTED_TARGET_MODEL_TYPE = {
+export const SupportedTargetModel = {
   MODEL_PAGE,
   MODEL_PAGE,
 } as const;
 } as const;
 
 
-export const SUPPORTED_EVENT_MODEL_TYPE = {
+export const SupportedEventModel = {
   MODEL_COMMENT,
   MODEL_COMMENT,
 } as const;
 } as const;
 
 
-export const SUPPORTED_ACTION_TYPE = {
+export const SupportedActionCategory = {
+  PAGE: 'Page',
+  COMMENT: 'Comment',
+  TAG: 'Tag',
+  ATTACHMENT: 'Attachment',
+  SHARE_LINK: 'ShareLink',
+  IN_APP_NOTIFICATION: 'InAppNotification',
+  SEARCH: 'Search',
+  USER: 'User',
+  ADMIN: 'Admin',
+} as const;
+
+export const SupportedAction = {
+  ACTION_UNSETTLED,
+  ACTION_USER_REGISTRATION_SUCCESS,
+  ACTION_USER_LOGIN_WITH_LOCAL,
+  ACTION_USER_LOGIN_WITH_LDAP,
+  ACTION_USER_LOGIN_WITH_GOOGLE,
+  ACTION_USER_LOGIN_WITH_GITHUB,
+  ACTION_USER_LOGIN_WITH_TWITTER,
+  ACTION_USER_LOGIN_WITH_OIDC,
+  ACTION_USER_LOGIN_WITH_SAML,
+  ACTION_USER_LOGIN_WITH_BASIC,
+  ACTION_USER_LOGIN_FAILURE,
+  ACTION_USER_LOGOUT,
+  ACTION_USER_PERSONAL_SETTINGS_UPDATE,
+  ACTION_USER_IMAGE_TYPE_UPDATE,
+  ACTION_USER_LDAP_ACCOUNT_ASSOCIATE,
+  ACTION_USER_LDAP_ACCOUNT_DISCONNECT,
+  ACTION_USER_PASSWORD_UPDATE,
+  ACTION_USER_API_TOKEN_UPDATE,
+  ACTION_USER_EDITOR_SETTINGS_UPDATE,
+  ACTION_USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE,
+  ACTION_PAGE_VIEW,
+  ACTION_PAGE_USER_HOME_VIEW,
+  ACTION_PAGE_FORBIDDEN,
+  ACTION_PAGE_NOT_FOUND,
+  ACTION_PAGE_NOT_CREATABLE,
   ACTION_PAGE_LIKE,
   ACTION_PAGE_LIKE,
+  ACTION_PAGE_UNLIKE,
   ACTION_PAGE_BOOKMARK,
   ACTION_PAGE_BOOKMARK,
+  ACTION_PAGE_UNBOOKMARK,
   ACTION_PAGE_CREATE,
   ACTION_PAGE_CREATE,
   ACTION_PAGE_UPDATE,
   ACTION_PAGE_UPDATE,
   ACTION_PAGE_RENAME,
   ACTION_PAGE_RENAME,
@@ -34,15 +198,321 @@ export const SUPPORTED_ACTION_TYPE = {
   ACTION_PAGE_DELETE,
   ACTION_PAGE_DELETE,
   ACTION_PAGE_DELETE_COMPLETELY,
   ACTION_PAGE_DELETE_COMPLETELY,
   ACTION_PAGE_REVERT,
   ACTION_PAGE_REVERT,
+  ACTION_PAGE_EMPTY_TRASH,
+  ACTION_PAGE_SUBSCRIBE,
+  ACTION_PAGE_UNSUBSCRIBE,
+  ACTION_PAGE_EXPORT,
+  ACTION_TAG_UPDATE,
+  ACTION_IN_APP_NOTIFICATION_ALL_STATUSES_OPEN,
   ACTION_COMMENT_CREATE,
   ACTION_COMMENT_CREATE,
   ACTION_COMMENT_UPDATE,
   ACTION_COMMENT_UPDATE,
+  ACTION_COMMENT_REMOVE,
+  ACTION_SHARE_LINK_CREATE,
+  ACTION_SHARE_LINK_DELETE,
+  ACTION_SHARE_LINK_DELETE_BY_PAGE,
+  ACTION_SHARE_LINK_ALL_DELETE,
+  ACTION_SHARE_LINK_PAGE_VIEW,
+  ACTION_SHARE_LINK_EXPIRED_PAGE_VIEW,
+  ACTION_SHARE_LINK_NOT_FOUND,
+  ACTION_ATTACHMENT_ADD,
+  ACTION_ATTACHMENT_REMOVE,
+  ACTION_ATTACHMENT_DOWNLOAD,
+  ACTION_SEARCH_PAGE,
+  ACTION_SEARCH_PAGE_VIEW,
+  ACTION_ADMIN_APP_SETTINGS_UPDATE,
+  ACTION_ADMIN_SITE_URL_UPDATE,
+  ACTION_ADMIN_MAIL_SMTP_UPDATE,
+  ACTION_ADMIN_MAIL_SES_UPDATE,
+  ACTION_ADMIN_MAIL_TEST_SUBMIT,
+  ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE,
+  ACTION_ADMIN_PLUGIN_UPDATE,
+  ACTION_ADMIN_MAINTENANCEMODE_ENABLED,
+  ACTION_ADMIN_MAINTENANCEMODE_DISABLED,
+  ACTION_ADMIN_SECURITY_SETTINGS_UPDATE,
+  ACTION_ADMIN_PERMIT_SHARE_LINK,
+  ACTION_ADMIN_REJECT_SHARE_LINK,
+  ACTION_ADMIN_AUTH_ID_PASS_ENABLED,
+  ACTION_ADMIN_AUTH_ID_PASS_DISABLED,
+  ACTION_ADMIN_AUTH_ID_PASS_UPDATE,
+  ACTION_ADMIN_AUTH_LDAP_ENABLED,
+  ACTION_ADMIN_AUTH_LDAP_DISABLED,
+  ACTION_ADMIN_AUTH_LDAP_UPDATE,
+  ACTION_ADMIN_AUTH_SAML_ENABLED,
+  ACTION_ADMIN_AUTH_SAML_DISABLED,
+  ACTION_ADMIN_AUTH_SAML_UPDATE,
+  ACTION_ADMIN_AUTH_OIDC_ENABLED,
+  ACTION_ADMIN_AUTH_OIDC_DISABLED,
+  ACTION_ADMIN_AUTH_OIDC_UPDATE,
+  ACTION_ADMIN_AUTH_BASIC_ENABLED,
+  ACTION_ADMIN_AUTH_BASIC_DISABLED,
+  ACTION_ADMIN_AUTH_BASIC_UPDATE,
+  ACTION_ADMIN_AUTH_GOOGLE_ENABLED,
+  ACTION_ADMIN_AUTH_GOOGLE_DISABLED,
+  ACTION_ADMIN_AUTH_GOOGLE_UPDATE,
+  ACTION_ADMIN_AUTH_GITHUB_ENABLED,
+  ACTION_ADMIN_AUTH_GITHUB_DISABLED,
+  ACTION_ADMIN_AUTH_GITHUB_UPDATE,
+  ACTION_ADMIN_AUTH_TWITTER_ENABLED,
+  ACTION_ADMIN_AUTH_TWITTER_DISABLED,
+  ACTION_ADMIN_AUTH_TWITTER_UPDATE,
+  ACTION_ADMIN_MARKDOWN_LINE_BREAK_UPDATE,
+  ACTION_ADMIN_MARKDOWN_INDENT_UPDATE,
+  ACTION_ADMIN_MARKDOWN_PRESENTATION_UPDATE,
+  ACTION_ADMIN_MARKDOWN_XSS_UPDATE,
+  ACTION_ADMIN_LAYOUT_UPDATE,
+  ACTION_ADMIN_THEME_UPDATE,
+  ACTION_ADMIN_FUNCTION_UPDATE,
+  ACTION_ADMIN_CODE_HIGHLIGHT_UPDATE,
+  ACTION_ADMIN_CUSTOM_TITLE_UPDATE,
+  ACTION_ADMIN_CUSTOM_HTML_HEADER_UPDATE,
+  ACTION_ADMIN_CUSTOM_CSS_UPDATE,
+  ACTION_ADMIN_CUSTOM_SCRIPT_UPDATE,
+  ACTION_ADMIN_ARCHIVE_DATA_UPLOAD,
+  ACTION_ADMIN_ARCHIVE_DATA_CREATE,
+  ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_ADD,
+  ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_DELETE,
+  ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ADD,
+  ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_UPDATE,
+  ACTION_ADMIN_NOTIFICATION_GRANT_SETTINGS_UPDATE,
+  ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ENABLED,
+  ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DISABLED,
+  ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DELETE,
+  ACTION_ADMIN_SLACK_WORKSPACE_CREATE,
+  ACTION_ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE,
+  ACTION_ADMIN_USERS_INVITE,
+  ACTION_ADMIN_USER_GROUP_CREATE,
+  ACTION_ADMIN_USER_GROUP_UPDATE,
+  ACTION_ADMIN_USER_GROUP_DELETE,
+  ACTION_ADMIN_USER_GROUP_ADD_USER,
+  ACTION_ADMIN_SEARCH_INDICES_NORMALIZE,
+  ACTION_ADMIN_SEARCH_INDICES_REBUILD,
+  ACTION_ADMIN_GROWI_DATA_IMPORTED,
+  ACTION_ADMIN_ESA_DATA_IMPORTED,
+  ACTION_ADMIN_QIITA_DATA_IMPORTED,
+  ACTION_ADMIN_UPLOADED_GROWI_DATA_DISCARDED,
+  ACTION_ADMIN_ESA_DATA_UPDATED,
+  ACTION_ADMIN_CONNECTION_TEST_OF_ESA_DATA,
+  ACTION_ADMIN_QIITA_DATA_UPDATED,
+  ACTION_ADMIN_CONNECTION_TEST_OF_QIITA_DATA,
 } as const;
 } as const;
 
 
+// Action required for notification
+export const EssentialActionGroup = {
+  ACTION_PAGE_LIKE,
+  ACTION_PAGE_BOOKMARK,
+  ACTION_PAGE_UPDATE,
+  ACTION_PAGE_RENAME,
+  ACTION_PAGE_DUPLICATE,
+  ACTION_PAGE_DELETE,
+  ACTION_PAGE_DELETE_COMPLETELY,
+  ACTION_PAGE_REVERT,
+  ACTION_COMMENT_CREATE,
+} as const;
+
+export const ActionGroupSize = {
+  Small: 'SMALL',
+  Medium: 'MEDIUM',
+  Large: 'LARGE',
+} as const;
+
+export const SmallActionGroup = {
+  ACTION_USER_LOGIN_WITH_LOCAL,
+  ACTION_USER_LOGIN_WITH_LDAP,
+  ACTION_USER_LOGIN_WITH_GOOGLE,
+  ACTION_USER_LOGIN_WITH_GITHUB,
+  ACTION_USER_LOGIN_WITH_TWITTER,
+  ACTION_USER_LOGIN_WITH_OIDC,
+  ACTION_USER_LOGIN_WITH_SAML,
+  ACTION_USER_LOGIN_WITH_BASIC,
+  ACTION_USER_LOGIN_FAILURE,
+  ACTION_USER_LOGOUT,
+  ACTION_PAGE_CREATE,
+  ACTION_PAGE_DELETE,
+} as const;
+
+// SmallActionGroup + Action by all General Users - PAGE_VIEW
+export const MediumActionGroup = {
+  ...SmallActionGroup,
+  ACTION_USER_REGISTRATION_SUCCESS,
+  ACTION_USER_PERSONAL_SETTINGS_UPDATE,
+  ACTION_USER_IMAGE_TYPE_UPDATE,
+  ACTION_USER_LDAP_ACCOUNT_ASSOCIATE,
+  ACTION_USER_LDAP_ACCOUNT_DISCONNECT,
+  ACTION_USER_PASSWORD_UPDATE,
+  ACTION_USER_API_TOKEN_UPDATE,
+  ACTION_USER_EDITOR_SETTINGS_UPDATE,
+  ACTION_USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE,
+  ACTION_PAGE_LIKE,
+  ACTION_PAGE_UNLIKE,
+  ACTION_PAGE_BOOKMARK,
+  ACTION_PAGE_UNBOOKMARK,
+  ACTION_PAGE_CREATE,
+  ACTION_PAGE_UPDATE,
+  ACTION_PAGE_RENAME,
+  ACTION_PAGE_DUPLICATE,
+  ACTION_PAGE_DELETE,
+  ACTION_PAGE_DELETE_COMPLETELY,
+  ACTION_PAGE_REVERT,
+  ACTION_PAGE_EMPTY_TRASH,
+  ACTION_PAGE_SUBSCRIBE,
+  ACTION_PAGE_UNSUBSCRIBE,
+  ACTION_PAGE_EXPORT,
+  ACTION_TAG_UPDATE,
+  ACTION_IN_APP_NOTIFICATION_ALL_STATUSES_OPEN,
+  ACTION_COMMENT_CREATE,
+  ACTION_COMMENT_UPDATE,
+  ACTION_COMMENT_REMOVE,
+  ACTION_SHARE_LINK_CREATE,
+  ACTION_SHARE_LINK_DELETE,
+  ACTION_SHARE_LINK_DELETE_BY_PAGE,
+  ACTION_ATTACHMENT_ADD,
+  ACTION_ATTACHMENT_REMOVE,
+  ACTION_ATTACHMENT_DOWNLOAD,
+  ACTION_SEARCH_PAGE,
+  ACTION_SEARCH_PAGE_VIEW,
+} as const;
+
+// MediumActionGroup + All Actions by Admin Users - PAGE_VIEW
+export const LargeActionGroup = {
+  ...MediumActionGroup,
+  ACTION_SHARE_LINK_ALL_DELETE,
+  ACTION_ADMIN_APP_SETTINGS_UPDATE,
+  ACTION_ADMIN_SITE_URL_UPDATE,
+  ACTION_ADMIN_MAIL_SMTP_UPDATE,
+  ACTION_ADMIN_MAIL_SES_UPDATE,
+  ACTION_ADMIN_MAIL_TEST_SUBMIT,
+  ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE,
+  ACTION_ADMIN_PLUGIN_UPDATE,
+  ACTION_ADMIN_MAINTENANCEMODE_ENABLED,
+  ACTION_ADMIN_MAINTENANCEMODE_DISABLED,
+  ACTION_ADMIN_SECURITY_SETTINGS_UPDATE,
+  ACTION_ADMIN_PERMIT_SHARE_LINK,
+  ACTION_ADMIN_REJECT_SHARE_LINK,
+  ACTION_ADMIN_AUTH_ID_PASS_ENABLED,
+  ACTION_ADMIN_AUTH_ID_PASS_DISABLED,
+  ACTION_ADMIN_AUTH_ID_PASS_UPDATE,
+  ACTION_ADMIN_AUTH_LDAP_ENABLED,
+  ACTION_ADMIN_AUTH_LDAP_DISABLED,
+  ACTION_ADMIN_AUTH_LDAP_UPDATE,
+  ACTION_ADMIN_AUTH_SAML_ENABLED,
+  ACTION_ADMIN_AUTH_SAML_DISABLED,
+  ACTION_ADMIN_AUTH_SAML_UPDATE,
+  ACTION_ADMIN_AUTH_OIDC_ENABLED,
+  ACTION_ADMIN_AUTH_OIDC_DISABLED,
+  ACTION_ADMIN_AUTH_OIDC_UPDATE,
+  ACTION_ADMIN_AUTH_BASIC_ENABLED,
+  ACTION_ADMIN_AUTH_BASIC_DISABLED,
+  ACTION_ADMIN_AUTH_BASIC_UPDATE,
+  ACTION_ADMIN_AUTH_GOOGLE_ENABLED,
+  ACTION_ADMIN_AUTH_GOOGLE_DISABLED,
+  ACTION_ADMIN_AUTH_GOOGLE_UPDATE,
+  ACTION_ADMIN_AUTH_GITHUB_ENABLED,
+  ACTION_ADMIN_AUTH_GITHUB_DISABLED,
+  ACTION_ADMIN_AUTH_GITHUB_UPDATE,
+  ACTION_ADMIN_AUTH_TWITTER_ENABLED,
+  ACTION_ADMIN_AUTH_TWITTER_DISABLED,
+  ACTION_ADMIN_AUTH_TWITTER_UPDATE,
+  ACTION_ADMIN_MARKDOWN_LINE_BREAK_UPDATE,
+  ACTION_ADMIN_MARKDOWN_INDENT_UPDATE,
+  ACTION_ADMIN_MARKDOWN_PRESENTATION_UPDATE,
+  ACTION_ADMIN_MARKDOWN_XSS_UPDATE,
+  ACTION_ADMIN_LAYOUT_UPDATE,
+  ACTION_ADMIN_THEME_UPDATE,
+  ACTION_ADMIN_FUNCTION_UPDATE,
+  ACTION_ADMIN_CODE_HIGHLIGHT_UPDATE,
+  ACTION_ADMIN_CUSTOM_TITLE_UPDATE,
+  ACTION_ADMIN_CUSTOM_HTML_HEADER_UPDATE,
+  ACTION_ADMIN_CUSTOM_CSS_UPDATE,
+  ACTION_ADMIN_CUSTOM_SCRIPT_UPDATE,
+  ACTION_ADMIN_ARCHIVE_DATA_UPLOAD,
+  ACTION_ADMIN_ARCHIVE_DATA_CREATE,
+  ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_ADD,
+  ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_DELETE,
+  ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ADD,
+  ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_UPDATE,
+  ACTION_ADMIN_NOTIFICATION_GRANT_SETTINGS_UPDATE,
+  ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ENABLED,
+  ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DISABLED,
+  ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DELETE,
+  ACTION_ADMIN_SLACK_WORKSPACE_CREATE,
+  ACTION_ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE,
+  ACTION_ADMIN_USERS_INVITE,
+  ACTION_ADMIN_USER_GROUP_CREATE,
+  ACTION_ADMIN_USER_GROUP_UPDATE,
+  ACTION_ADMIN_USER_GROUP_DELETE,
+  ACTION_ADMIN_USER_GROUP_ADD_USER,
+  ACTION_ADMIN_SEARCH_INDICES_NORMALIZE,
+  ACTION_ADMIN_SEARCH_INDICES_REBUILD,
+  ACTION_ADMIN_GROWI_DATA_IMPORTED,
+  ACTION_ADMIN_ESA_DATA_IMPORTED,
+  ACTION_ADMIN_QIITA_DATA_IMPORTED,
+  ACTION_ADMIN_UPLOADED_GROWI_DATA_DISCARDED,
+  ACTION_ADMIN_ESA_DATA_UPDATED,
+  ACTION_ADMIN_CONNECTION_TEST_OF_ESA_DATA,
+  ACTION_ADMIN_QIITA_DATA_UPDATED,
+  ACTION_ADMIN_CONNECTION_TEST_OF_QIITA_DATA,
+} as const;
+
+
+/*
+ * Array
+ */
+export const AllSupportedTargetModels = Object.values(SupportedTargetModel);
+export const AllSupportedEventModels = Object.values(SupportedEventModel);
+export const AllSupportedActions = Object.values(SupportedAction);
+export const AllEssentialActions = Object.values(EssentialActionGroup);
+export const AllSmallGroupActions = Object.values(SmallActionGroup);
+export const AllMediumGroupActions = Object.values(MediumActionGroup);
+export const AllLargeGroupActions = Object.values(LargeActionGroup);
+
+// Action categories(for SelectActionDropdown.tsx)
+const pageRegExp = new RegExp(`^${SupportedActionCategory.PAGE.toUpperCase()}_`);
+const commentRegExp = new RegExp(`^${SupportedActionCategory.COMMENT.toUpperCase()}_`);
+const tagRegExp = new RegExp(`^${SupportedActionCategory.TAG.toUpperCase()}_`);
+const attachmentRegExp = RegExp(`^${SupportedActionCategory.ATTACHMENT.toUpperCase()}_`);
+const shareLinkRegExp = RegExp(`^${SupportedActionCategory.SHARE_LINK.toUpperCase()}_`);
+const inAppNotificationRegExp = RegExp(`^${SupportedActionCategory.IN_APP_NOTIFICATION.toUpperCase()}_`);
+const searchRegExp = RegExp(`^${SupportedActionCategory.SEARCH.toUpperCase()}_`);
+const userRegExp = new RegExp(`^${SupportedActionCategory.USER.toUpperCase()}_`);
+const adminRegExp = new RegExp(`^${SupportedActionCategory.ADMIN.toUpperCase()}_`);
+
+export const PageActions = AllSupportedActions.filter(action => action.match(pageRegExp));
+export const CommentActions = AllSupportedActions.filter(action => action.match(commentRegExp));
+export const TagActions = AllSupportedActions.filter(action => action.match(tagRegExp));
+export const AttachmentActions = AllSupportedActions.filter(action => action.match(attachmentRegExp));
+export const ShareLinkActions = AllSupportedActions.filter(action => action.match(shareLinkRegExp));
+export const InAppNotificationActions = AllSupportedActions.filter(action => action.match(inAppNotificationRegExp));
+export const SearchActions = AllSupportedActions.filter(action => action.match(searchRegExp));
+export const UserActions = AllSupportedActions.filter(action => action.match(userRegExp));
+export const AdminActions = AllSupportedActions.filter(action => action.match(adminRegExp));
+
+/*
+ * Type
+ */
+export type SupportedTargetModelType = typeof SupportedTargetModel[keyof typeof SupportedTargetModel];
+export type SupportedEventModelType = typeof SupportedEventModel[keyof typeof SupportedEventModel];
+export type SupportedActionType = typeof SupportedAction[keyof typeof SupportedAction];
+export type SupportedActionCategoryType = typeof SupportedActionCategory[keyof typeof SupportedActionCategory]
+
+export type ISnapshot = Partial<Pick<IUser, 'username'>>
+
+export type IActivity = {
+  user?: Ref<IUser>
+  ip?: string
+  endpoint?: string
+  targetModel?: SupportedTargetModelType
+  target?: string
+  eventModel?: SupportedEventModelType
+  event?: string
+  action: SupportedActionType
+  createdAt: Date
+  snapshot?: ISnapshot
+}
 
 
-export const AllSupportedTargetModelType = Object.values(SUPPORTED_TARGET_MODEL_TYPE);
-export const AllSupportedEventModelType = Object.values(SUPPORTED_EVENT_MODEL_TYPE);
-export const AllSupportedActionType = Object.values(SUPPORTED_ACTION_TYPE);
+export type IActivityHasId = IActivity & HasObjectId;
 
 
-// type supportedTargetModelType = typeof SUPPORTED_TARGET_MODEL_NAMES[keyof typeof SUPPORTED_TARGET_MODEL_NAMES];
-// type supportedEventModelType = typeof SUPPORTED_EVENT_MODEL_NAMES[keyof typeof SUPPORTED_EVENT_MODEL_NAMES];
-// type supportedActionType = typeof SUPPORTED_ACTION_NAMES[keyof typeof SUPPORTED_ACTION_NAMES];
+export type ISearchFilter = {
+  usernames?: string[]
+  dates?: {startDate: string | null, endDate: string | null}
+  actions?: SupportedActionType[]
+}

+ 13 - 0
packages/app/src/interfaces/mongoose-utils.ts

@@ -0,0 +1,13 @@
+export interface PaginateResult<T> {
+  docs: T[];
+  hasNextPage: boolean;
+  hasPrevPage: boolean;
+  limit: number;
+  nextPage: number | null;
+  offset: number;
+  page: number;
+  pagingCounter: number;
+  prevPage: number | null;
+  totalDocs: number;
+  totalPages: number;
+}

+ 2 - 0
packages/app/src/server/crowi/index.js

@@ -89,6 +89,7 @@ function Crowi() {
   this.events = {
   this.events = {
     user: new (require('../events/user'))(this),
     user: new (require('../events/user'))(this),
     page: new (require('../events/page'))(this),
     page: new (require('../events/page'))(this),
+    activity: new (require('../events/activity'))(this),
     bookmark: new (require('../events/bookmark'))(this),
     bookmark: new (require('../events/bookmark'))(this),
     comment: new (require('../events/comment'))(this),
     comment: new (require('../events/comment'))(this),
     tag: new (require('../events/tag'))(this),
     tag: new (require('../events/tag'))(this),
@@ -706,6 +707,7 @@ Crowi.prototype.setupActivityService = async function() {
   const ActivityService = require('../service/activity');
   const ActivityService = require('../service/activity');
   if (this.activityService == null) {
   if (this.activityService == null) {
     this.activityService = new ActivityService(this);
     this.activityService = new ActivityService(this);
+    await this.activityService.createTtlIndex();
   }
   }
 };
 };
 
 

+ 10 - 13
packages/app/src/server/events/activity.ts

@@ -1,20 +1,17 @@
-import { EventEmitter } from 'events';
-import loggerFactory from '../../utils/logger';
-
-const logger = loggerFactory('growi:events:activity');
+import loggerFactory from '~/utils/logger';
 
 
+import Crowi from '../crowi';
 
 
-class ActivityEvent extends EventEmitter {
+const logger = loggerFactory('growi:events:activity');
 
 
-  onRemove(action: string, activity: any): void {
-    logger.info('onRemove activity event fired');
-  }
+const events = require('events');
+const util = require('util');
 
 
-  onCreate(action: string, activity: any): void {
-    logger.info('onCreate activity event fired');
-  }
+function ActivityEvent(crowi: Crowi) {
+  this.crowi = crowi;
 
 
+  events.EventEmitter.call(this);
 }
 }
+util.inherits(ActivityEvent, events.EventEmitter);
 
 
-const instance = new ActivityEvent();
-export default instance;
+module.exports = ActivityEvent;

+ 2 - 2
packages/app/src/server/events/comment.ts

@@ -2,9 +2,9 @@ import loggerFactory from '~/utils/logger';
 
 
 const logger = loggerFactory('growi:events:comment');
 const logger = loggerFactory('growi:events:comment');
 
 
+const events = require('events');
 const util = require('util');
 const util = require('util');
 
 
-const events = require('events');
 
 
 function CommentEvent(crowi) {
 function CommentEvent(crowi) {
   this.crowi = crowi;
   this.crowi = crowi;
@@ -20,7 +20,7 @@ CommentEvent.prototype.onUpdate = function(comment) {
   logger.info('onUpdate comment event fired');
   logger.info('onUpdate comment event fired');
 };
 };
 CommentEvent.prototype.onDelete = function(comment) {
 CommentEvent.prototype.onDelete = function(comment) {
-  logger.info('onRemove comment event fired');
+  logger.info('onDelete comment event fired');
 };
 };
 
 
 module.exports = CommentEvent;
 module.exports = CommentEvent;

+ 40 - 0
packages/app/src/server/middlewares/add-activity.ts

@@ -0,0 +1,40 @@
+import { NextFunction, Request, Response } from 'express';
+
+import { SupportedAction } from '~/interfaces/activity';
+import { IUserHasId } from '~/interfaces/user';
+import Activity from '~/server/models/activity';
+import loggerFactory from '~/utils/logger';
+
+
+const logger = loggerFactory('growi:middlewares:add-activity');
+
+interface AuthorizedRequest extends Request {
+  user?: IUserHasId
+}
+
+export const generateAddActivityMiddleware = crowi => async(req: AuthorizedRequest, res: Response, next: NextFunction): Promise<void> => {
+  if (req.method === 'GET') {
+    logger.warn('This middleware is not available for GET requests');
+    return next();
+  }
+
+  const parameter = {
+    ip:  req.ip,
+    endpoint: req.originalUrl,
+    action: SupportedAction.ACTION_UNSETTLED,
+    user: req.user?._id,
+    snapshot: {
+      username: req.user?.username,
+    },
+  };
+
+  try {
+    const activity = await Activity.createByParameters(parameter);
+    res.locals.activity = activity;
+  }
+  catch (err) {
+    logger.error('Create activity failed', err);
+  }
+
+  return next();
+};

+ 138 - 0
packages/app/src/server/middlewares/rate-limiter.ts

@@ -0,0 +1,138 @@
+import { NextFunction, Request, Response } from 'express';
+import md5 from 'md5';
+import mongoose from 'mongoose';
+import { IRateLimiterMongoOptions, RateLimiterMongo } from 'rate-limiter-flexible';
+
+import {
+  DEFAULT_DURATION_SEC, DEFAULT_MAX_REQUESTS, DEFAULT_USERS_PER_IP_PROSPECTION, IApiRateLimitConfig,
+} from '^/config/rate-limiter';
+
+import { IUserHasId } from '~/interfaces/user';
+import loggerFactory from '~/utils/logger';
+
+import { generateApiRateLimitConfig } from '../util/rate-limiter';
+
+
+const logger = loggerFactory('growi:middleware:api-rate-limit');
+
+// config sample
+// API_RATE_LIMIT_010_FOO_ENDPOINT=/_api/v3/foo
+// API_RATE_LIMIT_010_FOO_METHODS=GET,POST
+// API_RATE_LIMIT_010_FOO_MAX_REQUESTS=10
+
+const POINTS_THRESHOLD = 100;
+
+const opts: IRateLimiterMongoOptions = {
+  storeClient: mongoose.connection,
+  points: POINTS_THRESHOLD, // set default value
+  duration: DEFAULT_DURATION_SEC, // set default value
+};
+const rateLimiter = new RateLimiterMongo(opts);
+
+// generate ApiRateLimitConfig for api rate limiter
+const apiRateLimitConfig = generateApiRateLimitConfig();
+const configWithoutRegExp = apiRateLimitConfig.withoutRegExp;
+const configWithRegExp = apiRateLimitConfig.withRegExp;
+const allRegExp = new RegExp(Object.keys(configWithRegExp).join('|'));
+const keysWithRegExp = Object.keys(configWithRegExp).map(key => new RegExp(`^${key}`));
+const valuesWithRegExp = Object.values(configWithRegExp);
+
+
+const _consumePoints = async(
+    method: string, key: string | null, customizedConfig?: IApiRateLimitConfig, maxRequestsMultiplier?: number,
+) => {
+  if (key == null) {
+    return;
+  }
+
+  let maxRequests = DEFAULT_MAX_REQUESTS;
+
+  // use customizedConfig
+  if (customizedConfig != null && (customizedConfig.method.includes(method) || customizedConfig.method === 'ALL')) {
+    maxRequests = customizedConfig.maxRequests;
+  }
+
+  // multiply
+  if (maxRequestsMultiplier != null) {
+    maxRequests *= maxRequestsMultiplier;
+  }
+
+  // because the maximum request is reduced by 1 if it is divisible by
+  // https://github.com/weseek/growi/pull/6225
+  const consumePoints = (POINTS_THRESHOLD + 0.0001) / maxRequests;
+  await rateLimiter.consume(key, consumePoints);
+};
+
+/**
+ * consume per user per endpoint
+ * @param method
+ * @param key
+ * @param customizedConfig
+ * @returns
+ */
+const consumePointsByUser = async(method: string, key: string | null, customizedConfig?: IApiRateLimitConfig) => {
+  return _consumePoints(method, key, customizedConfig);
+};
+
+/**
+ * consume per ip per endpoint
+ * @param method
+ * @param key
+ * @param customizedConfig
+ * @returns
+ */
+const consumePointsByIp = async(method: string, key: string | null, customizedConfig?: IApiRateLimitConfig) => {
+  const maxRequestsMultiplier = customizedConfig?.usersPerIpProspection ?? DEFAULT_USERS_PER_IP_PROSPECTION;
+  return _consumePoints(method, key, customizedConfig, maxRequestsMultiplier);
+};
+
+
+module.exports = () => {
+
+  return async(req: Request & { user?: IUserHasId }, res: Response, next: NextFunction) => {
+
+    const endpoint = req.path;
+
+    // determine keys
+    const keyForUser: string | null = req.user != null
+      ? md5(`${req.user._id}_${endpoint}_${req.method}`)
+      : null;
+    const keyForIp: string = md5(`${req.ip}_${endpoint}_${req.method}`);
+
+    // determine customized config
+    let customizedConfig: IApiRateLimitConfig | undefined;
+    const configForEndpoint = configWithoutRegExp[endpoint];
+    if (configForEndpoint) {
+      customizedConfig = configForEndpoint;
+    }
+    else if (allRegExp.test(endpoint)) {
+      keysWithRegExp.forEach((key, index) => {
+        if (key.test(endpoint)) {
+          customizedConfig = valuesWithRegExp[index];
+        }
+      });
+    }
+
+    // check for the current user
+    if (req.user != null) {
+      try {
+        await consumePointsByUser(req.method, keyForUser, customizedConfig);
+      }
+      catch {
+        logger.error(`${req.user._id}: too many request at ${endpoint}`);
+        return res.sendStatus(429);
+      }
+    }
+
+    // check for ip
+    try {
+      await consumePointsByIp(req.method, keyForIp, customizedConfig);
+    }
+    catch {
+      logger.error(`${req.ip}: too many request at ${endpoint}`);
+      return res.sendStatus(429);
+    }
+
+    return next();
+  };
+};

+ 88 - 27
packages/app/src/server/models/activity.ts

@@ -2,70 +2,94 @@ import { getOrCreateModel, getModelSafely } from '@growi/core';
 import {
 import {
   Types, Document, Model, Schema,
   Types, Document, Model, Schema,
 } from 'mongoose';
 } from 'mongoose';
+import mongoosePaginate from 'mongoose-paginate-v2';
 
 
-import { AllSupportedTargetModelType, AllSupportedEventModelType, AllSupportedActionType } from '~/interfaces/activity';
+import {
+  IActivity, ISnapshot, AllSupportedActions, SupportedActionType,
+  AllSupportedTargetModels, SupportedTargetModelType,
+  AllSupportedEventModels, SupportedEventModelType,
+} from '~/interfaces/activity';
 
 
 import loggerFactory from '../../utils/logger';
 import loggerFactory from '../../utils/logger';
-import activityEvent from '../events/activity';
 
 
 import Subscription from './subscription';
 import Subscription from './subscription';
 
 
+
 const logger = loggerFactory('growi:models:activity');
 const logger = loggerFactory('growi:models:activity');
 
 
 export interface ActivityDocument extends Document {
 export interface ActivityDocument extends Document {
   _id: Types.ObjectId
   _id: Types.ObjectId
-  user: Types.ObjectId | any
-  targetModel: string
+  user: Types.ObjectId
+  ip: string
+  endpoint: string
+  targetModel: SupportedTargetModelType
   target: Types.ObjectId
   target: Types.ObjectId
-  action: string
+  eventModel: SupportedEventModelType
   event: Types.ObjectId
   event: Types.ObjectId
-  eventModel: string
+  action: SupportedActionType
+  snapshot: ISnapshot
 
 
   getNotificationTargetUsers(): Promise<any[]>
   getNotificationTargetUsers(): Promise<any[]>
 }
 }
 
 
 export interface ActivityModel extends Model<ActivityDocument> {
 export interface ActivityModel extends Model<ActivityDocument> {
+  [x:string]: any
   getActionUsersFromActivities(activities: ActivityDocument[]): any[]
   getActionUsersFromActivities(activities: ActivityDocument[]): any[]
 }
 }
+
+const snapshotSchema = new Schema<ISnapshot>({
+  username: { type: String, index: true },
+});
+
 // TODO: add revision id
 // TODO: add revision id
 const activitySchema = new Schema<ActivityDocument, ActivityModel>({
 const activitySchema = new Schema<ActivityDocument, ActivityModel>({
   user: {
   user: {
     type: Schema.Types.ObjectId,
     type: Schema.Types.ObjectId,
     ref: 'User',
     ref: 'User',
     index: true,
     index: true,
-    require: true,
+  },
+  ip: {
+    type: String,
+  },
+  endpoint: {
+    type: String,
   },
   },
   targetModel: {
   targetModel: {
     type: String,
     type: String,
-    require: true,
-    enum: AllSupportedTargetModelType,
+    enum: AllSupportedTargetModels,
   },
   },
   target: {
   target: {
     type: Schema.Types.ObjectId,
     type: Schema.Types.ObjectId,
     refPath: 'targetModel',
     refPath: 'targetModel',
-    require: true,
   },
   },
-  action: {
+  eventModel: {
     type: String,
     type: String,
-    require: true,
-    enum: AllSupportedActionType,
+    enum: AllSupportedEventModels,
   },
   },
   event: {
   event: {
     type: Schema.Types.ObjectId,
     type: Schema.Types.ObjectId,
-    refPath: 'eventModel',
   },
   },
-  eventModel: {
+  action: {
     type: String,
     type: String,
-    enum: AllSupportedEventModelType,
+    enum: AllSupportedActions,
+    required: true,
   },
   },
+  snapshot: snapshotSchema,
 }, {
 }, {
-  timestamps: true,
+  timestamps: {
+    createdAt: true,
+    updatedAt: false,
+  },
 });
 });
 activitySchema.index({ target: 1, action: 1 });
 activitySchema.index({ target: 1, action: 1 });
 activitySchema.index({
 activitySchema.index({
   user: 1, target: 1, action: 1, createdAt: 1,
   user: 1, target: 1, action: 1, createdAt: 1,
 }, { unique: true });
 }, { unique: true });
+activitySchema.plugin(mongoosePaginate);
 
 
+activitySchema.post('save', function() {
+  logger.debug('activity has been created', this);
+});
 
 
 activitySchema.methods.getNotificationTargetUsers = async function() {
 activitySchema.methods.getNotificationTargetUsers = async function() {
   const User = getModelSafely('User') || require('~/server/models/user')();
   const User = getModelSafely('User') || require('~/server/models/user')();
@@ -89,16 +113,53 @@ activitySchema.methods.getNotificationTargetUsers = async function() {
   return activeNotificationUsers;
   return activeNotificationUsers;
 };
 };
 
 
-activitySchema.post('save', async(savedActivity: ActivityDocument) => {
-  let targetUsers: Types.ObjectId[] = [];
-  try {
-    targetUsers = await savedActivity.getNotificationTargetUsers();
-  }
-  catch (err) {
-    logger.error(err);
-  }
+activitySchema.statics.createByParameters = async function(parameters): Promise<IActivity> {
+  const activity = await this.create(parameters) as unknown as IActivity;
 
 
-  activityEvent.emit('create', targetUsers, savedActivity);
-});
+  return activity;
+};
+
+// When using this method, ensure that activity updates are allowed using ActivityService.shoudUpdateActivity
+activitySchema.statics.updateByParameters = async function(activityId: string, parameters): Promise<IActivity> {
+  const activity = await this.findOneAndUpdate({ _id: activityId }, parameters, { new: true }) as unknown as IActivity;
+
+  return activity;
+};
+
+activitySchema.statics.getPaginatedActivity = async function(limit: number, offset: number, query) {
+  const paginateResult = await this.paginate(
+    query,
+    {
+      limit,
+      offset,
+      sort: { createdAt: -1 },
+    },
+  );
+  return paginateResult;
+};
+
+activitySchema.statics.findSnapshotUsernamesByUsernameRegexWithTotalCount = async function(
+    q: string, option: { sortOpt: number | string, offset: number, limit: number},
+): Promise<{usernames: string[], totalCount: number}> {
+  const opt = option || {};
+  const sortOpt = opt.sortOpt || 1;
+  const offset = opt.offset || 0;
+  const limit = opt.limit || 10;
+
+  const conditions = { 'snapshot.username': { $regex: q, $options: 'i' } };
+
+  const usernames = await this.aggregate()
+    .skip(0)
+    .limit(10000) // Narrow down the search target
+    .match(conditions)
+    .group({ _id: '$snapshot.username' })
+    .sort({ _id: sortOpt }) // Sort "snapshot.username" in ascending order
+    .skip(offset)
+    .limit(limit);
+
+  const totalCount = (await this.find(conditions).distinct('snapshot.username')).length;
+
+  return { usernames: usernames.map(r => r._id), totalCount };
+};
 
 
 export default getOrCreateModel<ActivityDocument, ActivityModel>('Activity', activitySchema);
 export default getOrCreateModel<ActivityDocument, ActivityModel>('Activity', activitySchema);

+ 2 - 2
packages/app/src/server/models/comment.js

@@ -99,7 +99,7 @@ module.exports = function(crowi) {
    * post remove hook
    * post remove hook
    */
    */
   commentSchema.post('reomove', async(savedComment) => {
   commentSchema.post('reomove', async(savedComment) => {
-    await commentEvent.emit('remove', savedComment);
+    await commentEvent.emit('delete', savedComment);
   });
   });
 
 
   commentSchema.methods.removeWithReplies = async function(comment) {
   commentSchema.methods.removeWithReplies = async function(comment) {
@@ -110,7 +110,7 @@ module.exports = function(crowi) {
         [{ replyTo: this._id }, { _id: this._id }]),
         [{ replyTo: this._id }, { _id: this._id }]),
     });
     });
 
 
-    await commentEvent.emit('remove', comment);
+    await commentEvent.emit('delete', comment);
     return;
     return;
   };
   };
 
 

+ 3 - 0
packages/app/src/server/models/config.ts

@@ -245,6 +245,9 @@ schema.statics.getLocalconfig = function(crowi) {
     globalLang: crowi.configManager.getConfig('crowi', 'app:globalLang'),
     globalLang: crowi.configManager.getConfig('crowi', 'app:globalLang'),
     pageLimitationL: crowi.configManager.getConfig('crowi', 'customize:showPageLimitationL'),
     pageLimitationL: crowi.configManager.getConfig('crowi', 'customize:showPageLimitationL'),
     pageLimitationXL: crowi.configManager.getConfig('crowi', 'customize:showPageLimitationXL'),
     pageLimitationXL: crowi.configManager.getConfig('crowi', 'customize:showPageLimitationXL'),
+    auditLogEnabled: crowi.configManager.getConfig('crowi', 'app:auditLogEnabled'),
+    activityExpirationSeconds: crowi.configManager.getConfig('crowi', 'app:activityExpirationSeconds'),
+    auditLogAvailableActions: crowi.activityService.getAvailableActions(false),
     isSidebarDrawerMode: crowi.configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
     isSidebarDrawerMode: crowi.configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
     isSidebarClosedAtDockMode: crowi.configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
     isSidebarClosedAtDockMode: crowi.configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
   };
   };

+ 2 - 2
packages/app/src/server/models/in-app-notification-settings.ts

@@ -1,5 +1,5 @@
-import { Schema, Model, Document } from 'mongoose';
 import { getOrCreateModel } from '@growi/core';
 import { getOrCreateModel } from '@growi/core';
+import { Schema, Model, Document } from 'mongoose';
 
 
 import { IInAppNotificationSettings, subscribeRuleNames } from '~/interfaces/in-app-notification';
 import { IInAppNotificationSettings, subscribeRuleNames } from '~/interfaces/in-app-notification';
 
 
@@ -10,7 +10,7 @@ const inAppNotificationSettingsSchema = new Schema<InAppNotificationSettingsDocu
   userId: { type: Schema.Types.ObjectId },
   userId: { type: Schema.Types.ObjectId },
   subscribeRules: [
   subscribeRules: [
     {
     {
-      name: { type: String, require: true, enum: subscribeRuleNames },
+      name: { type: String, required: true, enum: subscribeRuleNames },
       isEnabled: { type: Boolean },
       isEnabled: { type: Boolean },
     },
     },
   ],
   ],

+ 9 - 9
packages/app/src/server/models/in-app-notification.ts

@@ -4,7 +4,7 @@ import {
 } from 'mongoose';
 } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import mongoosePaginate from 'mongoose-paginate-v2';
 
 
-import { AllSupportedTargetModelType, AllSupportedActionType } from '~/interfaces/activity';
+import { AllSupportedTargetModels, AllSupportedActions } from '~/interfaces/activity';
 import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 
 
 import { ActivityDocument } from './activity';
 import { ActivityDocument } from './activity';
@@ -41,22 +41,22 @@ const inAppNotificationSchema = new Schema<InAppNotificationDocument, InAppNotif
     type: Schema.Types.ObjectId,
     type: Schema.Types.ObjectId,
     ref: 'User',
     ref: 'User',
     index: true,
     index: true,
-    require: true,
+    required: true,
   },
   },
   targetModel: {
   targetModel: {
     type: String,
     type: String,
-    require: true,
-    enum: AllSupportedTargetModelType,
+    required: true,
+    enum: AllSupportedTargetModels,
   },
   },
   target: {
   target: {
     type: Schema.Types.ObjectId,
     type: Schema.Types.ObjectId,
     refPath: 'targetModel',
     refPath: 'targetModel',
-    require: true,
+    required: true,
   },
   },
   action: {
   action: {
     type: String,
     type: String,
-    require: true,
-    enum: AllSupportedActionType,
+    required: true,
+    enum: AllSupportedActions,
   },
   },
   activities: [
   activities: [
     {
     {
@@ -69,11 +69,11 @@ const inAppNotificationSchema = new Schema<InAppNotificationDocument, InAppNotif
     default: STATUS_UNREAD,
     default: STATUS_UNREAD,
     enum: InAppNotificationStatuses,
     enum: InAppNotificationStatuses,
     index: true,
     index: true,
-    require: true,
+    required: true,
   },
   },
   snapshot: {
   snapshot: {
     type: String,
     type: String,
-    require: true,
+    required: true,
   },
   },
 }, {
 }, {
   timestamps: { createdAt: true, updatedAt: false },
   timestamps: { createdAt: true, updatedAt: false },

+ 5 - 5
packages/app/src/server/models/subscription.ts

@@ -3,7 +3,7 @@ import {
   Types, Document, Model, Schema,
   Types, Document, Model, Schema,
 } from 'mongoose';
 } from 'mongoose';
 
 
-import { AllSupportedTargetModelType } from '~/interfaces/activity';
+import { AllSupportedTargetModels } from '~/interfaces/activity';
 import { SubscriptionStatusType, AllSubscriptionStatusType } from '~/interfaces/subscription';
 import { SubscriptionStatusType, AllSubscriptionStatusType } from '~/interfaces/subscription';
 
 
 
 
@@ -37,17 +37,17 @@ const subscriptionSchema = new Schema<SubscriptionDocument, SubscriptionModel>({
   },
   },
   targetModel: {
   targetModel: {
     type: String,
     type: String,
-    require: true,
-    enum: AllSupportedTargetModelType,
+    required: true,
+    enum: AllSupportedTargetModels,
   },
   },
   target: {
   target: {
     type: Schema.Types.ObjectId,
     type: Schema.Types.ObjectId,
     refPath: 'targetModel',
     refPath: 'targetModel',
-    require: true,
+    required: true,
   },
   },
   status: {
   status: {
     type: String,
     type: String,
-    require: true,
+    required: true,
     enum: AllSubscriptionStatusType,
     enum: AllSubscriptionStatusType,
   },
   },
 }, {
 }, {

+ 22 - 2
packages/app/src/server/routes/admin.js

@@ -1,5 +1,6 @@
-import loggerFactory from '~/utils/logger';
+import { SupportedAction } from '~/interfaces/activity';
 import UserGroup from '~/server/models/user-group';
 import UserGroup from '~/server/models/user-group';
+import loggerFactory from '~/utils/logger';
 
 
 const logger = loggerFactory('growi:routes:admin');
 const logger = loggerFactory('growi:routes:admin');
 const debug = require('debug')('growi:routes:admin');
 const debug = require('debug')('growi:routes:admin');
@@ -27,6 +28,8 @@ module.exports = function(crowi, app) {
 
 
   const { check, param } = require('express-validator');
   const { check, param } = require('express-validator');
 
 
+  const activityEvent = crowi.event('activity');
+
   const api = {};
   const api = {};
 
 
   function createPager(total, limit, page, pagesCount, maxPageList) {
   function createPager(total, limit, page, pagesCount, maxPageList) {
@@ -289,6 +292,12 @@ module.exports = function(crowi, app) {
     return res.render('admin/user-group-detail', { userGroup });
     return res.render('admin/user-group-detail', { userGroup });
   };
   };
 
 
+  // AuditLog
+  actions.auditLog = {};
+  actions.auditLog.index = (req, res) => {
+    return res.render('admin/audit-log');
+  };
+
   // Importer management
   // Importer management
   actions.importer = {};
   actions.importer = {};
   actions.importer.api = api;
   actions.importer.api = api;
@@ -375,6 +384,8 @@ module.exports = function(crowi, app) {
 
 
     await configManager.updateConfigsInTheSameNamespace('crowi', form);
     await configManager.updateConfigsInTheSameNamespace('crowi', form);
     importer.initializeEsaClient(); // let it run in the back aftert res
     importer.initializeEsaClient(); // let it run in the back aftert res
+    const parameters = { action: SupportedAction.ACTION_ADMIN_ESA_DATA_UPDATED };
+    activityEvent.emit('update', res.locals.activity._id, parameters);
     return res.json(ApiResponse.success());
     return res.json(ApiResponse.success());
   };
   };
 
 
@@ -395,7 +406,8 @@ module.exports = function(crowi, app) {
 
 
     await configManager.updateConfigsInTheSameNamespace('crowi', form);
     await configManager.updateConfigsInTheSameNamespace('crowi', form);
     importer.initializeQiitaClient(); // let it run in the back aftert res
     importer.initializeQiitaClient(); // let it run in the back aftert res
-
+    const parameters = { action: SupportedAction.ACTION_ADMIN_QIITA_DATA_UPDATED };
+    activityEvent.emit('update', res.locals.activity._id, parameters);
     return res.json(ApiResponse.success());
     return res.json(ApiResponse.success());
   };
   };
 
 
@@ -411,6 +423,8 @@ module.exports = function(crowi, app) {
 
 
     try {
     try {
       errors = await importer.importDataFromEsa(user);
       errors = await importer.importDataFromEsa(user);
+      const parameters = { action: SupportedAction.ACTION_ADMIN_ESA_DATA_IMPORTED };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
     }
     }
     catch (err) {
     catch (err) {
       errors = [err];
       errors = [err];
@@ -434,6 +448,8 @@ module.exports = function(crowi, app) {
 
 
     try {
     try {
       errors = await importer.importDataFromQiita(user);
       errors = await importer.importDataFromQiita(user);
+      const parameters = { action: SupportedAction.ACTION_ADMIN_QIITA_DATA_IMPORTED };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
     }
     }
     catch (err) {
     catch (err) {
       errors = [err];
       errors = [err];
@@ -454,6 +470,8 @@ module.exports = function(crowi, app) {
   actions.api.testEsaAPI = async(req, res) => {
   actions.api.testEsaAPI = async(req, res) => {
     try {
     try {
       await importer.testConnectionToEsa();
       await importer.testConnectionToEsa();
+      const parameters = { action: SupportedAction.ACTION_ADMIN_CONNECTION_TEST_OF_ESA_DATA };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.json(ApiResponse.success());
       return res.json(ApiResponse.success());
     }
     }
     catch (err) {
     catch (err) {
@@ -470,6 +488,8 @@ module.exports = function(crowi, app) {
   actions.api.testQiitaAPI = async(req, res) => {
   actions.api.testQiitaAPI = async(req, res) => {
     try {
     try {
       await importer.testConnectionToQiita();
       await importer.testConnectionToQiita();
+      const parameters = { action: SupportedAction.ACTION_ADMIN_CONNECTION_TEST_OF_QIITA_DATA };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.json(ApiResponse.success());
       return res.json(ApiResponse.success());
     }
     }
     catch (err) {
     catch (err) {

+ 105 - 0
packages/app/src/server/routes/apiv3/activity.ts

@@ -0,0 +1,105 @@
+import { parseISO, addMinutes, isValid } from 'date-fns';
+import express, { Request, Router } from 'express';
+import { query } from 'express-validator';
+
+import { ISearchFilter } from '~/interfaces/activity';
+import Activity from '~/server/models/activity';
+import loggerFactory from '~/utils/logger';
+
+import Crowi from '../../crowi';
+import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+
+import { ApiV3Response } from './interfaces/apiv3-response';
+
+
+const logger = loggerFactory('growi:routes:apiv3:activity');
+
+
+const validator = {
+  list: [
+    query('limit').optional().isInt({ max: 100 }).withMessage('limit must be a number less than or equal to 100'),
+    query('offset').optional().isInt().withMessage('page must be a number'),
+    query('searchFilter').optional().isString().withMessage('query must be a string'),
+  ],
+};
+
+module.exports = (crowi: Crowi): Router => {
+  const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
+  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+
+  const router = express.Router();
+
+  // eslint-disable-next-line max-len
+  router.get('/', accessTokenParser, loginRequiredStrictly, adminRequired, validator.list, apiV3FormValidator, async(req: Request, res: ApiV3Response) => {
+    const auditLogEnabled = crowi.configManager?.getConfig('crowi', 'app:auditLogEnabled') || false;
+    if (!auditLogEnabled) {
+      const msg = 'AuditLog is not enabled';
+      logger.error(msg);
+      return res.apiv3Err(msg, 405);
+    }
+
+    const limit = req.query.limit || await crowi.configManager?.getConfig('crowi', 'customize:showPageLimitationS') || 10;
+    const offset = req.query.offset || 1;
+
+    const query = {};
+
+    try {
+      const parsedSearchFilter = JSON.parse(req.query.searchFilter as string) as ISearchFilter;
+
+      // add username to query
+      const canContainUsernameFilterToQuery = (
+        parsedSearchFilter.usernames != null
+        && parsedSearchFilter.usernames.length > 0
+        && parsedSearchFilter.usernames.every(u => typeof u === 'string')
+      );
+      if (canContainUsernameFilterToQuery) {
+        Object.assign(query, { 'snapshot.username': parsedSearchFilter.usernames });
+      }
+
+      // add action to query
+      if (parsedSearchFilter.actions != null) {
+        const availableActions = crowi.activityService.getAvailableActions(false);
+        const searchableActions = parsedSearchFilter.actions.filter(action => availableActions.includes(action));
+        Object.assign(query, { action: searchableActions });
+      }
+
+      // add date to query
+      const startDate = parseISO(parsedSearchFilter?.dates?.startDate || '');
+      const endDate = parseISO(parsedSearchFilter?.dates?.endDate || '');
+      if (isValid(startDate) && isValid(endDate)) {
+        Object.assign(query, {
+          createdAt: {
+            $gte: startDate,
+            // + 23 hours 59 minutes
+            $lt: addMinutes(endDate, 1439),
+          },
+        });
+      }
+      else if (isValid(startDate) && !isValid(endDate)) {
+        Object.assign(query, {
+          createdAt: {
+            $gte: startDate,
+            // + 23 hours 59 minutes
+            $lt: addMinutes(startDate, 1439),
+          },
+        });
+      }
+    }
+    catch (err) {
+      logger.error('Invalid value', err);
+      return res.apiv3Err(err, 400);
+    }
+
+    try {
+      const paginationResult = await Activity.getPaginatedActivity(limit, offset, query);
+      return res.apiv3({ paginationResult });
+    }
+    catch (err) {
+      logger.error('Failed to get paginated activity', err);
+      return res.apiv3Err(err, 500);
+    }
+  });
+
+  return router;
+};

+ 39 - 11
packages/app/src/server/routes/apiv3/app-settings.js

@@ -1,10 +1,13 @@
 import { body } from 'express-validator';
 import { body } from 'express-validator';
 
 
+import { SupportedAction } from '~/interfaces/activity';
 import { i18n } from '~/next-i18next.config';
 import { i18n } from '~/next-i18next.config';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 
+
 const logger = loggerFactory('growi:routes:apiv3:app-settings');
 const logger = loggerFactory('growi:routes:apiv3:app-settings');
 
 
 const debug = require('debug')('growi:routes:admin');
 const debug = require('debug')('growi:routes:admin');
@@ -150,6 +153,9 @@ module.exports = (crowi) => {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  const activityEvent = crowi.event('activity');
 
 
   const validator = {
   const validator = {
     appSetting: [
     appSetting: [
@@ -293,7 +299,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/AppSettingParams'
    *                  $ref: '#/components/schemas/AppSettingParams'
    */
    */
-  router.put('/app-setting', loginRequiredStrictly, adminRequired, validator.appSetting, apiV3FormValidator, async(req, res) => {
+  router.put('/app-setting', loginRequiredStrictly, adminRequired, addActivity, validator.appSetting, apiV3FormValidator, async(req, res) => {
     const requestAppSettingParams = {
     const requestAppSettingParams = {
       'app:title': req.body.title,
       'app:title': req.body.title,
       'app:confidential': req.body.confidential,
       'app:confidential': req.body.confidential,
@@ -311,6 +317,10 @@ module.exports = (crowi) => {
         isEmailPublishedForNewUser: crowi.configManager.getConfig('crowi', 'customize:isEmailPublishedForNewUser'),
         isEmailPublishedForNewUser: crowi.configManager.getConfig('crowi', 'customize:isEmailPublishedForNewUser'),
         fileUpload: crowi.configManager.getConfig('crowi', 'app:fileUpload'),
         fileUpload: crowi.configManager.getConfig('crowi', 'app:fileUpload'),
       };
       };
+
+      const parameters = { action: SupportedAction.ACTION_ADMIN_APP_SETTINGS_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3({ appSettingParams });
       return res.apiv3({ appSettingParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -344,7 +354,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/SiteUrlSettingParams'
    *                  $ref: '#/components/schemas/SiteUrlSettingParams'
    */
    */
-  router.put('/site-url-setting', loginRequiredStrictly, adminRequired, validator.siteUrlSetting, apiV3FormValidator, async(req, res) => {
+  router.put('/site-url-setting', loginRequiredStrictly, adminRequired, addActivity, validator.siteUrlSetting, apiV3FormValidator, async(req, res) => {
 
 
     const requestSiteUrlSettingParams = {
     const requestSiteUrlSettingParams = {
       'app:siteUrl': pathUtils.removeTrailingSlash(req.body.siteUrl),
       'app:siteUrl': pathUtils.removeTrailingSlash(req.body.siteUrl),
@@ -355,6 +365,9 @@ module.exports = (crowi) => {
       const siteUrlSettingParams = {
       const siteUrlSettingParams = {
         siteUrl: crowi.configManager.getConfig('crowi', 'app:siteUrl'),
         siteUrl: crowi.configManager.getConfig('crowi', 'app:siteUrl'),
       };
       };
+
+      const parameters = { action: SupportedAction.ACTION_ADMIN_SITE_URL_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ siteUrlSettingParams });
       return res.apiv3({ siteUrlSettingParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -476,7 +489,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/SmtpSettingParams'
    *                  $ref: '#/components/schemas/SmtpSettingParams'
    */
    */
-  router.put('/smtp-setting', loginRequiredStrictly, adminRequired, validator.smtpSetting, apiV3FormValidator, async(req, res) => {
+  router.put('/smtp-setting', loginRequiredStrictly, adminRequired, addActivity, validator.smtpSetting, apiV3FormValidator, async(req, res) => {
     const requestMailSettingParams = {
     const requestMailSettingParams = {
       'mail:from': req.body.fromAddress,
       'mail:from': req.body.fromAddress,
       'mail:transmissionMethod': req.body.transmissionMethod,
       'mail:transmissionMethod': req.body.transmissionMethod,
@@ -488,6 +501,8 @@ module.exports = (crowi) => {
 
 
     try {
     try {
       const mailSettingParams = await updateMailSettinConfig(requestMailSettingParams);
       const mailSettingParams = await updateMailSettinConfig(requestMailSettingParams);
+      const parameters = { action: SupportedAction.ACTION_ADMIN_MAIL_SMTP_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ mailSettingParams });
       return res.apiv3({ mailSettingParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -510,9 +525,11 @@ module.exports = (crowi) => {
    *          200:
    *          200:
    *            description: Succeeded to send test mail for smtp
    *            description: Succeeded to send test mail for smtp
    */
    */
-  router.post('/smtp-test', loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.post('/smtp-test', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
     try {
     try {
       await sendTestEmail(req.user.email);
       await sendTestEmail(req.user.email);
+      const parameters = { action: SupportedAction.ACTION_ADMIN_MAIL_TEST_SUBMIT };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({});
       return res.apiv3({});
     }
     }
     catch (err) {
     catch (err) {
@@ -546,7 +563,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/SesSettingParams'
    *                  $ref: '#/components/schemas/SesSettingParams'
    */
    */
-  router.put('/ses-setting', loginRequiredStrictly, adminRequired, validator.sesSetting, apiV3FormValidator, async(req, res) => {
+  router.put('/ses-setting', loginRequiredStrictly, adminRequired, addActivity, validator.sesSetting, apiV3FormValidator, async(req, res) => {
     const { mailService } = crowi;
     const { mailService } = crowi;
 
 
     const requestSesSettingParams = {
     const requestSesSettingParams = {
@@ -568,7 +585,8 @@ module.exports = (crowi) => {
 
 
     await mailService.initialize();
     await mailService.initialize();
     mailService.publishUpdatedMessage();
     mailService.publishUpdatedMessage();
-
+    const parameters = { action: SupportedAction.ACTION_ADMIN_MAIL_SES_UPDATE };
+    activityEvent.emit('update', res.locals.activity._id, parameters);
     return res.apiv3({ mailSettingParams });
     return res.apiv3({ mailSettingParams });
   });
   });
 
 
@@ -595,7 +613,8 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/FileUploadSettingParams'
    *                  $ref: '#/components/schemas/FileUploadSettingParams'
    */
    */
-  router.put('/file-upload-setting', loginRequiredStrictly, adminRequired, validator.fileUploadSetting, apiV3FormValidator, async(req, res) => {
+  //  eslint-disable-next-line max-len
+  router.put('/file-upload-setting', loginRequiredStrictly, adminRequired, addActivity, validator.fileUploadSetting, apiV3FormValidator, async(req, res) => {
     const { fileUploadType } = req.body;
     const { fileUploadType } = req.body;
 
 
     const requestParams = {
     const requestParams = {
@@ -642,7 +661,8 @@ module.exports = (crowi) => {
         responseParams.s3SecretAccessKey = crowi.configManager.getConfig('crowi', 'aws:s3SecretAccessKey');
         responseParams.s3SecretAccessKey = crowi.configManager.getConfig('crowi', 'aws:s3SecretAccessKey');
         responseParams.s3ReferenceFileWithRelayMode = crowi.configManager.getConfig('crowi', 'aws:referenceFileWithRelayMode');
         responseParams.s3ReferenceFileWithRelayMode = crowi.configManager.getConfig('crowi', 'aws:referenceFileWithRelayMode');
       }
       }
-
+      const parameters = { action: SupportedAction.ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ responseParams });
       return res.apiv3({ responseParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -676,7 +696,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/PluginSettingParams'
    *                  $ref: '#/components/schemas/PluginSettingParams'
    */
    */
-  router.put('/plugin-setting', loginRequiredStrictly, adminRequired, validator.pluginSetting, apiV3FormValidator, async(req, res) => {
+  router.put('/plugin-setting', loginRequiredStrictly, adminRequired, addActivity, validator.pluginSetting, apiV3FormValidator, async(req, res) => {
     const requestPluginSettingParams = {
     const requestPluginSettingParams = {
       'plugin:isEnabledPlugins': req.body.isEnabledPlugins,
       'plugin:isEnabledPlugins': req.body.isEnabledPlugins,
     };
     };
@@ -686,6 +706,8 @@ module.exports = (crowi) => {
       const pluginSettingParams = {
       const pluginSettingParams = {
         isEnabledPlugins: crowi.configManager.getConfig('crowi', 'plugin:isEnabledPlugins'),
         isEnabledPlugins: crowi.configManager.getConfig('crowi', 'plugin:isEnabledPlugins'),
       };
       };
+      const parameters = { action: SupportedAction.ACTION_ADMIN_PLUGIN_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ pluginSettingParams });
       return res.apiv3({ pluginSettingParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -718,15 +740,17 @@ module.exports = (crowi) => {
   });
   });
 
 
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
-  router.post('/maintenance-mode', accessTokenParser, loginRequiredStrictly, adminRequired, validator.maintenanceMode, apiV3FormValidator, async(req, res) => {
+  router.post('/maintenance-mode', accessTokenParser, loginRequiredStrictly, adminRequired, addActivity, validator.maintenanceMode, apiV3FormValidator, async(req, res) => {
     const { flag } = req.body;
     const { flag } = req.body;
-
+    const parameters = {};
     try {
     try {
       if (flag) {
       if (flag) {
         await crowi.appService.startMaintenanceMode();
         await crowi.appService.startMaintenanceMode();
+        Object.assign(parameters, { action: SupportedAction.ACTION_ADMIN_MAINTENANCEMODE_ENABLED });
       }
       }
       else {
       else {
         await crowi.appService.endMaintenanceMode();
         await crowi.appService.endMaintenanceMode();
+        Object.assign(parameters, { action: SupportedAction.ACTION_ADMIN_MAINTENANCEMODE_DISABLED });
       }
       }
     }
     }
     catch (err) {
     catch (err) {
@@ -739,6 +763,10 @@ module.exports = (crowi) => {
       }
       }
     }
     }
 
 
+    if ('action' in parameters) {
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+    }
+
     res.apiv3({ flag });
     res.apiv3({ flag });
   });
   });
 
 

+ 17 - 6
packages/app/src/server/routes/apiv3/bookmarks.js

@@ -1,11 +1,15 @@
+import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 
+
 const logger = loggerFactory('growi:routes:apiv3:bookmarks'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:bookmarks'); // eslint-disable-line no-unused-vars
 
 
 const express = require('express');
 const express = require('express');
 const { body, query, param } = require('express-validator');
 const { body, query, param } = require('express-validator');
+
 const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 
 
 const router = express.Router();
 const router = express.Router();
@@ -70,6 +74,9 @@ module.exports = (crowi) => {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  const activityEvent = crowi.event('activity');
 
 
   const { Page, Bookmark, User } = crowi.models;
   const { Page, Bookmark, User } = crowi.models;
 
 
@@ -256,7 +263,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/Bookmark'
    *                  $ref: '#/components/schemas/Bookmark'
    */
    */
-  router.put('/', accessTokenParser, loginRequiredStrictly, validator.bookmarks, apiV3FormValidator, async(req, res) => {
+  router.put('/', accessTokenParser, loginRequiredStrictly, addActivity, validator.bookmarks, apiV3FormValidator, async(req, res) => {
     const { pageId, bool } = req.body;
     const { pageId, bool } = req.body;
     const userId = req.user?._id;
     const userId = req.user?._id;
 
 
@@ -264,9 +271,10 @@ module.exports = (crowi) => {
       return res.apiv3Err('A logged in user is required.');
       return res.apiv3Err('A logged in user is required.');
     }
     }
 
 
+    let page;
     let bookmark;
     let bookmark;
     try {
     try {
-      const page = await Page.findByIdAndViewer(pageId, req.user);
+      page = await Page.findByIdAndViewer(pageId, req.user);
       if (page == null) {
       if (page == null) {
         return res.apiv3Err(`Page '${pageId}' is not found or forbidden`);
         return res.apiv3Err(`Page '${pageId}' is not found or forbidden`);
       }
       }
@@ -276,10 +284,6 @@ module.exports = (crowi) => {
       if (bookmark == null) {
       if (bookmark == null) {
         if (bool) {
         if (bool) {
           bookmark = await Bookmark.add(page, req.user);
           bookmark = await Bookmark.add(page, req.user);
-
-          const pageEvent = crowi.event('page');
-          // in-app notification
-          pageEvent.emit('bookmark', page, req.user);
         }
         }
         else {
         else {
           logger.warn(`Removing the bookmark for ${page._id} by ${req.user._id} failed because the bookmark does not exist.`);
           logger.warn(`Removing the bookmark for ${page._id} by ${req.user._id} failed because the bookmark does not exist.`);
@@ -305,6 +309,13 @@ module.exports = (crowi) => {
       bookmark.depopulate('user');
       bookmark.depopulate('user');
     }
     }
 
 
+    const parameters = {
+      targetModel: SupportedTargetModel.MODEL_PAGE,
+      target: page,
+      action: bool ? SupportedAction.ACTION_PAGE_BOOKMARK : SupportedAction.ACTION_PAGE_UNBOOKMARK,
+    };
+    activityEvent.emit('update', res.locals.activity._id, parameters, page);
+
     return res.apiv3({ bookmark });
     return res.apiv3({ bookmark });
   });
   });
 
 

+ 32 - 9
packages/app/src/server/routes/apiv3/customize-setting.js

@@ -1,8 +1,11 @@
 /* eslint-disable no-unused-vars */
 /* eslint-disable no-unused-vars */
+import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 
+
 const logger = loggerFactory('growi:routes:apiv3:customize-setting');
 const logger = loggerFactory('growi:routes:apiv3:customize-setting');
 
 
 const express = require('express');
 const express = require('express');
@@ -92,6 +95,9 @@ const ErrorV3 = require('../../models/vo/error-apiv3');
 module.exports = (crowi) => {
 module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  const activityEvent = crowi.event('activity');
 
 
   const { customizeService } = crowi;
   const { customizeService } = crowi;
 
 
@@ -239,7 +245,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/CustomizeLayout'
    *                  $ref: '#/components/schemas/CustomizeLayout'
    */
    */
-  router.put('/layout', loginRequiredStrictly, adminRequired, validator.layout, apiV3FormValidator, async(req, res) => {
+  router.put('/layout', loginRequiredStrictly, adminRequired, addActivity, validator.layout, apiV3FormValidator, async(req, res) => {
     const requestParams = {
     const requestParams = {
       'customize:isContainerFluid': req.body.isContainerFluid,
       'customize:isContainerFluid': req.body.isContainerFluid,
     };
     };
@@ -249,6 +255,10 @@ module.exports = (crowi) => {
       const customizedParams = {
       const customizedParams = {
         isContainerFluid: await crowi.configManager.getConfig('crowi', 'customize:isContainerFluid'),
         isContainerFluid: await crowi.configManager.getConfig('crowi', 'customize:isContainerFluid'),
       };
       };
+
+      const parameters = { action: SupportedAction.ACTION_ADMIN_LAYOUT_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3({ customizedParams });
       return res.apiv3({ customizedParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -292,7 +302,6 @@ module.exports = (crowi) => {
     if (assetPath == null) {
     if (assetPath == null) {
       return res.apiv3Err(new ErrorV3(`The asset for '${webpackAssetKey}' is undefined.`, 'invalid-asset'));
       return res.apiv3Err(new ErrorV3(`The asset for '${webpackAssetKey}' is undefined.`, 'invalid-asset'));
     }
     }
-
     return res.apiv3({ assetPath });
     return res.apiv3({ assetPath });
   });
   });
 
 
@@ -319,7 +328,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/CustomizeTheme'
    *                  $ref: '#/components/schemas/CustomizeTheme'
    */
    */
-  router.put('/theme', loginRequiredStrictly, adminRequired, validator.theme, apiV3FormValidator, async(req, res) => {
+  router.put('/theme', loginRequiredStrictly, adminRequired, addActivity, validator.theme, apiV3FormValidator, async(req, res) => {
     const requestParams = {
     const requestParams = {
       'customize:theme': req.body.themeType,
       'customize:theme': req.body.themeType,
     };
     };
@@ -329,6 +338,8 @@ module.exports = (crowi) => {
       const customizedParams = {
       const customizedParams = {
         themeType: await crowi.configManager.getConfig('crowi', 'customize:theme'),
         themeType: await crowi.configManager.getConfig('crowi', 'customize:theme'),
       };
       };
+      const parameters = { action: SupportedAction.ACTION_ADMIN_THEME_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ customizedParams });
       return res.apiv3({ customizedParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -397,7 +408,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/CustomizeFunction'
    *                  $ref: '#/components/schemas/CustomizeFunction'
    */
    */
-  router.put('/function', loginRequiredStrictly, adminRequired, validator.function, apiV3FormValidator, async(req, res) => {
+  router.put('/function', loginRequiredStrictly, adminRequired, addActivity, validator.function, apiV3FormValidator, async(req, res) => {
     const requestParams = {
     const requestParams = {
       'customize:isEnabledTimeline': req.body.isEnabledTimeline,
       'customize:isEnabledTimeline': req.body.isEnabledTimeline,
       'customize:isSavedStatesOfTabChanges': req.body.isSavedStatesOfTabChanges,
       'customize:isSavedStatesOfTabChanges': req.body.isSavedStatesOfTabChanges,
@@ -425,6 +436,8 @@ module.exports = (crowi) => {
         isAllReplyShown: await crowi.configManager.getConfig('crowi', 'customize:isAllReplyShown'),
         isAllReplyShown: await crowi.configManager.getConfig('crowi', 'customize:isAllReplyShown'),
         isSearchScopeChildrenAsDefault: await crowi.configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault'),
         isSearchScopeChildrenAsDefault: await crowi.configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault'),
       };
       };
+      const parameters = { action: SupportedAction.ACTION_ADMIN_FUNCTION_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ customizedParams });
       return res.apiv3({ customizedParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -457,7 +470,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/CustomizeHighlight'
    *                  $ref: '#/components/schemas/CustomizeHighlight'
    */
    */
-  router.put('/highlight', loginRequiredStrictly, adminRequired, validator.highlight, apiV3FormValidator, async(req, res) => {
+  router.put('/highlight', loginRequiredStrictly, adminRequired, addActivity, validator.highlight, apiV3FormValidator, async(req, res) => {
     const requestParams = {
     const requestParams = {
       'customize:highlightJsStyle': req.body.highlightJsStyle,
       'customize:highlightJsStyle': req.body.highlightJsStyle,
       'customize:highlightJsStyleBorder': req.body.highlightJsStyleBorder,
       'customize:highlightJsStyleBorder': req.body.highlightJsStyleBorder,
@@ -469,6 +482,8 @@ module.exports = (crowi) => {
         styleName: await crowi.configManager.getConfig('crowi', 'customize:highlightJsStyle'),
         styleName: await crowi.configManager.getConfig('crowi', 'customize:highlightJsStyle'),
         styleBorder: await crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
         styleBorder: await crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
       };
       };
+      const parameters = { action: SupportedAction.ACTION_ADMIN_CODE_HIGHLIGHT_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ customizedParams });
       return res.apiv3({ customizedParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -501,7 +516,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/CustomizeTitle'
    *                  $ref: '#/components/schemas/CustomizeTitle'
    */
    */
-  router.put('/customize-title', loginRequiredStrictly, adminRequired, validator.customizeTitle, apiV3FormValidator, async(req, res) => {
+  router.put('/customize-title', loginRequiredStrictly, adminRequired, addActivity, validator.customizeTitle, apiV3FormValidator, async(req, res) => {
     const requestParams = {
     const requestParams = {
       'customize:title': req.body.customizeTitle,
       'customize:title': req.body.customizeTitle,
     };
     };
@@ -514,6 +529,8 @@ module.exports = (crowi) => {
         customizeTitle: await crowi.configManager.getConfig('crowi', 'customize:title'),
         customizeTitle: await crowi.configManager.getConfig('crowi', 'customize:title'),
       };
       };
       customizeService.initCustomTitle();
       customizeService.initCustomTitle();
+      const parameters = { action: SupportedAction.ACTION_ADMIN_CUSTOM_TITLE_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ customizedParams });
       return res.apiv3({ customizedParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -546,7 +563,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/CustomizeHeader'
    *                  $ref: '#/components/schemas/CustomizeHeader'
    */
    */
-  router.put('/customize-header', loginRequiredStrictly, adminRequired, validator.customizeHeader, apiV3FormValidator, async(req, res) => {
+  router.put('/customize-header', loginRequiredStrictly, adminRequired, addActivity, validator.customizeHeader, apiV3FormValidator, async(req, res) => {
     const requestParams = {
     const requestParams = {
       'customize:header': req.body.customizeHeader,
       'customize:header': req.body.customizeHeader,
     };
     };
@@ -555,6 +572,8 @@ module.exports = (crowi) => {
       const customizedParams = {
       const customizedParams = {
         customizeHeader: await crowi.configManager.getConfig('crowi', 'customize:header'),
         customizeHeader: await crowi.configManager.getConfig('crowi', 'customize:header'),
       };
       };
+      const parameters = { action: SupportedAction.ACTION_ADMIN_CUSTOM_HTML_HEADER_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ customizedParams });
       return res.apiv3({ customizedParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -587,7 +606,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/CustomizeCss'
    *                  $ref: '#/components/schemas/CustomizeCss'
    */
    */
-  router.put('/customize-css', loginRequiredStrictly, adminRequired, validator.customizeCss, apiV3FormValidator, async(req, res) => {
+  router.put('/customize-css', loginRequiredStrictly, adminRequired, addActivity, validator.customizeCss, apiV3FormValidator, async(req, res) => {
     const requestParams = {
     const requestParams = {
       'customize:css': req.body.customizeCss,
       'customize:css': req.body.customizeCss,
     };
     };
@@ -599,6 +618,8 @@ module.exports = (crowi) => {
         customizeCss: await crowi.configManager.getConfig('crowi', 'customize:css'),
         customizeCss: await crowi.configManager.getConfig('crowi', 'customize:css'),
       };
       };
       customizeService.initCustomCss();
       customizeService.initCustomCss();
+      const parameters = { action: SupportedAction.ACTION_ADMIN_CUSTOM_CSS_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ customizedParams });
       return res.apiv3({ customizedParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -631,7 +652,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/CustomizeScript'
    *                  $ref: '#/components/schemas/CustomizeScript'
    */
    */
-  router.put('/customize-script', loginRequiredStrictly, adminRequired, validator.customizeScript, apiV3FormValidator, async(req, res) => {
+  router.put('/customize-script', loginRequiredStrictly, adminRequired, addActivity, validator.customizeScript, apiV3FormValidator, async(req, res) => {
     const requestParams = {
     const requestParams = {
       'customize:script': req.body.customizeScript,
       'customize:script': req.body.customizeScript,
     };
     };
@@ -640,6 +661,8 @@ module.exports = (crowi) => {
       const customizedParams = {
       const customizedParams = {
         customizeScript: await crowi.configManager.getConfig('crowi', 'customize:script'),
         customizeScript: await crowi.configManager.getConfig('crowi', 'customize:script'),
       };
       };
+      const parameters = { action: SupportedAction.ACTION_ADMIN_CUSTOM_SCRIPT_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ customizedParams });
       return res.apiv3({ customizedParams });
     }
     }
     catch (err) {
     catch (err) {

+ 9 - 1
packages/app/src/server/routes/apiv3/export.js

@@ -1,7 +1,10 @@
+import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 
+
 const logger = loggerFactory('growi:routes:apiv3:export');
 const logger = loggerFactory('growi:routes:apiv3:export');
 const fs = require('fs');
 const fs = require('fs');
 
 
@@ -44,9 +47,11 @@ module.exports = (crowi) => {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const addActivity = generateAddActivityMiddleware(crowi);
 
 
   const { exportService, socketIoService } = crowi;
   const { exportService, socketIoService } = crowi;
 
 
+  const activityEvent = crowi.event('activity');
   this.adminEvent = crowi.event('admin');
   this.adminEvent = crowi.event('admin');
 
 
   // setup event
   // setup event
@@ -117,13 +122,16 @@ module.exports = (crowi) => {
    *                  status:
    *                  status:
    *                    $ref: '#/components/schemas/ExportStatus'
    *                    $ref: '#/components/schemas/ExportStatus'
    */
    */
-  router.post('/', accessTokenParser, loginRequired, adminRequired, async(req, res) => {
+  router.post('/', accessTokenParser, loginRequired, adminRequired, addActivity, async(req, res) => {
     // TODO: add express validator
     // TODO: add express validator
     try {
     try {
       const { collections } = req.body;
       const { collections } = req.body;
 
 
       exportService.export(collections);
       exportService.export(collections);
 
 
+      const parameters = { action: SupportedAction.ACTION_ADMIN_ARCHIVE_DATA_CREATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       // TODO: use res.apiv3
       // TODO: use res.apiv3
       return res.status(200).json({
       return res.status(200).json({
         ok: true,
         ok: true,

+ 1 - 9
packages/app/src/server/routes/apiv3/forgot-password.js

@@ -1,5 +1,4 @@
 import { format, subSeconds } from 'date-fns';
 import { format, subSeconds } from 'date-fns';
-import rateLimit from 'express-rate-limit';
 
 
 import injectResetOrderByTokenMiddleware from '~/server/middlewares/inject-reset-order-by-token-middleware';
 import injectResetOrderByTokenMiddleware from '~/server/middlewares/inject-reset-order-by-token-middleware';
 import PasswordResetOrder from '~/server/models/password-reset-order';
 import PasswordResetOrder from '~/server/models/password-reset-order';
@@ -40,13 +39,6 @@ module.exports = (crowi) => {
     ],
     ],
   };
   };
 
 
-  const apiLimiter = rateLimit({
-    windowMs: 1 * 60 * 1000, // 1 minutes
-    max: 30, // limit each IP to 30 requests per windowMs
-    message:
-    'Too many requests were sent from this IP. Please try a password reset request again on the password reset request form',
-  });
-
   const checkPassportStrategyMiddleware = checkForgotPasswordEnabledMiddlewareFactory(crowi, true);
   const checkPassportStrategyMiddleware = checkForgotPasswordEnabledMiddlewareFactory(crowi, true);
 
 
   async function sendPasswordResetEmail(txtFileName, i18n, email, url, expiredAt) {
   async function sendPasswordResetEmail(txtFileName, i18n, email, url, expiredAt) {
@@ -94,7 +86,7 @@ module.exports = (crowi) => {
   });
   });
 
 
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
-  router.put('/', apiLimiter, checkPassportStrategyMiddleware, injectResetOrderByTokenMiddleware, validator.password, apiV3FormValidator, async(req, res) => {
+  router.put('/', checkPassportStrategyMiddleware, injectResetOrderByTokenMiddleware, validator.password, apiV3FormValidator, async(req, res) => {
     const { passwordResetOrder } = req;
     const { passwordResetOrder } = req;
     const { email } = passwordResetOrder;
     const { email } = passwordResetOrder;
     const grobalLang = configManager.getConfig('crowi', 'app:globalLang');
     const grobalLang = configManager.getConfig('crowi', 'app:globalLang');

+ 17 - 3
packages/app/src/server/routes/apiv3/import.js

@@ -1,15 +1,21 @@
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
+import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
+
+
 const logger = loggerFactory('growi:routes:apiv3:import'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:import'); // eslint-disable-line no-unused-vars
 
 
 const path = require('path');
 const path = require('path');
-const multer = require('multer');
 
 
 const express = require('express');
 const express = require('express');
+const multer = require('multer');
+
 
 
 const GrowiArchiveImportOption = require('~/models/admin/growi-archive-import-option');
 const GrowiArchiveImportOption = require('~/models/admin/growi-archive-import-option');
+
 const ErrorV3 = require('../../models/vo/error-apiv3');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 
 
 
@@ -67,8 +73,10 @@ module.exports = (crowi) => {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const addActivity = generateAddActivityMiddleware(crowi);
 
 
   this.adminEvent = crowi.event('admin');
   this.adminEvent = crowi.event('admin');
+  const activityEvent = crowi.event('activity');
 
 
   // setup event
   // setup event
   this.adminEvent.on('onProgressForImport', (data) => {
   this.adminEvent.on('onProgressForImport', (data) => {
@@ -203,7 +211,7 @@ module.exports = (crowi) => {
    *        200:
    *        200:
    *          description: Import process has requested
    *          description: Import process has requested
    */
    */
-  router.post('/', accessTokenParser, loginRequired, adminRequired, async(req, res) => {
+  router.post('/', accessTokenParser, loginRequired, adminRequired, addActivity, async(req, res) => {
     // TODO: add express validator
     // TODO: add express validator
     const { fileName, collections, optionsMap } = req.body;
     const { fileName, collections, optionsMap } = req.body;
 
 
@@ -285,6 +293,8 @@ module.exports = (crowi) => {
      */
      */
     try {
     try {
       importService.import(collections, importSettingsMap);
       importService.import(collections, importSettingsMap);
+      const parameters = { action: SupportedAction.ACTION_ADMIN_GROWI_DATA_IMPORTED };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
     }
     }
     catch (err) {
     catch (err) {
       logger.error(err);
       logger.error(err);
@@ -320,7 +330,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: the property of each extracted file
    *                      description: the property of each extracted file
    */
    */
-  router.post('/upload', uploads.single('file'), accessTokenParser, loginRequired, adminRequired, async(req, res) => {
+  router.post('/upload', uploads.single('file'), accessTokenParser, loginRequired, adminRequired, addActivity, async(req, res) => {
     const { file } = req;
     const { file } = req;
     const zipFile = importService.getFile(file.filename);
     const zipFile = importService.getFile(file.filename);
     let data = null;
     let data = null;
@@ -336,6 +346,10 @@ module.exports = (crowi) => {
     try {
     try {
       // validate with meta.json
       // validate with meta.json
       importService.validate(data.meta);
       importService.validate(data.meta);
+
+      const parameters = { action: SupportedAction.ACTION_ADMIN_ARCHIVE_DATA_UPLOAD };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3(data);
       return res.apiv3(data);
     }
     }
     catch {
     catch {

+ 13 - 1
packages/app/src/server/routes/apiv3/in-app-notification.ts

@@ -1,6 +1,10 @@
+import { SupportedAction } from '~/interfaces/activity';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+
 import { IInAppNotification } from '../../../interfaces/in-app-notification';
 import { IInAppNotification } from '../../../interfaces/in-app-notification';
 
 
 const express = require('express');
 const express = require('express');
+
 const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 
 
 const router = express.Router();
 const router = express.Router();
@@ -9,9 +13,14 @@ const router = express.Router();
 module.exports = (crowi) => {
 module.exports = (crowi) => {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const addActivity = generateAddActivityMiddleware(crowi);
+
   const inAppNotificationService = crowi.inAppNotificationService;
   const inAppNotificationService = crowi.inAppNotificationService;
+
   const User = crowi.model('User');
   const User = crowi.model('User');
 
 
+  const activityEvent = crowi.event('activity');
+
   router.get('/list', accessTokenParser, loginRequiredStrictly, async(req, res) => {
   router.get('/list', accessTokenParser, loginRequiredStrictly, async(req, res) => {
     const user = req.user;
     const user = req.user;
 
 
@@ -100,11 +109,14 @@ module.exports = (crowi) => {
     }
     }
   });
   });
 
 
-  router.put('/all-statuses-open', accessTokenParser, loginRequiredStrictly, async(req, res) => {
+  router.put('/all-statuses-open', accessTokenParser, loginRequiredStrictly, addActivity, async(req, res) => {
     const user = req.user;
     const user = req.user;
 
 
     try {
     try {
       await inAppNotificationService.updateAllNotificationsAsOpened(user);
       await inAppNotificationService.updateAllNotificationsAsOpened(user);
+
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_IN_APP_NOTIFICATION_ALL_STATUSES_OPEN });
+
       return res.apiv3();
       return res.apiv3();
     }
     }
     catch (err) {
     catch (err) {

+ 2 - 1
packages/app/src/server/routes/apiv3/index.js

@@ -35,6 +35,7 @@ module.exports = (crowi) => {
   routerForAdmin.use('/mongo', require('./mongo')(crowi));
   routerForAdmin.use('/mongo', require('./mongo')(crowi));
   routerForAdmin.use('/slack-integration-settings', require('./slack-integration-settings')(crowi));
   routerForAdmin.use('/slack-integration-settings', require('./slack-integration-settings')(crowi));
   routerForAdmin.use('/slack-integration-legacy-settings', require('./slack-integration-legacy-settings')(crowi));
   routerForAdmin.use('/slack-integration-legacy-settings', require('./slack-integration-legacy-settings')(crowi));
+  routerForAdmin.use('/activity', require('./activity')(crowi));
 
 
   // auth
   // auth
   routerForAuth.use('/logout', require('./logout')(crowi));
   routerForAuth.use('/logout', require('./logout')(crowi));
@@ -69,7 +70,7 @@ module.exports = (crowi) => {
   router.use('/forgot-password', require('./forgot-password')(crowi));
   router.use('/forgot-password', require('./forgot-password')(crowi));
 
 
   const user = require('../user')(crowi, null);
   const user = require('../user')(crowi, null);
-  router.get('/check_username', user.api.checkUsername);
+  router.get('/check-username', user.api.checkUsername);
 
 
   router.post('/complete-registration',
   router.post('/complete-registration',
     injectUserRegistrationOrderByTokenMiddleware,
     injectUserRegistrationOrderByTokenMiddleware,

+ 12 - 1
packages/app/src/server/routes/apiv3/logout.js

@@ -1,5 +1,8 @@
+import { SupportedAction } from '~/interfaces/activity';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+
 const logger = loggerFactory('growi:routes:apiv3:logout'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:logout'); // eslint-disable-line no-unused-vars
 
 
 const express = require('express');
 const express = require('express');
@@ -7,8 +10,16 @@ const express = require('express');
 const router = express.Router();
 const router = express.Router();
 
 
 module.exports = (crowi) => {
 module.exports = (crowi) => {
-  router.post('/', async(req, res) => {
+  const activityEvent = crowi.event('activity');
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  router.post('/', addActivity, async(req, res) => {
     req.session.destroy();
     req.session.destroy();
+
+    const activityId = res.locals.activity._id;
+    const parameters = { action: SupportedAction.ACTION_USER_LOGOUT };
+    activityEvent.emit('update', activityId, parameters);
+
     return res.send();
     return res.send();
   });
   });
 
 

+ 26 - 4
packages/app/src/server/routes/apiv3/markdown-setting.js

@@ -1,7 +1,10 @@
+import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 
+
 const logger = loggerFactory('growi:routes:apiv3:markdown-setting');
 const logger = loggerFactory('growi:routes:apiv3:markdown-setting');
 
 
 const express = require('express');
 const express = require('express');
@@ -90,6 +93,9 @@ const validator = {
 module.exports = (crowi) => {
 module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  const activityEvent = crowi.event('activity');
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -151,7 +157,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
   *                   $ref: '#/components/schemas/LineBreakParams'
   *                   $ref: '#/components/schemas/LineBreakParams'
    */
    */
-  router.put('/lineBreak', loginRequiredStrictly, adminRequired, validator.lineBreak, apiV3FormValidator, async(req, res) => {
+  router.put('/lineBreak', loginRequiredStrictly, adminRequired, addActivity, validator.lineBreak, apiV3FormValidator, async(req, res) => {
 
 
     const requestLineBreakParams = {
     const requestLineBreakParams = {
       'markdown:isEnabledLinebreaks': req.body.isEnabledLinebreaks,
       'markdown:isEnabledLinebreaks': req.body.isEnabledLinebreaks,
@@ -164,6 +170,10 @@ module.exports = (crowi) => {
         isEnabledLinebreaks: await crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
         isEnabledLinebreaks: await crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
         isEnabledLinebreaksInComments: await crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
         isEnabledLinebreaksInComments: await crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
       };
       };
+
+      const parameters = { action: SupportedAction.ACTION_ADMIN_MARKDOWN_LINE_BREAK_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3({ lineBreaksParams });
       return res.apiv3({ lineBreaksParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -174,7 +184,7 @@ module.exports = (crowi) => {
 
 
   });
   });
 
 
-  router.put('/indent', loginRequiredStrictly, adminRequired, validator.indent, apiV3FormValidator, async(req, res) => {
+  router.put('/indent', loginRequiredStrictly, adminRequired, addActivity, validator.indent, apiV3FormValidator, async(req, res) => {
 
 
     const requestIndentParams = {
     const requestIndentParams = {
       'markdown:adminPreferredIndentSize': req.body.adminPreferredIndentSize,
       'markdown:adminPreferredIndentSize': req.body.adminPreferredIndentSize,
@@ -187,6 +197,10 @@ module.exports = (crowi) => {
         adminPreferredIndentSize: await crowi.configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
         adminPreferredIndentSize: await crowi.configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
         isIndentSizeForced: await crowi.configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
         isIndentSizeForced: await crowi.configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
       };
       };
+
+      const parameters = { action: SupportedAction.ACTION_ADMIN_MARKDOWN_INDENT_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3({ indentParams });
       return res.apiv3({ indentParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -220,7 +234,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/PresentationParams'
    *                  $ref: '#/components/schemas/PresentationParams'
    */
    */
-  router.put('/presentation', loginRequiredStrictly, adminRequired, validator.presentationSetting, apiV3FormValidator, async(req, res) => {
+  router.put('/presentation', loginRequiredStrictly, adminRequired, addActivity, validator.presentationSetting, apiV3FormValidator, async(req, res) => {
     if (req.body.pageBreakSeparator === 3 && req.body.pageBreakCustomSeparator === '') {
     if (req.body.pageBreakSeparator === 3 && req.body.pageBreakCustomSeparator === '') {
       return res.apiv3Err(new ErrorV3('customRegularExpression is required'));
       return res.apiv3Err(new ErrorV3('customRegularExpression is required'));
     }
     }
@@ -236,6 +250,10 @@ module.exports = (crowi) => {
         pageBreakSeparator: await crowi.configManager.getConfig('markdown', 'markdown:presentation:pageBreakSeparator'),
         pageBreakSeparator: await crowi.configManager.getConfig('markdown', 'markdown:presentation:pageBreakSeparator'),
         pageBreakCustomSeparator: await crowi.configManager.getConfig('markdown', 'markdown:presentation:pageBreakCustomSeparator') || '',
         pageBreakCustomSeparator: await crowi.configManager.getConfig('markdown', 'markdown:presentation:pageBreakCustomSeparator') || '',
       };
       };
+
+      const parameters = { action: SupportedAction.ACTION_ADMIN_MARKDOWN_PRESENTATION_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3({ presentationParams });
       return res.apiv3({ presentationParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -269,7 +287,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/XssParams'
    *                  $ref: '#/components/schemas/XssParams'
    */
    */
-  router.put('/xss', loginRequiredStrictly, adminRequired, validator.xssSetting, apiV3FormValidator, async(req, res) => {
+  router.put('/xss', loginRequiredStrictly, adminRequired, addActivity, validator.xssSetting, apiV3FormValidator, async(req, res) => {
     if (req.body.isEnabledXss && req.body.xssOption == null) {
     if (req.body.isEnabledXss && req.body.xssOption == null) {
       return res.apiv3Err(new ErrorV3('xss option is required'));
       return res.apiv3Err(new ErrorV3('xss option is required'));
     }
     }
@@ -289,6 +307,10 @@ module.exports = (crowi) => {
         tagWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:xss:tagWhiteList'),
         tagWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:xss:tagWhiteList'),
         attrWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:xss:attrWhiteList'),
         attrWhiteList: await crowi.configManager.getConfig('markdown', 'markdown:xss:attrWhiteList'),
       };
       };
+
+      const parameters = { action: SupportedAction.ACTION_ADMIN_MARKDOWN_XSS_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3({ xssParams });
       return res.apiv3({ xssParams });
     }
     }
     catch (err) {
     catch (err) {

+ 51 - 9
packages/app/src/server/routes/apiv3/notification-setting.js

@@ -1,8 +1,11 @@
+import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 
 
-import UpdatePost from '../../models/update-post';
+import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+import UpdatePost from '../../models/update-post';
+
 
 
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:notification-setting');
 const logger = loggerFactory('growi:routes:apiv3:notification-setting');
@@ -90,6 +93,9 @@ const validator = {
 module.exports = (crowi) => {
 module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  const activityEvent = crowi.event('activity');
 
 
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
 
 
@@ -157,7 +163,8 @@ module.exports = (crowi) => {
   *                      type: object
   *                      type: object
   *                      description: user trigger notifications for updated
   *                      description: user trigger notifications for updated
   */
   */
-  router.post('/user-notification', loginRequiredStrictly, adminRequired, validator.userNotification, apiV3FormValidator, async(req, res) => {
+  // eslint-disable-next-line max-len
+  router.post('/user-notification', loginRequiredStrictly, adminRequired, addActivity, validator.userNotification, apiV3FormValidator, async(req, res) => {
     const { pathPattern, channel } = req.body;
     const { pathPattern, channel } = req.body;
 
 
     try {
     try {
@@ -166,6 +173,10 @@ module.exports = (crowi) => {
         createdUser: await UpdatePost.createUpdatePost(pathPattern, channel, req.user),
         createdUser: await UpdatePost.createUpdatePost(pathPattern, channel, req.user),
         userNotifications: await UpdatePost.findAll(),
         userNotifications: await UpdatePost.findAll(),
       };
       };
+
+      const parameters = { action: SupportedAction.ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_ADD };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3({ responseParams }, 201);
       return res.apiv3({ responseParams }, 201);
     }
     }
     catch (err) {
     catch (err) {
@@ -201,11 +212,15 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: deleted notification
    *                      description: deleted notification
    */
    */
-  router.delete('/user-notification/:id', loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.delete('/user-notification/:id', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
     const { id } = req.params;
     const { id } = req.params;
 
 
     try {
     try {
       const deletedNotificaton = await UpdatePost.findOneAndRemove({ _id: id });
       const deletedNotificaton = await UpdatePost.findOneAndRemove({ _id: id });
+
+      const parameters = { action: SupportedAction.ACTION_ADMIN_USER_NOTIFICATION_SETTINGS_DELETE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3(deletedNotificaton);
       return res.apiv3(deletedNotificaton);
     }
     }
     catch (err) {
     catch (err) {
@@ -241,7 +256,8 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: notification param created
    *                      description: notification param created
    */
    */
-  router.post('/global-notification', loginRequiredStrictly, adminRequired, validator.globalNotification, apiV3FormValidator, async(req, res) => {
+  // eslint-disable-next-line max-len
+  router.post('/global-notification', loginRequiredStrictly, adminRequired, addActivity, validator.globalNotification, apiV3FormValidator, async(req, res) => {
 
 
     const {
     const {
       notifyToType, toEmail, slackChannels, triggerPath, triggerEvents,
       notifyToType, toEmail, slackChannels, triggerPath, triggerEvents,
@@ -263,6 +279,10 @@ module.exports = (crowi) => {
 
 
     try {
     try {
       const createdNotification = await notification.save();
       const createdNotification = await notification.save();
+
+      const parameters = { action: SupportedAction.ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ADD };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3({ createdNotification }, 201);
       return res.apiv3({ createdNotification }, 201);
     }
     }
     catch (err) {
     catch (err) {
@@ -304,7 +324,8 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: notification param updated
    *                      description: notification param updated
    */
    */
-  router.put('/global-notification/:id', loginRequiredStrictly, adminRequired, validator.globalNotification, apiV3FormValidator, async(req, res) => {
+  // eslint-disable-next-line max-len
+  router.put('/global-notification/:id', loginRequiredStrictly, adminRequired, addActivity, validator.globalNotification, apiV3FormValidator, async(req, res) => {
     const { id } = req.params;
     const { id } = req.params;
     const {
     const {
       notifyToType, toEmail, slackChannels, triggerPath, triggerEvents,
       notifyToType, toEmail, slackChannels, triggerPath, triggerEvents,
@@ -343,6 +364,10 @@ module.exports = (crowi) => {
       setting.triggerEvents = triggerEvents || [];
       setting.triggerEvents = triggerEvents || [];
 
 
       const createdNotification = await setting.save();
       const createdNotification = await setting.save();
+
+      const parameters = { action: SupportedAction.ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3({ createdNotification });
       return res.apiv3({ createdNotification });
     }
     }
     catch (err) {
     catch (err) {
@@ -375,7 +400,8 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/NotifyForPageGrant'
    *                  $ref: '#/components/schemas/NotifyForPageGrant'
    */
    */
-  router.put('/notify-for-page-grant', loginRequiredStrictly, adminRequired, validator.notifyForPageGrant, apiV3FormValidator, async(req, res) => {
+  // eslint-disable-next-line max-len
+  router.put('/notify-for-page-grant', loginRequiredStrictly, adminRequired, addActivity, validator.notifyForPageGrant, apiV3FormValidator, async(req, res) => {
 
 
     let requestParams = {
     let requestParams = {
       'notification:owner-page:isEnabled': req.body.isNotificationForOwnerPageEnabled,
       'notification:owner-page:isEnabled': req.body.isNotificationForOwnerPageEnabled,
@@ -390,6 +416,10 @@ module.exports = (crowi) => {
         isNotificationForOwnerPageEnabled: await crowi.configManager.getConfig('notification', 'notification:owner-page:isEnabled'),
         isNotificationForOwnerPageEnabled: await crowi.configManager.getConfig('notification', 'notification:owner-page:isEnabled'),
         isNotificationForGroupPageEnabled: await crowi.configManager.getConfig('notification', 'notification:group-page:isEnabled'),
         isNotificationForGroupPageEnabled: await crowi.configManager.getConfig('notification', 'notification:group-page:isEnabled'),
       };
       };
+
+      const parameters = { action: SupportedAction.ACTION_ADMIN_NOTIFICATION_GRANT_SETTINGS_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3({ responseParams });
       return res.apiv3({ responseParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -397,7 +427,9 @@ module.exports = (crowi) => {
       logger.error('Error', err);
       logger.error('Error', err);
       return res.apiv3Err(new ErrorV3(msg, 'update-notify-for-page-grant-failed'));
       return res.apiv3Err(new ErrorV3(msg, 'update-notify-for-page-grant-failed'));
     }
     }
+
   });
   });
+
   /**
   /**
    * @swagger
    * @swagger
    *
    *
@@ -432,7 +464,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: notification id for updated
    *                      description: notification id for updated
    */
    */
-  router.put('/global-notification/:id/enabled', loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.put('/global-notification/:id/enabled', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
     const { id } = req.params;
     const { id } = req.params;
     const { isEnabled } = req.body;
     const { isEnabled } = req.body;
 
 
@@ -444,6 +476,13 @@ module.exports = (crowi) => {
         await GlobalNotificationSetting.disable(id);
         await GlobalNotificationSetting.disable(id);
       }
       }
 
 
+      const parameters = {
+        action: isEnabled
+          ? SupportedAction.ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ENABLED
+          : SupportedAction.ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DISABLED,
+      };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3({ id });
       return res.apiv3({ id });
 
 
     }
     }
@@ -480,11 +519,15 @@ module.exports = (crowi) => {
   *                      type: object
   *                      type: object
   *                      description: deleted notification
   *                      description: deleted notification
   */
   */
-  router.delete('/global-notification/:id', loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.delete('/global-notification/:id', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
     const { id } = req.params;
     const { id } = req.params;
 
 
     try {
     try {
       const deletedNotificaton = await GlobalNotificationSetting.findOneAndRemove({ _id: id });
       const deletedNotificaton = await GlobalNotificationSetting.findOneAndRemove({ _id: id });
+
+      const parameters = { action: SupportedAction.ACTION_ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DELETE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3(deletedNotificaton);
       return res.apiv3(deletedNotificaton);
     }
     }
     catch (err) {
     catch (err) {
@@ -493,7 +536,6 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3(msg, 'delete-globalNotification-failed'));
       return res.apiv3Err(new ErrorV3(msg, 'delete-globalNotification-failed'));
     }
     }
 
 
-
   });
   });
 
 
   return router;
   return router;

+ 39 - 7
packages/app/src/server/routes/apiv3/page.js

@@ -1,6 +1,8 @@
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
 
 
-import { AllSubscriptionStatusType } from '~/interfaces/subscription';
+import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
+import { AllSubscriptionStatusType, SubscriptionStatusType } from '~/interfaces/subscription';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import Subscription from '~/server/models/subscription';
 import Subscription from '~/server/models/subscription';
 import UserGroup from '~/server/models/user-group';
 import UserGroup from '~/server/models/user-group';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -161,12 +163,15 @@ module.exports = (crowi) => {
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const certifySharedPage = require('../../middlewares/certify-shared-page')(crowi);
   const certifySharedPage = require('../../middlewares/certify-shared-page')(crowi);
+  const addActivity = generateAddActivityMiddleware(crowi);
 
 
   const globalNotificationService = crowi.getGlobalNotificationService();
   const globalNotificationService = crowi.getGlobalNotificationService();
   const socketIoService = crowi.socketIoService;
   const socketIoService = crowi.socketIoService;
   const { Page, GlobalNotificationSetting, Bookmark } = crowi.models;
   const { Page, GlobalNotificationSetting, Bookmark } = crowi.models;
   const { pageService, exportService } = crowi;
   const { pageService, exportService } = crowi;
 
 
+  const activityEvent = crowi.event('activity');
+
   const validator = {
   const validator = {
     getPage: [
     getPage: [
       query('pageId').optional().isString(),
       query('pageId').optional().isString(),
@@ -313,7 +318,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/Page'
    *                  $ref: '#/components/schemas/Page'
    */
    */
-  router.put('/likes', accessTokenParser, loginRequiredStrictly, validator.likes, apiV3FormValidator, async(req, res) => {
+  router.put('/likes', accessTokenParser, loginRequiredStrictly, addActivity, validator.likes, apiV3FormValidator, async(req, res) => {
     const { pageId, bool: isLiked } = req.body;
     const { pageId, bool: isLiked } = req.body;
 
 
     let page;
     let page;
@@ -337,13 +342,17 @@ module.exports = (crowi) => {
 
 
     const result = { page };
     const result = { page };
     result.seenUser = page.seenUsers;
     result.seenUser = page.seenUsers;
+
+    const parameters = {
+      targetModel: SupportedTargetModel.MODEL_PAGE,
+      target: page,
+      action: isLiked ? SupportedAction.ACTION_PAGE_LIKE : SupportedAction.ACTION_PAGE_UNLIKE,
+    };
+    activityEvent.emit('update', res.locals.activity._id, parameters, page);
+
     res.apiv3({ result });
     res.apiv3({ result });
 
 
     if (isLiked) {
     if (isLiked) {
-      const pageEvent = crowi.event('page');
-      // in-app notification
-      pageEvent.emit('like', page, req.user);
-
       try {
       try {
         // global notification
         // global notification
         await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_LIKE, page, req.user);
         await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_LIKE, page, req.user);
@@ -606,6 +615,17 @@ module.exports = (crowi) => {
       'Content-Disposition': `attachment;filename*=UTF-8''${fileName}.${format}`,
       'Content-Disposition': `attachment;filename*=UTF-8''${fileName}.${format}`,
     });
     });
 
 
+    const parameters = {
+      ip:  req.ip,
+      endpoint: req.originalUrl,
+      action: SupportedAction.ACTION_PAGE_EXPORT,
+      user: req.user?._id,
+      snapshot: {
+        username: req.user?.username,
+      },
+    };
+    await crowi.activityService.createActivity(parameters);
+
     return stream.pipe(res);
     return stream.pipe(res);
   });
   });
 
 
@@ -777,12 +797,24 @@ module.exports = (crowi) => {
    *          500:
    *          500:
    *            description: Internal server error.
    *            description: Internal server error.
    */
    */
-  router.put('/subscribe', accessTokenParser, loginRequiredStrictly, validator.subscribe, apiV3FormValidator, async(req, res) => {
+  router.put('/subscribe', accessTokenParser, loginRequiredStrictly, addActivity, validator.subscribe, apiV3FormValidator, async(req, res) => {
     const { pageId, status } = req.body;
     const { pageId, status } = req.body;
     const userId = req.user._id;
     const userId = req.user._id;
 
 
     try {
     try {
       const subscription = await Subscription.subscribeByPageId(userId, pageId, status);
       const subscription = await Subscription.subscribeByPageId(userId, pageId, status);
+
+      const parameters = {};
+      if (SubscriptionStatusType.SUBSCRIBE === status) {
+        Object.assign(parameters, { action: SupportedAction.ACTION_PAGE_SUBSCRIBE });
+      }
+      else if (SubscriptionStatusType.UNSUBSCRIBE === status) {
+        Object.assign(parameters, { action: SupportedAction.ACTION_PAGE_UNSUBSCRIBE });
+      }
+      if ('action' in parameters) {
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+      }
+
       return res.apiv3({ subscription });
       return res.apiv3({ subscription });
     }
     }
     catch (err) {
     catch (err) {

+ 42 - 20
packages/app/src/server/routes/apiv3/pages.js

@@ -1,8 +1,8 @@
-import { SUPPORTED_TARGET_MODEL_TYPE, SUPPORTED_ACTION_TYPE } from '~/interfaces/activity';
+import { SupportedTargetModel, SupportedAction } from '~/interfaces/activity';
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-
+import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { isV5ConversionError } from '../../models/vo/v5-conversion-error';
 import { isV5ConversionError } from '../../models/vo/v5-conversion-error';
 
 
@@ -150,6 +150,8 @@ module.exports = (crowi) => {
   const PageTagRelation = crowi.model('PageTagRelation');
   const PageTagRelation = crowi.model('PageTagRelation');
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
 
 
+  const activityEvent = crowi.event('activity');
+
   const globalNotificationService = crowi.getGlobalNotificationService();
   const globalNotificationService = crowi.getGlobalNotificationService();
   const userNotificationService = crowi.getUserNotificationService();
   const userNotificationService = crowi.getUserNotificationService();
 
 
@@ -157,6 +159,8 @@ module.exports = (crowi) => {
   const { serializeRevisionSecurely } = require('../../models/serializers/revision-serializer');
   const { serializeRevisionSecurely } = require('../../models/serializers/revision-serializer');
   const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
   const { serializeUserSecurely } = require('../../models/serializers/user-serializer');
 
 
+  const addActivity = generateAddActivityMiddleware(crowi);
+
   const validator = {
   const validator = {
     createPage: [
     createPage: [
       body('body').exists()
       body('body').exists()
@@ -284,7 +288,7 @@ module.exports = (crowi) => {
    *          409:
    *          409:
    *            description: page path is already existed
    *            description: page path is already existed
    */
    */
-  router.post('/', accessTokenParser, loginRequiredStrictly, validator.createPage, apiV3FormValidator, async(req, res) => {
+  router.post('/', accessTokenParser, loginRequiredStrictly, addActivity, validator.createPage, apiV3FormValidator, async(req, res) => {
     const {
     const {
       body, grant, grantUserGroupId, overwriteScopesOfDescendants, isSlackEnabled, slackChannels, pageTags,
       body, grant, grantUserGroupId, overwriteScopesOfDescendants, isSlackEnabled, slackChannels, pageTags,
     } = req.body;
     } = req.body;
@@ -324,6 +328,13 @@ module.exports = (crowi) => {
       Page.applyScopesToDescendantsAsyncronously(createdPage, req.user);
       Page.applyScopesToDescendantsAsyncronously(createdPage, req.user);
     }
     }
 
 
+    const parameters = {
+      targetModel: SupportedTargetModel.MODEL_PAGE,
+      target: createdPage,
+      action: SupportedAction.ACTION_PAGE_CREATE,
+    };
+    activityEvent.emit('update', res.locals.activity._id, parameters);
+
     res.apiv3(result, 201);
     res.apiv3(result, 201);
 
 
     try {
     try {
@@ -349,20 +360,6 @@ module.exports = (crowi) => {
       }
       }
     }
     }
 
 
-    // create activity
-    try {
-      const parameters = {
-        user: req.user._id,
-        targetModel: SUPPORTED_TARGET_MODEL_TYPE.MODEL_PAGE,
-        target: createdPage,
-        action: SUPPORTED_ACTION_TYPE.ACTION_PAGE_CREATE,
-      };
-      await crowi.activityService.createByParameters(parameters);
-    }
-    catch (err) {
-      logger.error('Failed to create activity', err);
-    }
-
     // create subscription
     // create subscription
     try {
     try {
       await crowi.inAppNotificationService.createSubscription(req.user.id, createdPage._id, subscribeRuleNames.PAGE_CREATE);
       await crowi.inAppNotificationService.createSubscription(req.user.id, createdPage._id, subscribeRuleNames.PAGE_CREATE);
@@ -491,7 +488,7 @@ module.exports = (crowi) => {
    *          409:
    *          409:
    *            description: page path is already existed
    *            description: page path is already existed
    */
    */
-  router.put('/rename', accessTokenParser, loginRequiredStrictly, validator.renamePage, apiV3FormValidator, async(req, res) => {
+  router.put('/rename', accessTokenParser, loginRequiredStrictly, addActivity, validator.renamePage, apiV3FormValidator, async(req, res) => {
     const { pageId, revisionId } = req.body;
     const { pageId, revisionId } = req.body;
 
 
     let newPagePath = pathUtils.normalizePath(req.body.newPagePath);
     let newPagePath = pathUtils.normalizePath(req.body.newPagePath);
@@ -553,6 +550,14 @@ module.exports = (crowi) => {
       logger.error('Move notification failed', err);
       logger.error('Move notification failed', err);
     }
     }
 
 
+    const activityId = res.locals.activity._id;
+    const parameters = {
+      targetModel: SupportedTargetModel.MODEL_PAGE,
+      target: page,
+      action: SupportedAction.ACTION_PAGE_RENAME,
+    };
+    activityEvent.emit('update', activityId, parameters, page);
+
     return res.apiv3(result);
     return res.apiv3(result);
   });
   });
 
 
@@ -591,7 +596,7 @@ module.exports = (crowi) => {
    *          200:
    *          200:
    *            description: Succeeded to remove all trash pages
    *            description: Succeeded to remove all trash pages
    */
    */
-  router.delete('/empty-trash', accessTokenParser, loginRequired, apiV3FormValidator, async(req, res) => {
+  router.delete('/empty-trash', accessTokenParser, loginRequired, addActivity, apiV3FormValidator, async(req, res) => {
     const options = {};
     const options = {};
 
 
     const pagesInTrash = await crowi.pageService.findChildrenByParentPathOrIdAndViewer('/trash', req.user);
     const pagesInTrash = await crowi.pageService.findChildrenByParentPathOrIdAndViewer('/trash', req.user);
@@ -603,14 +608,20 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3(msg), 500);
       return res.apiv3Err(new ErrorV3(msg), 500);
     }
     }
 
 
+    const parameters = { action: SupportedAction.ACTION_PAGE_EMPTY_TRASH };
+
     // when some pages are not deletable
     // when some pages are not deletable
     if (deletablePages.length < pagesInTrash.length) {
     if (deletablePages.length < pagesInTrash.length) {
       try {
       try {
         const options = { isCompletely: true, isRecursively: true };
         const options = { isCompletely: true, isRecursively: true };
         await crowi.pageService.deleteMultiplePages(deletablePages, req.user, options);
         await crowi.pageService.deleteMultiplePages(deletablePages, req.user, options);
+
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
         return res.apiv3({ deletablePages });
         return res.apiv3({ deletablePages });
       }
       }
       catch (err) {
       catch (err) {
+        logger.error(err);
         return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
         return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
       }
       }
     }
     }
@@ -618,9 +629,13 @@ module.exports = (crowi) => {
     else {
     else {
       try {
       try {
         const pages = await crowi.pageService.emptyTrashPage(req.user, options);
         const pages = await crowi.pageService.emptyTrashPage(req.user, options);
+
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
         return res.apiv3({ pages });
         return res.apiv3({ pages });
       }
       }
       catch (err) {
       catch (err) {
+        logger.error(err);
         return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
         return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
       }
       }
     }
     }
@@ -707,7 +722,7 @@ module.exports = (crowi) => {
    *          500:
    *          500:
    *            description: Internal server error.
    *            description: Internal server error.
    */
    */
-  router.post('/duplicate', accessTokenParser, loginRequiredStrictly, validator.duplicatePage, apiV3FormValidator, async(req, res) => {
+  router.post('/duplicate', accessTokenParser, loginRequiredStrictly, addActivity, validator.duplicatePage, apiV3FormValidator, async(req, res) => {
     const { pageId, isRecursively } = req.body;
     const { pageId, isRecursively } = req.body;
 
 
     const newPagePath = pathUtils.normalizePath(req.body.pageNameInput);
     const newPagePath = pathUtils.normalizePath(req.body.pageNameInput);
@@ -753,6 +768,13 @@ module.exports = (crowi) => {
       logger.error('Failed to create subscription document', err);
       logger.error('Failed to create subscription document', err);
     }
     }
 
 
+    const parameters = {
+      targetModel: SupportedTargetModel.MODEL_PAGE,
+      target: page,
+      action: SupportedAction.ACTION_PAGE_DUPLICATE,
+    };
+    activityEvent.emit('update', res.locals.activity._id, parameters, page);
+
     return res.apiv3(result);
     return res.apiv3(result);
   });
   });
 
 

+ 47 - 9
packages/app/src/server/routes/apiv3/personal-setting.js

@@ -1,13 +1,15 @@
 import { body } from 'express-validator';
 import { body } from 'express-validator';
 
 
+import { SupportedAction } from '~/interfaces/activity';
 import { i18n } from '~/next-i18next.config';
 import { i18n } from '~/next-i18next.config';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-
+import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import EditorSettings from '../../models/editor-settings';
 import EditorSettings from '../../models/editor-settings';
 import InAppNotificationSettings from '../../models/in-app-notification-settings';
 import InAppNotificationSettings from '../../models/in-app-notification-settings';
 
 
+
 const logger = loggerFactory('growi:routes:apiv3:personal-setting');
 const logger = loggerFactory('growi:routes:apiv3:personal-setting');
 
 
 const express = require('express');
 const express = require('express');
@@ -68,9 +70,12 @@ const router = express.Router();
 module.exports = (crowi) => {
 module.exports = (crowi) => {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
+  const addActivity = generateAddActivityMiddleware(crowi);
 
 
   const { User, ExternalAccount } = crowi.models;
   const { User, ExternalAccount } = crowi.models;
 
 
+  const activityEvent = crowi.event('activity');
+
   const minPasswordLength = crowi.configManager.getConfig('crowi', 'app:minPasswordLength');
   const minPasswordLength = crowi.configManager.getConfig('crowi', 'app:minPasswordLength');
 
 
   const validator = {
   const validator = {
@@ -225,7 +230,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: personal params
    *                      description: personal params
    */
    */
-  router.put('/', accessTokenParser, loginRequiredStrictly, validator.personal, apiV3FormValidator, async(req, res) => {
+  router.put('/', accessTokenParser, loginRequiredStrictly, addActivity, validator.personal, apiV3FormValidator, async(req, res) => {
 
 
     try {
     try {
       const user = await User.findOne({ _id: req.user.id });
       const user = await User.findOne({ _id: req.user.id });
@@ -237,6 +242,10 @@ module.exports = (crowi) => {
 
 
       const updatedUser = await user.save();
       const updatedUser = await user.save();
       req.i18n.changeLanguage(req.body.lang);
       req.i18n.changeLanguage(req.body.lang);
+
+      const parameters = { action: SupportedAction.ACTION_USER_PERSONAL_SETTINGS_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3({ updatedUser });
       return res.apiv3({ updatedUser });
     }
     }
     catch (err) {
     catch (err) {
@@ -266,11 +275,15 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: user data
    *                      description: user data
    */
    */
-  router.put('/image-type', accessTokenParser, loginRequiredStrictly, validator.imageType, apiV3FormValidator, async(req, res) => {
+  router.put('/image-type', accessTokenParser, loginRequiredStrictly, addActivity, validator.imageType, apiV3FormValidator, async(req, res) => {
     const { isGravatarEnabled } = req.body;
     const { isGravatarEnabled } = req.body;
 
 
     try {
     try {
       const userData = await req.user.updateIsGravatarEnabled(isGravatarEnabled);
       const userData = await req.user.updateIsGravatarEnabled(isGravatarEnabled);
+
+      const parameters = { action: SupportedAction.ACTION_USER_IMAGE_TYPE_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3({ userData });
       return res.apiv3({ userData });
     }
     }
     catch (err) {
     catch (err) {
@@ -339,7 +352,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: user data updated
    *                      description: user data updated
    */
    */
-  router.put('/password', accessTokenParser, loginRequiredStrictly, validator.password, apiV3FormValidator, async(req, res) => {
+  router.put('/password', accessTokenParser, loginRequiredStrictly, addActivity, validator.password, apiV3FormValidator, async(req, res) => {
     const { body, user } = req;
     const { body, user } = req;
     const { oldPassword, newPassword } = body;
     const { oldPassword, newPassword } = body;
 
 
@@ -348,6 +361,10 @@ module.exports = (crowi) => {
     }
     }
     try {
     try {
       const userData = await user.updatePassword(newPassword);
       const userData = await user.updatePassword(newPassword);
+
+      const parameters = { action: SupportedAction.ACTION_USER_PASSWORD_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3({ userData });
       return res.apiv3({ userData });
     }
     }
     catch (err) {
     catch (err) {
@@ -377,11 +394,15 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: user data
    *                      description: user data
    */
    */
-  router.put('/api-token', loginRequiredStrictly, async(req, res) => {
+  router.put('/api-token', loginRequiredStrictly, addActivity, async(req, res) => {
     const { user } = req;
     const { user } = req;
 
 
     try {
     try {
       const userData = await user.updateApiToken();
       const userData = await user.updateApiToken();
+
+      const parameters = { action: SupportedAction.ACTION_USER_API_TOKEN_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3({ userData });
       return res.apiv3({ userData });
     }
     }
     catch (err) {
     catch (err) {
@@ -417,7 +438,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: Ldap account associate to me
    *                      description: Ldap account associate to me
    */
    */
-  router.put('/associate-ldap', accessTokenParser, loginRequiredStrictly, validator.associateLdap, apiV3FormValidator, async(req, res) => {
+  router.put('/associate-ldap', accessTokenParser, loginRequiredStrictly, addActivity, validator.associateLdap, apiV3FormValidator, async(req, res) => {
     const { passportService } = crowi;
     const { passportService } = crowi;
     const { user, body } = req;
     const { user, body } = req;
     const { username } = body;
     const { username } = body;
@@ -430,6 +451,10 @@ module.exports = (crowi) => {
     try {
     try {
       await passport.authenticate('ldapauth');
       await passport.authenticate('ldapauth');
       const associateUser = await ExternalAccount.associate('ldap', username, user);
       const associateUser = await ExternalAccount.associate('ldap', username, user);
+
+      const parameters = { action: SupportedAction.ACTION_USER_LDAP_ACCOUNT_ASSOCIATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3({ associateUser });
       return res.apiv3({ associateUser });
     }
     }
     catch (err) {
     catch (err) {
@@ -465,7 +490,8 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: Ldap account disassociate to me
    *                      description: Ldap account disassociate to me
    */
    */
-  router.put('/disassociate-ldap', accessTokenParser, loginRequiredStrictly, validator.disassociateLdap, apiV3FormValidator, async(req, res) => {
+  // eslint-disable-next-line max-len
+  router.put('/disassociate-ldap', accessTokenParser, loginRequiredStrictly, addActivity, validator.disassociateLdap, apiV3FormValidator, async(req, res) => {
     const { user, body } = req;
     const { user, body } = req;
     const { providerType, accountId } = body;
     const { providerType, accountId } = body;
 
 
@@ -476,6 +502,10 @@ module.exports = (crowi) => {
         return res.apiv3Err('disassociate-ldap-account-failed');
         return res.apiv3Err('disassociate-ldap-account-failed');
       }
       }
       const disassociateUser = await ExternalAccount.findOneAndRemove({ providerType, accountId, user });
       const disassociateUser = await ExternalAccount.findOneAndRemove({ providerType, accountId, user });
+
+      const parameters = { action: SupportedAction.ACTION_USER_LDAP_ACCOUNT_DISCONNECT };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3({ disassociateUser });
       return res.apiv3({ disassociateUser });
     }
     }
     catch (err) {
     catch (err) {
@@ -505,7 +535,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: editor settings
    *                      description: editor settings
    */
    */
-  router.put('/editor-settings', accessTokenParser, loginRequiredStrictly, validator.editorSettings, apiV3FormValidator, async(req, res) => {
+  router.put('/editor-settings', accessTokenParser, loginRequiredStrictly, addActivity, validator.editorSettings, apiV3FormValidator, async(req, res) => {
     const query = { userId: req.user.id };
     const query = { userId: req.user.id };
     const { body } = req;
     const { body } = req;
 
 
@@ -532,6 +562,10 @@ module.exports = (crowi) => {
     const options = { upsert: true, new: true };
     const options = { upsert: true, new: true };
     try {
     try {
       const response = await EditorSettings.findOneAndUpdate(query, { $set: document }, options);
       const response = await EditorSettings.findOneAndUpdate(query, { $set: document }, options);
+
+      const parameters = { action: SupportedAction.ACTION_USER_EDITOR_SETTINGS_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3(response);
       return res.apiv3(response);
     }
     }
     catch (err) {
     catch (err) {
@@ -594,7 +628,7 @@ module.exports = (crowi) => {
    *                      description: in-app-notification-settings
    *                      description: in-app-notification-settings
    */
    */
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
-  router.put('/in-app-notification-settings', accessTokenParser, loginRequiredStrictly, validator.inAppNotificationSettings, apiV3FormValidator, async(req, res) => {
+  router.put('/in-app-notification-settings', accessTokenParser, loginRequiredStrictly, addActivity, validator.inAppNotificationSettings, apiV3FormValidator, async(req, res) => {
     const query = { userId: req.user.id };
     const query = { userId: req.user.id };
     const subscribeRules = req.body.subscribeRules;
     const subscribeRules = req.body.subscribeRules;
 
 
@@ -605,6 +639,10 @@ module.exports = (crowi) => {
     const options = { upsert: true, new: true, runValidators: true };
     const options = { upsert: true, new: true, runValidators: true };
     try {
     try {
       const response = await InAppNotificationSettings.findOneAndUpdate(query, { $set: { subscribeRules } }, options);
       const response = await InAppNotificationSettings.findOneAndUpdate(query, { $set: { subscribeRules } }, options);
+
+      const parameters = { action: SupportedAction.ACTION_USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3(response);
       return res.apiv3(response);
     }
     }
     catch (err) {
     catch (err) {

+ 13 - 1
packages/app/src/server/routes/apiv3/search.js

@@ -1,7 +1,10 @@
+import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 
+
 const logger = loggerFactory('growi:routes:apiv3:search'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:search'); // eslint-disable-line no-unused-vars
 
 
 const express = require('express');
 const express = require('express');
@@ -22,6 +25,9 @@ module.exports = (crowi) => {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  const activityEvent = crowi.event('activity');
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -113,7 +119,7 @@ module.exports = (crowi) => {
    *        200:
    *        200:
    *          description: Return 200
    *          description: Return 200
    */
    */
-  router.put('/indices', accessTokenParser, loginRequired, adminRequired, validatorForPutIndices, apiV3FormValidator, async(req, res) => {
+  router.put('/indices', accessTokenParser, loginRequired, adminRequired, addActivity, validatorForPutIndices, apiV3FormValidator, async(req, res) => {
     const operation = req.body.operation;
     const operation = req.body.operation;
 
 
     const { searchService } = crowi;
     const { searchService } = crowi;
@@ -130,10 +136,16 @@ module.exports = (crowi) => {
         case 'normalize':
         case 'normalize':
           // wait the processing is terminated
           // wait the processing is terminated
           await searchService.normalizeIndices();
           await searchService.normalizeIndices();
+
+          activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SEARCH_INDICES_NORMALIZE });
+
           return res.status(200).send({ message: 'Operation is successfully processed.' });
           return res.status(200).send({ message: 'Operation is successfully processed.' });
         case 'rebuild':
         case 'rebuild':
           // NOT wait the processing is terminated
           // NOT wait the processing is terminated
           searchService.rebuildIndex();
           searchService.rebuildIndex();
+
+          activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ADMIN_SEARCH_INDICES_REBUILD });
+
           return res.status(200).send({ message: 'Operation is successfully requested.' });
           return res.status(200).send({ message: 'Operation is successfully requested.' });
         default:
         default:
           throw new Error(`Unimplemented operation: ${operation}`);
           throw new Error(`Unimplemented operation: ${operation}`);

+ 101 - 13
packages/app/src/server/routes/apiv3/security-setting.js

@@ -1,10 +1,13 @@
+import { SupportedAction } from '~/interfaces/activity';
 import { PageDeleteConfigValue } from '~/interfaces/page-delete-config';
 import { PageDeleteConfigValue } from '~/interfaces/page-delete-config';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';
 import { validateDeleteConfigs, prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 import { validateDeleteConfigs, prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 
 
+import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 
+
 const logger = loggerFactory('growi:routes:apiv3:security-setting');
 const logger = loggerFactory('growi:routes:apiv3:security-setting');
 
 
 const express = require('express');
 const express = require('express');
@@ -334,6 +337,9 @@ const validator = {
 module.exports = (crowi) => {
 module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  const activityEvent = crowi.event('activity');
 
 
   async function updateAndReloadStrategySettings(authId, params) {
   async function updateAndReloadStrategySettings(authId, params) {
     const { configManager, passportService } = crowi;
     const { configManager, passportService } = crowi;
@@ -504,11 +510,14 @@ module.exports = (crowi) => {
    *                  type: object
    *                  type: object
    *                  description: updated param
    *                  description: updated param
    */
    */
-  router.put('/authentication/enabled', loginRequiredStrictly, adminRequired, validator.authenticationSetting, apiV3FormValidator, async(req, res) => {
+  // eslint-disable-next-line max-len
+  router.put('/authentication/enabled', loginRequiredStrictly, adminRequired, addActivity, validator.authenticationSetting, apiV3FormValidator, async(req, res) => {
     const { isEnabled, authId } = req.body;
     const { isEnabled, authId } = req.body;
 
 
     let setupStrategies = await crowi.passportService.getSetupStrategies();
     let setupStrategies = await crowi.passportService.getSetupStrategies();
 
 
+    const parameters = {};
+
     // Reflect request param
     // Reflect request param
     setupStrategies = setupStrategies.filter(strategy => strategy !== authId);
     setupStrategies = setupStrategies.filter(strategy => strategy !== authId);
 
 
@@ -524,7 +533,65 @@ module.exports = (crowi) => {
       const responseParams = {
       const responseParams = {
         [`security:passport-${authId}:isEnabled`]: await crowi.configManager.getConfig('crowi', `security:passport-${authId}:isEnabled`),
         [`security:passport-${authId}:isEnabled`]: await crowi.configManager.getConfig('crowi', `security:passport-${authId}:isEnabled`),
       };
       };
-
+      switch (authId) {
+        case 'local':
+          if (isEnabled) {
+            parameters.action = SupportedAction.ACTION_ADMIN_AUTH_ID_PASS_ENABLED;
+            break;
+          }
+          parameters.action = SupportedAction.ACTION_ADMIN_AUTH_ID_PASS_DISABLED;
+          break;
+        case 'ldap':
+          if (isEnabled) {
+            parameters.action = SupportedAction.ACTION_ADMIN_AUTH_LDAP_ENABLED;
+            break;
+          }
+          parameters.action = SupportedAction.ACTION_ADMIN_AUTH_LDAP_DISABLED;
+          break;
+        case 'saml':
+          if (isEnabled) {
+            parameters.action = SupportedAction.ACTION_ADMIN_AUTH_SAML_ENABLED;
+            break;
+          }
+          parameters.action = SupportedAction.ACTION_ADMIN_AUTH_SAML_DISABLED;
+          break;
+        case 'oidc':
+          if (isEnabled) {
+            parameters.action = SupportedAction.ACTION_ADMIN_AUTH_OIDC_ENABLED;
+            break;
+          }
+          parameters.action = SupportedAction.ACTION_ADMIN_AUTH_OIDC_DISABLED;
+          break;
+        case 'basic':
+          if (isEnabled) {
+            parameters.action = SupportedAction.ACTION_ADMIN_AUTH_BASIC_ENABLED;
+            break;
+          }
+          parameters.action = SupportedAction.ACTION_ADMIN_AUTH_BASIC_DISABLED;
+          break;
+        case 'google':
+          if (isEnabled) {
+            parameters.action = SupportedAction.ACTION_ADMIN_AUTH_GOOGLE_ENABLED;
+            break;
+          }
+          parameters.action = SupportedAction.ACTION_ADMIN_AUTH_GOOGLE_DISABLED;
+          break;
+        case 'github':
+          if (isEnabled) {
+            parameters.action = SupportedAction.ACTION_ADMIN_AUTH_GITHUB_ENABLED;
+            break;
+          }
+          parameters.action = SupportedAction.ACTION_ADMIN_AUTH_GITHUB_DISABLED;
+          break;
+        case 'twitter':
+          if (isEnabled) {
+            parameters.action = SupportedAction.ACTION_ADMIN_AUTH_TWITTER_ENABLED;
+            break;
+          }
+          parameters.action = SupportedAction.ACTION_ADMIN_AUTH_TWITTER_DISABLED;
+          break;
+      }
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ responseParams });
       return res.apiv3({ responseParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -585,7 +652,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/GeneralSetting'
    *                  $ref: '#/components/schemas/GeneralSetting'
    */
    */
-  router.put('/general-setting', loginRequiredStrictly, adminRequired, validator.generalSetting, apiV3FormValidator, async(req, res) => {
+  router.put('/general-setting', loginRequiredStrictly, adminRequired, addActivity, validator.generalSetting, apiV3FormValidator, async(req, res) => {
     const updateData = {
     const updateData = {
       'security:sessionMaxAge': parseInt(req.body.sessionMaxAge),
       'security:sessionMaxAge': parseInt(req.body.sessionMaxAge),
       'security:restrictGuestMode': req.body.restrictGuestMode,
       'security:restrictGuestMode': req.body.restrictGuestMode,
@@ -625,6 +692,9 @@ module.exports = (crowi) => {
         hideRestrictedByGroup: await crowi.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
         hideRestrictedByGroup: await crowi.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup'),
       };
       };
 
 
+      const parameters = { action: SupportedAction.ACTION_ADMIN_SECURITY_SETTINGS_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3({ securitySettingParams });
       return res.apiv3({ securitySettingParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -655,7 +725,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/ShareLinkSetting'
    *                  $ref: '#/components/schemas/ShareLinkSetting'
    */
    */
-  router.put('/share-link-setting', loginRequiredStrictly, adminRequired, validator.generalSetting, apiV3FormValidator, async(req, res) => {
+  router.put('/share-link-setting', loginRequiredStrictly, adminRequired, addActivity, validator.generalSetting, apiV3FormValidator, async(req, res) => {
     const updateData = {
     const updateData = {
       'security:disableLinkSharing': req.body.disableLinkSharing,
       'security:disableLinkSharing': req.body.disableLinkSharing,
     };
     };
@@ -664,7 +734,9 @@ module.exports = (crowi) => {
       const securitySettingParams = {
       const securitySettingParams = {
         disableLinkSharing: crowi.configManager.getConfig('crowi', 'security:disableLinkSharing'),
         disableLinkSharing: crowi.configManager.getConfig('crowi', 'security:disableLinkSharing'),
       };
       };
-
+      // eslint-disable-next-line max-len
+      const parameters = { action: updateData['security:disableLinkSharing'] ? SupportedAction.ACTION_ADMIN_REJECT_SHARE_LINK : SupportedAction.ACTION_ADMIN_PERMIT_SHARE_LINK };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ securitySettingParams });
       return res.apiv3({ securitySettingParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -766,7 +838,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/LocalSetting'
    *                  $ref: '#/components/schemas/LocalSetting'
    */
    */
-  router.put('/local-setting', loginRequiredStrictly, adminRequired, validator.localSetting, apiV3FormValidator, async(req, res) => {
+  router.put('/local-setting', loginRequiredStrictly, adminRequired, addActivity, validator.localSetting, apiV3FormValidator, async(req, res) => {
     const requestParams = {
     const requestParams = {
       'security:registrationMode': req.body.registrationMode,
       'security:registrationMode': req.body.registrationMode,
       'security:registrationWhiteList': req.body.registrationWhiteList,
       'security:registrationWhiteList': req.body.registrationWhiteList,
@@ -782,6 +854,8 @@ module.exports = (crowi) => {
         isPasswordResetEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isPasswordResetEnabled'),
         isPasswordResetEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isPasswordResetEnabled'),
         isEmailAuthenticationEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled'),
         isEmailAuthenticationEnabled: await crowi.configManager.getConfig('crowi', 'security:passport-local:isEmailAuthenticationEnabled'),
       };
       };
+      const parameters = { action: SupportedAction.ACTION_ADMIN_AUTH_ID_PASS_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ localSettingParams });
       return res.apiv3({ localSettingParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -812,7 +886,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/LdapAuthSetting'
    *                  $ref: '#/components/schemas/LdapAuthSetting'
    */
    */
-  router.put('/ldap', loginRequiredStrictly, adminRequired, validator.ldapAuth, apiV3FormValidator, async(req, res) => {
+  router.put('/ldap', loginRequiredStrictly, adminRequired, addActivity, validator.ldapAuth, apiV3FormValidator, async(req, res) => {
     const requestParams = {
     const requestParams = {
       'security:passport-ldap:serverUrl': req.body.serverUrl,
       'security:passport-ldap:serverUrl': req.body.serverUrl,
       'security:passport-ldap:isUserBind': req.body.isUserBind,
       'security:passport-ldap:isUserBind': req.body.isUserBind,
@@ -845,6 +919,8 @@ module.exports = (crowi) => {
         ldapGroupSearchFilter: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:groupSearchFilter'),
         ldapGroupSearchFilter: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:groupSearchFilter'),
         ldapGroupDnProperty: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:groupDnProperty'),
         ldapGroupDnProperty: await crowi.configManager.getConfig('crowi', 'security:passport-ldap:groupDnProperty'),
       };
       };
+      const parameters = { action: SupportedAction.ACTION_ADMIN_AUTH_LDAP_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ securitySettingParams });
       return res.apiv3({ securitySettingParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -875,7 +951,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/SamlAuthSetting'
    *                  $ref: '#/components/schemas/SamlAuthSetting'
    */
    */
-  router.put('/saml', loginRequiredStrictly, adminRequired, validator.samlAuth, apiV3FormValidator, async(req, res) => {
+  router.put('/saml', loginRequiredStrictly, adminRequired, addActivity, validator.samlAuth, apiV3FormValidator, async(req, res) => {
 
 
     //  For the value of each mandatory items,
     //  For the value of each mandatory items,
     //  check whether it from the environment variables is empty and form value to update it is empty
     //  check whether it from the environment variables is empty and form value to update it is empty
@@ -936,6 +1012,8 @@ module.exports = (crowi) => {
         isSameEmailTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-saml:isSameEmailTreatedAsIdenticalUser'),
         isSameEmailTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-saml:isSameEmailTreatedAsIdenticalUser'),
         samlABLCRule: await crowi.configManager.getConfig('crowi', 'security:passport-saml:ABLCRule'),
         samlABLCRule: await crowi.configManager.getConfig('crowi', 'security:passport-saml:ABLCRule'),
       };
       };
+      const parameters = { action: SupportedAction.ACTION_ADMIN_AUTH_SAML_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ securitySettingParams });
       return res.apiv3({ securitySettingParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -966,7 +1044,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/OidcAuthSetting'
    *                  $ref: '#/components/schemas/OidcAuthSetting'
    */
    */
-  router.put('/oidc', loginRequiredStrictly, adminRequired, validator.oidcAuth, apiV3FormValidator, async(req, res) => {
+  router.put('/oidc', loginRequiredStrictly, adminRequired, addActivity, validator.oidcAuth, apiV3FormValidator, async(req, res) => {
     const requestParams = {
     const requestParams = {
       'security:passport-oidc:providerName': req.body.oidcProviderName,
       'security:passport-oidc:providerName': req.body.oidcProviderName,
       'security:passport-oidc:issuerHost': req.body.oidcIssuerHost,
       'security:passport-oidc:issuerHost': req.body.oidcIssuerHost,
@@ -1011,6 +1089,8 @@ module.exports = (crowi) => {
         isSameUsernameTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:isSameUsernameTreatedAsIdenticalUser'),
         isSameUsernameTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:isSameUsernameTreatedAsIdenticalUser'),
         isSameEmailTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:isSameEmailTreatedAsIdenticalUser'),
         isSameEmailTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-oidc:isSameEmailTreatedAsIdenticalUser'),
       };
       };
+      const parameters = { action: SupportedAction.ACTION_ADMIN_AUTH_OIDC_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ securitySettingParams });
       return res.apiv3({ securitySettingParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -1041,7 +1121,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/BasicAuthSetting'
    *                  $ref: '#/components/schemas/BasicAuthSetting'
    */
    */
-  router.put('/basic', loginRequiredStrictly, adminRequired, validator.basicAuth, apiV3FormValidator, async(req, res) => {
+  router.put('/basic', loginRequiredStrictly, adminRequired, addActivity, validator.basicAuth, apiV3FormValidator, async(req, res) => {
     const requestParams = {
     const requestParams = {
       'security:passport-basic:isSameUsernameTreatedAsIdenticalUser': req.body.isSameUsernameTreatedAsIdenticalUser,
       'security:passport-basic:isSameUsernameTreatedAsIdenticalUser': req.body.isSameUsernameTreatedAsIdenticalUser,
     };
     };
@@ -1052,6 +1132,8 @@ module.exports = (crowi) => {
       const securitySettingParams = {
       const securitySettingParams = {
         isSameUsernameTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-basic:isSameUsernameTreatedAsIdenticalUser'),
         isSameUsernameTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-basic:isSameUsernameTreatedAsIdenticalUser'),
       };
       };
+      const parameters = { action: SupportedAction.ACTION_ADMIN_AUTH_BASIC_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ securitySettingParams });
       return res.apiv3({ securitySettingParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -1082,7 +1164,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/GoogleOAuthSetting'
    *                  $ref: '#/components/schemas/GoogleOAuthSetting'
    */
    */
-  router.put('/google-oauth', loginRequiredStrictly, adminRequired, validator.googleOAuth, apiV3FormValidator, async(req, res) => {
+  router.put('/google-oauth', loginRequiredStrictly, adminRequired, addActivity, validator.googleOAuth, apiV3FormValidator, async(req, res) => {
     const requestParams = {
     const requestParams = {
       'security:passport-google:clientId': req.body.googleClientId,
       'security:passport-google:clientId': req.body.googleClientId,
       'security:passport-google:clientSecret': req.body.googleClientSecret,
       'security:passport-google:clientSecret': req.body.googleClientSecret,
@@ -1098,6 +1180,8 @@ module.exports = (crowi) => {
         googleClientSecret: await crowi.configManager.getConfig('crowi', 'security:passport-google:clientSecret'),
         googleClientSecret: await crowi.configManager.getConfig('crowi', 'security:passport-google:clientSecret'),
         isSameEmailTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-google:isSameEmailTreatedAsIdenticalUser'),
         isSameEmailTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-google:isSameEmailTreatedAsIdenticalUser'),
       };
       };
+      const parameters = { action: SupportedAction.ACTION_ADMIN_AUTH_GOOGLE_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ securitySettingParams });
       return res.apiv3({ securitySettingParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -1128,7 +1212,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/GitHubOAuthSetting'
    *                  $ref: '#/components/schemas/GitHubOAuthSetting'
    */
    */
-  router.put('/github-oauth', loginRequiredStrictly, adminRequired, validator.githubOAuth, apiV3FormValidator, async(req, res) => {
+  router.put('/github-oauth', loginRequiredStrictly, adminRequired, addActivity, validator.githubOAuth, apiV3FormValidator, async(req, res) => {
     const requestParams = {
     const requestParams = {
       'security:passport-github:clientId': req.body.githubClientId,
       'security:passport-github:clientId': req.body.githubClientId,
       'security:passport-github:clientSecret': req.body.githubClientSecret,
       'security:passport-github:clientSecret': req.body.githubClientSecret,
@@ -1143,6 +1227,8 @@ module.exports = (crowi) => {
         githubClientSecret: await crowi.configManager.getConfig('crowi', 'security:passport-github:clientSecret'),
         githubClientSecret: await crowi.configManager.getConfig('crowi', 'security:passport-github:clientSecret'),
         isSameUsernameTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-github:isSameUsernameTreatedAsIdenticalUser'),
         isSameUsernameTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-github:isSameUsernameTreatedAsIdenticalUser'),
       };
       };
+      const parameters = { action: SupportedAction.ACTION_ADMIN_AUTH_GITHUB_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ securitySettingParams });
       return res.apiv3({ securitySettingParams });
     }
     }
     catch (err) {
     catch (err) {
@@ -1175,7 +1261,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/TwitterOAuthSetting'
    *                  $ref: '#/components/schemas/TwitterOAuthSetting'
    */
    */
-  router.put('/twitter-oauth', loginRequiredStrictly, adminRequired, validator.twitterOAuth, apiV3FormValidator, async(req, res) => {
+  router.put('/twitter-oauth', loginRequiredStrictly, adminRequired, addActivity, validator.twitterOAuth, apiV3FormValidator, async(req, res) => {
 
 
     let requestParams = {
     let requestParams = {
       'security:passport-twitter:consumerKey': req.body.twitterConsumerKey,
       'security:passport-twitter:consumerKey': req.body.twitterConsumerKey,
@@ -1193,6 +1279,8 @@ module.exports = (crowi) => {
         twitterConsumerSecret: await crowi.configManager.getConfig('crowi', 'security:passport-twitter:consumerSecret'),
         twitterConsumerSecret: await crowi.configManager.getConfig('crowi', 'security:passport-twitter:consumerSecret'),
         isSameUsernameTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-twitter:isSameUsernameTreatedAsIdenticalUser'),
         isSameUsernameTreatedAsIdenticalUser: await crowi.configManager.getConfig('crowi', 'security:passport-twitter:isSameUsernameTreatedAsIdenticalUser'),
       };
       };
+      const parameters = { action: SupportedAction.ACTION_ADMIN_AUTH_TWITTER_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ securitySettingParams });
       return res.apiv3({ securitySettingParams });
     }
     }
     catch (err) {
     catch (err) {

+ 23 - 5
packages/app/src/server/routes/apiv3/share-links.js

@@ -1,9 +1,12 @@
 // TODO remove this setting after implemented all
 // TODO remove this setting after implemented all
 /* eslint-disable no-unused-vars */
 /* eslint-disable no-unused-vars */
+import { SupportedAction } from '~/interfaces/activity';
+import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 
+
 const logger = loggerFactory('growi:routes:apiv3:share-links');
 const logger = loggerFactory('growi:routes:apiv3:share-links');
 
 
 const express = require('express');
 const express = require('express');
@@ -27,9 +30,13 @@ const today = new Date();
 module.exports = (crowi) => {
 module.exports = (crowi) => {
   const loginRequired = require('../../middlewares/login-required')(crowi);
   const loginRequired = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const addActivity = generateAddActivityMiddleware(crowi);
+
   const ShareLink = crowi.model('ShareLink');
   const ShareLink = crowi.model('ShareLink');
   const Page = crowi.model('Page');
   const Page = crowi.model('Page');
 
 
+  const activityEvent = crowi.event('activity');
+
   /**
   /**
    * middleware to limit link sharing
    * middleware to limit link sharing
    */
    */
@@ -128,7 +135,7 @@ module.exports = (crowi) => {
    *            description: Succeeded to create one share link
    *            description: Succeeded to create one share link
    */
    */
 
 
-  router.post('/', loginRequired, linkSharingRequired, validator.shareLinkStatus, apiV3FormValidator, async(req, res) => {
+  router.post('/', loginRequired, linkSharingRequired, addActivity, validator.shareLinkStatus, apiV3FormValidator, async(req, res) => {
     const { relatedPage, expiredAt, description } = req.body;
     const { relatedPage, expiredAt, description } = req.body;
 
 
     const page = await Page.findByIdAndViewer(relatedPage, req.user);
     const page = await Page.findByIdAndViewer(relatedPage, req.user);
@@ -143,6 +150,9 @@ module.exports = (crowi) => {
 
 
     try {
     try {
       const postedShareLink = await ShareLink.create({ relatedPage, expiredAt, description });
       const postedShareLink = await ShareLink.create({ relatedPage, expiredAt, description });
+
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_SHARE_LINK_CREATE });
+
       return res.apiv3(postedShareLink, 201);
       return res.apiv3(postedShareLink, 201);
     }
     }
     catch (err) {
     catch (err) {
@@ -177,9 +187,8 @@ module.exports = (crowi) => {
   *          200:
   *          200:
   *            description: Succeeded to delete o all share links related one page
   *            description: Succeeded to delete o all share links related one page
   */
   */
-  router.delete('/', loginRequired, validator.deleteShareLinks, apiV3FormValidator, async(req, res) => {
+  router.delete('/', loginRequired, addActivity, validator.deleteShareLinks, apiV3FormValidator, async(req, res) => {
     const { relatedPage } = req.query;
     const { relatedPage } = req.query;
-
     const page = await Page.findByIdAndViewer(relatedPage, req.user);
     const page = await Page.findByIdAndViewer(relatedPage, req.user);
 
 
     if (page == null) {
     if (page == null) {
@@ -190,6 +199,9 @@ module.exports = (crowi) => {
 
 
     try {
     try {
       const deletedShareLink = await ShareLink.remove({ relatedPage });
       const deletedShareLink = await ShareLink.remove({ relatedPage });
+
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_SHARE_LINK_DELETE_BY_PAGE });
+
       return res.apiv3(deletedShareLink);
       return res.apiv3(deletedShareLink);
     }
     }
     catch (err) {
     catch (err) {
@@ -210,11 +222,14 @@ module.exports = (crowi) => {
   *          200:
   *          200:
   *            description: Succeeded to remove all share links
   *            description: Succeeded to remove all share links
   */
   */
-  router.delete('/all', loginRequired, adminRequired, async(req, res) => {
+  router.delete('/all', loginRequired, adminRequired, addActivity, async(req, res) => {
 
 
     try {
     try {
       const deletedShareLink = await ShareLink.deleteMany({});
       const deletedShareLink = await ShareLink.deleteMany({});
       const { deletedCount } = deletedShareLink;
       const { deletedCount } = deletedShareLink;
+
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_SHARE_LINK_ALL_DELETE });
+
       return res.apiv3({ deletedCount });
       return res.apiv3({ deletedCount });
     }
     }
     catch (err) {
     catch (err) {
@@ -246,7 +261,7 @@ module.exports = (crowi) => {
   *          200:
   *          200:
   *            description: Succeeded to delete one share link
   *            description: Succeeded to delete one share link
   */
   */
-  router.delete('/:id', loginRequired, validator.deleteShareLink, apiV3FormValidator, async(req, res) => {
+  router.delete('/:id', loginRequired, addActivity, validator.deleteShareLink, apiV3FormValidator, async(req, res) => {
     const { id } = req.params;
     const { id } = req.params;
     const { user } = req;
     const { user } = req;
 
 
@@ -266,6 +281,9 @@ module.exports = (crowi) => {
 
 
       // remove
       // remove
       await shareLinkToDelete.remove();
       await shareLinkToDelete.remove();
+
+      activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_SHARE_LINK_DELETE });
+
       return res.apiv3({ deletedShareLink: shareLinkToDelete });
       return res.apiv3({ deletedShareLink: shareLinkToDelete });
     }
     }
     catch (err) {
     catch (err) {

+ 11 - 1
packages/app/src/server/routes/apiv3/slack-integration-legacy-settings.js

@@ -1,7 +1,10 @@
+import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 
+
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:slack-integration-legacy-setting');
 const logger = loggerFactory('growi:routes:apiv3:slack-integration-legacy-setting');
 
 
@@ -48,6 +51,9 @@ const validator = {
 module.exports = (crowi) => {
 module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  const activityEvent = crowi.event('activity');
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -99,7 +105,7 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  $ref: '#/components/schemas/SlackConfigurationParams'
    *                  $ref: '#/components/schemas/SlackConfigurationParams'
    */
    */
-  router.put('/', loginRequiredStrictly, adminRequired, validator.slackConfiguration, apiV3FormValidator, async(req, res) => {
+  router.put('/', loginRequiredStrictly, adminRequired, addActivity, validator.slackConfiguration, apiV3FormValidator, async(req, res) => {
 
 
     const requestParams = {
     const requestParams = {
       'slack:incomingWebhookUrl': req.body.webhookUrl,
       'slack:incomingWebhookUrl': req.body.webhookUrl,
@@ -114,6 +120,10 @@ module.exports = (crowi) => {
         isIncomingWebhookPrioritized: await crowi.configManager.getConfig('notification', 'slack:isIncomingWebhookPrioritized'),
         isIncomingWebhookPrioritized: await crowi.configManager.getConfig('notification', 'slack:isIncomingWebhookPrioritized'),
         slackToken: await crowi.configManager.getConfig('notification', 'slack:token'),
         slackToken: await crowi.configManager.getConfig('notification', 'slack:token'),
       };
       };
+
+      const parameters = { action: SupportedAction.ACTION_ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3({ responseParams });
       return res.apiv3({ responseParams });
     }
     }
     catch (err) {
     catch (err) {

+ 16 - 6
packages/app/src/server/routes/apiv3/slack-integration-settings.js

@@ -1,14 +1,11 @@
 import { SlackbotType, defaultSupportedSlackEventActions } from '@growi/slack';
 import { SlackbotType, defaultSupportedSlackEventActions } from '@growi/slack';
 
 
+import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 
-const mongoose = require('mongoose');
-const express = require('express');
-const { body, query, param } = require('express-validator');
-const axios = require('axios');
-const urljoin = require('url-join');
 
 
 const {
 const {
   getConnectionStatus, getConnectionStatuses,
   getConnectionStatus, getConnectionStatuses,
@@ -16,6 +13,11 @@ const {
   defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse,
   defaultSupportedCommandsNameForBroadcastUse, defaultSupportedCommandsNameForSingleUse,
   REQUEST_TIMEOUT_FOR_GTOP,
   REQUEST_TIMEOUT_FOR_GTOP,
 } = require('@growi/slack');
 } = require('@growi/slack');
+const axios = require('axios');
+const express = require('express');
+const { body, query, param } = require('express-validator');
+const mongoose = require('mongoose');
+const urljoin = require('url-join');
 
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 
@@ -53,8 +55,12 @@ module.exports = (crowi) => {
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const accessTokenParser = require('../../middlewares/access-token-parser')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const addActivity = generateAddActivityMiddleware(crowi);
+
   const SlackAppIntegration = crowi.model('SlackAppIntegration');
   const SlackAppIntegration = crowi.model('SlackAppIntegration');
 
 
+  const activityEvent = crowi.event('activity');
+
   const validator = {
   const validator = {
     botType: [
     botType: [
       body('currentBotType').isString(),
       body('currentBotType').isString(),
@@ -440,7 +446,7 @@ module.exports = (crowi) => {
    *          200:
    *          200:
    *            description: Succeeded to create slack app integration
    *            description: Succeeded to create slack app integration
    */
    */
-  router.post('/slack-app-integrations', loginRequiredStrictly, adminRequired, async(req, res) => {
+  router.post('/slack-app-integrations', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
     const SlackAppIntegrationRecordsNum = await SlackAppIntegration.countDocuments();
     const SlackAppIntegrationRecordsNum = await SlackAppIntegration.countDocuments();
     if (SlackAppIntegrationRecordsNum >= 10) {
     if (SlackAppIntegrationRecordsNum >= 10) {
       const msg = 'Not be able to create more than 10 slack workspace integration settings';
       const msg = 'Not be able to create more than 10 slack workspace integration settings';
@@ -464,6 +470,10 @@ module.exports = (crowi) => {
         permissionsForSlackEvents: initialPermissionsForSlackEventActions,
         permissionsForSlackEvents: initialPermissionsForSlackEventActions,
         isPrimary: count === 0,
         isPrimary: count === 0,
       });
       });
+
+      const parameters = { action: SupportedAction.ACTION_ADMIN_SLACK_WORKSPACE_CREATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3(slackAppTokens, 200);
       return res.apiv3(slackAppTokens, 200);
     }
     }
     catch (error) {
     catch (error) {

+ 21 - 4
packages/app/src/server/routes/apiv3/user-group.js

@@ -1,9 +1,12 @@
+import { SupportedAction } from '~/interfaces/activity';
 import UserGroup from '~/server/models/user-group';
 import UserGroup from '~/server/models/user-group';
 import { excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
 import { excludeTestIdsFromTargetIds } from '~/server/util/compare-objectId';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 
+
 const logger = loggerFactory('growi:routes:apiv3:user-group'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:user-group'); // eslint-disable-line no-unused-vars
 
 
 const express = require('express');
 const express = require('express');
@@ -30,6 +33,9 @@ const { ObjectId } = mongoose.Types;
 module.exports = (crowi) => {
 module.exports = (crowi) => {
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  const activityEvent = crowi.event('activity');
 
 
   const {
   const {
     UserGroupRelation,
     UserGroupRelation,
@@ -221,7 +227,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: A result of `UserGroup.createGroupByName`
    *                      description: A result of `UserGroup.createGroupByName`
    */
    */
-  router.post('/', loginRequiredStrictly, adminRequired, validator.create, apiV3FormValidator, async(req, res) => {
+  router.post('/', loginRequiredStrictly, adminRequired, addActivity, validator.create, apiV3FormValidator, async(req, res) => {
     const { name, description = '', parentId } = req.body;
     const { name, description = '', parentId } = req.body;
 
 
     try {
     try {
@@ -229,6 +235,9 @@ module.exports = (crowi) => {
       const userGroupDescription = crowi.xss.process(description);
       const userGroupDescription = crowi.xss.process(description);
       const userGroup = await UserGroup.createGroup(userGroupName, userGroupDescription, parentId);
       const userGroup = await UserGroup.createGroup(userGroupName, userGroupDescription, parentId);
 
 
+      const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_CREATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3({ userGroup }, 201);
       return res.apiv3({ userGroup }, 201);
     }
     }
     catch (err) {
     catch (err) {
@@ -419,13 +428,16 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: A result of `UserGroup.removeCompletelyById`
    *                      description: A result of `UserGroup.removeCompletelyById`
    */
    */
-  router.delete('/:id', loginRequiredStrictly, adminRequired, validator.delete, apiV3FormValidator, async(req, res) => {
+  router.delete('/:id', loginRequiredStrictly, adminRequired, validator.delete, apiV3FormValidator, addActivity, async(req, res) => {
     const { id: deleteGroupId } = req.params;
     const { id: deleteGroupId } = req.params;
     const { actionName, transferToUserGroupId } = req.query;
     const { actionName, transferToUserGroupId } = req.query;
 
 
     try {
     try {
       const userGroups = await crowi.userGroupService.removeCompletelyByRootGroupId(deleteGroupId, actionName, transferToUserGroupId, req.user);
       const userGroups = await crowi.userGroupService.removeCompletelyByRootGroupId(deleteGroupId, actionName, transferToUserGroupId, req.user);
 
 
+      const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_DELETE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3({ userGroups });
       return res.apiv3({ userGroups });
     }
     }
     catch (err) {
     catch (err) {
@@ -463,7 +475,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: A result of `UserGroup.updateName`
    *                      description: A result of `UserGroup.updateName`
    */
    */
-  router.put('/:id', loginRequiredStrictly, adminRequired, validator.update, apiV3FormValidator, async(req, res) => {
+  router.put('/:id', loginRequiredStrictly, adminRequired, validator.update, apiV3FormValidator, addActivity, async(req, res) => {
     const { id } = req.params;
     const { id } = req.params;
     const {
     const {
       name, description, parentId, forceUpdateParents = false,
       name, description, parentId, forceUpdateParents = false,
@@ -472,6 +484,9 @@ module.exports = (crowi) => {
     try {
     try {
       const userGroup = await crowi.userGroupService.updateGroup(id, name, description, parentId, forceUpdateParents);
       const userGroup = await crowi.userGroupService.updateGroup(id, name, description, parentId, forceUpdateParents);
 
 
+      const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_UPDATE };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.apiv3({ userGroup });
       return res.apiv3({ userGroup });
     }
     }
     catch (err) {
     catch (err) {
@@ -629,7 +644,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: the associative entity between user and userGroup
    *                      description: the associative entity between user and userGroup
    */
    */
-  router.post('/:id/users/:username', loginRequiredStrictly, adminRequired, validator.users.post, apiV3FormValidator, async(req, res) => {
+  router.post('/:id/users/:username', loginRequiredStrictly, adminRequired, validator.users.post, apiV3FormValidator, addActivity, async(req, res) => {
     const { id, username } = req.params;
     const { id, username } = req.params;
 
 
     try {
     try {
@@ -650,6 +665,8 @@ module.exports = (crowi) => {
       const insertedRelations = await UserGroupRelation.createRelations(groupIdsOfRelationToCreate, user);
       const insertedRelations = await UserGroupRelation.createRelations(groupIdsOfRelationToCreate, user);
       const serializedUser = serializeUserSecurely(user);
       const serializedUser = serializeUserSecurely(user);
 
 
+      const parameters = { action: SupportedAction.ACTION_ADMIN_USER_GROUP_ADD_USER };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
       return res.apiv3({ user: serializedUser, createdRelationCount: insertedRelations.length });
       return res.apiv3({ user: serializedUser, createdRelationCount: insertedRelations.length });
     }
     }
     catch (err) {
     catch (err) {

+ 21 - 4
packages/app/src/server/routes/apiv3/users.js

@@ -1,7 +1,11 @@
+import { SupportedAction } from '~/interfaces/activity';
+import Activity from '~/server/models/activity';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 
+
 const logger = loggerFactory('growi:routes:apiv3:user-group');
 const logger = loggerFactory('growi:routes:apiv3:user-group');
 
 
 const express = require('express');
 const express = require('express');
@@ -75,6 +79,9 @@ module.exports = (crowi) => {
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
   const loginRequired = require('../../middlewares/login-required')(crowi, true);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const addActivity = generateAddActivityMiddleware(crowi);
+
+  const activityEvent = crowi.event('activity');
 
 
   const {
   const {
     User,
     User,
@@ -398,7 +405,7 @@ module.exports = (crowi) => {
    *                      type: object
    *                      type: object
    *                      description: Users email that failed to create or send email
    *                      description: Users email that failed to create or send email
    */
    */
-  router.post('/invite', loginRequiredStrictly, adminRequired, validator.inviteEmail, apiV3FormValidator, async(req, res) => {
+  router.post('/invite', loginRequiredStrictly, adminRequired, addActivity, validator.inviteEmail, apiV3FormValidator, async(req, res) => {
 
 
     // Delete duplicate email addresses
     // Delete duplicate email addresses
     const emailList = Array.from(new Set(req.body.shapedEmailList));
     const emailList = Array.from(new Set(req.body.shapedEmailList));
@@ -418,6 +425,9 @@ module.exports = (crowi) => {
       }
       }
     }
     }
 
 
+    const parameters = { action: SupportedAction.ACTION_ADMIN_USERS_INVITE };
+    activityEvent.emit('update', res.locals.activity._id, parameters);
+
     return res.apiv3({
     return res.apiv3({
       createdUserList: createUser.createdUserList,
       createdUserList: createUser.createdUserList,
       existingEmailList: createUser.existingEmailList,
       existingEmailList: createUser.existingEmailList,
@@ -948,14 +958,21 @@ module.exports = (crowi) => {
       }
       }
 
 
       if (options.isIncludeInactiveUser) {
       if (options.isIncludeInactiveUser) {
-        const inactiveUserStates = [User.STATUS_REGISTERED, User.STATUS_SUSPENDED, User.STATUS_DELETED, User.STATUS_INVITED];
+        const inactiveUserStates = [User.STATUS_REGISTERED, User.STATUS_SUSPENDED, User.STATUS_INVITED];
         const inactiveUserData = await User.findUserByUsernameRegexWithTotalCount(q, inactiveUserStates, { offset, limit });
         const inactiveUserData = await User.findUserByUsernameRegexWithTotalCount(q, inactiveUserStates, { offset, limit });
         const inactiveUsernames = inactiveUserData.users.map(user => user.username);
         const inactiveUsernames = inactiveUserData.users.map(user => user.username);
         Object.assign(data, { inactiveUser: { usernames: inactiveUsernames, totalCount: inactiveUserData.totalCount } });
         Object.assign(data, { inactiveUser: { usernames: inactiveUsernames, totalCount: inactiveUserData.totalCount } });
       }
       }
 
 
-      if (options.isIncludeMixedUsername) {
-        const allUsernames = [...data.activeUser?.usernames || [], ...data.inactiveUser?.usernames || []];
+      if (options.isIncludeActivitySnapshotUser && req.user.admin) {
+        const activitySnapshotUserData = await Activity.findSnapshotUsernamesByUsernameRegexWithTotalCount(q, { offset, limit });
+        Object.assign(data, { activitySnapshotUser: activitySnapshotUserData });
+      }
+
+      // eslint-disable-next-line max-len
+      const canIncludeMixedUsernames = (options.isIncludeMixedUsernames && req.user.admin) || (options.isIncludeMixedUsernames && !options.isIncludeActivitySnapshotUser);
+      if (canIncludeMixedUsernames) {
+        const allUsernames = [...data.activeUser?.usernames || [], ...data.inactiveUser?.usernames || [], ...data?.activitySnapshotUser?.usernames || []];
         const distinctUsernames = Array.from(new Set(allUsernames));
         const distinctUsernames = Array.from(new Set(allUsernames));
         Object.assign(data, { mixedUsernames: distinctUsernames });
         Object.assign(data, { mixedUsernames: distinctUsernames });
       }
       }

+ 19 - 0
packages/app/src/server/routes/attachment.js

@@ -1,5 +1,7 @@
+import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+
 /* eslint-disable no-use-before-define */
 /* eslint-disable no-use-before-define */
 
 
 
 
@@ -135,6 +137,8 @@ module.exports = function(crowi, app) {
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
   const { attachmentService, globalNotificationService } = crowi;
   const { attachmentService, globalNotificationService } = crowi;
 
 
+  const activityEvent = crowi.event('activity');
+
   /**
   /**
    * Check the user is accessible to the related page
    * Check the user is accessible to the related page
    *
    *
@@ -214,6 +218,17 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error(e.message));
       return res.json(ApiResponse.error(e.message));
     }
     }
 
 
+    const parameters = {
+      ip:  req.ip,
+      endpoint: req.originalUrl,
+      action: SupportedAction.ACTION_ATTACHMENT_DOWNLOAD,
+      user: req.user?._id,
+      snapshot: {
+        username: req.user?.username,
+      },
+    };
+    await crowi.activityService.createActivity(parameters);
+
     return fileStream.pipe(res);
     return fileStream.pipe(res);
   }
   }
 
 
@@ -472,6 +487,8 @@ module.exports = function(crowi, app) {
       pageCreated,
       pageCreated,
     };
     };
 
 
+    activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ATTACHMENT_ADD });
+
     res.json(ApiResponse.success(result));
     res.json(ApiResponse.success(result));
 
 
     if (pageCreated) {
     if (pageCreated) {
@@ -641,6 +658,8 @@ module.exports = function(crowi, app) {
       return res.status(500).json(ApiResponse.error('Error while deleting file'));
       return res.status(500).json(ApiResponse.error('Error while deleting file'));
     }
     }
 
 
+    activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_ATTACHMENT_REMOVE });
+
     return res.json(ApiResponse.success({}));
     return res.json(ApiResponse.success({}));
   };
   };
 
 

+ 19 - 1
packages/app/src/server/routes/comment.js

@@ -1,3 +1,5 @@
+
+import { SupportedAction, SupportedTargetModel, SupportedEventModel } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 /**
 /**
@@ -53,6 +55,8 @@ module.exports = function(crowi, app) {
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
   const ApiResponse = require('../util/apiResponse');
   const ApiResponse = require('../util/apiResponse');
 
 
+  const activityEvent = crowi.event('activity');
+
   const globalNotificationService = crowi.getGlobalNotificationService();
   const globalNotificationService = crowi.getGlobalNotificationService();
   const userNotificationService = crowi.getUserNotificationService();
   const userNotificationService = crowi.getUserNotificationService();
 
 
@@ -248,7 +252,6 @@ module.exports = function(crowi, app) {
       logger.error(err);
       logger.error(err);
       return res.json(ApiResponse.error(err));
       return res.json(ApiResponse.error(err));
     }
     }
-
     // update page
     // update page
     const page = await Page.findOneAndUpdate(
     const page = await Page.findOneAndUpdate(
       { _id: pageId },
       { _id: pageId },
@@ -258,6 +261,15 @@ module.exports = function(crowi, app) {
       },
       },
     );
     );
 
 
+    const parameters = {
+      targetModel: SupportedTargetModel.MODEL_PAGE,
+      target: page,
+      eventModel: SupportedEventModel.MODEL_COMMENT,
+      event: createdComment,
+      action: SupportedAction.ACTION_COMMENT_CREATE,
+    };
+    activityEvent.emit('update', res.locals.activity._id, parameters, page);
+
     res.json(ApiResponse.success({ comment: createdComment }));
     res.json(ApiResponse.success({ comment: createdComment }));
 
 
     // global notification
     // global notification
@@ -386,6 +398,9 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error(err));
       return res.json(ApiResponse.error(err));
     }
     }
 
 
+    const parameters = { action: SupportedAction.ACTION_COMMENT_UPDATE };
+    activityEvent.emit('update', res.locals.activity._id, parameters);
+
     res.json(ApiResponse.success({ comment: updatedComment }));
     res.json(ApiResponse.success({ comment: updatedComment }));
 
 
     // process notification if needed
     // process notification if needed
@@ -465,6 +480,9 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error(err));
       return res.json(ApiResponse.error(err));
     }
     }
 
 
+    const parameters = { action: SupportedAction.ACTION_COMMENT_REMOVE };
+    activityEvent.emit('update', res.locals.activity._id, parameters);
+
     return res.json(ApiResponse.success({}));
     return res.json(ApiResponse.success({}));
   };
   };
 
 

+ 35 - 35
packages/app/src/server/routes/index.js

@@ -1,6 +1,7 @@
 import csrf from 'csurf';
 import csrf from 'csurf';
 import express from 'express';
 import express from 'express';
 
 
+import { generateAddActivityMiddleware } from '../middlewares/add-activity';
 import apiV1FormValidator from '../middlewares/apiv1-form-validator';
 import apiV1FormValidator from '../middlewares/apiv1-form-validator';
 import injectResetOrderByTokenMiddleware from '../middlewares/inject-reset-order-by-token-middleware';
 import injectResetOrderByTokenMiddleware from '../middlewares/inject-reset-order-by-token-middleware';
 import injectUserRegistrationOrderByTokenMiddleware from '../middlewares/inject-user-registration-order-by-token-middleware';
 import injectUserRegistrationOrderByTokenMiddleware from '../middlewares/inject-user-registration-order-by-token-middleware';
@@ -10,26 +11,17 @@ import {
   generateUnavailableWhenMaintenanceModeMiddleware, generateUnavailableWhenMaintenanceModeMiddlewareForApi,
   generateUnavailableWhenMaintenanceModeMiddleware, generateUnavailableWhenMaintenanceModeMiddlewareForApi,
 } from '../middlewares/unavailable-when-maintenance-mode';
 } from '../middlewares/unavailable-when-maintenance-mode';
 
 
-
 import * as allInAppNotifications from './all-in-app-notifications';
 import * as allInAppNotifications from './all-in-app-notifications';
 import * as forgotPassword from './forgot-password';
 import * as forgotPassword from './forgot-password';
 import nextFactory from './next';
 import nextFactory from './next';
 import * as privateLegacyPages from './private-legacy-pages';
 import * as privateLegacyPages from './private-legacy-pages';
 import * as userActivation from './user-activation';
 import * as userActivation from './user-activation';
 
 
-const rateLimit = require('express-rate-limit');
 const multer = require('multer');
 const multer = require('multer');
 const autoReap = require('multer-autoreap');
 const autoReap = require('multer-autoreap');
 
 
 const csrfProtection = csrf({ cookie: false });
 const csrfProtection = csrf({ cookie: false });
 
 
-const apiLimiter = rateLimit({
-  windowMs: 1 * 60 * 1000, // 1 minutes
-  max: 60, // limit each IP to 60 requests per windowMs
-  message:
-    'Too many requests sent from this IP, please try again after 1 minute',
-});
-
 autoReap.options.reapOnError = true; // continue reaping the file even if an error occurs
 autoReap.options.reapOnError = true; // continue reaping the file even if an error occurs
 
 
 module.exports = function(crowi, app) {
 module.exports = function(crowi, app) {
@@ -42,6 +34,8 @@ module.exports = function(crowi, app) {
   const adminRequired = require('../middlewares/admin-required')(crowi);
   const adminRequired = require('../middlewares/admin-required')(crowi);
   const certifySharedFile = require('../middlewares/certify-shared-file')(crowi);
   const certifySharedFile = require('../middlewares/certify-shared-file')(crowi);
   const injectUserUISettings = require('../middlewares/inject-user-ui-settings-to-localvars')();
   const injectUserUISettings = require('../middlewares/inject-user-ui-settings-to-localvars')();
+  const rateLimiter = require('../middlewares/rate-limiter')();
+  const addActivity = generateAddActivityMiddleware(crowi);
 
 
   const uploads = multer({ dest: `${crowi.tmpDir}uploads` });
   const uploads = multer({ dest: `${crowi.tmpDir}uploads` });
   const page = require('./page')(crowi, app);
   const page = require('./page')(crowi, app);
@@ -71,6 +65,9 @@ module.exports = function(crowi, app) {
 
 
   app.use('/api-docs', require('./apiv3/docs')(crowi));
   app.use('/api-docs', require('./apiv3/docs')(crowi));
 
 
+  // Rate limiter
+  app.use(rateLimiter);
+
   // API v3 for admin
   // API v3 for admin
   app.use('/_api/v3', apiV3AdminRouter);
   app.use('/_api/v3', apiV3AdminRouter);
 
 
@@ -84,10 +81,10 @@ module.exports = function(crowi, app) {
   app.get('/login/error/:reason'      , applicationInstalled, login.error);
   app.get('/login/error/:reason'      , applicationInstalled, login.error);
   app.get('/login'                    , applicationInstalled, login.preLogin, login.login);
   app.get('/login'                    , applicationInstalled, login.preLogin, login.login);
   app.get('/login/invited'            , applicationInstalled, login.invited);
   app.get('/login/invited'            , applicationInstalled, login.invited);
-  app.post('/login/activateInvited'   , apiLimiter , applicationInstalled, loginFormValidator.inviteRules(), loginFormValidator.inviteValidation, csrfProtection, login.invited);
-  app.post('/login'                   , apiLimiter , applicationInstalled, loginFormValidator.loginRules(), loginFormValidator.loginValidation, csrfProtection, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
+  app.post('/login/activateInvited'   , applicationInstalled, loginFormValidator.inviteRules(), loginFormValidator.inviteValidation, csrfProtection, login.invited);
+  app.post('/login'                   , applicationInstalled, loginFormValidator.loginRules(), loginFormValidator.loginValidation, csrfProtection,  addActivity, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
 
 
-  app.post('/register'                , apiLimiter , applicationInstalled, registerFormValidator.registerRules(), registerFormValidator.registerValidation, csrfProtection, login.register);
+  app.post('/register'                , applicationInstalled, registerFormValidator.registerRules(), registerFormValidator.registerValidation, csrfProtection, addActivity, login.register);
   app.get('/register'                 , applicationInstalled, login.preLogin, login.register);
   app.get('/register'                 , applicationInstalled, login.preLogin, login.register);
 
 
   app.get('/admin/*'                    , applicationInstalled, loginRequiredStrictly , adminRequired , next.delegateToNext);
   app.get('/admin/*'                    , applicationInstalled, loginRequiredStrictly , adminRequired , next.delegateToNext);
@@ -98,7 +95,7 @@ module.exports = function(crowi, app) {
   if (!isInstalled) {
   if (!isInstalled) {
     const installer = require('./installer')(crowi);
     const installer = require('./installer')(crowi);
     app.get('/installer'              , applicationNotInstalled , installer.index);
     app.get('/installer'              , applicationNotInstalled , installer.index);
-    app.post('/installer'             , apiLimiter , applicationNotInstalled , registerFormValidator.registerRules(), registerFormValidator.registerValidation, csrfProtection, installer.install);
+    app.post('/installer'             , applicationNotInstalled , registerFormValidator.registerRules(), registerFormValidator.registerValidation, csrfProtection, addActivity, installer.install);
     return;
     return;
   }
   }
 
 
@@ -113,9 +110,9 @@ module.exports = function(crowi, app) {
   app.get('/passport/github/callback'             , loginPassport.loginPassportGitHubCallback   , loginPassport.loginFailure);
   app.get('/passport/github/callback'             , loginPassport.loginPassportGitHubCallback   , loginPassport.loginFailure);
   app.get('/passport/twitter/callback'            , loginPassport.loginPassportTwitterCallback  , loginPassport.loginFailure);
   app.get('/passport/twitter/callback'            , loginPassport.loginPassportTwitterCallback  , loginPassport.loginFailure);
   app.get('/passport/oidc/callback'               , loginPassport.loginPassportOidcCallback     , loginPassport.loginFailure);
   app.get('/passport/oidc/callback'               , loginPassport.loginPassportOidcCallback     , loginPassport.loginFailure);
-  app.post('/passport/saml/callback'              , loginPassport.loginPassportSamlCallback     , loginPassport.loginFailure);
+  app.post('/passport/saml/callback'              , addActivity, loginPassport.loginPassportSamlCallback, loginPassport.loginFailure);
 
 
-  app.post('/_api/login/testLdap'    , apiLimiter , loginRequiredStrictly , loginFormValidator.loginRules() , loginFormValidator.loginValidation , loginPassport.testLdapCredentials);
+  app.post('/_api/login/testLdap'    , loginRequiredStrictly , loginFormValidator.loginRules() , loginFormValidator.loginValidation , loginPassport.testLdapCredentials);
 
 
   // security admin
   // security admin
   // app.get('/admin/security'          , loginRequiredStrictly , adminRequired , admin.security.index);
   // app.get('/admin/security'          , loginRequiredStrictly , adminRequired , admin.security.index);
@@ -146,14 +143,17 @@ module.exports = function(crowi, app) {
   // app.get('/admin/user-groups'                          , loginRequiredStrictly, adminRequired, admin.userGroup.index);
   // app.get('/admin/user-groups'                          , loginRequiredStrictly, adminRequired, admin.userGroup.index);
   // app.get('/admin/user-group-detail/:id'                , loginRequiredStrictly, adminRequired, admin.userGroup.detail);
   // app.get('/admin/user-group-detail/:id'                , loginRequiredStrictly, adminRequired, admin.userGroup.detail);
 
 
+  // auditLog admin
+  app.get('/admin/audit-log'                            , loginRequiredStrictly, adminRequired, admin.auditLog.index);
+
   // importer management for admin
   // importer management for admin
   // app.get('/admin/importer'                     , loginRequiredStrictly , adminRequired , admin.importer.index);
   // app.get('/admin/importer'                     , loginRequiredStrictly , adminRequired , admin.importer.index);
-  app.post('/_api/admin/settings/importerEsa'   , loginRequiredStrictly , adminRequired , csrfProtection, admin.importer.api.validators.importer.esa(),admin.api.importerSettingEsa);
-  app.post('/_api/admin/settings/importerQiita' , loginRequiredStrictly , adminRequired , csrfProtection, admin.importer.api.validators.importer.qiita(), admin.api.importerSettingQiita);
-  app.post('/_api/admin/import/esa'             , loginRequiredStrictly , adminRequired , csrfProtection, admin.api.importDataFromEsa);
-  app.post('/_api/admin/import/testEsaAPI'      , loginRequiredStrictly , adminRequired , csrfProtection, admin.api.testEsaAPI);
-  app.post('/_api/admin/import/qiita'           , loginRequiredStrictly , adminRequired , csrfProtection, admin.api.importDataFromQiita);
-  app.post('/_api/admin/import/testQiitaAPI'    , loginRequiredStrictly , adminRequired , csrfProtection, admin.api.testQiitaAPI);
+  app.post('/_api/admin/settings/importerEsa'   , loginRequiredStrictly , adminRequired , csrfProtection, addActivity, admin.importer.api.validators.importer.esa(),admin.api.importerSettingEsa);
+  app.post('/_api/admin/settings/importerQiita' , loginRequiredStrictly , adminRequired , csrfProtection, addActivity, admin.importer.api.validators.importer.qiita(), admin.api.importerSettingQiita);
+  app.post('/_api/admin/import/esa'             , loginRequiredStrictly , adminRequired , csrfProtection, addActivity, admin.api.importDataFromEsa);
+  app.post('/_api/admin/import/testEsaAPI'      , loginRequiredStrictly , adminRequired , csrfProtection, addActivity, admin.api.testEsaAPI);
+  app.post('/_api/admin/import/qiita'           , loginRequiredStrictly , adminRequired , csrfProtection, addActivity, admin.api.importDataFromQiita);
+  app.post('/_api/admin/import/testQiitaAPI'    , loginRequiredStrictly , adminRequired , csrfProtection, addActivity, admin.api.testQiitaAPI);
 
 
   // export management for admin
   // export management for admin
   // app.get('/admin/export'                       , loginRequiredStrictly , adminRequired ,admin.export.index);
   // app.get('/admin/export'                       , loginRequiredStrictly , adminRequired ,admin.export.index);
@@ -172,30 +172,30 @@ module.exports = function(crowi, app) {
 
 
   apiV1Router.get('/search'                        , accessTokenParser , loginRequired , search.api.search);
   apiV1Router.get('/search'                        , accessTokenParser , loginRequired , search.api.search);
 
 
-  apiV1Router.get('/check_username'           , user.api.checkUsername);
   apiV1Router.get('/me/user-group-relations'  , accessTokenParser , loginRequiredStrictly , me.api.userGroupRelations);
   apiV1Router.get('/me/user-group-relations'  , accessTokenParser , loginRequiredStrictly , me.api.userGroupRelations);
 
 
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
   apiV1Router.get('/pages.list'          , accessTokenParser , loginRequired , page.api.list);
   apiV1Router.get('/pages.list'          , accessTokenParser , loginRequired , page.api.list);
-  apiV1Router.post('/pages.update'       , accessTokenParser , loginRequiredStrictly , page.api.update);
+  apiV1Router.post('/pages.update'       , accessTokenParser , loginRequiredStrictly , addActivity, page.api.update);
   apiV1Router.get('/pages.exist'         , accessTokenParser , loginRequired , page.api.exist);
   apiV1Router.get('/pages.exist'         , accessTokenParser , loginRequired , page.api.exist);
   apiV1Router.get('/pages.updatePost'    , accessTokenParser, loginRequired, page.api.getUpdatePost);
   apiV1Router.get('/pages.updatePost'    , accessTokenParser, loginRequired, page.api.getUpdatePost);
   apiV1Router.get('/pages.getPageTag'    , accessTokenParser , loginRequired , page.api.getPageTag);
   apiV1Router.get('/pages.getPageTag'    , accessTokenParser , loginRequired , page.api.getPageTag);
   // allow posting to guests because the client doesn't know whether the user logged in
   // allow posting to guests because the client doesn't know whether the user logged in
-  apiV1Router.post('/pages.remove'       , loginRequiredStrictly , page.validator.remove, apiV1FormValidator, page.api.remove); // (Avoid from API Token)
-  apiV1Router.post('/pages.revertRemove' , loginRequiredStrictly , page.validator.revertRemove, apiV1FormValidator, page.api.revertRemove); // (Avoid from API Token)
+  apiV1Router.post('/pages.remove'       , loginRequiredStrictly , addActivity, page.validator.remove, apiV1FormValidator, page.api.remove); // (Avoid from API Token)
+  apiV1Router.post('/pages.revertRemove' , loginRequiredStrictly , addActivity, page.validator.revertRemove, apiV1FormValidator, page.api.revertRemove); // (Avoid from API Token)
   apiV1Router.post('/pages.unlink'       , loginRequiredStrictly , page.api.unlink); // (Avoid from API Token)
   apiV1Router.post('/pages.unlink'       , loginRequiredStrictly , page.api.unlink); // (Avoid from API Token)
   apiV1Router.post('/pages.duplicate'    , accessTokenParser, loginRequiredStrictly, page.api.duplicate);
   apiV1Router.post('/pages.duplicate'    , accessTokenParser, loginRequiredStrictly, page.api.duplicate);
   apiV1Router.get('/tags.list'           , accessTokenParser, loginRequired, tag.api.list);
   apiV1Router.get('/tags.list'           , accessTokenParser, loginRequired, tag.api.list);
   apiV1Router.get('/tags.search'         , accessTokenParser, loginRequired, tag.api.search);
   apiV1Router.get('/tags.search'         , accessTokenParser, loginRequired, tag.api.search);
-  apiV1Router.post('/tags.update'        , accessTokenParser, loginRequiredStrictly, tag.api.update);
+  apiV1Router.post('/tags.update'        , accessTokenParser, loginRequiredStrictly, addActivity, tag.api.update);
   apiV1Router.get('/comments.get'        , accessTokenParser , loginRequired , comment.api.get);
   apiV1Router.get('/comments.get'        , accessTokenParser , loginRequired , comment.api.get);
-  apiV1Router.post('/comments.add'       , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , comment.api.add);
-  apiV1Router.post('/comments.update'    , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , comment.api.update);
-  apiV1Router.post('/comments.remove'    , accessTokenParser , loginRequiredStrictly , comment.api.remove);
-  apiV1Router.post('/attachments.add'                  , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly, attachment.api.add);
-  apiV1Router.post('/attachments.uploadProfileImage'   , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly, attachment.api.uploadProfileImage);
-  apiV1Router.post('/attachments.remove'               , accessTokenParser , loginRequiredStrictly , attachment.api.remove);
+  apiV1Router.post('/comments.add'       , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , addActivity, comment.api.add);
+  apiV1Router.post('/comments.update'    , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , addActivity, comment.api.update);
+  apiV1Router.post('/comments.remove'    , accessTokenParser , loginRequiredStrictly , addActivity, comment.api.remove);
+
+  apiV1Router.post('/attachments.add'                  , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly ,addActivity ,attachment.api.add);
+  apiV1Router.post('/attachments.uploadProfileImage'   , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly ,attachment.api.uploadProfileImage);
+  apiV1Router.post('/attachments.remove'               , accessTokenParser , loginRequiredStrictly , addActivity ,attachment.api.remove);
   apiV1Router.post('/attachments.removeProfileImage'   , accessTokenParser , loginRequiredStrictly , attachment.api.removeProfileImage);
   apiV1Router.post('/attachments.removeProfileImage'   , accessTokenParser , loginRequiredStrictly , attachment.api.removeProfileImage);
   apiV1Router.get('/attachments.limit'   , accessTokenParser , loginRequiredStrictly, attachment.api.limit);
   apiV1Router.get('/attachments.limit'   , accessTokenParser , loginRequiredStrictly, attachment.api.limit);
 
 
@@ -235,15 +235,15 @@ module.exports = function(crowi, app) {
   app.use('/forgot-password', express.Router()
   app.use('/forgot-password', express.Router()
     .use(forgotPassword.checkForgotPasswordEnabledMiddlewareFactory(crowi))
     .use(forgotPassword.checkForgotPasswordEnabledMiddlewareFactory(crowi))
     .get('/', forgotPassword.forgotPassword)
     .get('/', forgotPassword.forgotPassword)
-    .get('/:token', apiLimiter, injectResetOrderByTokenMiddleware, forgotPassword.resetPassword)
+    .get('/:token', injectResetOrderByTokenMiddleware, forgotPassword.resetPassword)
     .use(forgotPassword.handleErrosMiddleware));
     .use(forgotPassword.handleErrosMiddleware));
 
 
   app.use('/_private-legacy-pages', express.Router()
   app.use('/_private-legacy-pages', express.Router()
     .get('/', injectUserUISettings, privateLegacyPages.renderPrivateLegacyPages));
     .get('/', injectUserUISettings, privateLegacyPages.renderPrivateLegacyPages));
   app.use('/user-activation', express.Router()
   app.use('/user-activation', express.Router()
-    .get('/:token', apiLimiter, applicationInstalled, injectUserRegistrationOrderByTokenMiddleware, userActivation.form)
+    .get('/:token', applicationInstalled, injectUserRegistrationOrderByTokenMiddleware, userActivation.form)
     .use(userActivation.tokenErrorHandlerMiddeware));
     .use(userActivation.tokenErrorHandlerMiddeware));
-  app.post('/user-activation/register', apiLimiter, applicationInstalled, csrfProtection, userActivation.registerRules(), userActivation.validateRegisterForm, userActivation.registerAction(crowi));
+  app.post('/user-activation/register', applicationInstalled, csrfProtection, userActivation.registerRules(), userActivation.validateRegisterForm, userActivation.registerAction(crowi));
 
 
   app.get('/share/:linkId', page.showSharedPage);
   app.get('/share/:linkId', page.showSharedPage);
 
 

+ 7 - 0
packages/app/src/server/routes/installer.js

@@ -1,3 +1,4 @@
+import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { InstallerService, FailedToCreateAdminUserError } from '../service/installer';
 import { InstallerService, FailedToCreateAdminUserError } from '../service/installer';
@@ -8,6 +9,8 @@ module.exports = function(crowi) {
 
 
   const actions = {};
   const actions = {};
 
 
+  const activityEvent = crowi.event('activity');
+
   actions.index = function(req, res) {
   actions.index = function(req, res) {
     return res.render('installer');
     return res.render('installer');
   };
   };
@@ -55,6 +58,10 @@ module.exports = function(crowi) {
       }
       }
 
 
       req.flash('successMessage', req.t('message.complete_to_install2'));
       req.flash('successMessage', req.t('message.complete_to_install2'));
+
+      const parameters = { action: SupportedAction.ACTION_USER_REGISTRATION_SUCCESS };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.redirect('/');
       return res.redirect('/');
     });
     });
   };
   };

+ 78 - 6
packages/app/src/server/routes/login-passport.js

@@ -1,3 +1,4 @@
+import { SupportedAction } from '~/interfaces/activity';
 import { NullUsernameToBeRegisteredError } from '~/server/models/errors';
 import { NullUsernameToBeRegisteredError } from '~/server/models/errors';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -9,6 +10,9 @@ module.exports = function(crowi, app) {
   const passport = require('passport');
   const passport = require('passport');
   const ExternalAccount = crowi.model('ExternalAccount');
   const ExternalAccount = crowi.model('ExternalAccount');
   const passportService = crowi.passportService;
   const passportService = crowi.passportService;
+
+  const activityEvent = crowi.event('activity');
+
   const ApiResponse = require('../util/apiResponse');
   const ApiResponse = require('../util/apiResponse');
 
 
   /**
   /**
@@ -16,7 +20,7 @@ module.exports = function(crowi, app) {
    * @param {*} req
    * @param {*} req
    * @param {*} res
    * @param {*} res
    */
    */
-  const loginSuccessHandler = (req, res, user) => {
+  const loginSuccessHandler = async(req, res, user) => {
     // update lastLoginAt
     // update lastLoginAt
     user.updateLastLoginAt(new Date(), (err, userData) => {
     user.updateLastLoginAt(new Date(), (err, userData) => {
       if (err) {
       if (err) {
@@ -28,6 +32,7 @@ module.exports = function(crowi, app) {
     const { redirectTo } = req.session;
     const { redirectTo } = req.session;
     // remove session.redirectTo
     // remove session.redirectTo
     delete req.session.redirectTo;
     delete req.session.redirectTo;
+
     return res.safeRedirect(redirectTo);
     return res.safeRedirect(redirectTo);
   };
   };
 
 
@@ -36,8 +41,12 @@ module.exports = function(crowi, app) {
    * @param {*} req
    * @param {*} req
    * @param {*} res
    * @param {*} res
    */
    */
-  const loginFailureHandler = (req, res, message) => {
+  const loginFailureHandler = async(req, res, message) => {
     req.flash('errorMessage', message || req.t('message.sign_in_failure'));
     req.flash('errorMessage', message || req.t('message.sign_in_failure'));
+
+    const parameters = { action: SupportedAction.ACTION_USER_LOGIN_FAILURE };
+    activityEvent.emit('update', res.locals.activity._id, parameters);
+
     return res.redirect('/login');
     return res.redirect('/login');
   };
   };
 
 
@@ -131,6 +140,10 @@ module.exports = function(crowi, app) {
     // login
     // login
     await req.logIn(user, (err) => {
     await req.logIn(user, (err) => {
       if (err) { debug(err.message); return next() }
       if (err) { debug(err.message); return next() }
+
+      const parameters = { action: SupportedAction.ACTION_USER_LOGIN_WITH_LDAP };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return loginSuccessHandler(req, res, user);
       return loginSuccessHandler(req, res, user);
     });
     });
   };
   };
@@ -223,6 +236,9 @@ module.exports = function(crowi, app) {
       req.logIn(user, (err) => {
       req.logIn(user, (err) => {
         if (err) { debug(err.message); return next() }
         if (err) { debug(err.message); return next() }
 
 
+        const parameters = { action: SupportedAction.ACTION_USER_LOGIN_WITH_LOCAL };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+
         return loginSuccessHandler(req, res, user);
         return loginSuccessHandler(req, res, user);
       });
       });
     })(req, res, next);
     })(req, res, next);
@@ -291,8 +307,20 @@ module.exports = function(crowi, app) {
     const user = await externalAccount.getPopulatedUser();
     const user = await externalAccount.getPopulatedUser();
 
 
     // login
     // login
-    req.logIn(user, (err) => {
+    req.logIn(user, async(err) => {
       if (err) { debug(err.message); return next() }
       if (err) { debug(err.message); return next() }
+
+      const parameters = {
+        ip:  req.ip,
+        endpoint: req.originalUrl,
+        action: SupportedAction.ACTION_USER_LOGIN_WITH_GOOGLE,
+        user: req.user?._id,
+        snapshot: {
+          username: req.user?.username,
+        },
+      };
+      await crowi.activityService.createActivity(parameters);
+
       return loginSuccessHandler(req, res, user);
       return loginSuccessHandler(req, res, user);
     });
     });
   };
   };
@@ -333,8 +361,20 @@ module.exports = function(crowi, app) {
     const user = await externalAccount.getPopulatedUser();
     const user = await externalAccount.getPopulatedUser();
 
 
     // login
     // login
-    req.logIn(user, (err) => {
+    req.logIn(user, async(err) => {
       if (err) { debug(err.message); return next() }
       if (err) { debug(err.message); return next() }
+
+      const parameters = {
+        ip:  req.ip,
+        endpoint: req.originalUrl,
+        action: SupportedAction.ACTION_USER_LOGIN_WITH_GITHUB,
+        user: req.user?._id,
+        snapshot: {
+          username: req.user?.username,
+        },
+      };
+      await crowi.activityService.createActivity(parameters);
+
       return loginSuccessHandler(req, res, user);
       return loginSuccessHandler(req, res, user);
     });
     });
   };
   };
@@ -375,8 +415,20 @@ module.exports = function(crowi, app) {
     const user = await externalAccount.getPopulatedUser();
     const user = await externalAccount.getPopulatedUser();
 
 
     // login
     // login
-    req.logIn(user, (err) => {
+    req.logIn(user, async(err) => {
       if (err) { debug(err.message); return next() }
       if (err) { debug(err.message); return next() }
+
+      const parameters = {
+        ip:  req.ip,
+        endpoint: req.originalUrl,
+        action: SupportedAction.ACTION_USER_LOGIN_WITH_TWITTER,
+        user: req.user?._id,
+        snapshot: {
+          username: req.user?.username,
+        },
+      };
+      await crowi.activityService.createActivity(parameters);
+
       return loginSuccessHandler(req, res, user);
       return loginSuccessHandler(req, res, user);
     });
     });
   };
   };
@@ -423,8 +475,20 @@ module.exports = function(crowi, app) {
 
 
     // login
     // login
     const user = await externalAccount.getPopulatedUser();
     const user = await externalAccount.getPopulatedUser();
-    req.logIn(user, (err) => {
+    req.logIn(user, async(err) => {
       if (err) { debug(err.message); return next() }
       if (err) { debug(err.message); return next() }
+
+      const parameters = {
+        ip:  req.ip,
+        endpoint: req.originalUrl,
+        action: SupportedAction.ACTION_USER_LOGIN_WITH_OIDC,
+        user: req.user?._id,
+        snapshot: {
+          username: req.user?.username,
+        },
+      };
+      await crowi.activityService.createActivity(parameters);
+
       return loginSuccessHandler(req, res, user);
       return loginSuccessHandler(req, res, user);
     });
     });
   };
   };
@@ -487,6 +551,10 @@ module.exports = function(crowi, app) {
         logger.error(err);
         logger.error(err);
         return loginFailureHandler(req, res);
         return loginFailureHandler(req, res);
       }
       }
+
+      const parameters = { action: SupportedAction.ACTION_USER_LOGIN_WITH_SAML };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return loginSuccessHandler(req, res, user);
       return loginSuccessHandler(req, res, user);
     });
     });
   };
   };
@@ -529,6 +597,10 @@ module.exports = function(crowi, app) {
     const user = await externalAccount.getPopulatedUser();
     const user = await externalAccount.getPopulatedUser();
     await req.logIn(user, (err) => {
     await req.logIn(user, (err) => {
       if (err) { debug(err.message); return next() }
       if (err) { debug(err.message); return next() }
+
+      const parameters = { action: SupportedAction.ACTION_USER_LOGIN_WITH_BASIC };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return loginSuccessHandler(req, res, user);
       return loginSuccessHandler(req, res, user);
     });
     });
   };
   };

+ 6 - 1
packages/app/src/server/routes/login.js

@@ -1,5 +1,5 @@
+import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
-
 // disable all of linting
 // disable all of linting
 // because this file is a deprecated legacy of Crowi
 // because this file is a deprecated legacy of Crowi
 
 
@@ -11,6 +11,7 @@ module.exports = function(crowi, app) {
   const {
   const {
     configManager, appService, aclService, mailService,
     configManager, appService, aclService, mailService,
   } = crowi;
   } = crowi;
+  const activityEvent = crowi.event('activity');
 
 
   const actions = {};
   const actions = {};
 
 
@@ -38,6 +39,10 @@ module.exports = function(crowi, app) {
       const { redirectTo } = req.session;
       const { redirectTo } = req.session;
       // remove session.redirectTo
       // remove session.redirectTo
       delete req.session.redirectTo;
       delete req.session.redirectTo;
+
+      const parameters = { action: SupportedAction.ACTION_USER_REGISTRATION_SUCCESS };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+
       return res.safeRedirect(redirectTo);
       return res.safeRedirect(redirectTo);
     });
     });
   };
   };

+ 0 - 11
packages/app/src/server/routes/logout.js

@@ -1,11 +0,0 @@
-module.exports = function(crowi, app) {
-  return {
-    logout(req, res) {
-      req.session.destroy();
-
-      // redirect
-      const redirectTo = req.headers.referer || '/';
-      return res.safeRedirect(redirectTo);
-    },
-  };
-};

+ 108 - 0
packages/app/src/server/routes/page.js

@@ -3,6 +3,8 @@ import { body } from 'express-validator';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
+import { SupportedTargetModel, SupportedAction } from '~/interfaces/activity';
+import Activity from '~/server/models/activity';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { PathAlreadyExistsError } from '../models/errors';
 import { PathAlreadyExistsError } from '../models/errors';
@@ -156,6 +158,8 @@ module.exports = function(crowi, app) {
   const globalNotificationService = crowi.getGlobalNotificationService();
   const globalNotificationService = crowi.getGlobalNotificationService();
   const userNotificationService = crowi.getUserNotificationService();
   const userNotificationService = crowi.getUserNotificationService();
 
 
+  const activityEvent = crowi.event('activity');
+
   const XssOption = require('~/services/xss/xssOption');
   const XssOption = require('~/services/xss/xssOption');
   const Xss = require('~/services/xss/index');
   const Xss = require('~/services/xss/index');
   const initializedConfig = {
   const initializedConfig = {
@@ -302,20 +306,25 @@ module.exports = function(crowi, app) {
     const pathOrId = req.params.id || path;
     const pathOrId = req.params.id || path;
 
 
     let view;
     let view;
+    let action;
     const renderVars = { path };
     const renderVars = { path };
 
 
     if (!isCreatablePage(path)) {
     if (!isCreatablePage(path)) {
       view = 'layout-growi/not_creatable';
       view = 'layout-growi/not_creatable';
+      action = SupportedAction.ACTION_PAGE_NOT_CREATABLE;
     }
     }
     else if (req.isForbidden) {
     else if (req.isForbidden) {
       view = 'layout-growi/forbidden';
       view = 'layout-growi/forbidden';
+      action = SupportedAction.ACTION_PAGE_FORBIDDEN;
     }
     }
     else {
     else {
       view = 'layout-growi/not_found';
       view = 'layout-growi/not_found';
+      action = SupportedAction.ACTION_PAGE_NOT_FOUND;
 
 
       // retrieve templates
       // retrieve templates
       if (req.user != null) {
       if (req.user != null) {
         const template = await Page.findTemplate(path);
         const template = await Page.findTemplate(path);
+
         if (template.templateBody) {
         if (template.templateBody) {
           const body = replacePlaceholdersOfTemplate(template.templateBody, req);
           const body = replacePlaceholdersOfTemplate(template.templateBody, req);
           const tags = template.templateTags;
           const tags = template.templateTags;
@@ -338,6 +347,18 @@ module.exports = function(crowi, app) {
     await addRenderVarsForPageTree(renderVars, pathOrId, req.user);
     await addRenderVarsForPageTree(renderVars, pathOrId, req.user);
     await addRenderVarsWhenNotFound(renderVars, pathOrId);
     await addRenderVarsWhenNotFound(renderVars, pathOrId);
     await addRenderVarsWhenEmptyPage(renderVars, req.isEmpty, req.pageId);
     await addRenderVarsWhenEmptyPage(renderVars, req.isEmpty, req.pageId);
+
+    const parameters = {
+      ip:  req.ip,
+      endpoint: req.originalUrl,
+      action,
+      user: req.user?._id,
+      snapshot: {
+        username: req.user?.username,
+      },
+    };
+    crowi.activityService.createActivity(parameters);
+
     return res.render(view, renderVars);
     return res.render(view, renderVars);
   }
   }
 
 
@@ -405,6 +426,17 @@ module.exports = function(crowi, app) {
 
 
     await addRenderVarsForPageTree(renderVars, portalPath, req.user);
     await addRenderVarsForPageTree(renderVars, portalPath, req.user);
 
 
+    const parameters = {
+      ip:  req.ip,
+      endpoint: req.originalUrl,
+      action: SupportedAction.ACTION_PAGE_VIEW,
+      user: req.user?._id,
+      snapshot: {
+        username: req.user?.username,
+      },
+    };
+    crowi.activityService.createActivity(parameters);
+
     return res.render(view, renderVars);
     return res.render(view, renderVars);
   }
   }
 
 
@@ -463,6 +495,17 @@ module.exports = function(crowi, app) {
 
 
     await addRenderVarsForPageTree(renderVars, path, req.user);
     await addRenderVarsForPageTree(renderVars, path, req.user);
 
 
+    const parameters = {
+      ip:  req.ip,
+      endpoint: req.originalUrl,
+      action: isUsersHomePage(path) ? SupportedAction.ACTION_PAGE_USER_HOME_VIEW : SupportedAction.ACTION_PAGE_VIEW,
+      user: req.user?._id,
+      snapshot: {
+        username: req.user?.username,
+      },
+    };
+    crowi.activityService.createActivity(parameters);
+
     return res.render(view, renderVars);
     return res.render(view, renderVars);
   }
   }
 
 
@@ -496,13 +539,30 @@ module.exports = function(crowi, app) {
     const revisionId = req.query.revision;
     const revisionId = req.query.revision;
     const renderVars = {};
     const renderVars = {};
 
 
+    const parameters = {
+      ip:  req.ip,
+      endpoint: req.originalUrl,
+      user: req.user?._id,
+      snapshot: {
+        username: req.user?.username,
+      },
+    };
+
     const shareLink = await ShareLink.findOne({ _id: linkId }).populate('relatedPage');
     const shareLink = await ShareLink.findOne({ _id: linkId }).populate('relatedPage');
 
 
     if (shareLink == null || shareLink.relatedPage == null || shareLink.relatedPage.isEmpty) {
     if (shareLink == null || shareLink.relatedPage == null || shareLink.relatedPage.isEmpty) {
+
+      Object.assign(parameters, { action: SupportedAction.ACTION_SHARE_LINK_NOT_FOUND });
+      crowi.activityService.createActivity(parameters);
+
       // page or sharelink are not found (or page is empty: abnormaly)
       // page or sharelink are not found (or page is empty: abnormaly)
       return res.render('layout-growi/not_found_shared_page');
       return res.render('layout-growi/not_found_shared_page');
     }
     }
     if (crowi.configManager.getConfig('crowi', 'security:disableLinkSharing')) {
     if (crowi.configManager.getConfig('crowi', 'security:disableLinkSharing')) {
+
+      Object.assign(parameters, { action: SupportedAction.ACTION_SHARE_LINK_NOT_FOUND });
+      crowi.activityService.createActivity(parameters);
+
       return res.render('layout-growi/forbidden');
       return res.render('layout-growi/forbidden');
     }
     }
 
 
@@ -510,6 +570,9 @@ module.exports = function(crowi, app) {
 
 
     // check if share link is expired
     // check if share link is expired
     if (shareLink.isExpired()) {
     if (shareLink.isExpired()) {
+      Object.assign(parameters, { action: SupportedAction.ACTION_SHARE_LINK_EXPIRED_PAGE_VIEW });
+      crowi.activityService.createActivity(parameters);
+
       // page is not found
       // page is not found
       return res.render('layout-growi/expired_shared_page', renderVars);
       return res.render('layout-growi/expired_shared_page', renderVars);
     }
     }
@@ -532,6 +595,9 @@ module.exports = function(crowi, app) {
     addRenderVarsForPage(renderVars, page);
     addRenderVarsForPage(renderVars, page);
     addRenderVarsForScope(renderVars, page);
     addRenderVarsForScope(renderVars, page);
 
 
+    Object.assign(parameters, { action: SupportedAction.ACTION_SHARE_LINK_PAGE_VIEW });
+    crowi.activityService.createActivity(parameters);
+
     return res.render('layout-growi/shared_page', renderVars);
     return res.render('layout-growi/shared_page', renderVars);
   };
   };
 
 
@@ -643,6 +709,16 @@ module.exports = function(crowi, app) {
   actions.redirector = async function(req, res, next) {
   actions.redirector = async function(req, res, next) {
     const path = getPathFromRequest(req);
     const path = getPathFromRequest(req);
 
 
+    const parameters = {
+      ip:  req.ip,
+      endpoint: req.originalUrl,
+      action: SupportedAction.ACTION_PAGE_VIEW,
+      user: req.user?._id,
+      snapshot: {
+        username: req.user?.username,
+      },
+    };
+    crowi.activityService.createActivity(parameters);
     return redirector(req, res, next, path);
     return redirector(req, res, next, path);
   };
   };
 
 
@@ -650,6 +726,17 @@ module.exports = function(crowi, app) {
     const _path = getPathFromRequest(req);
     const _path = getPathFromRequest(req);
     const path = pathUtils.removeTrailingSlash(_path);
     const path = pathUtils.removeTrailingSlash(_path);
 
 
+    const parameters = {
+      ip:  req.ip,
+      endpoint: req.originalUrl,
+      action: SupportedAction.ACTION_PAGE_VIEW,
+      user: req.user?._id,
+      snapshot: {
+        username: req.user?.username,
+      },
+    };
+    crowi.activityService.createActivity(parameters);
+
     return redirector(req, res, next, path);
     return redirector(req, res, next, path);
   };
   };
 
 
@@ -984,6 +1071,13 @@ module.exports = function(crowi, app) {
         logger.error('Create user notification failed', err);
         logger.error('Create user notification failed', err);
       }
       }
     }
     }
+
+    const parameters = {
+      targetModel: SupportedTargetModel.MODEL_PAGE,
+      target: page,
+      action: SupportedAction.ACTION_PAGE_UPDATE,
+    };
+    activityEvent.emit('update', res.locals.activity._id, parameters, page);
   };
   };
 
 
   /**
   /**
@@ -1230,6 +1324,13 @@ module.exports = function(crowi, app) {
     result.isRecursively = isRecursively;
     result.isRecursively = isRecursively;
     result.isCompletely = isCompletely;
     result.isCompletely = isCompletely;
 
 
+    const parameters = {
+      targetModel: SupportedTargetModel.MODEL_PAGE,
+      target: page,
+      action: isCompletely ? SupportedAction.ACTION_PAGE_DELETE_COMPLETELY : SupportedAction.ACTION_PAGE_DELETE,
+    };
+    activityEvent.emit('update', res.locals.activity._id, parameters, page);
+
     res.json(ApiResponse.success(result));
     res.json(ApiResponse.success(result));
 
 
     try {
     try {
@@ -1281,6 +1382,13 @@ module.exports = function(crowi, app) {
     const result = {};
     const result = {};
     result.page = page; // TODO consider to use serializePageSecurely method -- 2018.08.06 Yuki Takei
     result.page = page; // TODO consider to use serializePageSecurely method -- 2018.08.06 Yuki Takei
 
 
+    const parameters = {
+      targetModel: SupportedTargetModel.MODEL_PAGE,
+      target: page,
+      action: SupportedAction.ACTION_PAGE_REVERT,
+    };
+    activityEvent.emit('update', res.locals.activity._id, parameters, page);
+
     return res.json(ApiResponse.success(result));
     return res.json(ApiResponse.success(result));
   };
   };
 
 

+ 27 - 1
packages/app/src/server/routes/search.ts

@@ -1,6 +1,9 @@
+import { SupportedAction } from '~/interfaces/activity';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
+
 import { isSearchError } from '../models/vo/search-error';
 import { isSearchError } from '../models/vo/search-error';
 
 
+
 const logger = loggerFactory('growi:routes:search');
 const logger = loggerFactory('growi:routes:search');
 
 
 /**
 /**
@@ -37,9 +40,20 @@ module.exports = function(crowi, app) {
   const actions: any = {};
   const actions: any = {};
   const api: any = {};
   const api: any = {};
 
 
-  actions.searchPage = function(req, res) {
+  actions.searchPage = async function(req, res) {
     const keyword = req.query.q || null;
     const keyword = req.query.q || null;
 
 
+    const parameters = {
+      ip:  req.ip,
+      endpoint: req.originalUrl,
+      action: SupportedAction.ACTION_SEARCH_PAGE_VIEW,
+      user: req.user?._id,
+      snapshot: {
+        username: req.user?.username,
+      },
+    };
+    await crowi.activityService.createActivity(parameters);
+
     return res.render('search', {
     return res.render('search', {
       q: keyword,
       q: keyword,
     });
     });
@@ -168,6 +182,18 @@ module.exports = function(crowi, app) {
       logger.error(err);
       logger.error(err);
       return res.json(ApiResponse.error(err));
       return res.json(ApiResponse.error(err));
     }
     }
+
+    const parameters = {
+      ip:  req.ip,
+      endpoint: req.originalUrl,
+      action: SupportedAction.ACTION_SEARCH_PAGE,
+      user: req.user?._id,
+      snapshot: {
+        username: req.user?.username,
+      },
+    };
+    await crowi.activityService.createActivity(parameters);
+
     return res.json(ApiResponse.success(result));
     return res.json(ApiResponse.success(result));
   };
   };
 
 

+ 5 - 0
packages/app/src/server/routes/tag.js

@@ -1,3 +1,4 @@
+import { SupportedAction } from '~/interfaces/activity';
 import Tag from '~/server/models/tag';
 import Tag from '~/server/models/tag';
 
 
 /**
 /**
@@ -32,6 +33,7 @@ import Tag from '~/server/models/tag';
 module.exports = function(crowi, app) {
 module.exports = function(crowi, app) {
 
 
   const PageTagRelation = crowi.model('PageTagRelation');
   const PageTagRelation = crowi.model('PageTagRelation');
+  const activityEvent = crowi.event('activity');
   const ApiResponse = require('../util/apiResponse');
   const ApiResponse = require('../util/apiResponse');
   const actions = {};
   const actions = {};
   const api = {};
   const api = {};
@@ -166,6 +168,9 @@ module.exports = function(crowi, app) {
     catch (err) {
     catch (err) {
       return res.json(ApiResponse.error(err));
       return res.json(ApiResponse.error(err));
     }
     }
+
+    activityEvent.emit('update', res.locals.activity._id, { action: SupportedAction.ACTION_TAG_UPDATE });
+
     return res.json(ApiResponse.success(result));
     return res.json(ApiResponse.success(result));
   };
   };
 
 

+ 127 - 16
packages/app/src/server/service/activity.ts

@@ -1,36 +1,147 @@
-import { getModelSafely } from '@growi/core';
+import mongoose from 'mongoose';
+
+import {
+  IActivity, SupportedAction, SupportedActionType, AllSupportedActions, ActionGroupSize,
+  AllEssentialActions, AllSmallGroupActions, AllMediumGroupActions, AllLargeGroupActions,
+} from '~/interfaces/activity';
+import { IPage } from '~/interfaces/page';
+import Activity from '~/server/models/activity';
+
+import loggerFactory from '../../utils/logger';
 import Crowi from '../crowi';
 import Crowi from '../crowi';
 
 
 
 
+const logger = loggerFactory('growi:service:ActivityService');
+
+const parseActionString = (actionsString: string): SupportedActionType[] => {
+  if (actionsString == null) {
+    return [];
+  }
+
+  const actions = actionsString.split(',').map(value => value.trim());
+  return actions.filter(action => (AllSupportedActions as string[]).includes(action)) as SupportedActionType[];
+};
+
 class ActivityService {
 class ActivityService {
 
 
   crowi!: Crowi;
   crowi!: Crowi;
 
 
-  inAppNotificationService!: any;
+  activityEvent: any;
 
 
   constructor(crowi: Crowi) {
   constructor(crowi: Crowi) {
     this.crowi = crowi;
     this.crowi = crowi;
-    this.inAppNotificationService = crowi.inAppNotificationService;
+    this.activityEvent = crowi.event('activity');
+
+    this.getAvailableActions = this.getAvailableActions.bind(this);
+    this.shoudUpdateActivity = this.shoudUpdateActivity.bind(this);
+
+    this.initActivityEventListeners();
+  }
+
+  initActivityEventListeners(): void {
+    this.activityEvent.on('update', async(activityId: string, parameters, target?: IPage) => {
+      let activity: IActivity;
+      const shoudUpdate = this.shoudUpdateActivity(parameters.action);
+
+      if (shoudUpdate) {
+        try {
+          activity = await Activity.updateByParameters(activityId, parameters);
+        }
+        catch (err) {
+          logger.error('Update activity failed', err);
+          return;
+        }
+
+        this.activityEvent.emit('updated', activity, target);
+      }
+    });
   }
   }
 
 
+  getAvailableActions = function(isIncludeEssentialActions = true): SupportedActionType[] {
+    const auditLogEnabled = this.crowi.configManager.getConfig('crowi', 'app:auditLogEnabled') || false;
+    const auditLogActionGroupSize = this.crowi.configManager.getConfig('crowi', 'app:auditLogActionGroupSize') || ActionGroupSize.Small;
+    const auditLogAdditionalActions = this.crowi.configManager.getConfig('crowi', 'app:auditLogAdditionalActions');
+    const auditLogExcludeActions = this.crowi.configManager.getConfig('crowi', 'app:auditLogExcludeActions');
+
+    if (!auditLogEnabled) {
+      return AllEssentialActions;
+    }
 
 
-  /**
-     * @param {object} parameters
-     * @return {Promise}
-     */
-  createByParameters = function(parameters) {
-    const Activity = getModelSafely('Activity') || require('../models/activity')(this.crowi);
+    const availableActionsSet = new Set<SupportedActionType>();
 
 
-    return Activity.create(parameters);
+    // Set base action group
+    switch (auditLogActionGroupSize) {
+      case ActionGroupSize.Small:
+        AllSmallGroupActions.forEach(action => availableActionsSet.add(action));
+        break;
+      case ActionGroupSize.Medium:
+        AllMediumGroupActions.forEach(action => availableActionsSet.add(action));
+        break;
+      case ActionGroupSize.Large:
+        AllLargeGroupActions.forEach(action => availableActionsSet.add(action));
+        break;
+    }
+
+    // Add additionalActions
+    const additionalActions = parseActionString(auditLogAdditionalActions);
+    additionalActions.forEach(action => availableActionsSet.add(action));
+
+    // Delete excludeActions
+    const excludeActions = parseActionString(auditLogExcludeActions);
+    excludeActions.forEach(action => availableActionsSet.delete(action));
+
+    // Add essentialActions
+    if (isIncludeEssentialActions) {
+      AllEssentialActions.forEach(action => availableActionsSet.add(action));
+    }
+
+    return Array.from(availableActionsSet);
+  }
+
+  shoudUpdateActivity = function(action: SupportedActionType): boolean {
+    return this.getAvailableActions().includes(action);
+  }
+
+  // for GET request
+  createActivity = async function(parameters): Promise<void> {
+    const shoudCreateActivity = this.crowi.activityService.shoudUpdateActivity(parameters.action);
+    if (shoudCreateActivity) {
+      try {
+        await Activity.createByParameters(parameters);
+      }
+      catch (err) {
+        logger.error('Create activity failed', err);
+      }
+    }
   };
   };
 
 
+  createTtlIndex = async function() {
+    const configManager = this.crowi.configManager;
+    const activityExpirationSeconds = configManager != null ? configManager.getConfig('crowi', 'app:activityExpirationSeconds') : 2592000;
+    const collection = mongoose.connection.collection('activities');
+
+    try {
+      const targetField = 'createdAt_1';
+
+      const indexes = await collection.indexes();
+      const foundCreatedAt = indexes.find(i => i.name === targetField);
+
+      const isNotSpec = foundCreatedAt?.expireAfterSeconds == null || foundCreatedAt?.expireAfterSeconds !== activityExpirationSeconds;
+      const shoudDropIndex = foundCreatedAt != null && isNotSpec;
+      const shoudCreateIndex = foundCreatedAt == null || shoudDropIndex;
+
+      if (shoudDropIndex) {
+        await collection.dropIndex(targetField);
+      }
 
 
-  /**
-   * @param {User} user
-   * @return {Promise}
-   */
-  findByUser = function(user) {
-    return this.find({ user }).sort({ createdAt: -1 }).exec();
+      if (shoudCreateIndex) {
+        await collection.createIndex({ createdAt: 1 }, { expireAfterSeconds: activityExpirationSeconds });
+      }
+    }
+    catch (err) {
+      logger.error('Failed to create TTL Index', err);
+      throw err;
+    }
   };
   };
 
 
 }
 }

+ 4 - 45
packages/app/src/server/service/comment.ts

@@ -1,9 +1,6 @@
 import { getModelSafely } from '@growi/core';
 import { getModelSafely } from '@growi/core';
 import { Types } from 'mongoose';
 import { Types } from 'mongoose';
 
 
-import { SUPPORTED_TARGET_MODEL_TYPE, SUPPORTED_EVENT_MODEL_TYPE, SUPPORTED_ACTION_TYPE } from '~/interfaces/activity';
-import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
-
 import loggerFactory from '../../utils/logger';
 import loggerFactory from '../../utils/logger';
 import Crowi from '../crowi';
 import Crowi from '../crowi';
 
 
@@ -40,15 +37,6 @@ class CommentService {
       try {
       try {
         const Page = getModelSafely('Page') || require('../models/page')(this.crowi);
         const Page = getModelSafely('Page') || require('../models/page')(this.crowi);
         await Page.updateCommentCount(savedComment.page);
         await Page.updateCommentCount(savedComment.page);
-
-        const page = await Page.findById(savedComment.page);
-        if (page == null) {
-          logger.error('Page is not found');
-          return;
-        }
-
-        const activity = await this.createActivity(savedComment, SUPPORTED_ACTION_TYPE.ACTION_COMMENT_CREATE);
-        await this.createAndSendNotifications(activity, page);
       }
       }
       catch (err) {
       catch (err) {
         logger.error('Error occurred while handling the comment create event:\n', err);
         logger.error('Error occurred while handling the comment create event:\n', err);
@@ -57,10 +45,9 @@ class CommentService {
     });
     });
 
 
     // update
     // update
-    this.commentEvent.on('update', async(updatedComment) => {
+    this.commentEvent.on('update', async() => {
       try {
       try {
         this.commentEvent.onUpdate();
         this.commentEvent.onUpdate();
-        await this.createActivity(updatedComment, SUPPORTED_ACTION_TYPE.ACTION_COMMENT_UPDATE);
       }
       }
       catch (err) {
       catch (err) {
         logger.error('Error occurred while handling the comment update event:\n', err);
         logger.error('Error occurred while handling the comment update event:\n', err);
@@ -68,12 +55,12 @@ class CommentService {
     });
     });
 
 
     // remove
     // remove
-    this.commentEvent.on('remove', async(comment) => {
-      this.commentEvent.onRemove();
+    this.commentEvent.on('delete', async(removedComment) => {
+      this.commentEvent.onDelete();
 
 
       try {
       try {
         const Page = getModelSafely('Page') || require('../models/page')(this.crowi);
         const Page = getModelSafely('Page') || require('../models/page')(this.crowi);
-        await Page.updateCommentCount(comment.page);
+        await Page.updateCommentCount(removedComment.page);
       }
       }
       catch (err) {
       catch (err) {
         logger.error('Error occurred while updating the comment count:\n', err);
         logger.error('Error occurred while updating the comment count:\n', err);
@@ -81,34 +68,6 @@ class CommentService {
     });
     });
   }
   }
 
 
-  private createActivity = async function(comment, action) {
-    const parameters = {
-      user: comment.creator,
-      targetModel: SUPPORTED_TARGET_MODEL_TYPE.MODEL_PAGE,
-      target: comment.page,
-      eventModel: SUPPORTED_EVENT_MODEL_TYPE.MODEL_COMMENT,
-      event: comment._id,
-      action,
-    };
-    const activity = await this.activityService.createByParameters(parameters);
-    return activity;
-  };
-
-  private createAndSendNotifications = async function(activity, page) {
-    const snapshot = stringifySnapshot(page);
-
-    // Get user to be notified
-    let targetUsers: Types.ObjectId[] = [];
-    targetUsers = await activity.getNotificationTargetUsers();
-
-    // Add mentioned users to targetUsers
-    const mentionedUsers = await this.getMentionedUsers(activity.event);
-    targetUsers = targetUsers.concat(mentionedUsers);
-
-    await this.inAppNotificationService.upsertByActivity(targetUsers, activity, snapshot);
-    await this.inAppNotificationService.emitSocketIo(targetUsers);
-  };
-
   getMentionedUsers = async(commentId: Types.ObjectId): Promise<Types.ObjectId[]> => {
   getMentionedUsers = async(commentId: Types.ObjectId): Promise<Types.ObjectId[]> => {
     const Comment = getModelSafely('Comment') || require('../models/comment')(this.crowi);
     const Comment = getModelSafely('Comment') || require('../models/comment')(this.crowi);
     const User = getModelSafely('User') || require('../models/user')(this.crowi);
     const User = getModelSafely('User') || require('../models/user')(this.crowi);

+ 30 - 0
packages/app/src/server/service/config-loader.ts

@@ -622,6 +622,36 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type: ValueType.NUMBER,
     type: ValueType.NUMBER,
     default: 8,
     default: 8,
   },
   },
+  AUDIT_LOG_ENABLED: {
+    ns: 'crowi',
+    key: 'app:auditLogEnabled',
+    type: ValueType.BOOLEAN,
+    default: false,
+  },
+  ACTIVITY_EXPIRATION_SECONDS: {
+    ns: 'crowi',
+    key: 'app:activityExpirationSeconds',
+    type: ValueType.NUMBER,
+    default: 2592000, // 30 days
+  },
+  AUDIT_LOG_ACTION_GROUP_SIZE: {
+    ns: 'crowi',
+    key: 'app:auditLogActionGroupSize',
+    type: ValueType.STRING,
+    default: 'SMALL',
+  },
+  AUDIT_LOG_ADDITIONAL_ACTIONS: {
+    ns: 'crowi',
+    key: 'app:auditLogAdditionalActions',
+    type: ValueType.STRING,
+    default: null,
+  },
+  AUDIT_LOG_EXCLUDE_ACTIONS: {
+    ns: 'crowi',
+    key: 'app:auditLogExcludeActions',
+    type: ValueType.STRING,
+    default: null,
+  },
 };
 };
 
 
 
 

+ 51 - 9
packages/app/src/server/service/in-app-notification.ts

@@ -1,22 +1,25 @@
-import { Types } from 'mongoose';
 import { subDays } from 'date-fns';
 import { subDays } from 'date-fns';
+import { Types } from 'mongoose';
+
+import { AllEssentialActions, SupportedAction } from '~/interfaces/activity';
+import { HasObjectId } from '~/interfaces/has-object-id';
 import { InAppNotificationStatuses, PaginateResult } from '~/interfaces/in-app-notification';
 import { InAppNotificationStatuses, PaginateResult } from '~/interfaces/in-app-notification';
-import Crowi from '../crowi';
+import { IPage } from '~/interfaces/page';
+import { SubscriptionStatusType } from '~/interfaces/subscription';
+import { IUser } from '~/interfaces/user';
+import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
+import { ActivityDocument } from '~/server/models/activity';
 import {
 import {
   InAppNotification,
   InAppNotification,
   InAppNotificationDocument,
   InAppNotificationDocument,
 } from '~/server/models/in-app-notification';
 } from '~/server/models/in-app-notification';
-
-import { ActivityDocument } from '~/server/models/activity';
 import InAppNotificationSettings from '~/server/models/in-app-notification-settings';
 import InAppNotificationSettings from '~/server/models/in-app-notification-settings';
 import Subscription from '~/server/models/subscription';
 import Subscription from '~/server/models/subscription';
-
-import { IUser } from '~/interfaces/user';
-
-import { HasObjectId } from '~/interfaces/has-object-id';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
+
+import Crowi from '../crowi';
 import { RoomPrefix, getRoomNameWithId } from '../util/socket-io-helpers';
 import { RoomPrefix, getRoomNameWithId } from '../util/socket-io-helpers';
-import { SubscriptionStatusType } from '~/interfaces/subscription';
+
 
 
 const { STATUS_UNREAD, STATUS_UNOPENED, STATUS_OPENED } = InAppNotificationStatuses;
 const { STATUS_UNREAD, STATUS_UNOPENED, STATUS_OPENED } = InAppNotificationStatuses;
 
 
@@ -29,16 +32,37 @@ export default class InAppNotificationService {
 
 
   socketIoService!: any;
   socketIoService!: any;
 
 
+  activityEvent!: any;
+
   commentEvent!: any;
   commentEvent!: any;
 
 
 
 
   constructor(crowi: Crowi) {
   constructor(crowi: Crowi) {
     this.crowi = crowi;
     this.crowi = crowi;
+    this.activityEvent = crowi.event('activity');
     this.socketIoService = crowi.socketIoService;
     this.socketIoService = crowi.socketIoService;
 
 
+    this.emitSocketIo = this.emitSocketIo.bind(this);
+    this.upsertByActivity = this.upsertByActivity.bind(this);
     this.getUnreadCountByUser = this.getUnreadCountByUser.bind(this);
     this.getUnreadCountByUser = this.getUnreadCountByUser.bind(this);
+    this.createInAppNotification = this.createInAppNotification.bind(this);
+
+    this.initActivityEventListeners();
   }
   }
 
 
+  initActivityEventListeners(): void {
+    this.activityEvent.on('updated', async(activity: ActivityDocument, target: IPage) => {
+      try {
+        const shouldNotification = activity != null && target != null && (AllEssentialActions as ReadonlyArray<string>).includes(activity.action);
+        if (shouldNotification) {
+          await this.createInAppNotification(activity, target);
+        }
+      }
+      catch (err) {
+        logger.error('Create InAppNotification failed', err);
+      }
+    });
+  }
 
 
   emitSocketIo = async(targetUsers) => {
   emitSocketIo = async(targetUsers) => {
     if (this.socketIoService.isInitialized) {
     if (this.socketIoService.isInitialized) {
@@ -175,6 +199,24 @@ export default class InAppNotificationService {
     return;
     return;
   };
   };
 
 
+  createInAppNotification = async function(activity: ActivityDocument, target: IPage): Promise<void> {
+    const shouldNotification = activity != null && target != null && (AllEssentialActions as ReadonlyArray<string>).includes(activity.action);
+    if (shouldNotification) {
+      let mentionedUsers: IUser[] = [];
+      if (activity.action === SupportedAction.ACTION_COMMENT_CREATE) {
+        mentionedUsers = await this.crowi.commentService.getMentionedUsers(activity.event);
+      }
+      const notificationTargetUsers = await activity?.getNotificationTargetUsers();
+      const snapshot = stringifySnapshot(target as IPage);
+      await this.upsertByActivity([...notificationTargetUsers, ...mentionedUsers], activity, snapshot);
+      await this.emitSocketIo(notificationTargetUsers);
+    }
+    else {
+      throw Error('No activity to notify');
+    }
+    return;
+  };
+
 }
 }
 
 
 module.exports = InAppNotificationService;
 module.exports = InAppNotificationService;

+ 2 - 109
packages/app/src/server/service/page.ts

@@ -6,7 +6,6 @@ import escapeStringRegexp from 'escape-string-regexp';
 import mongoose, { ObjectId, QueryCursor } from 'mongoose';
 import mongoose, { ObjectId, QueryCursor } from 'mongoose';
 import streamToPromise from 'stream-to-promise';
 import streamToPromise from 'stream-to-promise';
 
 
-import { SUPPORTED_TARGET_MODEL_TYPE, SUPPORTED_ACTION_TYPE } from '~/interfaces/activity';
 import { Ref } from '~/interfaces/common';
 import { Ref } from '~/interfaces/common';
 import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 import { HasObjectId } from '~/interfaces/has-object-id';
 import { HasObjectId } from '~/interfaces/has-object-id';
@@ -19,7 +18,6 @@ import {
 import { IPageOperationProcessInfo, IPageOperationProcessData } from '~/interfaces/page-operation';
 import { IPageOperationProcessInfo, IPageOperationProcessData } from '~/interfaces/page-operation';
 import { IUserHasId } from '~/interfaces/user';
 import { IUserHasId } from '~/interfaces/user';
 import { PageMigrationErrorData, SocketEventName, UpdateDescCountRawData } from '~/interfaces/websocket';
 import { PageMigrationErrorData, SocketEventName, UpdateDescCountRawData } from '~/interfaces/websocket';
-import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
 import {
 import {
   CreateMethod, PageCreateOptions, PageModel, PageDocument, pushRevision, PageQueryBuilder,
   CreateMethod, PageCreateOptions, PageModel, PageDocument, pushRevision, PageQueryBuilder,
 } from '~/server/models/page';
 } from '~/server/models/page';
@@ -153,89 +151,6 @@ class PageService {
     // createMany
     // createMany
     this.pageEvent.on('createMany', this.pageEvent.onCreateMany);
     this.pageEvent.on('createMany', this.pageEvent.onCreateMany);
     this.pageEvent.on('addSeenUsers', this.pageEvent.onAddSeenUsers);
     this.pageEvent.on('addSeenUsers', this.pageEvent.onAddSeenUsers);
-
-    // update
-    this.pageEvent.on('update', async(page, user) => {
-
-      this.pageEvent.onUpdate();
-
-      try {
-        await this.createAndSendNotifications(page, user, SUPPORTED_ACTION_TYPE.ACTION_PAGE_UPDATE);
-      }
-      catch (err) {
-        logger.error(err);
-      }
-    });
-
-    // rename
-    this.pageEvent.on('rename', async(page, user) => {
-      try {
-        await this.createAndSendNotifications(page, user, SUPPORTED_ACTION_TYPE.ACTION_PAGE_RENAME);
-      }
-      catch (err) {
-        logger.error(err);
-      }
-    });
-
-    // duplicate
-    this.pageEvent.on('duplicate', async(page, user) => {
-      try {
-        await this.createAndSendNotifications(page, user, SUPPORTED_ACTION_TYPE.ACTION_PAGE_DUPLICATE);
-      }
-      catch (err) {
-        logger.error(err);
-      }
-    });
-
-    // delete
-    this.pageEvent.on('delete', async(page, user) => {
-      try {
-        await this.createAndSendNotifications(page, user, SUPPORTED_ACTION_TYPE.ACTION_PAGE_DELETE);
-      }
-      catch (err) {
-        logger.error(err);
-      }
-    });
-
-    // delete completely
-    this.pageEvent.on('deleteCompletely', async(page, user) => {
-      try {
-        await this.createAndSendNotifications(page, user, SUPPORTED_ACTION_TYPE.ACTION_PAGE_DELETE_COMPLETELY);
-      }
-      catch (err) {
-        logger.error(err);
-      }
-    });
-
-    // revert
-    this.pageEvent.on('revert', async(page, user) => {
-      try {
-        await this.createAndSendNotifications(page, user, SUPPORTED_ACTION_TYPE.ACTION_PAGE_REVERT);
-      }
-      catch (err) {
-        logger.error(err);
-      }
-    });
-
-    // likes
-    this.pageEvent.on('like', async(page, user) => {
-      try {
-        await this.createAndSendNotifications(page, user, SUPPORTED_ACTION_TYPE.ACTION_PAGE_LIKE);
-      }
-      catch (err) {
-        logger.error(err);
-      }
-    });
-
-    // bookmark
-    this.pageEvent.on('bookmark', async(page, user) => {
-      try {
-        await this.createAndSendNotifications(page, user, SUPPORTED_ACTION_TYPE.ACTION_PAGE_BOOKMARK);
-      }
-      catch (err) {
-        logger.error(err);
-      }
-    });
   }
   }
 
 
   canDeleteCompletely(path: string, creatorId: ObjectIdLike, operator: any | null, isRecursively: boolean): boolean {
   canDeleteCompletely(path: string, creatorId: ObjectIdLike, operator: any | null, isRecursively: boolean): boolean {
@@ -563,7 +478,7 @@ class PageService {
       const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
       const PageRedirect = mongoose.model('PageRedirect') as unknown as PageRedirectModel;
       await PageRedirect.create({ fromPath: page.path, toPath: newPagePath });
       await PageRedirect.create({ fromPath: page.path, toPath: newPagePath });
     }
     }
-    this.pageEvent.emit('rename', page, user);
+    this.pageEvent.emit('rename');
 
 
     // Set to Sub
     // Set to Sub
     const pageOp = await PageOperation.findByIdAndUpdatePageActionStage(pageOpId, PageActionStage.Sub);
     const pageOp = await PageOperation.findByIdAndUpdatePageActionStage(pageOpId, PageActionStage.Sub);
@@ -743,7 +658,7 @@ class PageService {
       await PageRedirect.create({ fromPath: page.path, toPath: newPagePath });
       await PageRedirect.create({ fromPath: page.path, toPath: newPagePath });
     }
     }
 
 
-    this.pageEvent.emit('rename', page, user);
+    this.pageEvent.emit('rename');
 
 
     return renamedPage;
     return renamedPage;
   }
   }
@@ -2283,28 +2198,6 @@ class PageService {
     return shortBodiesMap;
     return shortBodiesMap;
   }
   }
 
 
-  private async createAndSendNotifications(page, user, action) {
-    const { activityService, inAppNotificationService } = this.crowi;
-
-    const snapshot = stringifySnapshot(page);
-
-    // Create activity
-    const parameters = {
-      user: user._id,
-      targetModel: SUPPORTED_TARGET_MODEL_TYPE.MODEL_PAGE,
-      target: page,
-      action,
-    };
-    const activity = await activityService.createByParameters(parameters);
-
-    // Get user to be notified
-    const targetUsers = await activity.getNotificationTargetUsers();
-
-    // Create and send notifications
-    await inAppNotificationService.upsertByActivity(targetUsers, activity, snapshot);
-    await inAppNotificationService.emitSocketIo(targetUsers);
-  }
-
   async normalizeParentByPath(path: string, user): Promise<void> {
   async normalizeParentByPath(path: string, user): Promise<void> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
     const { PageQueryBuilder } = Page;
     const { PageQueryBuilder } = Page;

+ 84 - 0
packages/app/src/server/util/rate-limiter.ts

@@ -0,0 +1,84 @@
+import {
+  defaultConfig, defaultConfigWithRegExp, IApiRateLimitEndpointMap,
+} from '^/config/rate-limiter';
+
+const envVar = process.env;
+
+// https://regex101.com/r/aNDjmI/1
+const regExp = /^API_RATE_LIMIT_(\w+)_ENDPOINT(_WITH_REGEXP)?$/;
+
+const generateApiRateLimitConfigFromEndpoint = (envVar: NodeJS.ProcessEnv, targets: string[], withRegExp: boolean): IApiRateLimitEndpointMap => {
+  const apiRateLimitConfig: IApiRateLimitEndpointMap = {};
+  targets.forEach((target) => {
+
+    const endpointKey = withRegExp ? `API_RATE_LIMIT_${target}_ENDPOINT_WITH_REGEXP` : `API_RATE_LIMIT_${target}_ENDPOINT`;
+
+    const endpoint = envVar[endpointKey];
+
+    if (endpoint == null) {
+      return;
+    }
+    const methodKey = `API_RATE_LIMIT_${target}_METHODS`;
+    const maxRequestsKey = `API_RATE_LIMIT_${target}_MAX_REQUESTS`;
+    const method = envVar[methodKey] ?? 'ALL';
+    const maxRequests = Number(envVar[maxRequestsKey]);
+
+    if (endpoint == null || maxRequests == null) {
+      return;
+    }
+
+    const config = {
+      method,
+      maxRequests,
+    };
+
+    apiRateLimitConfig[endpoint] = config;
+  });
+
+  return apiRateLimitConfig;
+};
+
+type ApiRateLimitConfigResult = {
+  'withoutRegExp': IApiRateLimitEndpointMap,
+  'withRegExp': IApiRateLimitEndpointMap
+}
+
+export const generateApiRateLimitConfig = (): ApiRateLimitConfigResult => {
+
+  const apiRateConfigTargets: string[] = [];
+  const apiRateConfigTargetsWithRegExp: string[] = [];
+  Object.keys(envVar).forEach((key) => {
+    const result = key.match(regExp);
+
+    if (result == null) { return null }
+
+    const target = result[1];
+    const isWithRegExp = result[2] != null;
+
+    if (isWithRegExp) {
+      apiRateConfigTargetsWithRegExp.push(target);
+    }
+    else {
+      apiRateConfigTargets.push(target);
+    }
+  });
+
+  // sort priority
+  apiRateConfigTargets.sort();
+  apiRateConfigTargetsWithRegExp.sort();
+
+  // get config
+  const apiRateLimitConfig = generateApiRateLimitConfigFromEndpoint(envVar, apiRateConfigTargets, false);
+  const apiRateLimitConfigWithRegExp = generateApiRateLimitConfigFromEndpoint(envVar, apiRateConfigTargetsWithRegExp, true);
+
+  const config = { ...defaultConfig, ...apiRateLimitConfig };
+  const configWithRegExp = { ...defaultConfigWithRegExp, ...apiRateLimitConfigWithRegExp };
+
+  const result: ApiRateLimitConfigResult = {
+    withoutRegExp: config,
+    withRegExp: configWithRegExp,
+  };
+
+
+  return result;
+};

+ 11 - 0
packages/app/src/server/views/admin/audit-log.html

@@ -0,0 +1,11 @@
+{% extends '../layout/admin.html' %}
+
+{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('AuditLog')) }}{% endblock %}
+
+{% block content_header %}
+<h1 class="title">{{ t('AuditLog') }}</h1>
+{% endblock %}
+
+{% block content_main %}
+<div id ="admin-audit-log"></div>
+{% endblock content_main %}

+ 1 - 1
packages/app/src/server/views/invited.html

@@ -133,7 +133,7 @@ $(function() {
     $('#input-group-username').removeClass('has-error');
     $('#input-group-username').removeClass('has-error');
     $('#help-block-username').html("");
     $('#help-block-username').html("");
 
 
-    $.getJSON('/_api/check_username', {username: username}, function(json) {
+    $.getJSON('/_api/v3/check-username', {username: username}, function(json) {
       if (!json.valid) {
       if (!json.valid) {
         $('#help-block-username').html(
         $('#help-block-username').html(
           '<i class="icon-fw icon-ban"></i>このユーザーIDは利用できません。'
           '<i class="icon-fw icon-ban"></i>このユーザーIDは利用できません。'

+ 1 - 1
packages/app/src/server/views/login.html

@@ -158,7 +158,7 @@
     $('#input-group-username').removeClass('has-error');
     $('#input-group-username').removeClass('has-error');
     $('#help-block-username').html("");
     $('#help-block-username').html("");
 
 
-    $.getJSON('/_api/check_username', {username: username}, function(json) {
+    $.getJSON('/_api/v3/check-username', {username: username}, function(json) {
       if (!json.valid) {
       if (!json.valid) {
         $('#help-block-username').html(
         $('#help-block-username').html(
           '<i class="icon-fw icon-ban"></i> This User ID is not available.'
           '<i class="icon-fw icon-ban"></i> This User ID is not available.'

+ 18 - 0
packages/app/src/stores/activity.ts

@@ -0,0 +1,18 @@
+import { SWRResponse } from 'swr';
+import useSWRImmutable from 'swr/immutable';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+import { IActivityHasId, ISearchFilter } from '~/interfaces/activity';
+import { PaginateResult } from '~/interfaces/mongoose-utils';
+import { useAuditLogEnabled } from '~/stores/context';
+
+export const useSWRxActivity = (limit?: number, offset?: number, searchFilter?: ISearchFilter): SWRResponse<PaginateResult<IActivityHasId>, Error> => {
+  const { data: auditLogEnabled } = useAuditLogEnabled();
+
+  const stringifiedSearchFilter = JSON.stringify(searchFilter);
+  return useSWRImmutable(
+    auditLogEnabled ? ['/activity', limit, offset, stringifiedSearchFilter] : null,
+    (endpoint, limit, offset, stringifiedSearchFilter) => apiv3Get(endpoint, { limit, offset, searchFilter: stringifiedSearchFilter })
+      .then(result => result.data.paginationResult),
+  );
+};

+ 16 - 2
packages/app/src/stores/context.tsx

@@ -3,6 +3,8 @@ import { Key, SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
 
 
 
 
+import { SupportedActionType } from '~/interfaces/activity';
+
 import { TargetAndAncestors } from '../interfaces/page-listing-results';
 import { TargetAndAncestors } from '../interfaces/page-listing-results';
 import { IUser } from '../interfaces/user';
 import { IUser } from '../interfaces/user';
 
 
@@ -92,8 +94,8 @@ export const useHasChildren = (initialData?: Nullable<any>): SWRResponse<Nullabl
   return useStaticSWR<Nullable<any>, Error>('hasChildren', initialData);
   return useStaticSWR<Nullable<any>, Error>('hasChildren', initialData);
 };
 };
 
 
-export const useTemplateTagData = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('templateTagData', initialData);
+export const useTemplateTagData = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
+  return useStaticSWR<Nullable<string>, Error>('templateTagData', initialData);
 };
 };
 
 
 export const useIsSharedUser = (initialData?: Nullable<boolean>): SWRResponse<Nullable<boolean>, Error> => {
 export const useIsSharedUser = (initialData?: Nullable<boolean>): SWRResponse<Nullable<boolean>, Error> => {
@@ -172,6 +174,18 @@ export const useDefaultIndentSize = (initialData?: number) : SWRResponse<number,
   return useStaticSWR<number, Error>('defaultIndentSize', initialData, { fallbackData: 4 });
   return useStaticSWR<number, Error>('defaultIndentSize', initialData, { fallbackData: 4 });
 };
 };
 
 
+export const useAuditLogEnabled = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useStaticSWR<boolean, Error>('auditLogEnabled', initialData, { fallbackData: false });
+};
+
+export const useActivityExpirationSeconds = (initialData?: number) : SWRResponse<number, Error> => {
+  return useStaticSWR<number, Error>('activityExpirationSeconds', initialData);
+};
+
+export const useAuditLogAvailableActions = (initialData?: Array<SupportedActionType>) : SWRResponse<Array<SupportedActionType>, Error> => {
+  return useStaticSWR<Array<SupportedActionType>, Error>('auditLogAvailableActions', initialData);
+};
+
 export const useGrowiVersion = (initialData?: string): SWRResponse<string, any> => {
 export const useGrowiVersion = (initialData?: string): SWRResponse<string, any> => {
   return useStaticSWR('growiVersion', initialData);
   return useStaticSWR('growiVersion', initialData);
 };
 };

+ 18 - 8
packages/app/src/stores/page.tsx

@@ -35,14 +35,24 @@ export const useSWRxCurrentPage = (shareLinkId?: string, initialData?: IPageHasI
   return useSWRxPage(currentPageId ?? undefined, shareLinkId, initialData);
   return useSWRxPage(currentPageId ?? undefined, shareLinkId, initialData);
 };
 };
 
 
-export const useSWRxTagsInfo = (pageId: Nullable<string>): SWRResponse<IPageTagsInfo, Error> => {
-  const key = pageId == null ? null : `/pages.getPageTag?pageId=${pageId}`;
-
-  return useSWRImmutable(key, endpoint => apiGet(endpoint).then((response: IPageTagsInfo) => {
-    return {
-      tags: response.tags,
-    };
-  }));
+
+export const useSWRxTagsInfo = (pageId: Nullable<string>): SWRResponse<IPageTagsInfo | undefined, Error> => {
+
+  const endpoint = `/pages.getPageTag?pageId=${pageId}`;
+  const key = [endpoint, pageId];
+
+  const fetcher = async(endpoint: string, pageId: Nullable<string>) => {
+    let tags: string[] = [];
+    // when the page exists
+    if (pageId != null) {
+      const res = await apiGet<IPageTagsInfo>(endpoint, { pageId });
+      tags = res?.tags;
+    }
+
+    return { tags };
+  };
+
+  return useSWRImmutable(key, fetcher);
 };
 };
 
 
 export const useSWRxPageInfo = (
 export const useSWRxPageInfo = (

+ 8 - 7
packages/app/src/stores/ui.tsx

@@ -1,7 +1,5 @@
 import { RefObject } from 'react';
 import { RefObject } from 'react';
 
 
-import { constants } from 'zlib';
-
 import { isClient, isServer, pagePathUtils } from '@growi/core';
 import { isClient, isServer, pagePathUtils } from '@growi/core';
 import { Breakpoint, addBreakpointListener } from '@growi/ui';
 import { Breakpoint, addBreakpointListener } from '@growi/ui';
 import SimpleBar from 'simplebar-react';
 import SimpleBar from 'simplebar-react';
@@ -21,12 +19,12 @@ import loggerFactory from '~/utils/logger';
 
 
 import {
 import {
   useCurrentPageId, useCurrentPagePath, useIsEditable, useIsTrashPage, useIsUserPage, useIsGuestUser,
   useCurrentPageId, useCurrentPagePath, useIsEditable, useIsTrashPage, useIsUserPage, useIsGuestUser,
-  useIsNotCreatable, useIsSharedUser, useIsForbidden, useIsIdenticalPath, useCurrentUser, useIsNotFound,
+  useIsNotCreatable, useIsSharedUser, useIsForbidden, useIsIdenticalPath, useCurrentUser, useIsNotFound, useShareLinkId,
 } from './context';
 } from './context';
 import { localStorageMiddleware } from './middlewares/sync-to-storage';
 import { localStorageMiddleware } from './middlewares/sync-to-storage';
 import { useStaticSWR } from './use-static-swr';
 import { useStaticSWR } from './use-static-swr';
 
 
-const { isSharedPage } = pagePathUtils;
+const { isTrashTopPage } = pagePathUtils;
 
 
 const logger = loggerFactory('growi:stores:ui');
 const logger = loggerFactory('growi:stores:ui');
 
 
@@ -425,18 +423,21 @@ export const useIsAbleToShowPageManagement = (): SWRResponse<boolean, Error> =>
 export const useIsAbleToShowTagLabel = (): SWRResponse<boolean, Error> => {
 export const useIsAbleToShowTagLabel = (): SWRResponse<boolean, Error> => {
   const key = 'isAbleToShowTagLabel';
   const key = 'isAbleToShowTagLabel';
   const { data: isUserPage } = useIsUserPage();
   const { data: isUserPage } = useIsUserPage();
-  const { data: isSharedUser } = useIsSharedUser();
+  const { data: currentPagePath } = useCurrentPagePath();
   const { data: isIdenticalPath } = useIsIdenticalPath();
   const { data: isIdenticalPath } = useIsIdenticalPath();
   const { data: isNotFound } = useIsNotFound();
   const { data: isNotFound } = useIsNotFound();
   const { data: editorMode } = useEditorMode();
   const { data: editorMode } = useEditorMode();
+  const { data: shareLinkId } = useShareLinkId();
 
 
-  const includesUndefined = [isUserPage, isSharedUser, isIdenticalPath, isNotFound, editorMode].some(v => v === undefined);
+  const includesUndefined = [isUserPage, currentPagePath, isIdenticalPath, isNotFound, editorMode].some(v => v === undefined);
 
 
   const isViewMode = editorMode === EditorMode.View;
   const isViewMode = editorMode === EditorMode.View;
 
 
   return useSWRImmutable(
   return useSWRImmutable(
     includesUndefined ? null : [key, editorMode],
     includesUndefined ? null : [key, editorMode],
-    () => !isUserPage && !isSharedUser && !isIdenticalPath && !(isViewMode && isNotFound),
+    // "/trash" page does not exist on page collection and unable to add tags
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    () => !isUserPage && !isTrashTopPage(currentPagePath!) && shareLinkId == null && !isIdenticalPath && !(isViewMode && isNotFound),
   );
   );
 };
 };
 
 

+ 31 - 2
packages/app/src/stores/user.tsx

@@ -1,8 +1,8 @@
 import useSWR, { SWRResponse } from 'swr';
 import useSWR, { SWRResponse } from 'swr';
-import { apiv3Get } from '~/client/util/apiv3-client';
+import useSWRImmutable from 'swr/immutable';
 
 
+import { apiv3Get } from '~/client/util/apiv3-client';
 import { IUserHasId } from '~/interfaces/user';
 import { IUserHasId } from '~/interfaces/user';
-
 import { checkAndUpdateImageUrlCached } from '~/stores/middlewares/user';
 import { checkAndUpdateImageUrlCached } from '~/stores/middlewares/user';
 
 
 export const useSWRxUsersList = (userIds: string[]): SWRResponse<IUserHasId[], Error> => {
 export const useSWRxUsersList = (userIds: string[]): SWRResponse<IUserHasId[], Error> => {
@@ -15,3 +15,32 @@ export const useSWRxUsersList = (userIds: string[]): SWRResponse<IUserHasId[], E
     { use: [checkAndUpdateImageUrlCached] },
     { use: [checkAndUpdateImageUrlCached] },
   );
   );
 };
 };
+
+
+type usernameRequestOptions = {
+  isIncludeActiveUser?: boolean,
+  isIncludeInactiveUser?: boolean,
+  isIncludeActivitySnapshotUser?: boolean,
+  isIncludeMixedUsernames?: boolean,
+}
+
+type userData = {
+  usernames: string[]
+  totalCount: number
+}
+
+type usernameResult = {
+  activeUser?: userData
+  inactiveUser?: userData
+  activitySnapshotUser?: userData
+  mixedUsernames?: string[]
+}
+
+export const useSWRxUsernames = (q: string, offset?: number, limit?: number, options?: usernameRequestOptions): SWRResponse<usernameResult, Error> => {
+  return useSWRImmutable(
+    (q != null && q.trim() !== '') ? ['/users/usernames', q, offset, limit, options] : null,
+    (endpoint, q, offset, limit, options) => apiv3Get(endpoint, {
+      q, offset, limit, options,
+    }).then(result => result.data),
+  );
+};

+ 7 - 0
packages/app/src/styles/_admin.scss

@@ -228,6 +228,13 @@ $slack-work-space-name-card-border: #efc1f6;
   //   }
   //   }
   // }
   // }
 
 
+  .admin-audit-log {
+    .select-action-dropdown {
+      max-height: 500px;
+      overflow-y: auto;
+    }
+  }
+
   #layoutOptions {
   #layoutOptions {
     .customize-layout-card {
     .customize-layout-card {
       border: 4px solid $border-color;
       border: 4px solid $border-color;

+ 1 - 3
packages/app/src/styles/atoms/_buttons.scss

@@ -7,7 +7,6 @@
   &:not(:disabled):not(.disabled):not(:hover) {
   &:not(:disabled):not(.disabled):not(:hover) {
     background-color: transparent;
     background-color: transparent;
   }
   }
-  box-shadow: none !important;
 }
 }
 
 
 .btn.btn-bookmark {
 .btn.btn-bookmark {
@@ -19,7 +18,6 @@
   &:not(:disabled):not(.disabled):not(:hover) {
   &:not(:disabled):not(.disabled):not(:hover) {
     background-color: transparent;
     background-color: transparent;
   }
   }
-  box-shadow: none !important;
 }
 }
 
 
 .btn.btn-subscribe {
 .btn.btn-subscribe {
@@ -31,6 +29,7 @@
   &:not(:disabled):not(.disabled):not(:hover) {
   &:not(:disabled):not(.disabled):not(:hover) {
     background-color: transparent;
     background-color: transparent;
   }
   }
+  width: 44px;
 }
 }
 
 
 .btn.btn-seen-user {
 .btn.btn-seen-user {
@@ -48,7 +47,6 @@
   &:not(:disabled):not(.disabled):not(:hover) {
   &:not(:disabled):not(.disabled):not(:hover) {
     background-color: transparent;
     background-color: transparent;
   }
   }
-  box-shadow: none !important;
 }
 }
 
 
 .btn-copy,
 .btn-copy,

+ 95 - 0
packages/app/test/cypress/integration/20-basic-features/access-to-pagelist.spec.ts

@@ -0,0 +1,95 @@
+context('Access to pagelist', () => {
+  const ssPrefix = 'access-to-pagelist-';
+  beforeEach(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+    // collapse sidebar
+    cy.collapseSidebar(true);
+  });
+
+  it('Page list modal is successfully opened ', () => {
+    cy.visit('/');
+    cy.getByTestid('pageListButton').click({force: true});
+    cy.getByTestid('page-accessories-modal').parent().should('have.class','show');
+    cy.screenshot(`${ssPrefix}1-open-pagelist-modal`);
+  });
+
+  it('Successfully duplicate a page from page list', () => {
+    cy.visit('/');
+    cy.getByTestid('pageListButton').click({force: true});
+    cy.getByTestid('page-accessories-modal').parent().should('have.class','show').within(() => {
+      cy.getByTestid('open-page-item-control-btn').first().click();
+      cy.screenshot(`${ssPrefix}2-click-on-three-dots-menu`);
+      cy.get('.dropdown-menu').should('have.class', 'show').first().within(() => {
+        cy.getByTestid('open-page-duplicate-modal-btn').click();
+      });
+    });
+    cy.getByTestid('page-duplicate-modal').should('be.visible').screenshot(`${ssPrefix}3-duplicate-page-modal-opened`);
+    cy.getByTestid('page-duplicate-modal').should('be.visible').within(() => {
+      cy.get('.rbt-input-main').type('-duplicate', {force: true})
+    }).screenshot(`${ssPrefix}4-input-duplicated-page-name`);
+    cy.getByTestid('page-duplicate-modal').should('be.visible').within(() => {
+      cy.get('.modal-footer > button').click();
+    });
+    cy.get('body').type('{esc}');
+    cy.getByTestid('pageListButton').click({force: true});
+    cy.getByTestid('page-accessories-modal').parent().should('have.class','show').within(() => {
+      cy.get('.list-group-item').eq(0).within(() => {
+        cy.screenshot(`${ssPrefix}5-duplicated-page`);
+      });
+    });
+  });
+
+  it('Successfully expand and close modal', () => {
+    cy.visit('/');
+    cy.getByTestid('pageListButton').click({force: true});
+    cy.getByTestid('page-accessories-modal').parent().should('have.class','show');
+    cy.screenshot(`${ssPrefix}6-page-list-modal-size-normal`, {capture: 'viewport'});
+    cy.getByTestid('page-accessories-modal').parent().should('have.class','show').within(() => {
+      cy.get('button.close').eq(0).click();
+    });
+    cy.screenshot(`${ssPrefix}7-page-list-modal-size-fullscreen`, {capture: 'viewport'});
+
+    cy.getByTestid('page-accessories-modal').parent().should('have.class','show').within(() => {
+      cy.get('button.close').eq(1).click();
+    });
+
+    cy.screenshot(`${ssPrefix}8-close-page-list-modal`, {capture: 'viewport'});
+  });
+});
+
+context('Access to timeline', () => {
+  const ssPrefix = 'access-to-timeline-';
+  beforeEach(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+    // collapse sidebar
+    cy.collapseSidebar(true);
+  });
+  it('Timeline list successfully openend', () => {
+    cy.visit('/');
+    cy.getByTestid('pageListButton').click({force: true});
+    cy.getByTestid('page-accessories-modal').parent().should('have.class','show').within(() => {
+      cy.get('.nav-title > li').eq(1).find('a').click();
+    });
+    cy.screenshot(`${ssPrefix}1-timeline-list`, {capture: 'viewport'});
+  });
+
+  it('Successfully expand and close modal', () => {
+    cy.visit('/');
+    cy.getByTestid('pageListButton').click({force: true});
+    cy.getByTestid('page-accessories-modal').parent().should('have.class','show').within(() => {
+      cy.get('.nav-title > li').eq(1).find('a').click();
+      cy.get('button.close').eq(0).click();
+    });
+    cy.screenshot(`${ssPrefix}2-timeline-list-fullscreen`, {capture: 'viewport'});
+    cy.getByTestid('page-accessories-modal').parent().should('have.class','show').within(() => {
+      cy.get('button.close').eq(1).click();
+    });
+    cy.screenshot(`${ssPrefix}3-close-modal`, {capture: 'viewport'});
+  });
+});

+ 4 - 4
packages/app/test/integration/service/page.test.js

@@ -442,7 +442,7 @@ describe('PageService', () => {
 
 
         expect(xssSpy).toHaveBeenCalled();
         expect(xssSpy).toHaveBeenCalled();
 
 
-        expect(pageEventSpy).toHaveBeenCalledWith('rename', parentForRename1, testUser2);
+        expect(pageEventSpy).toHaveBeenCalledWith('rename');
 
 
         expect(resultPage.path).toBe('/renamed1');
         expect(resultPage.path).toBe('/renamed1');
         expect(resultPage.updatedAt).toEqual(parentForRename1.updatedAt);
         expect(resultPage.updatedAt).toEqual(parentForRename1.updatedAt);
@@ -455,7 +455,7 @@ describe('PageService', () => {
 
 
         expect(xssSpy).toHaveBeenCalled();
         expect(xssSpy).toHaveBeenCalled();
 
 
-        expect(pageEventSpy).toHaveBeenCalledWith('rename', parentForRename2, testUser2);
+        expect(pageEventSpy).toHaveBeenCalledWith('rename');
 
 
         expect(resultPage.path).toBe('/renamed2');
         expect(resultPage.path).toBe('/renamed2');
         expect(resultPage.updatedAt).toEqual(dateToUse);
         expect(resultPage.updatedAt).toEqual(dateToUse);
@@ -467,7 +467,7 @@ describe('PageService', () => {
         const resultPage = await crowi.pageService.renamePage(parentForRename3, '/renamed3', testUser2, { createRedirectPage: true });
         const resultPage = await crowi.pageService.renamePage(parentForRename3, '/renamed3', testUser2, { createRedirectPage: true });
 
 
         expect(xssSpy).toHaveBeenCalled();
         expect(xssSpy).toHaveBeenCalled();
-        expect(pageEventSpy).toHaveBeenCalledWith('rename', parentForRename3, testUser2);
+        expect(pageEventSpy).toHaveBeenCalledWith('rename');
 
 
         expect(resultPage.path).toBe('/renamed3');
         expect(resultPage.path).toBe('/renamed3');
         expect(resultPage.updatedAt).toEqual(parentForRename3.updatedAt);
         expect(resultPage.updatedAt).toEqual(parentForRename3.updatedAt);
@@ -480,7 +480,7 @@ describe('PageService', () => {
 
 
         expect(xssSpy).toHaveBeenCalled();
         expect(xssSpy).toHaveBeenCalled();
         expect(renameDescendantsWithStreamSpy).toHaveBeenCalled();
         expect(renameDescendantsWithStreamSpy).toHaveBeenCalled();
-        expect(pageEventSpy).toHaveBeenCalledWith('rename', parentForRename4, testUser2);
+        expect(pageEventSpy).toHaveBeenCalledWith('rename');
 
 
         expect(resultPage.path).toBe('/renamed4');
         expect(resultPage.path).toBe('/renamed4');
         expect(resultPage.updatedAt).toEqual(parentForRename4.updatedAt);
         expect(resultPage.updatedAt).toEqual(parentForRename4.updatedAt);

Some files were not shown because too many files changed in this diff