Browse Source

Merge pull request #10333 from growilabs/master

Release v7.3.2
mergify[bot] 6 months ago
parent
commit
2bd3823291
79 changed files with 1208 additions and 614 deletions
  1. 1 1
      .github/mergify.yml
  2. 1 1
      .github/workflows/ci-app-prod.yml
  3. 1 1
      .mcp.json
  4. 0 14
      .roo/mcp.json
  5. 1 1
      apps/app/bin/openapi/generate-operation-ids/cli.spec.ts
  6. 1 1
      apps/app/bin/openapi/generate-operation-ids/cli.ts
  7. 3 3
      apps/app/bin/openapi/generate-operation-ids/generate-operation-ids.spec.ts
  8. 1 1
      apps/app/bin/print-memory-consumption.ts
  9. 0 14
      apps/app/config/cdn.js
  10. 1 1
      apps/app/config/migrate-mongo-config.js
  11. 1 0
      apps/app/config/next-i18next.config.js
  12. 2 2
      apps/app/next.config.js
  13. 2 2
      apps/app/package.json
  14. 18 8
      apps/app/src/client/components/Admin/ElasticsearchManagement/ElasticsearchManagement.tsx
  15. 22 31
      apps/app/src/client/components/Admin/ImportData/GrowiArchive/ImportCollectionItem.jsx
  16. 287 0
      apps/app/src/client/components/LoginForm/LoginForm.spec.tsx
  17. 22 17
      apps/app/src/client/components/LoginForm/LoginForm.tsx
  18. 0 2
      apps/app/src/client/components/NotAvailableForReadOnlyUser.spec.tsx
  19. 0 2
      apps/app/src/client/components/PageHeader/PageTitleHeader.spec.tsx
  20. 5 2
      apps/app/src/client/components/TreeItem/TreeItemLayout.tsx
  21. 1 1
      apps/app/src/features/comment/server/events/event-emitter.ts
  22. 1 1
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts
  23. 1 1
      apps/app/src/features/growi-plugin/server/consts/index.ts
  24. 1 1
      apps/app/src/features/opentelemetry/server/anonymization/handlers/page-access-handler.spec.ts
  25. 6 3
      apps/app/src/features/opentelemetry/server/anonymization/handlers/page-access-handler.ts
  26. 1 1
      apps/app/src/features/opentelemetry/server/anonymization/handlers/page-api-handler.spec.ts
  27. 5 2
      apps/app/src/features/opentelemetry/server/anonymization/handlers/page-api-handler.ts
  28. 1 1
      apps/app/src/features/opentelemetry/server/anonymization/handlers/page-listing-api-handler.spec.ts
  29. 5 2
      apps/app/src/features/opentelemetry/server/anonymization/handlers/page-listing-api-handler.ts
  30. 1 1
      apps/app/src/features/opentelemetry/server/anonymization/handlers/search-api-handler.spec.ts
  31. 1 1
      apps/app/src/features/opentelemetry/server/anonymization/handlers/search-api-handler.ts
  32. 1 1
      apps/app/src/features/opentelemetry/server/anonymization/interfaces/anonymization-module.ts
  33. 1 1
      apps/app/src/features/opentelemetry/server/custom-metrics/application-metrics.spec.ts
  34. 1 1
      apps/app/src/features/opentelemetry/server/custom-metrics/application-metrics.ts
  35. 2 2
      apps/app/src/features/opentelemetry/server/logger.ts
  36. 1 1
      apps/app/src/features/opentelemetry/server/node-sdk-configuration.ts
  37. 1 1
      apps/app/src/features/opentelemetry/server/node-sdk-resource.ts
  38. 1 1
      apps/app/src/features/page-bulk-export/client/components/PageBulkExportSelectModal.tsx
  39. 3 3
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/index.ts
  40. 2 2
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/create-page-snapshots-async.ts
  41. 3 3
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/export-pages-to-fs-async.ts
  42. 2 2
      apps/app/src/features/templates/server/routes/apiv3/index.ts
  43. 2 2
      apps/app/src/migrations/20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js
  44. 1 1
      apps/app/src/server/crowi/express-init.js
  45. 5 3
      apps/app/src/server/crowi/index.js
  46. 3 2
      apps/app/src/server/routes/apiv3/g2g-transfer.ts
  47. 23 17
      apps/app/src/server/routes/apiv3/import.ts
  48. 3 3
      apps/app/src/server/routes/attachment/get.ts
  49. 1 1
      apps/app/src/server/routes/ogp.ts
  50. 5 0
      apps/app/src/server/service/config-manager/config-definition.ts
  51. 1 1
      apps/app/src/server/service/export.ts
  52. 59 20
      apps/app/src/server/service/file-uploader/aws/index.ts
  53. 81 16
      apps/app/src/server/service/file-uploader/azure.ts
  54. 34 10
      apps/app/src/server/service/file-uploader/gcs/index.ts
  55. 114 52
      apps/app/src/server/service/file-uploader/gridfs.ts
  56. 25 4
      apps/app/src/server/service/file-uploader/local.ts
  57. 47 47
      apps/app/src/server/service/file-uploader/utils/headers.ts
  58. 6 6
      apps/app/src/server/service/g2g-transfer.ts
  59. 1 3
      apps/app/src/server/service/growi-bridge/index.ts
  60. 1 1
      apps/app/src/server/service/i18next.ts
  61. 23 3
      apps/app/src/server/service/import/construct-convert-map.ts
  62. 131 95
      apps/app/src/server/service/import/import.ts
  63. 34 8
      apps/app/src/server/service/search-delegator/elasticsearch.ts
  64. 3 4
      apps/app/src/server/util/project-dir-utils.ts
  65. 1 2
      apps/app/src/stores-universal/context.tsx
  66. 1 0
      apps/app/src/stores-universal/use-context-swr.tsx
  67. 2 2
      apps/app/src/utils/next.config.utils.js
  68. 1 0
      apps/app/test-with-vite/setup/jest-dom.ts
  69. 1 1
      apps/app/test/integration/models/page-redirect.test.js
  70. 1 1
      apps/app/test/integration/models/page.test.js
  71. 1 1
      apps/app/test/integration/models/user.test.js
  72. 1 1
      apps/app/test/integration/setup-crowi.ts
  73. 1 1
      apps/app/tsconfig.json
  74. 1 0
      apps/app/vitest.workspace.mts
  75. 1 1
      apps/pdf-converter/package.json
  76. 1 1
      apps/slackbot-proxy/package.json
  77. 9 4
      biome.json
  78. 2 2
      package.json
  79. 170 161
      pnpm-lock.yaml

+ 1 - 1
.github/mergify.yml

@@ -13,7 +13,7 @@ queue_rules:
       - check-success ~= ci-app-test
       - check-success ~= ci-app-launch-dev
       - check-success = test-prod-node20 / build-prod
-      - check-success = test-prod-node20 / launch-prod
+      - check-success ~= test-prod-node20 / launch-prod
       - check-success ~= test-prod-node20 / run-playwright
       - -check-failure ~= ci-app-
       - -check-failure ~= ci-slackbot-

+ 1 - 1
.github/workflows/ci-app-prod.yml

@@ -40,7 +40,7 @@ concurrency:
 jobs:
 
   test-prod-node18:
-    uses: weseek/growi/.github/workflows/reusable-app-prod.yml@master
+    uses: growilabs/growi/.github/workflows/reusable-app-prod.yml@master
     if: |
       ( github.event_name == 'push'
         || github.base_ref == 'master'

+ 1 - 1
.mcp.json

@@ -14,7 +14,7 @@
         "--context",
         "ide-assistant",
         "--project",
-        "/workspace/growi"
+        "."
       ],
       "env": {}
     }

+ 0 - 14
.roo/mcp.json

@@ -1,14 +0,0 @@
-{
-  "mcpServers": {
-    "fetch": {
-      "command": "uvx",
-      "args": ["mcp-server-fetch"],
-      "alwaysAllow": ["fetch"]
-    },
-    "context7": {
-      "type": "streamable-http",
-      "url": "https://mcp.context7.com/mcp",
-      "alwaysAllow": ["resolve-library-id", "get-library-docs"]
-    }
-  }
-}

+ 1 - 1
apps/app/bin/openapi/generate-operation-ids/cli.spec.ts

@@ -1,4 +1,4 @@
-import { writeFileSync } from 'fs';
+import { writeFileSync } from 'node:fs';
 
 import { beforeEach, describe, expect, it, vi } from 'vitest';
 

+ 1 - 1
apps/app/bin/openapi/generate-operation-ids/cli.ts

@@ -1,5 +1,5 @@
+import { writeFileSync } from 'node:fs';
 import { Command } from 'commander';
-import { writeFileSync } from 'fs';
 
 import { generateOperationIds } from './generate-operation-ids';
 

+ 3 - 3
apps/app/bin/openapi/generate-operation-ids/generate-operation-ids.spec.ts

@@ -1,7 +1,7 @@
-import fs from 'fs/promises';
+import fs from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
 import type { OpenAPI3 } from 'openapi-typescript';
-import { tmpdir } from 'os';
-import path from 'path';
 import { describe, expect, it } from 'vitest';
 
 import { generateOperationIds } from './generate-operation-ids';

+ 1 - 1
apps/app/bin/print-memory-consumption.ts

@@ -11,7 +11,7 @@
  *        print-memory-consumption.ts [--port=9229] [--host=localhost] [--json]
  */
 
-import { get } from 'http';
+import { get } from 'node:http';
 
 import WebSocket from 'ws';
 

+ 0 - 14
apps/app/config/cdn.js

@@ -1,14 +0,0 @@
-import path from 'path';
-
-import { projectRoot } from '~/utils/project-dir-utils';
-
-export const cdnLocalScriptRoot = path.join(
-  projectRoot,
-  'public/static/js/cdn',
-);
-export const cdnLocalScriptWebRoot = '/static/js/cdn';
-export const cdnLocalStyleRoot = path.join(
-  projectRoot,
-  'public/static/styles/cdn',
-);
-export const cdnLocalStyleWebRoot = '/static/styles/cdn';

+ 1 - 1
apps/app/config/migrate-mongo-config.js

@@ -6,7 +6,7 @@
  */
 const isProduction = process.env.NODE_ENV === 'production';
 
-const { URL } = require('url');
+const { URL } = require('node:url');
 
 const { getMongoUri, mongoOptions } = isProduction
   ? // eslint-disable-next-line import/extensions, import/no-unresolved

+ 1 - 0
apps/app/config/next-i18next.config.js

@@ -1,5 +1,6 @@
 const isDev = process.env.NODE_ENV === 'development';
 
+// biome-ignore lint/style/useNodejsImportProtocol: ignore
 const path = require('path');
 
 const { AllLang } = require('@growi/core');

+ 2 - 2
apps/app/next.config.js

@@ -5,7 +5,7 @@
  * See: https://github.com/vercel/next.js/discussions/35969#discussioncomment-2522954
  */
 
-const path = require('path');
+const path = require('node:path');
 
 const { withSuperjson } = require('next-superjson');
 const {
@@ -93,7 +93,7 @@ const optimizePackageImports = [
   '@growi/ui',
 ];
 
-module.exports = async (phase, { defaultConfig }) => {
+module.exports = async (phase) => {
   const { i18n, localePath } = require('./config/next-i18next.config');
 
   /** @type {import('next').NextConfig} */

+ 2 - 2
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.3.1",
+  "version": "7.3.2-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -166,7 +166,7 @@
     "mkdirp": "^1.0.3",
     "mongodb": "^4.17.2",
     "mongoose": "^6.13.6",
-    "mongoose-gridfs": "^1.2.42",
+    "mongoose-gridfs": "^1.3.0",
     "mongoose-paginate-v2": "^1.3.9",
     "mongoose-unique-validator": "^2.0.3",
     "multer": "~1.4.0",

+ 18 - 8
apps/app/src/client/components/Admin/ElasticsearchManagement/ElasticsearchManagement.tsx

@@ -14,7 +14,7 @@ import RebuildIndexControls from './RebuildIndexControls';
 import ReconnectControls from './ReconnectControls';
 import StatusTable from './StatusTable';
 
-const ElasticsearchManagement = () => {
+const ElasticsearchManagement = (): JSX.Element => {
   const { t } = useTranslation('admin');
   const { data: isSearchServiceReachable } = useIsSearchServiceReachable();
   const { data: socket } = useAdminSocket();
@@ -43,6 +43,8 @@ const ElasticsearchManagement = () => {
       setIndicesData(info.indices);
       setAliasesData(info.aliases);
       setIsNormalized(info.isNormalized);
+
+      return info.isNormalized;
     }
     catch (errors: unknown) {
       setIsConnected(false);
@@ -60,6 +62,7 @@ const ElasticsearchManagement = () => {
         toastError(errors as Error);
       }
 
+      return false;
     }
     finally {
       setIsInitialized(true);
@@ -67,13 +70,9 @@ const ElasticsearchManagement = () => {
   }, []);
 
   useEffect(() => {
-    const fetchIndicesStatusData = async() => {
-      await retrieveIndicesStatus();
-    };
-    fetchIndicesStatusData();
+    retrieveIndicesStatus();
   }, [retrieveIndicesStatus]);
 
-
   useEffect(() => {
     if (socket == null) {
       return;
@@ -83,7 +82,19 @@ const ElasticsearchManagement = () => {
     });
 
     socket.on(SocketEventName.FinishAddPage, async(data) => {
-      await retrieveIndicesStatus();
+      let retryCount = 0;
+      const maxRetries = 5;
+      const retryDelay = 500;
+
+      const retrieveIndicesStatusWithRetry = async() => {
+        const isNormalizedResult = await retrieveIndicesStatus();
+        if (!isNormalizedResult && retryCount < maxRetries) {
+          retryCount++;
+          setTimeout(retrieveIndicesStatusWithRetry, retryDelay);
+        }
+      };
+
+      await retrieveIndicesStatusWithRetry();
       setIsRebuildingProcessing(false);
       setIsRebuildingCompleted(true);
     });
@@ -99,7 +110,6 @@ const ElasticsearchManagement = () => {
     };
   }, [retrieveIndicesStatus, socket]);
 
-
   const reconnect = async() => {
     setIsReconnectingProcessing(true);
 

+ 22 - 31
apps/app/src/client/components/Admin/ImportData/GrowiArchive/ImportCollectionItem.jsx

@@ -1,7 +1,9 @@
 import React from 'react';
 
 import PropTypes from 'prop-types';
-import { Progress } from 'reactstrap';
+import {
+  Progress, UncontrolledDropdown, DropdownToggle, DropdownMenu, DropdownItem,
+} from 'reactstrap';
 
 import { GrowiArchiveImportOption } from '~/models/admin/growi-archive-import-option';
 
@@ -49,6 +51,8 @@ export default class ImportCollectionItem extends React.Component {
     onOptionChange(collectionName, { mode });
   }
 
+  // No toggle state needed when using UncontrolledDropdown
+
   configButtonClickedHandler() {
     const { collectionName, onConfigButtonClicked } = this.props;
 
@@ -103,40 +107,28 @@ export default class ImportCollectionItem extends React.Component {
     const {
       collectionName, option, isImporting,
     } = this.props;
-
-    const attrMap = MODE_ATTR_MAP[option.mode];
-    const btnColor = `btn-${attrMap.color}`;
-
+    const currentMode = option?.mode || 'insert';
+    const attrMap = MODE_ATTR_MAP[currentMode];
     const modes = MODE_RESTRICTED_COLLECTION[collectionName] || Object.keys(MODE_ATTR_MAP);
 
     return (
       <span className="d-inline-flex align-items-center">
         Mode:&nbsp;
-        <div className="dropdown d-inline-block">
-          <button
-            className={`btn ${btnColor} btn-sm dropdown-toggle`}
-            type="button"
-            id="ddmMode"
-            disabled={isImporting}
-            data-bs-toggle="dropdown"
-            aria-haspopup="true"
-            aria-expanded="true"
-          >
-            {this.renderModeLabel(option.mode)}
-            <span className="caret ms-2"></span>
-          </button>
-          <ul className="dropdown-menu" aria-labelledby="ddmMode">
-            { modes.map((mode) => {
-              return (
-                <li key={`buttonMode_${mode}`}>
-                  <button type="button" className="dropdown-item" role="button" onClick={() => this.modeSelectedHandler(mode)}>
-                    {this.renderModeLabel(mode, true)}
-                  </button>
-                </li>
-              );
-            }) }
-          </ul>
-        </div>
+        <UncontrolledDropdown size="sm" className="d-inline-block">
+          <DropdownToggle color={attrMap.color} caret disabled={isImporting} id={`ddmMode-${collectionName}`}>
+            {this.renderModeLabel(currentMode)}
+          </DropdownToggle>
+          <DropdownMenu>
+            {modes.map(mode => (
+              <DropdownItem
+                key={`buttonMode_${mode}`}
+                onClick={() => this.modeSelectedHandler(mode)}
+              >
+                {this.renderModeLabel(mode, true)}
+              </DropdownItem>
+            ))}
+          </DropdownMenu>
+        </UncontrolledDropdown>
       </span>
     );
   }
@@ -190,7 +182,6 @@ export default class ImportCollectionItem extends React.Component {
         }
       </div>
     );
-
   }
 
   render() {

+ 287 - 0
apps/app/src/client/components/LoginForm/LoginForm.spec.tsx

@@ -0,0 +1,287 @@
+import React from 'react';
+
+import {
+  render, screen, fireEvent, waitFor,
+} from '@testing-library/react';
+import {
+  describe, it, expect, vi, beforeEach,
+} from 'vitest';
+
+import { apiv3Post } from '~/client/util/apiv3-client';
+import type { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
+
+import { LoginForm } from './LoginForm';
+
+vi.mock('next-i18next', () => ({
+  useTranslation: () => ({
+    t: (key: string) => key,
+  }),
+}));
+
+vi.mock('next/router', () => ({
+  useRouter: () => ({
+    push: vi.fn(),
+  }),
+}));
+
+vi.mock('~/client/util/t-with-opt', () => ({
+  useTWithOpt: () => (key: string) => key,
+}));
+
+vi.mock('~/client/util/apiv3-client', () => ({
+  apiv3Post: vi.fn(),
+}));
+
+vi.mock('./ExternalAuthButton', () => ({
+  ExternalAuthButton: ({ authType }: { authType: string }) => (
+    <button type="button" data-testid={`external-auth-${authType}`}>
+      External Auth {authType}
+    </button>
+  ),
+}));
+
+vi.mock('../CompleteUserRegistration', () => ({
+  CompleteUserRegistration: () => <div>Complete Registration</div>,
+}));
+
+const defaultProps = {
+  isEmailAuthenticationEnabled: false,
+  registrationMode: 'Open' as const,
+  registrationWhitelist: [],
+  isPasswordResetEnabled: true,
+  isLocalStrategySetup: true,
+  isLdapStrategySetup: false,
+  isLdapSetupFailed: false,
+  minPasswordLength: 8,
+  isMailerSetup: true,
+};
+
+const mockApiv3Post = vi.mocked(apiv3Post);
+
+describe('LoginForm - Error Display', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  describe('when password login is enabled', () => {
+    it('should display login form', () => {
+      const props = {
+        ...defaultProps,
+        isLocalStrategySetup: true,
+      };
+
+      render(<LoginForm {...props} />);
+
+      expect(screen.getByTestId('login-form')).toBeInTheDocument();
+    });
+
+    it('should display external account login errors', () => {
+      const externalAccountLoginError = {
+        message: 'jwks must be a JSON Web Key Set formatted object',
+        name: 'ExternalAccountLoginError',
+      };
+
+      const props = {
+        ...defaultProps,
+        isLocalStrategySetup: true,
+        externalAccountLoginError,
+      };
+
+      render(<LoginForm {...props} />);
+
+      expect(screen.getByText('jwks must be a JSON Web Key Set formatted object')).toBeInTheDocument();
+    });
+  });
+
+  describe('when password login is disabled', () => {
+    it('should still display external account login errors', () => {
+      const externalAccountLoginError = {
+        message: 'jwks must be a JSON Web Key Set formatted object',
+        name: 'ExternalAccountLoginError',
+      };
+
+      const props = {
+        ...defaultProps,
+        isLocalStrategySetup: false,
+        isLdapStrategySetup: false,
+        enabledExternalAuthType: ['oidc'] satisfies IExternalAuthProviderType[],
+        externalAccountLoginError,
+      };
+
+      render(<LoginForm {...props} />);
+
+      expect(screen.getByText('jwks must be a JSON Web Key Set formatted object')).toBeInTheDocument();
+    });
+
+    it('should not render local/LDAP form but should still show errors', () => {
+      const externalAccountLoginError = {
+        message: 'OIDC authentication failed',
+        name: 'ExternalAccountLoginError',
+      };
+
+      const props = {
+        ...defaultProps,
+        isLocalStrategySetup: false,
+        isLdapStrategySetup: false,
+        enabledExternalAuthType: ['oidc'] satisfies IExternalAuthProviderType[],
+        externalAccountLoginError,
+      };
+
+      render(<LoginForm {...props} />);
+
+      expect(screen.queryByTestId('tiUsernameForLogin')).not.toBeInTheDocument();
+      expect(screen.queryByTestId('tiPasswordForLogin')).not.toBeInTheDocument();
+      expect(screen.getByText('OIDC authentication failed')).toBeInTheDocument();
+    });
+  });
+
+  describe('error display priority and login error handling', () => {
+    it('should show external errors when no login errors exist', () => {
+      const externalAccountLoginError = {
+        message: 'External error message',
+        name: 'ExternalAccountLoginError',
+      };
+
+      const props = {
+        ...defaultProps,
+        isLocalStrategySetup: true,
+        externalAccountLoginError,
+      };
+
+      render(<LoginForm {...props} />);
+
+      expect(screen.getByText('External error message')).toBeInTheDocument();
+    });
+
+    it('should prioritize login errors over external account login errors after failed login', async() => {
+      const externalAccountLoginError = {
+        message: 'External error message',
+        name: 'ExternalAccountLoginError',
+      };
+
+      // Mock API call to return error
+      mockApiv3Post.mockRejectedValueOnce([
+        {
+          message: 'Invalid username or password',
+          code: 'LOGIN_FAILED',
+          args: {},
+        },
+      ]);
+
+      const props = {
+        ...defaultProps,
+        isLocalStrategySetup: true,
+        externalAccountLoginError,
+      };
+
+      render(<LoginForm {...props} />);
+
+      // Initially, external error should be visible
+      expect(screen.getByText('External error message')).toBeInTheDocument();
+
+      // Fill in login form and submit
+      const usernameInput = screen.getByTestId('tiUsernameForLogin');
+      const passwordInput = screen.getByTestId('tiPasswordForLogin');
+      const submitButton = screen.getByTestId('btnSubmitForLogin');
+
+      fireEvent.change(usernameInput, { target: { value: 'testuser' } });
+      fireEvent.change(passwordInput, { target: { value: 'wrongpassword' } });
+      fireEvent.click(submitButton);
+
+      // Wait for login error to appear and external error to be replaced
+      await waitFor(() => {
+        expect(screen.getByText('Invalid username or password')).toBeInTheDocument();
+      });
+
+      // External error should no longer be visible when login error exists
+      expect(screen.queryByText('External error message')).not.toBeInTheDocument();
+    });
+
+    it('should display dangerouslySetInnerHTML errors for PROVIDER_DUPLICATED_USERNAME_EXCEPTION', async() => {
+      // Mock API call to return PROVIDER_DUPLICATED_USERNAME_EXCEPTION error
+      mockApiv3Post.mockRejectedValueOnce([
+        {
+          message: 'This username is already taken by <a href="/login">another provider</a>',
+          code: 'provider-duplicated-username-exception',
+          args: {},
+        },
+      ]);
+
+      const props = {
+        ...defaultProps,
+        isLocalStrategySetup: true,
+      };
+
+      render(<LoginForm {...props} />);
+
+      // Fill in login form and submit
+      const usernameInput = screen.getByTestId('tiUsernameForLogin');
+      const passwordInput = screen.getByTestId('tiPasswordForLogin');
+      const submitButton = screen.getByTestId('btnSubmitForLogin');
+
+      fireEvent.change(usernameInput, { target: { value: 'testuser' } });
+      fireEvent.change(passwordInput, { target: { value: 'password' } });
+      fireEvent.click(submitButton);
+
+      // Wait for the dangerouslySetInnerHTML error to appear
+      await waitFor(() => {
+        // Check that the error with HTML content is rendered
+        expect(screen.getByText(/This username is already taken by/)).toBeInTheDocument();
+      });
+    });
+
+    it('should handle multiple login errors correctly', async() => {
+      // Mock API call to return multiple errors
+      mockApiv3Post.mockRejectedValueOnce([
+        {
+          message: 'Username is required',
+          code: 'VALIDATION_ERROR',
+          args: {},
+        },
+        {
+          message: 'Password is too short',
+          code: 'VALIDATION_ERROR',
+          args: {},
+        },
+      ]);
+
+      const props = {
+        ...defaultProps,
+        isLocalStrategySetup: true,
+      };
+
+      render(<LoginForm {...props} />);
+
+      // Submit form without filling inputs
+      const submitButton = screen.getByTestId('btnSubmitForLogin');
+      fireEvent.click(submitButton);
+
+      // Wait for multiple errors to appear
+      await waitFor(() => {
+        expect(screen.getByText('Username is required')).toBeInTheDocument();
+        expect(screen.getByText('Password is too short')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('error display when both login methods are disabled', () => {
+    it('should still display external errors when no login methods are available', () => {
+      const externalAccountLoginError = {
+        message: 'Authentication service unavailable',
+        name: 'ExternalAccountLoginError',
+      };
+
+      const props = {
+        ...defaultProps,
+        isLocalStrategySetup: false,
+        isLdapStrategySetup: false,
+        enabledExternalAuthType: undefined,
+        externalAccountLoginError,
+      };
+
+      render(<LoginForm {...props} />);
+
+      expect(screen.getByText('Authentication service unavailable')).toBeInTheDocument();
+    });
+  });
+});

+ 22 - 17
apps/app/src/client/components/LoginForm/LoginForm.tsx

@@ -154,9 +154,9 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     return (
       <ul className="alert alert-danger">
         {errors.map((err, index) => (
-          <li className={index > 0 ? 'mt-1' : ''}>
+          <small className={index > 0 ? 'mt-1' : ''}>
             {tWithOpt(err.message, err.args)}
-          </li>
+          </small>
         ))}
       </ul>
     );
@@ -165,17 +165,6 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
   const renderLocalOrLdapLoginForm = useCallback(() => {
     const { isLdapStrategySetup } = props;
 
-    // separate login errors into two arrays based on error code
-    const [loginErrorListForDangerouslySetInnerHTML, loginErrorList] = separateErrorsBasedOnErrorCode(loginErrors);
-    // Generate login error elements using dangerouslySetInnerHTML
-    const loginErrorElementWithDangerouslySetInnerHTML = generateDangerouslySetErrors(loginErrorListForDangerouslySetInnerHTML);
-    // Generate login error elements using <ul>, <li>
-
-    const loginErrorElement = (loginErrorList ?? []).length > 0
-      // prioritize loginErrorList because the list should contains new error
-      ? generateSafelySetErrors(loginErrorList)
-      : generateSafelySetErrors(props.externalAccountLoginError != null ? [props.externalAccountLoginError] : []);
-
     return (
       <>
         {/* !! - DO NOT DELETE HIDDEN ELEMENT - !! -- 7.12 ryoji-s */}
@@ -191,8 +180,6 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
             <span dangerouslySetInnerHTML={{ __html: t('login.set_env_var_for_logs') }}></span>
           </div>
         )}
-        {loginErrorElementWithDangerouslySetInnerHTML}
-        {loginErrorElement}
 
         <form role="form" onSubmit={handleLoginWithLocalSubmit} id="login-form">
           <div className="input-group">
@@ -253,8 +240,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
       </>
     );
   }, [
-    props, separateErrorsBasedOnErrorCode, loginErrors, generateDangerouslySetErrors, generateSafelySetErrors,
-    isLdapSetupFailed, t, handleLoginWithLocalSubmit, isLoading,
+    props, isLdapSetupFailed, t, handleLoginWithLocalSubmit, isLoading,
   ]);
 
 
@@ -510,6 +496,25 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
           <div className="col-12 px-md-4 pb-5">
             <ReactCardFlip isFlipped={isRegistering} flipDirection="horizontal" cardZIndex="3">
               <div className="front">
+                {/* Error display section - always shown regardless of login method configuration */}
+                {(() => {
+                  // separate login errors into two arrays based on error code
+                  const [loginErrorListForDangerouslySetInnerHTML, loginErrorList] = separateErrorsBasedOnErrorCode(loginErrors);
+                  // Generate login error elements using dangerouslySetInnerHTML
+                  const loginErrorElementWithDangerouslySetInnerHTML = generateDangerouslySetErrors(loginErrorListForDangerouslySetInnerHTML);
+                  // Generate login error elements - prioritize loginErrorList, fallback to externalAccountLoginError
+                  const loginErrorElement = (loginErrorList ?? []).length > 0
+                    ? generateSafelySetErrors(loginErrorList)
+                    : generateSafelySetErrors(props.externalAccountLoginError != null ? [props.externalAccountLoginError] : []);
+
+                  return (
+                    <>
+                      {loginErrorElementWithDangerouslySetInnerHTML}
+                      {loginErrorElement}
+                    </>
+                  );
+                })()}
+
                 {isLocalOrLdapStrategiesEnabled && renderLocalOrLdapLoginForm()}
                 {isLocalOrLdapStrategiesEnabled && isSomeExternalAuthEnabled && (
                   <div className="text-center text-line d-flex align-items-center mb-3">

+ 0 - 2
apps/app/src/client/components/NotAvailableForReadOnlyUser.spec.tsx

@@ -1,5 +1,3 @@
-import '@testing-library/jest-dom/vitest';
-
 import { render, screen } from '@testing-library/react';
 import {
   describe, it, expect, vi,

+ 0 - 2
apps/app/src/client/components/PageHeader/PageTitleHeader.spec.tsx

@@ -1,5 +1,3 @@
-import '@testing-library/jest-dom/vitest';
-
 import { faker } from '@faker-js/faker';
 import type { IPagePopulatedToShowRevision } from '@growi/core';
 import {

+ 5 - 2
apps/app/src/client/components/TreeItem/TreeItemLayout.tsx

@@ -9,6 +9,8 @@ import React, {
   type JSX,
 } from 'react';
 
+import { addTrailingSlash } from '@growi/core/dist/utils/path-utils';
+
 import { useSWRxPageChildren } from '~/stores/page-listing';
 import { usePageTreeDescCountMap } from '~/stores/ui';
 
@@ -88,9 +90,10 @@ export const TreeItemLayout = (props: TreeItemLayoutProps): JSX.Element => {
     setIsOpen(!isOpen);
   }, [isOpen]);
 
-  // didMount
   useEffect(() => {
-    const isPathToTarget = page.path != null && targetPath.startsWith(page.path) && targetPath !== page.path; // Target Page does not need to be opened
+    const isPathToTarget = page.path != null
+      && targetPath.startsWith(addTrailingSlash(page.path))
+      && targetPath !== page.path; // Target Page does not need to be opened
     if (isPathToTarget) setIsOpen(true);
   }, [targetPath, page.path]);
 

+ 1 - 1
apps/app/src/features/comment/server/events/event-emitter.ts

@@ -1,3 +1,3 @@
-import { EventEmitter } from 'events';
+import { EventEmitter } from 'node:events';
 
 export const commentEvent = new EventEmitter();

+ 1 - 1
apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group.ts

@@ -964,7 +964,7 @@ module.exports = (crowi: Crowi): Router => {
           req.user.name,
           req.body.password,
         );
-      } catch (e) {
+      } catch (_e) {
         return res.apiv3Err(
           new ErrorV3(
             'LDAP group sync failed',

+ 1 - 1
apps/app/src/features/growi-plugin/server/consts/index.ts

@@ -1,4 +1,4 @@
-import { resolveFromRoot } from '~/utils/project-dir-utils';
+import { resolveFromRoot } from '~/server/util/project-dir-utils';
 
 export const PLUGIN_STORING_PATH = resolveFromRoot('tmp/plugins');
 

+ 1 - 1
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-access-handler.spec.ts

@@ -1,4 +1,4 @@
-import type { IncomingMessage } from 'http';
+import type { IncomingMessage } from 'node:http';
 
 import { describe, expect, it } from 'vitest';
 

+ 6 - 3
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-access-handler.ts

@@ -1,3 +1,5 @@
+import { createHash } from 'node:crypto';
+import type { IncomingMessage } from 'node:http';
 import {
   getUsernameByPath,
   isCreatablePage,
@@ -7,8 +9,6 @@ import {
   isUsersTopPage,
 } from '@growi/core/dist/utils/page-path-utils';
 import { diag } from '@opentelemetry/api';
-import { createHash } from 'crypto';
-import type { IncomingMessage } from 'http';
 
 import { ATTR_HTTP_TARGET } from '../../semconv';
 import type { AnonymizationModule } from '../interfaces/anonymization-module';
@@ -132,7 +132,10 @@ export const pageAccessModule: AnonymizationModule = {
   /**
    * Handle anonymization for page access requests
    */
-  handle(request: IncomingMessage, url: string): Record<string, string> | null {
+  handle(
+    _request: IncomingMessage,
+    url: string,
+  ): Record<string, string> | null {
     try {
       const parsedUrl = new URL(url, 'http://localhost');
       const originalPath = parsedUrl.pathname;

+ 1 - 1
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-api-handler.spec.ts

@@ -1,4 +1,4 @@
-import type { IncomingMessage } from 'http';
+import type { IncomingMessage } from 'node:http';
 
 import { beforeEach, describe, expect, it } from 'vitest';
 

+ 5 - 2
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-api-handler.ts

@@ -1,5 +1,5 @@
+import type { IncomingMessage } from 'node:http';
 import { diag } from '@opentelemetry/api';
-import type { IncomingMessage } from 'http';
 
 import { ATTR_HTTP_TARGET } from '../../semconv';
 import type { AnonymizationModule } from '../interfaces/anonymization-module';
@@ -28,7 +28,10 @@ export const pageApiModule: AnonymizationModule = {
   /**
    * Handle anonymization for page API endpoints
    */
-  handle(request: IncomingMessage, url: string): Record<string, string> | null {
+  handle(
+    _request: IncomingMessage,
+    url: string,
+  ): Record<string, string> | null {
     const attributes: Record<string, string> = {};
     let hasAnonymization = false;
 

+ 1 - 1
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-listing-api-handler.spec.ts

@@ -1,4 +1,4 @@
-import type { IncomingMessage } from 'http';
+import type { IncomingMessage } from 'node:http';
 
 import { beforeEach, describe, expect, it } from 'vitest';
 

+ 5 - 2
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-listing-api-handler.ts

@@ -1,5 +1,5 @@
+import type { IncomingMessage } from 'node:http';
 import { diag } from '@opentelemetry/api';
-import type { IncomingMessage } from 'http';
 
 import { ATTR_HTTP_TARGET } from '../../semconv';
 import type { AnonymizationModule } from '../interfaces/anonymization-module';
@@ -28,7 +28,10 @@ export const pageListingApiModule: AnonymizationModule = {
   /**
    * Handle anonymization for page-listing API endpoints
    */
-  handle(request: IncomingMessage, url: string): Record<string, string> | null {
+  handle(
+    _request: IncomingMessage,
+    url: string,
+  ): Record<string, string> | null {
     const attributes: Record<string, string> = {};
     let hasAnonymization = false;
 

+ 1 - 1
apps/app/src/features/opentelemetry/server/anonymization/handlers/search-api-handler.spec.ts

@@ -1,4 +1,4 @@
-import type { IncomingMessage } from 'http';
+import type { IncomingMessage } from 'node:http';
 
 import { beforeEach, describe, expect, it } from 'vitest';
 

+ 1 - 1
apps/app/src/features/opentelemetry/server/anonymization/handlers/search-api-handler.ts

@@ -1,5 +1,5 @@
+import type { IncomingMessage } from 'node:http';
 import { diag } from '@opentelemetry/api';
-import type { IncomingMessage } from 'http';
 
 import { ATTR_HTTP_TARGET } from '../../semconv';
 import type { AnonymizationModule } from '../interfaces/anonymization-module';

+ 1 - 1
apps/app/src/features/opentelemetry/server/anonymization/interfaces/anonymization-module.ts

@@ -1,4 +1,4 @@
-import type { IncomingMessage } from 'http';
+import type { IncomingMessage } from 'node:http';
 
 /**
  * Interface for anonymization modules

+ 1 - 1
apps/app/src/features/opentelemetry/server/custom-metrics/application-metrics.spec.ts

@@ -1,5 +1,5 @@
+import crypto from 'node:crypto';
 import { type Meter, metrics, type ObservableGauge } from '@opentelemetry/api';
-import crypto from 'crypto';
 import { mock } from 'vitest-mock-extended';
 
 import { configManager } from '~/server/service/config-manager';

+ 1 - 1
apps/app/src/features/opentelemetry/server/custom-metrics/application-metrics.ts

@@ -1,5 +1,5 @@
+import crypto from 'node:crypto';
 import { diag, metrics } from '@opentelemetry/api';
-import crypto from 'crypto';
 
 import { configManager } from '~/server/service/config-manager';
 import loggerFactory from '~/utils/logger';

+ 2 - 2
apps/app/src/features/opentelemetry/server/logger.ts

@@ -23,7 +23,7 @@ class DiagLoggerBunyanAdapter implements DiagLogger {
             ? data.message
             : message;
       }
-    } catch (e) {
+    } catch (_e) {
       // do nothing if the message is not a JSON string
     }
 
@@ -35,7 +35,7 @@ class DiagLoggerBunyanAdapter implements DiagLogger {
           try {
             const parsed = JSON.parse(arg);
             return { ...acc, ...parsed };
-          } catch (e) {
+          } catch (_e) {
             return { ...acc, additionalInfo: arg };
           }
         }

+ 1 - 1
apps/app/src/features/opentelemetry/server/node-sdk-configuration.ts

@@ -76,7 +76,7 @@ export const generateNodeSDKConfiguration = (opts?: Option): Configuration => {
  * This function should be called after database is available
  */
 export const generateAdditionalResourceAttributes = async (
-  opts?: Option,
+  _opts?: Option,
 ): Promise<Resource> => {
   if (resource == null) {
     throw new Error(

+ 1 - 1
apps/app/src/features/opentelemetry/server/node-sdk-resource.ts

@@ -23,7 +23,7 @@ export const setResource = (sdk: NodeSDK, resource: Resource): void => {
   // Verify that we can access the _resource property
   try {
     getResource(sdk);
-  } catch (e) {
+  } catch (_e) {
     throw new Error('Failed to access SDK resource');
   }
 

+ 1 - 1
apps/app/src/features/page-bulk-export/client/components/PageBulkExportSelectModal.tsx

@@ -50,7 +50,7 @@ const PageBulkExportSelectModal = (): JSX.Element => {
           restartJob: true,
         });
         toastSuccess(t('page_export.bulk_export_started'));
-      } catch (e) {
+      } catch (_e) {
         toastError(t('page_export.failed_to_export'));
       }
       setIsRestartModalOpened(false);

+ 3 - 3
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/index.ts

@@ -1,9 +1,9 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import type { Readable } from 'node:stream';
 import type { IUser } from '@growi/core';
 import { getIdForRef, isPopulated } from '@growi/core';
-import fs from 'fs';
 import mongoose from 'mongoose';
-import path from 'path';
-import type { Readable } from 'stream';
 
 import type { SupportedActionType } from '~/interfaces/activity';
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';

+ 2 - 2
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/create-page-snapshots-async.ts

@@ -1,8 +1,8 @@
+import { createHash } from 'node:crypto';
+import { pipeline, Writable } from 'node:stream';
 import type { IPage } from '@growi/core';
 import { getIdForRef, getIdStringForRef } from '@growi/core';
-import { createHash } from 'crypto';
 import mongoose from 'mongoose';
-import { pipeline, Writable } from 'stream';
 
 import { PageBulkExportJobStatus } from '~/features/page-bulk-export/interfaces/page-bulk-export';
 import { SupportedAction } from '~/interfaces/activity';

+ 3 - 3
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/export-pages-to-fs-async.ts

@@ -1,15 +1,15 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { pipeline, Writable } from 'node:stream';
 import { dynamicImport } from '@cspell/dynamic-import';
 import { isPopulated } from '@growi/core';
 import {
   getParentPath,
   normalizePath,
 } from '@growi/core/dist/utils/path-utils';
-import fs from 'fs';
 import type { Root } from 'mdast';
-import path from 'path';
 import type * as RemarkHtml from 'remark-html';
 import type * as RemarkParse from 'remark-parse';
-import { pipeline, Writable } from 'stream';
 import type * as Unified from 'unified';
 
 import {

+ 2 - 2
apps/app/src/features/templates/server/routes/apiv3/index.ts

@@ -1,3 +1,4 @@
+import path from 'node:path';
 import { GrowiPluginType } from '@growi/core';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import type { TemplateSummary } from '@growi/pluginkit/dist/v4';
@@ -7,15 +8,14 @@ import {
 } from '@growi/pluginkit/dist/v4/server/index.cjs';
 import express from 'express';
 import { param, query } from 'express-validator';
-import path from 'path';
 import { PLUGIN_STORING_PATH } from '~/features/growi-plugin/server/consts';
 import { GrowiPlugin } from '~/features/growi-plugin/server/models';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+import { resolveFromRoot } from '~/server/util/project-dir-utils';
 import loggerFactory from '~/utils/logger';
-import { resolveFromRoot } from '~/utils/project-dir-utils';
 
 const logger = loggerFactory('growi:routes:apiv3:templates');
 

+ 2 - 2
apps/app/src/migrations/20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js

@@ -1,6 +1,6 @@
+import { Writable } from 'node:stream';
+import { pipeline } from 'node:stream/promises';
 import mongoose from 'mongoose';
-import { Writable } from 'stream';
-import { pipeline } from 'stream/promises';
 
 import getPageModel from '~/server/models/page';
 import { Revision } from '~/server/models/revision';

+ 1 - 1
apps/app/src/server/crowi/express-init.js

@@ -3,8 +3,8 @@ import csrf from 'csurf';
 import qs from 'qs';
 
 import { PLUGIN_EXPRESS_STATIC_DIR, PLUGIN_STORING_PATH } from '~/features/growi-plugin/server/consts';
+import { resolveFromRoot } from '~/server/util/project-dir-utils';
 import loggerFactory from '~/utils/logger';
-import { resolveFromRoot } from '~/utils/project-dir-utils';
 
 import registerSafeRedirectFactory from '../middlewares/safe-redirect';
 

+ 5 - 3
apps/app/src/server/crowi/index.js

@@ -18,9 +18,9 @@ import instanciatePageBulkExportJobCleanUpCronService, {
 } from '~/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron';
 import instanciatePageBulkExportJobCronService from '~/features/page-bulk-export/server/service/page-bulk-export-job-cron';
 import { startCron as startAccessTokenCron } from '~/server/service/access-token';
+import { projectRoot } from '~/server/util/project-dir-utils';
 import { getGrowiVersion } from '~/utils/growi-version';
 import loggerFactory from '~/utils/logger';
-import { projectRoot } from '~/utils/project-dir-utils';
 
 import UserEvent from '../events/user';
 import { accessTokenParser } from '../middlewares/access-token-parser';
@@ -32,7 +32,7 @@ import instanciateExportService from '../service/export';
 import instanciateExternalAccountService from '../service/external-account';
 import { FileUploader, getUploader } from '../service/file-uploader'; // eslint-disable-line no-unused-vars
 import { G2GTransferPusherService, G2GTransferReceiverService } from '../service/g2g-transfer';
-import GrowiBridgeService from '../service/growi-bridge';
+import { GrowiBridgeService } from '../service/growi-bridge';
 import { initializeImportService } from '../service/import';
 import { InstallerService } from '../service/installer';
 import { normalizeData } from '../service/normalize-data';
@@ -82,6 +82,9 @@ class Crowi {
   /** @type {import('../service/growi-info').GrowiInfoService} */
   growiInfoService;
 
+  /** @type {import('../service/growi-bridge').GrowiBridgeService} */
+  growiBridgeService;
+
   /** @type {import('../service/page').IPageService} */
   pageService;
 
@@ -134,7 +137,6 @@ class Crowi {
     this.aclService = null;
     this.appService = null;
     this.fileUploadService = null;
-    this.growiBridgeService = null;
     this.pluginService = null;
     this.searchService = null;
     this.socketIoService = null;

+ 3 - 2
apps/app/src/server/routes/apiv3/g2g-transfer.ts

@@ -1,19 +1,20 @@
 import { createReadStream } from 'fs';
 import path from 'path';
 
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { NextFunction, Request, Router } from 'express';
 import express from 'express';
 import { body } from 'express-validator';
 import multer from 'multer';
 
-import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { isG2GTransferError } from '~/server/models/vo/g2g-transfer-error';
 import { configManager } from '~/server/service/config-manager';
 import { exportService } from '~/server/service/export';
 import type { IDataGROWIInfo } from '~/server/service/g2g-transfer';
 import { X_GROWI_TRANSFER_KEY_HEADER_NAME } from '~/server/service/g2g-transfer';
+import type { ImportSettings } from '~/server/service/import';
 import { getImportService } from '~/server/service/import';
 import loggerFactory from '~/utils/logger';
 import { TransferKey } from '~/utils/vo/transfer-key';
@@ -313,7 +314,7 @@ module.exports = (crowi: Crowi): Router => {
     /*
      * generate maps of ImportSettings to import
      */
-    let importSettingsMap;
+    let importSettingsMap: Map<string, ImportSettings>;
     try {
       importSettingsMap = g2gTransferReceiverService.getImportSettingMap(innerFileStats, optionsMap, operatorUserId);
     }

+ 23 - 17
apps/app/src/server/routes/apiv3/import.js → apps/app/src/server/routes/apiv3/import.ts

@@ -1,14 +1,18 @@
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 
 import { SupportedAction } from '~/interfaces/activity';
-import { SCOPE } from '@growi/core/dist/interfaces';
+import type { GrowiArchiveImportOption } from '~/models/admin/growi-archive-import-option';
+import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import type { ImportSettings } from '~/server/service/import';
 import { getImportService } from '~/server/service/import';
 import { generateOverwriteParams } from '~/server/service/import/overwrite-params';
 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 path = require('path');
@@ -122,14 +126,13 @@ const router = express.Router();
  *                  type: integer
  *                  nullable: true
  */
-/** @param {import('~/server/crowi').default} crowi Crowi instance */
-export default function route(crowi) {
+export default function route(crowi: Crowi): void {
   const { growiBridgeService, socketIoService } = crowi;
-  const importService = getImportService(crowi);
+  const importService = getImportService();
 
   const loginRequired = require('../../middlewares/login-required')(crowi);
   const adminRequired = require('../../middlewares/admin-required')(crowi);
-  const addActivity = generateAddActivityMiddleware(crowi);
+  const addActivity = generateAddActivityMiddleware();
 
   const adminEvent = crowi.event('admin');
   const activityEvent = crowi.event('activity');
@@ -312,18 +315,22 @@ export default function route(crowi) {
     /*
      * unzip, parse
      */
-    let meta = null;
-    let fileStatsToImport = null;
+    let meta;
+    let fileStatsToImport;
     try {
       // unzip
       await importService.unzip(zipFile);
 
       // eslint-disable-next-line no-unused-vars
-      const { meta: parsedMeta, fileStats, innerFileStats } = await growiBridgeService.parseZipFile(zipFile);
-      meta = parsedMeta;
+      const parseZipResult = await growiBridgeService.parseZipFile(zipFile);
+      if (parseZipResult == null) {
+        throw new Error('parseZipFile returns null');
+      }
+
+      meta = parseZipResult.meta;
 
       // filter innerFileStats
-      fileStatsToImport = innerFileStats.filter(({ fileName, collectionName, size }) => {
+      fileStatsToImport = parseZipResult.innerFileStats.filter(({ collectionName }) => {
         return collections.includes(collectionName);
       });
     }
@@ -346,21 +353,20 @@ export default function route(crowi) {
     }
 
     // generate maps of ImportSettings to import
-    const importSettingsMap = {};
+    // Use the Map for a potential fix for the code scanning alert no. 895: Prototype-polluting assignment
+    const importSettingsMap = new Map<string, ImportSettings>();
     fileStatsToImport.forEach(({ fileName, collectionName }) => {
       // instanciate GrowiArchiveImportOption
-      /** @type {import('~/models/admin/growi-archive-import-option').GrowiArchiveImportOption} */
-      const option = options.find(opt => opt.collectionName === collectionName);
+      const option: GrowiArchiveImportOption = options.find(opt => opt.collectionName === collectionName);
 
       // generate options
-      /** @type {import('~/server/service/import').ImportSettings} */
       const importSettings = {
         mode: option.mode,
         jsonFileName: fileName,
         overwriteParams: generateOverwriteParams(collectionName, req.user._id, option),
-      };
+      } satisfies ImportSettings;
 
-      importSettingsMap[collectionName] = importSettings;
+      importSettingsMap.set(collectionName, importSettings);
     });
 
     /*
@@ -411,7 +417,7 @@ export default function route(crowi) {
     async(req, res) => {
       const { file } = req;
       const zipFile = importService.getFile(file.filename);
-      let data = null;
+      let data;
 
       try {
         data = await growiBridgeService.parseZipFile(zipFile);

+ 3 - 3
apps/app/src/server/routes/attachment/get.ts

@@ -11,7 +11,7 @@ import type { CrowiProperties, CrowiRequest } from '~/interfaces/crowi-request';
 import { ResponseMode, type ExpressHttpHeader, type RespondOptions } from '~/server/interfaces/attachment';
 import {
   type FileUploader,
-  toExpressHttpHeaders, ContentHeaders, applyHeaders,
+  toExpressHttpHeaders, applyHeaders, createContentHeaders,
 } from '~/server/service/file-uploader';
 import loggerFactory from '~/utils/logger';
 
@@ -110,8 +110,8 @@ const respondForRedirectMode = async(res: Response, fileUploadService: FileUploa
 const respondForRelayMode = async(res: Response, fileUploadService: FileUploader, attachment: IAttachmentDocument, opts?: RespondOptions): Promise<void> => {
   // apply content-* headers before response
   const isDownload = opts?.download ?? false;
-  const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
-  applyHeaders(res, contentHeaders.toExpressHttpHeaders());
+  const contentHeaders = createContentHeaders(attachment, { inline: !isDownload });
+  applyHeaders(res, contentHeaders);
 
   try {
     const readable = await fileUploadService.findDeliveryFile(attachment);

+ 1 - 1
apps/app/src/server/routes/ogp.ts

@@ -13,8 +13,8 @@ import { param, validationResult } from 'express-validator';
 import type { HydratedDocument } from 'mongoose';
 import mongoose from 'mongoose';
 
+import { projectRoot } from '~/server/util/project-dir-utils';
 import loggerFactory from '~/utils/logger';
-import { projectRoot } from '~/utils/project-dir-utils';
 
 import type Crowi from '../crowi';
 import { Attachment } from '../models/attachment';

+ 5 - 0
apps/app/src/server/service/config-manager/config-definition.ts

@@ -50,6 +50,7 @@ export const CONFIG_KEYS = [
   'app:aiEnabled',
   'app:publishOpenAPI',
   'app:maxFileSize',
+  'app:fileUploadTimeout',
   'app:fileUploadTotalLimit',
   'app:fileUploadDisabled',
   'app:elasticsearchVersion',
@@ -429,6 +430,10 @@ export const CONFIG_DEFINITIONS = {
     envVarName: 'MAX_FILE_SIZE',
     defaultValue: Infinity,
   }),
+  'app:fileUploadTimeout': defineConfig<number>({
+    envVarName: 'FILE_UPLOAD_TIMEOUT',
+    defaultValue: 10 * 60 * 1000, // 10 minutes
+  }),
   'app:fileUploadTotalLimit': defineConfig<number>({
     envVarName: 'FILE_UPLOAD_TOTAL_LIMIT',
     defaultValue: Infinity,

+ 1 - 1
apps/app/src/server/service/export.ts

@@ -13,7 +13,7 @@ import CollectionProgressingStatus from '../models/vo/collection-progressing-sta
 
 import type AppService from './app';
 import { configManager } from './config-manager';
-import type GrowiBridgeService from './growi-bridge';
+import type { GrowiBridgeService } from './growi-bridge';
 import { growiInfoService } from './growi-info';
 import type { ZipFileStat } from './interfaces/export';
 

+ 59 - 20
apps/app/src/server/service/file-uploader/aws/index.ts

@@ -28,7 +28,7 @@ import { configManager } from '../../config-manager';
 import {
   AbstractFileUploader, type TemporaryUrl, type SaveFileParam,
 } from '../file-uploader';
-import { ContentHeaders } from '../utils';
+import { createContentHeaders, getContentHeaderValue } from '../utils';
 
 import { AwsMultipartUploader } from './multipart-uploader';
 
@@ -44,6 +44,8 @@ interface FileMeta {
   size: number;
 }
 
+// Cache holder to avoid repeated instantiation of S3 client
+let cachedS3Client: { configKey: string, client: S3Client } | null = null;
 const isFileExists = async(s3: S3Client, params: HeadObjectCommandInput) => {
   try {
     await s3.send(new HeadObjectCommand(params));
@@ -86,12 +88,21 @@ const getS3Bucket = (): NonBlankString | undefined => {
 };
 
 const S3Factory = (): S3Client => {
+  // Cache key based on configuration values to detect changes
   const accessKeyId = configManager.getConfig('aws:s3AccessKeyId');
   const secretAccessKey = configManager.getConfig('aws:s3SecretAccessKey');
   const s3Region = toNonBlankStringOrUndefined(configManager.getConfig('aws:s3Region')); // Blank strings may remain in the DB, so convert with toNonBlankStringOrUndefined for safety
   const s3CustomEndpoint = toNonBlankStringOrUndefined(configManager.getConfig('aws:s3CustomEndpoint'));
 
-  return new S3Client({
+  const configKey = `${accessKeyId ?? ''}|${secretAccessKey ?? ''}|${s3Region ?? ''}|${s3CustomEndpoint ?? ''}`;
+
+  // Return cached client if configuration hasn't changed
+  if (cachedS3Client != null && cachedS3Client.configKey === configKey) {
+    return cachedS3Client.client;
+  }
+
+  // Create new client instance with connection pooling optimizations
+  const client = new S3Client({
     credentials: accessKeyId != null && secretAccessKey != null
       ? {
         accessKeyId,
@@ -102,6 +113,10 @@ const S3Factory = (): S3Client => {
     endpoint: s3CustomEndpoint,
     forcePathStyle: s3CustomEndpoint != null, // s3ForcePathStyle renamed to forcePathStyle in v3
   });
+
+  // Cache the new client
+  cachedS3Client = { configKey, client };
+  return client;
 };
 
 const getFilePathOnStorage = (attachment: IAttachmentDocument) => {
@@ -177,17 +192,38 @@ class AwsFileUploader extends AbstractFileUploader {
     const s3 = S3Factory();
 
     const filePath = getFilePathOnStorage(attachment);
-    const contentHeaders = new ContentHeaders(attachment);
+    const contentHeaders = createContentHeaders(attachment);
 
-    await s3.send(new PutObjectCommand({
-      Bucket: getS3Bucket(),
-      Key: filePath,
-      Body: readable,
-      ACL: getS3PutObjectCannedAcl(),
-      // put type and the file name for reference information when uploading
-      ContentType: contentHeaders.contentType?.value.toString(),
-      ContentDisposition: contentHeaders.contentDisposition?.value.toString(),
-    }));
+    try {
+      const uploadTimeout = configManager.getConfig('app:fileUploadTimeout');
+
+      await s3.send(
+        new PutObjectCommand({
+          Bucket: getS3Bucket(),
+          Key: filePath,
+          Body: readable,
+          ACL: getS3PutObjectCannedAcl(),
+          // put type and the file name for reference information when uploading
+          ContentType: getContentHeaderValue(contentHeaders, 'Content-Type'),
+          ContentDisposition: getContentHeaderValue(contentHeaders, 'Content-Disposition'),
+        }),
+        { abortSignal: AbortSignal.timeout(uploadTimeout) },
+      );
+
+      logger.debug(`File upload completed successfully: fileName=${attachment.fileName}`);
+    }
+    catch (error) {
+      // Handle timeout error specifically
+      if (error.name === 'AbortError') {
+        logger.warn(`Upload timeout: fileName=${attachment.fileName}`, error);
+      }
+      else {
+        logger.error(`File upload failed: fileName=${attachment.fileName}`, error);
+      }
+      // Re-throw the error to be handled by the caller.
+      // The pipeline automatically handles stream cleanup on error.
+      throw error;
+    }
   }
 
   /**
@@ -205,7 +241,7 @@ class AwsFileUploader extends AbstractFileUploader {
       throw new Error('AWS is not configured.');
     }
 
-    const s3 = S3Factory();
+    const s3 = S3Factory(); // Use singleton client
     const filePath = getFilePathOnStorage(attachment);
 
     const params = {
@@ -220,20 +256,20 @@ class AwsFileUploader extends AbstractFileUploader {
     }
 
     try {
-      const body = (await s3.send(new GetObjectCommand(params))).Body;
+      const response = await s3.send(new GetObjectCommand(params));
+      const body = response.Body;
 
       if (body == null) {
         throw new Error(`S3 returned null for the Attachment (${filePath})`);
       }
 
-      // eslint-disable-next-line no-nested-ternary
       return 'stream' in body
         ? body.stream() as unknown as NodeJS.ReadableStream // get stream from Blob and cast force
         : body as unknown as NodeJS.ReadableStream; // cast force
     }
     catch (err) {
-      logger.error(err);
-      throw new Error(`Coudn't get file from AWS for the Attachment (${attachment._id.toString()})`);
+      logger.error(`Failed to get file from AWS S3 for attachment ${attachment._id.toString()}:`, err);
+      throw new Error(`Couldn't get file from AWS for the Attachment (${attachment._id.toString()})`);
     }
   }
 
@@ -252,12 +288,12 @@ class AwsFileUploader extends AbstractFileUploader {
     // issue signed url (default: expires 120 seconds)
     // https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#getSignedUrl-property
     const isDownload = opts?.download ?? false;
-    const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
+    const contentHeaders = createContentHeaders(attachment, { inline: !isDownload });
     const params: GetObjectCommandInput = {
       Bucket: getS3Bucket(),
       Key: filePath,
-      ResponseContentType: contentHeaders.contentType?.value.toString(),
-      ResponseContentDisposition: contentHeaders.contentDisposition?.value.toString(),
+      ResponseContentType: getContentHeaderValue(contentHeaders, 'Content-Type'),
+      ResponseContentDisposition: getContentHeaderValue(contentHeaders, 'Content-Disposition'),
     };
     const signedUrl = await getSignedUrl(s3, new GetObjectCommand(params), {
       expiresIn: lifetimeSecForTemporaryUrl,
@@ -282,12 +318,15 @@ class AwsFileUploader extends AbstractFileUploader {
         Key: uploadKey,
         UploadId: uploadId,
       }));
+      logger.debug(`Successfully aborted multipart upload: uploadKey=${uploadKey}, uploadId=${uploadId}`);
     }
     catch (e) {
       // allow duplicate abort requests to ensure abortion
       if (e.response?.status !== 404) {
+        logger.error(`Failed to abort multipart upload: uploadKey=${uploadKey}, uploadId=${uploadId}`, e);
         throw e;
       }
+      logger.debug(`Multipart upload already aborted: uploadKey=${uploadKey}, uploadId=${uploadId}`);
     }
   }
 

+ 81 - 16
apps/app/src/server/service/file-uploader/azure.ts

@@ -29,7 +29,7 @@ import { configManager } from '../config-manager';
 import {
   AbstractFileUploader, type TemporaryUrl, type SaveFileParam,
 } from './file-uploader';
-import { ContentHeaders } from './utils';
+import { createContentHeaders, getContentHeaderValue } from './utils';
 
 const urljoin = require('url-join');
 
@@ -45,6 +45,11 @@ type AzureConfig = {
   containerName: string,
 }
 
+// Cache holders to avoid repeated instantiation of credential and clients
+let cachedCredential: { key: string, credential: TokenCredential } | null = null;
+let cachedBlobServiceClient: { key: string, client: BlobServiceClient } | null = null;
+let cachedContainerClient: { key: string, client: ContainerClient } | null = null;
+
 
 function getAzureConfig(): AzureConfig {
   const accountName = configManager.getConfig('azure:storageAccountName');
@@ -61,6 +66,7 @@ function getAzureConfig(): AzureConfig {
 }
 
 function getCredential(): TokenCredential {
+  // Build cache key from credential-related configs
   const tenantId = toNonBlankStringOrUndefined(configManager.getConfig('azure:tenantId'));
   const clientId = toNonBlankStringOrUndefined(configManager.getConfig('azure:clientId'));
   const clientSecret = toNonBlankStringOrUndefined(configManager.getConfig('azure:clientSecret'));
@@ -69,13 +75,52 @@ function getCredential(): TokenCredential {
     throw new Error(`Azure Blob Storage missing required configuration: tenantId=${tenantId}, clientId=${clientId}, clientSecret=${clientSecret}`);
   }
 
-  return new ClientSecretCredential(tenantId, clientId, clientSecret);
+  const key = `${tenantId}|${clientId}|${clientSecret}`;
+
+  // Reuse cached credential when config has not changed
+  if (cachedCredential != null && cachedCredential.key === key) {
+    return cachedCredential.credential;
+  }
+
+  const credential = new ClientSecretCredential(tenantId, clientId, clientSecret);
+  cachedCredential = { key, credential };
+  return credential;
+}
+
+function getBlobServiceClient(): BlobServiceClient {
+  const { accountName } = getAzureConfig();
+  // Include credential cache key to ensure we re-create if cred changed
+  const credential = getCredential();
+  const credentialKey = (cachedCredential?.key) ?? 'unknown-cred';
+  const key = `${accountName}|${credentialKey}`;
+
+  if (cachedBlobServiceClient != null && cachedBlobServiceClient.key === key) {
+    return cachedBlobServiceClient.client;
+  }
+
+  // Use keep-alive to minimize socket churn; reuse client across calls
+  const client = new BlobServiceClient(
+    `https://${accountName}.blob.core.windows.net`,
+    credential,
+    { keepAliveOptions: { enable: true } },
+  );
+  cachedBlobServiceClient = { key, client };
+  return client;
 }
 
 async function getContainerClient(): Promise<ContainerClient> {
   const { accountName, containerName } = getAzureConfig();
-  const blobServiceClient = new BlobServiceClient(`https://${accountName}.blob.core.windows.net`, getCredential());
-  return blobServiceClient.getContainerClient(containerName);
+  const credentialKey = (cachedCredential?.key) ?? 'unknown-cred';
+  const key = `${accountName}|${containerName}|${credentialKey}`;
+
+  if (cachedContainerClient != null && cachedContainerClient.key === key) {
+    return cachedContainerClient.client;
+  }
+
+  const blobServiceClient = getBlobServiceClient();
+  const client = blobServiceClient.getContainerClient(containerName);
+  cachedContainerClient = { key, client };
+  return client;
 }
 
 function getFilePathOnStorage(attachment: IAttachmentDocument) {
@@ -132,15 +177,34 @@ class AzureFileUploader extends AbstractFileUploader {
     const filePath = getFilePathOnStorage(attachment);
     const containerClient = await getContainerClient();
     const blockBlobClient: BlockBlobClient = containerClient.getBlockBlobClient(filePath);
-    const contentHeaders = new ContentHeaders(attachment);
+    const contentHeaders = createContentHeaders(attachment);
 
-    await blockBlobClient.uploadStream(readable, undefined, undefined, {
-      blobHTTPHeaders: {
-        // put type and the file name for reference information when uploading
-        blobContentType: contentHeaders.contentType?.value.toString(),
-        blobContentDisposition: contentHeaders.contentDisposition?.value.toString(),
-      },
-    });
+    try {
+      const uploadTimeout = configManager.getConfig('app:fileUploadTimeout');
+
+      await blockBlobClient.uploadStream(readable, undefined, undefined, {
+        blobHTTPHeaders: {
+          // put type and the file name for reference information when uploading
+          blobContentType: getContentHeaderValue(contentHeaders, 'Content-Type'),
+          blobContentDisposition: getContentHeaderValue(contentHeaders, 'Content-Disposition'),
+        },
+        abortSignal: AbortSignal.timeout(uploadTimeout),
+      });
+
+      logger.debug(`File upload completed successfully: fileName=${attachment.fileName}`);
+    }
+    catch (error) {
+      // Handle timeout error specifically
+      if (error.name === 'AbortError') {
+        logger.warn(`Upload timeout: fileName=${attachment.fileName}`, error);
+      }
+      else {
+        logger.error(`File upload failed: fileName=${attachment.fileName}`, error);
+      }
+      // Re-throw the error to be handled by the caller.
+      // The pipeline automatically handles stream cleanup on error.
+      throw error;
+    }
   }
 
   /**
@@ -202,7 +266,8 @@ class AzureFileUploader extends AbstractFileUploader {
 
     const sasToken = await (async() => {
       const { accountName, containerName } = getAzureConfig();
-      const blobServiceClient = new BlobServiceClient(`https://${accountName}.blob.core.windows.net`, getCredential());
+      // Reuse the same BlobServiceClient (singleton)
+      const blobServiceClient = getBlobServiceClient();
 
       const now = Date.now();
       const startsOn = new Date(now - 30 * 1000);
@@ -210,7 +275,7 @@ class AzureFileUploader extends AbstractFileUploader {
       const userDelegationKey = await blobServiceClient.getUserDelegationKey(startsOn, expiresOn);
 
       const isDownload = opts?.download ?? false;
-      const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
+      const contentHeaders = createContentHeaders(attachment, { inline: !isDownload });
 
       // https://github.com/Azure/azure-sdk-for-js/blob/d4d55f73/sdk/storage/storage-blob/src/ContainerSASPermissions.ts#L24
       // r:read, a:add, c:create, w:write, d:delete, l:list
@@ -221,8 +286,8 @@ class AzureFileUploader extends AbstractFileUploader {
         protocol: SASProtocol.HttpsAndHttp,
         startsOn,
         expiresOn,
-        contentType: contentHeaders.contentType?.value.toString(),
-        contentDisposition: contentHeaders.contentDisposition?.value.toString(),
+        contentType: getContentHeaderValue(contentHeaders, 'Content-Type'),
+        contentDisposition: getContentHeaderValue(contentHeaders, 'Content-Disposition'),
       };
 
       return generateBlobSASQueryParameters(sasOptions, userDelegationKey, accountName).toString();

+ 34 - 10
apps/app/src/server/service/file-uploader/gcs/index.ts

@@ -17,7 +17,7 @@ import { configManager } from '../../config-manager';
 import {
   AbstractFileUploader, type TemporaryUrl, type SaveFileParam,
 } from '../file-uploader';
-import { ContentHeaders } from '../utils';
+import { createContentHeaders, getContentHeaderValue } from '../utils';
 
 import { GcsMultipartUploader } from './multipart-uploader';
 
@@ -131,14 +131,38 @@ class GcsFileUploader extends AbstractFileUploader {
     const gcs = getGcsInstance();
     const myBucket = gcs.bucket(getGcsBucket());
     const filePath = getFilePathOnStorage(attachment);
-    const contentHeaders = new ContentHeaders(attachment);
+    const contentHeaders = createContentHeaders(attachment);
 
     const file = myBucket.file(filePath);
-
-    await pipeline(readable, file.createWriteStream({
+    const writeStream = file.createWriteStream({
       // put type and the file name for reference information when uploading
-      contentType: contentHeaders.contentType?.value.toString(),
-    }));
+      contentType: getContentHeaderValue(contentHeaders, 'Content-Type'),
+    });
+
+    try {
+      const uploadTimeout = configManager.getConfig('app:fileUploadTimeout');
+
+      // Use AbortSignal.timeout() for robust timeout handling (Node.js 16+)
+      await pipeline(
+        readable,
+        writeStream,
+        { signal: AbortSignal.timeout(uploadTimeout) },
+      );
+
+      logger.debug(`File upload completed successfully: fileName=${attachment.fileName}`);
+    }
+    catch (error) {
+      // Handle timeout error specifically
+      if (error.name === 'AbortError') {
+        logger.warn(`Upload timeout: fileName=${attachment.fileName}`, error);
+      }
+      else {
+        logger.error(`File upload failed: fileName=${attachment.fileName}`, error);
+      }
+      // Re-throw the error to be handled by the caller.
+      // The pipeline automatically handles stream cleanup on error.
+      throw error;
+    }
   }
 
   /**
@@ -172,7 +196,7 @@ class GcsFileUploader extends AbstractFileUploader {
     }
     catch (err) {
       logger.error(err);
-      throw new Error(`Coudn't get file from AWS for the Attachment (${attachment._id.toString()})`);
+      throw new Error(`Coudn't get file from GCS for the Attachment (${attachment._id.toString()})`);
     }
   }
 
@@ -193,12 +217,12 @@ class GcsFileUploader extends AbstractFileUploader {
     // issue signed url (default: expires 120 seconds)
     // https://cloud.google.com/storage/docs/access-control/signed-urls
     const isDownload = opts?.download ?? false;
-    const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
+    const contentHeaders = createContentHeaders(attachment, { inline: !isDownload });
     const [signedUrl] = await file.getSignedUrl({
       action: 'read',
       expires: Date.now() + lifetimeSecForTemporaryUrl * 1000,
-      responseType: contentHeaders.contentType?.value.toString(),
-      responseDisposition: contentHeaders.contentDisposition?.value.toString(),
+      responseType: getContentHeaderValue(contentHeaders, 'Content-Type'),
+      responseDisposition: getContentHeaderValue(contentHeaders, 'Content-Disposition'),
     });
 
     return {

+ 114 - 52
apps/app/src/server/service/file-uploader/gridfs.ts

@@ -12,20 +12,69 @@ import loggerFactory from '~/utils/logger';
 import { configManager } from '../config-manager';
 
 import { AbstractFileUploader, type TemporaryUrl, type SaveFileParam } from './file-uploader';
-import { ContentHeaders } from './utils';
+import { createContentHeaders, getContentHeaderValue } from './utils';
 
 const logger = loggerFactory('growi:service:fileUploaderGridfs');
 
-
 const COLLECTION_NAME = 'attachmentFiles';
 const CHUNK_COLLECTION_NAME = `${COLLECTION_NAME}.chunks`;
 
-// instantiate mongoose-gridfs
-const AttachmentFile = createModel({
-  modelName: COLLECTION_NAME,
-  bucketName: COLLECTION_NAME,
-  connection: mongoose.connection,
-});
+type PromisifiedUtils = {
+  read: (options?: object) => Readable;
+  // eslint-disable-next-line @typescript-eslint/ban-types
+  write: (file: object, stream: Readable, done?: Function) => void;
+  // eslint-disable-next-line @typescript-eslint/ban-types
+  unlink: (file: object, done?: Function) => void;
+  promisifiedWrite: (file: object, readable: Readable) => Promise<any>;
+  promisifiedUnlink: (file: object) => Promise<any>;
+}
+
+type AttachmentFileModel = mongoose.Model<any> & PromisifiedUtils;
+
+// Cache holders to avoid repeated model creation and manage lifecycle
+let cachedAttachmentFileModel: AttachmentFileModel;
+let cachedChunkCollection: mongoose.Collection;
+let cachedConnection: mongoose.Connection; // Track the connection instance itself
+
+/**
+ * Initialize GridFS models with connection instance monitoring
+ * This prevents memory leaks from repeated model creation
+ */
+function initializeGridFSModels(): { attachmentFileModel: AttachmentFileModel, chunkCollection: mongoose.Collection } {
+  // Check if we can reuse cached models by comparing connection instance
+  if (cachedAttachmentFileModel != null && cachedChunkCollection != null && cachedConnection === mongoose.connection) {
+    return { attachmentFileModel: cachedAttachmentFileModel, chunkCollection: cachedChunkCollection };
+  }
+
+  // Check connection state
+  if (mongoose.connection.readyState !== 1) {
+    throw new Error('MongoDB connection is not ready for GridFS operations');
+  }
+
+  // Create new model instances
+  const attachmentFileModel: AttachmentFileModel = createModel({
+    modelName: COLLECTION_NAME,
+    bucketName: COLLECTION_NAME,
+    connection: mongoose.connection,
+  });
+
+  const chunkCollection = mongoose.connection.collection(CHUNK_COLLECTION_NAME);
+
+  // Setup promisified methods on the model instance (not globally)
+  if (!attachmentFileModel.promisifiedWrite) {
+    attachmentFileModel.promisifiedWrite = util.promisify(attachmentFileModel.write).bind(attachmentFileModel);
+    attachmentFileModel.promisifiedUnlink = util.promisify(attachmentFileModel.unlink).bind(attachmentFileModel);
+  }
+
+  // Cache the instances
+  cachedAttachmentFileModel = attachmentFileModel;
+  cachedChunkCollection = chunkCollection;
+  cachedConnection = mongoose.connection;
+
+  logger.debug('GridFS models initialized successfully');
+
+  return { attachmentFileModel, chunkCollection };
+}
 
 
 // TODO: rewrite this module to be a type-safe implementation
@@ -65,13 +114,14 @@ class GridfsFileUploader extends AbstractFileUploader {
   override async uploadAttachment(readable: Readable, attachment: IAttachmentDocument): Promise<void> {
     logger.debug(`File uploading: fileName=${attachment.fileName}`);
 
-    const contentHeaders = new ContentHeaders(attachment);
+    const { attachmentFileModel } = initializeGridFSModels();
+    const contentHeaders = createContentHeaders(attachment);
 
-    return AttachmentFile.promisifiedWrite(
+    return attachmentFileModel.promisifiedWrite(
       {
         // put type and the file name for reference information when uploading
         filename: attachment.fileName,
-        contentType: contentHeaders.contentType?.value.toString(),
+        contentType: getContentHeaderValue(contentHeaders, 'Content-Type'),
       },
       readable,
     );
@@ -104,60 +154,42 @@ class GridfsFileUploader extends AbstractFileUploader {
 module.exports = function(crowi: Crowi) {
   const lib = new GridfsFileUploader(crowi);
 
-  // get Collection instance of chunk
-  const chunkCollection = mongoose.connection.collection(CHUNK_COLLECTION_NAME);
-
-  // create promisified method
-  AttachmentFile.promisifiedWrite = util.promisify(AttachmentFile.write).bind(AttachmentFile);
-  AttachmentFile.promisifiedUnlink = util.promisify(AttachmentFile.unlink).bind(AttachmentFile);
-
   lib.isValidUploadSettings = function() {
     return true;
   };
 
   (lib as any).deleteFile = async function(attachment) {
+    const { attachmentFileModel } = initializeGridFSModels();
     const filenameValue = attachment.fileName;
 
-    const attachmentFile = await AttachmentFile.findOne({ filename: filenameValue });
+    const attachmentFile = await attachmentFileModel.findOne({ filename: filenameValue });
 
     if (attachmentFile == null) {
       logger.warn(`Any AttachmentFile that relate to the Attachment (${attachment._id.toString()}) does not exist in GridFS`);
       return;
     }
-    return AttachmentFile.promisifiedUnlink({ _id: attachmentFile._id });
+
+    return attachmentFileModel.promisifiedUnlink({ _id: attachmentFile._id });
   };
 
+  /**
+   * Bulk delete files since unlink method of mongoose-gridfs does not support bulk operation
+   */
   (lib as any).deleteFiles = async function(attachments) {
+    const { attachmentFileModel, chunkCollection } = initializeGridFSModels();
+
     const filenameValues = attachments.map((attachment) => {
       return attachment.fileName;
     });
-    const fileIdObjects = await AttachmentFile.find({ filename: { $in: filenameValues } }, { _id: 1 });
+    const fileIdObjects = await attachmentFileModel.find({ filename: { $in: filenameValues } }, { _id: 1 });
     const idsRelatedFiles = fileIdObjects.map((obj) => { return obj._id });
 
     return Promise.all([
-      AttachmentFile.deleteMany({ filename: { $in: filenameValues } }),
+      attachmentFileModel.deleteMany({ filename: { $in: filenameValues } }),
       chunkCollection.deleteMany({ files_id: { $in: idsRelatedFiles } }),
     ]);
   };
 
-  /**
-   * get size of data uploaded files using (Promise wrapper)
-   */
-  // const getCollectionSize = () => {
-  //   return new Promise((resolve, reject) => {
-  //     chunkCollection.stats((err, data) => {
-  //       if (err) {
-  //         // return 0 if not exist
-  //         if (err.errmsg.includes('not found')) {
-  //           return resolve(0);
-  //         }
-  //         return reject(err);
-  //       }
-  //       return resolve(data.size);
-  //     });
-  //   });
-  // };
-
   /**
    * check the file size limit
    *
@@ -172,17 +204,44 @@ module.exports = function(crowi: Crowi) {
   };
 
   lib.saveFile = async function({ filePath, contentType, data }) {
-    const readable = new Readable();
-    readable.push(data);
-    readable.push(null); // EOF
+    const { attachmentFileModel } = initializeGridFSModels();
 
-    return AttachmentFile.promisifiedWrite(
-      {
-        filename: filePath,
-        contentType,
+    // Create a readable stream from the data
+    const readable = new Readable({
+      read() {
+        this.push(data);
+        this.push(null); // EOF
       },
-      readable,
-    );
+    });
+
+    try {
+      // Add error handling to prevent resource leaks
+      readable.on('error', (err) => {
+        logger.error('Readable stream error:', err);
+        readable.destroy();
+        throw err;
+      });
+
+      // Use async/await for cleaner code
+      const result = await attachmentFileModel.promisifiedWrite(
+        {
+          filename: filePath,
+          contentType,
+        },
+        readable,
+      );
+
+      return result;
+    }
+    catch (error) {
+      throw error;
+    }
+    finally {
+      // Explicit cleanup to prevent memory leaks
+      if (typeof readable.destroy === 'function') {
+        readable.destroy();
+      }
+    }
   };
 
   /**
@@ -192,23 +251,26 @@ module.exports = function(crowi: Crowi) {
    * @return {stream.Readable} readable stream
    */
   lib.findDeliveryFile = async function(attachment) {
+    const { attachmentFileModel } = initializeGridFSModels();
     const filenameValue = attachment.fileName;
 
-    const attachmentFile = await AttachmentFile.findOne({ filename: filenameValue });
+    const attachmentFile = await attachmentFileModel.findOne({ filename: filenameValue });
 
     if (attachmentFile == null) {
       throw new Error(`Any AttachmentFile that relate to the Attachment (${attachment._id.toString()}) does not exist in GridFS`);
     }
 
     // return stream.Readable
-    return AttachmentFile.read({ _id: attachmentFile._id });
+    return attachmentFileModel.read({ _id: attachmentFile._id });
   };
 
   /**
    * List files in storage
    */
   (lib as any).listFiles = async function() {
-    const attachmentFiles = await AttachmentFile.find();
+    const { attachmentFileModel } = initializeGridFSModels();
+
+    const attachmentFiles = await attachmentFileModel.find();
     return attachmentFiles.map(({ filename: name, length: size }) => ({
       name, size,
     }));

+ 25 - 4
apps/app/src/server/service/file-uploader/local.ts

@@ -15,7 +15,7 @@ import {
   AbstractFileUploader, type TemporaryUrl, type SaveFileParam,
 } from './file-uploader';
 import {
-  ContentHeaders, applyHeaders,
+  applyHeaders, createContentHeaders, toExpressHttpHeaders,
 } from './utils';
 
 
@@ -166,7 +166,28 @@ module.exports = function(crowi: Crowi) {
 
     const writeStream: Writable = fs.createWriteStream(filePath);
 
-    return pipeline(fileStream, writeStream);
+    try {
+      const uploadTimeout = configManager.getConfig('app:fileUploadTimeout');
+      await pipeline(
+        fileStream,
+        writeStream,
+        { signal: AbortSignal.timeout(uploadTimeout) },
+      );
+
+      logger.debug(`File upload completed successfully: fileName=${attachment.fileName}`);
+    }
+    catch (error) {
+      // Handle timeout error specifically
+      if (error.name === 'AbortError') {
+        logger.warn(`Upload timeout: fileName=${attachment.fileName}`, error);
+      }
+      else {
+        logger.error(`File upload failed: fileName=${attachment.fileName}`, error);
+      }
+      // Re-throw the error to be handled by the caller.
+      // The pipeline automatically handles stream cleanup on error.
+      throw error;
+    }
   };
 
   lib.saveFile = async function({ filePath, contentType, data }) {
@@ -229,9 +250,9 @@ module.exports = function(crowi: Crowi) {
     const internalPath = urljoin(internalPathRoot, relativePath);
 
     const isDownload = opts?.download ?? false;
-    const contentHeaders = new ContentHeaders(attachment, { inline: !isDownload });
+    const contentHeaders = createContentHeaders(attachment, { inline: !isDownload });
     applyHeaders(res, [
-      ...contentHeaders.toExpressHttpHeaders(),
+      ...toExpressHttpHeaders(contentHeaders),
       { field: 'X-Accel-Redirect', value: internalPath },
       { field: 'X-Sendfile', value: storagePath },
     ]);

+ 47 - 47
apps/app/src/server/service/file-uploader/utils/headers.ts

@@ -1,68 +1,68 @@
 import type { Response } from 'express';
 
-import type { ExpressHttpHeader, IContentHeaders } from '~/server/interfaces/attachment';
+import type { ExpressHttpHeader } from '~/server/interfaces/attachment';
 import type { IAttachmentDocument } from '~/server/models/attachment';
 
+type ContentHeaderField = 'Content-Type' | 'Content-Security-Policy' | 'Content-Disposition' | 'Content-Length';
+type ContentHeader = ExpressHttpHeader<ContentHeaderField>;
 
-export class ContentHeaders implements IContentHeaders {
-
-  contentType?: ExpressHttpHeader<'Content-Type'>;
+/**
+ * Factory function to generate content headers.
+ * This approach avoids creating a class instance for each call, improving memory efficiency.
+ */
+export const createContentHeaders = (attachment: IAttachmentDocument, opts?: { inline?: boolean }): ContentHeader[] => {
+  const headers: ContentHeader[] = [];
 
-  contentLength?: ExpressHttpHeader<'Content-Length'>;
+  // Content-Type
+  headers.push({
+    field: 'Content-Type',
+    value: attachment.fileFormat,
+  });
 
-  contentSecurityPolicy?: ExpressHttpHeader<'Content-Security-Policy'>;
+  // Content-Security-Policy
+  headers.push({
+    field: 'Content-Security-Policy',
+    // eslint-disable-next-line max-len
+    value: "script-src 'unsafe-hashes'; style-src 'self' 'unsafe-inline'; object-src 'none'; require-trusted-types-for 'script'; media-src 'self'; default-src 'none';",
+  });
 
-  contentDisposition?: ExpressHttpHeader<'Content-Disposition'>;
+  // Content-Disposition
+  headers.push({
+    field: 'Content-Disposition',
+    value: `${opts?.inline ? 'inline' : 'attachment'};filename*=UTF-8''${encodeURIComponent(attachment.originalName)}`,
+  });
 
-  constructor(attachment: IAttachmentDocument, opts?: {
-    inline?: boolean,
-  }) {
+  // Content-Length
+  if (attachment.fileSize != null) {
+    headers.push({
+      field: 'Content-Length',
+      value: attachment.fileSize.toString(),
+    });
+  }
 
-    this.contentType = {
-      field: 'Content-Type',
-      value: attachment.fileFormat,
-    };
-    this.contentSecurityPolicy = {
-      field: 'Content-Security-Policy',
-      // eslint-disable-next-line max-len
-      value: "script-src 'unsafe-hashes'; style-src 'self' 'unsafe-inline'; object-src 'none'; require-trusted-types-for 'script'; media-src 'self'; default-src 'none';",
-    };
-    this.contentDisposition = {
-      field: 'Content-Disposition',
-      value: `${opts?.inline ? 'inline' : 'attachment'};filename*=UTF-8''${encodeURIComponent(attachment.originalName)}`,
-    };
+  return headers;
+};
 
-    if (attachment.fileSize) {
-      this.contentLength = {
-        field: 'Content-Length',
-        value: attachment.fileSize.toString(),
-      };
-    }
-  }
+export const getContentHeaderValue = (contentHeaders: ContentHeader[], field: ContentHeaderField): string | undefined => {
+  const header = contentHeaders.find(h => h.field === field);
+  return header?.value.toString();
+};
 
-  /**
-   * Convert to ExpressHttpHeader[]
-   */
-  toExpressHttpHeaders(): ExpressHttpHeader[] {
-    return [
-      this.contentType,
-      this.contentLength,
-      this.contentSecurityPolicy,
-      this.contentDisposition,
-    ]
+/**
+ * Convert to ExpressHttpHeader[]
+ */
+export function toExpressHttpHeaders(records: Record<string, string | string[]>): ExpressHttpHeader[];
+export function toExpressHttpHeaders(contentHeaders: ContentHeader[]): ExpressHttpHeader[];
+export function toExpressHttpHeaders(arg: Record<string, string | string[]> | ContentHeader[]): ExpressHttpHeader[] {
+  if (Array.isArray(arg)) {
+    return arg
       // exclude undefined
       .filter((member): member is NonNullable<typeof member> => member != null);
   }
 
+  return Object.entries(arg).map(([field, value]) => { return { field, value } });
 }
 
-/**
- * Convert Record to ExpressHttpHeader[]
- */
-export const toExpressHttpHeaders = (records: Record<string, string | string[]>): ExpressHttpHeader[] => {
-  return Object.entries(records).map(([field, value]) => { return { field, value } });
-};
-
 export const applyHeaders = (res: Response, headers: ExpressHttpHeader[]): void => {
   headers.forEach((header) => {
     res.header(header.field, header.value);

+ 6 - 6
apps/app/src/server/service/g2g-transfer.ts

@@ -189,7 +189,7 @@ interface Receiver {
     innerFileStats: any[],
     optionsMap: { [key: string]: GrowiArchiveImportOption; },
     operatorUserId: string,
-  ): { [key: string]: ImportSettings; }
+  ): Map<string, ImportSettings>
   /**
    * Import collections
    * @param {string} collections Array of collection name
@@ -198,7 +198,7 @@ interface Receiver {
    */
   importCollections(
     collections: string[],
-    importSettingsMap: { [key: string]: ImportSettings; },
+    importSettingsMap: Map<string, ImportSettings>,
     sourceGROWIUploadConfigs: FileUploadConfigs,
   ): Promise<void>
   /**
@@ -618,8 +618,8 @@ export class G2GTransferReceiverService implements Receiver {
       innerFileStats: any[],
       optionsMap: { [key: string]: GrowiArchiveImportOption; },
       operatorUserId: string,
-  ): { [key: string]: ImportSettings; } {
-    const importSettingsMap = {};
+  ): Map<string, ImportSettings> {
+    const importSettingsMap = new Map<string, ImportSettings>();
     innerFileStats.forEach(({ fileName, collectionName }) => {
       const options = new GrowiArchiveImportOption(collectionName, undefined, optionsMap[collectionName]);
 
@@ -641,7 +641,7 @@ export class G2GTransferReceiverService implements Receiver {
         jsonFileName: fileName,
         overwriteParams: generateOverwriteParams(collectionName, operatorUserId, options),
       };
-      importSettingsMap[collectionName] = importSettings;
+      importSettingsMap.set(collectionName, importSettings);
     });
 
     return importSettingsMap;
@@ -649,7 +649,7 @@ export class G2GTransferReceiverService implements Receiver {
 
   public async importCollections(
       collections: string[],
-      importSettingsMap: { [key: string]: ImportSettings; },
+      importSettingsMap: Map<string, ImportSettings>,
       sourceGROWIUploadConfigs: FileUploadConfigs,
   ): Promise<void> {
     const { appService } = this.crowi;

+ 1 - 3
apps/app/src/server/service/growi-bridge/index.ts

@@ -19,7 +19,7 @@ const logger = loggerFactory('growi:services:GrowiBridgeService'); // eslint-dis
  * the service class for bridging GROWIs (export and import)
  * common properties and methods between export service and import service are defined in this service
  */
-class GrowiBridgeService {
+export class GrowiBridgeService {
 
   crowi: Crowi;
 
@@ -123,5 +123,3 @@ class GrowiBridgeService {
   }
 
 }
-
-export default GrowiBridgeService;

+ 1 - 1
apps/app/src/server/service/i18next.ts

@@ -7,7 +7,7 @@ import resourcesToBackend from 'i18next-resources-to-backend';
 
 import * as i18nextConfig from '^/config/i18next.config';
 
-import { resolveFromRoot } from '~/utils/project-dir-utils';
+import { resolveFromRoot } from '~/server/util/project-dir-utils';
 
 import { configManager } from './config-manager';
 

+ 23 - 3
apps/app/src/server/service/import/construct-convert-map.ts

@@ -10,10 +10,29 @@ export type ConvertMap = {
   }
 }
 
+/**
+ * Special conversion functions for problematic fields
+ * Add entries here for fields that require custom handling during import
+ */
+const SPECIAL_CONVERT_FUNCTIONS: Record<string, Record<string, OverwriteFunction>> = {
+  activities: {
+    snapshot: (value: unknown) => value, // Skip SubdocumentPath casting to avoid Mongoose errors
+  },
+  // Add more collections and fields as needed:
+  // otherCollection: {
+  //   problematicField: (value: unknown) => customProcessing(value),
+  // },
+};
+
+/**
+ * Get special conversion function for a specific collection.field combination
+ */
+const getSpecialConvertFunction = (collectionName: string, propertyName: string): OverwriteFunction | null => {
+  return SPECIAL_CONVERT_FUNCTIONS[collectionName]?.[propertyName] ?? null;
+};
+
 /**
  * Initialize convert map. set keepOriginal as default
- *
- * @param {Crowi} crowi Crowi instance
  */
 export const constructConvertMap = (): ConvertMap => {
   const convertMap: ConvertMap = {};
@@ -30,7 +49,8 @@ export const constructConvertMap = (): ConvertMap => {
     convertMap[collectionName] = {};
 
     for (const key of Object.keys(model.schema.paths)) {
-      convertMap[collectionName][key] = keepOriginal;
+      const specialHandler = getSpecialConvertFunction(collectionName, key);
+      convertMap[collectionName][key] = specialHandler ?? keepOriginal;
     }
   });
 

+ 131 - 95
apps/app/src/server/service/import/import.ts

@@ -1,13 +1,13 @@
 import fs from 'fs';
 import path from 'path';
 import type { EventEmitter } from 'stream';
-import { Writable, Transform, pipeline } from 'stream';
-import { finished, pipeline as pipelinePromise } from 'stream/promises';
+import { Writable, Transform } from 'stream';
+import { pipeline } from 'stream/promises';
 
 import JSONStream from 'JSONStream';
 import gc from 'expose-gc/function';
 import type {
-  BulkWriteResult, MongoBulkWriteError, UnorderedBulkOperation, WriteError,
+  BulkWriteResult, MongoBulkWriteError, UnorderedBulkOperation, WriteError, BulkOperationBase,
 } from 'mongodb';
 import type { Document } from 'mongoose';
 import mongoose from 'mongoose';
@@ -51,6 +51,8 @@ class ImportingCollectionError extends Error {
 
 export class ImportService {
 
+  private modelCache: Map<string, { Model: any, schema: any }> = new Map();
+
   private crowi: Crowi;
 
   private growiBridgeService: any;
@@ -59,7 +61,7 @@ export class ImportService {
 
   private currentProgressingStatus: CollectionProgressingStatus | null;
 
-  private convertMap: ConvertMap;
+  private convertMap: ConvertMap | undefined;
 
   constructor(crowi: Crowi) {
     this.crowi = crowi;
@@ -139,7 +141,7 @@ export class ImportService {
    * @param collections MongoDB collection name
    * @param importSettingsMap
    */
-  async import(collections: string[], importSettingsMap: { [collectionName: string]: ImportSettings }): Promise<void> {
+  async import(collections: string[], importSettingsMap: Map<string, ImportSettings>): Promise<void> {
     await this.preImport();
 
     // init status object
@@ -147,7 +149,10 @@ export class ImportService {
 
     // process serially so as not to waste memory
     const promises = collections.map((collectionName) => {
-      const importSettings = importSettingsMap[collectionName];
+      const importSettings = importSettingsMap.get(collectionName);
+      if (importSettings == null) {
+        throw new Error(`ImportSettings for ${collectionName} is not found`);
+      }
       return this.importCollection(collectionName, importSettings);
     });
     for await (const promise of promises) {
@@ -172,6 +177,10 @@ export class ImportService {
     const shouldNormalizePages = currentIsV5Compatible && isImportPagesCollection;
 
     if (shouldNormalizePages) await this.crowi.pageService.normalizeAllPublicPages();
+
+    // Release caches after import process
+    this.modelCache.clear();
+    this.convertMap = undefined;
   }
 
   /**
@@ -183,13 +192,7 @@ export class ImportService {
     if (this.currentProgressingStatus == null) {
       throw new Error('Something went wrong: currentProgressingStatus is not initialized');
     }
-
-    // prepare functions invoked from custom streams
-    const convertDocuments = this.convertDocuments.bind(this);
-    const bulkOperate = this.bulkOperate.bind(this);
-    const execUnorderedBulkOpSafely = this.execUnorderedBulkOpSafely.bind(this);
-    const emitProgressEvent = this.emitProgressEvent.bind(this);
-
+    // Avoid closure references by passing direct method references
     const collection = mongoose.connection.collection(collectionName);
 
     const { mode, jsonFileName, overwriteParams } = importSettings;
@@ -215,52 +218,71 @@ export class ImportService {
       // stream 3
       const convertStream = new Transform({
         objectMode: true,
-        transform(doc, encoding, callback) {
-          const converted = convertDocuments(collectionName, doc, overwriteParams);
-          this.push(converted);
-          callback();
+        transform(this: Transform, doc, encoding, callback) {
+          try {
+          // Direct reference to convertDocuments
+            const converted = (importSettings as any).service.convertDocuments(collectionName, doc, overwriteParams);
+            this.push(converted);
+            callback();
+          }
+          catch (error) {
+            callback(error);
+          }
         },
       });
+      // Reference for importService within Transform
+      (importSettings as any).service = this;
 
       // stream 4
       const batchStream = createBatchStream(BULK_IMPORT_SIZE);
-
-      // stream 5
       const writeStream = new Writable({
         objectMode: true,
-        async write(batch, encoding, callback) {
-          const unorderedBulkOp = collection.initializeUnorderedBulkOp();
-
-          // documents are not persisted until unorderedBulkOp.execute()
-          batch.forEach((document) => {
-            bulkOperate(unorderedBulkOp, collectionName, document, importSettings);
-          });
-
-          // exec
-          const { result, errors } = await execUnorderedBulkOpSafely(unorderedBulkOp);
-          const { insertedCount, modifiedCount } = result;
-          const errorCount = errors?.length ?? 0;
-
-          logger.debug(`Importing ${collectionName}. Inserted: ${insertedCount}. Modified: ${modifiedCount}. Failed: ${errorCount}.`);
-
-          const increment = insertedCount + modifiedCount + errorCount;
-          collectionProgress.currentCount += increment;
-          collectionProgress.totalCount += increment;
-          collectionProgress.insertedCount += insertedCount;
-          collectionProgress.modifiedCount += modifiedCount;
-
-          emitProgressEvent(collectionProgress, errors);
-
+        write: async(batch, encoding, callback) => {
           try {
+            const unorderedBulkOp = collection.initializeUnorderedBulkOp();
+            // documents are not persisted until unorderedBulkOp.execute()
+            batch.forEach((document) => {
+              this.bulkOperate(unorderedBulkOp, collectionName, document, importSettings);
+            });
+
+            // exec
+            const { result, errors } = await this.execUnorderedBulkOpSafely(unorderedBulkOp);
+            const {
+              insertedCount, modifiedCount, upsertedCount, matchedCount,
+            } = result;
+            const errorCount = errors?.length ?? 0;
+
+            // For upsert operations, count matched documents as modified
+            const actualModifiedCount = importSettings.mode === ImportMode.upsert
+              ? (matchedCount || 0) // In upsert mode, matchedCount indicates documents that were found and potentially updated
+              : modifiedCount;
+
+            const actualInsertedCount = importSettings.mode === ImportMode.upsert
+              ? (upsertedCount || 0) // In upsert mode, upsertedCount indicates newly created documents
+              : insertedCount;
+
+            logger.debug(`Importing ${collectionName}. Inserted: ${actualInsertedCount}. Modified: ${actualModifiedCount}. Failed: ${errorCount}.`
+              + ` (Raw: inserted=${insertedCount}, modified=${modifiedCount}, upserted=${upsertedCount}, matched=${matchedCount})`);
+            const increment = actualInsertedCount + actualModifiedCount + errorCount;
+            collectionProgress.currentCount += increment;
+            collectionProgress.totalCount += increment;
+            collectionProgress.insertedCount += actualInsertedCount;
+            collectionProgress.modifiedCount += actualModifiedCount;
+            this.emitProgressEvent(collectionProgress, errors);
             // First aid to prevent unexplained memory leaks
-            logger.info('global.gc() invoked.');
-            gc();
+            try {
+              logger.info('global.gc() invoked.');
+              gc();
+            }
+            catch (err) {
+              logger.error('fail garbage collection: ', err);
+            }
+            callback();
           }
           catch (err) {
-            logger.error('fail garbage collection: ', err);
+            logger.error('Error in writeStream:', err);
+            callback(err);
           }
-
-          callback();
         },
         final(callback) {
           logger.info(`Importing ${collectionName} has completed.`);
@@ -268,7 +290,13 @@ export class ImportService {
         },
       });
 
-      await pipelinePromise(readStream, jsonStream, convertStream, batchStream, writeStream);
+      await pipeline(readStream, jsonStream, convertStream, batchStream, writeStream);
+
+      // Ensure final progress event is emitted even when no data was processed
+      if (collectionProgress.currentCount === 0) {
+        logger.info(`No data processed for collection ${collectionName}. Emitting final progress event.`);
+        this.emitProgressEvent(collectionProgress, null);
+      }
 
       // clean up tmp directory
       fs.unlinkSync(jsonFile);
@@ -276,15 +304,9 @@ export class ImportService {
     catch (err) {
       throw new ImportingCollectionError(collectionProgress, err);
     }
-
   }
 
-  /**
-   *
-   * @param {string} collectionName
-   * @param {importSettings} importSettings
-   */
-  validateImportSettings(collectionName, importSettings) {
+  validateImportSettings(collectionName: string, importSettings: ImportSettings): void {
     const { mode } = importSettings;
 
     switch (collectionName) {
@@ -298,15 +320,18 @@ export class ImportService {
 
   /**
    * process bulk operation
-   * @param bulk MongoDB Bulk instance
-   * @param collectionName collection name
    */
-  bulkOperate(bulk, collectionName: string, document, importSettings: ImportSettings) {
+  bulkOperate(
+      bulk: UnorderedBulkOperation,
+      collectionName: string,
+      document: Record<string, unknown>,
+      importSettings: ImportSettings,
+  ): BulkOperationBase | void {
     // insert
     if (importSettings.mode !== ImportMode.upsert) {
+      // Optimization such as splitting and adding large documents can be considered
       return bulk.insert(document);
     }
-
     // upsert
     switch (collectionName) {
       case 'pages':
@@ -321,7 +346,7 @@ export class ImportService {
    * @param {CollectionProgress} collectionProgress
    * @param {object} appendedErrors key: collection name, value: array of error object
    */
-  emitProgressEvent(collectionProgress, appendedErrors) {
+  emitProgressEvent(collectionProgress: CollectionProgress, appendedErrors: any): void {
     const { collectionName } = collectionProgress;
 
     // send event (in progress in global)
@@ -331,7 +356,7 @@ export class ImportService {
   /**
    * emit terminate event
    */
-  emitTerminateEvent() {
+  emitTerminateEvent(): void {
     this.adminEvent.emit('onTerminateForImport');
   }
 
@@ -342,13 +367,12 @@ export class ImportService {
    * @param {string} zipFile absolute path to zip file
    * @return {Array.<string>} array of absolute paths to extracted files
    */
-  async unzip(zipFile) {
+  async unzip(zipFile: string): Promise<string[]> {
     const readStream = fs.createReadStream(zipFile);
     const parseStream = unzipStream.Parse();
-    const unzipEntryStream = pipeline(readStream, parseStream, () => {});
-    const files: string[] = [];
+    const entryPromises: Promise<string | null>[] = [];
 
-    unzipEntryStream.on('entry', (/** @type {Entry} */ entry) => {
+    parseStream.on('entry', (/** @type {Entry} */ entry) => {
       const fileName = entry.path;
       // https://regex101.com/r/mD4eZs/6
       // prevent from unexpecting attack doing unzip file (path traversal attack)
@@ -356,6 +380,7 @@ export class ImportService {
       // ../../src/server/example.html
       if (fileName.match(/(\.\.\/|\.\.\\)/)) {
         logger.error('File path is not appropriate.', fileName);
+        entry.autodrain();
         return;
       }
 
@@ -364,16 +389,28 @@ export class ImportService {
         entry.autodrain();
       }
       else {
-        const jsonFile = path.join(this.baseDir, fileName);
-        const writeStream = fs.createWriteStream(jsonFile, { encoding: this.growiBridgeService.getEncoding() });
-        pipeline(entry, writeStream, () => {});
-        files.push(jsonFile);
+        const entryPromise = new Promise<string | null>((resolve) => {
+          const jsonFile = path.join(this.baseDir, fileName);
+          const writeStream = fs.createWriteStream(jsonFile, { encoding: this.growiBridgeService.getEncoding() });
+
+          pipeline(entry, writeStream)
+            .then(() => resolve(jsonFile))
+            .catch((err) => {
+              logger.error('Failed to extract entry:', err);
+              resolve(null); // Continue processing other entries
+            });
+        });
+
+        entryPromises.push(entryPromise);
       }
     });
 
-    await finished(unzipEntryStream);
+    await pipeline(readStream, parseStream);
+    const results = await Promise.allSettled(entryPromises);
 
-    return files;
+    return results
+      .filter((result): result is PromiseFulfilledResult<string> => result.status === 'fulfilled' && result.value !== null)
+      .map(result => result.value);
   }
 
   /**
@@ -414,32 +451,32 @@ export class ImportService {
    * @returns document to be persisted
    */
   convertDocuments<D extends Document>(collectionName: string, document: D, overwriteParams: OverwriteParams): D {
-    const Model = getModelFromCollectionName(collectionName);
-    const schema = (Model != null) ? Model.schema : undefined;
-    const convertMap = this.convertMap[collectionName];
+  // Model and schema cache (optimization)
+    if (!this.modelCache) {
+      this.modelCache = new Map();
+    }
 
-    const _document: D = structuredClone(document);
+    let modelInfo = this.modelCache.get(collectionName);
+    if (!modelInfo) {
+      const Model = getModelFromCollectionName(collectionName);
+      const schema = (Model != null) ? Model.schema : undefined;
+      modelInfo = { Model, schema };
+      this.modelCache.set(collectionName, modelInfo);
+    }
 
-    // apply keepOriginal to all of properties
-    Object.entries(document).forEach(([propertyName, value]) => {
-      _document[propertyName] = keepOriginal(value, { document, propertyName });
-    });
+    const { schema } = modelInfo;
+    const convertMap = this.convertMap?.[collectionName];
 
-    // Mongoose Model
-    if (convertMap != null) {
-      // assign value from documents being imported
-      Object.entries(convertMap).forEach(([propertyName, convertedValue]) => {
-        const value = document[propertyName];
+    // Use shallow copy instead of structuredClone() when sufficient
+    const _document: D = (typeof document === 'object' && document !== null && !Array.isArray(document)) ? { ...document } : structuredClone(document);
 
-        // distinguish between null and undefined
-        if (value === undefined) {
-          return; // next entry
-        }
+    Object.entries(document).forEach(([propertyName, value]) => {
+      // Check if there's a custom convert function for this property, otherwise use keepOriginal
+      const convertedValue = convertMap?.[propertyName];
+      const convertFunc = (convertedValue != null && typeof convertedValue === 'function') ? convertedValue : keepOriginal;
 
-        const convertFunc = (typeof convertedValue === 'function') ? convertedValue : null;
-        _document[propertyName] = (convertFunc != null) ? convertFunc(value, { document, propertyName, schema }) : convertedValue;
-      });
-    }
+      _document[propertyName] = convertFunc(value, { document, propertyName, schema });
+    });
 
     // overwrite documents with custom values
     Object.entries(overwriteParams).forEach(([propertyName, overwriteValue]) => {
@@ -451,7 +488,6 @@ export class ImportService {
         _document[propertyName] = (overwriteFunc != null) ? overwriteFunc(value, { document: _document, propertyName, schema }) : overwriteValue;
       }
     });
-
     return _document;
   }
 
@@ -463,7 +499,7 @@ export class ImportService {
    * @memberOf ImportService
    * @param {object} meta meta data from meta.json
    */
-  validate(meta) {
+  validate(meta: any): void {
     if (meta.version !== getGrowiVersion()) {
       throw new Error('The version of this GROWI and the uploaded GROWI data are not the same');
     }
@@ -476,7 +512,7 @@ export class ImportService {
   /**
    * Delete all uploaded files
    */
-  deleteAllZipFiles() {
+  deleteAllZipFiles(): void {
     fs.readdirSync(this.baseDir)
       .filter(file => path.extname(file) === '.zip')
       .forEach(file => fs.unlinkSync(path.join(this.baseDir, file)));

+ 34 - 8
apps/app/src/server/service/search-delegator/elasticsearch.ts

@@ -23,7 +23,9 @@ import { configManager } from '../config-manager';
 import type { UpdateOrInsertPagesOpts } from '../interfaces/search';
 
 import { aggregatePipelineToIndex } from './aggregate-to-index';
-import type { AggregatedPage, BulkWriteBody, BulkWriteCommand } from './bulk-write';
+import type {
+  AggregatedPage, BulkWriteBody, BulkWriteCommand, BulkWriteBodyRestriction,
+} from './bulk-write';
 import {
   getClient,
   isES7ClientDelegator,
@@ -75,6 +77,10 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
 
   private indexName: string;
 
+  private pageModel?: PageModel;
+
+  private userModel?: typeof mongoose.Model;
+
   constructor(socketIoService: SocketIoService) {
     this.name = SearchDelegatorName.DEFAULT;
     this.socketIoService = socketIoService;
@@ -92,6 +98,26 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     this.isElasticsearchReindexOnBoot = configManager.getConfig('app:elasticsearchReindexOnBoot');
   }
 
+  /**
+   * Get Page model with proper typing
+   */
+  private getPageModel(): PageModel {
+    if (!this.pageModel) {
+      this.pageModel = mongoose.model<IPage, PageModel>('Page');
+    }
+    return this.pageModel;
+  }
+
+  /**
+   * Get User model with proper typing
+   */
+  private getUserModel() {
+    if (!this.userModel) {
+      this.userModel = mongoose.model('User');
+    }
+    return this.userModel;
+  }
+
   get aliasName(): string {
     return `${this.indexName}-alias`;
   }
@@ -359,7 +385,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
   /**
    * generate object that is related to page.grant*
    */
-  generateDocContentsRelatedToRestriction(page: AggregatedPage) {
+  generateDocContentsRelatedToRestriction(page: AggregatedPage): BulkWriteBodyRestriction {
     const grantedUserIds = page.grantedUsers.map(user => getIdStringForRef(user));
     const grantedGroupIds = page.grantedGroups.map(group => getIdStringForRef(group.item));
 
@@ -416,17 +442,17 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
   }
 
   addAllPages() {
-    const Page = mongoose.model('Page');
+    const Page = this.getPageModel();
     return this.updateOrInsertPages(() => Page.find(), { shouldEmitProgress: true, invokeGarbageCollection: true });
   }
 
   updateOrInsertPageById(pageId) {
-    const Page = mongoose.model('Page');
+    const Page = this.getPageModel();
     return this.updateOrInsertPages(() => Page.findById(pageId));
   }
 
   updateOrInsertDescendantsPagesById(page, user) {
-    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Page = this.getPageModel();
     const { PageQueryBuilder } = Page;
     const builder = new PageQueryBuilder(Page.find());
     builder.addConditionToListWithDescendants(page.path);
@@ -439,7 +465,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
   async updateOrInsertPages(queryFactory, option: UpdateOrInsertPagesOpts = {}): Promise<void> {
     const { shouldEmitProgress = false, invokeGarbageCollection = false } = option;
 
-    const Page = mongoose.model<IPage, PageModel>('Page');
+    const Page = this.getPageModel();
     const { PageQueryBuilder } = Page;
 
     const socket = shouldEmitProgress ? this.socketIoService.getAdminSocket() : null;
@@ -827,7 +853,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
       throw new Error('query.body.query.bool is not initialized');
     }
 
-    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Page = this.getPageModel();
     const {
       GRANT_PUBLIC, GRANT_SPECIFIED, GRANT_OWNER, GRANT_USER_GROUP,
     } = Page;
@@ -886,7 +912,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
   }
 
   async appendFunctionScore(query, queryString): Promise<void> {
-    const User = mongoose.model('User');
+    const User = this.getUserModel();
     const count = await User.count({}) || 1;
 
     const minScore = queryString.length * 0.1 - 1; // increase with length

+ 3 - 4
apps/app/src/utils/project-dir-utils.ts → apps/app/src/server/util/project-dir-utils.ts

@@ -1,9 +1,8 @@
-/* eslint-disable import/prefer-default-export */
+import fs from 'node:fs';
+import path from 'node:path';
+import process from 'node:process';
 
 import { isServer } from '@growi/core/dist/utils/browser-utils';
-import fs from 'fs';
-import path from 'path';
-import process from 'process';
 
 const isCurrentDirRoot = isServer() && fs.existsSync('./next.config.js');
 

+ 1 - 2
apps/app/src/stores-universal/context.tsx

@@ -1,8 +1,7 @@
+import type EventEmitter from 'node:events';
 import type { ColorScheme, IUserHasId } from '@growi/core';
-
 import { AcceptedUploadFileType } from '@growi/core';
 import { useSWRStatic } from '@growi/core/dist/swr';
-import type EventEmitter from 'events';
 import type { SWRResponse } from 'swr';
 import useSWR from 'swr';
 import useSWRImmutable from 'swr/immutable';

+ 1 - 0
apps/app/src/stores-universal/use-context-swr.tsx

@@ -1,4 +1,5 @@
 import { useSWRStatic } from '@growi/core/dist/swr';
+// biome-ignore lint/style/useNodejsImportProtocol: ignore
 import assert from 'assert';
 import type { Key, SWRConfiguration, SWRResponse } from 'swr';
 

+ 2 - 2
apps/app/src/utils/next.config.utils.js

@@ -1,7 +1,7 @@
 // workaround by https://github.com/martpie/next-transpile-modules/issues/143#issuecomment-817467144
 
-const fs = require('fs');
-const path = require('path');
+const fs = require('node:fs');
+const path = require('node:path');
 
 const nodeModulesPaths = [
   path.resolve(__dirname, '../../node_modules'),

+ 1 - 0
apps/app/test-with-vite/setup/jest-dom.ts

@@ -0,0 +1 @@
+import '@testing-library/jest-dom/vitest';

+ 1 - 1
apps/app/test/integration/models/page-redirect.test.js

@@ -3,7 +3,7 @@ import mongoose from 'mongoose';
 import { getInstance } from '../setup-crowi';
 
 describe('PageRedirect', () => {
-  // eslint-disable-next-line no-unused-vars
+  // biome-ignore lint/correctness/noUnusedVariables: ignore
   let crowi;
   let PageRedirect;
 

+ 1 - 1
apps/app/test/integration/models/page.test.js

@@ -9,7 +9,7 @@ let testGroup0;
 let parentPage;
 
 describe('Page', () => {
-  // eslint-disable-next-line no-unused-vars
+  // biome-ignore lint/correctness/noUnusedVariables: ignore
   let crowi;
   let Page;
   let PageQueryBuilder;

+ 1 - 1
apps/app/test/integration/models/user.test.js

@@ -3,7 +3,7 @@ const mongoose = require('mongoose');
 const { getInstance } = require('../setup-crowi');
 
 describe('User', () => {
-  // eslint-disable-next-line no-unused-vars
+  // biome-ignore lint/correctness/noUnusedVariables: ignore
   let crowi;
   let User;
 

+ 1 - 1
apps/app/test/integration/setup-crowi.ts

@@ -1,4 +1,4 @@
-import { Server } from 'http';
+import { Server } from 'node:http';
 
 import Crowi from '../../src/server/crowi';
 import { setupModelsDependentOnCrowi } from '../../src/server/crowi/setup-models';

+ 1 - 1
apps/app/tsconfig.json

@@ -4,7 +4,7 @@
   "compilerOptions": {
     "jsx": "preserve",
     "resolveJsonModule": true,
-    "types": ["vitest/globals"],
+    "types": ["vitest/globals", "@testing-library/jest-dom/vitest"],
 
     "baseUrl": ".",
     "paths": {

+ 1 - 0
apps/app/vitest.workspace.mts

@@ -38,6 +38,7 @@ export default defineWorkspace([
       name: 'app-components',
       environment: 'happy-dom',
       include: ['**/*.spec.{tsx,jsx}'],
+      setupFiles: ['./test-with-vite/setup/jest-dom.ts'],
     },
   }),
 ]);

+ 1 - 1
apps/pdf-converter/package.json

@@ -48,7 +48,7 @@
     "@types/connect": "^3.4.38",
     "@types/express": "^4.17.21",
     "@types/multer": "^1.4.12",
-    "@types/node": "^22.5.4",
+    "@types/node": "^20.18.3",
     "@types/supertest": "^6.0.3",
     "supertest": "^7.1.1",
     "unplugin-swc": "^1.5.3"

+ 1 - 1
apps/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "7.3.1-slackbot-proxy.0",
+  "version": "7.3.2-slackbot-proxy.0",
   "license": "MIT",
   "private": "true",
   "scripts": {

+ 9 - 4
biome.json

@@ -9,11 +9,12 @@
       "!**/vite.config.ts.timestamp-*",
       "!**/vite.server.config.ts.timestamp-*",
       "!**/vite.client.config.ts.timestamp-*",
-      "!**/.pnpm-store/**",
       "!**/.turbo/**",
       "!**/.vscode/**",
       "!**/turbo.json",
+      "!**/.claude/**",
       "!**/.next/**",
+      "!**/.terraform/**",
       "!bin/**",
       "!tsconfig.base.json",
       "!**/.devcontainer/**",
@@ -37,7 +38,8 @@
       "!apps/app/src/stores/**",
       "!apps/app/src/styles/**",
       "!apps/app/test/integration/service/**",
-      "!apps/app/test-with-vite/**"
+      "!apps/app/test-with-vite/**",
+      "!apps/app/tmp/**"
     ]
   },
   "formatter": {
@@ -67,7 +69,10 @@
   },
   "overrides": [
     {
-      "includes": ["apps/pdf-converter/**", "./apps/slackbot-proxy/**"],
+      "includes": [
+        "apps/pdf-converter/**",
+        "./apps/slackbot-proxy/**"
+      ],
       "linter": {
         "rules": {
           "style": {
@@ -77,4 +82,4 @@
       }
     }
   ]
-}
+}

+ 2 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "7.3.1",
+  "version": "7.3.2-RC.0",
   "description": "Team collaboration software using markdown",
   "license": "MIT",
   "private": "true",
@@ -53,7 +53,7 @@
     "@types/css-modules": "^1.0.2",
     "@types/eslint": "^8.37.0",
     "@types/estree": "^1.0.1",
-    "@types/node": "^20.14.0",
+    "@types/node": "^20.18.3",
     "@types/path-browserify": "^1.0.0",
     "@typescript-eslint/eslint-plugin": "^5.59.7",
     "@typescript-eslint/parser": "^5.59.7",

File diff suppressed because it is too large
+ 170 - 161
pnpm-lock.yaml


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