From e86ce4ab1e6c0cd48f30602bcfefa023bad460c2 Mon Sep 17 00:00:00 2001 From: tknkaa Date: Mon, 9 Nov 2026 15:53:01 +0900 Subject: [PATCH 1/2] feat: limit Excel uploads to 10 MB --- app/components/my-notebooks/ImportCardsForm.tsx | 4 ++++ app/lib/server/my-notebooks/actions.server.test.ts | 13 +++++++++++++ app/lib/server/my-notebooks/actions.server.ts | 4 ++++ app/lib/shared/excel-constraints.ts | 2 ++ app/lib/shared/excel.test.ts | 13 ++++++++++++- app/lib/shared/excel.ts | 7 +++++++ 6 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 app/lib/shared/excel-constraints.ts diff --git a/app/components/my-notebooks/ImportCardsForm.tsx b/app/components/my-notebooks/ImportCardsForm.tsx index 7bb1e95..2bd97e8 100644 --- a/app/components/my-notebooks/ImportCardsForm.tsx +++ b/app/components/my-notebooks/ImportCardsForm.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { useFetcher } from "react-router"; +import { MAX_EXCEL_UPLOAD_SIZE } from "@/lib/shared/excel-constraints"; import type { FormState } from "@/lib/shared/form-state"; const initialState: FormState = {}; @@ -174,6 +175,9 @@ export default function ImportCardsForm({ notebookId }: { notebookId: string }) required className="text-sm text-zinc-600 file:mr-3 file:rounded-full file:border-0 file:bg-coral-100 file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-coral-700 hover:file:bg-coral-200 hover:file:text-coral-800 dark:text-zinc-400 dark:file:bg-coral-900/30 dark:file:text-coral-300 dark:hover:file:bg-coral-900/50 dark:hover:file:text-coral-200" /> +

+ ファイルサイズは最大 {MAX_EXCEL_UPLOAD_SIZE / (1024 * 1024)} MB です。 +

1行目を見出し行として読み取ります。既存の列と同じ名前の列はそこに追加され、無い列名は新しい列として追加されます。

diff --git a/app/lib/server/my-notebooks/actions.server.test.ts b/app/lib/server/my-notebooks/actions.server.test.ts index d9f6f74..dab9b52 100644 --- a/app/lib/server/my-notebooks/actions.server.test.ts +++ b/app/lib/server/my-notebooks/actions.server.test.ts @@ -20,11 +20,13 @@ import { MAX_STAR_COUNT, NOTEBOOK_TITLE_MAX_LENGTH, } from "@/lib/shared/notebook-schema"; +import { EXCEL_UPLOAD_SIZE_ERROR, MAX_EXCEL_UPLOAD_SIZE } from "@/lib/shared/excel-constraints"; import { addNotebookColumn, createBlankNotebook, createCard as createCardAction, + importCardsFromExcel, deleteCard, deleteNotebook, moveCard, @@ -585,6 +587,17 @@ describe("★(toggleStar / setStarCount)", () => { }); describe("入力の検証", () => { + it("10 MB を超える Excel ファイルは解析せず拒否する", async () => { + loginAs(owner); + const formData = new FormData(); + formData.set("file", new File([new Uint8Array(MAX_EXCEL_UPLOAD_SIZE + 1)], "large.xlsx")); + + expect(await importCardsFromExcel(notebook.id, formData)).toEqual({ + error: EXCEL_UPLOAD_SIZE_ERROR, + }); + expect(await prisma.card.count({ where: { notebookId: notebook.id } })).toBe(0); + }); + it("長すぎるタイトルの単語帳は作成しない", async () => { loginAs(owner); const before = await prisma.notebook.count({ where: { userId: owner.id } }); diff --git a/app/lib/server/my-notebooks/actions.server.ts b/app/lib/server/my-notebooks/actions.server.ts index 6b7634e..61775a1 100644 --- a/app/lib/server/my-notebooks/actions.server.ts +++ b/app/lib/server/my-notebooks/actions.server.ts @@ -4,6 +4,7 @@ import { prisma } from "@/lib/server/prisma.server"; import { requireUser } from "@/lib/server/session.server"; import { requireNotebookRole } from "@/lib/server/notebook-access.server"; import { ExcelParseError, parseExcelWorkbook } from "@/lib/shared/excel"; +import { EXCEL_UPLOAD_SIZE_ERROR, MAX_EXCEL_UPLOAD_SIZE } from "@/lib/shared/excel-constraints"; import { normalizeCardData, type CardData } from "@/lib/shared/card-data"; import { DEFAULT_HEAD_COLUMN, @@ -96,6 +97,9 @@ export async function importCardsFromExcel( if (!(file instanceof File) || file.size === 0) { return { error: "Excelファイル(.xlsx)を選択してください。" }; } + if (file.size > MAX_EXCEL_UPLOAD_SIZE) { + return { error: EXCEL_UPLOAD_SIZE_ERROR }; + } const notebook = await prisma.notebook.findUniqueOrThrow({ where: { id: notebookId }, diff --git a/app/lib/shared/excel-constraints.ts b/app/lib/shared/excel-constraints.ts new file mode 100644 index 0000000..f222f6a --- /dev/null +++ b/app/lib/shared/excel-constraints.ts @@ -0,0 +1,2 @@ +export const MAX_EXCEL_UPLOAD_SIZE = 10 * 1024 * 1024; +export const EXCEL_UPLOAD_SIZE_ERROR = "Excelファイルは10 MB以下にしてください。"; diff --git a/app/lib/shared/excel.test.ts b/app/lib/shared/excel.test.ts index b766826..d089491 100644 --- a/app/lib/shared/excel.test.ts +++ b/app/lib/shared/excel.test.ts @@ -1,7 +1,12 @@ import ExcelJS from "exceljs"; import { describe, expect, it } from "vite-plus/test"; -import { createTemplateWorkbookBuffer, ExcelParseError, parseExcelWorkbook } from "./excel"; +import { + createTemplateWorkbookBuffer, + ExcelParseError, + parseExcelWorkbook, +} from "./excel"; +import { MAX_EXCEL_UPLOAD_SIZE } from "./excel-constraints"; async function workbookBuffer(rows: (string | number | null)[][]): Promise { const workbook = new ExcelJS.Workbook(); @@ -55,6 +60,12 @@ describe("parseExcelWorkbook", () => { ); }); + it("10 MB を超えるファイルは解析前に拒否する", async () => { + await expect(parseExcelWorkbook(new ArrayBuffer(MAX_EXCEL_UPLOAD_SIZE + 1))).rejects.toThrow( + ExcelParseError, + ); + }); + it("xlsx でないデータのエラーでは、Googleスプレッドシートを .xlsx で書き出す方法を案内する", async () => { // Googleスプレッドシートから CSV などで書き出したファイルを選んだときを想定する await expect( diff --git a/app/lib/shared/excel.ts b/app/lib/shared/excel.ts index 51faff6..7241ac9 100644 --- a/app/lib/shared/excel.ts +++ b/app/lib/shared/excel.ts @@ -1,6 +1,9 @@ import ExcelJS from "exceljs"; import type { CardData } from "@/lib/shared/card-data"; +import { EXCEL_UPLOAD_SIZE_ERROR, MAX_EXCEL_UPLOAD_SIZE } from "@/lib/shared/excel-constraints"; + +export { EXCEL_UPLOAD_SIZE_ERROR, MAX_EXCEL_UPLOAD_SIZE } from "@/lib/shared/excel-constraints"; export type ParsedNotebook = { // Excelの1行目(ヘッダー行)の値を、出現順のまま並べたもの。 @@ -52,6 +55,10 @@ function cellToText(value: ExcelJS.CellValue): string { // アップロードされたExcelファイルの1シート目を読み取り、1行目をヘッダーとする可変列の単語帳データに変換する export async function parseExcelWorkbook(buffer: ArrayBuffer): Promise { + if (buffer.byteLength > MAX_EXCEL_UPLOAD_SIZE) { + throw new ExcelParseError(EXCEL_UPLOAD_SIZE_ERROR); + } + // ファイルを読み込み、データをworkbookに格納する const workbook = new ExcelJS.Workbook(); try { From e7e1fd15ea03a04f3d8862fa6303a5d77665608e Mon Sep 17 00:00:00 2001 From: tknkaa Date: Mon, 9 Nov 2026 15:55:46 +0900 Subject: [PATCH 2/2] style: format Excel upload test --- app/lib/shared/excel.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app/lib/shared/excel.test.ts b/app/lib/shared/excel.test.ts index d089491..2973b00 100644 --- a/app/lib/shared/excel.test.ts +++ b/app/lib/shared/excel.test.ts @@ -1,11 +1,7 @@ import ExcelJS from "exceljs"; import { describe, expect, it } from "vite-plus/test"; -import { - createTemplateWorkbookBuffer, - ExcelParseError, - parseExcelWorkbook, -} from "./excel"; +import { createTemplateWorkbookBuffer, ExcelParseError, parseExcelWorkbook } from "./excel"; import { MAX_EXCEL_UPLOAD_SIZE } from "./excel-constraints"; async function workbookBuffer(rows: (string | number | null)[][]): Promise {