Post

Cloudflare R2とD1で作る自社ファイル共有サービスの構成例

Cloudflare R2とD1で作る自社ファイル共有サービスの構成例

Cloudflare R2、D1、Workers / Pages を組み合わせると、自社ドメインで使える軽量なファイル共有サービスを構築できます。 この記事では、LWJ Secure Share の考え方をもとに、公開できる範囲に絞って構成例を整理します。

はじめに

メール添付では送りにくい大容量ファイルや、パスワード付きZIPを別メールで送る運用は、現場ではまだよく使われています。

ただ、業務で使い続けるには次のような課題があります。

  • 添付ファイルの容量制限に引っかかる
  • 送信後に共有を止めにくい
  • 誰がダウンロードしたか分からない
  • URLやパスワードの扱いが属人的になる
  • 無料ファイル便では自社ドメインや履歴管理が難しい

そこで、Cloudflare のサービスを使って、自社ドメインで運用できるファイル共有サービス を構築する方法を考えます。

LWJ ではこの考え方をもとに、LWJ Secure Share という法人向けファイル共有サービスを構成しています。

この記事のコードや設定値は説明用に簡略化したサンプルです。実際の運用環境のURL、バケット名、APIキー、Webhook URL、顧客情報は掲載していません。

全体構成

基本構成は次の通りです。

flowchart LR
    USER[送信者]
    PAGE[Cloudflare Pages<br>アップロード画面]
    WORKER[Workers Functions<br>API処理]
    R2[Cloudflare R2<br>ファイル保存]
    D1[Cloudflare D1<br>共有情報・履歴]
    MAIL[Google Apps Script<br>またはメール配信サービス]
    RECEIVER[受信者]

    USER --> PAGE
    PAGE --> WORKER
    WORKER --> R2
    WORKER --> D1
    WORKER --> MAIL
    MAIL --> RECEIVER
    RECEIVER --> WORKER
    WORKER --> R2

役割を分けると、こうなります。

  • Cloudflare Pages: 管理画面、アップロード画面、ダウンロード画面
  • Workers Functions: アップロード、共有URL発行、OTP検証、ダウンロード制御
  • Cloudflare R2: 実ファイルの保存
  • Cloudflare D1: 共有情報、宛先、OTP、履歴、監査ログ
  • Google Apps Script: 共有案内メールやOTPメールの送信

小規模な構成なら、サーバーを自前で持たずに始められるのが利点です。

保存する情報

ファイル共有サービスでは、ファイルそのものだけでなく、共有状態や履歴も管理します。

最小構成なら、D1には次のようなテーブルを用意します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
CREATE TABLE IF NOT EXISTS files (
  id TEXT PRIMARY KEY,
  r2_key TEXT NOT NULL,
  original_name TEXT NOT NULL,
  size_bytes INTEGER NOT NULL,
  mime_type TEXT,
  created_at INTEGER NOT NULL,
  expires_at INTEGER NOT NULL,
  deleted_at INTEGER
);

CREATE TABLE IF NOT EXISTS shares (
  id TEXT PRIMARY KEY,
  file_id TEXT NOT NULL,
  token_hash TEXT NOT NULL UNIQUE,
  recipient_email TEXT NOT NULL,
  max_downloads INTEGER NOT NULL DEFAULT 1,
  downloaded_count INTEGER NOT NULL DEFAULT 0,
  status TEXT NOT NULL DEFAULT 'active',
  created_at INTEGER NOT NULL,
  expires_at INTEGER NOT NULL,
  FOREIGN KEY (file_id) REFERENCES files(id)
);

CREATE TABLE IF NOT EXISTS otp_challenges (
  id TEXT PRIMARY KEY,
  share_id TEXT NOT NULL,
  code_hash TEXT NOT NULL,
  recipient_email TEXT,
  attempts INTEGER NOT NULL DEFAULT 0,
  expires_at INTEGER NOT NULL,
  consumed_at INTEGER,
  created_at INTEGER NOT NULL,
  FOREIGN KEY (share_id) REFERENCES shares(id)
);

CREATE TABLE IF NOT EXISTS download_events (
  id TEXT PRIMARY KEY,
  share_id TEXT NOT NULL,
  recipient_email TEXT,
  result TEXT NOT NULL,
  created_at INTEGER NOT NULL,
  FOREIGN KEY (share_id) REFERENCES shares(id)
);

実運用では、CC送信、複数ファイル、アドレス帳、レート制限、監査ログ、分割アップロードなども追加します。

環境変数の例

Cloudflare Pages / Workers には、URLや上限値を環境変数として設定します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
APP_PUBLIC_BASE=https://share.example.co.jp
API_BASE=https://example-project.pages.dev
ALLOWED_ORIGINS=https://share.example.co.jp,https://example-project.pages.dev

MAX_UPLOAD_MB=1100
MAX_FILE_MB=1000
ALLOWED_UPLOAD_EXTS=pdf,zip,7z,stp,step,igs,iges,dxf,dwg,xlsx,xls,docx,doc,png,jpg,jpeg,txt,csv

OTP_REQUEST_LIMIT_COUNT=5
OTP_REQUEST_LIMIT_WINDOW_MINUTES=15
OTP_VERIFY_LIMIT_COUNT=5
OTP_VERIFY_LIMIT_WINDOW_MINUTES=10

MAIL_WEBHOOK_URL=https://script.google.com/macros/s/xxxxxxxxxxxxxxxx/exec

シークレットは通常の環境変数ではなく、Cloudflare の Secret として設定します。

1
2
3
ADMIN_PIN=<set as secret>
OTP_SECRET=<set as secret>
MAIL_WEBHOOK_SECRET=<set as secret>

ブログやマニュアルに本番値を載せないことが重要です。

アップロード処理のサンプル

次は、Workers Functions でファイルを受け取り、R2へ保存し、D1へ共有情報を登録する簡略化サンプルです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
export async function onRequestPost(context) {
  const { request, env } = context;
  const formData = await request.formData();
  const file = formData.get("file");
  const recipientEmail = formData.get("email");

  if (!file || typeof file === "string") {
    return Response.json({ error: "file is required" }, { status: 400 });
  }

  if (!recipientEmail || typeof recipientEmail !== "string") {
    return Response.json({ error: "email is required" }, { status: 400 });
  }

  const now = Math.floor(Date.now() / 1000);
  const fileId = crypto.randomUUID();
  const shareId = crypto.randomUUID();
  const shareToken = crypto.randomUUID();
  const r2Key = `shares/${shareId}/${file.name}`;

  await env.FILES.put(r2Key, file.stream(), {
    httpMetadata: {
      contentType: file.type || "application/octet-stream",
    },
  });

  await env.DB.prepare(`
    INSERT INTO files
      (id, r2_key, original_name, size_bytes, mime_type, created_at, expires_at)
    VALUES
      (?, ?, ?, ?, ?, ?, ?)
  `).bind(
    fileId,
    r2Key,
    file.name,
    file.size,
    file.type || "application/octet-stream",
    now,
    now + 7 * 24 * 60 * 60
  ).run();

  await env.DB.prepare(`
    INSERT INTO shares
      (id, file_id, token_hash, recipient_email, created_at, expires_at)
    VALUES
      (?, ?, ?, ?, ?, ?)
  `).bind(
    shareId,
    fileId,
    await sha256(shareToken),
    recipientEmail,
    now,
    now + 7 * 24 * 60 * 60
  ).run();

  return Response.json({
    shareUrl: `${env.APP_PUBLIC_BASE}/share/${shareToken}`,
  });
}

async function sha256(value) {
  const data = new TextEncoder().encode(value);
  const hash = await crypto.subtle.digest("SHA-256", data);
  return [...new Uint8Array(hash)]
    .map((byte) => byte.toString(16).padStart(2, "0"))
    .join("");
}

ポイントは、共有URLに使うトークンをそのままDBへ保存しないことです。DBにはハッシュ化した値を保存します。

OTP認証の考え方

共有URLだけでダウンロードできる構成は簡単ですが、URLが転送された場合に弱くなります。

そこで、受信者のメールアドレスへワンタイムコードを送信し、次の2つがそろった場合だけダウンロードできるようにします。

  • 共有URLを知っている
  • 宛先メールで受け取ったOTPコードを知っている

OTPは平文保存せず、こちらもハッシュ化して保存します。

1
2
3
4
5
function generateOtp() {
  const array = new Uint32Array(1);
  crypto.getRandomValues(array);
  return String(array[0] % 1000000).padStart(6, "0");
}

実運用では、OTPの有効期限、試行回数制限、ロック時間、IPやメールアドレス単位のレート制限も必要です。

メール送信

小規模な運用では Google Apps Script をメール送信のWebhookとして使う構成も考えられます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function doPost(e) {
  const payload = JSON.parse(e.postData.contents);

  if (payload.secret !== PropertiesService.getScriptProperties().getProperty("MAIL_WEBHOOK_SECRET")) {
    return ContentService
      .createTextOutput(JSON.stringify({ error: "unauthorized" }))
      .setMimeType(ContentService.MimeType.JSON);
  }

  GmailApp.sendEmail(
    payload.to,
    payload.subject,
    payload.body
  );

  return ContentService
    .createTextOutput(JSON.stringify({ ok: true }))
    .setMimeType(ContentService.MimeType.JSON);
}

ただし、GmailやApps Scriptには送信数の上限があります。共有件数が増える場合は、Google Workspace、Postmark、Resendなどの専用メール配信サービスを検討します。

ダウンロード履歴

ダウンロード時には、成功・失敗を履歴として残します。

1
2
3
4
INSERT INTO download_events
  (id, share_id, recipient_email, result, created_at)
VALUES
  (?, ?, ?, ?, ?);

履歴があると、次の確認ができます。

  • 受信者がダウンロードしたか
  • 期限切れで失敗したか
  • OTP認証で失敗が続いていないか
  • ダウンロード回数制限に達したか

法人間のファイル送付では、この「あとから確認できる」ことがかなり重要です。

定期削除

ファイル共有サービスは、ファイル保管サービスとは分けて考えた方が安全です。

期限切れのファイルは、Cloudflare Cron Triggers などで定期的に削除します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export default {
  async scheduled(event, env, ctx) {
    const expired = await env.DB.prepare(`
      SELECT id, r2_key
      FROM files
      WHERE expires_at < ? AND deleted_at IS NULL
      LIMIT 100
    `).bind(Math.floor(Date.now() / 1000)).all();

    for (const file of expired.results) {
      await env.FILES.delete(file.r2_key);
      await env.DB.prepare(`
        UPDATE files
        SET deleted_at = ?
        WHERE id = ?
      `).bind(Math.floor(Date.now() / 1000), file.id).run();
    }
  },
};

R2に残った孤立ファイルを検出する処理や、監査ログの保存期間も決めておくと運用しやすくなります。

実運用で必要になる機能

最低限のアップロードとダウンロードだけなら短いコードで作れます。

ただし、業務で使う場合は次のような設計が必要です。

  • 管理者認証
  • CSRF対策
  • アップロード拡張子制限
  • ファイルサイズ制限
  • OTPの試行回数制限
  • ダウンロード回数制限
  • 共有停止
  • 期限切れ削除
  • ダウンロード履歴
  • 監査ログ
  • CC送信
  • アドレス帳
  • メール送信上限への対応
  • セキュリティヘッダー

LWJ Secure Share では、このような要素を組み合わせて、自社ドメインで使えるファイル共有サービスとして構成しています。

まとめ

Cloudflare R2 と D1 を使うと、小規模なファイル共有サービスをかなり軽い構成で作れます。

一方で、ファイル共有は「アップロードできる」だけでは業務利用には足りません。 宛先確認、OTP認証、履歴、有効期限、削除、誤送信対策まで含めて設計することで、現場で使いやすい仕組みになります。

LWJでは、こうした自社ドメイン型ファイル共有サービスの構築や運用相談にも対応しています。

This post is licensed under CC BY 4.0 by the author.