MENU

問い合わせ


    【Nuxtで作るPDF管理システム 第1回】PDFをアップロードして安全に保存する

    PDF管理システムの最初の機能は「アップロード」です。画面でPDFを選んで送ると、サーバーが本当にPDFかどうかを確かめてから保存する。一見シンプルですが、ファイルを受け取る処理はシステムの入口であり、ここが甘いと後のすべての機能に影響します。

    前回の第0回:全体像とNuxt 4プロジェクトの土台づくりでは、連載の全体像と、共通レイアウト・設定値の置き場を用意しました。今回はそこに、Nuxt のサーバー機能を使ったPDFのアップロードAPIと画面を追加します。

    「ファイル選択の画面で『PDFだけ選べる』ようにしておけば、それで十分じゃないの?」

    結論から言うと、画面側のチェックは使い勝手のためのもので、安全のためにはなりません。安全に保存するには、サーバー側で「サイズ」「拡張子」「ファイルの種類の申告(Content-Type)」「ファイルの中身の先頭」の4点を確認し、保存するファイル名はユーザーが付けた名前ではなくランダムなIDにします。この回では、この流れを Nuxt 4 のコードで実装します。

    目次

    PDFアップロードを安全にする4つのチェックと保存ルール

    なぜサーバー側で中身まで確認するのか

    ブラウザの <input type="file" accept=".pdf"> は、ファイル選択画面の絞り込みにすぎません。ツールを使えば、画面を通さずにAPIへ直接どんなファイルでも送れます。拡張子もファイル名を変えるだけで偽れます。そこで、サーバー側で次の4点を確認します。

    チェック内容失敗時
    サイズ上限(今回は20MB)を超えていないか。空のファイルでないか413 / 400
    拡張子ファイル名が .pdf で終わるか400
    Content-Typeブラウザが申告したファイルの種類が application/pdf か400
    先頭バイトファイルの先頭が %PDF- で始まるか400

    最後の「先頭バイト」がポイントです。PDFファイルは必ず %PDF- という文字列で始まる決まりがあり、これを確認することで「画像の拡張子だけを .pdf に変えたファイル」を弾けます。ただし、先頭が正しくても中身が壊れているPDFや、不正な仕掛けを含むPDFまでは見抜けません。これは「PDFらしくないものを入口で断る」ための最低限のチェックです。

    保存するファイル名はUUIDにする

    ユーザーが付けたファイル名(例:見積書_最終版.pdf)を、そのまま保存先のファイル名に使うのは避けます。

    • ../../ のような文字を含む名前で、保存先の外にファイルを書かれるおそれがある(パストラバーサル=フォルダをさかのぼって想定外の場所にアクセスする攻撃)
    • 同じ名前のファイルが上書きされる
    • 日本語や記号がOSによって扱いにくい

    そこで、保存名は重複しないランダムなID(UUID)にし、元のファイル名は画面表示用として別に持ちます。第2回からは、この元のファイル名をデータベースに保存します。

    Nuxtのサーバー機能でアップロードAPIを作る

    Nuxt では、server/api/ にファイルを置くと、そのままAPIになります。ファイル名の .post で「POSTで送られたときだけ動く」ことを指定できます。今回の server/api/files.post.ts は POST /api/files になります。また、server/utils/ に置いた関数は、import を書かなくてもサーバー側のコードから使えます(自動インポート)。

    📰 出典:Nuxt 公式ドキュメント server ディレクトリ

    今回追加・変更するファイルは次のとおりです。

    ファイル役割
    nuxt.config.tsアップロード上限 maxUploadBytes を追加
    server/api/files.post.tsアップロードAPI本体
    server/utils/validate-pdf.ts4つのチェックとファイル名の整形
    server/utils/storage/types.ts, local.ts, index.ts保存先の抽象化とローカル保存
    app/pages/files/upload.vueアップロード画面
    app/layouts/default.vueナビに「アップロード」を追加

    上限サイズを runtimeConfig に置く

    上限サイズは、サーバーの判定と画面の案内表示の両方で使うため、同じ値をサーバー用と public(ブラウザにも渡す値)の両方に入れます。

    nuxt.config.ts(抜粋)

    // アップロード上限(既定 20MB)。サーバー側の判定と画面の表示で同じ値を使う
    const MAX_UPLOAD_BYTES = 20 * 1024 * 1024
    
    export default defineNuxtConfig({
      // …(第0回の設定は省略)
      runtimeConfig: {
        uploadDir: './uploads',
        maxUploadBytes: MAX_UPLOAD_BYTES,
        public: {
          appName: 'PDF管理システム',
          maxUploadBytes: MAX_UPLOAD_BYTES,
        },
      },
    })

    環境ごとに変えたいときは、.env の NUXT_MAX_UPLOAD_BYTES と NUXT_PUBLIC_MAX_UPLOAD_BYTES で上書きします(.env.example にキー名を追記済みです)。

    4つのチェックをまとめた関数

    server/utils/validate-pdf.ts(抜粋)

    import type { MultiPartData } from 'h3'
    
    // PDFファイルの先頭は必ず "%PDF-" で始まる(マジックナンバー)
    const PDF_MAGIC = Buffer.from('%PDF-', 'ascii')
    
    export function assertPdfFile(file: MultiPartData, maxBytes: number): void {
      if (file.data.length === 0) {
        throw createError({ statusCode: 400, statusMessage: 'ファイルが空です' })
      }
      if (file.data.length > maxBytes) {
        throw createError({ statusCode: 413, statusMessage: 'ファイルサイズが上限を超えています' })
      }
      if (!file.filename || !file.filename.toLowerCase().endsWith('.pdf')) {
        throw createError({ statusCode: 400, statusMessage: '拡張子が .pdf のファイルを選択してください' })
      }
      if (file.type !== 'application/pdf') {
        throw createError({ statusCode: 400, statusMessage: 'PDF形式(application/pdf)のファイルではありません' })
      }
      if (!file.data.subarray(0, PDF_MAGIC.length).equals(PDF_MAGIC)) {
        throw createError({ statusCode: 400, statusMessage: 'ファイルの中身がPDFではありません' })
      }
    }
    
    /** 元のファイル名から、パス区切りや制御文字を取り除く(DB保存・表示用。保存パスには使わない) */
    export function sanitizeFileName(name: string): string {
      const base = name.split(/[\\/]/).pop() ?? ''
      const cleaned = base.replace(/[\u0000-\u001f\u007f]/g, '').trim()
      return cleaned.slice(0, 255) || 'untitled.pdf'
    }

    エラーは createError({ statusCode, statusMessage }) で返します。利用者に見せてよい短いメッセージだけを返し、サーバー内部の情報(ファイルのパスやエラーの詳細)は含めません。

    保存先を差し替えられるようにする

    PDFの保存先は、開発中はパソコンのフォルダ、本番ではクラウドのストレージ(Amazon S3)にする予定です(第11回)。保存先が変わってもAPIのコードを書き換えなくて済むよう、「保存する・読み出す・消す」の3つだけを決めた入れ物(インターフェース)を先に作ります。

    server/utils/storage/types.ts

    import type { Readable } from 'node:stream'
    
    export interface StorageAdapter {
      /** key の場所にデータを保存する */
      put(key: string, data: Buffer, contentType: string): Promise<void>
      /** key のデータを読み出すストリームを返す(ダウンロード用) */
      getStream(key: string): Promise<Readable>
      /** key のデータを削除する(存在しなければ何もしない) */
      delete(key: string): Promise<void>
    }

    ローカル版は Node.js 標準のファイル操作で実装します。保存名はUUIDなので本来は安全ですが、念のため「保存フォルダの外を指すキー」は拒否し、同名ファイルがあれば上書きしない設定にしています。

    server/utils/storage/local.ts

    import { createReadStream } from 'node:fs'
    import { mkdir, rm, writeFile } from 'node:fs/promises'
    import { resolve, sep } from 'node:path'
    import type { Readable } from 'node:stream'
    import type { StorageAdapter } from './types'
    
    export function createLocalStorage(baseDir: string): StorageAdapter {
      const root = resolve(baseDir)
    
      // key から保存先の絶対パスを作る。root の外を指すキーは拒否する(パストラバーサル対策)
      function pathOf(key: string): string {
        const full = resolve(root, key)
        if (!full.startsWith(root + sep)) {
          throw new Error(`invalid storage key: ${key}`)
        }
        return full
      }
    
      return {
        async put(key: string, data: Buffer): Promise<void> {
          await mkdir(root, { recursive: true })
          // flag 'wx':同名ファイルがあれば上書きせずエラーにする
          await writeFile(pathOf(key), data, { flag: 'wx' })
        },
        async getStream(key: string): Promise<Readable> {
          return createReadStream(pathOf(key))
        },
        async delete(key: string): Promise<void> {
          await rm(pathOf(key), { force: true })
        },
      }
    }

    server/utils/storage/index.ts の useFileStorage() は、設定の uploadDir を使ってローカル版を1つだけ作って返す関数です(全体はサンプルコードを参照してください)。Nitro(Nuxt に内蔵のサーバー)にも useStorage() という保存の仕組みがありますが、PDFのような大きなファイルを「ストリーム(=少しずつ流して送る方式)」で返したり、S3の期限付きURLを発行したりしたいので、この連載では自前の薄い抽象化にしています。

    アップロードAPI本体

    server/api/files.post.ts

    import { randomUUID } from 'node:crypto'
    
    export default defineEventHandler(async (event) => {
      const { maxUploadBytes } = useRuntimeConfig(event)
    
      // 1. 本文を読み込む前に Content-Length で大きすぎるリクエストを断る
      const contentLength = Number(getRequestHeader(event, 'content-length') ?? 0)
      // multipart の区切り文字などの分として 64KB の余裕を見る
      if (contentLength > maxUploadBytes + 64 * 1024) {
        throw createError({ statusCode: 413, statusMessage: 'ファイルサイズが上限を超えています' })
      }
    
      // 2. multipart/form-data を読み取り、"file" フィールドを取り出す
      const parts = await readMultipartFormData(event)
      const file = parts?.find((part) => part.name === 'file' && part.filename !== undefined)
      if (!file) {
        throw createError({ statusCode: 400, statusMessage: 'file フィールドにPDFを指定してください' })
      }
    
      // 3. サイズ・拡張子・MIME・先頭バイトを検証
      assertPdfFile(file, maxUploadBytes)
    
      // 4. 保存名はUUID。ユーザーが送ったファイル名はパスに使わない
      const id = randomUUID()
      const storageKey = `${id}.pdf`
      await useFileStorage().put(storageKey, file.data, 'application/pdf')
    
      setResponseStatus(event, 201)
      return {
        id,
        originalName: sanitizeFileName(file.filename ?? ''),
        size: file.data.length,
      }
    })

    readMultipartFormData は、ファイル送信で使われる multipart/form-data 形式を読み取る h3(Nuxt のサーバーが使うHTTPライブラリ)の関数です。

    📰 出典:h3 公式ドキュメント Request utils

    注意点として、この関数はリクエストの本文をすべてメモリに読み込んでから分解します(インストールされた h3 1系のソースで確認)。そのため、本文を読む前に Content-Length(送られてくるデータの大きさの申告)で明らかに大きいものを断っています。

    アップロード画面を作る

    app/pages/files/upload.vue(スタイルは省略)

    <script setup lang="ts">
    useHead({ title: 'アップロード | PDF管理システム' })
    
    interface UploadResult {
      id: string
      originalName: string
      size: number
    }
    
    const config = useRuntimeConfig()
    const maxBytes = config.public.maxUploadBytes
    
    const selected = ref<File | null>(null)
    const uploading = ref(false)
    const message = ref('')
    const result = ref<UploadResult | null>(null)
    
    function onChange(e: Event) {
      const input = e.target as HTMLInputElement
      const file = input.files?.[0] ?? null
      result.value = null
      message.value = ''
      // ここでのチェックは使い勝手のため。最終的な判定はサーバー側で行う
      if (file && file.size > maxBytes) {
        message.value = `ファイルサイズが上限(${Math.floor(maxBytes / 1024 / 1024)}MB)を超えています`
        selected.value = null
        return
      }
      selected.value = file
    }
    
    async function upload() {
      if (!selected.value) return
      const body = new FormData()
      body.append('file', selected.value)
      uploading.value = true
      message.value = ''
      try {
        result.value = await $fetch<UploadResult>('/api/files', { method: 'POST', body })
        message.value = 'アップロードしました'
      } catch (err: unknown) {
        const statusMessage = (err as { data?: { statusMessage?: string } }).data?.statusMessage
        message.value = statusMessage ?? 'アップロードに失敗しました'
      } finally {
        uploading.value = false
      }
    }
    </script>
    
    <template>
      <section>
        <h1>PDFのアップロード</h1>
        <p>PDFファイル({{ Math.floor(maxBytes / 1024 / 1024) }}MBまで)を選択してください。</p>
        <input type="file" accept="application/pdf,.pdf" @change="onChange">
        <button type="button" :disabled="!selected || uploading" @click="upload">
          {{ uploading ? '送信中…' : 'アップロード' }}
        </button>
        <p v-if="message">{{ message }}</p>
        <dl v-if="result">
          <dt>ID</dt><dd>{{ result.id }}</dd>
          <dt>ファイル名</dt><dd>{{ result.originalName }}</dd>
          <dt>サイズ</dt><dd>{{ result.size.toLocaleString() }} バイト</dd>
        </dl>
      </section>
    </template>

    画面側でもサイズを確認していますが、これは「20MBを超えるファイルを送って待たされた挙句に失敗する」ことを防ぐためのものです。サーバー側のチェックとは役割が違うことを、コメントに残しています。

    動作確認の方法

    npm run build と npm run typecheck が通ることを確認したうえで、ビルド結果を起動し、curl(コマンドでHTTPリクエストを送るツール)で次のケースを試しました。

    npm run build
    node .output/server/index.mjs
    
    # 正しいPDF → 201
    curl -F "file=@sample.pdf" http://localhost:3000/api/files
    # 画像の拡張子を .pdf に変えたもの → 400(ファイルの中身がPDFではありません)
    curl -F "file=@fake.pdf" http://localhost:3000/api/files
    # 21MBのファイル → 413
    curl -F "file=@big.pdf" http://localhost:3000/api/files
    送ったもの結果
    正しいPDF201。uploads/ にUUID名のファイルができる
    PNG画像の拡張子を .pdf に変えたもの400(中身がPDFではない)
    21MBのファイル413(上限超過)
    Content-Type を application/octet-stream にしたPDF400
    拡張子 .txt のPDF400
    ファイル名 ../../evil.pdf201。保存名はUUIDで、uploads/ の外には書かれない。元のファイル名は evil.pdf に整形
    ファイル名 見積書.pdf201。元のファイル名が文字化けせずに返る

    画面(/files/upload)はサーバーでの表示までを確認しています。ブラウザでファイルを選んで送る操作は、筆者の環境では実施していないため、お手元で確認してください。

    つまずきやすい点・セキュリティ上の注意

    • 大きなファイルはメモリを使う:readMultipartFormData は本文を丸ごとメモリに載せます。Content-Length を付けずに送られた場合(分割送信)は事前に断れず、読み込み後のサイズ判定になります。本番では、前段のWebサーバーやロードバランサーでも本文サイズの上限を設定してください。数百MB級のファイルを扱うなら、ストリームで受ける方式やS3への直接アップロードを検討します。
    • uploads/ を公開フォルダにしない:public/ の下に保存すると、URLを知っている人は誰でもダウンロードできてしまいます。ダウンロードは第5回で、ログインと権限を確認するAPI経由で実装します。
    • ウイルス対策は別途:今回のチェックはPDFの形式確認であり、マルウェア(悪意のあるプログラム)の検査ではありません。外部の人からファイルを受け取る運用なら、ウイルススキャンの仕組みを追加で検討します。
    • この時点ではまだ誰でもアップロードできる:ログイン機能は第4回で追加します。それまでは社外に公開しないでください。

    発注者向けメモ:「PDFだけ受け付ける」の中身を確認する

    「PDFだけアップロードできるようにしてください」という要望は、作り方によって安全性も工数も変わります。次の点を打ち合わせで確認すると、認識のずれを防げます。

    • どこまで確認するか:拡張子だけか、ファイルの中身(先頭バイト)まで確認するか。社外から受け取るならウイルススキャンまで必要か。
    • 上限サイズと件数:スキャン書類が多い部署では1ファイル数十MBになることがあります。上限を上げるほど、サーバーのメモリや保存先の容量、アップロード方式の設計に影響します。
    • パスワード付きPDFを受け付けるか:この連載で使う pdf-lib は暗号化PDFの編集に対応していません。受け付けても編集できない、という扱いでよいかを決めておきます。
    • 元のファイル名の扱い:一覧やダウンロード時に元の名前を表示するか、社内ルールで名前を付け直すか。

    打ち合わせでは、次のように聞いてみてください。

    • 「アップロードされたファイルがPDFかどうかは、拡張子以外に何で確認していますか?」
    • 「保存するときのファイル名は、利用者が付けた名前をそのまま使いますか?」
    • 「アップロードしたファイルは、ログインしていない人がURLで直接見られる場所に置かれませんか?」
    • 「上限サイズを超えるファイルが来たとき、利用者にはどのようなメッセージが表示されますか?」

    まとめと次回予告

    第1回では、PDFをアップロードして安全に保存する仕組みを作りました。

    • サーバー側で「サイズ・拡張子・Content-Type・先頭バイト %PDF-」の4点を確認する
    • 保存名はUUIDにし、利用者が付けたファイル名は保存パスに使わない
    • 保存先は StorageAdapter で抽象化し、後からS3へ差し替えられるようにする
    • 画面側のチェックは使い勝手のため。安全はサーバー側で守る

    次回は「ファイル情報をDBに持ち、一覧を表示する」です。SQLite と Drizzle ORM を使って、元のファイル名・サイズ・ページ数・登録日時をデータベースに保存し、一覧画面を作ります。

    この連載の記事一覧

    この記事は連載「Nuxtで作るPDF管理システム」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (1件)

      目次