Просмотр исходного кода

Merge branch 'master' into feat/unified-merge-view

Shun Miyazawa 11 месяцев назад
Родитель
Сommit
e3e0fec9a2
27 измененных файлов с 448 добавлено и 107 удалено
  1. 28 18
      apps/app/docker/Dockerfile
  2. 2 2
      apps/app/src/client/components/AuthorInfo/AuthorInfo.module.scss
  3. 2 2
      apps/app/src/client/components/Bookmarks/BookmarkFolderTree.module.scss
  4. 6 1
      apps/app/src/client/components/Me/ProfileImageSettings.tsx
  5. 1 1
      apps/app/src/client/components/PageComment.tsx
  6. 2 2
      apps/app/src/client/components/PageComment/Comment.module.scss
  7. 1 1
      apps/app/src/client/components/PageComment/Comment.tsx
  8. 2 2
      apps/app/src/client/components/PageComment/CommentEditor.module.scss
  9. 1 1
      apps/app/src/client/components/PageComment/CommentEditor.tsx
  10. 1 1
      apps/app/src/client/components/PageComment/_comment-inheritance.scss
  11. 1 1
      apps/app/src/client/components/PageEditor/EditorNavbar/EditingUserList.tsx
  12. 1 1
      apps/app/src/client/components/PageHistory/Revision.module.scss
  13. 3 1
      apps/app/src/client/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx
  14. 2 2
      apps/app/src/client/components/UsersHomepageFooter.module.scss
  15. 1 1
      apps/app/src/components/User/UserInfo.module.scss
  16. 1 1
      apps/app/src/components/User/UserInfo.tsx
  17. 7 4
      apps/app/src/server/routes/attachment/api.js
  18. 65 0
      apps/app/src/server/routes/attachment/image-content-type-validator.spec.ts
  19. 56 0
      apps/app/src/server/routes/attachment/image-content-type-validator.ts
  20. 82 0
      apps/app/src/server/service/config-manager/config-loader.spec.ts
  21. 8 1
      apps/app/src/server/service/config-manager/config-loader.ts
  22. 62 1
      apps/app/src/server/service/config-manager/config-manager.spec.ts
  23. 0 26
      apps/app/src/styles/style-app.scss
  24. 33 22
      apps/pdf-converter/docker/Dockerfile
  25. 2 2
      packages/ui/scss/molecules/_page_list.scss
  26. 50 0
      packages/ui/src/components/UserPicture.module.scss
  27. 28 13
      packages/ui/src/components/UserPicture.tsx

+ 28 - 18
apps/app/docker/Dockerfile

@@ -1,25 +1,31 @@
-# syntax = docker/dockerfile:1
+# syntax = docker/dockerfile:1.4
 
+ARG OPT_DIR="/opt"
+ARG PNPM_HOME="/root/.local/share/pnpm"
 
 ##
 ## base
 ##
 FROM node:20-slim AS base
 
-ENV optDir=/opt
+ARG OPT_DIR
+ARG PNPM_HOME
 
-WORKDIR ${optDir}
+WORKDIR $OPT_DIR
 
 # install tools
-RUN apt-get update && apt-get install -y ca-certificates wget curl --no-install-recommends
+RUN --mount=type=cache,target=/var/lib/apt,sharing=locked \
+    --mount=type=cache,target=/var/cache/apt,sharing=locked \
+  apt-get update && apt-get install -y ca-certificates wget curl --no-install-recommends
 
 # install pnpm
 RUN wget -qO- https://get.pnpm.io/install.sh | ENV="$HOME/.shrc" SHELL="$(which sh)" PNPM_VERSION="10.4.1" sh -
-ENV PNPM_HOME="/root/.local/share/pnpm"
+ENV PNPM_HOME=$PNPM_HOME
 ENV PATH="$PNPM_HOME:$PATH"
 
 # install turbo
-RUN pnpm add turbo --global
+RUN --mount=type=cache,target=$PNPM_HOME/store,sharing=locked \
+  pnpm add turbo --global
 
 
 
@@ -28,14 +34,17 @@ RUN pnpm add turbo --global
 ##
 FROM base AS builder
 
-ENV optDir=/opt
+ENV PNPM_HOME=$PNPM_HOME
+ENV PATH="$PNPM_HOME:$PATH"
 
-WORKDIR ${optDir}
+WORKDIR $OPT_DIR
 
 COPY . .
 
-RUN pnpm add node-gyp --global
-RUN pnpm install ---frozen-lockfile
+RUN --mount=type=cache,target=$PNPM_HOME/store,sharing=locked \
+  pnpm add node-gyp --global
+RUN --mount=type=cache,target=$PNPM_HOME/store,sharing=locked \
+  pnpm install ---frozen-lockfile
 
 # build
 RUN turbo run clean
@@ -45,7 +54,7 @@ RUN turbo run build --filter @growi/app
 RUN pnpm deploy out --prod --filter @growi/app
 RUN rm -rf apps/app/node_modules && mv out/node_modules apps/app/node_modules
 RUN rm -rf apps/app/.next/cache
-RUN tar -zcf packages.tar.gz \
+RUN tar -zcf /tmp/packages.tar.gz \
   package.json \
   apps/app/.next \
   apps/app/config \
@@ -66,27 +75,28 @@ RUN tar -zcf packages.tar.gz \
 FROM node:20-slim
 LABEL maintainer="Yuki Takei <yuki@weseek.co.jp>"
 
+ARG OPT_DIR
+
 ENV NODE_ENV="production"
 
-ENV optDir=/opt
-ENV appDir=${optDir}/growi
+ENV appDir="$OPT_DIR/growi"
 
 # Add gosu
 # see: https://github.com/tianon/gosu/blob/1.13/INSTALL.md
-RUN set -eux; \
+RUN --mount=type=cache,target=/var/lib/apt,sharing=locked \
+    --mount=type=cache,target=/var/cache/apt,sharing=locked \
+  set -eux; \
 	apt-get update; \
 	apt-get install -y gosu; \
 	rm -rf /var/lib/apt/lists/*; \
 # verify that the binary works
 	gosu nobody true
 
-COPY --from=builder --chown=node:node \
-  ${optDir}/packages.tar.gz ${appDir}/
-
 # extract artifacts as 'node' user
 USER node
 WORKDIR ${appDir}
-RUN tar -zxf packages.tar.gz && rm packages.tar.gz
+RUN --mount=type=bind,from=builder,source=/tmp/packages.tar.gz,target=/tmp/packages.tar.gz \
+  tar -zxf /tmp/packages.tar.gz -C ${appDir}/
 
 COPY --chown=node:node --chmod=700 apps/app/docker/docker-entrypoint.sh /
 

+ 2 - 2
apps/app/src/client/components/AuthorInfo/AuthorInfo.module.scss

@@ -10,12 +10,12 @@ $date-font-size: 12px;
     font-size: $date-font-size;
   }
 
-  .picture {
+  .user-picture {
     width: 22px;
     height: 22px;
     border: 1px solid bs.$gray-300;
 
-    &.picture-xs {
+    &.user-picture-xs {
       width: 14px;
       height: 14px;
     }

+ 2 - 2
apps/app/src/client/components/Bookmarks/BookmarkFolderTree.module.scss

@@ -65,12 +65,12 @@ $grw-bookmark-item-padding-left: 35px;
       min-width: 30px;
       height: 50px;
 
-      .picture {
+      .user-picture {
         width: 16px;
         height: 16px;
         vertical-align: text-bottom;
 
-        &.picture-md {
+        &.user-picture-md {
           width: 20px;
           height: 20px;
         }

+ 6 - 1
apps/app/src/client/components/Me/ProfileImageSettings.tsx

@@ -147,7 +147,12 @@ const ProfileImageSettings = (): JSX.Element => {
               {t('Upload new image')}
             </label>
             <div className="col-md-6 col-lg-8">
-              <input type="file" onChange={selectFileHandler} name="profileImage" accept="image/*" />
+              <input
+                type="file"
+                onChange={selectFileHandler}
+                name="profileImage"
+                accept="image/png,image/jpeg,image/jpg,image/gif,image/webp,image/avif,image/heic,image/heif,image/tiff,image/svg+xml"
+              />
             </div>
           </div>
         </div>

+ 1 - 1
apps/app/src/client/components/PageComment.tsx

@@ -190,7 +190,7 @@ export const PageComment: FC<PageCommentProps> = memo((props: PageCommentProps):
                           className="btn btn-secondary btn-comment-reply text-start w-100 ms-5"
                           onClick={() => onReplyButtonClickHandler(comment._id)}
                         >
-                          <UserPicture user={currentUser} noLink noTooltip additionalClassName="me-2" />
+                          <UserPicture user={currentUser} noLink noTooltip className="me-2" />
                           <span className="material-symbols-outlined me-1 fs-5 pb-1">reply</span><small>{t('page_comment.reply')}...</small>
                         </button>
                       </NotAvailableIfReadOnlyUserNotAllowedToComment>

+ 2 - 2
apps/app/src/client/components/PageComment/Comment.module.scss

@@ -15,8 +15,8 @@
     }
 
     // user icon
-    .picture {
-      @extend %picture;
+    .user-picture {
+      @extend %user-picture;
     }
 
     // comment section

+ 1 - 1
apps/app/src/client/components/PageComment/Comment.tsx

@@ -151,7 +151,7 @@ export const Comment = (props: CommentProps): JSX.Element => {
         <div id={commentId} className={rootClassName}>
           <div className="page-comment-main bg-comment rounded mb-2">
             <div className="d-flex align-items-center">
-              <UserPicture user={creator} additionalClassName="me-2" />
+              <UserPicture user={creator} className="me-2" />
               <div className="small fw-bold me-3">
                 <Username user={creator} />
               </div>

+ 2 - 2
apps/app/src/client/components/PageComment/CommentEditor.module.scss

@@ -13,8 +13,8 @@
     }
 
     // user icon
-    .picture {
-      @extend %picture;
+    .user-picture {
+      @extend %user-picture;
     }
 
   }

+ 1 - 1
apps/app/src/client/components/PageComment/CommentEditor.tsx

@@ -341,7 +341,7 @@ export const CommentEditorPre = (props: CommentEditorProps): JSX.Element => {
               onClick={() => setIsReadyToUse(true)}
               data-testid="open-comment-editor-button"
             >
-              <UserPicture user={currentUser} noLink noTooltip additionalClassName="me-3" />
+              <UserPicture user={currentUser} noLink noTooltip className="me-3" />
               <span className="material-symbols-outlined me-1 fs-5">add_comment</span>
               <small>{t('page_comment.add_a_comment')}...</small>
             </button>

+ 1 - 1
apps/app/src/client/components/PageComment/_comment-inheritance.scss

@@ -21,7 +21,7 @@
   }
 }
 
-%picture {
+%user-picture {
   width: 1.2em;
   height: 1.2em;
 }

+ 1 - 1
apps/app/src/client/components/PageEditor/EditorNavbar/EditingUserList.tsx

@@ -34,7 +34,7 @@ export const EditingUserList: FC<Props> = ({ clientList }) => {
             <UserPicture
               user={editingClient.userId}
               noLink
-              additionalClassName="border border-info"
+              className="border border-info"
             />
           </div>
         ))}

+ 1 - 1
apps/app/src/client/components/PageHistory/Revision.module.scss

@@ -1,5 +1,5 @@
 .revision-history-main :global {
-  img.picture-lg {
+  img.user-picture-lg {
     width: 32px;
     height: 32px;
   }

+ 3 - 1
apps/app/src/client/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx

@@ -108,7 +108,9 @@ const PageItem = memo(({ page, isSmall, onClickTag }: PageItemProps): JSX.Elemen
     <li className={`list-group-item ${styles['list-group-item']} py-2 px-0`}>
       <div className="d-flex w-100">
 
-        <UserPicture user={page.lastUpdateUser} size="md" noTooltip />
+        <div>
+          <UserPicture user={page.lastUpdateUser} size="md" className="d-inline-block" />
+        </div>
 
         <div className="flex-grow-1 ms-2">
           <div className={`row ${isSmall ? 'gy-0' : 'gy-1'}`}>

+ 2 - 2
apps/app/src/client/components/UsersHomepageFooter.module.scss

@@ -24,12 +24,12 @@ $grw-sidebar-content-footer-height: 50px;
         border-radius: 0;
 
         &.grw-bookmark-item-list {
-          .picture {
+          .user-picture {
             width: 16px;
             height: 16px;
             vertical-align: text-bottom;
 
-            &.picture-md {
+            &.user-picture-md {
               width: 20px;
               height: 20px;
             }

+ 1 - 1
apps/app/src/components/User/UserInfo.module.scss

@@ -11,7 +11,7 @@
     color: bs.$secondary;
   }
 
-  .picture {
+  .user-picture {
     width: 120px;
     height: 120px;
   }

+ 1 - 1
apps/app/src/components/User/UserInfo.tsx

@@ -20,7 +20,7 @@ export const UserInfo = (props: UserInfoProps): JSX.Element => {
 
   return (
     <div className={`${styles['grw-users-info']} grw-users-info d-flex align-items-center d-edit-none mb-5 pb-3 border-bottom`} data-testid="grw-users-info">
-      <UserPicture user={author} />
+      <UserPicture user={author} noTooltip noLink />
       <div className="users-meta">
         <h1 className="user-page-name">
           {author.name}

+ 7 - 4
apps/app/src/server/routes/attachment/api.js

@@ -4,6 +4,8 @@ import { AttachmentType } from '~/server/interfaces/attachment';
 import loggerFactory from '~/utils/logger';
 
 import { Attachment } from '../../models/attachment';
+
+import { validateImageContentType } from './image-content-type-validator';
 /* eslint-disable no-use-before-define */
 
 
@@ -246,10 +248,11 @@ export const routesFactory = (crowi) => {
 
     const file = req.file;
 
-    // check type
-    const acceptableFileType = /image\/.+/;
-    if (!file.mimetype.match(acceptableFileType)) {
-      return res.json(ApiResponse.error('File type error. Only image files is allowed to set as user picture.'));
+    // Validate file type
+    const { isValid, error } = validateImageContentType(file.mimetype);
+
+    if (!isValid) {
+      return res.json(ApiResponse.error(error));
     }
 
     let attachment;

+ 65 - 0
apps/app/src/server/routes/attachment/image-content-type-validator.spec.ts

@@ -0,0 +1,65 @@
+import { describe, test, expect } from 'vitest';
+
+import { validateImageContentType, type SupportedImageMimeType } from './image-content-type-validator';
+
+describe('validateImageContentType', () => {
+  describe('valid cases', () => {
+    // Test supported MIME types
+    const supportedTypes: SupportedImageMimeType[] = [
+      'image/png',
+      'image/jpeg',
+      'image/jpg',
+      'image/gif',
+      'image/webp',
+      'image/avif',
+      'image/heic',
+      'image/heif',
+      'image/tiff',
+      'image/svg+xml',
+    ];
+
+    test.each(supportedTypes)('should accept %s', (mimeType) => {
+      const result = validateImageContentType(mimeType);
+      expect(result.isValid).toBe(true);
+      expect(result.contentType).toBe(mimeType);
+      expect(result.error).toBeUndefined();
+    });
+
+    test('should accept MIME type with surrounding whitespace', () => {
+      const result = validateImageContentType('  image/png  ');
+      expect(result.isValid).toBe(true);
+      expect(result.contentType).toBe('image/png');
+      expect(result.error).toBeUndefined();
+    });
+  });
+
+  describe('invalid cases', () => {
+    // Test invalid input types
+    test.each([
+      ['undefined', undefined],
+      ['null', null],
+      ['number', 42],
+      ['object', {}],
+    ])('should reject %s', (_, input) => {
+      const result = validateImageContentType(input as unknown as string);
+      expect(result.isValid).toBe(false);
+      expect(result.contentType).toBeNull();
+      expect(result.error).toBe('Invalid MIME type format');
+    });
+
+    // Test invalid MIME types
+    test.each([
+      ['empty string', ''],
+      ['whitespace only', '   '],
+      ['non-image type', 'text/plain'],
+      ['unknown image type', 'image/unknown'],
+      ['multiple MIME types', 'text/plain, image/png'],
+      ['multiple image types', 'image/png, image/jpeg'],
+      ['MIME type with comma', 'image/png,'],
+    ])('should reject %s', (_, input) => {
+      const result = validateImageContentType(input);
+      expect(result.isValid).toBe(false);
+      expect(result.error).toContain('Invalid file type');
+    });
+  });
+});

+ 56 - 0
apps/app/src/server/routes/attachment/image-content-type-validator.ts

@@ -0,0 +1,56 @@
+/**
+ * Define supported image MIME types
+ */
+export const SUPPORTED_IMAGE_MIME_TYPES = [
+  'image/png', // Universal web format
+  'image/jpeg', // Universal web format
+  'image/jpg', // Universal web format
+  'image/gif', // Universal web format
+  'image/webp', // Modern efficient format
+  'image/avif', // Next-gen format
+  'image/heic', // iOS format
+  'image/heif', // iOS format
+  'image/tiff', // High quality format
+  'image/svg+xml', // Vector format
+] as const;
+
+// Create a type for supported MIME types
+export type SupportedImageMimeType = typeof SUPPORTED_IMAGE_MIME_TYPES[number];
+
+export interface ImageContentTypeValidatorResult {
+  isValid: boolean;
+  contentType: string | null;
+  error?: string;
+}
+
+/**
+ * Validate and extract content type from MIME type string
+ * @param mimeType MIME type string
+ * @returns Validation result containing isValid flag and extracted content type
+ */
+export const validateImageContentType = (mimeType: string): ImageContentTypeValidatorResult => {
+  if (typeof mimeType !== 'string') {
+    return {
+      isValid: false,
+      contentType: null,
+      error: 'Invalid MIME type format',
+    };
+  }
+
+  const trimmedType = mimeType.trim();
+  const isValid = SUPPORTED_IMAGE_MIME_TYPES.includes(trimmedType as SupportedImageMimeType);
+
+  if (!isValid) {
+    const supportedFormats = 'PNG, JPEG, GIF, WebP, AVIF, HEIC/HEIF, TIFF, SVG';
+    return {
+      isValid: false,
+      contentType: trimmedType,
+      error: `Invalid file type. Supported formats: ${supportedFormats}`,
+    };
+  }
+
+  return {
+    isValid: true,
+    contentType: trimmedType,
+  };
+};

+ 82 - 0
apps/app/src/server/service/config-manager/config-loader.spec.ts

@@ -0,0 +1,82 @@
+import type { RawConfigData } from '@growi/core/dist/interfaces';
+
+import type { ConfigKey, ConfigValues } from './config-definition';
+import { ConfigLoader } from './config-loader';
+
+const mockExec = vi.fn();
+const mockFind = vi.fn().mockReturnValue({ exec: mockExec });
+
+// Mock the Config model
+vi.mock('../../models/config', () => ({
+  Config: {
+    find: mockFind,
+  },
+}));
+
+describe('ConfigLoader', () => {
+  let configLoader: ConfigLoader;
+
+  beforeEach(async() => {
+    configLoader = new ConfigLoader();
+    vi.clearAllMocks();
+  });
+
+  describe('loadFromDB', () => {
+    describe('when doc.value is empty string', () => {
+      beforeEach(() => {
+        const mockDocs = [
+          { key: 'app:referrerPolicy' as ConfigKey, value: '' },
+        ];
+        mockExec.mockResolvedValue(mockDocs);
+      });
+
+      it('should return null for value', async() => {
+        const config: RawConfigData<ConfigKey, ConfigValues> = await configLoader.loadFromDB();
+        expect(config['app:referrerPolicy'].value).toBe(null);
+      });
+    });
+
+    describe('when doc.value is invalid JSON', () => {
+      beforeEach(() => {
+        const mockDocs = [
+          { key: 'app:referrerPolicy' as ConfigKey, value: '{invalid:json' },
+        ];
+        mockExec.mockResolvedValue(mockDocs);
+      });
+
+      it('should return null for value', async() => {
+        const config: RawConfigData<ConfigKey, ConfigValues> = await configLoader.loadFromDB();
+        expect(config['app:referrerPolicy'].value).toBe(null);
+      });
+    });
+
+    describe('when doc.value is valid JSON', () => {
+      const validJson = { key: 'value' };
+      beforeEach(() => {
+        const mockDocs = [
+          { key: 'app:referrerPolicy' as ConfigKey, value: JSON.stringify(validJson) },
+        ];
+        mockExec.mockResolvedValue(mockDocs);
+      });
+
+      it('should return parsed value', async() => {
+        const config: RawConfigData<ConfigKey, ConfigValues> = await configLoader.loadFromDB();
+        expect(config['app:referrerPolicy'].value).toEqual(validJson);
+      });
+    });
+
+    describe('when doc.value is null', () => {
+      beforeEach(() => {
+        const mockDocs = [
+          { key: 'app:referrerPolicy' as ConfigKey, value: null },
+        ];
+        mockExec.mockResolvedValue(mockDocs);
+      });
+
+      it('should return null for value', async() => {
+        const config: RawConfigData<ConfigKey, ConfigValues> = await configLoader.loadFromDB();
+        expect(config['app:referrerPolicy'].value).toBe(null);
+      });
+    });
+  });
+});

+ 8 - 1
apps/app/src/server/service/config-manager/config-loader.ts

@@ -44,7 +44,14 @@ export class ConfigLoader implements IConfigLoader<ConfigKey, ConfigValues> {
     for (const doc of docs) {
       dbConfig[doc.key as ConfigKey] = {
         definition: (doc.key in CONFIG_DEFINITIONS) ? CONFIG_DEFINITIONS[doc.key as ConfigKey] : undefined,
-        value: doc.value != null ? JSON.parse(doc.value) : null,
+        value: doc.value != null ? (() => {
+          try {
+            return JSON.parse(doc.value);
+          }
+          catch {
+            return null;
+          }
+        })() : null,
       };
     }
 

+ 62 - 1
apps/app/src/server/service/config-manager/config-manager.spec.ts

@@ -1,9 +1,14 @@
+import type { RawConfigData } from '@growi/core/dist/interfaces';
 import { mock } from 'vitest-mock-extended';
 
 import type { S2sMessagingService } from '../s2s-messaging/base';
 
+import type { ConfigKey, ConfigValues } from './config-definition';
 import { configManager } from './config-manager';
 
+// Test helper type for setting configs
+type TestConfigData = RawConfigData<ConfigKey, ConfigValues>;
+
 const mocks = vi.hoisted(() => ({
   ConfigMock: {
     updateOne: vi.fn(),
@@ -104,7 +109,6 @@ describe('ConfigManager test', () => {
   });
 
   describe('getManagedEnvVars()', () => {
-
     beforeAll(async() => {
       process.env.AUTO_INSTALL_ADMIN_USERNAME = 'admin';
       process.env.AUTO_INSTALL_ADMIN_PASSWORD = 'password';
@@ -129,7 +133,64 @@ describe('ConfigManager test', () => {
       expect(result.AUTO_INSTALL_ADMIN_USERNAME).toEqual('admin');
       expect(result.AUTO_INSTALL_ADMIN_PASSWORD).toEqual('***');
     });
+  });
+
+  describe('getConfig()', () => {
+    // Helper function to set configs with proper typing
+    const setTestConfigs = (dbConfig: Partial<TestConfigData>, envConfig: Partial<TestConfigData>): void => {
+      Object.defineProperties(configManager, {
+        dbConfig: { value: dbConfig, configurable: true },
+        envConfig: { value: envConfig, configurable: true },
+      });
+    };
+
+    beforeEach(async() => {
+      // Reset configs before each test using properly typed empty objects
+      setTestConfigs({}, {});
+    });
 
+    test('should fallback to env value when dbConfig[key] exists but its value is undefined', async() => {
+      // Prepare test data that simulates the issue with proper typing
+      const dbConfig: Partial<TestConfigData> = {
+        'app:title': { value: undefined },
+      };
+      const envConfig: Partial<TestConfigData> = {
+        'app:title': { value: 'GROWI' },
+      };
+      setTestConfigs(dbConfig, envConfig);
+
+      // Act
+      const result = configManager.getConfig('app:title');
+
+      // Assert - Should return env value since db value is undefined
+      expect(result).toBe('GROWI');
+    });
+
+    test('should handle various edge case scenarios correctly', async() => {
+      // Setup multiple test scenarios with proper typing
+      const dbConfig: Partial<TestConfigData> = {
+        'app:title': { value: undefined }, // db value is explicitly undefined
+        'app:siteUrl': { value: undefined }, // another undefined value
+        'app:fileUpload': undefined, // db config entry itself is undefined
+        'app:fileUploadType': { value: 'gridfs' }, // db has valid value
+      };
+      const envConfig: Partial<TestConfigData> = {
+        'app:title': { value: 'GROWI' },
+        'app:siteUrl': { value: 'https://example.com' },
+        'app:fileUpload': { value: true },
+        'app:fileUploadType': { value: 'aws' },
+        // Add control flags for env vars
+        'env:useOnlyEnvVars:app:siteUrl': { value: false },
+        'env:useOnlyEnvVars:app:fileUploadType': { value: false },
+      };
+      setTestConfigs(dbConfig, envConfig);
+
+      // Test each scenario
+      expect(configManager.getConfig('app:title')).toBe('GROWI'); // Should fallback to env when db value is undefined
+      expect(configManager.getConfig('app:siteUrl')).toBe('https://example.com'); // Should fallback to env when db value is undefined
+      expect(configManager.getConfig('app:fileUpload')).toBe(true); // Should fallback to env when db config is undefined
+      expect(configManager.getConfig('app:fileUploadType')).toBe('gridfs'); // Should use db value when valid
+    });
   });
 
 });

+ 0 - 26
apps/app/src/styles/style-app.scss

@@ -56,32 +56,6 @@
   flex-basis: 0;
 }
 
-.picture {
-  width: 24px;
-  height: 24px;
-
-  // size list
-  &.picture-lg {
-    width: 48px;
-    height: 48px;
-  }
-
-  &.picture-md {
-    width: 24px;
-    height: 24px;
-  }
-
-  &.picture-sm {
-    width: 18px;
-    height: 18px;
-  }
-
-  &.picture-xs {
-    width: 14px;
-    height: 14px;
-  }
-}
-
 .grw-page-control-dropdown-item {
   display: flex !important;
   align-items: center;

+ 33 - 22
apps/pdf-converter/docker/Dockerfile

@@ -1,22 +1,31 @@
-# syntax = docker/dockerfile:1
+# syntax = docker/dockerfile:1.4
+
+ARG OPT_DIR="/opt"
+ARG PNPM_HOME="/root/.local/share/pnpm"
 
 ##
 ## base
 ##
 FROM node:20-slim AS base
 
-ENV optDir="/opt"
+ARG OPT_DIR
+ARG PNPM_HOME
+
+WORKDIR $OPT_DIR
 
-WORKDIR ${optDir}
+# install tools
+RUN --mount=type=cache,target=/var/lib/apt,sharing=locked \
+    --mount=type=cache,target=/var/cache/apt,sharing=locked \
+  apt-get update && apt-get install -y ca-certificates wget --no-install-recommends
 
 # install pnpm
-RUN apt-get update && apt-get install -y ca-certificates wget --no-install-recommends \
-  && wget -qO- https://get.pnpm.io/install.sh | ENV="$HOME/.shrc" SHELL="$(which sh)" PNPM_VERSION="10.4.1" sh -
-ENV PNPM_HOME="/root/.local/share/pnpm"
+RUN wget -qO- https://get.pnpm.io/install.sh | ENV="$HOME/.shrc" SHELL="$(which sh)" PNPM_VERSION="10.4.1" sh -
+ENV PNPM_HOME=$PNPM_HOME
 ENV PATH="$PNPM_HOME:$PATH"
 
 # install turbo
-RUN pnpm add turbo --global
+RUN --mount=type=cache,target=$PNPM_HOME/store,sharing=locked \
+  pnpm add turbo --global
 
 
 
@@ -25,13 +34,15 @@ RUN pnpm add turbo --global
 ##
 FROM base AS builder
 
-ENV optDir="/opt"
+ENV PNPM_HOME=$PNPM_HOME
+ENV PATH="$PNPM_HOME:$PATH"
 
-WORKDIR ${optDir}
+WORKDIR $OPT_DIR
 
 COPY . .
 
-RUN pnpm install ---frozen-lockfile
+RUN --mount=type=cache,target=$PNPM_HOME/store,sharing=locked \
+  pnpm install ---frozen-lockfile
 
 # build
 RUN turbo run clean
@@ -40,7 +51,7 @@ RUN turbo run build --filter @growi/pdf-converter
 # make artifacts
 RUN pnpm deploy out --prod --filter @growi/pdf-converter
 RUN rm -rf apps/pdf-converter/node_modules && mv out/node_modules apps/pdf-converter/node_modules
-RUN tar -zcf packages.tar.gz \
+RUN tar -zcf /tmp/packages.tar.gz \
   package.json \
   apps/pdf-converter/package.json \
   apps/pdf-converter/dist \
@@ -55,27 +66,27 @@ RUN tar -zcf packages.tar.gz \
 FROM node:20-slim
 LABEL maintainer="Yuki Takei <yuki@weseek.co.jp>"
 
+ARG OPT_DIR
+
 ENV NODE_ENV="production"
 ENV PUPPETEER_EXECUTABLE_PATH="/usr/bin/chromium"
 ENV LANG="ja_JP.UTF-8"
 
-ENV optDir="/opt"
-ENV appDir="${optDir}/pdf-converter"
+ENV appDir="$OPT_DIR/pdf-converter"
 
-RUN apt-get update && apt-get install -y chromium fonts-lato fonts-ipafont-gothic fonts-noto-cjk gosu \
-    && apt-get clean \
-    && rm -rf /var/lib/apt/lists/*; \
+RUN --mount=type=cache,target=/var/lib/apt,sharing=locked \
+    --mount=type=cache,target=/var/cache/apt,sharing=locked \
+  apt-get update; \
+  apt-get install -y chromium fonts-lato fonts-ipafont-gothic fonts-noto-cjk gosu; \
+  rm -rf /var/lib/apt/lists/*; \
 # verify that the binary works
-    gosu nobody true
-
-# copy artifacts
-COPY --from=builder --chown=node:node \
-  ${optDir}/packages.tar.gz ${appDir}/
+  gosu nobody true
 
 # extract artifacts as 'node' user
 USER node
 WORKDIR ${appDir}
-RUN tar -xf packages.tar.gz && rm packages.tar.gz
+RUN --mount=type=bind,from=builder,source=/tmp/packages.tar.gz,target=/tmp/packages.tar.gz \
+  tar -zxf /tmp/packages.tar.gz -C ${appDir}/
 
 COPY --chown=node:node --chmod=700 apps/pdf-converter/docker/docker-entrypoint.sh /
 

+ 2 - 2
packages/ui/scss/molecules/_page_list.scss

@@ -22,12 +22,12 @@
       }
     }
 
-    .picture {
+    .user-picture {
       width: 16px;
       height: 16px;
       vertical-align: text-bottom;
 
-      &.picture-md {
+      &.user-picture-md {
         width: 20px;
         height: 20px;
       }

+ 50 - 0
packages/ui/src/components/UserPicture.module.scss

@@ -0,0 +1,50 @@
+.user-picture {
+  width: 24px;
+  height: 24px;
+
+  &:global {
+    // size list
+    &.user-picture-lg {
+      width: 48px;
+      height: 48px;
+    }
+
+    &.user-picture-md {
+      width: 24px;
+      height: 24px;
+    }
+
+    &.user-picture-sm {
+      width: 18px;
+      height: 18px;
+    }
+
+    &.user-picture-xs {
+      width: 14px;
+      height: 14px;
+    }
+  }
+}
+
+.user-picture-tooltip {
+  --bs-tooltip-margin: 12px 0 0 0;
+
+  &:global {
+    // size list
+    &.user-picture-tooltip-lg {
+      --bs-tooltip-margin: 24px 0 0 0;
+    }
+
+    &.user-picture-tooltip-md {
+      --bs-tooltip-margin: 12px 0 0 0;
+    }
+
+    &.user-picture-tooltip-sm {
+      --bs-tooltip-margin: 9px 0 0 0;
+    }
+
+    &.user-picture-tooltip-xs {
+      --bs-tooltip-margin: 7px 0 0 0;
+    }
+  }
+}

+ 28 - 13
packages/ui/src/components/UserPicture.tsx

@@ -9,13 +9,21 @@ import dynamic from 'next/dynamic';
 import { useRouter } from 'next/router';
 import type { UncontrolledTooltipProps } from 'reactstrap';
 
+import styles from './UserPicture.module.scss';
+
+const moduleClass = styles['user-picture'];
+const moduleTooltipClass = styles['user-picture-tooltip'];
+
 const UncontrolledTooltip = dynamic<UncontrolledTooltipProps>(() => import('reactstrap').then(mod => mod.UncontrolledTooltip), { ssr: false });
 
 const DEFAULT_IMAGE = '/images/icons/user.svg';
 
 
+type UserPitureSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
+
 type UserPictureRootProps = {
   user: IUser,
+  size?: UserPitureSize,
   className?: string,
   children?: ReactNode,
 }
@@ -44,19 +52,26 @@ const UserPictureRootWithLink = forwardRef<HTMLSpanElement, UserPictureRootProps
 // wrapper with Tooltip
 const withTooltip = (UserPictureSpanElm: React.ForwardRefExoticComponent<UserPictureRootProps & React.RefAttributes<HTMLSpanElement>>) => {
   return (props: UserPictureRootProps) => {
-    const { user } = props;
+    const { user, size } = props;
+
+    const tooltipClassName = `${moduleTooltipClass} user-picture-tooltip-${size ?? 'md'}`;
 
     const userPictureRef = useRef<HTMLSpanElement>(null);
 
     return (
       <>
         <UserPictureSpanElm ref={userPictureRef} user={user}>{props.children}</UserPictureSpanElm>
-        {userPictureRef.current != null && (
-          <UncontrolledTooltip placement="bottom" target={userPictureRef.current} delay={0} fade={false}>
-            @{user.username}<br />
-            {user.name}
-          </UncontrolledTooltip>
-        )}
+        <UncontrolledTooltip
+          placement="bottom"
+          target={userPictureRef}
+          popperClassName={tooltipClassName}
+          delay={0}
+          fade={false}
+          show
+        >
+          @{user.username}<br />
+          {user.name}
+        </UncontrolledTooltip>
       </>
     );
   };
@@ -73,21 +88,21 @@ const isUserObj = (obj: Partial<IUser> | Ref<IUser>): obj is IUser => {
 
 type Props = {
   user?: Partial<IUser> | Ref<IUser> | null,
-  size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl',
+  size?: UserPitureSize,
   noLink?: boolean,
   noTooltip?: boolean,
-  additionalClassName?: string
+  className?: string
 };
 
 export const UserPicture = memo((props: Props): JSX.Element => {
 
   const {
-    user, size, noLink, noTooltip, additionalClassName,
+    user, size, noLink, noTooltip, className: additionalClassName,
   } = props;
 
-  const classNames = ['rounded-circle', 'picture'];
+  const classNames = [moduleClass, 'user-picture', 'rounded-circle'];
   if (size != null) {
-    classNames.push(`picture-${size}`);
+    classNames.push(`user-picture-${size}`);
   }
   if (additionalClassName != null) {
     classNames.push(additionalClassName);
@@ -113,7 +128,7 @@ export const UserPicture = memo((props: Props): JSX.Element => {
   const userPictureSrc = user.imageUrlCached ?? DEFAULT_IMAGE;
 
   return (
-    <UserPictureRootElm user={user}>
+    <UserPictureRootElm user={user} size={size}>
       <img
         src={userPictureSrc}
         alt={user.username}