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

Merge branch 'dev/7.5.x' into support/reduce-modules-loaded

Yuki Takei 1 месяц назад
Родитель
Сommit
b3fa18e7a2
35 измененных файлов с 1863 добавлено и 361 удалено
  1. 0 144
      .claude/agents/security-reviewer.md
  2. 83 0
      .claude/commands/create-next-version-branch.md
  3. 15 32
      .github/workflows/ci-app-prod.yml
  4. 3 3
      .github/workflows/ci-app.yml
  5. 3 3
      .github/workflows/ci-pdf-converter.yml
  6. 3 3
      .github/workflows/ci-slackbot-proxy.yml
  7. 2 2
      .github/workflows/release-rc.yml
  8. 2 2
      .github/workflows/release-subpackages.yml
  9. 1 1
      .github/workflows/reusable-app-build-image.yml
  10. 1 1
      .github/workflows/reusable-app-prod.yml
  11. 233 0
      .kiro/specs/official-docker-image/design.md
  12. 82 0
      .kiro/specs/official-docker-image/requirements.md
  13. 288 0
      .kiro/specs/official-docker-image/research.md
  14. 22 0
      .kiro/specs/official-docker-image/spec.json
  15. 193 0
      .kiro/specs/official-docker-image/tasks.md
  16. 25 1
      CHANGELOG.md
  17. 1 1
      README.md
  18. 1 1
      README_JP.md
  19. 76 48
      apps/app/docker/Dockerfile
  20. 74 4
      apps/app/docker/Dockerfile.dockerignore
  21. 11 1
      apps/app/docker/README.md
  22. 2 0
      apps/app/docker/codebuild/buildspec.yml
  23. 0 18
      apps/app/docker/docker-entrypoint.sh
  24. 358 0
      apps/app/docker/docker-entrypoint.spec.ts
  25. 265 0
      apps/app/docker/docker-entrypoint.ts
  26. 1 1
      apps/app/package.json
  27. 27 27
      apps/app/public/static/locales/fr_FR/admin.json
  28. 3 1
      apps/app/public/static/locales/fr_FR/commons.json
  29. 53 52
      apps/app/public/static/locales/fr_FR/translation.json
  30. 1 1
      apps/app/src/client/components/ForbiddenPage.tsx
  31. 22 7
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/compress-and-upload.ts
  32. 4 2
      apps/pdf-converter/docker/Dockerfile
  33. 5 2
      apps/slackbot-proxy/docker/Dockerfile
  34. 1 1
      apps/slackbot-proxy/package.json
  35. 2 2
      package.json

+ 0 - 144
.claude/agents/security-reviewer.md

@@ -1,144 +0,0 @@
----
-name: security-reviewer
-description: Security vulnerability detection specialist for GROWI. Use after writing code that handles user input, authentication, API endpoints, or sensitive data. Flags secrets, injection, XSS, and OWASP Top 10 vulnerabilities.
-tools: Read, Write, Edit, Bash, Grep, Glob
-model: opus
----
-
-# Security Reviewer
-
-You are a security specialist focused on identifying vulnerabilities in the GROWI codebase. Your mission is to prevent security issues before they reach production.
-
-## GROWI Security Stack
-
-GROWI uses these security measures:
-- **helmet**: Security headers
-- **express-mongo-sanitize**: NoSQL injection prevention
-- **xss**, **rehype-sanitize**: XSS prevention
-- **Passport.js**: Authentication (Local, LDAP, SAML, OAuth)
-
-## Security Review Workflow
-
-### 1. Automated Checks
-```bash
-# Check for vulnerable dependencies
-pnpm audit
-
-# Search for potential secrets
-grep -r "api[_-]?key\|password\|secret\|token" --include="*.ts" --include="*.tsx" .
-```
-
-### 2. OWASP Top 10 Checklist
-
-1. **Injection (NoSQL)** - Are Mongoose queries safe? No string concatenation in queries?
-2. **Broken Authentication** - Passwords hashed? Sessions secure? Passport configured correctly?
-3. **Sensitive Data Exposure** - Secrets in env vars? HTTPS enforced? Logs sanitized?
-4. **Broken Access Control** - Authorization on all routes? CORS configured?
-5. **Security Misconfiguration** - Helmet enabled? Debug mode off in production?
-6. **XSS** - Output escaped? Content-Security-Policy set?
-7. **Components with Vulnerabilities** - `pnpm audit` clean?
-8. **Insufficient Logging** - Security events logged?
-
-## Vulnerability Patterns
-
-### Hardcoded Secrets (CRITICAL)
-```typescript
-// ❌ CRITICAL
-const apiKey = "sk-xxxxx"
-
-// ✅ CORRECT
-const apiKey = process.env.API_KEY
-```
-
-### NoSQL Injection (CRITICAL)
-```typescript
-// ❌ CRITICAL: Unsafe query
-const user = await User.findOne({ email: req.body.email, password: req.body.password })
-
-// ✅ CORRECT: Use express-mongo-sanitize middleware + validate input
-```
-
-### XSS (HIGH)
-```typescript
-// ❌ HIGH: Direct HTML insertion
-element.innerHTML = userInput
-
-// ✅ CORRECT: Use textContent or sanitize
-element.textContent = userInput
-// OR use xss library
-import xss from 'xss'
-element.innerHTML = xss(userInput)
-```
-
-### SSRF (HIGH)
-```typescript
-// ❌ HIGH: User-controlled URL
-const response = await fetch(userProvidedUrl)
-
-// ✅ CORRECT: Validate URL against allowlist
-const allowedDomains = ['api.example.com']
-const url = new URL(userProvidedUrl)
-if (!allowedDomains.includes(url.hostname)) {
-  throw new Error('Invalid URL')
-}
-```
-
-### Authorization Check (CRITICAL)
-```typescript
-// ❌ CRITICAL: No authorization
-app.get('/api/page/:id', async (req, res) => {
-  const page = await Page.findById(req.params.id)
-  res.json(page)
-})
-
-// ✅ CORRECT: Check user access
-app.get('/api/page/:id', loginRequired, async (req, res) => {
-  const page = await Page.findById(req.params.id)
-  if (!page.isAccessibleBy(req.user)) {
-    return res.status(403).json({ error: 'Forbidden' })
-  }
-  res.json(page)
-})
-```
-
-## Security Report Format
-
-```markdown
-## Security Review Summary
-- **Critical Issues:** X
-- **High Issues:** Y
-- **Risk Level:** 🔴 HIGH / 🟡 MEDIUM / 🟢 LOW
-
-### Issues Found
-1. **[SEVERITY]** Description @ `file:line`
-   - Impact: ...
-   - Fix: ...
-```
-
-## When to Review
-
-**ALWAYS review when:**
-- New API endpoints added
-- Authentication/authorization changed
-- User input handling added
-- Database queries modified
-- File upload features added
-- Dependencies updated
-
-## Best Practices
-
-1. **Defense in Depth** - Multiple security layers
-2. **Least Privilege** - Minimum permissions
-3. **Fail Securely** - Errors don't expose data
-4. **Separation of Concerns** - Isolate security-critical code
-5. **Keep it Simple** - Complex code has more vulnerabilities
-6. **Don't Trust Input** - Validate everything
-7. **Update Regularly** - Keep dependencies current
-
-## Emergency Response
-
-If CRITICAL vulnerability found:
-1. Document the issue
-2. Provide secure code fix
-3. Check if vulnerability was exploited
-4. Rotate any exposed secrets

+ 83 - 0
.claude/commands/create-next-version-branch.md

@@ -0,0 +1,83 @@
+---
+name: create-next-version-branch
+description: Create development and release branches with GitHub Release for the next version. Usage: /create-next-version-branch dev/{major}.{minor}.x
+---
+
+# Create Next Version Branch
+
+Automate the creation of development branches and GitHub Release for a new GROWI version.
+
+## Input
+
+The argument `$ARGUMENTS` must be a branch name in the format `dev/{major}.{minor}.x` (e.g., `dev/7.5.x`).
+
+## Procedure
+
+### Step 1: Parse and Validate Input
+
+1. Parse `$ARGUMENTS` to extract `{major}` and `{minor}` from the `dev/{major}.{minor}.x` pattern
+2. If the format is invalid, display an error and stop:
+   - Must match `dev/{number}.{number}.x`
+3. Set the following variables:
+   - `DEV_BRANCH`: `dev/{major}.{minor}.x`
+   - `RELEASE_BRANCH`: `release/{major}.{minor}.x`
+   - `TAG_NAME`: `v{major}.{minor}.x-base`
+   - `RELEASE_TITLE`: `v{major}.{minor}.x Base Release`
+
+### Step 2: Create and Push the Development Branch
+
+1. Confirm with the user before proceeding
+2. Create and push `DEV_BRANCH` from the current HEAD:
+   ```bash
+   git checkout -b {DEV_BRANCH}
+   git push origin {DEV_BRANCH}
+   ```
+
+### Step 3: Create GitHub Release
+
+1. Create a GitHub Release using `gh release create`:
+   ```bash
+   gh release create {TAG_NAME} \
+     --target {DEV_BRANCH} \
+     --title "{RELEASE_TITLE}" \
+     --notes "The base release for release-drafter to avoid \`Error: GraphQL Rate Limit Exceeded\`
+   https://github.com/release-drafter/release-drafter/issues/1018" \
+     --latest=false \
+     --prerelease=false
+   ```
+   - `--latest=false`: Do NOT set as latest release
+   - `--prerelease=false`: Do NOT set as pre-release
+
+### Step 4: Verify targetCommitish
+
+1. Run the following command and confirm that `targetCommitish` equals `DEV_BRANCH`:
+   ```bash
+   gh release view {TAG_NAME} --json targetCommitish
+   ```
+2. If `targetCommitish` does not match, display an error and stop
+
+### Step 5: Create and Push the Release Branch
+
+1. From the same commit (still on `DEV_BRANCH`), create and push `RELEASE_BRANCH`:
+   ```bash
+   git checkout -b {RELEASE_BRANCH}
+   git push origin {RELEASE_BRANCH}
+   ```
+
+### Step 6: Summary
+
+Display a summary of all created resources:
+
+```
+Created:
+  - Branch: {DEV_BRANCH} (pushed to origin)
+  - Branch: {RELEASE_BRANCH} (pushed to origin)
+  - GitHub Release: {RELEASE_TITLE} (tag: {TAG_NAME}, target: {DEV_BRANCH})
+```
+
+## Error Handling
+
+- If `DEV_BRANCH` already exists on the remote, warn the user and ask how to proceed
+- If `RELEASE_BRANCH` already exists on the remote, warn the user and ask how to proceed
+- If the tag `TAG_NAME` already exists, warn the user and ask how to proceed
+- If `gh` CLI is not authenticated, instruct the user to run `gh auth login`

+ 15 - 32
.github/workflows/ci-app-prod.yml

@@ -39,22 +39,21 @@ concurrency:
 
 jobs:
 
-  test-prod-node18:
-    uses: growilabs/growi/.github/workflows/reusable-app-prod.yml@master
-    if: |
-      ( github.event_name == 'push'
-        || github.base_ref == 'master'
-        || github.base_ref == 'dev/7.*.x'
-        || startsWith( github.base_ref, 'release/' )
-        || startsWith( github.head_ref, 'mergify/merge-queue/' ))
-    with:
-      node-version: 18.x
-      skip-e2e-test: true
-    secrets:
-      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
-
+  # test-prod-node22:
+  #   uses: growilabs/growi/.github/workflows/reusable-app-prod.yml@master
+  #   if: |
+  #     ( github.event_name == 'push'
+  #       || github.base_ref == 'master'
+  #       || github.base_ref == 'dev/7.*.x'
+  #       || startsWith( github.base_ref, 'release/' )
+  #       || startsWith( github.head_ref, 'mergify/merge-queue/' ))
+  #   with:
+  #     node-version: 22.x
+  #     skip-e2e-test: true
+  #   secrets:
+  #     SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
 
-  test-prod-node20:
+  test-prod-node24:
     uses: growilabs/growi/.github/workflows/reusable-app-prod.yml@master
     if: |
       ( github.event_name == 'push'
@@ -63,23 +62,7 @@ jobs:
         || startsWith( github.base_ref, 'release/' )
         || startsWith( github.head_ref, 'mergify/merge-queue/' ))
     with:
-      node-version: 20.x
+      node-version: 24.x
       skip-e2e-test: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) }}
     secrets:
       SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
-
-  # run-reg-suit-node20:
-  #   needs: [test-prod-node20]
-
-  #   uses: growilabs/growi/.github/workflows/reusable-app-reg-suit.yml@master
-
-  #   if: always()
-
-  #   with:
-  #     node-version: 20.x
-  #     skip-reg-suit: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) }}
-  #   secrets:
-  #     REG_NOTIFY_GITHUB_PLUGIN_CLIENTID: ${{ secrets.REG_NOTIFY_GITHUB_PLUGIN_CLIENTID }}
-  #     AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
-  #     AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
-  #     SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

+ 3 - 3
.github/workflows/ci-app.yml

@@ -44,7 +44,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
 
     steps:
       - uses: actions/checkout@v4
@@ -92,7 +92,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
         mongodb-version: ['6.0', '8.0']
 
     services:
@@ -157,7 +157,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
         mongodb-version: ['6.0', '8.0']
 
     services:

+ 3 - 3
.github/workflows/ci-pdf-converter.yml

@@ -29,7 +29,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
 
     steps:
     - uses: actions/checkout@v4
@@ -65,7 +65,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
 
     steps:
     - uses: actions/checkout@v4
@@ -104,7 +104,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
 
     steps:
     - uses: actions/checkout@v4

+ 3 - 3
.github/workflows/ci-slackbot-proxy.yml

@@ -30,7 +30,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
 
     steps:
     - uses: actions/checkout@v4
@@ -85,7 +85,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
 
     services:
       mysql:
@@ -163,7 +163,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [20.x]
+        node-version: [24.x]
 
     services:
       mysql:

+ 2 - 2
.github/workflows/release-rc.yml

@@ -37,7 +37,7 @@ jobs:
           type=raw,value=${{ steps.package-json.outputs.packageVersion }}.{{sha}}
 
   build-image-rc:
-    uses: growilabs/growi/.github/workflows/reusable-app-build-image.yml@master
+    uses: growilabs/growi/.github/workflows/reusable-app-build-image.yml@rc/v7.5.x-node24
     with:
       image-name: growilabs/growi
       tag-temporary: latest-rc
@@ -47,7 +47,7 @@ jobs:
   publish-image-rc:
     needs: [determine-tags, build-image-rc]
 
-    uses: growilabs/growi/.github/workflows/reusable-app-create-manifests.yml@master
+    uses: growilabs/growi/.github/workflows/reusable-app-create-manifests.yml@rc/v7.5.x-node24
     with:
       tags: ${{ needs.determine-tags.outputs.TAGS }}
       registry: docker.io

+ 2 - 2
.github/workflows/release-subpackages.yml

@@ -32,7 +32,7 @@ jobs:
 
     - uses: actions/setup-node@v4
       with:
-        node-version: '20'
+        node-version: '24'
         cache: 'pnpm'
 
     - name: Install dependencies
@@ -75,7 +75,7 @@ jobs:
 
     - uses: actions/setup-node@v4
       with:
-        node-version: '20'
+        node-version: '24'
         cache: 'pnpm'
 
     - name: Install dependencies

+ 1 - 1
.github/workflows/reusable-app-build-image.yml

@@ -48,7 +48,7 @@ jobs:
         projectName: growi-official-image-builder
       env:
         CODEBUILD__sourceVersion: ${{ inputs.source-version }}
-        CODEBUILD__imageOverride: ${{ (matrix.platform == 'amd64' && 'aws/codebuild/amazonlinux2-x86_64-standard:4.0') || 'aws/codebuild/amazonlinux2-aarch64-standard:2.0' }}
+        CODEBUILD__imageOverride: ${{ (matrix.platform == 'amd64' && 'aws/codebuild/amazonlinux2-x86_64-standard:5.0') || 'aws/codebuild/amazonlinux2-aarch64-standard:3.0' }}
         CODEBUILD__environmentTypeOverride: ${{ (matrix.platform == 'amd64' && 'LINUX_CONTAINER') || 'ARM_CONTAINER' }}
         CODEBUILD__environmentVariablesOverride: '[
           { "name": "IMAGE_TAG", "type": "PLAINTEXT", "value": "docker.io/${{ inputs.image-name }}:${{ inputs.tag-temporary }}-${{ matrix.platform }}" }

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

@@ -16,7 +16,7 @@ on:
       node-version:
         required: true
         type: string
-        default: 22.x
+        default: 24.x
       skip-e2e-test:
         type: boolean
         default: false

+ 233 - 0
.kiro/specs/official-docker-image/design.md

@@ -0,0 +1,233 @@
+# Design Document: official-docker-image
+
+## Overview
+
+**Purpose**: Modernize the Dockerfile and entrypoint for the GROWI official Docker image based on 2025-2026 best practices, achieving enhanced security, optimized memory management, and improved build efficiency.
+
+**Users**: Infrastructure administrators (build/deploy), GROWI operators (memory tuning), and Docker image end users (usage via docker-compose).
+
+**Impact**: Redesign the existing 3-stage Dockerfile into a 5-stage configuration. Migrate the base image to Docker Hardened Images (DHI). Change the entrypoint from a shell script to TypeScript (using Node.js 24 native TypeScript execution), achieving a fully hardened configuration that requires no shell.
+
+### Goals
+
+- Up to 95% CVE reduction through DHI base image adoption
+- **Fully shell-free TypeScript entrypoint** — Node.js 24 native TypeScript execution (type stripping), maintaining the minimized attack surface of the DHI runtime as-is
+- Memory management via 3-tier fallback: `V8_MAX_HEAP_SIZE` / cgroup auto-calculation / V8 default
+- Environment variable names aligned with V8 option names (`V8_MAX_HEAP_SIZE`, `V8_OPTIMIZE_FOR_SIZE`, `V8_LITE_MODE`)
+- Improved build cache efficiency through the `turbo prune --docker` pattern
+- Privilege drop via gosu → `process.setuid/setgid` (Node.js native)
+
+### Non-Goals
+
+- Changes to Kubernetes manifests / Helm charts (GROWI.cloud `V8_MAX_HEAP_SIZE` configuration is out of scope)
+- Application code changes (adding gc(), migrating to .pipe(), etc. are separate specs)
+- Updating docker-compose.yml (documentation updates only)
+- Support for Node.js versions below 24
+- Adding HEALTHCHECK instructions (k8s uses its own probes, Docker Compose users can configure their own)
+
+## Architecture
+
+### Existing Architecture Analysis
+
+**Current Dockerfile 3-stage configuration:**
+
+| Stage | Base Image | Role |
+|-------|-----------|------|
+| `base` | `node:20-slim` | Install pnpm + turbo |
+| `builder` | `base` | `COPY . .` → install → build → artifacts |
+| release (unnamed) | `node:20-slim` | gosu install → artifact extraction → execution |
+
+**Main issues:**
+- `COPY . .` includes the entire monorepo in the build layer
+- pnpm version is hardcoded (`PNPM_VERSION="10.4.1"`)
+- Typo in `---frozen-lockfile`
+- Base image is node:20-slim (prone to CVE accumulation)
+- No memory management flags
+- No OCI labels
+- gosu installation requires apt-get (runtime dependency on apt)
+
+### Architecture Pattern & Boundary Map
+
+```mermaid
+graph TB
+    subgraph BuildPhase
+        base[base stage<br>DHI dev + pnpm + turbo]
+        pruner[pruner stage<br>turbo prune --docker]
+        deps[deps stage<br>dependency install]
+        builder[builder stage<br>build + artifacts]
+    end
+
+    subgraph ReleasePhase
+        release[release stage<br>DHI runtime - no shell]
+    end
+
+    base --> pruner
+    pruner --> deps
+    deps --> builder
+    builder -->|artifacts| release
+
+    subgraph RuntimeFiles
+        entrypoint[docker-entrypoint.ts<br>TypeScript entrypoint]
+    end
+
+    entrypoint --> release
+```
+
+**Architecture Integration:**
+- Selected pattern: Multi-stage build with dependency caching separation
+- Domain boundaries: Build concerns (stages 1-4) vs Runtime concerns (stage 5 + entrypoint)
+- Existing patterns preserved: Production dependency extraction via pnpm deploy, tar.gz artifact transfer
+- New components: pruner stage (turbo prune), TypeScript entrypoint
+- **Key change**: gosu + shell script → TypeScript entrypoint (`process.setuid/setgid` + `fs` module + `child_process.execFileSync/spawn`). Eliminates the need for copying busybox/bash, maintaining the minimized attack surface of the DHI runtime as-is. Executes `.ts` directly via Node.js 24 type stripping
+- Steering compliance: Maintains Debian base (glibc performance), maintains monorepo build pattern
+
+### Technology Stack
+
+| Layer | Choice / Version | Role in Feature | Notes |
+|-------|------------------|-----------------|-------|
+| Base Image (build) | `dhi.io/node:24-debian13-dev` | Base for build stages | apt/bash/git/util-linux available |
+| Base Image (runtime) | `dhi.io/node:24-debian13` | Base for release stage | Minimal configuration, 95% CVE reduction, **no shell** |
+| Entrypoint | Node.js (TypeScript) | Initialization, heap calculation, privilege drop, process startup | Node.js 24 native type stripping, no busybox/bash needed |
+| Privilege Drop | `process.setuid/setgid` (Node.js) | root → node user switch | No external binaries needed |
+| Build Tool | `turbo prune --docker` | Monorepo minimization | Official Turborepo recommendation |
+| Package Manager | pnpm (wget standalone) | Dependency management | corepack not adopted (scheduled for removal in Node.js 25+) |
+
+> For the rationale behind adopting the TypeScript entrypoint and comparison with busybox-static/setpriv, see `research.md`.
+
+## System Flows
+
+### Entrypoint Execution Flow
+
+```mermaid
+flowchart TD
+    Start[Container Start<br>as root via node entrypoint.ts] --> Setup[Directory Setup<br>fs.mkdirSync + symlinkSync + chownSync]
+    Setup --> HeapCalc{V8_MAX_HEAP_SIZE<br>is set?}
+    HeapCalc -->|Yes| UseEnv[Use V8_MAX_HEAP_SIZE]
+    HeapCalc -->|No| CgroupCheck{cgroup limit<br>detectable?}
+    CgroupCheck -->|Yes| AutoCalc[Auto-calculate<br>60% of cgroup limit]
+    CgroupCheck -->|No| NoFlag[No heap flag<br>V8 default]
+    UseEnv --> OptFlags[Check V8_OPTIMIZE_FOR_SIZE<br>and V8_LITE_MODE]
+    AutoCalc --> OptFlags
+    NoFlag --> OptFlags
+    OptFlags --> LogFlags[console.log applied flags]
+    LogFlags --> DropPriv[Drop privileges<br>process.setgid + setuid]
+    DropPriv --> Migration[Run migration<br>execFileSync node migrate-mongo]
+    Migration --> SpawnApp[Spawn app process<br>node --max-heap-size=X ... app.js]
+    SpawnApp --> SignalFwd[Forward SIGTERM/SIGINT<br>to child process]
+```
+
+**Key Decisions:**
+- Prioritize cgroup v2 (`/sys/fs/cgroup/memory.max`), fall back to v1
+- Treat cgroup v1 unlimited value (very large number) as no flag (threshold: 64GB)
+- `--max-heap-size` is passed to the spawned child process (the application itself), not the entrypoint process
+- Migration is invoked directly via `child_process.execFileSync` calling node (no `npm run`, no shell needed)
+- App startup uses `child_process.spawn` + signal forwarding to fulfill PID 1 responsibilities
+
+### Docker Build Flow
+
+```mermaid
+flowchart LR
+    subgraph Stage1[base]
+        S1[DHI dev image<br>+ pnpm + turbo]
+    end
+
+    subgraph Stage2[pruner]
+        S2A[COPY monorepo]
+        S2B[turbo prune --docker]
+    end
+
+    subgraph Stage3[deps]
+        S3A[COPY json + lockfile]
+        S3B[pnpm install --frozen-lockfile]
+    end
+
+    subgraph Stage4[builder]
+        S4A[COPY full source]
+        S4B[turbo run build]
+        S4C[pnpm deploy + tar.gz]
+    end
+
+    subgraph Stage5[release]
+        S5A[DHI runtime<br>no additional binaries]
+        S5B[Extract artifacts]
+        S5C[COPY entrypoint.js]
+    end
+
+    Stage1 --> Stage2 --> Stage3 --> Stage4
+    Stage4 -->|tar.gz| Stage5
+```
+
+## Components and Interfaces
+
+| Component | Domain/Layer | Intent | Key Dependencies |
+|-----------|-------------|--------|-----------------|
+| Dockerfile | Infrastructure | 5-stage Docker image build definition | DHI images, turbo, pnpm |
+| docker-entrypoint.ts | Infrastructure | Container startup initialization (TypeScript) | Node.js fs/child_process, cgroup fs |
+| docker-entrypoint.spec.ts | Infrastructure | Unit tests for entrypoint | vitest |
+| Dockerfile.dockerignore | Infrastructure | Build context filter | — |
+| README.md | Documentation | Docker Hub image documentation | — |
+| buildspec.yml | CI/CD | CodeBuild build definition | AWS Secrets Manager, dhi.io |
+
+### Dockerfile
+
+**Responsibilities & Constraints**
+- 5-stage configuration: `base` → `pruner` → `deps` → `builder` → `release`
+- Use of DHI base images (`dhi.io/node:24-debian13-dev` / `dhi.io/node:24-debian13`)
+- **No shell or additional binary copying in runtime** (everything is handled by the Node.js entrypoint)
+
+**Stage Definitions:**
+- **base**: DHI dev image + pnpm (wget) + turbo + apt packages (`ca-certificates`, `wget`)
+- **pruner**: `COPY . .` + `turbo prune @growi/app --docker`
+- **deps**: COPY json/lockfile from pruner + `pnpm install --frozen-lockfile` + node-gyp
+- **builder**: COPY full source from pruner + `turbo run build` + `pnpm deploy` + artifact packaging
+- **release**: DHI runtime (no shell) + `COPY --from=builder` artifacts + entrypoint + OCI labels + EXPOSE/VOLUME
+
+### docker-entrypoint.ts
+
+**Responsibilities & Constraints**
+- Written in TypeScript, executed via Node.js 24 native type stripping (enums not allowed)
+- Directory setup as root (`/data/uploads` + symlink, `/tmp/page-bulk-export`)
+- Heap size determination via 3-tier fallback
+- Privilege drop via `process.setgid()` + `process.setuid()`
+- Migration execution via `child_process.execFileSync` (direct node invocation, no shell)
+- App process startup via `child_process.spawn` with signal forwarding (PID 1 responsibilities)
+- No external binary dependencies
+
+**Environment Variable Interface**
+
+| Variable | Type | Default | Description |
+|----------|------|---------|-------------|
+| `V8_MAX_HEAP_SIZE` | int (MB) | (unset) | Explicitly specify the --max-heap-size value for Node.js |
+| `V8_OPTIMIZE_FOR_SIZE` | `"true"` / (unset) | (unset) | Enable the --optimize-for-size flag |
+| `V8_LITE_MODE` | `"true"` / (unset) | (unset) | Enable the --lite-mode flag |
+
+> **Naming Convention**: Environment variable names are aligned with their corresponding V8 option names (`--max-heap-size`, `--optimize-for-size`, `--lite-mode`) prefixed with `V8_`. This improves discoverability and self-documentation compared to the previous `GROWI_`-prefixed names.
+
+**Batch Contract**
+- **Trigger**: On container startup (`ENTRYPOINT ["node", "/docker-entrypoint.ts"]`)
+- **Input validation**: V8_MAX_HEAP_SIZE (positive int, empty = unset), V8_OPTIMIZE_FOR_SIZE/V8_LITE_MODE (only `"true"` is valid), cgroup v2 (`memory.max`: numeric or `"max"`), cgroup v1 (`memory.limit_in_bytes`: numeric, large value = unlimited)
+- **Output**: Node flags passed directly as arguments to `child_process.spawn`
+- **Idempotency**: Executed on every restart, safe via `fs.mkdirSync({ recursive: true })`
+
+### README.md
+
+**Responsibilities & Constraints**
+- Docker Hub image documentation (published to hub.docker.com/r/growilabs/growi)
+- Document the V8 memory management environment variables under Configuration > Environment Variables section
+- Include variable name, type, default, and description for each: `V8_MAX_HEAP_SIZE`, `V8_OPTIMIZE_FOR_SIZE`, `V8_LITE_MODE`
+
+## Error Handling
+
+| Error | Category | Response |
+|-------|----------|----------|
+| cgroup file read failure | System | Warn and continue with no flag (V8 default) |
+| V8_MAX_HEAP_SIZE is invalid | User | Warn and continue with no flag (container still starts) |
+| Directory creation/permission failure | System | `process.exit(1)` — check volume mount configuration |
+| Migration failure | Business Logic | `execFileSync` throws → `process.exit(1)` — Docker/k8s restarts |
+| App process abnormal exit | System | Propagate child process exit code |
+
+## Performance & Scalability
+
+- **Build cache**: `turbo prune --docker` caches the dependency install layer. Skips dependency installation during rebuilds when only source code changes
+- **Image size**: No additional binaries in DHI runtime. Base layer is smaller compared to node:24-slim
+- **Memory efficiency**: Total heap control via `--max-heap-size` avoids the v24 trusted_space overhead issue. Prevents memory pressure in multi-tenant environments

+ 82 - 0
.kiro/specs/official-docker-image/requirements.md

@@ -0,0 +1,82 @@
+# Requirements Document
+
+## Introduction
+
+Modernize and optimize the GROWI official Docker image's Dockerfile (`apps/app/docker/Dockerfile`) and `docker-entrypoint.sh` based on 2025-2026 best practices. Target Node.js 24 and incorporate findings from the memory report (`apps/app/tmp/memory-results/REPORT.md`) to improve memory management.
+
+### Summary of Current State Analysis
+
+**Current Dockerfile structure:**
+- 3-stage structure: `base` → `builder` → `release` (based on node:20-slim)
+- Monorepo build with pnpm + turbo, production dependency extraction via `pnpm deploy`
+- Privilege drop from root to node user using gosu (after directory creation in entrypoint)
+- `COPY . .` copies the entire context into the builder
+- Application starts after running `npm run migrate` in CMD
+
+**GROWI-specific design intentions (items to maintain):**
+- Privilege drop pattern: The entrypoint must create and set permissions for `/data/uploads` and `/tmp/page-bulk-export` with root privileges, then drop to the node user for execution
+- `pnpm deploy --prod`: The official method for extracting only production dependencies from a pnpm monorepo
+- Inter-stage artifact transfer via tar.gz: Cleanly transfers build artifacts to the release stage
+- `apps/app/tmp` directory: Required in the production image as files are placed there during operation
+- `--expose_gc` flag: Required for explicitly calling `gc()` in batch processing (ES rebuild, import, etc.)
+- `npm run migrate` in CMD: Automatically runs migrations at startup for the convenience of Docker image users
+
+**References:**
+- [Future Architect: 2024 Dockerfile Best Practices](https://future-architect.github.io/articles/20240726a/)
+- [Snyk: 10 best practices to containerize Node.js](https://snyk.io/blog/10-best-practices-to-containerize-nodejs-web-applications-with-docker/)
+- [ByteScrum: Dockerfile Best Practices 2025](https://blog.bytescrum.com/dockerfile-best-practices-2025-secure-fast-and-modern)
+- [OneUptime: Docker Health Check Best Practices 2026](https://oneuptime.com/blog/post/2026-01-30-docker-health-check-best-practices/view)
+- [Docker: Introduction to heredocs in Dockerfiles](https://www.docker.com/blog/introduction-to-heredocs-in-dockerfiles/)
+- [Docker Hardened Images: Node.js Migration Guide](https://docs.docker.com/dhi/migration/examples/node/)
+- [Docker Hardened Images Catalog: Node.js](https://hub.docker.com/hardened-images/catalog/dhi/node)
+- GROWI Memory Usage Investigation Report (`apps/app/tmp/memory-results/REPORT.md`)
+
+## Requirements
+
+### Requirement 1: Modernize Base Image and Build Environment
+
+**Objective:** As an infrastructure administrator, I want the Dockerfile's base image and syntax to comply with the latest best practices, so that security patch application, performance improvements, and maintainability enhancements are achieved
+
+**Summary**: DHI base images adopted (`dhi.io/node:24-debian13-dev` for build, `dhi.io/node:24-debian13` for release) with up to 95% CVE reduction. Syntax directive updated to auto-follow latest stable. pnpm installed via wget standalone script (corepack not adopted due to planned removal in Node.js 25+). Fixed `---frozen-lockfile` typo and eliminated hardcoded pnpm version.
+
+### Requirement 2: Memory Management Optimization
+
+**Objective:** As a GROWI operator, I want the Node.js heap size to be appropriately controlled according to container memory constraints, so that the risk of OOMKilled is reduced and memory efficiency in multi-tenant environments is improved
+
+**Summary**: 3-tier heap size fallback implemented in docker-entrypoint.ts: (1) `GROWI_HEAP_SIZE` env var, (2) cgroup v2/v1 auto-calculation at 60%, (3) V8 default. Uses `--max-heap-size` (not `--max_old_space_size`) passed as direct spawn arguments (not `NODE_OPTIONS`). Additional flags: `--optimize-for-size` via `GROWI_OPTIMIZE_MEMORY=true`, `--lite-mode` via `GROWI_LITE_MODE=true`.
+
+### Requirement 3: Build Efficiency and Cache Optimization
+
+**Objective:** As a developer, I want Docker builds to be fast and efficient, so that CI/CD pipeline build times are reduced and image size is minimized
+
+**Summary**: `turbo prune --docker` pattern adopted to eliminate `COPY . .` and maximize layer cache (dependency install cached separately from source changes). pnpm store and apt-get cache mounts maintained. `.next/cache` excluded from release stage. Artifact transfer uses `COPY --from=builder` (adapted from design's `--mount=type=bind,from=builder` due to shell-less DHI runtime).
+
+### Requirement 4: Security Hardening
+
+**Objective:** As a security officer, I want the Docker image to comply with security best practices, so that the attack surface is minimized and the safety of the production environment is improved
+
+**Summary**: Non-root execution via Node.js native `process.setuid/setgid` (no gosu/setpriv). Release stage contains no unnecessary packages — no shell, no apt, no build tools. Enhanced `.dockerignore` excludes `.git`, secrets, test files, IDE configs. `--no-install-recommends` used for apt-get in build stage.
+
+### Requirement 5: Operability and Observability Improvement
+
+**Objective:** As an operations engineer, I want the Docker image to have appropriate metadata configured, so that management by container orchestrators is facilitated
+
+**Summary**: OCI standard LABEL annotations added (`org.opencontainers.image.source`, `.title`, `.description`, `.vendor`). `EXPOSE 3000` and `VOLUME /data` maintained.
+
+### Requirement 6: Entrypoint and CMD Refactoring
+
+**Objective:** As a developer, I want the entrypoint script and CMD to have a clear and maintainable structure, so that dynamic assembly of memory flags and future extensions are facilitated
+
+**Summary**: Entrypoint rewritten in TypeScript (`docker-entrypoint.ts`) executed via Node.js 24 native type stripping. Handles: directory setup (`/data/uploads`, `/tmp/page-bulk-export`), heap size calculation (3-tier fallback), privilege drop (`process.setgid` + `process.setuid`), migration execution (`execFileSync`), app process spawn with signal forwarding. Always includes `--expose_gc`. Logs applied flags to stdout.
+
+### Requirement 7: Backward Compatibility
+
+**Objective:** As an existing Docker image user, I want existing operations to not break when migrating to the new Dockerfile, so that the risk during upgrades is minimized
+
+**Summary**: Full backward compatibility maintained. Environment variables (`MONGO_URI`, `FILE_UPLOAD`, etc.), `VOLUME /data`, port 3000, and docker-compose usage patterns all work as before. Without memory management env vars, behavior is equivalent to V8 defaults.
+
+### Requirement 8: Production Replacement and CI/CD Support
+
+**Objective:** As an infrastructure administrator, I want the artifacts in the docker-new directory to officially replace the existing docker directory and the CI/CD pipeline to operate with the new Dockerfile, so that DHI-based images are used in production builds
+
+**Summary**: All files moved from `apps/app/docker-new/` to `apps/app/docker/`, old files deleted. Dockerfile self-referencing path updated. `docker login dhi.io` added to buildspec.yml pre_build phase, reusing existing `DOCKER_REGISTRY_PASSWORD` secret. `codebuild/` directory and `README.md` maintained.

+ 288 - 0
.kiro/specs/official-docker-image/research.md

@@ -0,0 +1,288 @@
+# Research & Design Decisions
+
+---
+**Purpose**: Discovery findings and design decision rationale for the official Docker image modernization.
+---
+
+## Summary
+- **Feature**: `official-docker-image`
+- **Discovery Scope**: Extension (major improvement of existing Dockerfile)
+- **Key Findings**:
+  - The DHI runtime image (`dhi.io/node:24-debian13`) is a minimal configuration that does not include a shell, package manager, or coreutils. By adopting a Node.js entrypoint (TypeScript), a configuration requiring no shell or additional binaries is achieved
+  - `--mount=type=bind` is impractical for monorepo multi-step builds. `turbo prune --docker` is the officially recommended Docker optimization approach by Turborepo
+  - gosu is replaced by Node.js native `process.setuid/setgid`. External binaries (gosu/setpriv/busybox) are completely unnecessary
+  - HEALTHCHECK is not adopted (k8s uses its own probes. Docker Compose users can configure it themselves)
+  - Node.js 24 supports native TypeScript execution (type stripping). The entrypoint can be written in TypeScript
+
+## Research Log
+
+### DHI Runtime Image Configuration
+
+- **Context**: Investigation of constraints when adopting `dhi.io/node:24-debian13` as the base image for the release stage
+- **Sources Consulted**:
+  - [DHI Catalog GitHub](https://github.com/docker-hardened-images/catalog) — `image/node/debian-13/` directory
+  - [DHI Documentation](https://docs.docker.com/dhi/)
+  - [DHI Use an Image](https://docs.docker.com/dhi/how-to/use/)
+- **Findings**:
+  - Pre-installed packages in the runtime image: only `base-files`, `ca-certificates`, `libc6`, `libgomp1`, `libstdc++6`, `netbase`, `tzdata`
+  - **No shell**, **no apt**, **no coreutils**, **no curl/wget**
+  - Default user: `node` (UID 1000, GID 1000)
+  - Dev image (`-dev`): `apt`, `bash`, `git`, `util-linux`, `coreutils`, etc. are pre-installed
+  - Available tags: `dhi.io/node:24-debian13`, `dhi.io/node:24-debian13-dev`
+  - Platforms: `linux/amd64`, `linux/arm64`
+- **Implications**:
+  - By writing the entrypoint in Node.js (TypeScript), neither a shell nor additional binaries are needed at all
+  - gosu/setpriv are replaced by Node.js native `process.setuid/setgid`. No need to copy external binaries
+  - HEALTHCHECK is not adopted (k8s uses its own probes). Health checks via curl/Node.js http module are unnecessary
+
+### Applicability of `--mount=type=bind` in Monorepo Builds
+
+- **Context**: Investigation of the feasibility of Requirement 3.1 "Use `--mount=type=bind` instead of `COPY . .` in the builder stage"
+- **Sources Consulted**:
+  - [Docker Build Cache Optimization](https://docs.docker.com/build/cache/optimize/)
+  - [Dockerfile Reference - RUN --mount](https://docs.docker.com/reference/dockerfile/)
+  - [pnpm Docker Documentation](https://pnpm.io/docker)
+  - [Turborepo Docker Guide](https://turbo.build/repo/docs/handbook/deploying-with-docker)
+- **Findings**:
+  - `--mount=type=bind` is **only valid during the execution of a RUN instruction** and is not carried over to the next RUN instruction
+  - In the multi-step process of monorepo builds (install -> build -> deploy), each step depends on artifacts from the previous step, making it difficult to achieve with bind mounts alone
+  - It is possible to combine all steps into a single RUN, but this loses the benefits of layer caching
+  - **Turborepo official recommendation**: Use `turbo prune --docker` to minimize the monorepo for Docker
+    - `out/json/` — only package.json files needed for dependency install
+    - `out/pnpm-lock.yaml` — lockfile
+    - `out/full/` — source code needed for the build
+  - This approach avoids `COPY . .` while leveraging layer caching
+- **Implications**:
+  - Requirement 3.1 should be achieved using the `turbo prune --docker` pattern instead of `--mount=type=bind`
+  - The goal (minimizing source code layers / improving cache efficiency) can be equally achieved
+  - **However**, compatibility of `turbo prune --docker` with pnpm workspaces needs to be verified during implementation
+
+### Alternatives to gosu
+
+- **Context**: Investigation of alternatives since gosu is not available in the DHI runtime image
+- **Sources Consulted**:
+  - [gosu GitHub](https://github.com/tianon/gosu) — list of alternative tools
+  - [Debian Packages - gosu in trixie](https://packages.debian.org/trixie/admin/gosu)
+  - [PhotoPrism: Switch from gosu to setpriv](https://github.com/photoprism/photoprism/pull/2730)
+  - [MongoDB Docker: Replace gosu by setpriv](https://github.com/docker-library/mongo/pull/714)
+  - Node.js `process.setuid/setgid` documentation
+- **Findings**:
+  - `setpriv` is part of `util-linux` and is pre-installed in the DHI dev image
+  - `gosu node command` can be replaced with `setpriv --reuid=node --regid=node --init-groups -- command`
+  - PhotoPrism and the official MongoDB Docker image have already migrated from gosu to setpriv
+  - **Node.js native**: Can be fully replaced with `process.setgid(1000)` + `process.setuid(1000)` + `process.initgroups('node', 1000)`
+  - When adopting a Node.js entrypoint, no external binaries (gosu/setpriv/busybox) are needed at all
+- **Implications**:
+  - **Final decision**: Adopt Node.js native `process.setuid/setgid` (setpriv is also unnecessary)
+  - No need to copy gosu/setpriv binaries, resulting in no additional binaries in the release stage
+  - Maintains the minimized attack surface of the DHI runtime as-is
+
+### HEALTHCHECK Implementation Approach (Not Adopted)
+
+- **Context**: Investigation of HEALTHCHECK implementation approaches since curl is not available in the DHI runtime image
+- **Sources Consulted**:
+  - [Docker Healthchecks in Distroless Node.js](https://www.mattknight.io/blog/docker-healthchecks-in-distroless-node-js)
+  - [Docker Healthchecks: Why Not to Use curl](https://blog.sixeyed.com/docker-healthchecks-why-not-to-use-curl-or-iwr/)
+  - GROWI healthcheck endpoint: `apps/app/src/server/routes/apiv3/healthcheck.ts`
+- **Findings**:
+  - Node.js `http` module is sufficient (curl is unnecessary)
+  - GROWI's `/_api/v3/healthcheck` endpoint returns `{ status: 'OK' }` without any parameters
+  - Docker HEALTHCHECK is useful for Docker Compose's `depends_on: service_healthy` dependency order control
+  - In k8s environments, custom probes (liveness/readiness) are used, so the Dockerfile's HEALTHCHECK is unnecessary
+- **Implications**:
+  - **Final decision: Not adopted**. k8s uses its own probes, and Docker Compose users can configure it themselves in compose.yaml
+  - By not including HEALTHCHECK in the Dockerfile, simplicity is maintained
+
+### Shell Dependency of npm run migrate
+
+- **Context**: Investigation of whether `npm run migrate` within CMD requires a shell
+- **Sources Consulted**:
+  - GROWI `apps/app/package.json`'s `migrate` script
+- **Findings**:
+  - The actual `migrate` script content: `node -r dotenv-flow/config node_modules/migrate-mongo/bin/migrate-mongo up -f config/migrate-mongo-config.js`
+  - `npm run` internally uses `sh -c`, so a shell is required
+  - Alternative: Running the script contents directly with node eliminates the need for npm/sh
+  - However, using npm run is more maintainable (can track changes in package.json)
+- **Implications**:
+  - **Final decision**: Use `child_process.execFileSync` in the Node.js entrypoint to directly execute the migration command (not using npm run, no shell needed)
+  - Adopt the approach of directly writing the `migrate` script contents within the entrypoint
+  - When package.json changes, the entrypoint also needs to be updated, but priority is given to fully shell-less DHI runtime
+
+### Node.js 24 Native TypeScript Execution
+
+- **Context**: Investigation of whether Node.js 24's native TypeScript execution feature can be used when writing the entrypoint in TypeScript
+- **Sources Consulted**:
+  - [Node.js 23 Release Notes](https://nodejs.org/en/blog/release/v23.0.0) — `--experimental-strip-types` unflagged
+  - [Node.js Type Stripping Documentation](https://nodejs.org/docs/latest/api/typescript.html)
+- **Findings**:
+  - From Node.js 23, type stripping is enabled by default (no `--experimental-strip-types` flag needed)
+  - Available as a stable feature in Node.js 24
+  - **Constraint**: "Non-erasable syntax" such as enum and namespace cannot be used. `--experimental-transform-types` is required for those
+  - interface, type alias, and type annotations (`: string`, `: number`, etc.) can be used without issues
+  - Can be executed directly with `ENTRYPOINT ["node", "docker-entrypoint.ts"]`
+- **Implications**:
+  - The entrypoint can be written in TypeScript, enabling type-safe implementation
+  - Do not use enum; use union types (`type Foo = 'a' | 'b'`) as alternatives
+  - tsconfig.json is not required (type stripping operates independently)
+
+## Architecture Pattern Evaluation
+
+| Option | Description | Strengths | Risks / Limitations | Notes |
+|--------|-------------|-----------|---------------------|-------|
+| DHI runtime + busybox-static | Copy busybox-static to provide sh/coreutils | Minimal addition (~1MB) enables full functionality | Contradicts the original intent of DHI adoption (minimizing attack surface). Additional binaries are attack vectors | Rejected |
+| DHI runtime + bash/coreutils copy | Copy bash and various binaries individually from the dev stage | Full bash functionality available | Shared library dependencies are complex, many files need to be copied | Rejected |
+| DHI dev image as runtime | Use the dev image as-is for production | Minimal configuration changes | Increased attack surface due to apt/git etc., diminishes the meaning of DHI | Rejected |
+| Node.js entrypoint (TypeScript, shell-less) | Write the entrypoint in TypeScript. Runs with Node.js 24's native TypeScript execution | Completely shell-free, maintains DHI runtime's attack surface as-is, type-safe | Migration command written directly (not using npm run), updates needed when package.json changes | **Adopted** |
+
+## Design Decisions
+
+### Decision: Node.js TypeScript Entrypoint (Completely Shell-Free)
+
+- **Context**: The DHI runtime image contains neither a shell nor coreutils. Copying busybox-static contradicts the intent of DHI adoption (minimizing attack surface)
+- **Alternatives Considered**:
+  1. Copy busybox-static to provide shell + coreutils — Contradicts DHI's attack surface minimization
+  2. Copy bash + coreutils individually — Complex dependencies
+  3. Node.js TypeScript entrypoint — Everything can be accomplished with `fs`, `child_process`, and `process.setuid/setgid`
+- **Selected Approach**: Write the entrypoint in TypeScript (`docker-entrypoint.ts`). Execute directly using Node.js 24's native TypeScript execution (type stripping)
+- **Rationale**: No additional binaries needed in the DHI runtime whatsoever. Directory operations via fs module, privilege dropping via process.setuid/setgid, migration via execFileSync, and app startup via spawn. Improved maintainability through type safety
+- **Trade-offs**: Migration command is written directly (not using npm run). When the migrate script in package.json changes, the entrypoint also needs to be updated
+- **Follow-up**: Verify that Node.js 24's type stripping works correctly with a single-file entrypoint without import statements
+
+### Decision: Privilege Dropping via Node.js Native process.setuid/setgid
+
+- **Context**: gosu cannot be installed in the DHI runtime. busybox-static/setpriv are also not adopted (policy of eliminating additional binaries)
+- **Alternatives Considered**:
+  1. Copy gosu binary — Works but goes against industry trends
+  2. Copy setpriv binary — Works but goes against the policy of eliminating additional binaries
+  3. Node.js `process.setuid/setgid` — Standard Node.js API
+  4. Docker `--user` flag — Cannot handle dynamic processing in the entrypoint
+- **Selected Approach**: Drop privileges with `process.initgroups('node', 1000)` + `process.setgid(1000)` + `process.setuid(1000)`
+- **Rationale**: No external binaries needed at all. Can be called directly within the Node.js entrypoint. Safe privilege dropping in the order setgid -> setuid
+- **Trade-offs**: The entrypoint starts as a Node.js process running as root, and the app becomes its child process (not an exec like gosu). However, the app process is separated via spawn, and signal forwarding fulfills PID 1 responsibilities
+- **Follow-up**: None
+
+### Decision: turbo prune --docker Pattern
+
+- **Context**: Requirement 3.1 requires eliminating `COPY . .`, but `--mount=type=bind` is impractical for monorepo builds
+- **Alternatives Considered**:
+  1. `--mount=type=bind` — Does not persist across RUN instructions, unsuitable for multi-step builds
+  2. Combine all steps into a single RUN — Poor cache efficiency
+  3. `turbo prune --docker` — Officially recommended by Turborepo
+- **Selected Approach**: Use `turbo prune --docker` to minimize the monorepo for Docker, using optimized COPY patterns
+- **Rationale**: Officially recommended by Turborepo. Separates dependency install and source copy to maximize layer cache utilization. Eliminates `COPY . .` while remaining practical
+- **Trade-offs**: One additional build stage (pruner stage), but offset by improved cache efficiency
+- **Follow-up**: Verify `turbo prune --docker` compatibility with pnpm workspaces during implementation
+
+### Decision: Flag Injection via spawn Arguments
+
+- **Context**: `--max-heap-size` cannot be used in `NODE_OPTIONS`. It needs to be passed as a direct argument to the node command
+- **Alternatives Considered**:
+  1. Export environment variable `GROWI_NODE_FLAGS` and inject via shell variable expansion in CMD — Requires a shell
+  2. Rewrite CMD string with sed in the entrypoint — Fragile
+  3. Pass directly as arguments to `child_process.spawn` in the Node.js entrypoint — No shell needed
+- **Selected Approach**: Build a flag array within the entrypoint and pass it directly with `spawn(process.execPath, [...nodeFlags, ...appArgs])`
+- **Rationale**: No shell variable expansion needed. Passed directly as an array, resulting in zero risk of shell injection. Natural integration with the Node.js entrypoint
+- **Trade-offs**: CMD becomes unnecessary (the entrypoint handles all startup processing). Overriding the command with docker run does not affect the logic within the entrypoint
+- **Follow-up**: None
+
+### DHI Registry Authentication and CI/CD Integration
+
+- **Context**: Investigation of the authentication method required for pulling DHI base images and how to integrate with the existing CodeBuild pipeline
+- **Sources Consulted**:
+  - [DHI How to Use an Image](https://docs.docker.com/dhi/how-to/use/) — DHI usage instructions
+  - Existing `apps/app/docker/codebuild/buildspec.yml` — Current CodeBuild build definition
+  - Existing `apps/app/docker/codebuild/secretsmanager.tf` — AWS Secrets Manager configuration
+- **Findings**:
+  - DHI uses Docker Hub credentials (DHI is a feature of Docker Business/Team subscriptions)
+  - Authentication is possible with `docker login dhi.io --username <dockerhub-user> --password-stdin`
+  - The existing buildspec.yml is already logged into docker.io with the `DOCKER_REGISTRY_PASSWORD` secret
+  - The same credentials can be used to log into `dhi.io` as well (no additional secrets required)
+  - The flow of CodeBuild's `reusable-app-build-image.yml` -> CodeBuild Project -> buildspec.yml does not need to change
+- **Implications**:
+  - Can be addressed by simply adding one line of `docker login dhi.io` to the pre_build in buildspec.yml
+  - No changes to `secretsmanager.tf` are needed
+  - Login to both Docker Hub and DHI is required (docker.io for push, dhi.io for pull)
+
+### Impact Scope of Directory Replacement (Codebase Investigation)
+
+- **Context**: Confirming that existing references will not break when replacing `apps/app/docker-new/` with `apps/app/docker/`
+- **Sources Consulted**: Grep investigation of the entire codebase with the `apps/app/docker` keyword
+- **Findings**:
+  - `buildspec.yml`: `-f ./apps/app/docker/Dockerfile` — Same path after replacement (no change needed)
+  - `codebuild.tf`: `buildspec = "apps/app/docker/codebuild/buildspec.yml"` — Same (no change needed)
+  - `.github/workflows/release.yml`: `readme-filepath: ./apps/app/docker/README.md` — Same (no change needed)
+  - `.github/workflows/ci-app.yml` / `ci-app-prod.yml`: `!apps/app/docker/**` exclusion pattern — Same (no change needed)
+  - `apps/app/bin/github-actions/update-readme.sh`: `cd docker` + sed — Same (no change needed)
+  - Within Dockerfile: line 122 `apps/app/docker-new/docker-entrypoint.ts` — **Needs updating** (self-referencing path)
+  - `package.json` and `vitest.config` for docker-related references — None
+  - `lefthook.yml` for docker-related hooks — None
+- **Implications**:
+  - Only one location within the Dockerfile (self-referencing path) needs to be updated during replacement
+  - All external references (CI/CD, GitHub Actions) already use the `apps/app/docker/` path and require no changes
+  - The `codebuild/` directory and `README.md` are maintained as-is within `docker/`
+
+### Environment Variable Renaming: GROWI_ prefix → V8_ prefix
+
+- **Context**: The initial implementation used `GROWI_HEAP_SIZE`, `GROWI_OPTIMIZE_MEMORY`, and `GROWI_LITE_MODE` as environment variable names. These names obscure the relationship between the env var and the underlying V8 flag it controls
+- **Motivation**: Align environment variable names with the actual V8 option names they map to, improving discoverability and self-documentation
+- **Mapping**:
+  | Old Name | New Name | V8 Flag |
+  |----------|----------|---------|
+  | `GROWI_HEAP_SIZE` | `V8_MAX_HEAP_SIZE` | `--max-heap-size` |
+  | `GROWI_OPTIMIZE_MEMORY` | `V8_OPTIMIZE_FOR_SIZE` | `--optimize-for-size` |
+  | `GROWI_LITE_MODE` | `V8_LITE_MODE` | `--lite-mode` |
+- **Benefits**:
+  - Users can immediately understand which V8 flag each variable controls
+  - Naming convention is consistent: `V8_` prefix + option name in UPPER_SNAKE_CASE
+  - No need to consult documentation to understand the mapping
+- **Impact scope**:
+  - `docker-entrypoint.ts`: Code changes (env var reads, comments, log messages)
+  - `docker-entrypoint.spec.ts`: Test updates (env var references in test cases)
+  - `README.md`: Add documentation for the new environment variables
+  - `design.md`, `requirements.md`, `tasks.md`: Spec document updates
+- **Breaking change**: Yes — users who have configured `GROWI_HEAP_SIZE`, `GROWI_OPTIMIZE_MEMORY`, or `GROWI_LITE_MODE` in their docker-compose.yml or deployment configs will need to update to the new names. This is acceptable as these variables were introduced in the same release (v7.5.x) and have not been published yet
+- **Implications**: No backward compatibility shim needed since the variables are new in this version
+
+## Risks & Mitigations
+
+- **Stability of Node.js 24 native TypeScript execution**: Type stripping was unflagged in Node.js 23. It is a stable feature in Node.js 24. However, non-erasable syntax such as enum cannot be used -> Use only interface/type
+- **Direct description of migration command**: The `migrate` script from package.json is written directly in the entrypoint, so synchronization is needed when changes occur -> Clearly noted in comments during implementation
+- **turbo prune compatibility with pnpm workspaces**: Verify during implementation. If incompatible, fall back to an optimized COPY pattern
+- **Limitations of process.setuid/setgid**: `process.initgroups` is required for supplementary group initialization. The order setgid -> setuid must be strictly followed
+- **docker login requirement for DHI images**: `docker login dhi.io` is required in CI/CD. Security considerations for credential management are needed
+
+## Production Implementation Discoveries
+
+### DHI Dev Image Minimal Configuration (Phase 1 E2E)
+
+- **Issue**: The DHI dev image (`dhi.io/node:24-debian13-dev`) did not include the `which` command
+- **Resolution**: Changed pnpm installation from `SHELL="$(which sh)"` to `SHELL=/bin/sh`
+- **Impact**: Minor — only affects the pnpm install script invocation
+
+### Complete Absence of Shell in DHI Runtime Image (Phase 1 E2E)
+
+- **Issue**: The DHI runtime image (`dhi.io/node:24-debian13`) did not have `/bin/sh`. The design planned `--mount=type=bind,from=builder` + `RUN tar -zxf`, but `RUN` instructions require `/bin/sh`
+- **Resolution**:
+  - **builder stage**: Changed from `tar -zcf` to `cp -a` into a staging directory `/tmp/release/`
+  - **release stage**: Changed from `RUN --mount=type=bind... tar -zxf` to `COPY --from=builder --chown=node:node`
+- **Impact**: Design Req 3.5 (`--mount=type=bind,from=builder` pattern) was replaced with `COPY --from=builder`. The security goal of not requiring a shell at runtime was achieved even more robustly
+- **Lesson**: DHI runtime images are truly minimal — `COPY`, `WORKDIR`, `ENV`, `LABEL`, `ENTRYPOINT` are processed by the Docker daemon and do not require a shell
+
+### process.initgroups() Type Definition Gap
+
+- **Issue**: `process.initgroups('node', 1000)` was called for in the design, but implementation was deferred because the type definition does not exist in `@types/node`
+- **Status**: Deferred (Known Issue)
+- **Runtime**: `process.initgroups` does exist at runtime in Node.js 24
+- **Workaround options**: Wait for `@types/node` fix, or use `(process as any).initgroups('node', 1000)`
+- **Practical impact**: Low — the node user in a Docker container typically has no supplementary groups
+
+## References
+
+- [Docker Hardened Images Documentation](https://docs.docker.com/dhi/) — Overview and usage of DHI
+- [DHI Catalog GitHub](https://github.com/docker-hardened-images/catalog) — Image definitions and tag list
+- [Turborepo Docker Guide](https://turbo.build/repo/docs/handbook/deploying-with-docker) — turbo prune --docker pattern
+- [pnpm Docker Documentation](https://pnpm.io/docker) — pnpm Docker build recommendations
+- [Future Architect: 2024 Edition Dockerfile Best Practices](https://future-architect.github.io/articles/20240726a/) — Modern Dockerfile syntax
+- [MongoDB Docker: gosu -> setpriv](https://github.com/docker-library/mongo/pull/714) — Precedent for setpriv migration
+- [Docker Healthchecks in Distroless](https://www.mattknight.io/blog/docker-healthchecks-in-distroless-node-js) — Health checks without curl
+- GROWI memory usage investigation report (`apps/app/tmp/memory-results/REPORT.md`) — Basis for heap size control

+ 22 - 0
.kiro/specs/official-docker-image/spec.json

@@ -0,0 +1,22 @@
+{
+  "feature_name": "official-docker-image",
+  "created_at": "2026-02-20T00:00:00.000Z",
+  "updated_at": "2026-02-24T15:30:00.000Z",
+  "language": "en",
+  "phase": "tasks-generated",
+  "approvals": {
+    "requirements": {
+      "generated": true,
+      "approved": true
+    },
+    "design": {
+      "generated": true,
+      "approved": true
+    },
+    "tasks": {
+      "generated": true,
+      "approved": false
+    }
+  },
+  "ready_for_implementation": true
+}

+ 193 - 0
.kiro/specs/official-docker-image/tasks.md

@@ -0,0 +1,193 @@
+# Implementation Plan
+
+> **Task ordering design policy**:
+> - **Phase 1 (this phase)**: Reproduce an image with the same specifications as the current one using a DHI base image + TypeScript entrypoint. The build pipeline (3-stage structure using `COPY . .`) is kept as-is, **prioritizing a safe runtime migration**.
+> - **Phase 2 (next phase)**: Introduction of build optimization via the `turbo prune --docker` pattern. This will be done after runtime is stable in Phase 1. Adding pruner/deps stages to create a 5-stage structure.
+>
+> **Implementation directory**: Create new files in `apps/app/docker-new/`. The existing `apps/app/docker/` will not be modified at all. Maintain a state where parallel comparison and verification is possible.
+>
+> Directory permission handling is implemented and tested as the highest priority to detect regressions early. Since the entrypoint (TypeScript) and Dockerfile are independent files, some tasks can be executed in parallel.
+
+## Phase 1: DHI + TypeScript entrypoint (maintaining current build pattern)
+
+- [x] 1. (P) Strengthen build context filter
+  - Add `.git`, `.env*` (except production), test files, IDE configuration files, etc. to the current exclusion rules
+  - Verify that security-sensitive files (secrets, credentials) are not included in the context
+  - Maintain the current exclusion rules (`node_modules`, `.next`, `.turbo`, `apps/slackbot-proxy`, etc.)
+  - _Requirements: 4.3_
+
+- [x] 2. TypeScript entrypoint directory initialization and permission management
+- [x] 2.1 (P) Create entrypoint skeleton and recursive chown helper
+  - Create a new TypeScript file that can be directly executed with Node.js 24 type stripping (no enums, erasable syntax only)
+  - Structure the main execution flow as a `main()` function with top-level try-catch for error handling
+  - Implement a helper function that recursively changes ownership of files and subdirectories within a directory
+  - Create unit tests for the helper function (verify recursive behavior with nested directory structures)
+  - _Requirements: 6.8_
+
+- [x] 2.2 Implement directory initialization processing
+  - Implement creation of `/data/uploads`, symlink creation to `./public/uploads`, and recursive ownership change
+  - Implement creation of `/tmp/page-bulk-export`, recursive ownership change, and permission 700 setting
+  - Ensure idempotency (`recursive: true` for mkdir, prevent duplicate symlink creation)
+  - Create unit tests that **guarantee the same behavior as the current `docker-entrypoint.sh`** (using fs mocks, verifying each state of directories, symlinks, ownership, and permissions)
+  - Verify that the process exits (exit code 1) on failure (e.g., volume mount not configured)
+  - _Requirements: 6.3, 6.4_
+
+- [x] 2.3 Implement privilege dropping
+  - Implement demotion from root to node user (UID 1000, GID 1000)
+  - Initialize supplementary groups, strictly following the order of setgid then setuid (reverse order causes setgid to fail)
+  - Output an error message and exit the process on privilege drop failure
+  - _Requirements: 4.1, 6.2_
+
+- [x] 3. Heap size calculation and node flag assembly
+- [x] 3.1 (P) Implement cgroup memory limit detection
+  - Implement reading and numeric parsing of cgroup v2 files (treat the `"max"` string as unlimited)
+  - Implement fallback to cgroup v1 files (treat values exceeding 64GB as unlimited)
+  - Calculate 60% of the memory limit as the heap size (in MB)
+  - On file read failure, output a warning log and continue without flags (V8 default)
+  - Create unit tests for each pattern (v2 normal detection, v2 unlimited, v1 fallback, v1 unlimited, detection unavailable)
+  - _Requirements: 2.2, 2.3_
+
+- [x] 3.2 (P) Implement heap size specification via environment variable
+  - Implement parsing and validation of the `GROWI_HEAP_SIZE` environment variable (positive integer, in MB)
+  - On invalid values (NaN, negative numbers, empty string), output a warning log and fall back to no flags
+  - Confirm via tests that the environment variable takes priority over cgroup auto-calculation
+  - _Requirements: 2.1_
+
+- [x] 3.3 Implement node flag assembly and log output
+  - Implement the 3-tier fallback integration logic (environment variable -> cgroup calculation -> V8 default)
+  - Always include the `--expose_gc` flag
+  - Add `--optimize-for-size` when `GROWI_OPTIMIZE_MEMORY=true`, and `--lite-mode` when `GROWI_LITE_MODE=true`
+  - Pass `--max-heap-size` directly as a spawn argument (do not use `--max_old_space_size`, do not include in `NODE_OPTIONS`)
+  - Log the applied flags to stdout (including which tier determined the value)
+  - Create unit tests for each combination of environment variables (all unset, HEAP_SIZE only, all enabled, etc.)
+  - _Requirements: 2.4, 2.5, 2.6, 2.7, 6.1, 6.6, 6.7_
+
+- [x] 4. Migration execution and app process management
+- [x] 4.1 Direct migration execution
+  - Execute migrate-mongo by directly calling the node binary (do not use npm run, do not go through a shell)
+  - Inherit stdio to display migration logs
+  - On migration failure, catch the exception and exit the process, prompting restart by the container orchestrator
+  - _Requirements: 6.5_
+
+- [x] 4.2 App process startup and signal management
+  - Start the application as a child process with the calculated node flags included in the arguments
+  - Forward SIGTERM, SIGINT, and SIGHUP to the child process
+  - Propagate the child process exit code (or signal) as the entrypoint exit code
+  - Create tests to verify PID 1 responsibilities (signal forwarding, child process reaping, graceful shutdown)
+  - _Requirements: 6.2, 6.5_
+
+- [x] 5. Dockerfile reconstruction (current 3-stage pattern + DHI)
+- [x] 5.1 (P) Build the base stage
+  - Set the DHI dev image as the base and update the syntax directive to auto-follow the latest stable version
+  - Install pnpm via wget standalone script (eliminate hardcoded versions)
+  - Install turbo globally
+  - Install packages required for building with `--no-install-recommends` and apply apt cache mounts
+  - _Requirements: 1.1, 1.2, 1.3, 1.5, 3.3, 4.4_
+
+- [x] 5.2 Build the builder stage
+  - Maintain the current `COPY . .` pattern to copy the entire monorepo, then install dependencies, build, and extract production dependencies
+  - Fix the `--frozen-lockfile` typo (3 dashes -> 2 dashes)
+  - Configure pnpm store cache mounts to reduce rebuild time
+  - Extract only production dependencies and package them into tar.gz (including the `apps/app/tmp` directory)
+  - Guarantee that `.next/cache` is not included in the artifact
+  - _Requirements: 1.4, 3.2, 3.4_
+
+- [x] 5.3 Build the release stage
+  - Set the DHI runtime image as the base with no additional binary copying
+  - Extract build stage artifacts via bind mount
+  - COPY the TypeScript entrypoint file and set ENTRYPOINT to direct execution via node
+  - Verify that build tools (turbo, pnpm, node-gyp, etc.) and build packages (wget, curl, etc.) are not included in the release stage
+  - _Requirements: 1.1, 3.5, 4.2, 4.5_
+
+- [x] 5.4 (P) Configure OCI labels and port/volume declarations
+  - Set OCI standard labels (source, title, description, vendor)
+  - Maintain `EXPOSE 3000` and `VOLUME /data`
+  - _Requirements: 5.1, 5.2, 5.3_
+
+- [x] 6. Integration verification and backward compatibility confirmation
+- [x] 6.1 Docker build E2E verification
+  - Execute a Docker build where all 3 stages complete successfully and confirm there are no build errors
+  - Verify that the release image does not contain a shell, apt, or build tools
+  - _Requirements: 1.1, 4.2, 4.5_
+
+- [x] 6.2 Runtime behavior and backward compatibility verification
+  - Verify that environment variables (`MONGO_URI`, `FILE_UPLOAD`, etc.) are transparently passed to the application as before
+  - Verify compatibility with `/data` volume mounts and file upload functionality
+  - Verify listening on port 3000
+  - Verify that V8 default behavior is used when memory management environment variables are not set
+  - Verify startup with `docker compose up` and graceful shutdown via SIGTERM
+  - _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5_
+
+## Phase 2: turbo prune --docker build optimization
+
+> To be done after runtime is stable in Phase 1. Migrate from the current `COPY . .` + 3-stage structure to a `turbo prune --docker` + 5-stage structure to improve build cache efficiency.
+
+- [x] 7. Introduction of turbo prune --docker pattern
+- [x] 7.1 Create pruner stage
+  - Add a pruner stage immediately after the base stage, minimizing the monorepo for Docker with `turbo prune @growi/app @growi/pdf-converter --docker`
+  - Reason for including `@growi/pdf-converter`: `@growi/pdf-converter-client/turbo.json` has a task dependency on `@growi/pdf-converter#gen:swagger-spec`, so turbo cannot resolve task dependencies unless it is included in the pruned workspace
+  - Verified compatibility with pnpm workspace (18 packages are correctly output)
+  - Confirmed that the output (json directory, lockfile, full directory) is generated correctly
+  - _Requirements: 3.1_
+
+- [x] 7.2 Separate deps stage and restructure builder
+  - Separate dependency installation from the builder stage into an independent deps stage
+  - Copy only the package.json files and lockfile from pruner output to install dependencies (layer cache optimization)
+  - Change the builder stage to a structure that uses deps as a base and only copies source code and builds
+  - Verify that the dependency installation layer is cached when there are no dependency changes and only source code changes
+  - _Requirements: 3.1, 3.2_
+
+- [x] 7.3 Integration verification of 5-stage structure
+  - Confirm that all 5 stages (base -> pruner -> deps -> builder -> release) complete successfully
+  - Confirm that the same runtime behavior as the Phase 1 3-stage structure is maintained
+  - Verify improvement in build cache efficiency (dependency installation is skipped when only source code changes)
+  - _Requirements: 3.1, 3.2, 3.4_
+
+## Phase 3: Production replacement and CI/CD support
+
+> To be done after the 5-stage structure is stable in Phase 2. Move the artifacts from `apps/app/docker-new/` to `apps/app/docker/`, delete the old files, and update the CI/CD pipeline for DHI support.
+
+- [x] 8. Production replacement and CI/CD support
+- [x] 8.1 (P) Replace docker-new directory with docker directory
+  - Delete old files in `apps/app/docker/` (old `Dockerfile`, `docker-entrypoint.sh`, old `Dockerfile.dockerignore`)
+  - Move all files in `apps/app/docker-new/` (`Dockerfile`, `docker-entrypoint.ts`, `docker-entrypoint.spec.ts`, `Dockerfile.dockerignore`) to `apps/app/docker/`
+  - Delete the `apps/app/docker-new/` directory
+  - Confirm that the `codebuild/` directory and `README.md` are maintained within `apps/app/docker/`
+  - Update the entrypoint copy path in the Dockerfile (from `apps/app/docker-new/docker-entrypoint.ts` to `apps/app/docker/docker-entrypoint.ts`)
+  - _Requirements: 8.1, 8.2_
+
+- [x] 8.2 (P) Add DHI registry login to buildspec.yml
+  - Add a `docker login dhi.io` command to the pre_build phase of `apps/app/docker/codebuild/buildspec.yml`
+  - DHI uses Docker Hub credentials, so reuse the existing `DOCKER_REGISTRY_PASSWORD` secret and `growimoogle` username
+  - Confirm that the Dockerfile path in buildspec.yml (`./apps/app/docker/Dockerfile`) is correct after replacement
+  - _Requirements: 8.3, 8.4_
+
+- [x] 8.3 Integration verification after replacement
+  - Confirm that Docker build completes successfully with the replaced `apps/app/docker/Dockerfile`
+  - Confirm that existing external references (`codebuild.tf`, `.github/workflows/release.yml`, `ci-app.yml`, `update-readme.sh`) work correctly
+  - _Requirements: 8.1, 8.2, 8.3, 8.4_
+
+## Phase 4: Environment variable renaming and README documentation
+
+> Rename the `GROWI_`-prefixed memory management environment variables to `V8_`-prefixed names aligned with V8 option names, and add documentation to the Docker Hub README.
+
+- [x] 9. Rename environment variables to align with V8 option names
+- [x] 9.1 (P) Rename all GROWI_-prefixed environment variables to V8_-prefixed names in the entrypoint
+  - Rename `GROWI_HEAP_SIZE` to `V8_MAX_HEAP_SIZE` in the heap size detection function, validation logic, and error messages
+  - Rename `GROWI_OPTIMIZE_MEMORY` to `V8_OPTIMIZE_FOR_SIZE` in the node flag assembly function
+  - Rename `GROWI_LITE_MODE` to `V8_LITE_MODE` in the node flag assembly function
+  - Update the heap size source log message to reflect the new variable name
+  - Update the file header comment documenting the heap size detection fallback chain
+  - _Requirements: 2.1, 2.5, 2.6, 2.7, 6.1, 6.6, 6.7_
+
+- [x] 9.2 (P) Update all environment variable references in entrypoint unit tests
+  - Update heap size detection tests: replace all `GROWI_HEAP_SIZE` references with `V8_MAX_HEAP_SIZE`
+  - Update node flag assembly tests: replace `GROWI_OPTIMIZE_MEMORY` with `V8_OPTIMIZE_FOR_SIZE` and `GROWI_LITE_MODE` with `V8_LITE_MODE`
+  - Verify all tests pass with the new environment variable names
+  - _Requirements: 2.1, 2.5, 2.6_
+
+- [x] 10. Add V8 memory management environment variable documentation to README
+  - Add a subsection under Configuration > Environment Variables documenting the three V8 memory management variables
+  - Include variable name, type, default value, and description for each: `V8_MAX_HEAP_SIZE`, `V8_OPTIMIZE_FOR_SIZE`, `V8_LITE_MODE`
+  - Describe the 3-tier heap size fallback behavior (env var → cgroup auto-calculation → V8 default)
+  - _Requirements: 5.1_

+ 25 - 1
CHANGELOG.md

@@ -1,9 +1,33 @@
 # Changelog
 
-## [Unreleased](https://github.com/growilabs/compare/v7.4.4...HEAD)
+## [Unreleased](https://github.com/growilabs/compare/v7.4.5...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v7.4.5](https://github.com/growilabs/compare/v7.4.4...v7.4.5) - 2026-02-19
+
+### 💎 Features
+
+* feat: Realtime Increment View Count Without Refreshing Pages (#10760) @ryotaro-nagahara
+
+### 🚀 Improvement
+
+* imprv: Unchanged revision (#10770) @yuki-takei
+* imprv: Close the Sidebar in drawer mode when the route changes (#10763) @yuki-takei
+
+### 🐛 Bug Fixes
+
+* fix: Use currentPageId for share link page fetching (#10797) @yuki-takei
+* fix: Allow viewing shared pages regardless of page permissions (#10762) @ryotaro-nagahara
+* fix: Bulk export fails due to S3 upload minimal version (#10782) @ryotaro-nagahara
+* fix: Block revisions API from returning info about user pages when user pages are disabled (#10751) @arvid-e
+* fix: OpenAPI spec mismatch for GET /page endpoint response format (#10787) @[copilot-swe-agent[bot]](https://github.com/apps/copilot-swe-agent)
+
+### 🧰 Maintenance
+
+* support: Extract `/page/info` endpoint handler into a dedicated module (#10795) @yuki-takei
+* ci(deps): bump qs from 6.14.1 to 6.14.2 (#10785) @[dependabot[bot]](https://github.com/apps/dependabot)
+
 ## [v7.4.4](https://github.com/growilabs/compare/v7.4.3...v7.4.4) - 2026-01-30
 
 ### 🐛 Bug Fixes

+ 1 - 1
README.md

@@ -81,7 +81,7 @@ See [GROWI Docs: Environment Variables](https://docs.growi.org/en/admin-guide/ad
 
 ## Dependencies
 
-- Node.js v18.x or v20.x
+- Node.js v24.x
 - npm 6.x
 - pnpm 9.x
 - [Turborepo](https://turbo.build/repo)

+ 1 - 1
README_JP.md

@@ -81,7 +81,7 @@ Crowi からの移行は **[こちら](https://docs.growi.org/en/admin-guide/mig
 
 ## 依存関係
 
-- Node.js v18.x or v20.x
+- Node.js v24.x
 - npm 6.x
 - pnpm 9.x
 - [Turborepo](https://turbo.build/repo)

+ 76 - 48
apps/app/docker/Dockerfile

@@ -1,110 +1,138 @@
-# syntax = docker/dockerfile:1.4
+# syntax=docker/dockerfile:1
 
+ARG NODE_VERSION=24
 ARG OPT_DIR="/opt"
 ARG PNPM_HOME="/root/.local/share/pnpm"
 
 ##
-## base
+## base — DHI dev image with pnpm + turbo
 ##
-FROM node:20-slim AS base
+FROM dhi.io/node:24-debian13-dev AS base
 
 ARG OPT_DIR
 ARG PNPM_HOME
 
 WORKDIR $OPT_DIR
 
-# install tools
+# Install build dependencies
 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
+  apt-get update && apt-get install -y --no-install-recommends ca-certificates wget
 
-# install pnpm
-RUN wget -qO- https://get.pnpm.io/install.sh | ENV="$HOME/.shrc" SHELL="$(which sh)" PNPM_VERSION="10.4.1" sh -
+# Install pnpm (standalone script, no version hardcoding)
+RUN wget -qO- https://get.pnpm.io/install.sh | ENV="$HOME/.shrc" SHELL=/bin/sh sh -
 ENV PNPM_HOME=$PNPM_HOME
 ENV PATH="$PNPM_HOME:$PATH"
 
-# install turbo
+# Install turbo globally
 RUN --mount=type=cache,target=$PNPM_HOME/store,sharing=locked \
   pnpm add turbo --global
 
 
+##
+## pruner — turbo prune for Docker-optimized monorepo subset
+##
+FROM base AS pruner
+
+ARG OPT_DIR
+
+WORKDIR $OPT_DIR
+
+COPY . .
+
+# Include @growi/pdf-converter because @growi/pdf-converter-client has a turbo
+# task dependency on @growi/pdf-converter#gen:swagger-spec (generates the OpenAPI
+# spec that orval uses to build the client). Without it, turbo cannot resolve
+# the cross-package task dependency in the pruned workspace.
+RUN turbo prune @growi/app @growi/pdf-converter --docker
+
 
 ##
-## builder
+## deps — dependency installation (layer cached when only source changes)
 ##
-FROM base AS builder
+FROM base AS deps
+
+ARG OPT_DIR
+ARG PNPM_HOME
 
 ENV PNPM_HOME=$PNPM_HOME
 ENV PATH="$PNPM_HOME:$PATH"
 
 WORKDIR $OPT_DIR
 
-COPY . .
+# Copy only package manifests and lockfile for dependency caching
+COPY --from=pruner $OPT_DIR/out/json/ .
 
+# Install build tools and dependencies
 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
+  pnpm install --frozen-lockfile
 
-# build
+
+##
+## builder — build + produce artifacts
+##
+FROM deps AS builder
+
+ARG OPT_DIR
+
+WORKDIR $OPT_DIR
+
+# Copy full source on top of installed dependencies
+COPY --from=pruner $OPT_DIR/out/full/ .
+
+# turbo prune does not include root-level config files in its output.
+# tsconfig.base.json is referenced by most packages via "extends": "../../tsconfig.base.json"
+COPY tsconfig.base.json .
+
+# Build
 RUN turbo run clean
 RUN turbo run build --filter @growi/app
 
-# make artifacts
+# Produce artifacts
 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 /tmp/packages.tar.gz \
-  package.json \
-  apps/app/.next \
-  apps/app/config \
-  apps/app/dist \
-  apps/app/public \
-  apps/app/resource \
-  apps/app/tmp \
-  apps/app/.env.production* \
-  apps/app/next.config.js \
-  apps/app/package.json \
-  apps/app/node_modules
 
+# Stage artifacts into a clean directory for COPY --from
+RUN mkdir -p /tmp/release/apps/app && \
+  cp package.json /tmp/release/ && \
+  cp -a apps/app/.next apps/app/config apps/app/dist apps/app/public \
+       apps/app/resource apps/app/tmp apps/app/next.config.js \
+       apps/app/package.json apps/app/node_modules \
+       /tmp/release/apps/app/ && \
+  (cp apps/app/.env.production* /tmp/release/apps/app/ 2>/dev/null || true)
 
 
 ##
-## release
+## release — DHI runtime (no shell, no additional binaries)
 ##
-FROM node:20-slim
-LABEL maintainer="Yuki Takei <yuki@weseek.co.jp>"
+FROM dhi.io/node:24-debian13 AS release
 
 ARG OPT_DIR
 
 ENV NODE_ENV="production"
-
 ENV appDir="$OPT_DIR/growi"
 
-# Add gosu
-# see: https://github.com/tianon/gosu/blob/1.13/INSTALL.md
-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
-
-# extract artifacts as 'node' user
-USER node
+# Copy artifacts from builder (no shell required)
 WORKDIR ${appDir}
-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 --from=builder --chown=node:node /tmp/release/ ${appDir}/
 
-COPY --chown=node:node --chmod=700 apps/app/docker/docker-entrypoint.sh /
+# Copy TypeScript entrypoint
+COPY --chown=node:node apps/app/docker/docker-entrypoint.ts /docker-entrypoint.ts
 
+# Switch back to root for entrypoint (it handles privilege drop)
 USER root
 WORKDIR ${appDir}/apps/app
 
+# OCI standard labels
+LABEL org.opencontainers.image.source="https://github.com/weseek/growi"
+LABEL org.opencontainers.image.title="GROWI"
+LABEL org.opencontainers.image.description="Team collaboration wiki using Markdown"
+LABEL org.opencontainers.image.vendor="WESEEK, Inc."
+
 VOLUME /data
 EXPOSE 3000
 
-ENTRYPOINT ["/docker-entrypoint.sh"]
-CMD ["npm run migrate && node -r dotenv-flow/config --expose_gc dist/server/app.js"]
+ENTRYPOINT ["node", "/docker-entrypoint.ts"]

+ 74 - 4
apps/app/docker/Dockerfile.dockerignore

@@ -1,9 +1,79 @@
+# ============================================================
+# Build artifacts and caches
+# ============================================================
 **/node_modules
-**/coverage
-**/Dockerfile
-**/*.dockerignore
-**/.pnpm-store
 **/.next
 **/.turbo
+**/.pnpm-store
+**/coverage
 out
+
+# ============================================================
+# Version control
+# ============================================================
+.git
+
+# ============================================================
+# Docker files (prevent recursive inclusion)
+# ============================================================
+**/Dockerfile
+**/*.dockerignore
+
+# ============================================================
+# Unrelated apps
+# ============================================================
 apps/slackbot-proxy
+
+# ============================================================
+# Test files
+# ============================================================
+**/*.spec.*
+**/*.test.*
+**/test/
+**/__tests__/
+**/playwright/
+
+# ============================================================
+# Documentation (no .md files are needed for build)
+# ============================================================
+**/*.md
+
+# ============================================================
+# Local environment overrides
+# ============================================================
+.env.local
+.env.*.local
+
+# ============================================================
+# IDE and editor settings
+# ============================================================
+.vscode
+.idea
+**/.DS_Store
+
+# ============================================================
+# CI/CD, DevOps, and project management
+# ============================================================
+.changeset
+.devcontainer
+.github
+aws
+bin
+
+# ============================================================
+# Linter, formatter, and tool configs (not needed for build)
+# ============================================================
+**/.editorconfig
+**/.markdownlint.yml
+**/.prettier*
+**/.stylelintrc*
+**/biome.json
+**/lefthook.yml
+
+# ============================================================
+# AI agent configuration
+# ============================================================
+**/.claude
+**/.kiro
+**/.mcp.json
+**/.serena

+ 11 - 1
apps/app/docker/README.md

@@ -10,7 +10,7 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`7.4.4`, `7.4`, `7`, `latest` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.4.4/apps/app/docker/Dockerfile)
+* [`7.4.5`, `7.4`, `7`, `latest` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.4.5/apps/app/docker/Dockerfile)
 * [`7.3.0`, `7.3` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.3.0/apps/app/docker/Dockerfile)
 * [`7.2.0`, `7.2` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.2.0/apps/app/docker/Dockerfile)
 
@@ -72,6 +72,16 @@ See [GROWI Docs: Admin Guide](https://docs.growi.org/en/admin-guide/) ([en](http
 
 - [GROWI Docs: Environment Variables](https://docs.growi.org/en/admin-guide/admin-cookbook/env-vars.html)
 
+#### V8 Memory Management
+
+| Variable | Type | Default | Description |
+|----------|------|---------|-------------|
+| `V8_MAX_HEAP_SIZE` | int (MB) | (unset) | Explicitly specify the `--max-heap-size` value for Node.js |
+| `V8_OPTIMIZE_FOR_SIZE` | `"true"` / (unset) | (unset) | Enable the `--optimize-for-size` V8 flag to reduce memory usage |
+| `V8_LITE_MODE` | `"true"` / (unset) | (unset) | Enable the `--lite-mode` V8 flag to reduce memory usage at the cost of performance |
+
+**Heap size fallback behavior**: When `V8_MAX_HEAP_SIZE` is not set, the entrypoint automatically detects the container's memory limit via cgroup (v2/v1) and sets the heap size to 60% of the limit. If no cgroup limit is detected, V8's default heap behavior is used.
+
 
 Issues
 ------

+ 2 - 0
apps/app/docker/codebuild/buildspec.yml

@@ -12,6 +12,8 @@ phases:
     commands:
       # login to docker.io
       - echo ${DOCKER_REGISTRY_PASSWORD} | docker login --username growimoogle --password-stdin
+      # login to dhi.io (DHI uses Docker Hub credentials)
+      - echo ${DOCKER_REGISTRY_PASSWORD} | docker login dhi.io --username growimoogle --password-stdin
   build:
     commands:
       - docker build -t ${IMAGE_TAG} -f ./apps/app/docker/Dockerfile .

+ 0 - 18
apps/app/docker/docker-entrypoint.sh

@@ -1,18 +0,0 @@
-#!/bin/sh
-
-set -e
-
-# Support `FILE_UPLOAD=local`
-mkdir -p /data/uploads
-if [ ! -e "./public/uploads" ]; then
-  ln -s /data/uploads ./public/uploads
-fi
-chown -R node:node /data/uploads
-chown -h node:node ./public/uploads
-
-# Set permissions for shared directory for bulk export
-mkdir -p /tmp/page-bulk-export
-chown -R node:node /tmp/page-bulk-export
-chmod 700 /tmp/page-bulk-export
-
-exec gosu node /bin/bash -c "$@"

+ 358 - 0
apps/app/docker/docker-entrypoint.spec.ts

@@ -0,0 +1,358 @@
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import {
+  buildNodeFlags,
+  chownRecursive,
+  detectHeapSize,
+  readCgroupLimit,
+  setupDirectories,
+} from './docker-entrypoint';
+
+describe('chownRecursive', () => {
+  let tmpDir: string;
+
+  beforeEach(() => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'entrypoint-test-'));
+  });
+
+  afterEach(() => {
+    fs.rmSync(tmpDir, { recursive: true, force: true });
+  });
+
+  it('should chown a flat directory', () => {
+    const chownSyncSpy = vi.spyOn(fs, 'chownSync').mockImplementation(() => {});
+    chownRecursive(tmpDir, 1000, 1000);
+    // Should chown the directory itself
+    expect(chownSyncSpy).toHaveBeenCalledWith(tmpDir, 1000, 1000);
+    chownSyncSpy.mockRestore();
+  });
+
+  it('should chown nested directories and files recursively', () => {
+    // Create nested structure
+    const subDir = path.join(tmpDir, 'sub');
+    fs.mkdirSync(subDir);
+    fs.writeFileSync(path.join(tmpDir, 'file1.txt'), 'hello');
+    fs.writeFileSync(path.join(subDir, 'file2.txt'), 'world');
+
+    const chownedPaths: string[] = [];
+    const chownSyncSpy = vi.spyOn(fs, 'chownSync').mockImplementation((p) => {
+      chownedPaths.push(p as string);
+    });
+
+    chownRecursive(tmpDir, 1000, 1000);
+
+    expect(chownedPaths).toContain(tmpDir);
+    expect(chownedPaths).toContain(subDir);
+    expect(chownedPaths).toContain(path.join(tmpDir, 'file1.txt'));
+    expect(chownedPaths).toContain(path.join(subDir, 'file2.txt'));
+    expect(chownedPaths).toHaveLength(4);
+
+    chownSyncSpy.mockRestore();
+  });
+
+  it('should handle empty directory', () => {
+    const chownSyncSpy = vi.spyOn(fs, 'chownSync').mockImplementation(() => {});
+    chownRecursive(tmpDir, 1000, 1000);
+    // Should only chown the directory itself
+    expect(chownSyncSpy).toHaveBeenCalledTimes(1);
+    expect(chownSyncSpy).toHaveBeenCalledWith(tmpDir, 1000, 1000);
+    chownSyncSpy.mockRestore();
+  });
+});
+
+describe('readCgroupLimit', () => {
+  it('should read cgroup v2 numeric limit', () => {
+    const readSpy = vi
+      .spyOn(fs, 'readFileSync')
+      .mockReturnValue('1073741824\n');
+    const result = readCgroupLimit('/sys/fs/cgroup/memory.max');
+    expect(result).toBe(1073741824);
+    readSpy.mockRestore();
+  });
+
+  it('should return undefined for cgroup v2 "max" (unlimited)', () => {
+    const readSpy = vi.spyOn(fs, 'readFileSync').mockReturnValue('max\n');
+    const result = readCgroupLimit('/sys/fs/cgroup/memory.max');
+    expect(result).toBeUndefined();
+    readSpy.mockRestore();
+  });
+
+  it('should return undefined when file does not exist', () => {
+    const readSpy = vi.spyOn(fs, 'readFileSync').mockImplementation(() => {
+      throw new Error('ENOENT');
+    });
+    const result = readCgroupLimit('/sys/fs/cgroup/memory.max');
+    expect(result).toBeUndefined();
+    readSpy.mockRestore();
+  });
+
+  it('should return undefined for NaN content', () => {
+    const readSpy = vi.spyOn(fs, 'readFileSync').mockReturnValue('invalid\n');
+    const result = readCgroupLimit('/sys/fs/cgroup/memory.max');
+    expect(result).toBeUndefined();
+    readSpy.mockRestore();
+  });
+});
+
+describe('detectHeapSize', () => {
+  const originalEnv = process.env;
+
+  beforeEach(() => {
+    process.env = { ...originalEnv };
+  });
+
+  afterEach(() => {
+    process.env = originalEnv;
+  });
+
+  it('should use V8_MAX_HEAP_SIZE when set', () => {
+    process.env.V8_MAX_HEAP_SIZE = '512';
+    const readSpy = vi.spyOn(fs, 'readFileSync');
+    const result = detectHeapSize();
+    expect(result).toBe(512);
+    // Should not attempt to read cgroup files
+    expect(readSpy).not.toHaveBeenCalled();
+    readSpy.mockRestore();
+  });
+
+  it('should return undefined for invalid V8_MAX_HEAP_SIZE', () => {
+    process.env.V8_MAX_HEAP_SIZE = 'abc';
+    const readSpy = vi.spyOn(fs, 'readFileSync').mockImplementation(() => {
+      throw new Error('ENOENT');
+    });
+    const result = detectHeapSize();
+    expect(result).toBeUndefined();
+    readSpy.mockRestore();
+  });
+
+  it('should return undefined for empty V8_MAX_HEAP_SIZE', () => {
+    process.env.V8_MAX_HEAP_SIZE = '';
+    const readSpy = vi.spyOn(fs, 'readFileSync').mockImplementation(() => {
+      throw new Error('ENOENT');
+    });
+    const result = detectHeapSize();
+    expect(result).toBeUndefined();
+    readSpy.mockRestore();
+  });
+
+  it('should auto-calculate from cgroup v2 at 60%', () => {
+    delete process.env.V8_MAX_HEAP_SIZE;
+    // 1GB = 1073741824 bytes → 60% ≈ 614 MB
+    const readSpy = vi
+      .spyOn(fs, 'readFileSync')
+      .mockImplementation((filePath) => {
+        if (filePath === '/sys/fs/cgroup/memory.max') return '1073741824\n';
+        throw new Error('ENOENT');
+      });
+    const result = detectHeapSize();
+    expect(result).toBe(Math.floor((1073741824 / 1024 / 1024) * 0.6));
+    readSpy.mockRestore();
+  });
+
+  it('should fallback to cgroup v1 when v2 is unlimited', () => {
+    delete process.env.V8_MAX_HEAP_SIZE;
+    // v2 = max (unlimited), v1 = 2GB
+    const readSpy = vi
+      .spyOn(fs, 'readFileSync')
+      .mockImplementation((filePath) => {
+        if (filePath === '/sys/fs/cgroup/memory.max') return 'max\n';
+        if (filePath === '/sys/fs/cgroup/memory/memory.limit_in_bytes')
+          return '2147483648\n';
+        throw new Error('ENOENT');
+      });
+    const result = detectHeapSize();
+    expect(result).toBe(Math.floor((2147483648 / 1024 / 1024) * 0.6));
+    readSpy.mockRestore();
+  });
+
+  it('should treat cgroup v1 > 64GB as unlimited', () => {
+    delete process.env.V8_MAX_HEAP_SIZE;
+    const hugeValue = 128 * 1024 * 1024 * 1024; // 128GB
+    const readSpy = vi
+      .spyOn(fs, 'readFileSync')
+      .mockImplementation((filePath) => {
+        if (filePath === '/sys/fs/cgroup/memory.max') return 'max\n';
+        if (filePath === '/sys/fs/cgroup/memory/memory.limit_in_bytes')
+          return `${hugeValue}\n`;
+        throw new Error('ENOENT');
+      });
+    const result = detectHeapSize();
+    expect(result).toBeUndefined();
+    readSpy.mockRestore();
+  });
+
+  it('should return undefined when no cgroup limits detected', () => {
+    delete process.env.V8_MAX_HEAP_SIZE;
+    const readSpy = vi.spyOn(fs, 'readFileSync').mockImplementation(() => {
+      throw new Error('ENOENT');
+    });
+    const result = detectHeapSize();
+    expect(result).toBeUndefined();
+    readSpy.mockRestore();
+  });
+
+  it('should prioritize V8_MAX_HEAP_SIZE over cgroup', () => {
+    process.env.V8_MAX_HEAP_SIZE = '256';
+    const readSpy = vi
+      .spyOn(fs, 'readFileSync')
+      .mockReturnValue('1073741824\n');
+    const result = detectHeapSize();
+    expect(result).toBe(256);
+    // Should not have read cgroup files
+    expect(readSpy).not.toHaveBeenCalled();
+    readSpy.mockRestore();
+  });
+});
+
+describe('buildNodeFlags', () => {
+  const originalEnv = process.env;
+
+  beforeEach(() => {
+    process.env = { ...originalEnv };
+  });
+
+  afterEach(() => {
+    process.env = originalEnv;
+  });
+
+  it('should always include --expose_gc', () => {
+    const flags = buildNodeFlags(undefined);
+    expect(flags).toContain('--expose_gc');
+  });
+
+  it('should include --max-heap-size when heapSize is provided', () => {
+    const flags = buildNodeFlags(512);
+    expect(flags).toContain('--max-heap-size=512');
+  });
+
+  it('should not include --max-heap-size when heapSize is undefined', () => {
+    const flags = buildNodeFlags(undefined);
+    expect(flags.some((f) => f.startsWith('--max-heap-size'))).toBe(false);
+  });
+
+  it('should include --optimize-for-size when V8_OPTIMIZE_FOR_SIZE=true', () => {
+    process.env.V8_OPTIMIZE_FOR_SIZE = 'true';
+    const flags = buildNodeFlags(undefined);
+    expect(flags).toContain('--optimize-for-size');
+  });
+
+  it('should not include --optimize-for-size when V8_OPTIMIZE_FOR_SIZE is not true', () => {
+    process.env.V8_OPTIMIZE_FOR_SIZE = 'false';
+    const flags = buildNodeFlags(undefined);
+    expect(flags).not.toContain('--optimize-for-size');
+  });
+
+  it('should include --lite-mode when V8_LITE_MODE=true', () => {
+    process.env.V8_LITE_MODE = 'true';
+    const flags = buildNodeFlags(undefined);
+    expect(flags).toContain('--lite-mode');
+  });
+
+  it('should not include --lite-mode when V8_LITE_MODE is not true', () => {
+    delete process.env.V8_LITE_MODE;
+    const flags = buildNodeFlags(undefined);
+    expect(flags).not.toContain('--lite-mode');
+  });
+
+  it('should combine all flags when all options enabled', () => {
+    process.env.V8_OPTIMIZE_FOR_SIZE = 'true';
+    process.env.V8_LITE_MODE = 'true';
+    const flags = buildNodeFlags(256);
+    expect(flags).toContain('--expose_gc');
+    expect(flags).toContain('--max-heap-size=256');
+    expect(flags).toContain('--optimize-for-size');
+    expect(flags).toContain('--lite-mode');
+  });
+
+  it('should not use --max_old_space_size', () => {
+    const flags = buildNodeFlags(512);
+    expect(flags.some((f) => f.includes('max_old_space_size'))).toBe(false);
+  });
+});
+
+describe('setupDirectories', () => {
+  let tmpDir: string;
+
+  beforeEach(() => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'entrypoint-setup-'));
+  });
+
+  afterEach(() => {
+    fs.rmSync(tmpDir, { recursive: true, force: true });
+  });
+
+  it('should create uploads directory and symlink', () => {
+    const uploadsDir = path.join(tmpDir, 'data', 'uploads');
+    const publicUploads = path.join(tmpDir, 'public', 'uploads');
+    fs.mkdirSync(path.join(tmpDir, 'public'), { recursive: true });
+
+    const chownSyncSpy = vi.spyOn(fs, 'chownSync').mockImplementation(() => {});
+    const lchownSyncSpy = vi
+      .spyOn(fs, 'lchownSync')
+      .mockImplementation(() => {});
+
+    setupDirectories(
+      uploadsDir,
+      publicUploads,
+      path.join(tmpDir, 'bulk-export'),
+    );
+
+    expect(fs.existsSync(uploadsDir)).toBe(true);
+    expect(fs.lstatSync(publicUploads).isSymbolicLink()).toBe(true);
+    expect(fs.readlinkSync(publicUploads)).toBe(uploadsDir);
+
+    chownSyncSpy.mockRestore();
+    lchownSyncSpy.mockRestore();
+  });
+
+  it('should not recreate symlink if it already exists', () => {
+    const uploadsDir = path.join(tmpDir, 'data', 'uploads');
+    const publicUploads = path.join(tmpDir, 'public', 'uploads');
+    fs.mkdirSync(path.join(tmpDir, 'public'), { recursive: true });
+    fs.mkdirSync(uploadsDir, { recursive: true });
+    fs.symlinkSync(uploadsDir, publicUploads);
+
+    const symlinkSpy = vi.spyOn(fs, 'symlinkSync');
+    const chownSyncSpy = vi.spyOn(fs, 'chownSync').mockImplementation(() => {});
+    const lchownSyncSpy = vi
+      .spyOn(fs, 'lchownSync')
+      .mockImplementation(() => {});
+
+    setupDirectories(
+      uploadsDir,
+      publicUploads,
+      path.join(tmpDir, 'bulk-export'),
+    );
+
+    expect(symlinkSpy).not.toHaveBeenCalled();
+
+    symlinkSpy.mockRestore();
+    chownSyncSpy.mockRestore();
+    lchownSyncSpy.mockRestore();
+  });
+
+  it('should create bulk export directory with permissions', () => {
+    const bulkExportDir = path.join(tmpDir, 'bulk-export');
+    fs.mkdirSync(path.join(tmpDir, 'public'), { recursive: true });
+    const chownSyncSpy = vi.spyOn(fs, 'chownSync').mockImplementation(() => {});
+    const lchownSyncSpy = vi
+      .spyOn(fs, 'lchownSync')
+      .mockImplementation(() => {});
+
+    setupDirectories(
+      path.join(tmpDir, 'data', 'uploads'),
+      path.join(tmpDir, 'public', 'uploads'),
+      bulkExportDir,
+    );
+
+    expect(fs.existsSync(bulkExportDir)).toBe(true);
+    const stat = fs.statSync(bulkExportDir);
+    expect(stat.mode & 0o777).toBe(0o700);
+
+    chownSyncSpy.mockRestore();
+    lchownSyncSpy.mockRestore();
+  });
+});

+ 265 - 0
apps/app/docker/docker-entrypoint.ts

@@ -0,0 +1,265 @@
+/**
+ * Docker entrypoint for GROWI (TypeScript)
+ *
+ * Runs directly with Node.js 24 native type stripping.
+ * Uses only erasable TypeScript syntax (no enums, no namespaces).
+ *
+ * Responsibilities:
+ * - Directory setup (as root): /data/uploads, symlinks, /tmp/page-bulk-export
+ * - Heap size detection: V8_MAX_HEAP_SIZE → cgroup auto-calc → V8 default
+ * - Privilege drop: process.setgid + process.setuid (root → node)
+ * - Migration execution: execFileSync (no shell)
+ * - App process spawn: spawn with signal forwarding
+ */
+
+/** biome-ignore-all lint/suspicious/noConsole: Allow printing to console */
+
+import { execFileSync, spawn } from 'node:child_process';
+import fs from 'node:fs';
+
+// -- Constants --
+
+const NODE_UID = 1000;
+const NODE_GID = 1000;
+const CGROUP_V2_PATH = '/sys/fs/cgroup/memory.max';
+const CGROUP_V1_PATH = '/sys/fs/cgroup/memory/memory.limit_in_bytes';
+const CGROUP_V1_UNLIMITED_THRESHOLD = 64 * 1024 * 1024 * 1024; // 64GB
+const HEAP_RATIO = 0.6;
+
+// -- Exported utility functions --
+
+/**
+ * Recursively chown a directory and all its contents.
+ */
+export function chownRecursive(
+  dirPath: string,
+  uid: number,
+  gid: number,
+): void {
+  const entries = fs.readdirSync(dirPath, { withFileTypes: true });
+  for (const entry of entries) {
+    const fullPath = `${dirPath}/${entry.name}`;
+    if (entry.isDirectory()) {
+      chownRecursive(fullPath, uid, gid);
+    } else {
+      fs.chownSync(fullPath, uid, gid);
+    }
+  }
+  fs.chownSync(dirPath, uid, gid);
+}
+
+/**
+ * Read a cgroup memory limit file and return the numeric value in bytes.
+ * Returns undefined if the file cannot be read or the value is "max" / NaN.
+ */
+export function readCgroupLimit(filePath: string): number | undefined {
+  try {
+    const content = fs.readFileSync(filePath, 'utf-8').trim();
+    if (content === 'max') return undefined;
+    const value = parseInt(content, 10);
+    if (Number.isNaN(value)) return undefined;
+    return value;
+  } catch {
+    return undefined;
+  }
+}
+
+/**
+ * Detect heap size (MB) using 3-level fallback:
+ * 1. V8_MAX_HEAP_SIZE env var
+ * 2. cgroup v2/v1 auto-calculation (60% of limit)
+ * 3. undefined (V8 default)
+ */
+export function detectHeapSize(): number | undefined {
+  // Priority 1: V8_MAX_HEAP_SIZE env
+  const envValue = process.env.V8_MAX_HEAP_SIZE;
+  if (envValue != null && envValue !== '') {
+    const parsed = parseInt(envValue, 10);
+    if (Number.isNaN(parsed)) {
+      console.error(
+        `[entrypoint] V8_MAX_HEAP_SIZE="${envValue}" is not a valid number, ignoring`,
+      );
+      return undefined;
+    }
+    return parsed;
+  }
+
+  // Priority 2: cgroup v2
+  const cgroupV2 = readCgroupLimit(CGROUP_V2_PATH);
+  if (cgroupV2 != null) {
+    return Math.floor((cgroupV2 / 1024 / 1024) * HEAP_RATIO);
+  }
+
+  // Priority 3: cgroup v1 (treat > 64GB as unlimited)
+  const cgroupV1 = readCgroupLimit(CGROUP_V1_PATH);
+  if (cgroupV1 != null && cgroupV1 < CGROUP_V1_UNLIMITED_THRESHOLD) {
+    return Math.floor((cgroupV1 / 1024 / 1024) * HEAP_RATIO);
+  }
+
+  // Priority 4: V8 default
+  return undefined;
+}
+
+/**
+ * Build Node.js flags array based on heap size and environment variables.
+ */
+export function buildNodeFlags(heapSize: number | undefined): string[] {
+  const flags: string[] = ['--expose_gc'];
+
+  if (heapSize != null) {
+    flags.push(`--max-heap-size=${heapSize}`);
+  }
+
+  if (process.env.V8_OPTIMIZE_FOR_SIZE === 'true') {
+    flags.push('--optimize-for-size');
+  }
+
+  if (process.env.V8_LITE_MODE === 'true') {
+    flags.push('--lite-mode');
+  }
+
+  return flags;
+}
+
+/**
+ * Setup required directories (as root).
+ * - /data/uploads with symlink to ./public/uploads
+ * - /tmp/page-bulk-export with mode 700
+ */
+export function setupDirectories(
+  uploadsDir: string,
+  publicUploadsLink: string,
+  bulkExportDir: string,
+): void {
+  // /data/uploads
+  fs.mkdirSync(uploadsDir, { recursive: true });
+  if (!fs.existsSync(publicUploadsLink)) {
+    fs.symlinkSync(uploadsDir, publicUploadsLink);
+  }
+  chownRecursive(uploadsDir, NODE_UID, NODE_GID);
+  fs.lchownSync(publicUploadsLink, NODE_UID, NODE_GID);
+
+  // /tmp/page-bulk-export
+  fs.mkdirSync(bulkExportDir, { recursive: true });
+  chownRecursive(bulkExportDir, NODE_UID, NODE_GID);
+  fs.chmodSync(bulkExportDir, 0o700);
+}
+
+/**
+ * Drop privileges from root to node user.
+ * These APIs are POSIX-only and guaranteed to exist in the Docker container (Linux).
+ */
+export function dropPrivileges(): void {
+  if (process.setgid == null || process.setuid == null) {
+    throw new Error('Privilege drop APIs not available (non-POSIX platform)');
+  }
+  process.setgid(NODE_GID);
+  process.setuid(NODE_UID);
+}
+
+/**
+ * Log applied Node.js flags to stdout.
+ */
+function logFlags(heapSize: number | undefined, flags: string[]): void {
+  const source = (() => {
+    if (
+      process.env.V8_MAX_HEAP_SIZE != null &&
+      process.env.V8_MAX_HEAP_SIZE !== ''
+    ) {
+      return 'V8_MAX_HEAP_SIZE env';
+    }
+    if (heapSize != null) return 'cgroup auto-detection';
+    return 'V8 default (no heap limit)';
+  })();
+
+  console.log(`[entrypoint] Heap size source: ${source}`);
+  console.log(`[entrypoint] Node.js flags: ${flags.join(' ')}`);
+}
+
+/**
+ * Run database migration via execFileSync (no shell needed).
+ * Equivalent to: node -r dotenv-flow/config node_modules/migrate-mongo/bin/migrate-mongo up -f config/migrate-mongo-config.js
+ */
+function runMigration(): void {
+  console.log('[entrypoint] Running migration...');
+  execFileSync(
+    process.execPath,
+    [
+      '-r',
+      'dotenv-flow/config',
+      'node_modules/migrate-mongo/bin/migrate-mongo',
+      'up',
+      '-f',
+      'config/migrate-mongo-config.js',
+    ],
+    {
+      stdio: 'inherit',
+      env: { ...process.env, NODE_ENV: 'production' },
+    },
+  );
+  console.log('[entrypoint] Migration completed');
+}
+
+/**
+ * Spawn the application process and forward signals.
+ */
+function spawnApp(nodeFlags: string[]): void {
+  const child = spawn(
+    process.execPath,
+    [...nodeFlags, '-r', 'dotenv-flow/config', 'dist/server/app.js'],
+    {
+      stdio: 'inherit',
+      env: { ...process.env, NODE_ENV: 'production' },
+    },
+  );
+
+  // PID 1 signal forwarding
+  const signals: NodeJS.Signals[] = ['SIGTERM', 'SIGINT', 'SIGHUP'];
+  for (const sig of signals) {
+    process.on(sig, () => child.kill(sig));
+  }
+
+  child.on('exit', (code: number | null, signal: NodeJS.Signals | null) => {
+    process.exit(code ?? (signal === 'SIGTERM' ? 0 : 1));
+  });
+}
+
+// -- Main entrypoint --
+
+function main(): void {
+  try {
+    // Step 1: Directory setup (as root)
+    setupDirectories(
+      '/data/uploads',
+      './public/uploads',
+      '/tmp/page-bulk-export',
+    );
+
+    // Step 2: Detect heap size and build flags
+    const heapSize = detectHeapSize();
+    const nodeFlags = buildNodeFlags(heapSize);
+    logFlags(heapSize, nodeFlags);
+
+    // Step 3: Drop privileges (root → node)
+    dropPrivileges();
+
+    // Step 4: Run migration
+    runMigration();
+
+    // Step 5: Start application
+    spawnApp(nodeFlags);
+  } catch (err) {
+    console.error('[entrypoint] Fatal error:', err);
+    process.exit(1);
+  }
+}
+
+// Run main only when executed directly (not when imported for testing)
+const isMainModule =
+  process.argv[1] != null &&
+  (process.argv[1].endsWith('docker-entrypoint.ts') ||
+    process.argv[1].endsWith('docker-entrypoint.js'));
+
+if (isMainModule) {
+  main();
+}

+ 1 - 1
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.4.5-RC.0",
+  "version": "7.5.0-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {

+ 27 - 27
apps/app/public/static/locales/fr_FR/admin.json

@@ -15,8 +15,8 @@
     "security_settings": "Sécurité",
     "scope_of_page_disclosure": "Confidentialité de la page",
     "set_point": "Valeur",
-    "Guest Users Access": "Accès invité",
-    "readonly_users_access": "Accès des utilisateurs lecture seule",
+    "Guest Users Access": "Accès public",
+    "readonly_users_access": "Utilisateurs en lecture seule",
     "always_hidden": "Toujours caché",
     "always_displayed": "Toujours affiché",
     "Fixed by env var": "Configuré par la variable d'environnement <code>{{key}}={{value}}</code>.",
@@ -36,25 +36,25 @@
     "page_listing_2_desc": "Voir les pages restreintes au groupe utilisateur lors de la recherche",
     "page_access_rights": "Lecture",
     "page_delete_rights": "Suppression",
-    "page_delete": "Suppression de page",
-    "page_delete_completely": "Suppression complète de page",
-    "comment_manage_rights": "Droits de gestion des commentaires",
-    "other_options": "Paramètres supplémentaires",
-    "deletion_explanation": "Restreindre les utilisateurs pouvant supprimer une page.",
-    "complete_deletion_explanation": "Restreindre les utilisateurs pouvant supprimer complètement une page.",
+    "page_delete": "Corbeille",
+    "page_delete_completely": "Suppression définitive",
+    "comment_manage_rights": "Gestion des commentaires",
+    "other_options": "Autres options",
+    "deletion_explanation": "Restreindre les utilisateurs pouvant mettre à la corbeille une page.",
+    "complete_deletion_explanation": "Restreindre les utilisateurs pouvant supprimer définitivement une page.",
     "recursive_deletion_explain": "Restreindre les utilisateurs pouvant récursivement supprimer une page.",
     "recursive_complete_deletion_explain": "Restreindre les utilisateurs pouvant récursivement supprimer complètement une page.",
     "is_all_group_membership_required_for_page_complete_deletion": "L'utilisateur doit faire partie de tout les groupes ayant l'accès à la page",
     "is_all_group_membership_required_for_page_complete_deletion_explanation": "Effectif lorsque les paramètres de page sont \"Seulement groupes spécifiés\".",
-    "inherit": "Hériter(Utilise le même paramètre que pour une page)",
-    "admin_only": "Administrateur seulement",
+    "inherit": "Hériter",
+    "admin_only": "Administrateur",
     "admin_and_author": "Administrateur et auteur",
     "anyone": "Tout le monde",
     "user_homepage_deletion": {
-      "user_homepage_deletion": "Suppression de page d'accueil utilisateur",
-      "enable_user_homepage_deletion": "Suppression de page d'accueil utilisateur",
+      "user_homepage_deletion": "Page d'utilisateur",
+      "enable_user_homepage_deletion": "Autoriser la suppression",
       "enable_force_delete_user_homepage_on_user_deletion": "Supprimer la page d'accueil et ses pages enfants",
-      "desc": "Les pages d'accueil utilisateurs pourront être supprimées."
+      "desc": "La page d'accueuil d'un utilisateur supprimé sera supprimée."
     },
     "disable_user_pages": {
       "disable_user_pages": "Désactiver les pages utilisateur",
@@ -63,13 +63,13 @@
     },
     "session": "Session",
     "max_age": "Âge maximal (ms)",
-    "max_age_desc": "Spécifie (en milliseconde) l'âge maximal d'une session <br>Par défaut: 2592000000 (30 jours)",
-    "max_age_caution": "Un rédemarrage du serveur est nécessaire lorsque cette valeur est modifiée",
+    "max_age_desc": "L'âge maximal (en millisecondes) d'une session <br>Par défaut: 2592000000 (30 jours)",
+    "max_age_caution": "La modification de cette valeur nécessite un rédemarrage du serveur.",
     "forced_update_desc": "Ce paramètre à été modifié. Valeur précedente: ",
     "page_delete_rights_caution": "Lorsque \"Supprimer / Supprimer récursivement\" est activé, le paramètre is \"Supprimer / Supprimer complètement\" est écrasé. <br> <br> Administrateur seulement > Administrateur et auteur > Tout le monde",
     "Authentication mechanism settings": "Mécanisme d'authentification",
     "setup_is_not_yet_complete": "Configuration incomplète",
-    "xss_prevent_setting": "Prévenir les attaques XSS(Cross Site Scripting)",
+    "xss_prevent_setting": "Prévention des attaques XSS",
     "xss_prevent_setting_link": "Paramètres Markdown",
     "callback_URL": "URL de Callback",
     "providerName": "Nom du fournisseur",
@@ -89,8 +89,8 @@
     "updated_general_security_setting": "Paramètres mis à jour",
     "setup_not_completed_yet": "Configuration incomplète",
     "guest_mode": {
-      "deny": "Refuser (Utilisateurs inscrits seulement)",
-      "readonly": "Autoriser (Lecture seule)"
+      "deny": "Interdit",
+      "readonly": "Lecture seule"
     },
     "read_only_users_comment": {
       "deny": "Ne peut pas commenter",
@@ -298,7 +298,7 @@
     "normalize_description": "Réparer les indices cassés.",
     "rebuild": "Reconstruire",
     "rebuild_button": "Reconstruire",
-    "rebuild_description_1": "Reconstruire l'index est les données de pages",
+    "rebuild_description_1": "Reconstruire l'index et les données de pages",
     "rebuild_description_2": "Cela peut prendre un certain temps."
   },
   "mailer_setup_required": "La <a href='/admin/app'>configuration du SMTP</a> est requise.",
@@ -360,9 +360,9 @@
     "confidential_name": "Nom interne",
     "confidential_example": "ex): usage interne seulement",
     "default_language": "Langue par défaut",
-    "default_mail_visibility": "Mode d'affichage de l'adresse courriel",
-    "default_read_only_for_new_user": "Restriction d'édition pour les nouveaux utilisateurs",
-    "set_read_only_for_new_user": "Rendre les nouveaux utilisateurs en lecture seule",
+    "default_mail_visibility": "Confidentialité de l'adresse courriel",
+    "default_read_only_for_new_user": "Permissions des nouveaux utilisateurs",
+    "set_read_only_for_new_user": "Lecture seule",
     "file_uploading": "Téléversement de fichiers",
     "page_bulk_export_settings": "Paramètres d'exportation de pages par lots",
     "enable_page_bulk_export": "Activer l'exportation groupée",
@@ -433,9 +433,9 @@
     "lineBreak_desc": "Conversion du saut de ligne automatique.",
     "lineBreak_options": {
       "enable_lineBreak": "Saut de ligne",
-      "enable_lineBreak_desc": "Convertir le saut de ligne<code>&lt;br&gt;</code>en HTML",
+      "enable_lineBreak_desc": "Convertir le saut de ligne vers un tag HTML <code>&lt;br&gt;</code>.",
       "enable_lineBreak_for_comment": "Saut de ligne dans les commentaires",
-      "enable_lineBreak_for_comment_desc": "Convertir le saut de ligne dans les commentaires<code>&lt;br&gt;</code>en HTML"
+      "enable_lineBreak_for_comment_desc": "Convertir le saut de ligne dans les commentaires vers un tag HTML <code>&lt;br&gt;</code>."
     },
     "indent_header": "Indentation",
     "indent_desc": "Taille d'indentation dans une page.",
@@ -737,14 +737,14 @@
   },
   "user_management": {
     "user_management": "Utilisateurs",
-    "invite_users": "Nouvel utilisateur temporaire",
+    "invite_users": "Nouvel utilisateur",
     "click_twice_same_checkbox": "Il est nécessaire de sélectionner une option.",
     "status": "Statut",
     "invite_modal": {
       "emails": "Adresse(s) courriel(s) (Supporte l'usage de plusieurs lignes)",
-      "description1": "Créer des utilisateurs temporaires avec une adresse courriel.",
+      "description1": "Créer des utilisateurs avec une adresse courriel.",
       "description2": "Un mot de passe temporaire est généré automatiquement.",
-      "invite_thru_email": "Courriel d'invitation",
+      "invite_thru_email": "Envoyer une invitation",
       "mail_setting_link": "<span class='material-symbols-outlined me-2'>settings</span><a href='/admin/app'>Paramètres courriel</a>",
       "valid_email": "Adresse courriel valide requise",
       "temporary_password": "Cette utilisateur a un mot de passe temporaire",

+ 3 - 1
apps/app/public/static/locales/fr_FR/commons.json

@@ -1,10 +1,12 @@
 {
   "Show": "Afficher",
+  "View": "Lecture",
+  "Edit": "Écriture",
   "Hide": "Cacher",
   "Add": "Ajouter",
   "Insert": "Insérer",
   "Reset": "Réinitialiser",
-  "Sign out": "Se déconnecter",
+  "Sign out": "Déconnexion",
   "New": "Nouveau",
   "Delete": "Supprimer",
   "meta": {

+ 53 - 52
apps/app/public/static/locales/fr_FR/translation.json

@@ -16,7 +16,7 @@
   "tablet": "Tablette",
   "Click to copy": "Cliquer pour copier",
   "Rename": "Renommer",
-  "Move/Rename": "Déplacer/renommer",
+  "Move/Rename": "Déplacer",
   "Redirected": "Redirigé",
   "Unlinked": "Délié",
   "unlink_redirection": "Délier la redirection",
@@ -39,11 +39,11 @@
   "Category": "Catégorie",
   "User": "Utilisateur",
   "account_id": "Identifiant de compte",
-  "Update": "Mettre à jour",
+  "Update": "Enregistrer",
   "Update Page": "Mettre à jour la page",
   "Error": "Erreur",
   "Warning": "Avertissement",
-  "Sign in": "Se connecter",
+  "Sign in": "Connexion",
   "Sign in with External auth": "Se connecter avec {{signin}}",
   "Sign up is here": "Inscription",
   "Sign in is here": "Connexion",
@@ -61,15 +61,14 @@
   "History": "Historique",
   "attachment_data": "Pièces jointes",
   "No_attachments_yet": "Aucune pièce jointe.",
-  "Presentation Mode": "Mode présentation",
+  "Presentation Mode": "Présentation",
   "Not available for guest": "Indisponible pour les invités",
   "Not available in this version": "Indisponible dans cette version",
   "Not available when \"anyone with the link\" is selected": "Si \"Tous les utilisateurs disposant du lien\" est sélectionné, la portée ne peut pas être modifiée",
-  "No users have liked this yet": "Aucun utilisateur n'a aimé cette page",
   "No users have liked this yet.": "Aucun utilisateur n'a aimé cette page.",
   "No users have bookmarked yet": "Aucun utilisateur n'a mis en favoris cette page",
   "Create Archive Page": "Créer page d'archive",
-  "Create Sidebar Page": "Créer page <strong>/Sidebar</strong>",
+  "Create Sidebar Page": "Créer la page <strong>/Sidebar</strong>",
   "File type": "Type de fichier",
   "Target page": "Page cible",
   "Include Attachment File": "Inclure le fichier de pièces jointes",
@@ -87,7 +86,7 @@
   "Create/Edit Template": "Créer/Modifier page modèle",
   "Go to this version": "Voir cette version",
   "View diff": "Voir le diff",
-  "No diff": "Aucune différences",
+  "No diff": "Aucune différence",
   "Latest": "Dernière version",
   "User ID": "Identifiant utilisateur",
   "User Settings": "Paramètres utilisateur",
@@ -123,6 +122,8 @@
   "UserGroup": "Groupe utilisateur",
   "Basic Settings": "Paramètres de base",
   "The contents entered here will be shown in the header etc": "Le contenu entré ici sera visible dans l'en-tête",
+  "Browsing of this page is restricted": "Le contenu de cette page est restreint.",
+  "Forbidden": "Accès interdit",
   "Public": "Tout le monde",
   "Anyone with the link": "Tous les utilisateurs disposant du lien",
   "Specified users only": "Utilisateurs spécifiés",
@@ -230,7 +231,7 @@
     "notice": {
       "apitoken_issued": "Aucun jeton d'API existant.",
       "update_token1": "Un nouveau jeton peut être généré.",
-      "update_token2": "Modifiez le jeton aux endroits où celui-ci est utilisé."
+      "update_token2": "Modifiez le jeton aux endroits où celui-ci est utilisé, car l'ancien sera invalide."
     },
     "form_help": {}
   },
@@ -295,17 +296,17 @@
   "Other Settings": "Autres paramètres",
   "in_app_notification_settings": {
     "in_app_notification_settings": "Notifications",
-    "subscribe_settings": "Paramètres d'abonnement automatique aux notifications de pages",
+    "subscribe_settings": "Notifications d'application",
     "default_subscribe_rules": {
-      "page_create": "S'abonner aux modifications d'une page lors de sa création."
+      "page_create": "Abonne systématiquement aux notifications de modification d'une page lors de sa création."
     }
   },
   "ui_settings": {
     "ui_settings": "Interface",
     "side_bar_mode": {
       "settings": "Paramètres navigation latérale",
-      "side_bar_mode_setting": "Épingler la navigation latérale",
-      "description": "Activer pour toujours afficher la barre de navigation latérale lorsque l'écran est large. Si la largeur d'écran est faible, le cas inverse est applicable."
+      "side_bar_mode_setting": "Épingler la barre latérale",
+      "description": "Épingle sur l'écran la barre de navigation latérale. Si la résolution de l'écran est trop faible, la barre latérale ne sera plus épinglée."
     }
   },
   "color_mode_settings": {
@@ -368,7 +369,7 @@
     "keymap": "Raccourcis",
     "indent": "Indentation",
     "paste": {
-      "title": "Comportement du collage",
+      "title": "Collage",
       "both": "Texte et fichier",
       "text": "Texte seulement",
       "file": "Fichier seulement"
@@ -377,7 +378,7 @@
     "editor_assistant": "Assistant d'édition",
     "Show active line": "Surligner la ligne active",
     "auto_format_table": "Formatter les tableaux",
-    "overwrite_scopes": "{{operation}} et écraser les scopes des pages enfants",
+    "overwrite_scopes": "{{operation}} et écraser les permissions des pages enfants",
     "notice": {
       "conflict": "Sauvegarde impossible, la page est en cours de modification par un autre utilisateur. Recharger la page."
     },
@@ -385,10 +386,10 @@
   },
   "page_comment": {
     "comments": "Commentaires",
-    "comment": "Commmenter",
-    "preview": "Prévisualiser",
-    "write": "Écrire",
-    "add_a_comment": "Ajouter un commentaire",
+    "comment": "Cer",
+    "preview": "Visualiser",
+    "write": "Rédaction",
+    "add_a_comment": "Nouveau commentaire",
     "display_the_page_when_posting_this_comment": "Afficher la page en postant le commentaire",
     "no_user_found": "Aucun utilisateur trouvé",
     "reply": "Répondre",
@@ -408,22 +409,22 @@
     "revision": "Révision",
     "comparing_source": "Source",
     "comparing_target": "Cible",
-    "comparing_revisions": "Comparer les modifications",
+    "comparing_revisions": "Historique des modifications",
     "compare_latest": "Comparer avec la version la plus récente",
     "compare_previous": "Comparer avec la version précédente"
   },
   "modal_rename": {
     "label": {
-      "Move/Rename page": "Déplacer/renommer page",
-      "New page name": "Nouveau chemin",
+      "Move/Rename page": "Déplacer ou renommer la page",
+      "New page name": "Nouvel emplacement",
       "Failed to get subordinated pages": "échec de récupération des pages subordinnées",
       "Failed to get exist path": "échec de la récupération du chemin",
-      "Current page name": "Chemin actuel",
+      "Current page name": "Emplacement actuel",
       "Rename this page only": "Renommer cette page",
       "Force rename all child pages": "Forcer le renommage des pages",
       "Other options": "Autres options",
-      "Do not update metadata": "Ne pas modifier les métadonnées",
-      "Redirect": "Redirection automatique"
+      "Do not update metadata": "Conserver les métadonnées",
+      "Redirect": "Redirection"
     },
     "help": {
       "redirect": "Redirige automatiquement vers le nouveau chemin de la page.",
@@ -456,18 +457,18 @@
   },
   "modal_duplicate": {
     "label": {
-      "Duplicate page": "Dupliquer",
-      "New page name": "Nom de la page",
+      "Duplicate page": "Dupliquer la page",
+      "New page name": "Emplacement de la nouvelle page",
       "Failed to get subordinated pages": "échec de récupération des pages subordinnées",
-      "Current page name": "Nom de la page courante",
-      "Recursively": "Récursivement",
+      "Current page name": "Emplacement actuel",
+      "Recursively": "Récursif",
       "Duplicate without exist path": "Dupliquer sans le chemin existant",
       "Same page already exists": "Une page avec ce chemin existe déjà",
-      "Only duplicate user related pages": "Seul les pages dupliquées auquelles vous avez accès"
+      "Only duplicate user related pages": "Hériter les permissions"
     },
     "help": {
-      "recursive": "Dupliquer les pages enfants récursivement",
-      "only_inherit_user_related_groups": "Si la page est configuré en \"Seulement dans le groupe\", les groupes auxquels vous n'appartenez pas perdront l'accès aux pages dupliquées"
+      "recursive": "Duplique également les pages enfants.",
+      "only_inherit_user_related_groups": "Les pages restreintes a un groupe hériteront des permissions assignées."
     }
   },
   "duplicated_pages": "{{fromPath}} dupliquée",
@@ -704,20 +705,20 @@
   "template": {
     "modal_label": {
       "Select template": "Sélectionner modèle",
-      "Create/Edit Template Page": "Créer/modifier page modèle",
+      "Create/Edit Template Page": "Créer un modèle",
       "Create template under": "Créer une page modèle enfant"
     },
     "option_label": {
-      "create/edit": "Créer/modifier page modèle",
+      "create/edit": "Modèle",
       "select": "Sélectionner type de page modèle"
     },
     "children": {
       "label": "Modèle pour page enfant",
-      "desc": "Applicable aux pages de même niveau que la page modèle"
+      "desc": "Le modèle est appliqué aux pages du même niveau dans l'arbre."
     },
-    "decendants": {
+    "descendants": {
       "label": "Modèle pour descendants",
-      "desc": "Applicable aux page descendantes"
+      "desc": "Le modèle est appliqué à toutes les pages enfants."
     }
   },
   "sandbox": {
@@ -836,14 +837,14 @@
   "page_export": {
     "failed_to_export": "Échec de l'export",
     "failed_to_count_pages": "Échec du compte des pages",
-    "export_page_markdown": "Exporter la page en Markdown",
+    "export_page_markdown": "Exporter",
     "export_page_pdf": "Exporter la page en PDF",
-    "bulk_export": "Exporter la page et toutes les pages enfants",
+    "bulk_export": "Exporter les pages enfants",
     "bulk_export_download_explanation": "Une notification sera envoyée lorsque l’exportation sera terminée. Pour télécharger le fichier exporté, cliquez sur la notification.",
     "bulk_export_exec_time_warning": "Si le nombre de pages est important, l'exportation peut prendre un certain temps.",
-    "large_bulk_export_warning": "Pour préserver les ressources du système, veuillez éviter d'exporter un grand nombre de pages consécutivement",
+    "large_bulk_export_warning": "Pour préserver les ressources du système, veuillez éviter d'exporter un grand nombre de pages consécutivement.",
     "markdown": "Markdown",
-    "choose_export_format": "Sélectionnez le format d'exportation",
+    "choose_export_format": "Format de l'export",
     "bulk_export_started": "Patientez s'il-vous-plait...",
     "bulk_export_download_expired": "La période de téléchargement a expiré",
     "bulk_export_job_expired": "Le traitement a été interrompu car le temps d'exportation était trop long",
@@ -978,7 +979,7 @@
     }
   },
   "tooltip": {
-    "like": "Like!",
+    "like": "Aimer",
     "cancel_like": "Annuler",
     "bookmark": "Ajouter aux favoris",
     "cancel_bookmark": "Retirer des favoris",
@@ -998,8 +999,8 @@
   },
   "user_home_page": {
     "bookmarks": "Favoris",
-    "recently_created": "Page récentes",
-    "recent_activity": "Activité récente",
+    "recently_created": "Pages récentes",
+    "recent_activity": "Modifications récentes",
     "unknown_action": "a effectué une modification non spécifiée",
     "page_create": "a créé une page",
     "page_update": "a mis à jour une page",
@@ -1034,7 +1035,7 @@
   },
   "tag_edit_modal": {
     "edit_tags": "Étiquettes",
-    "done": "Mettre à jour",
+    "done": "Assigner",
     "tags_input": {
       "tag_name": "Choisir ou créer une étiquette"
     }
@@ -1049,24 +1050,24 @@
     "select_page_location": "Sélectionner emplacement de la page"
   },
   "wip_page": {
-    "save_as_wip": "Sauvegarder comme brouillon",
+    "save_as_wip": "Enregistrer comme brouillon",
     "success_save_as_wip": "Sauvegardée en tant que brouillon",
     "fail_save_as_wip": "Échec de la sauvegarde du brouillon",
-    "alert": "Page en cours d'écriture",
-    "publish_page": "Publier page",
+    "alert": "Page en cours d'écriture.",
+    "publish_page": "Publier",
     "success_publish_page": "Page publiée",
     "fail_publish_page": "Échec de publication de la page"
   },
   "sidebar_header": {
-    "show_wip_page": "Voir brouillon",
+    "show_wip_page": "Inclure les brouillons",
     "compact_view": "Vue compacte"
   },
   "sync-latest-revision-body": {
     "menuitem": "Synchroniser avec la dernière révision",
-    "confirm": "Supprime les données en brouillon et synchronise avec la dernière révision. Synchroniser?",
-    "alert": "Il se peut que le texte le plus récent n'ait pas été synchronisé. Veuillez recharger et vérifier à nouveau.",
-    "success-toaster": "Dernier texte synchronisé",
-    "skipped-toaster": "L'éditeur n'est pas actif. Synchronisation annulée.",
+    "confirm": "Le contenu non enregistré sera écrasé. Synchroniser?",
+    "alert": "Certaines modifications n'ont pas été enregistrées. Veuillez rafraîchir votre onglet de navigateur.",
+    "success-toaster": "Dernière révision synchronisée",
+    "skipped-toaster": "Le mode édition doit être activé pour déclencher la synchronisation. Synchronisation annulée.",
     "error-toaster": "Synchronisation échouée"
   }
 }

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

@@ -16,7 +16,7 @@ const ForbiddenPage = React.memo((props: Props): JSX.Element => {
             <span className="material-symbols-outlined" aria-hidden="true">
               block
             </span>
-            Forbidden
+            {t('Forbidden')}
           </h2>
         </div>
       </div>

+ 22 - 7
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/compress-and-upload.ts

@@ -1,3 +1,4 @@
+import { PassThrough } from 'node:stream';
 import type { Archiver } from 'archiver';
 import archiver from 'archiver';
 
@@ -75,19 +76,33 @@ export async function compressAndUpload(
 
   const fileUploadService: FileUploader = this.crowi.fileUploadService;
 
+  // Wrap with Node.js native PassThrough so that AWS SDK recognizes the stream as a native Readable
+  const uploadStream = new PassThrough();
+
+  // Establish pipe before finalize to ensure data flows correctly
+  pageArchiver.pipe(uploadStream);
+  pageArchiver.on('error', (err) => {
+    uploadStream.destroy(err);
+    pageArchiver.destroy();
+  });
+
   pageArchiver.directory(this.getTmpOutputDir(pageBulkExportJob), false);
   pageArchiver.finalize();
-  this.setStreamsInExecution(pageBulkExportJob._id, pageArchiver);
+
+  this.setStreamsInExecution(pageBulkExportJob._id, pageArchiver, uploadStream);
 
   try {
-    await fileUploadService.uploadAttachment(pageArchiver, attachment);
+    await fileUploadService.uploadAttachment(uploadStream, attachment);
+    await postProcess.bind(this)(
+      pageBulkExportJob,
+      attachment,
+      pageArchiver.pointer(),
+    );
   } catch (e) {
     logger.error(e);
     this.handleError(e, pageBulkExportJob);
+  } finally {
+    pageArchiver.destroy();
+    uploadStream.destroy();
   }
-  await postProcess.bind(this)(
-    pageBulkExportJob,
-    attachment,
-    pageArchiver.pointer(),
-  );
 }

+ 4 - 2
apps/pdf-converter/docker/Dockerfile

@@ -1,12 +1,13 @@
 # syntax = docker/dockerfile:1.4
 
+ARG NODE_VERSION=24
 ARG OPT_DIR="/opt"
 ARG PNPM_HOME="/root/.local/share/pnpm"
 
 ##
 ## base
 ##
-FROM node:20-slim AS base
+FROM node:${NODE_VERSION}-slim AS base
 
 ARG OPT_DIR
 ARG PNPM_HOME
@@ -63,7 +64,8 @@ RUN tar -zcf /tmp/packages.tar.gz \
 ##
 ## release
 ##
-FROM node:20-slim
+ARG NODE_VERSION=24
+FROM node:${NODE_VERSION}-slim
 LABEL maintainer="Yuki Takei <yuki@weseek.co.jp>"
 
 ARG OPT_DIR

+ 5 - 2
apps/slackbot-proxy/docker/Dockerfile

@@ -1,9 +1,11 @@
 # syntax = docker/dockerfile:1
 
+ARG NODE_VERSION=24
+
 ##
 ## base
 ##
-FROM node:20-slim AS base
+FROM node:${NODE_VERSION}-slim AS base
 
 ENV optDir="/opt"
 
@@ -52,7 +54,8 @@ RUN tar -zcf packages.tar.gz \
 ##
 ## release
 ##
-FROM node:20-slim
+ARG NODE_VERSION=24
+FROM node:${NODE_VERSION}-slim
 LABEL maintainer="Yuki Takei <yuki@weseek.co.jp>"
 
 ENV NODE_ENV="production"

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

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

+ 2 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "7.4.5-RC.0",
+  "version": "7.5.0-RC.0",
   "description": "Team collaboration software using markdown",
   "license": "MIT",
   "private": "true",
@@ -117,6 +117,6 @@
     ]
   },
   "engines": {
-    "node": "^18 || ^20"
+    "node": "^24"
   }
 }