Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/components/my-notebooks/ImportCardsForm.tsx
Original file line number Diff line number Diff line change
@@ -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 = {};
Expand Down Expand Up @@ -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"
/>
<p className="text-xs text-zinc-500 dark:text-zinc-500">
ファイルサイズは最大 {MAX_EXCEL_UPLOAD_SIZE / (1024 * 1024)} MB です。
</p>
<p className="text-xs text-zinc-500 dark:text-zinc-500">
1行目を見出し行として読み取ります。既存の列と同じ名前の列はそこに追加され、無い列名は新しい列として追加されます。
</p>
Expand Down
13 changes: 13 additions & 0 deletions app/lib/server/my-notebooks/actions.server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 } });
Expand Down
4 changes: 4 additions & 0 deletions app/lib/server/my-notebooks/actions.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 },
Expand Down
2 changes: 2 additions & 0 deletions app/lib/shared/excel-constraints.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const MAX_EXCEL_UPLOAD_SIZE = 10 * 1024 * 1024;
export const EXCEL_UPLOAD_SIZE_ERROR = "Excelファイルは10 MB以下にしてください。";
7 changes: 7 additions & 0 deletions app/lib/shared/excel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import ExcelJS from "exceljs";
import { describe, expect, it } from "vite-plus/test";

import { createTemplateWorkbookBuffer, ExcelParseError, parseExcelWorkbook } from "./excel";
import { MAX_EXCEL_UPLOAD_SIZE } from "./excel-constraints";

async function workbookBuffer(rows: (string | number | null)[][]): Promise<ArrayBuffer> {
const workbook = new ExcelJS.Workbook();
Expand Down Expand Up @@ -55,6 +56,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(
Expand Down
7 changes: 7 additions & 0 deletions app/lib/shared/excel.ts
Original file line number Diff line number Diff line change
@@ -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行目(ヘッダー行)の値を、出現順のまま並べたもの。
Expand Down Expand Up @@ -52,6 +55,10 @@ function cellToText(value: ExcelJS.CellValue): string {

// アップロードされたExcelファイルの1シート目を読み取り、1行目をヘッダーとする可変列の単語帳データに変換する
export async function parseExcelWorkbook(buffer: ArrayBuffer): Promise<ParsedNotebook> {
if (buffer.byteLength > MAX_EXCEL_UPLOAD_SIZE) {
throw new ExcelParseError(EXCEL_UPLOAD_SIZE_ERROR);
}

// ファイルを読み込み、データをworkbookに格納する
const workbook = new ExcelJS.Workbook();
try {
Expand Down
Loading