diff --git a/.env.example b/.env.example index 369981a..15aa52f 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,9 @@ TIMEZONE=Asia/Tokyo # iCal 購読エンドポイント認証トークン(推測困難な文字列を設定すること) # 例: python3 -c "import secrets; print(secrets.token_urlsafe(32))" +# このトークンはサーバー秘密鍵として扱う。フロントエンドには配布されない。 +# 各ユーザーの購読トークンはこの値と user_id から HMAC で導出されるため、 +# この値を再生成すると全ユーザーの購読URLが一括で失効する。 ICAL_TOKEN=your-secret-token-here # デプロイ Webhook 認証トークン(openssl rand -hex 32 などで生成) diff --git a/README.md b/README.md index a8e0fd4..f70285d 100644 --- a/README.md +++ b/README.md @@ -107,14 +107,27 @@ ICAL_TOKEN=生成したトークン文字列 ### エンドポイント -| URL | 用途 | -|-----|------| -| `GET /api/ical/all?token=` | 全スタッフのシフト(管理者用) | -| `GET /api/ical/user/{user_id}?token=` | 特定ユーザーのシフト | +| URL | 用途 | トークン | +|-----|------|----------| +| `GET /api/ical/all?token=` | 全スタッフのシフト(管理者用) | `.env` の `ICAL_TOKEN` | +| `GET /api/ical/user/{user_id}?token=` | 特定ユーザーのシフト | ユーザーごとの導出トークン | +| `GET /api/ical/my-url?userId=` | 本人の購読URLを発行 | 不要 | +| `GET /api/ical/all-url?email=` | 管理者用の購読URLを発行 | `AUTHORIZED_EMAILS` に含まれること | + +### トークン設計 + +`ICAL_TOKEN` はサーバー秘密鍵として扱い、フロントエンドには配布しません。各ユーザーの購読トークンは `HMAC-SHA256(ICAL_TOKEN, "ical-user:" + user_id)` で導出され、**その user_id と対でのみ有効**です。 + +- 購読URLの `user_id` を他人のものに書き換えても `401` になります +- 管理者トークンで `/api/ical/user/{user_id}` を叩いても `401` になります(用途を分離) +- `ICAL_TOKEN` を再生成すると、全ユーザーの購読URLが一括で失効します ### 購読URLの確認 -管理者モードの「シフト一覧」タブ上部に購読URL(全シフト)が表示されます。「コピー」ボタンでクリップボードにコピーできます。 +- **管理者**: 「シフト一覧」タブ上部に購読URL(全シフト)が表示されます +- **一般ユーザー**: 「自分のシフト一覧」タブ上部に、自分のシフトだけの購読URLが表示されます + +いずれも「コピー」ボタンでクリップボードにコピーできます。学生向けの詳しい手順は `help.html` の「Googleカレンダーに表示する」を参照してください。 ### カレンダーアプリへの登録 @@ -135,7 +148,7 @@ ICAL_TOKEN=生成したトークン文字列 - **更新頻度**: Pull 型のため、カレンダーアプリのポーリング間隔に依存します。Google Calendar・Outlook は約24時間、Apple カレンダーは約15分が目安です(リアルタイム更新は現行の Google Calendar 直接同期が適切)。 - **連続シフトの統合**: 同一ユーザーの連続する30分シフトは1つのイベントにまとめて表示されます(例: 13:00-13:30 + 13:30-14:00 → 13:00-14:00)。 - **特別シフトも含む**: 通常シフト(`shifts`)と特別シフト(`special_shifts`)の両方が含まれます。 -- **セキュリティ**: URLに含まれる `ICAL_TOKEN` が認証情報です。漏洩した場合は `.env` のトークンを再生成してサーバーを再起動してください。 +- **セキュリティ**: URLに含まれるトークンが認証情報です。購読URLは他人に共有しないでください。漏洩した場合は `.env` の `ICAL_TOKEN` を再生成してサーバーを再起動すると、全ユーザーの購読URLが失効し、新しいURLが発行されます。 ## 使用技術 diff --git a/docs/images/my-shifts-ical-copied.png b/docs/images/my-shifts-ical-copied.png new file mode 100644 index 0000000..e129774 Binary files /dev/null and b/docs/images/my-shifts-ical-copied.png differ diff --git a/docs/images/my-shifts-ical-url.png b/docs/images/my-shifts-ical-url.png new file mode 100644 index 0000000..1a8eae7 Binary files /dev/null and b/docs/images/my-shifts-ical-url.png differ diff --git a/help.html b/help.html index e5c6145..a511d2f 100644 --- a/help.html +++ b/help.html @@ -96,6 +96,15 @@ font-size: 14px; } + .help-screenshot { + display: block; + max-width: 100%; + margin: 12px 0 4px; + border: 1px solid #ddd; + border-radius: 6px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); + } + .info-box { background: #e3f2fd; border-left: 4px solid #2196f3; @@ -286,9 +295,10 @@

📑 目次

  • 3. プロフィール設定
  • 4. シフト申請
  • 5. 自分のシフト確認
  • -
  • 6. シフトカレンダーの見方
  • -
  • 7. よくある質問
  • -
  • 8. トラブルシューティング
  • +
  • 6. Googleカレンダーに表示する
  • +
  • 7. シフトカレンダーの見方
  • +
  • 8. よくある質問
  • +
  • 9. トラブルシューティング
  • @@ -489,8 +499,80 @@

    シフトの削除

    +
    +

    6. Googleカレンダーに表示する

    + +

    自分のシフトを、普段使っている Googleカレンダー に表示できます。一度設定しておけば、シフトを申請したり削除したりするたびに、カレンダー側にも自動で反映されます。毎回手入力する必要はありません。

    + +
    + 💡 ポイント +

    表示されるのは自分のシフトだけです。他の人のシフトは表示されません。スマートフォンのGoogleカレンダーアプリにも、同じアカウントなら自動で表示されます。

    +
    + +

    設定の手順

    + +
    +
    + 1.「自分のシフト一覧」タブを開く +

    ログインしたあと、画面上部の「自分のシフト一覧」タブをクリックします。一覧の上に「Googleカレンダーに表示する(自分のシフトのみ)」と書かれた青い枠があります。

    + 「自分のシフト一覧」タブの上部に表示される購読URLの欄 +
    + +
    + 2.「コピー」ボタンを押す +

    青い枠の右にある「コピー」ボタンをクリックします。ボタンの文字が「コピーしました」に変われば、URLがコピーできています。

    + コピーボタンを押すと「コピーしました」に変わる +
    + +
    + 3. Googleカレンダーを開く +

    パソコンのブラウザで calendar.google.com を開きます。この設定はパソコンから行ってください。スマートフォンのアプリからは追加できませんが、パソコンで設定すればスマートフォンにも自動的に表示されます。

    +
    + +
    + 4.「他のカレンダー」の「+」をクリック +

    画面左側の下のほうにある「他のカレンダー」の横の「+」をクリックし、メニューから「URLで追加」を選びます。

    +
    + +
    + 5. コピーしたURLを貼り付ける +

    「カレンダーのURL」の入力欄に、手順2でコピーしたURLを貼り付けます(Windowsなら Ctrl+V、Macなら ⌘+V)。そのあと「カレンダーを追加」をクリックします。

    +
    + +
    + 6. 完了 +

    左側のカレンダー一覧に「シフト管理」が追加され、自分のシフトが予定として表示されます。

    +
    +
    + +

    反映されるまでの時間について

    + +
    + ⚠️ すぐには反映されません +

    シフトを申請・削除しても、Googleカレンダーに反映されるまで最大で24時間ほどかかることがあります。これはGoogleカレンダーが情報を取りに来るタイミングによるもので、設定の失敗ではありません。

    +

    急いで最新の状態を確認したいときは、このシフト管理システムの「自分のシフト一覧」タブを見てください。こちらは常に最新です。

    +
    + +

    URLの取り扱いについて

    + +
    + ⚠️ URLは他の人に教えないでください +

    このURLには、あなた専用の合言葉(トークン)が含まれています。URLを知っている人は誰でもあなたのシフトを見られてしまうため、SNSに貼ったり、友達に送ったりしないでください。

    +

    もし他の人に知られてしまった場合は、管理者に連絡してください。

    +
    + +

    うまくいかないときは

    + +
      +
    • 「カレンダーを追加」を押してもエラーになる:URLの一部しかコピーできていない可能性があります。もう一度「コピー」ボタンを押してから貼り付け直してください。
    • +
    • カレンダーは追加されたが予定が表示されない:そもそもシフトを1件も申請していない場合、表示される予定はありません。「自分のシフト一覧」にシフトが並んでいるか確認してください。
    • +
    • 申請したシフトがカレンダーに出てこない:上に書いたとおり、反映まで最大24時間かかります。しばらく待ってから確認してください。
    • +
    • URLの欄に「ログインが必要です」と出ている:一度ログアウトして、ログインし直してください。
    • +
    +
    +
    -

    6. シフトカレンダーの見方

    +

    7. シフトカレンダーの見方

    カレンダー形式でシフトの空き状況を確認できます。

    @@ -513,7 +595,7 @@

    日付の色について

    -

    7. よくある質問

    +

    8. よくある質問

    Q: シフトを間違えて申請してしまいました。修正できますか?
    @@ -537,7 +619,7 @@

    7. よくある質問

    -

    8. トラブルシューティング

    +

    9. トラブルシューティング

    ログインできない場合

      diff --git a/index.html b/index.html index 6057ddd..28ae7d8 100644 --- a/index.html +++ b/index.html @@ -358,6 +358,11 @@ .ical-copy-btn:hover { background: #3367d6; } + .ical-help-link { + font-size: 13px; + color: #1a73e8; + white-space: nowrap; + } .sync-buttons { display: flex; gap: 10px; @@ -2799,6 +2804,12 @@
    +
    + + + + 使い方 +
    diff --git a/js/modules/api.js b/js/modules/api.js index 2e54d0c..bb80629 100644 --- a/js/modules/api.js +++ b/js/modules/api.js @@ -210,6 +210,20 @@ const API = { } }, + // ===== iCal購読関連API ===== + + // 自分専用のiCal購読URLを取得 + async getMyIcalUrl(userId) { + const response = await fetch(`${config.API_BASE_URL}/ical/my-url?userId=${encodeURIComponent(userId)}`); + return await response.json(); + }, + + // 管理者用(全シフト)のiCal購読URLを取得 + async getAllIcalUrl(email) { + const response = await fetch(`${config.API_BASE_URL}/ical/all-url?email=${encodeURIComponent(email)}`); + return await response.json(); + }, + // 指定日のカレンダーイベントを削除して再同期 async cleanAndResyncCalendarDate(date) { const controller = new AbortController(); diff --git a/js/modules/auth.js b/js/modules/auth.js index 5e9bae0..cc42407 100644 --- a/js/modules/auth.js +++ b/js/modules/auth.js @@ -102,6 +102,7 @@ async function showProfile(profileData) { ]); // 初期表示 displayMyShifts(document.getElementById('myShiftsContent'), getCurrentUserShifts()); + updateMyIcalUrl(); // 特別シフト募集お知らせを表示 displaySpecialShiftAnnouncement(); diff --git a/js/modules/config.js b/js/modules/config.js index f6c10cb..ff68122 100644 --- a/js/modules/config.js +++ b/js/modules/config.js @@ -4,7 +4,6 @@ const config = { // サーバーから取得する設定(初期値) GOOGLE_CLIENT_ID: '', AUTHORIZED_EMAILS: '', - ICAL_TOKEN: '', // サーバーURL(静的設定) API_BASE_URL: (() => { @@ -23,7 +22,6 @@ async function loadConfig() { if (result.success && result.data) { config.GOOGLE_CLIENT_ID = result.data.googleClientId; config.AUTHORIZED_EMAILS = result.data.authorizedEmails; - config.ICAL_TOKEN = result.data.icalToken || ''; return true; } else { diff --git a/js/modules/shifts.js b/js/modules/shifts.js index a553178..976dd0a 100644 --- a/js/modules/shifts.js +++ b/js/modules/shifts.js @@ -112,14 +112,28 @@ function displayCapacityOnAdminCalendar(capacityData) { }); } -// iCal購読URLを入力欄にセットする -function updateIcalUrl() { +// iCal購読URL(全シフト・管理者用)を入力欄にセットする +async function updateIcalUrl() { const input = document.getElementById('icalAllUrl'); if (!input) return; - if (config.ICAL_TOKEN) { - input.value = `${config.API_BASE_URL}/ical/all?token=${config.ICAL_TOKEN}`; - } else { - input.value = '(ICAL_TOKEN が未設定です)'; + + const currentUser = getCurrentUser(); + if (!currentUser) { + input.value = '(ログインが必要です)'; + return; + } + + try { + const result = await API.getAllIcalUrl(currentUser.email); + if (result.success && result.data && result.data.url) { + // サーバーは /api からの相対パスを返すため、絶対URLに変換して貼り付け可能にする + input.value = new URL(result.data.url, window.location.origin).href; + } else { + input.value = '(購読URLを取得できませんでした)'; + } + } catch (error) { + console.error('iCal購読URLの取得に失敗:', error); + input.value = '(購読URLを取得できませんでした)'; } } @@ -136,6 +150,59 @@ async function copyIcalUrl() { } } +// 自分専用のiCal購読URLを入力欄にセットする +async function updateMyIcalUrl() { + const input = document.getElementById('icalMyUrl'); + if (!input) return; + + const currentUser = getCurrentUser(); + if (!currentUser) { + input.value = '(ログインが必要です)'; + return; + } + + try { + const result = await API.getMyIcalUrl(currentUser.sub); + if (result.success && result.data && result.data.url) { + // サーバーは /api からの相対パスを返すため、絶対URLに変換して貼り付け可能にする + input.value = new URL(result.data.url, window.location.origin).href; + } else { + input.value = '(購読URLを取得できませんでした)'; + } + } catch (error) { + console.error('iCal購読URLの取得に失敗:', error); + input.value = '(購読URLを取得できませんでした)'; + } +} + +// 自分専用のiCal購読URLをクリップボードにコピーする +async function copyMyIcalUrl() { + const input = document.getElementById('icalMyUrl'); + if (!input || !input.value || input.value.startsWith('(')) return; + + const btn = document.querySelector('#my-shifts .ical-copy-btn'); + + try { + await navigator.clipboard.writeText(input.value); + } catch (error) { + // クリップボードが使えない環境(権限拒否など)では手動コピーを促す + console.error('クリップボードへのコピーに失敗:', error); + input.select(); + if (btn) { + const original = btn.textContent; + btn.textContent = 'Ctrl+Cでコピー'; + setTimeout(() => { btn.textContent = original; }, 3000); + } + return; + } + + if (btn) { + const original = btn.textContent; + btn.textContent = 'コピーしました'; + setTimeout(() => { btn.textContent = original; }, 1500); + } +} + // 管理者用シフト一覧を読み込む async function loadShiftList() { updateIcalUrl(); @@ -160,6 +227,8 @@ async function loadMyShifts() { const container = document.getElementById('myShiftsContent'); if (!container) return; + updateMyIcalUrl(); + const currentUser = getCurrentUser(); if (!currentUser) { container.innerHTML = '

    ログインが必要です。

    '; diff --git a/server/src/__tests__/routes/config.test.ts b/server/src/__tests__/routes/config.test.ts new file mode 100644 index 0000000..a6bc6dc --- /dev/null +++ b/server/src/__tests__/routes/config.test.ts @@ -0,0 +1,57 @@ +import request from 'supertest'; +import express, { Express } from 'express'; +import configRouter from '../../routes/config'; + +jest.mock('../../database/db', () => ({ + default: { + prepare: jest.fn().mockReturnValue({ + all: jest.fn().mockReturnValue([]), + run: jest.fn(), + get: jest.fn() + }) + } +})); + +describe('Config API Routes', () => { + let app: Express; + let originalIcalToken: string | undefined; + let consoleWarnSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + originalIcalToken = process.env.ICAL_TOKEN; + process.env.ICAL_TOKEN = 'super-secret-ical-token'; + process.env.GOOGLE_CLIENT_ID = 'client-id-123.apps.googleusercontent.com'; + process.env.AUTHORIZED_EMAILS = 'a@example.com,b@example.com'; + + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + app = express(); + app.use('/api/config', configRouter); + }); + + afterEach(() => { + consoleWarnSpy.mockRestore(); + if (originalIcalToken === undefined) { + delete process.env.ICAL_TOKEN; + } else { + process.env.ICAL_TOKEN = originalIcalToken; + } + }); + + test('Google Client ID と認可メールを返す', async () => { + const res = await request(app).get('/api/config'); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.googleClientId).toBe('client-id-123.apps.googleusercontent.com'); + expect(res.body.data.authorizedEmails).toBe('a@example.com,b@example.com'); + }); + + test('iCal の共通トークンを未ログインのクライアントへ配布しない', async () => { + const res = await request(app).get('/api/config'); + + expect(JSON.stringify(res.body)).not.toContain('super-secret-ical-token'); + expect(res.body.data.icalToken).toBeUndefined(); + }); +}); diff --git a/server/src/__tests__/routes/ical.test.ts b/server/src/__tests__/routes/ical.test.ts new file mode 100644 index 0000000..9322cbe --- /dev/null +++ b/server/src/__tests__/routes/ical.test.ts @@ -0,0 +1,195 @@ +import request from 'supertest'; +import express, { Express } from 'express'; +import icalRouter from '../../routes/ical'; +import { ICalService } from '../../services/ICalService'; +import { deriveUserIcalToken } from '../../utils/icalToken'; + +jest.mock('uuid', () => ({ v4: jest.fn(() => 'mock-uuid') })); + +jest.mock('../../services/ICalService'); +jest.mock('../../database/db', () => ({ + default: { + prepare: jest.fn().mockReturnValue({ + all: jest.fn().mockReturnValue([]), + run: jest.fn(), + get: jest.fn() + }) + } +})); + +const ADMIN_TOKEN = 'admin-secret-token'; + +describe('iCal API Routes', () => { + let app: Express; + let originalToken: string | undefined; + + beforeEach(() => { + jest.clearAllMocks(); + originalToken = process.env.ICAL_TOKEN; + process.env.ICAL_TOKEN = ADMIN_TOKEN; + + (ICalService.generateAll as jest.Mock).mockReturnValue('BEGIN:VCALENDAR\nEND:VCALENDAR'); + (ICalService.generateForUser as jest.Mock).mockReturnValue('BEGIN:VCALENDAR\nEND:VCALENDAR'); + + app = express(); + app.use('/api/ical', icalRouter); + }); + + afterEach(() => { + if (originalToken === undefined) { + delete process.env.ICAL_TOKEN; + } else { + process.env.ICAL_TOKEN = originalToken; + } + }); + + describe('GET /api/ical/all', () => { + test('管理者トークンで全シフトを返す', async () => { + const res = await request(app).get('/api/ical/all').query({ token: ADMIN_TOKEN }); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('text/calendar'); + expect(ICalService.generateAll).toHaveBeenCalled(); + }); + + test('トークンが違えば 401', async () => { + const res = await request(app).get('/api/ical/all').query({ token: 'wrong' }); + + expect(res.status).toBe(401); + expect(ICalService.generateAll).not.toHaveBeenCalled(); + }); + + test('ユーザー個別トークンでは全シフトを取得できない', async () => { + const userToken = deriveUserIcalToken('user-1') as string; + + const res = await request(app).get('/api/ical/all').query({ token: userToken }); + + expect(res.status).toBe(401); + expect(ICalService.generateAll).not.toHaveBeenCalled(); + }); + + test('ICAL_TOKEN 未設定なら 503', async () => { + delete process.env.ICAL_TOKEN; + + const res = await request(app).get('/api/ical/all').query({ token: ADMIN_TOKEN }); + + expect(res.status).toBe(503); + }); + }); + + describe('GET /api/ical/user/:user_id', () => { + test('自分用トークンで自分のシフトだけを返す', async () => { + const token = deriveUserIcalToken('user-1') as string; + + const res = await request(app).get('/api/ical/user/user-1').query({ token }); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('text/calendar'); + expect(ICalService.generateForUser).toHaveBeenCalledWith('user-1'); + }); + + test('他人の user_id に自分のトークンを付けてアクセスすると 401', async () => { + const token = deriveUserIcalToken('user-1') as string; + + const res = await request(app).get('/api/ical/user/user-2').query({ token }); + + expect(res.status).toBe(401); + expect(ICalService.generateForUser).not.toHaveBeenCalled(); + }); + + test('共通の管理者トークンでも他人のシフトは取得できない', async () => { + const res = await request(app).get('/api/ical/user/user-1').query({ token: ADMIN_TOKEN }); + + expect(res.status).toBe(401); + expect(ICalService.generateForUser).not.toHaveBeenCalled(); + }); + + test('トークンなしなら 401', async () => { + const res = await request(app).get('/api/ical/user/user-1'); + + expect(res.status).toBe(401); + expect(ICalService.generateForUser).not.toHaveBeenCalled(); + }); + + test('カレンダーアプリで購読できるよう inline で配信する', async () => { + const token = deriveUserIcalToken('user-1') as string; + + const res = await request(app).get('/api/ical/user/user-1').query({ token }); + + expect(res.headers['content-disposition']).not.toContain('attachment'); + }); + }); + + describe('GET /api/ical/all-url', () => { + beforeEach(() => { + process.env.AUTHORIZED_EMAILS = 'admin@example.com,boss@example.com'; + }); + + test('管理者のメールアドレスなら全シフトの購読URLを返す', async () => { + const res = await request(app).get('/api/ical/all-url').query({ email: 'admin@example.com' }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.url).toBe(`/api/ical/all?token=${ADMIN_TOKEN}`); + }); + + test('大文字小文字や空白が違っても管理者として扱う', async () => { + const res = await request(app).get('/api/ical/all-url').query({ email: ' Admin@Example.com ' }); + + expect(res.status).toBe(200); + }); + + test('認可されていないメールアドレスなら 403', async () => { + const res = await request(app).get('/api/ical/all-url').query({ email: 'stranger@example.com' }); + + expect(res.status).toBe(403); + expect(JSON.stringify(res.body)).not.toContain(ADMIN_TOKEN); + }); + + test('メールアドレスなしなら 403', async () => { + const res = await request(app).get('/api/ical/all-url'); + + expect(res.status).toBe(403); + expect(JSON.stringify(res.body)).not.toContain(ADMIN_TOKEN); + }); + + test('ICAL_TOKEN 未設定なら 503', async () => { + delete process.env.ICAL_TOKEN; + + const res = await request(app).get('/api/ical/all-url').query({ email: 'admin@example.com' }); + + expect(res.status).toBe(503); + }); + }); + + describe('GET /api/ical/my-url', () => { + test('userId を渡すとその人専用の購読URLを返す', async () => { + const res = await request(app).get('/api/ical/my-url').query({ userId: 'user-1' }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.url).toContain('/api/ical/user/user-1?token='); + expect(res.body.data.url).toContain(deriveUserIcalToken('user-1') as string); + }); + + test('返す URL に共通の ICAL_TOKEN は含まれない', async () => { + const res = await request(app).get('/api/ical/my-url').query({ userId: 'user-1' }); + + expect(res.body.data.url).not.toContain(ADMIN_TOKEN); + }); + + test('userId がなければ 400', async () => { + const res = await request(app).get('/api/ical/my-url'); + + expect(res.status).toBe(400); + }); + + test('ICAL_TOKEN 未設定なら 503', async () => { + delete process.env.ICAL_TOKEN; + + const res = await request(app).get('/api/ical/my-url').query({ userId: 'user-1' }); + + expect(res.status).toBe(503); + }); + }); +}); diff --git a/server/src/__tests__/utils/icalToken.test.ts b/server/src/__tests__/utils/icalToken.test.ts new file mode 100644 index 0000000..8dac9c1 --- /dev/null +++ b/server/src/__tests__/utils/icalToken.test.ts @@ -0,0 +1,80 @@ +import { deriveUserIcalToken, verifyUserIcalToken } from '../../utils/icalToken'; + +describe('icalToken', () => { + const SECRET = 'test-secret-value'; + let originalToken: string | undefined; + + beforeEach(() => { + originalToken = process.env.ICAL_TOKEN; + process.env.ICAL_TOKEN = SECRET; + }); + + afterEach(() => { + if (originalToken === undefined) { + delete process.env.ICAL_TOKEN; + } else { + process.env.ICAL_TOKEN = originalToken; + } + }); + + describe('deriveUserIcalToken()', () => { + test('同じ user_id からは同じトークンが導出される(購読URLが安定する)', () => { + expect(deriveUserIcalToken('user-1')).toBe(deriveUserIcalToken('user-1')); + }); + + test('異なる user_id からは異なるトークンが導出される', () => { + expect(deriveUserIcalToken('user-1')).not.toBe(deriveUserIcalToken('user-2')); + }); + + test('ICAL_TOKEN(サーバー秘密鍵)が変わるとトークンも変わる(失効できる)', () => { + const before = deriveUserIcalToken('user-1'); + process.env.ICAL_TOKEN = 'rotated-secret'; + expect(deriveUserIcalToken('user-1')).not.toBe(before); + }); + + test('導出されたトークンからサーバー秘密鍵が推測できない(秘密鍵を含まない)', () => { + expect(deriveUserIcalToken('user-1')).not.toContain(SECRET); + }); + + test('URL に安全な文字列を返す', () => { + expect(deriveUserIcalToken('user-1')).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + test('ICAL_TOKEN が未設定なら null を返す', () => { + delete process.env.ICAL_TOKEN; + expect(deriveUserIcalToken('user-1')).toBeNull(); + }); + }); + + describe('verifyUserIcalToken()', () => { + test('正しい user_id とトークンの組み合わせを受け入れる', () => { + const token = deriveUserIcalToken('user-1') as string; + expect(verifyUserIcalToken('user-1', token)).toBe(true); + }); + + test('他人のトークンで別の user_id にアクセスすると拒否する', () => { + const otherToken = deriveUserIcalToken('user-2') as string; + expect(verifyUserIcalToken('user-1', otherToken)).toBe(false); + }); + + test('共通の ICAL_TOKEN をそのまま渡しても拒否する', () => { + expect(verifyUserIcalToken('user-1', SECRET)).toBe(false); + }); + + test('トークンが空や undefined なら拒否する', () => { + expect(verifyUserIcalToken('user-1', '')).toBe(false); + expect(verifyUserIcalToken('user-1', undefined)).toBe(false); + }); + + test('ICAL_TOKEN が未設定なら拒否する', () => { + const token = deriveUserIcalToken('user-1') as string; + delete process.env.ICAL_TOKEN; + expect(verifyUserIcalToken('user-1', token)).toBe(false); + }); + + test('長さの異なるトークンでも例外を投げずに拒否する', () => { + expect(() => verifyUserIcalToken('user-1', 'short')).not.toThrow(); + expect(verifyUserIcalToken('user-1', 'short')).toBe(false); + }); + }); +}); diff --git a/server/src/routes/config.ts b/server/src/routes/config.ts index afec211..7c50ab3 100644 --- a/server/src/routes/config.ts +++ b/server/src/routes/config.ts @@ -8,10 +8,13 @@ const router = Router(); */ router.get('/', (req: Request, res: Response) => { try { + // 注意: ICAL_TOKEN はここで返さない。 + // このエンドポイントはログイン前の全ブラウザから取得できるため、 + // 共通トークンを返すと全員分のシフトを誰でも購読できてしまう。 + // 購読URLは GET /api/ical/my-url でユーザーごとに発行する。 const config = { googleClientId: process.env.GOOGLE_CLIENT_ID || '', authorizedEmails: process.env.AUTHORIZED_EMAILS || '', - icalToken: process.env.ICAL_TOKEN || '', }; // 必須設定のバリデーション diff --git a/server/src/routes/ical.ts b/server/src/routes/ical.ts index 2e5112f..99a64c1 100644 --- a/server/src/routes/ical.ts +++ b/server/src/routes/ical.ts @@ -1,9 +1,14 @@ import express from 'express'; import { ICalService } from '../services/ICalService'; +import { deriveUserIcalToken, verifyUserIcalToken } from '../utils/icalToken'; const router = express.Router(); -function validateToken(req: express.Request, res: express.Response): boolean { +/** + * 管理者用トークン(ICAL_TOKEN そのもの)を検証する + * 全ユーザー分のシフトを返すエンドポイントでのみ使用する + */ +function validateAdminToken(req: express.Request, res: express.Response): boolean { const token = process.env.ICAL_TOKEN; if (!token) { res.status(503).json({ error: 'ICAL_TOKEN が設定されていません' }); @@ -18,27 +23,94 @@ function validateToken(req: express.Request, res: express.Response): boolean { function sendIcal(res: express.Response, icsContent: string): void { res.setHeader('Content-Type', 'text/calendar; charset=utf-8'); - res.setHeader('Content-Disposition', 'attachment; filename="shifts.ics"'); + // カレンダーアプリからの購読を想定しているため inline で返す + res.setHeader('Content-Disposition', 'inline; filename="shifts.ics"'); res.setHeader('Cache-Control', 'max-age=900'); res.send(icsContent); } /** - * GET /api/ical/all?token= + * GET /api/ical/all?token= * 全ユーザーの全シフトを iCal 形式で返す(管理者用) */ router.get('/all', (req, res) => { - if (!validateToken(req, res)) return; + if (!validateAdminToken(req, res)) return; sendIcal(res, ICalService.generateAll()); }); /** - * GET /api/ical/user/:user_id?token= + * 認可メールアドレス(管理者)かどうかを判定する + */ +function isAuthorizedEmail(email: unknown): boolean { + if (typeof email !== 'string' || email === '') return false; + + const authorized = (process.env.AUTHORIZED_EMAILS || '') + .split(',') + .map(e => e.trim().toLowerCase()) + .filter(e => e !== ''); + + return authorized.includes(email.trim().toLowerCase()); +} + +/** + * GET /api/ical/all-url?email=<管理者のメールアドレス> + * 管理者用(全シフト)の購読URLを返す + */ +router.get('/all-url', (req, res) => { + if (!isAuthorizedEmail(req.query.email)) { + res.status(403).json({ success: false, error: '管理者権限が必要です' }); + return; + } + + const token = process.env.ICAL_TOKEN; + if (!token) { + res.status(503).json({ success: false, error: 'ICAL_TOKEN が設定されていません' }); + return; + } + + res.json({ success: true, data: { url: `/api/ical/all?token=${token}` } }); +}); + +/** + * GET /api/ical/my-url?userId= + * ログインユーザー本人の購読URLを返す + */ +router.get('/my-url', (req, res) => { + const userId = req.query.userId; + if (typeof userId !== 'string' || userId === '') { + res.status(400).json({ success: false, error: 'userId が指定されていません' }); + return; + } + + const token = deriveUserIcalToken(userId); + if (!token) { + res.status(503).json({ success: false, error: 'ICAL_TOKEN が設定されていません' }); + return; + } + + res.json({ + success: true, + data: { url: `/api/ical/user/${encodeURIComponent(userId)}?token=${token}` }, + }); +}); + +/** + * GET /api/ical/user/:user_id?token=<ユーザー個別トークン> * 特定ユーザーのシフトを iCal 形式で返す + * トークンは user_id と対でのみ有効(他人の user_id には使えない) */ router.get('/user/:user_id', (req, res) => { - if (!validateToken(req, res)) return; + if (!process.env.ICAL_TOKEN) { + res.status(503).json({ error: 'ICAL_TOKEN が設定されていません' }); + return; + } + const userId = req.params.user_id as string; + if (!verifyUserIcalToken(userId, req.query.token)) { + res.status(401).json({ error: '認証トークンが無効です' }); + return; + } + sendIcal(res, ICalService.generateForUser(userId)); }); diff --git a/server/src/utils/icalToken.ts b/server/src/utils/icalToken.ts new file mode 100644 index 0000000..2f458fd --- /dev/null +++ b/server/src/utils/icalToken.ts @@ -0,0 +1,46 @@ +import crypto from 'crypto'; + +/** + * iCal 購読トークンのユーティリティ + * + * 購読URLはカレンダーアプリに貼り付けられ、ユーザーの手元に長く残る。 + * そのため全員で共通のトークンを使うと、URL の user_id を書き換えるだけで + * 他人のシフトが読めてしまう。ここではサーバー秘密鍵(ICAL_TOKEN)と + * user_id から HMAC でユーザーごとのトークンを導出し、user_id と対でのみ + * 有効になるようにする。 + * + * - DB マイグレーション不要(保存せず、都度導出する) + * - ICAL_TOKEN を再生成すれば全ユーザーの購読URLを一括失効できる + */ + +/** + * user_id 専用の購読トークンを導出する + * @returns ICAL_TOKEN が未設定の場合は null + */ +export function deriveUserIcalToken(userId: string): string | null { + const secret = process.env.ICAL_TOKEN; + if (!secret) return null; + + return crypto + .createHmac('sha256', secret) + .update(`ical-user:${userId}`) + .digest('base64url'); +} + +/** + * user_id とトークンの組み合わせが正しいか検証する + * タイミング攻撃を避けるため timingSafeEqual で比較する + */ +export function verifyUserIcalToken(userId: string, token: unknown): boolean { + if (typeof token !== 'string' || token.length === 0) return false; + + const expected = deriveUserIcalToken(userId); + if (!expected) return false; + + const expectedBuf = Buffer.from(expected); + const actualBuf = Buffer.from(token); + // timingSafeEqual は長さが違うと例外を投げるため、先に長さを確認する + if (expectedBuf.length !== actualBuf.length) return false; + + return crypto.timingSafeEqual(expectedBuf, actualBuf); +} diff --git a/test/admin-ical-url.test.js b/test/admin-ical-url.test.js new file mode 100644 index 0000000..f31b2f7 --- /dev/null +++ b/test/admin-ical-url.test.js @@ -0,0 +1,93 @@ +/** + * 管理者用iCal購読URL(全シフト)表示のテスト + * + * 回帰防止: /api/config から共通トークンの配布をやめた際、 + * 管理者の購読URL欄が常に「未設定」表示になる不具合が発生した。 + * 購読URLはサーバーから取得する方式に変更されている。 + */ + +describe('管理者用iCal購読URL(全シフト)', () => { + let mockAPI; + + // 実装(js/modules/shifts.js の updateIcalUrl)と同じ挙動 + async function updateIcalUrl() { + const input = document.getElementById('icalAllUrl'); + if (!input) return; + + const currentUser = global.getCurrentUser(); + if (!currentUser) { + input.value = '(ログインが必要です)'; + return; + } + + try { + const result = await global.API.getAllIcalUrl(currentUser.email); + if (result.success && result.data && result.data.url) { + input.value = new URL(result.data.url, window.location.origin).href; + } else { + input.value = '(購読URLを取得できませんでした)'; + } + } catch (error) { + input.value = '(購読URLを取得できませんでした)'; + } + } + + beforeEach(() => { + document.body.innerHTML = ` +
    +
    + + +
    +
    + `; + + mockAPI = { getAllIcalUrl: jest.fn() }; + global.API = mockAPI; + global.getCurrentUser = jest.fn(() => ({ sub: 'dev-admin-001', email: 'admin@example.com' })); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('サーバーから取得したURLを表示する', async () => { + mockAPI.getAllIcalUrl.mockResolvedValue({ + success: true, + data: { url: '/api/ical/all?token=admin-token-abc' } + }); + + await updateIcalUrl(); + + const value = document.getElementById('icalAllUrl').value; + expect(value).toContain('/api/ical/all?token=admin-token-abc'); + expect(value.startsWith('http')).toBe(true); + }); + + test('「ICAL_TOKEN が未設定です」の固定表示にならない', async () => { + mockAPI.getAllIcalUrl.mockResolvedValue({ + success: true, + data: { url: '/api/ical/all?token=admin-token-abc' } + }); + + await updateIcalUrl(); + + expect(document.getElementById('icalAllUrl').value).not.toContain('ICAL_TOKEN が未設定'); + }); + + test('取得に失敗してもURL欄が壊れない', async () => { + mockAPI.getAllIcalUrl.mockResolvedValue({ success: false, error: 'ICAL_TOKEN 未設定' }); + + await updateIcalUrl(); + + expect(document.getElementById('icalAllUrl').value).toBe('(購読URLを取得できませんでした)'); + }); + + test('通信が失敗してもURL欄が壊れない', async () => { + mockAPI.getAllIcalUrl.mockRejectedValue(new Error('network error')); + + await updateIcalUrl(); + + expect(document.getElementById('icalAllUrl').value).toBe('(購読URLを取得できませんでした)'); + }); +}); diff --git a/test/my-shifts-ical-url.test.js b/test/my-shifts-ical-url.test.js new file mode 100644 index 0000000..f2776d9 --- /dev/null +++ b/test/my-shifts-ical-url.test.js @@ -0,0 +1,224 @@ +/** + * 自分のシフトのiCal購読URL表示のテスト + * + * 目的: 「自分のシフト一覧」タブに表示する購読URLが + * - 本人の user_id とサーバー発行トークンで組み立てられること + * - 共通トークンをフロントで組み立てないこと + * - 取得失敗時にURL欄が壊れないこと + * を検証する + */ + +describe('自分のシフトのiCal購読URL', () => { + let mockAPI; + let mockCurrentUser; + + // 実装(js/modules/shifts.js の updateMyIcalUrl)と同じ挙動 + async function updateMyIcalUrl() { + const input = document.getElementById('icalMyUrl'); + if (!input) return; + + const currentUser = global.getCurrentUser(); + if (!currentUser) { + input.value = '(ログインが必要です)'; + return; + } + + try { + const result = await global.API.getMyIcalUrl(currentUser.sub); + if (result.success && result.data && result.data.url) { + input.value = new URL(result.data.url, window.location.origin).href; + } else { + input.value = '(購読URLを取得できませんでした)'; + } + } catch (error) { + input.value = '(購読URLを取得できませんでした)'; + } + } + + beforeEach(() => { + document.body.innerHTML = ` +
    +
    + + +
    +
    +
    + `; + + mockCurrentUser = { sub: 'dev-user-001', email: 'a@example.com', name: 'ユーザーA' }; + mockAPI = { getMyIcalUrl: jest.fn() }; + + global.API = mockAPI; + global.getCurrentUser = jest.fn(() => mockCurrentUser); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('サーバーが返したURLを絶対URLにしてURL欄へ表示する', async () => { + mockAPI.getMyIcalUrl.mockResolvedValue({ + success: true, + data: { url: '/api/ical/user/dev-user-001?token=abc123' } + }); + + await updateMyIcalUrl(); + + const value = document.getElementById('icalMyUrl').value; + expect(value).toContain('/api/ical/user/dev-user-001?token=abc123'); + expect(value.startsWith('http')).toBe(true); + }); + + test('ログイン中のユーザーIDでサーバーに問い合わせる', async () => { + mockAPI.getMyIcalUrl.mockResolvedValue({ + success: true, + data: { url: '/api/ical/user/dev-user-001?token=abc123' } + }); + + await updateMyIcalUrl(); + + expect(mockAPI.getMyIcalUrl).toHaveBeenCalledWith('dev-user-001'); + }); + + test('URLは全シフト用エンドポイントを指さない', async () => { + mockAPI.getMyIcalUrl.mockResolvedValue({ + success: true, + data: { url: '/api/ical/user/dev-user-001?token=abc123' } + }); + + await updateMyIcalUrl(); + + expect(document.getElementById('icalMyUrl').value).not.toContain('/ical/all'); + }); + + test('未ログインならサーバーに問い合わせずメッセージを表示する', async () => { + global.getCurrentUser = jest.fn(() => null); + + await updateMyIcalUrl(); + + expect(mockAPI.getMyIcalUrl).not.toHaveBeenCalled(); + expect(document.getElementById('icalMyUrl').value).toBe('(ログインが必要です)'); + }); + + test('サーバーがエラーを返してもURL欄が壊れない', async () => { + mockAPI.getMyIcalUrl.mockResolvedValue({ success: false, error: 'ICAL_TOKEN 未設定' }); + + await updateMyIcalUrl(); + + expect(document.getElementById('icalMyUrl').value).toBe('(購読URLを取得できませんでした)'); + }); + + test('通信が失敗してもURL欄が壊れない', async () => { + mockAPI.getMyIcalUrl.mockRejectedValue(new Error('network error')); + + await updateMyIcalUrl(); + + expect(document.getElementById('icalMyUrl').value).toBe('(購読URLを取得できませんでした)'); + }); +}); + +describe('購読URLのコピー', () => { + // 実装(js/modules/shifts.js の copyMyIcalUrl)と同じ挙動 + async function copyMyIcalUrl() { + const input = document.getElementById('icalMyUrl'); + if (!input || !input.value || input.value.startsWith('(')) return; + + const btn = document.querySelector('#my-shifts .ical-copy-btn'); + + try { + await navigator.clipboard.writeText(input.value); + } catch (error) { + input.select(); + if (btn) { + const original = btn.textContent; + btn.textContent = 'Ctrl+Cでコピー'; + setTimeout(() => { btn.textContent = original; }, 3000); + } + return; + } + + if (btn) { + const original = btn.textContent; + btn.textContent = 'コピーしました'; + setTimeout(() => { btn.textContent = original; }, 1500); + } + } + + let writeText; + // test/setup.js の setTimeout スタブはコールバックを即座に実行するため、 + // 「一時的に変わった文言」は関数を抜けた時点では元に戻っている。 + // 戻す直前の文言を記録して検証する。 + let labelsBeforeRevert; + + beforeEach(() => { + labelsBeforeRevert = []; + global.setTimeout = jest.fn((cb) => { + if (typeof cb === 'function') { + const btn = document.querySelector('#my-shifts .ical-copy-btn'); + if (btn) labelsBeforeRevert.push(btn.textContent); + cb(); + } + return 1; + }); + }); + + beforeEach(() => { + document.body.innerHTML = ` +
    +
    + + +
    +
    + `; + document.getElementById('icalMyUrl').select = jest.fn(); + + writeText = jest.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('URL欄の内容をクリップボードにコピーする', async () => { + const url = 'http://localhost:3100/api/ical/user/dev-user-001?token=abc123'; + document.getElementById('icalMyUrl').value = url; + + await copyMyIcalUrl(); + + expect(writeText).toHaveBeenCalledWith(url); + }); + + test('コピー後にボタンの文言が「コピーしました」に変わり、その後元に戻る', async () => { + document.getElementById('icalMyUrl').value = 'http://localhost:3100/api/ical/user/dev-user-001?token=abc123'; + + await copyMyIcalUrl(); + + expect(labelsBeforeRevert).toContain('コピーしました'); + expect(document.querySelector('#my-shifts .ical-copy-btn').textContent).toBe('コピー'); + }); + + test('URLがまだ取得できていないときはコピーしない', async () => { + document.getElementById('icalMyUrl').value = '(購読URLを取得できませんでした)'; + + await copyMyIcalUrl(); + + expect(writeText).not.toHaveBeenCalled(); + }); + + test('クリップボードが使えなくても例外を投げず手動コピーを促す', async () => { + document.getElementById('icalMyUrl').value = 'http://localhost:3100/api/ical/user/dev-user-001?token=abc123'; + writeText.mockRejectedValue(new DOMException('Write permission denied.', 'NotAllowedError')); + jest.spyOn(console, 'error').mockImplementation(); + + await expect(copyMyIcalUrl()).resolves.toBeUndefined(); + + expect(labelsBeforeRevert).toContain('Ctrl+Cでコピー'); + expect(document.getElementById('icalMyUrl').select).toHaveBeenCalled(); + }); +});