First commit

This commit is contained in:
CyberRex
2026-05-23 17:03:05 +09:00
commit 40e7953ee5
52 changed files with 13004 additions and 0 deletions

8
.env.example Normal file
View File

@@ -0,0 +1,8 @@
NODE_ENV=development
PORT=3000
DATABASE_URL=postgres://certremind:certremind@localhost:5432/certremind
COOKIE_SECRET=replace-with-a-long-random-string
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:admin@example.com
OPENSSL_PATH=openssl

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
.pnpm-store/
dist/
.env
*.log

5
.prettierrc Normal file
View File

@@ -0,0 +1,5 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100
}

372
AGENTS.md Normal file
View File

@@ -0,0 +1,372 @@
# CertRemind 開発ガイド
## 概要
- アプリケーション名: CertRemind
- 目的: ユーザーが登録した Web サイトの TLS/SSL 証明書の有効期限を監視し、期限切れ前にアプリ内アラート、Webhook、Push 通知で知らせる。
- 現在の実装状況: `development_plan.md` のフェーズ 8 までの機能を実装済み。MVP の主要機能に加え、基本的な品質向上、テスト、運用準備も整備済み。
## 技術スタック
- Node.js v22
- pnpm
- Hono.js
- React.js / Vite
- Radix UI
- lucide-react
- PostgreSQL
- Docker Compose
- OpenSSL
- Argon2id
- TOTP: `otplib`
- Push 通知: `web-push`
- QR コード: `qrcode.react`
- テスト: Vitest
- Lint / Format: ESLint / Prettier
## 主要コマンド
```text
pnpm install
docker compose up -d postgres
pnpm dev
pnpm monitor:once
pnpm monitor:worker
pnpm lint
pnpm test
pnpm exec vite build
pnpm format
```
開発サーバー:
```text
Frontend: http://127.0.0.1:5173/
API: http://127.0.0.1:3000
```
## ディレクトリ構成
```text
db/schema.sql
README.md
public/push-sw.js
src/client
src/client/api/client.js
src/client/components
src/client/components/Toast.jsx
src/client/routes
src/client/styles/app.css
src/server
src/server/app.js
src/server/index.js
src/server/config/env.js
src/server/db/pool.js
src/server/middleware
src/server/modules
src/server/jobs/monitorCertificates.js
src/server/jobs/monitorWorker.js
src/server/utils/logger.js
tests/apiSecurity.test.js
tests/monitoring.test.js
tests/urlPolicy.test.js
```
## 環境変数
`.env.example` を参照すること。
主な項目:
- `NODE_ENV`
- `PORT`
- `DATABASE_URL`
- `COOKIE_SECRET`
- `VAPID_PUBLIC_KEY`
- `VAPID_PRIVATE_KEY`
- `VAPID_SUBJECT`
- `OPENSSL_PATH`
補足:
- `OPENSSL_PATH` 未設定時は `openssl` を使う。
- Windows では Git 付属の `openssl.exe` を自動検出する実装がある。
- VAPID private key が未設定の場合、Push 実送信は失敗として `delivery_result` に記録される。
## データベース
DDL は `db/schema.sql` に保管している。
実装済みテーブル:
- `users`
- `user_totp`
- `sessions`
- `sites`
- `notification_methods`
- `site_alert_conditions`
- `alert_history`
設計メモ:
- 主キーは UUID。
- 全テーブルに `created_at` / `updated_at` を持つ。
- `updated_at` はトリガーで更新する。
- `sites` は最新の証明書期限、確認日時、取得失敗内容を保持する。
- ユーザー関連データは `users` 削除を起点に CASCADE で削除する。
- サイト削除時、通知条件は CASCADE で削除する。
- アラート履歴の `site_id` はサイト削除時に `NULL` へ変更する。
- アラート重複抑制は `alert_history.dedupe_key` を使う。
注意:
- `docker-compose.yml` は初回 DB 起動時に `db/schema.sql` を読み込む。既存 volume がある場合、schema の変更は自動再適用されない。
## 実装済み API
```text
GET /api/health
GET /api/auth/csrf
POST /api/auth/register
POST /api/auth/login
POST /api/auth/logout
GET /api/auth/me
GET /api/sites
POST /api/sites
GET /api/sites/:siteId
PATCH /api/sites/:siteId
DELETE /api/sites/:siteId
GET /api/sites/:siteId/settings
PUT /api/sites/:siteId/settings
DELETE /api/sites/:siteId/settings
GET /api/alerts
PATCH /api/alerts/:alertId/read
DELETE /api/alerts/:alertId
GET /api/notification-methods
POST /api/notification-methods/webhooks
PATCH /api/notification-methods/webhooks/:methodId
DELETE /api/notification-methods/webhooks/:methodId
POST /api/notification-methods/push-subscriptions
GET /api/account
PATCH /api/account/profile
PATCH /api/account/password
POST /api/account/totp/setup
POST /api/account/totp/verify
DELETE /api/account/totp
DELETE /api/account
```
## 実装済み画面
認証前:
- 登録画面
- ログイン画面
- 2 段階認証コード入力
認証後:
- URL ルーティング
- `/sites`
- `/sites/:siteId/settings`
- `/alerts`
- `/notifications`
- `/account`
- `/login`
- `/register`
- 直リンク、初期読込み、ブラウザバック / フォワードに対応
- 未認証で保護画面 URL を開いた場合はログイン画面を表示し、ログイン後に元の画面へ復帰する
- 左サイドメニュー
- PC 幅では展開表示
- 720px 以下ではアイコンのみの畳み表示
- サイト一覧、アラート履歴、通知方法、アカウント、ログアウトに対応
- 共通トースト通知
- 認証後画面の正常メッセージ、エラーメッセージを右上に表示
- 5 秒後の自動非表示と手動クローズに対応
- ログイン / 登録画面のエラーはインライン表示を維持
- サイト一覧
- サイト追加
- 最新監視結果に基づく証明書期限表示
- 確認ダイアログ付きサイト削除
- サイト設定
- エイリアス編集
- 通知タイミング設定
- 時間 / 日 / 週間の単位指定
- 複数タイミングの追加
- 確認ダイアログ付き通知タイミング削除
- アプリ内アラート必須表示
- Webhook 選択
- Push 通知フラグ設定
- 確認ダイアログ付き設定削除
- アラート一覧
- サイト絞り込み
- アラート種類絞り込み
- 開始日時 / 終了日時絞り込み
- 既読更新
- 確認ダイアログ付き履歴削除
- 通知方法管理
- Webhook 登録
- Webhook 編集
- 確認ダイアログ付き Webhook 削除
- ブラウザ Push 通知の許可状態表示
- VAPID public key がある場合の Push 購読登録
- アカウント設定
- 表示名更新
- ダイアログでのパスワード更新
- パスワード更新後の全セッション無効化
- ステップ式ポップアップでの 2 段階認証セットアップ
- 2 段階認証 QR コード表示
- シークレット文字列表示
- 確認ダイアログ付き 2 段階認証解除
- 確認ダイアログ付きアカウント削除
## 入力検証
サーバー側:
- Zod でリクエストを検証する。
- クライアントから送られる `user_id` は信用しない。
- 認証済み API はセッションのユーザー ID を使う。
- SQL はプレースホルダを使う。
クライアント側:
- 登録 / ログイン、サイト、Webhook、アラート絞り込み、アカウント設定で入力必須チェックと形式チェックを実装済み。
- クライアント側検証は UX 用であり、サーバー側検証を省略してはいけない。
## 認証・セッション
- パスワードは Argon2id でハッシュ化する。
- セッションは `sessions` テーブルで管理する。
- セッション Cookie は HttpOnly / SameSite=Lax。
- 本番環境では Secure Cookie を有効化する。
- CSRF トークンを状態変更 API で必須にする。
- パスワード更新時は対象ユーザーの全セッションを削除し、現在の Cookie も削除する。
- TOTP が有効なユーザーはログイン時に OTP 検証が必須。
## サイト管理
- サイト URL は HTTPS のみ許可する。
- URL は `normalizeHttpsUrl` で正規化する。
- `localhost`、private IPv4、loopback IPv4 は拒否する。
- サイト API は常にセッションユーザーの所有データのみ扱う。
- サイト設定の通知タイミングは内部的に時間単位で保存する。
- 通知タイミングは 1 時間以上、17520 時間以内。
- 同じサイトに同じ通知タイミングを重複登録できない。
## アラート履歴
- アラート履歴は `alert_history` に保存する。
- アラート一覧はログインユーザーの履歴のみ返す。
- 既読更新、削除もログインユーザーの履歴のみ対象にする。
- 絞り込み条件:
- サイト
- アラート種類
- 開始日時
- 終了日時
- アラート重複抑制には `dedupe_key` を使う。
## 通知方法
Webhook:
- HTTPS のみ許可する。
- `normalizeHttpsUrl` を通し、localhost / private IPv4 / loopback IPv4 を拒否する。
- Slack 互換 Webhook として送信する。
- 更新・削除はログインユーザーの通知方法のみ対象。
Push:
- ブラウザ Push 購読情報を `notification_methods` に保存する。
- endpoint は HTTPS のみ許可する。
- Service Worker は `public/push-sw.js`
- VAPID key は環境変数で管理する。
## 証明書監視ジョブ
一回実行:
```text
pnpm monitor:once
```
定期実行 worker:
```text
pnpm monitor:worker
```
処理内容:
- 登録サイトを取得する。
- OpenSSL で証明書期限を取得する。
- 成功時は `sites.certificate_expires_at` / `certificate_checked_at` を更新し、取得失敗内容をクリアする。
- 失敗時は `sites.certificate_checked_at` / `certificate_check_error` を更新し、既存の期限値は残す。
- サイトごとの通知条件を評価する。
- 条件一致時にアラート履歴を作成する。
- Webhook / Push 通知を送信する。
- 証明書取得失敗も `certificate_check_failed` としてアラート化する。
- サイト単位の失敗で全体を止めない。
- 外部通信を伴う監視処理は並列数を制限する。
- Webhook / Push の送信失敗は `delivery_result` に記録する。
- 監視ジョブの開始、終了、失敗は構造化ログで出力する。
- `pnpm monitor:worker` は 1 時間ごとに監視ジョブを実行する長時間起動プロセス。
注意:
- サンドボックス内では外部 TLS 接続が `Permission denied` になる場合がある。その場合は権限付き実行が必要。
- 定期実行は `pnpm monitor:worker` で提供済み。運用要件に応じて cron やタスクスケジューラから `pnpm monitor:once` を実行する構成も選択できる。
## セキュリティ方針
- リクエスト改ざんに注意する。
- クライアントから送信されてきたデータを信頼しない。
- CSRF トークンを使用する。
- パスワードを平文保存しない。
- SQL インジェクションを避けるためプレースホルダを使う。
- 認可はセッションユーザーを基準に判定する。
- URL ルーティングは表示状態の復元用であり、認可は必ず API 側のセッションユーザー基準で判定する。
- 未認証時の復帰先 URL はアプリ内の許可済みパスのみ扱い、外部 URL へのリダイレクトに使わない。
- 1 リクエストがサーバー全体の処理をブロックしないようにする。
- 外部通信はタイムアウトや並列数制限を設定する。
- 削除操作には確認ダイアログを置く。
- API の未捕捉エラーは構造化ログで記録する。
## テスト・運用準備
- `tests/urlPolicy.test.js` で URL 正規化と基本的な SSRF 対策を検証する。
- `tests/apiSecurity.test.js` で CSRF、認証必須、セッションユーザー基準の認可、Webhook URL 検証、通知条件の所有者確認を検証する。
- `tests/monitoring.test.js` で証明書監視成功 / 失敗時の DB 更新、アラート作成、通知呼び出しを検証する。
- `README.md` に開発環境起動手順、検証コマンド、DB 注意点、環境変数一覧を記載済み。
- サンドボックス環境では Vite / Vitest の設定解決で権限付き実行が必要になる場合がある。
## 既知の強化候補
- API 単体テスト / 統合テストをさらに増やす。
- ログイン試行回数制限を追加する。
- SSRF 対策として DNS 解決後の IP チェックを追加する。
- IPv6 private / link-local / unique local address の検証を追加する。
- セッションローテーションを追加する。
- E2E テストを追加する。
## 開発時の注意
- `development_status.md` は作業後に更新する。
- DB 変更を行う場合は `db/schema.sql` も更新する。
- API を追加したら、認証、CSRF、認可、入力検証を確認する。
- UI の削除操作には確認ダイアログを追加する。
- 既存の左サイドメニューとレスポンシブ挙動を崩さない。
- フロントエンドの入力チェックを追加しても、サーバー側検証を必ず維持する。
- 変更後は少なくとも以下を実行する。
```text
pnpm lint
pnpm test
pnpm exec vite build
```

75
README.md Normal file
View File

@@ -0,0 +1,75 @@
# CertRemind
CertRemind monitors the TLS/SSL certificate expiry dates of registered HTTPS sites and sends reminders through in-app alerts, webhooks, and browser push notifications.
## Requirements
- Node.js v22
- pnpm
- Docker Compose
- PostgreSQL
- OpenSSL
## Development Setup
```text
pnpm install
docker compose up -d postgres
pnpm dev
```
Development URLs:
```text
Frontend: http://127.0.0.1:5173/
API: http://127.0.0.1:3000
```
Run the one-shot certificate monitor:
```text
pnpm monitor:once
```
Run the certificate monitor worker every hour:
```text
pnpm monitor:worker
```
Run quality checks:
```text
pnpm lint
pnpm test
pnpm exec vite build
```
## Database
The initial schema is in `db/schema.sql`. Docker Compose loads it when the PostgreSQL volume is first created.
If an existing database volume is already present, schema changes are not reapplied automatically. Apply the relevant `ALTER TABLE` statements from `db/schema.sql`, or recreate the development volume when data loss is acceptable.
## Environment Variables
Copy `.env.example` to `.env` for local development.
| Name | Required | Default | Purpose |
| --- | --- | --- | --- |
| `NODE_ENV` | No | `development` | Runtime mode. `production` enables secure cookies. |
| `PORT` | No | `3000` | API server port. |
| `DATABASE_URL` | No | `postgres://certremind:certremind@localhost:5432/certremind` | PostgreSQL connection string. |
| `COOKIE_SECRET` | Reserved | none | Reserved for future signed-cookie support. |
| `VAPID_PUBLIC_KEY` | For Push | empty | Browser Push public key. |
| `VAPID_PRIVATE_KEY` | For Push | empty | Browser Push private key. Push delivery fails gracefully if missing. |
| `VAPID_SUBJECT` | For Push | `mailto:admin@example.com` | VAPID contact subject. |
| `OPENSSL_PATH` | No | `openssl` | OpenSSL executable path. On Windows, the app can also detect Git's bundled `openssl.exe`. |
## Operational Notes
- Run `pnpm monitor:worker` as a long-lived Node process for hourly certificate checks.
- `pnpm monitor:once` remains available for manual checks or external schedulers.
- The monitor limits concurrent external certificate checks and records per-site failures without stopping the whole run.
- Webhook URLs and monitored site URLs must be HTTPS and reject localhost/private IPv4 targets.
- Existing browser Push subscriptions require valid VAPID keys to deliver successfully.

144
db/schema.sql Normal file
View File

@@ -0,0 +1,144 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TABLE IF NOT EXISTS users (
user_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
username text NOT NULL UNIQUE,
password_hash text NOT NULL,
display_name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
DROP TRIGGER IF EXISTS users_set_updated_at ON users;
CREATE TRIGGER users_set_updated_at
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE IF NOT EXISTS user_totp (
user_id uuid PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
otp_secret text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
DROP TRIGGER IF EXISTS user_totp_set_updated_at ON user_totp;
CREATE TRIGGER user_totp_set_updated_at
BEFORE UPDATE ON user_totp
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE IF NOT EXISTS sessions (
session_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS sessions_user_id_idx ON sessions(user_id);
CREATE INDEX IF NOT EXISTS sessions_expires_at_idx ON sessions(expires_at);
DROP TRIGGER IF EXISTS sessions_set_updated_at ON sessions;
CREATE TRIGGER sessions_set_updated_at
BEFORE UPDATE ON sessions
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE IF NOT EXISTS sites (
site_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
url text NOT NULL,
alias text NOT NULL,
certificate_issuer text,
certificate_issued_at timestamptz,
certificate_expires_at timestamptz,
certificate_checked_at timestamptz,
certificate_check_error text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, url)
);
ALTER TABLE sites
ADD COLUMN IF NOT EXISTS certificate_issuer text,
ADD COLUMN IF NOT EXISTS certificate_issued_at timestamptz,
ADD COLUMN IF NOT EXISTS certificate_expires_at timestamptz,
ADD COLUMN IF NOT EXISTS certificate_checked_at timestamptz,
ADD COLUMN IF NOT EXISTS certificate_check_error text;
CREATE INDEX IF NOT EXISTS sites_user_id_idx ON sites(user_id);
DROP TRIGGER IF EXISTS sites_set_updated_at ON sites;
CREATE TRIGGER sites_set_updated_at
BEFORE UPDATE ON sites
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE IF NOT EXISTS notification_methods (
notification_method_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
notification_type text NOT NULL CHECK (notification_type IN ('webhook', 'push')),
alias text NOT NULL,
url text,
push_endpoint text,
push_p256dh text,
push_auth text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS notification_methods_user_id_idx ON notification_methods(user_id);
DROP TRIGGER IF EXISTS notification_methods_set_updated_at ON notification_methods;
CREATE TRIGGER notification_methods_set_updated_at
BEFORE UPDATE ON notification_methods
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE IF NOT EXISTS site_alert_conditions (
site_alert_condition_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
site_id uuid NOT NULL REFERENCES sites(site_id) ON DELETE CASCADE,
condition_type text NOT NULL CHECK (condition_type IN ('expires_within_hours')),
threshold_hours integer NOT NULL CHECK (threshold_hours > 0 AND threshold_hours <= 17520),
webhook_method_ids uuid[] NOT NULL DEFAULT '{}',
push_enabled boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (site_id, condition_type, threshold_hours)
);
CREATE INDEX IF NOT EXISTS site_alert_conditions_site_id_idx ON site_alert_conditions(site_id);
DROP TRIGGER IF EXISTS site_alert_conditions_set_updated_at ON site_alert_conditions;
CREATE TRIGGER site_alert_conditions_set_updated_at
BEFORE UPDATE ON site_alert_conditions
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TABLE IF NOT EXISTS alert_history (
alert_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
site_id uuid REFERENCES sites(site_id) ON DELETE SET NULL,
alert_type text NOT NULL,
content text NOT NULL,
occurred_at timestamptz NOT NULL DEFAULT now(),
read_at timestamptz,
delivery_channels text[] NOT NULL DEFAULT ARRAY['app'],
delivery_result jsonb NOT NULL DEFAULT '{}'::jsonb,
dedupe_key text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, dedupe_key)
);
CREATE INDEX IF NOT EXISTS alert_history_user_id_idx ON alert_history(user_id);
CREATE INDEX IF NOT EXISTS alert_history_site_id_idx ON alert_history(site_id);
CREATE INDEX IF NOT EXISTS alert_history_occurred_at_idx ON alert_history(occurred_at);
DROP TRIGGER IF EXISTS alert_history_set_updated_at ON alert_history;
CREATE TRIGGER alert_history_set_updated_at
BEFORE UPDATE ON alert_history
FOR EACH ROW EXECUTE FUNCTION set_updated_at();

342
development_plan.md Normal file
View File

@@ -0,0 +1,342 @@
# CertRemind 開発計画
## 目的
CertRemind は、ユーザーが登録した Web サイトの TLS/SSL 証明書の有効期限を監視し、期限切れ前に Web アプリ内アラート、Webhook、プッシュ通知で知らせるサービスである。
この計画では、初期実装から運用可能な MVP までを段階的に進める。
## 前提
- Node.js v22 を使用する。
- パッケージ管理は pnpm を使用する。
- バックエンドは Hono.js を使用する。
- フロントエンドは React.js と Radix UI を使用する。
- データベースは PostgreSQL を使用する。
- 証明書情報の取得には OpenSSL を使用する。
- セキュリティ上、クライアントから送信された値は信頼しない。
- CSRF 対策、セッション管理、パスワードハッシュ、入力検証を初期段階から組み込む。
## 全体方針
1. まず認証、DB、基本画面、API の土台を作る。
2. 次にサイト登録、通知条件、アラート履歴までの主要業務フローを作る。
3. その後、バックグラウンド監視、Webhook、プッシュ通知を追加する。
4. 最後に 2 段階認証、アカウント削除、監視停止保証、テスト、運用設定を固める。
## 推奨ディレクトリ構成
```text
.
├── AGENTS.md
├── development_plan.md
├── package.json
├── pnpm-lock.yaml
├── docker-compose.yml
├── .env.example
├── db
│ ├── migrations
│ └── schema.sql
├── src
│ ├── server
│ │ ├── app.js
│ │ ├── index.js
│ │ ├── config
│ │ ├── db
│ │ ├── middleware
│ │ ├── modules
│ │ │ ├── auth
│ │ │ ├── users
│ │ │ ├── sites
│ │ │ ├── alerts
│ │ │ ├── notificationMethods
│ │ │ └── monitoring
│ │ └── jobs
│ └── client
│ ├── main.jsx
│ ├── App.jsx
│ ├── routes
│ ├── components
│ ├── api
│ └── styles
└── tests
```
## データベース設計方針
AGENTS.md の論理定義を基準に、実装時には次の補足を加える。
- 全テーブルに `created_at``updated_at` を持たせる。
- 主キーは UUID を基本とする。
- ユーザー ID に紐づくデータは外部キーで整合性を保つ。
- アカウント削除時にユーザー関連データを確実に削除できるよう、外部キーの削除動作を設計する。
- サイトにはエイリアス名が必要なため `alias` を追加する。
- 通知方法は複数 Webhook に対応するため、主キーは `notification_method_id` とし、`user_id` は外部キーにする。
- プッシュ通知はブラウザ購読情報を保持するため、endpoint と key 情報を保存できるテーブルを用意する。
- アラート履歴には既読状態、対象サイト、発生日時、通知種別、送信結果を保存する。
- 同じ条件でアラートを連続送信しすぎないよう、通知済み状態または抑制用の履歴を持つ。
## 実装フェーズ
### フェーズ 0: プロジェクト基盤
目的: 開発を進められる最小構成を整える。
作業:
- pnpm ベースの依存関係を追加する。
- Hono サーバーを起動できるようにする。
- React/Vite のフロントエンドを構成する。
- PostgreSQL 用の `docker-compose.yml` を作成する。
- `.env.example` を作成する。
- DB 接続モジュールを作る。
- マイグレーション管理方法を決める。
- ESLint、Formatter、テストランナーを導入する。
成果物:
- 開発サーバーが起動できる。
- PostgreSQL が Docker で起動できる。
- ヘルスチェック API が動く。
### フェーズ 1: 認証とセッション
目的: ユーザー登録、ログイン、ログアウト、セッション管理を実装する。
作業:
- ユーザーテーブルの DDL を作成する。
- パスワードを Argon2id などの強力な方式でハッシュ化する。
- 登録 API を作る。
- ログイン API を作る。
- ログアウト API を作る。
- 現在のユーザー取得 API を作る。
- CSRF トークンを導入する。
- 認証必須ミドルウェアを作る。
- 登録画面、ログイン画面を作る。
注意:
- ユーザー名はユニーク制約を必須にする。
- 認証エラーでは、ユーザー名の存在有無が推測されにくいレスポンスにする。
- セッション Cookie は `HttpOnly``SameSite`、本番では `Secure` を有効にする。
### フェーズ 2: サイト管理
目的: 監視対象サイトを登録、表示、編集、削除できるようにする。
作業:
- サイトテーブルの DDL を作成する。
- サイト登録 API を作る。
- サイト一覧 API を作る。
- サイトエイリアス更新 API を作る。
- サイト削除 API を作る。
- URL の正規化と検証を実装する。
- サイト一覧画面を作る。
注意:
- リクエスト上の `user_id` は信用せず、必ずセッションから取得する。
- URL は `https://` を基本とし、ホスト名を検証する。
- 削除時は関連する通知条件と監視状態も削除する。
### フェーズ 3: サイト設定と通知条件
目的: サイトごとに通知タイミングと通知方法を設定できるようにする。
作業:
- サイトアラート条件テーブルの DDL を作成する。
- 通知条件の取得、作成、更新、削除 API を作る。
- 通知タイミングを内部的には時間単位で保存する。
- UI では時間、日、週間で指定できる入力を作る。
- Web アプリ内アラートは必須通知先として扱う。
- サイト設定画面を作る。
注意:
- 0 以下の通知時間、過大な値、不正な単位を拒否する。
- 同じサイトに同一条件が重複しないように制約またはアプリ側検証を入れる。
### フェーズ 4: アラート履歴
目的: システムが送信したアラートを Web アプリ内で確認できるようにする。
作業:
- アラート履歴テーブルの DDL を作成する。
- アラート一覧 API を作る。
- 既読更新 API を作る。
- サイト、日時、アラート種類で絞り込める API を作る。
- アラート一覧画面を作る。
注意:
- 履歴はユーザー単位で必ず分離する。
- 絞り込み条件は SQL インジェクションを避けるため、プレースホルダを使用する。
### フェーズ 5: 通知方法管理
目的: Webhook とプッシュ通知の設定を管理できるようにする。
作業:
- 通知方法テーブルの DDL を作成する。
- Webhook 登録、一覧、更新、削除 API を作る。
- Webhook URL とエイリアス名の検証を実装する。
- プッシュ通知の購読情報登録 API を作る。
- VAPID key を設定ファイルから読み込む。
- 通知方法管理画面を作る。
注意:
- Webhook URL は HTTPS を推奨し、ローカルアドレスやメタデータ IP への SSRF を防ぐ。
- Slack 互換 Webhook の送信形式を固定する。
- プッシュ通知はブラウザ許可状態を UI に反映する。
### フェーズ 6: 証明書監視ジョブ
目的: 登録サイトの証明書期限を取得し、条件に合う場合にアラートを送信する。
作業:
- OpenSSL を呼び出して証明書の期限を取得する処理を作る。
- 監視対象サイトをユーザー単位で取得する。
- サイトごとの通知条件を評価する。
- 条件に一致したらアラート履歴を作成する。
- Webhook 通知を送信する。
- プッシュ通知を送信する。
- 二重送信を防ぐ仕組みを作る。
- ジョブ実行ログを残す。
注意:
- OpenSSL 呼び出しはタイムアウトを設定する。
- 1 件の監視失敗が全体を止めないようにする。
- 外部通信と証明書取得は並列数を制限する。
- 証明書取得失敗もアラート種類として扱う。
### フェーズ 7: アカウント設定と 2 段階認証
目的: ユーザー情報、パスワード、2 段階認証、アカウント削除を実装する。
作業:
- 表示名更新 API を作る。
- パスワード更新 API を作る。
- パスワード更新時に全セッションを無効化する。
- OTP シークレット登録、検証、解除 API を作る。
- ログインフローに 2 段階認証を組み込む。
- アカウント削除 API を作る。
- アカウント設定画面を作る。
注意:
- 2 段階認証解除には再認証を求める。
- アカウント削除時は監視対象、通知先、アラート履歴、セッションをすべて削除する。
- 削除後に監視ジョブが対象ユーザーを処理しないことを確認する。
### フェーズ 8: 品質向上と運用準備
目的: セキュリティ、安定性、テスト、運用手順を整える。
作業:
- API の単体テストを追加する。
- 認証、サイト管理、通知条件、アラート履歴の統合テストを追加する。
- 証明書監視処理のテストを追加する。
- CSRF、認可漏れ、SSRF、入力検証の観点でレビューする。
- エラーハンドリングとログ出力を整理する。
- README に開発環境起動手順を追加する。
- 本番向け環境変数の一覧を整理する。
## API 設計メモ
初期案として、以下のようにリソース単位で分ける。
```text
POST /api/auth/register
POST /api/auth/login
POST /api/auth/logout
GET /api/auth/me
GET /api/auth/csrf
GET /api/sites
POST /api/sites
GET /api/sites/:siteId
PATCH /api/sites/:siteId
DELETE /api/sites/:siteId
GET /api/sites/:siteId/settings
PUT /api/sites/:siteId/settings
DELETE /api/sites/:siteId/settings
GET /api/alerts
PATCH /api/alerts/:alertId/read
GET /api/notification-methods
POST /api/notification-methods/webhooks
PATCH /api/notification-methods/webhooks/:methodId
DELETE /api/notification-methods/webhooks/:methodId
POST /api/notification-methods/push-subscriptions
GET /api/account
PATCH /api/account/profile
PATCH /api/account/password
POST /api/account/totp/setup
POST /api/account/totp/verify
DELETE /api/account/totp
DELETE /api/account
```
## 画面実装順
1. 登録画面
2. ログイン画面
3. サイト一覧
4. サイト設定
5. アラート一覧
6. 通知方法の管理
7. アカウント設定
## セキュリティチェックリスト
- パスワードを平文保存しない。
- 認証が必要な API ではセッションからユーザーを特定する。
- 他ユーザーのサイト、通知先、アラート履歴にアクセスできないことをテストする。
- CSRF トークンを状態変更 API に必須化する。
- Cookie に `HttpOnly``SameSite`、本番 `Secure` を設定する。
- Webhook 送信先の SSRF 対策を行う。
- OpenSSL 呼び出しにタイムアウトを設定する。
- 外部入力はスキーマ検証する。
- SQL はプレースホルダを使う。
- アカウント削除で関連データが残らないことを確認する。
## MVP の完了条件
- ユーザーが登録、ログイン、ログアウトできる。
- ユーザーが監視対象サイトを登録、一覧、編集、削除できる。
- サイトごとに通知タイミングを設定できる。
- 証明書期限を監視してアラート履歴に記録できる。
- Webhook に通知できる。
- アラート一覧で履歴を確認し、既読にできる。
- 基本的な認可テストと入力検証テストが通る。
## MVP 後に追加する機能
- プッシュ通知
- 2 段階認証
- パスワード更新時の全セッション無効化
- アカウント削除時の完全削除と監視停止保証
- 監視ジョブの詳細ログと管理用メトリクス
- 通知失敗時のリトライ制御
## 初回実装の推奨タスク
1. Hono と React/Vite の最小構成を作成する。
2. Docker Compose で PostgreSQL を起動できるようにする。
3. DB マイグレーションまたは `db/schema.sql` を作成する。
4. 認証 API とセッション管理を実装する。
5. 登録画面とログイン画面を実装する。
6. サイト管理 API とサイト一覧画面を実装する。

378
development_status.md Normal file
View File

@@ -0,0 +1,378 @@
# CertRemind 開発進捗
最終更新: 2026-05-22
## 現在の実装状況
`development_plan.md` のフェーズ 8 までを中心に、MVP の土台、サイト設定、アラート履歴、通知方法管理、証明書監視ジョブ、アカウント設定、品質向上と運用準備を実装済み。
### 完了済み
- Hono.js ベースの API サーバー構成
- React / Vite ベースのフロントエンド構成
- PostgreSQL 開発環境用の `docker-compose.yml`
- 環境変数サンプル `.env.example`
- DB 論理定義に基づく `db/schema.sql`
- DB 接続モジュール
- CSRF トークン発行と状態変更 API での検証
- Cookie セッション管理
- 認証必須ミドルウェア
- 登録 API
- ログイン API
- ログアウト API
- 現在ユーザー取得 API
- サイト一覧 API
- サイト一覧 API で最新の証明書発行元・発行日時・期限・確認日時・取得失敗状態を返却
- サイト登録 API
- サイト登録時の証明書発行元・発行日時・期限の初期取得
- サイト詳細 API
- サイトエイリアス更新 API
- サイト削除 API
- サイト設定取得 API
- サイト設定保存 API
- サイト設定削除 API
- アラート一覧 API
- アラート既読更新 API
- アラート履歴削除 API
- サイト、日時、アラート種類によるアラート絞り込み API
- 通知方法一覧 API
- Webhook 登録 API
- Webhook 更新 API
- Webhook 削除 API
- Push 購読情報登録 API
- OpenSSL による証明書発行元・発行日時・期限取得処理
- 監視ジョブで取得した最新の証明書発行元・発行日時・期限・確認日時・取得失敗状態をサイトに保存
- サイトごとの通知条件評価
- 条件一致時のアラート履歴作成
- 証明書取得失敗時のアラート履歴作成
- Webhook 通知送信処理
- Push 通知送信処理
- 二重送信を防ぐ `dedupe_key` 利用
- 並列数を制限した監視ジョブ
- 監視ジョブの一回実行スクリプト
- 1 時間ごとに監視ジョブを実行する Node worker
- アカウント情報取得 API
- 表示名更新 API
- パスワード更新 API
- パスワード更新時の全セッション無効化
- TOTP セットアップ API
- TOTP 検証・有効化 API
- TOTP 解除 API
- ログイン時の TOTP 検証
- アカウント削除 API
- URL 正規化と基本的な SSRF 対策
- 登録 / ログイン画面
- サイト一覧・登録・編集・削除画面
- サイト設定画面
- アラート一覧画面
- 通知方法管理画面
- アカウント設定画面
- ESLint / Prettier / Vitest / Vite 設定
- URL ポリシーの単体テスト
- API セキュリティ境界の統合テスト
- 証明書監視処理の単体テスト
- API 未捕捉エラーと監視ジョブの構造化ログ
- README に開発環境起動手順と環境変数一覧を追加
- `.gitignore` の整備
## 作成・更新した主なファイル
- `package.json`
- `pnpm-lock.yaml`
- `pnpm-workspace.yaml`
- `docker-compose.yml`
- `.env.example`
- `.gitignore`
- `README.md`
- `.prettierrc`
- `eslint.config.js`
- `vite.config.js`
- `index.html`
- `db/schema.sql`
- `src/server/app.js`
- `src/server/index.js`
- `src/server/config/env.js`
- `src/server/db/pool.js`
- `src/server/middleware/auth.js`
- `src/server/middleware/csrf.js`
- `src/server/modules/account/routes.js`
- `src/server/modules/alerts/routes.js`
- `src/server/modules/auth/routes.js`
- `src/server/modules/notificationMethods/routes.js`
- `src/server/modules/monitoring/certificate.js`
- `src/server/modules/monitoring/monitor.js`
- `src/server/modules/monitoring/notifications.js`
- `src/server/modules/sites/routes.js`
- `src/server/jobs/monitorCertificates.js`
- `src/server/utils/httpErrors.js`
- `src/server/utils/logger.js`
- `src/server/utils/urlPolicy.js`
- `src/client/main.jsx`
- `src/client/App.jsx`
- `src/client/api/client.js`
- `src/client/components/Field.jsx`
- `src/client/routes/AlertsView.jsx`
- `src/client/routes/AuthPanel.jsx`
- `src/client/routes/AccountView.jsx`
- `src/client/routes/NotificationMethodsView.jsx`
- `src/client/routes/SiteSettingsPanel.jsx`
- `src/client/routes/SitesView.jsx`
- `src/client/styles/app.css`
- `public/push-sw.js`
- `tests/urlPolicy.test.js`
- `tests/apiSecurity.test.js`
- `tests/monitoring.test.js`
## データベース
`db/schema.sql` に以下のテーブルを定義済み。
- `users`
- `user_totp`
- `sessions`
- `sites`
- `notification_methods`
- `site_alert_conditions`
- `alert_history`
補足:
- 主キーは UUID。
- 全テーブルに `created_at` / `updated_at` を付与。
- `sites` は最新の証明書発行元、発行日時、期限、確認日時、取得失敗内容を保持。
- `updated_at` 更新用トリガーを定義。
- ユーザー関連データは `ON DELETE CASCADE` を中心に設計。
- サイト削除時は通知条件も削除される。
- アラート履歴のサイト参照は `ON DELETE SET NULL`
## API
### 実装済み
```text
GET /api/health
GET /api/auth/csrf
POST /api/auth/register
POST /api/auth/login
POST /api/auth/logout
GET /api/auth/me
GET /api/sites
POST /api/sites
GET /api/sites/:siteId
PATCH /api/sites/:siteId
DELETE /api/sites/:siteId
GET /api/sites/:siteId/settings
PUT /api/sites/:siteId/settings
DELETE /api/sites/:siteId/settings
GET /api/alerts
PATCH /api/alerts/:alertId/read
DELETE /api/alerts/:alertId
GET /api/notification-methods
POST /api/notification-methods/webhooks
PATCH /api/notification-methods/webhooks/:methodId
DELETE /api/notification-methods/webhooks/:methodId
POST /api/notification-methods/push-subscriptions
GET /api/account
PATCH /api/account/profile
PATCH /api/account/password
POST /api/account/totp/setup
POST /api/account/totp/verify
DELETE /api/account/totp
DELETE /api/account
```
### 実装済みジョブ
```text
pnpm monitor:once
pnpm monitor:worker
```
### 未実装
```text
なし
```
## フロントエンド
### 実装済み画面
- 登録画面
- ログイン画面
- サイト一覧画面
- サイト追加
- 最新監視結果に基づく証明書期限表示
- 確認ダイアログ付きサイト削除
- ログアウト
- サイト設定画面
- エイリアス編集
- 証明書情報として発行元、発行日時、失効日時を表示
- 通知タイミング設定
- 時間 / 日 / 週間の単位指定
- 複数タイミングの追加・確認ダイアログ付き削除
- アプリ内アラート必須表示
- Webhook 選択
- プッシュ通知フラグ設定
- アラート一覧画面
- サイト絞り込み
- アラート種類絞り込み
- 開始日時 / 終了日時絞り込み
- 既読更新
- 確認ダイアログ付き履歴削除
- 通知方法管理画面
- Webhook 登録
- Webhook 編集
- 確認ダイアログ付き Webhook 削除
- ブラウザ Push 通知の許可状態表示
- VAPID public key がある場合の Push 購読登録
- アカウント設定画面
- 表示名更新
- ダイアログでのパスワード更新
- ステップ式ポップアップでの 2 段階認証セットアップ
- 2 段階認証 QR コード表示
- 確認ダイアログ付き 2 段階認証解除
- 確認ダイアログ付きアカウント削除
- 認証後画面共通の左サイドメニュー
- PC 幅では展開表示
- スマートフォンなど狭い幅ではアイコンのみの畳み表示
- サイト一覧、アラート履歴、通知方法、アカウント、ログアウトに対応
- 認証後画面の URL ルーティング
- サイト一覧、サイト設定、アラート履歴、通知方法、アカウントに個別 URL を付与
- 直リンク、初期読込み、ブラウザバック / フォワードに対応
- 未認証時はログイン後に元の保護画面へ復帰
- 認証後画面共通のトースト通知
- 正常メッセージとエラーメッセージを右上に表示
- 5 秒後の自動非表示と手動クローズに対応
### 未実装画面
- なし
## セキュリティ対応状況
### 対応済み
- パスワードは Argon2id でハッシュ化。
- セッションは HttpOnly Cookie。
- Cookie は `SameSite=Lax`
- 本番環境では Secure Cookie を有効化する設定。
- 状態変更 API では CSRF トークンを必須化。
- 認証済み API はセッションからユーザーを取得。
- サイト API ではリクエスト上の `user_id` を信用しない。
- サイト設定 API でもサイト所有者をセッションユーザーで確認。
- サイト設定で選択された Webhook がログインユーザーのものか検証。
- アラート履歴 API はログインユーザーの履歴のみ返す。
- アラート既読更新 API はログインユーザーの履歴のみ更新する。
- アラート履歴削除 API はログインユーザーの履歴のみ削除する。
- アラート絞り込み条件は Zod で検証し、SQL はプレースホルダで組み立て。
- Webhook URL は HTTPS のみ許可。
- Webhook URL は `normalizeHttpsUrl` を通し、localhost / private IPv4 / loopback IPv4 を拒否。
- Webhook 更新・削除はログインユーザーの通知方法のみ対象。
- Push endpoint は HTTPS のみ許可。
- OpenSSL 呼び出しはタイムアウトを設定。
- 監視ジョブはサイト単位の失敗で全体を止めない。
- 外部通信を伴う監視処理は並列数を制限。
- API の未捕捉エラーと監視ジョブの開始・終了・失敗を構造化ログで出力。
- 証明書取得失敗も `certificate_check_failed` としてアラート化。
- Webhook / Push の送信失敗は `delivery_result` に記録。
- パスワード更新時は全セッションを削除し、現在の Cookie も削除。
- 2 段階認証解除には現在のパスワードと OTP を要求。
- アカウント削除には現在のパスワードを要求。
- アカウント削除は `users` の削除を起点に関連データを CASCADE で削除。
- ログイン時、TOTP が有効なユーザーは OTP 検証を必須化。
- 通知タイミングは 1 時間以上、17520 時間以内に制限。
- 同一サイト内の重複した通知タイミングを拒否。
- SQL はプレースホルダを使用。
- サイト URL は HTTPS のみ許可。
- `localhost`、private IPv4、loopback IPv4 を監視対象から拒否。
- サイト登録時は 3 秒以内に証明書期限を取得できる場合のみ登録する。
### 今後強化が必要
- IPv6 private / link-local / unique local address の追加検証。
- DNS 解決後の SSRF 対策。
- Webhook 送信先の詳細な SSRF 対策。
- ログイン試行回数制限。
- セッションローテーション。
- 2 段階認証。
- パスワード更新時の全セッション失効。
- アカウント削除時の完全削除確認。
## 検証結果
以下は成功済み。
```text
pnpm lint
pnpm test
pnpm exec vite build
```
API 動作確認:
- `GET /api/health` 成功。
- API 経由でユーザー登録成功。
- API 経由でサイト登録成功。
- API 経由でサイト設定保存成功。
- API 経由でサイト設定取得成功。
- API 経由でアラート履歴取得成功。
- API 経由でアラート既読更新成功。
- API 経由で Webhook 登録成功。
- API 経由で Webhook 更新成功。
- API 経由で Webhook 削除成功。
- API 経由で Push 購読情報登録成功。
- `pnpm monitor:once` 成功。
- API セキュリティ境界と証明書監視処理のテスト成功。
- サイト登録時の証明書期限初期取得と取得失敗時の登録拒否テスト成功。
- 権限付き実行で OpenSSL による実サイトの証明書期限取得成功。
- サンドボックス内の外部接続拒否時に証明書取得失敗アラート作成を確認。
- API 経由で表示名更新成功。
- API 経由で TOTP セットアップ・有効化成功。
- TOTP 有効ユーザーの OTP なしログインが 401 になることを確認。
- TOTP 有効ユーザーの OTP ありログイン成功。
- API 経由で TOTP 解除成功。
- API 経由でパスワード更新成功。
- パスワード更新後、旧セッションが 401 になることを確認。
- 新パスワードでログイン成功。
- API 経由でアカウント削除成功。
## 起動状況
実装時点で以下を起動確認済み。
```text
docker compose up -d postgres
pnpm dev
```
確認 URL:
```text
Frontend: http://127.0.0.1:5173/
API: http://127.0.0.1:3000
```
## 既知の注意点
- `pnpm` の build script 承認で `esbuild` を承認済み。
- サンドボックス環境では Vite / Vitest の設定解決時に権限付き実行が必要だった。
- `docker-compose.yml` は初回 DB 起動時に `db/schema.sql` を読み込む。既存 volume がある場合、schema の変更は自動再適用されない。
- 実装確認用に API 経由でテストユーザーと `https://example.com/` / `https://example.org/` / `https://example.net/` のサイトを登録済み。
- 実装確認用にアラート履歴を手動挿入済み。
- 実装確認用に Webhook と Push 購読情報を API 経由で登録済み。
- サンドボックス内で `pnpm monitor:once` を実行すると、外部 TLS 接続が `Permission denied` になる場合がある。その場合は権限付き実行が必要。
- OpenSSL は PATH 上にない場合でも、Windows では Git 付属の `openssl.exe` を自動検出する。
- VAPID private key が未設定の場合、Push 通知送信は `delivery_result` に失敗として記録される。
- TOTP セットアップ画面は QR コードとシークレット文字列の両方を表示。
## 次に実装する候補
1. API 単体テスト / 統合テストの追加
2. ログイン試行回数制限
3. SSRF 対策の DNS 解決後チェック強化
4. E2E テストの追加

21
docker-compose.yml Normal file
View File

@@ -0,0 +1,21 @@
services:
postgres:
image: postgres:17-alpine
container_name: certremind-postgres
environment:
POSTGRES_DB: certremind
POSTGRES_USER: certremind
POSTGRES_PASSWORD: certremind
ports:
- '5432:5432'
volumes:
- postgres-data:/var/lib/postgresql/data
- ./db/schema.sql:/docker-entrypoint-initdb.d/001-schema.sql:ro
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U certremind -d certremind']
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres-data:

35
eslint.config.js Normal file
View File

@@ -0,0 +1,35 @@
import js from '@eslint/js';
import globals from 'globals';
import react from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
export default [
{ ignores: ['dist', 'coverage'] },
js.configs.recommended,
{
files: ['src/**/*.{js,jsx}', 'tests/**/*.js'],
languageOptions: {
ecmaVersion: 2024,
globals: {
...globals.browser,
...globals.node,
},
parserOptions: {
ecmaFeatures: { jsx: true },
},
},
plugins: {
react,
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
'react/jsx-uses-vars': 'error',
'react/react-in-jsx-scope': 'off',
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'react-hooks/set-state-in-effect': 'off',
},
},
];

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CertRemind</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/client/main.jsx"></script>
</body>
</html>

57
package.json Normal file
View File

@@ -0,0 +1,57 @@
{
"name": "ssl-expiry-checker",
"version": "1.0.0",
"description": "CertRemind SSL certificate expiry reminder service",
"main": "src/server/index.js",
"scripts": {
"dev": "concurrently \"pnpm dev:server\" \"pnpm dev:client\"",
"dev:server": "node --watch src/server/index.js",
"dev:client": "vite --host 127.0.0.1",
"start": "node src/server/index.js",
"monitor:once": "node src/server/jobs/monitorCertificates.js",
"monitor:worker": "node src/server/jobs/monitorWorker.js",
"test": "vitest run",
"lint": "eslint .",
"format": "prettier --write ."
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@hono/node-server": "^1.19.6",
"@node-rs/argon2": "^2.0.2",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-tabs": "^1.1.13",
"@vitejs/plugin-react": "^5.1.1",
"hono": "^4.10.7",
"lucide-react": "^0.555.0",
"otplib": "^13.4.0",
"pg": "^8.16.3",
"qrcode.react": "^4.2.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"web-push": "^3.6.7",
"zod": "^4.1.13"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"concurrently": "^9.2.1",
"eslint": "^9.39.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"prettier": "^3.7.4",
"vite": "^7.2.4",
"vitest": "^4.0.15"
},
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "^11.1.3",
"onFail": "download"
}
},
"type": "module"
}

6089
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

4
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,4 @@
allowBuilds:
esbuild: true
onlyBuiltDependencies:
- esbuild

19
public/push-sw.js Normal file
View File

@@ -0,0 +1,19 @@
/* global self */
self.addEventListener('push', (event) => {
const payload = event.data?.json?.() ?? {
title: 'CertRemind',
body: '証明書に関する通知があります',
};
event.waitUntil(
self.registration.showNotification(payload.title || 'CertRemind', {
body: payload.body || '証明書に関する通知があります',
}),
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(self.clients.openWindow('/'));
});

232
src/client/App.jsx Normal file
View File

@@ -0,0 +1,232 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { request } from './api/client.js';
import { Sidebar } from './components/Sidebar.jsx';
import { ToastProvider, useToast } from './components/Toast.jsx';
import { AccountView } from './routes/AccountView.jsx';
import { AlertsView } from './routes/AlertsView.jsx';
import { AuthPanel } from './routes/AuthPanel.jsx';
import { NotificationMethodsView } from './routes/NotificationMethodsView.jsx';
import { SiteSettingsPanel } from './routes/SiteSettingsPanel.jsx';
import { SitesView } from './routes/SitesView.jsx';
const viewPaths = {
sites: '/sites',
alerts: '/alerts',
notifications: '/notifications',
account: '/account',
};
function normalizedPath(pathname) {
if (!pathname || pathname === '/') return '/';
return pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
}
function parseRoute(pathname) {
const path = normalizedPath(pathname);
if (path === '/') return { kind: 'root' };
if (path === '/login') return { kind: 'auth', mode: 'login' };
if (path === '/register') return { kind: 'auth', mode: 'register' };
if (path === '/sites') return { kind: 'sites', activeView: 'sites', protected: true };
if (path === '/alerts') return { kind: 'alerts', activeView: 'alerts', protected: true };
if (path === '/notifications') {
return { kind: 'notifications', activeView: 'notifications', protected: true };
}
if (path === '/account') return { kind: 'account', activeView: 'account', protected: true };
const siteSettingsMatch = path.match(/^\/sites\/([^/]+)\/settings$/);
if (siteSettingsMatch) {
return {
kind: 'siteSettings',
activeView: 'sites',
protected: true,
siteId: decodeURIComponent(siteSettingsMatch[1]),
};
}
return { kind: 'notFound' };
}
function isProtectedPath(pathname) {
return Boolean(parseRoute(pathname).protected);
}
function currentPath() {
return normalizedPath(window.location.pathname);
}
export function App() {
const [user, setUser] = useState(null);
const [locationPath, setLocationPath] = useState(currentPath);
const [ready, setReady] = useState(false);
const pendingReturnPath = useRef(null);
const route = parseRoute(locationPath);
const navigate = useCallback((path, options = {}) => {
const nextPath = normalizedPath(path);
if (currentPath() !== nextPath) {
const method = options.replace ? 'replaceState' : 'pushState';
window.history[method](null, '', nextPath);
}
setLocationPath(nextPath);
}, []);
const logout = useMemo(
() => async () => {
await request('/api/auth/logout', { method: 'POST' }).catch(() => {});
pendingReturnPath.current = null;
setUser(null);
navigate('/login', { replace: true });
},
[navigate],
);
useEffect(() => {
function handlePopState() {
setLocationPath(currentPath());
}
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
useEffect(() => {
async function boot() {
const csrf = await request('/api/auth/csrf');
localStorage.setItem('csrfToken', csrf.csrfToken);
const me = await request('/api/auth/me').catch(() => null);
setUser(me?.user ?? null);
setReady(true);
}
boot();
}, []);
useEffect(() => {
if (!ready) return;
if (user) {
if (route.kind === 'auth' || route.kind === 'root' || route.kind === 'notFound') {
navigate('/sites', { replace: true });
}
return;
}
if (route.protected) {
pendingReturnPath.current = locationPath;
navigate('/login', { replace: true });
return;
}
if (route.kind === 'root' || route.kind === 'notFound') {
navigate('/login', { replace: true });
}
}, [locationPath, navigate, ready, route.kind, route.protected, user]);
if (!ready) {
return <div className="loading">CertRemind</div>;
}
if (!user) {
const mode = route.kind === 'auth' ? route.mode : 'login';
return (
<AuthPanel
mode={mode}
onModeChange={(nextMode) => navigate(nextMode === 'register' ? '/register' : '/login')}
onAuthed={(nextUser) => {
setUser(nextUser);
const returnPath = pendingReturnPath.current;
pendingReturnPath.current = null;
navigate(isProtectedPath(returnPath) ? returnPath : '/sites', { replace: true });
}}
/>
);
}
function withShell(content) {
return (
<ToastProvider>
<div className="app-frame">
<Sidebar
activeView={route.activeView ?? 'sites'}
user={user}
onNavigate={(nextView) => navigate(viewPaths[nextView] ?? '/sites')}
onLogout={logout}
/>
<div className="app-content">{content}</div>
</div>
</ToastProvider>
);
}
if (route.kind === 'siteSettings') {
return withShell(
<SiteSettingsRoute siteId={route.siteId} onBack={() => navigate('/sites')} navigate={navigate} />,
);
}
if (route.kind === 'alerts') {
return withShell(<AlertsView onBack={() => navigate('/sites')} />);
}
if (route.kind === 'notifications') {
return withShell(<NotificationMethodsView onBack={() => navigate('/sites')} />);
}
if (route.kind === 'account') {
return withShell(
<AccountView
onBack={() => navigate('/sites')}
onSignedOut={() => {
pendingReturnPath.current = null;
setUser(null);
navigate('/login', { replace: true });
}}
/>,
);
}
return withShell(
<SitesView
user={user}
onConfigureSite={(site) =>
navigate(`/sites/${encodeURIComponent(site.siteId)}/settings`)
}
/>,
);
}
function SiteSettingsRoute({ siteId, onBack, navigate }) {
const [site, setSite] = useState(null);
const [loadingSiteId, setLoadingSiteId] = useState(siteId);
const { showToast } = useToast();
useEffect(() => {
let active = true;
setSite(null);
setLoadingSiteId(siteId);
async function loadSite() {
try {
const data = await request(`/api/sites/${siteId}`);
if (active) {
setSite(data.site);
}
} catch (err) {
if (active) {
showToast({ type: 'error', message: err.message });
navigate('/sites', { replace: true });
}
}
}
loadSite();
return () => {
active = false;
};
}, [navigate, showToast, siteId]);
if (!site || loadingSiteId !== siteId) {
return <div className="loading">CertRemind</div>;
}
return <SiteSettingsPanel site={site} onBack={onBack} />;
}

20
src/client/api/client.js Normal file
View File

@@ -0,0 +1,20 @@
export async function request(path, options = {}) {
const csrf = localStorage.getItem('csrfToken');
const response = await fetch(path, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(csrf ? { 'x-csrf-token': csrf } : {}),
...options.headers,
},
...options,
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(data.error || 'リクエストに失敗しました');
Object.assign(error, data);
throw error;
}
return data;
}

View File

@@ -0,0 +1,41 @@
import * as Dialog from '@radix-ui/react-dialog';
export function ConfirmDialog({
trigger,
title,
description,
confirmLabel = '削除',
cancelLabel = 'キャンセル',
onConfirm,
disabled = false,
}) {
return (
<Dialog.Root>
<Dialog.Trigger asChild>{trigger}</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="dialog-content">
<Dialog.Title className="dialog-title">{title}</Dialog.Title>
<Dialog.Description className="dialog-description">{description}</Dialog.Description>
<div className="dialog-actions">
<Dialog.Close asChild>
<button className="secondary" type="button">
{cancelLabel}
</button>
</Dialog.Close>
<Dialog.Close asChild>
<button
className="secondary danger-text"
type="button"
onClick={onConfirm}
disabled={disabled}
>
{confirmLabel}
</button>
</Dialog.Close>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -0,0 +1,10 @@
import * as Label from '@radix-ui/react-label';
export function Field({ label, children }) {
return (
<div className="field">
<Label.Root className="label">{label}</Label.Root>
{children}
</div>
);
}

View File

@@ -0,0 +1,49 @@
import { Bell, Globe2, Link, LogOut, ShieldCheck, UserRound } from 'lucide-react';
const navItems = [
{ view: 'sites', label: 'サイト一覧', icon: Globe2 },
{ view: 'alerts', label: 'アラート履歴', icon: Bell },
{ view: 'notifications', label: '通知方法', icon: Link },
{ view: 'account', label: 'アカウント', icon: UserRound },
];
export function Sidebar({ activeView, user, onNavigate, onLogout }) {
return (
<aside className="sidebar" aria-label="メインメニュー">
<div className="sidebar-brand">
<ShieldCheck aria-hidden="true" size={24} />
<span>CertRemind</span>
</div>
<nav className="sidebar-nav">
{navItems.map((item) => {
const Icon = item.icon;
const active = activeView === item.view;
return (
<button
className={`sidebar-link ${active ? 'active' : ''}`}
key={item.view}
onClick={() => onNavigate(item.view)}
aria-current={active ? 'page' : undefined}
title={item.label}
>
<Icon aria-hidden="true" size={20} />
<span>{item.label}</span>
</button>
);
})}
</nav>
<div className="sidebar-footer">
<div className="sidebar-user">
<UserRound aria-hidden="true" size={18} />
<span>{user.displayName}</span>
</div>
<button className="sidebar-link" onClick={onLogout} title="ログアウト">
<LogOut aria-hidden="true" size={20} />
<span>ログアウト</span>
</button>
</div>
</aside>
);
}

View File

@@ -0,0 +1,104 @@
/* eslint-disable react-refresh/only-export-components */
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { AlertCircle, CheckCircle2, X } from 'lucide-react';
const ToastContext = createContext(null);
let nextToastId = 0;
export function ToastProvider({ children }) {
const [toasts, setToasts] = useState([]);
const timeoutIds = useRef(new Map());
const dismissToast = useCallback((toastId) => {
const timeoutId = timeoutIds.current.get(toastId);
if (timeoutId) {
window.clearTimeout(timeoutId);
timeoutIds.current.delete(toastId);
}
setToasts((current) => current.filter((item) => item.toastId !== toastId));
}, []);
const showToast = useCallback(
({ type = 'success', message, timeout = 5000 }) => {
if (!message) return;
const toastId = nextToastId + 1;
nextToastId = toastId;
const timeoutId =
timeout > 0 ? window.setTimeout(() => dismissToast(toastId), timeout) : undefined;
if (timeoutId) {
timeoutIds.current.set(toastId, timeoutId);
}
setToasts((current) => {
const next = [{ toastId, type, message }, ...current].slice(0, 4);
current
.filter((toast) => !next.some((nextToast) => nextToast.toastId === toast.toastId))
.forEach((toast) => {
const staleTimeoutId = timeoutIds.current.get(toast.toastId);
if (staleTimeoutId) {
window.clearTimeout(staleTimeoutId);
timeoutIds.current.delete(toast.toastId);
}
});
return next;
});
},
[dismissToast],
);
useEffect(() => {
const timeouts = timeoutIds.current;
return () => {
timeouts.forEach((timeoutId) => window.clearTimeout(timeoutId));
timeouts.clear();
};
}, []);
const value = useMemo(() => ({ showToast }), [showToast]);
return (
<ToastContext.Provider value={value}>
{children}
<ToastViewport toasts={toasts} onDismiss={dismissToast} />
</ToastContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within ToastProvider');
}
return context;
}
function ToastViewport({ toasts, onDismiss }) {
if (toasts.length === 0) return null;
return (
<div className="toast-viewport" aria-live="polite" aria-label="通知">
{toasts.map((toast) => {
const isError = toast.type === 'error';
const Icon = isError ? AlertCircle : CheckCircle2;
return (
<div
className={`toast toast-${isError ? 'error' : 'success'}`}
role={isError ? 'alert' : 'status'}
key={toast.toastId}
>
<Icon aria-hidden="true" size={20} />
<p>{toast.message}</p>
<button
type="button"
className="toast-close"
onClick={() => onDismiss(toast.toastId)}
aria-label="通知を閉じる"
title="閉じる"
>
<X aria-hidden="true" size={16} />
</button>
</div>
);
})}
</div>
);
}

10
src/client/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App.jsx';
import './styles/app.css';
createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@@ -0,0 +1,439 @@
import { useEffect, useState } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import { ArrowLeft, KeyRound, ShieldCheck, Trash2, UserRound } from 'lucide-react';
import { QRCodeSVG } from 'qrcode.react';
import { request } from '../api/client.js';
import { ConfirmDialog } from '../components/ConfirmDialog.jsx';
import { Field } from '../components/Field.jsx';
import { useToast } from '../components/Toast.jsx';
function requireValue(value, label) {
if (!value.trim()) {
throw new Error(`${label}を入力してください`);
}
}
function validatePassword(value, label) {
requireValue(value, label);
if (value.length < 12) {
throw new Error(`${label}は12文字以上で入力してください`);
}
if (value.length > 200) {
throw new Error(`${label}は200文字以内で入力してください`);
}
}
function validateOtp(value) {
requireValue(value, '認証コード');
if (!/^\d{6}$/.test(value)) {
throw new Error('認証コードは6桁の数字で入力してください');
}
}
export function AccountView({ onBack, onSignedOut }) {
const [account, setAccount] = useState(null);
const [profile, setProfile] = useState({ displayName: '' });
const [passwordForm, setPasswordForm] = useState({ currentPassword: '', newPassword: '' });
const [totpSetup, setTotpSetup] = useState(null);
const [totpStep, setTotpStep] = useState(1);
const [totpForm, setTotpForm] = useState({ otp: '', currentPassword: '' });
const [deletePassword, setDeletePassword] = useState('');
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
const [totpDialogOpen, setTotpDialogOpen] = useState(false);
const [busy, setBusy] = useState(false);
const { showToast } = useToast();
async function loadAccount() {
const data = await request('/api/account');
setAccount(data.account);
setProfile({ displayName: data.account.displayName });
}
useEffect(() => {
loadAccount().catch((err) => showToast({ type: 'error', message: err.message }));
}, [showToast]);
async function saveProfile(event) {
event.preventDefault();
setBusy(true);
try {
requireValue(profile.displayName, '表示名');
if (profile.displayName.trim().length > 80) {
throw new Error('表示名は80文字以内で入力してください');
}
const data = await request('/api/account/profile', {
method: 'PATCH',
body: JSON.stringify({ displayName: profile.displayName.trim() }),
});
setAccount(data.account);
showToast({ type: 'success', message: '表示名を更新しました' });
} catch (err) {
showToast({ type: 'error', message: err.message });
} finally {
setBusy(false);
}
}
async function updatePassword(event) {
event.preventDefault();
setBusy(true);
try {
requireValue(passwordForm.currentPassword, '現在のパスワード');
validatePassword(passwordForm.newPassword, '新しいパスワード');
await request('/api/account/password', {
method: 'PATCH',
body: JSON.stringify(passwordForm),
});
setPasswordDialogOpen(false);
onSignedOut();
} catch (err) {
showToast({ type: 'error', message: err.message });
} finally {
setBusy(false);
}
}
async function startTotpSetup() {
setBusy(true);
try {
const data = await request('/api/account/totp/setup', { method: 'POST' });
setTotpSetup(data);
setTotpStep(1);
setTotpForm({ otp: '', currentPassword: '' });
setTotpDialogOpen(true);
} catch (err) {
showToast({ type: 'error', message: err.message });
} finally {
setBusy(false);
}
}
async function verifyTotp(event) {
event.preventDefault();
setBusy(true);
try {
validateOtp(totpForm.otp);
await request('/api/account/totp/verify', {
method: 'POST',
body: JSON.stringify({ secret: totpSetup.secret, otp: totpForm.otp.trim() }),
});
setTotpSetup(null);
setTotpDialogOpen(false);
setTotpStep(1);
showToast({ type: 'success', message: '2段階認証を有効にしました' });
await loadAccount();
} catch (err) {
showToast({ type: 'error', message: err.message });
} finally {
setBusy(false);
}
}
async function disableTotp() {
setBusy(true);
try {
requireValue(totpForm.currentPassword, '現在のパスワード');
validateOtp(totpForm.otp);
await request('/api/account/totp', {
method: 'DELETE',
body: JSON.stringify({
currentPassword: totpForm.currentPassword,
otp: totpForm.otp.trim(),
}),
});
setTotpForm({ otp: '', currentPassword: '' });
showToast({ type: 'success', message: '2段階認証を解除しました' });
await loadAccount();
} catch (err) {
showToast({ type: 'error', message: err.message });
} finally {
setBusy(false);
}
}
async function deleteAccount() {
setBusy(true);
try {
requireValue(deletePassword, '現在のパスワード');
await request('/api/account', {
method: 'DELETE',
body: JSON.stringify({ currentPassword: deletePassword }),
});
onSignedOut();
} catch (err) {
showToast({ type: 'error', message: err.message });
} finally {
setBusy(false);
}
}
if (!account) {
return <div className="loading">CertRemind</div>;
}
return (
<main className="app-shell">
<header className="topbar">
<button className="back-button" onClick={onBack}>
<ArrowLeft aria-hidden="true" size={18} />
サイト一覧
</button>
<div className="settings-title">
<span className="eyebrow">アカウント</span>
<h1>{account.username}</h1>
</div>
</header>
<section className="workspace account-layout">
<form className="panel" onSubmit={saveProfile}>
<div className="panel-heading">
<UserRound aria-hidden="true" size={20} />
<h2>ユーザー情報</h2>
</div>
<Field label="表示名">
<input
value={profile.displayName}
onChange={(event) => setProfile({ displayName: event.target.value })}
maxLength="80"
required
/>
</Field>
<button className="primary fit-button" disabled={busy}>
保存
</button>
</form>
<section className="panel">
<div className="panel-heading">
<KeyRound aria-hidden="true" size={20} />
<div>
<h2>パスワード</h2>
</div>
</div>
<Dialog.Root open={passwordDialogOpen} onOpenChange={setPasswordDialogOpen}>
<Dialog.Trigger asChild>
<button className="secondary fit-button" type="button">
パスワードを変更
</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="dialog-content">
<Dialog.Title className="dialog-title">パスワード変更</Dialog.Title>
<Dialog.Description className="dialog-description">
更新後はすべてのセッションからログアウトします<br />
新しいパスワードで再ログインが必要になります
</Dialog.Description>
<form className="dialog-form" onSubmit={updatePassword}>
<Field label="現在のパスワード">
<input
type="password"
value={passwordForm.currentPassword}
onChange={(event) =>
setPasswordForm({ ...passwordForm, currentPassword: event.target.value })
}
required
/>
</Field>
<Field label="新しいパスワード">
<input
type="password"
minLength="12"
maxLength="200"
value={passwordForm.newPassword}
onChange={(event) =>
setPasswordForm({ ...passwordForm, newPassword: event.target.value })
}
required
/>
</Field>
<div className="dialog-actions">
<Dialog.Close asChild>
<button className="secondary" type="button">
キャンセル
</button>
</Dialog.Close>
<button className="primary" disabled={busy}>
変更
</button>
</div>
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</section>
<section className="panel">
<div className="panel-heading">
<ShieldCheck aria-hidden="true" size={20} />
<div>
<h2>2段階認証</h2>
<p>{account.totpEnabled ? '有効' : '未設定'}</p>
</div>
</div>
{!account.totpEnabled ? (
<>
<button
className="secondary fit-button"
type="button"
onClick={startTotpSetup}
disabled={busy}
>
セットアップ開始
</button>
{totpSetup ? (
<Dialog.Root open={totpDialogOpen} onOpenChange={setTotpDialogOpen}>
<Dialog.Portal>
<Dialog.Overlay className="dialog-overlay" />
<Dialog.Content className="dialog-content totp-dialog">
<Dialog.Title className="dialog-title">2段階認証セットアップ</Dialog.Title>
<Dialog.Description className="dialog-description">
ステップ {totpStep} / 3
</Dialog.Description>
{totpStep === 1 ? (
<div className="totp-step">
<h3>認証アプリを準備</h3>
<p className="muted">
Google Authenticator1PasswordMicrosoft Authenticator
などを使用できます
</p>
</div>
) : null}
{totpStep === 2 ? (
<div className="totp-step">
<h3>QR コードを読み取り</h3>
<div className="qr-box">
<QRCodeSVG value={totpSetup.otpauthUrl} size={192} />
</div>
<p className="muted">
読み取れない場合は以下のシークレットを手動入力してください<br/>
種類時間ベース
</p>
<code>{totpSetup.secret}</code>
</div>
) : null}
{totpStep === 3 ? (
<form className="totp-step" onSubmit={verifyTotp}>
<h3>認証コードを確認</h3>
<Field label="認証コード">
<input
value={totpForm.otp}
onChange={(event) =>
setTotpForm({ ...totpForm, otp: event.target.value })
}
inputMode="numeric"
maxLength="6"
pattern="[0-9]{6}"
required
/>
</Field>
<div className="dialog-actions">
<button
className="secondary"
type="button"
onClick={() => setTotpStep(2)}
>
戻る
</button>
<button className="primary" disabled={busy}>
有効化
</button>
</div>
</form>
) : (
<div className="dialog-actions">
<Dialog.Close asChild>
<button className="secondary" type="button">
キャンセル
</button>
</Dialog.Close>
<button
className="primary"
type="button"
onClick={() => setTotpStep((current) => current + 1)}
>
次へ
</button>
</div>
)}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
) : null}
</>
) : (
<form className="totp-box" onSubmit={(event) => event.preventDefault()}>
<Field label="現在のパスワード">
<input
type="password"
value={totpForm.currentPassword}
onChange={(event) =>
setTotpForm({ ...totpForm, currentPassword: event.target.value })
}
required
/>
</Field>
<Field label="認証コード">
<input
value={totpForm.otp}
onChange={(event) => setTotpForm({ ...totpForm, otp: event.target.value })}
inputMode="numeric"
maxLength="6"
pattern="[0-9]{6}"
required
/>
</Field>
<ConfirmDialog
title="2段階認証を解除"
description="このアカウントのログイン時に認証コードが不要になります。"
confirmLabel="解除"
onConfirm={disableTotp}
disabled={busy}
trigger={
<button
className="secondary danger-text fit-button"
type="button"
disabled={busy}
>
解除
</button>
}
/>
</form>
)}
</section>
<form className="panel danger-panel" onSubmit={(event) => event.preventDefault()}>
<div className="panel-heading">
<Trash2 aria-hidden="true" size={20} />
<h2>アカウント削除</h2>
</div>
<Field label="現在のパスワード">
<input
type="password"
value={deletePassword}
onChange={(event) => setDeletePassword(event.target.value)}
required
/>
</Field>
<ConfirmDialog
title="アカウントを削除"
description="ユーザー、サイト、通知方法、アラート履歴、セッションを削除します。"
confirmLabel="アカウントを削除"
onConfirm={deleteAccount}
disabled={busy}
trigger={
<button className="secondary danger-text fit-button" type="button" disabled={busy}>
アカウントを削除
</button>
}
/>
</form>
</section>
</main>
);
}

View File

@@ -0,0 +1,214 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ArrowLeft, Check, Filter, RotateCcw, Trash2 } from 'lucide-react';
import { request } from '../api/client.js';
import { ConfirmDialog } from '../components/ConfirmDialog.jsx';
import { Field } from '../components/Field.jsx';
import { useToast } from '../components/Toast.jsx';
function toIsoFromLocal(value) {
if (!value) return undefined;
return new Date(value).toISOString();
}
function formatDateTime(value) {
return new Intl.DateTimeFormat('ja-JP', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
}
function buildQuery(filters) {
const params = new URLSearchParams();
if (filters.siteId) params.set('siteId', filters.siteId);
if (filters.alertType) params.set('alertType', filters.alertType);
const from = toIsoFromLocal(filters.from);
const to = toIsoFromLocal(filters.to);
if (from) params.set('from', from);
if (to) params.set('to', to);
const query = params.toString();
return query ? `?${query}` : '';
}
export function AlertsView({ onBack }) {
const [alerts, setAlerts] = useState([]);
const [sites, setSites] = useState([]);
const [filters, setFilters] = useState({ siteId: '', alertType: '', from: '', to: '' });
const [busy, setBusy] = useState(false);
const { showToast } = useToast();
const alertTypes = useMemo(
() => [...new Set(alerts.map((alert) => alert.alertType))].sort(),
[alerts],
);
const loadAlerts = useCallback(async (nextFilters) => {
setBusy(true);
try {
const data = await request(`/api/alerts${buildQuery(nextFilters)}`);
setAlerts(data.alerts);
} catch (err) {
showToast({ type: 'error', message: err.message });
} finally {
setBusy(false);
}
}, [showToast]);
useEffect(() => {
async function loadInitialData() {
const siteData = await request('/api/sites');
setSites(siteData.sites);
await loadAlerts({ siteId: '', alertType: '', from: '', to: '' });
}
loadInitialData().catch((err) => showToast({ type: 'error', message: err.message }));
}, [loadAlerts, showToast]);
async function applyFilters(event) {
event.preventDefault();
if (filters.from && filters.to && new Date(filters.from) > new Date(filters.to)) {
showToast({ type: 'error', message: '開始日時は終了日時より前にしてください' });
return;
}
await loadAlerts(filters);
}
async function resetFilters() {
const empty = { siteId: '', alertType: '', from: '', to: '' };
setFilters(empty);
await loadAlerts(empty);
}
async function markRead(alertId) {
try {
const data = await request(`/api/alerts/${alertId}/read`, { method: 'PATCH' });
setAlerts((current) =>
current.map((alert) =>
alert.alertId === alertId ? { ...alert, readAt: data.alert.readAt } : alert,
),
);
} catch (err) {
showToast({ type: 'error', message: err.message });
}
}
async function deleteAlert(alertId) {
try {
await request(`/api/alerts/${alertId}`, { method: 'DELETE' });
setAlerts((current) => current.filter((alert) => alert.alertId !== alertId));
} catch (err) {
showToast({ type: 'error', message: err.message });
}
}
return (
<main className="app-shell">
<header className="topbar">
<button className="back-button" onClick={onBack}>
<ArrowLeft aria-hidden="true" size={18} />
サイト一覧
</button>
<div className="settings-title">
<span className="eyebrow">アラート履歴</span>
<h1>送信済みアラート</h1>
</div>
</header>
<section className="workspace alerts-layout">
<form className="alert-filter" onSubmit={applyFilters}>
<Field label="サイト">
<select
value={filters.siteId}
onChange={(event) => setFilters({ ...filters, siteId: event.target.value })}
>
<option value="">すべて</option>
{sites.map((site) => (
<option key={site.siteId} value={site.siteId}>
{site.alias}
</option>
))}
</select>
</Field>
<Field label="種類">
<input
list="alert-types"
value={filters.alertType}
onChange={(event) => setFilters({ ...filters, alertType: event.target.value })}
placeholder="certificate_expiring"
/>
<datalist id="alert-types">
{alertTypes.map((type) => (
<option key={type} value={type} />
))}
</datalist>
</Field>
<Field label="開始日時">
<input
type="datetime-local"
value={filters.from}
onChange={(event) => setFilters({ ...filters, from: event.target.value })}
/>
</Field>
<Field label="終了日時">
<input
type="datetime-local"
value={filters.to}
onChange={(event) => setFilters({ ...filters, to: event.target.value })}
/>
</Field>
<div className="filter-actions">
<button className="primary" disabled={busy}>
<Filter aria-hidden="true" size={18} />
絞り込み
</button>
<button type="button" className="secondary" onClick={resetFilters} disabled={busy}>
<RotateCcw aria-hidden="true" size={18} />
解除
</button>
</div>
</form>
<div className="alert-list">
{alerts.length === 0 ? (
<div className="empty">アラート履歴はまだありません</div>
) : (
alerts.map((alert) => (
<article className={`alert-row ${alert.readAt ? '' : 'unread'}`} key={alert.alertId}>
<div className="alert-main">
<div className="alert-meta">
<span>{alert.alertType}</span>
<time dateTime={alert.occurredAt}>{formatDateTime(alert.occurredAt)}</time>
</div>
<h2>{alert.siteAlias || '削除済みサイト'}</h2>
<p>{alert.content}</p>
<small>{alert.siteUrl || 'サイト情報なし'}</small>
</div>
<div className="alert-side">
<span className={alert.readAt ? 'status-pill read' : 'status-pill unread'}>
{alert.readAt ? '既読' : '未読'}
</span>
<button
className="icon-button"
onClick={() => markRead(alert.alertId)}
disabled={Boolean(alert.readAt)}
aria-label="既読にする"
title="既読にする"
>
<Check aria-hidden="true" size={18} />
</button>
<ConfirmDialog
title="アラートを削除"
description={`選択したアラートを削除します。`}
onConfirm={() => deleteAlert(alert.alertId)}
trigger={
<button className="icon-button danger" aria-label="削除" title="削除">
<Trash2 aria-hidden="true" size={18} />
</button>
}
/>
</div>
</article>
))
)}
</div>
</section>
</main>
);
}

View File

@@ -0,0 +1,161 @@
import { useEffect, useState } from 'react';
import { ShieldCheck } from 'lucide-react';
import { request } from '../api/client.js';
import { Field } from '../components/Field.jsx';
function validateAuthForm(mode, form) {
if (mode === 'register' && !form.displayName.trim()) {
throw new Error('表示名を入力してください');
}
if (!form.username.trim()) {
throw new Error('ユーザー名を入力してください');
}
if (!/^[a-zA-Z0-9_.-]{3,40}$/.test(form.username.trim())) {
throw new Error('ユーザー名は3〜40文字の英数字、_、.、-で入力してください');
}
if (!form.password) {
throw new Error('パスワードを入力してください');
}
if (mode === 'register' && form.password.length < 12) {
throw new Error('パスワードは12文字以上で入力してください');
}
if (form.otp && !/^\d{6}$/.test(form.otp.trim())) {
throw new Error('2段階認証コードは6桁の数字で入力してください');
}
}
export function AuthPanel({ mode, onModeChange, onAuthed }) {
const [form, setForm] = useState({ displayName: '', username: '', password: '', otp: '' });
const [totpRequired, setTotpRequired] = useState(false);
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
useEffect(() => {
setError('');
setTotpRequired(false);
}, [mode]);
async function submit(event) {
event.preventDefault();
setBusy(true);
setError('');
try {
validateAuthForm(mode, form);
const endpoint = mode === 'login' ? '/api/auth/login' : '/api/auth/register';
const payload =
mode === 'login'
? {
username: form.username.trim(),
password: form.password,
otp: form.otp.trim() || undefined,
}
: {
displayName: form.displayName.trim(),
username: form.username.trim(),
password: form.password,
};
const data = await request(endpoint, {
method: 'POST',
body: JSON.stringify(payload),
});
onAuthed(data.user);
} catch (err) {
if (err.totpRequired) {
setTotpRequired(true);
setError('2段階認証コードを入力してください');
return;
}
setError(err.message);
} finally {
setBusy(false);
}
}
return (
<main className="auth-shell">
<section className="auth-panel">
<div className="brand-row">
<ShieldCheck aria-hidden="true" />
<div>
<h1>CertRemind</h1>
<p>SSL/TLS証明書期限監視システム</p>
</div>
</div>
<div className="segmented" role="tablist" aria-label="認証モード">
<button
type="button"
className={mode === 'login' ? 'active' : ''}
onClick={() => onModeChange('login')}
>
ログイン
</button>
<button
type="button"
className={mode === 'register' ? 'active' : ''}
onClick={() => onModeChange('register')}
>
登録
</button>
</div>
<form onSubmit={submit} className="stack">
{mode === 'register' ? (
<Field label="表示名">
<input
value={form.displayName}
onChange={(event) => setForm({ ...form, displayName: event.target.value })}
autoComplete="name"
maxLength="80"
required
/>
</Field>
) : null}
<Field label="ユーザー名">
<input
value={form.username}
onChange={(event) => setForm({ ...form, username: event.target.value })}
autoComplete="username"
minLength="3"
maxLength="40"
pattern="[a-zA-Z0-9_.-]+"
required
/>
</Field>
<Field label="パスワード">
<input
value={form.password}
onChange={(event) => setForm({ ...form, password: event.target.value })}
type="password"
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
minLength={mode === 'register' ? 12 : 1}
maxLength="200"
required
/>
</Field>
{mode === 'login' && totpRequired ? (
<Field label="2段階認証コード">
<input
value={form.otp}
onChange={(event) => setForm({ ...form, otp: event.target.value })}
inputMode="numeric"
maxLength="6"
pattern="[0-9]{6}"
autoComplete="one-time-code"
required
/>
</Field>
) : null}
{error ? <p className="error">{error}</p> : null}
<button className="primary" disabled={busy}>
{busy ? '処理中...' : mode === 'login' ? 'ログイン' : '登録'}
</button>
</form>
</section>
</main>
);
}

View File

@@ -0,0 +1,275 @@
import { useEffect, useState } from 'react';
import { ArrowLeft, BellRing, Link, Pencil, Plus, Trash2 } from 'lucide-react';
import { request } from '../api/client.js';
import { ConfirmDialog } from '../components/ConfirmDialog.jsx';
import { Field } from '../components/Field.jsx';
import { useToast } from '../components/Toast.jsx';
function validateWebhookForm(form) {
if (!form.alias.trim()) {
throw new Error('エイリアス名を入力してください');
}
if (form.alias.trim().length > 120) {
throw new Error('エイリアス名は120文字以内で入力してください');
}
if (!form.url.trim()) {
throw new Error('Webhook URL を入力してください');
}
const url = new URL(form.url.trim());
if (url.protocol !== 'https:') {
throw new Error('Webhook URL は HTTPS で入力してください');
}
}
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = `${base64String}${padding}`.replaceAll('-', '+').replaceAll('_', '/');
const rawData = window.atob(base64);
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
}
function permissionText(permission) {
if (permission === 'granted') return '許可済み';
if (permission === 'denied') return '拒否されています';
return '未設定';
}
export function NotificationMethodsView({ onBack }) {
const [webhooks, setWebhooks] = useState([]);
const [pushSubscriptions, setPushSubscriptions] = useState([]);
const [vapidPublicKey, setVapidPublicKey] = useState('');
const [form, setForm] = useState({ alias: '', url: '' });
const [editingId, setEditingId] = useState('');
const [permission, setPermission] = useState(
typeof Notification === 'undefined' ? 'unsupported' : Notification.permission,
);
const [busy, setBusy] = useState(false);
const { showToast } = useToast();
async function loadMethods() {
const data = await request('/api/notification-methods');
setWebhooks(data.webhooks);
setPushSubscriptions(data.pushSubscriptions);
setVapidPublicKey(data.vapidPublicKey);
}
useEffect(() => {
loadMethods().catch((err) => showToast({ type: 'error', message: err.message }));
}, [showToast]);
async function submitWebhook(event) {
event.preventDefault();
setBusy(true);
try {
validateWebhookForm(form);
const endpoint = editingId
? `/api/notification-methods/webhooks/${editingId}`
: '/api/notification-methods/webhooks';
await request(endpoint, {
method: editingId ? 'PATCH' : 'POST',
body: JSON.stringify({ alias: form.alias.trim(), url: form.url.trim() }),
});
setForm({ alias: '', url: '' });
setEditingId('');
showToast({
type: 'success',
message: editingId ? 'Webhookを更新しました' : 'Webhookを登録しました',
});
await loadMethods();
} catch (err) {
showToast({ type: 'error', message: err.message });
} finally {
setBusy(false);
}
}
function startEdit(webhook) {
setEditingId(webhook.notificationMethodId);
setForm({ alias: webhook.alias, url: webhook.url });
}
async function deleteWebhook(methodId) {
setBusy(true);
try {
await request(`/api/notification-methods/webhooks/${methodId}`, { method: 'DELETE' });
showToast({ type: 'success', message: 'Webhookを削除しました' });
await loadMethods();
} catch (err) {
showToast({ type: 'error', message: err.message });
} finally {
setBusy(false);
}
}
async function subscribePush() {
setBusy(true);
try {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
throw new Error('このブラウザはプッシュ通知に対応していません');
}
if (!vapidPublicKey) {
throw new Error('VAPID public key が設定されていません');
}
const nextPermission = await Notification.requestPermission();
setPermission(nextPermission);
if (nextPermission !== 'granted') {
throw new Error('ブラウザ通知が許可されませんでした');
}
const registration = await navigator.serviceWorker.register('/push-sw.js');
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
await request('/api/notification-methods/push-subscriptions', {
method: 'POST',
body: JSON.stringify(subscription.toJSON()),
});
showToast({ type: 'success', message: 'プッシュ通知を登録しました' });
await loadMethods();
} catch (err) {
showToast({ type: 'error', message: err.message });
} finally {
setBusy(false);
}
}
return (
<main className="app-shell">
<header className="topbar">
<button className="back-button" onClick={onBack}>
<ArrowLeft aria-hidden="true" size={18} />
サイト一覧
</button>
<div className="settings-title">
<span className="eyebrow">通知方法</span>
<h1>通知方法の管理</h1>
</div>
</header>
<section className="workspace notification-layout">
<form className="panel" onSubmit={submitWebhook}>
<div className="panel-heading">
<Link aria-hidden="true" size={20} />
<div>
<h2>Webhook</h2>
<p>Slack互換Webhookとして送信します</p>
</div>
</div>
<div className="webhook-form">
<Field label="エイリアス名">
<input
value={form.alias}
onChange={(event) => setForm({ ...form, alias: event.target.value })}
placeholder="Slack 通知"
maxLength="120"
required
/>
</Field>
<Field label="URL">
<input
value={form.url}
onChange={(event) => setForm({ ...form, url: event.target.value })}
placeholder="https://hooks.slack.com/services/..."
maxLength="2048"
required
/>
</Field>
<button className="primary" disabled={busy}>
<Plus aria-hidden="true" size={18} />
{editingId ? '更新' : '登録'}
</button>
</div>
</form>
<section className="panel">
<h2>登録済みWebhook</h2>
<div className="method-list">
{webhooks.length === 0 ? (
<div className="empty">Webhookはまだ登録されていません</div>
) : (
webhooks.map((webhook) => (
<article className="method-row" key={webhook.notificationMethodId}>
<div>
<strong>{webhook.alias}</strong>
<span>{webhook.url}</span>
</div>
<div className="site-actions">
<button
className="icon-button"
type="button"
onClick={() => startEdit(webhook)}
aria-label={`${webhook.alias}を編集`}
title="編集"
>
<Pencil aria-hidden="true" size={18} />
</button>
<ConfirmDialog
title="Webhookを削除"
description={`${webhook.alias}を通知方法から削除します。`}
onConfirm={() => deleteWebhook(webhook.notificationMethodId)}
disabled={busy}
trigger={
<button
className="icon-button danger"
type="button"
aria-label={`${webhook.alias}を削除`}
title="削除"
>
<Trash2 aria-hidden="true" size={18} />
</button>
}
/>
</div>
</article>
))
)}
</div>
</section>
<section className="panel">
<div className="panel-heading">
<BellRing aria-hidden="true" size={20} />
<div>
<h2>プッシュ通知</h2>
<p>ブラウザ許可状態: {permissionText(permission)}</p>
</div>
</div>
<div className="push-actions">
<button
className="secondary"
type="button"
onClick={subscribePush}
disabled={busy || !vapidPublicKey}
>
<BellRing aria-hidden="true" size={18} />
このブラウザを登録
</button>
{!vapidPublicKey ? (
<p className="muted">VAPID public key を設定すると登録できます</p>
) : null}
</div>
<div className="method-list">
{pushSubscriptions.length === 0 ? (
<div className="empty">登録済みのブラウザはありません</div>
) : (
pushSubscriptions.map((subscription) => (
<article className="method-row" key={subscription.notificationMethodId}>
<div>
<strong>Browser Push</strong>
<span>{subscription.endpoint}</span>
</div>
</article>
))
)}
</div>
</section>
</section>
</main>
);
}

View File

@@ -0,0 +1,313 @@
import { useEffect, useMemo, useState } from 'react';
import { ArrowLeft, Bell, Plus, Save, Trash2 } from 'lucide-react';
import { request } from '../api/client.js';
import { ConfirmDialog } from '../components/ConfirmDialog.jsx';
import { Field } from '../components/Field.jsx';
import { useToast } from '../components/Toast.jsx';
const unitToHours = {
hours: 1,
days: 24,
weeks: 168,
};
function toDisplayThreshold(thresholdHours) {
if (thresholdHours % unitToHours.weeks === 0) {
return { value: thresholdHours / unitToHours.weeks, unit: 'weeks' };
}
if (thresholdHours % unitToHours.days === 0) {
return { value: thresholdHours / unitToHours.days, unit: 'days' };
}
return { value: thresholdHours, unit: 'hours' };
}
function toThresholdHours(condition) {
return Number.parseInt(condition.value, 10) * unitToHours[condition.unit];
}
function formatCertificateValue(value) {
if (!value) return '未取得';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '未取得';
return date.toLocaleString();
}
export function SiteSettingsPanel({ site, onBack }) {
const [alias, setAlias] = useState(site.alias);
const [savedAlias, setSavedAlias] = useState(site.alias);
const [conditions, setConditions] = useState([{ value: 7, unit: 'days' }]);
const [availableWebhooks, setAvailableWebhooks] = useState([]);
const [webhookMethodIds, setWebhookMethodIds] = useState([]);
const [pushEnabled, setPushEnabled] = useState(false);
const [busy, setBusy] = useState(false);
const { showToast } = useToast();
const previewText = useMemo(() => {
const hours = conditions
.map(toThresholdHours)
.filter((value) => Number.isInteger(value) && value > 0)
.sort((a, b) => a - b);
if (hours.length === 0) return '通知タイミング未設定';
return hours.map((hour) => `${hour} 時間前`).join(' / ');
}, [conditions]);
useEffect(() => {
setAlias(site.alias);
setSavedAlias(site.alias);
}, [site.alias, site.siteId]);
useEffect(() => {
async function loadSettings() {
const data = await request(`/api/sites/${site.siteId}/settings`);
setAvailableWebhooks(data.settings.availableWebhooks);
if (data.settings.conditions.length > 0) {
setConditions(
data.settings.conditions.map((condition) => toDisplayThreshold(condition.thresholdHours)),
);
setWebhookMethodIds(data.settings.conditions[0].webhookMethodIds);
setPushEnabled(data.settings.conditions[0].pushEnabled);
}
}
loadSettings().catch((err) => showToast({ type: 'error', message: err.message }));
}, [showToast, site.siteId]);
function updateCondition(index, patch) {
setConditions((current) =>
current.map((condition, currentIndex) =>
currentIndex === index ? { ...condition, ...patch } : condition,
),
);
}
function addCondition() {
setConditions((current) => [...current, { value: 7, unit: 'days' }]);
}
function removeCondition(index) {
setConditions((current) => current.filter((_, currentIndex) => currentIndex !== index));
}
function toggleWebhook(methodId) {
setWebhookMethodIds((current) =>
current.includes(methodId) ? current.filter((id) => id !== methodId) : [...current, methodId],
);
}
async function saveSettings(event) {
event.preventDefault();
setBusy(true);
try {
const nextAlias = alias.trim();
if (!nextAlias) {
throw new Error('エイリアス名を入力してください');
}
if (nextAlias.length > 120) {
throw new Error('エイリアス名は120文字以内で入力してください');
}
const thresholdHours = conditions.map(toThresholdHours);
if (thresholdHours.some((value) => !Number.isInteger(value) || value <= 0 || value > 17520)) {
throw new Error('通知タイミングは 1 時間以上、2 年以内で指定してください');
}
if (nextAlias !== savedAlias) {
await request(`/api/sites/${site.siteId}`, {
method: 'PATCH',
body: JSON.stringify({ alias: nextAlias }),
});
setAlias(nextAlias);
setSavedAlias(nextAlias);
}
await request(`/api/sites/${site.siteId}/settings`, {
method: 'PUT',
body: JSON.stringify({
conditions: thresholdHours.map((value) => ({ thresholdHours: value })),
webhookMethodIds,
pushEnabled,
}),
});
showToast({ type: 'success', message: '設定を保存しました' });
} catch (err) {
showToast({ type: 'error', message: err.message });
} finally {
setBusy(false);
}
}
async function deleteSettings() {
setBusy(true);
try {
await request(`/api/sites/${site.siteId}/settings`, { method: 'DELETE' });
setConditions([{ value: 7, unit: 'days' }]);
setWebhookMethodIds([]);
setPushEnabled(false);
showToast({ type: 'success', message: '設定を削除しました' });
} catch (err) {
showToast({ type: 'error', message: err.message });
} finally {
setBusy(false);
}
}
return (
<main className="app-shell">
<header className="topbar">
<button className="back-button" onClick={onBack}>
<ArrowLeft aria-hidden="true" size={18} />
サイト一覧
</button>
<div className="settings-title">
<span className="eyebrow">サイト設定</span>
<h1>{savedAlias}</h1>
<p>{site.url}</p>
</div>
</header>
<section className="workspace settings-layout">
<form className="settings-form" onSubmit={saveSettings}>
<section className="panel">
<h2>基本情報</h2>
<Field label="エイリアス名">
<input
value={alias}
onChange={(event) => setAlias(event.target.value)}
maxLength="120"
required
/>
</Field>
</section>
<section className="panel">
<h2>証明書情報</h2>
<dl className="certificate-details">
<div>
<dt>発行元</dt>
<dd>{site.certificateIssuer || '未取得'}</dd>
</div>
<div>
<dt>発行日時</dt>
<dd>{formatCertificateValue(site.certificateIssuedAt)}</dd>
</div>
<div>
<dt>失効日時</dt>
<dd>{formatCertificateValue(site.certificateExpiresAt)}</dd>
</div>
</dl>
</section>
<section className="panel">
<div className="panel-heading">
<Bell aria-hidden="true" size={20} />
<div>
<h2>通知タイミング</h2>
<p>{previewText}</p>
</div>
</div>
<div className="condition-list">
{conditions.map((condition, index) => (
<div className="condition-row" key={index}>
<Field label="値">
<input
type="number"
min="1"
max="17520"
value={condition.value}
onChange={(event) => updateCondition(index, { value: event.target.value })}
required
/>
</Field>
<Field label="単位">
<select
value={condition.unit}
onChange={(event) => updateCondition(index, { unit: event.target.value })}
>
<option value="hours">時間前</option>
<option value="days">日前</option>
<option value="weeks">週間前</option>
</select>
</Field>
<ConfirmDialog
title="通知タイミングを削除"
description="この通知タイミングを設定から削除します。"
onConfirm={() => removeCondition(index)}
disabled={conditions.length === 1}
trigger={
<button
type="button"
className="icon-button danger"
disabled={conditions.length === 1}
aria-label="通知タイミングを削除"
title="削除"
>
<Trash2 aria-hidden="true" size={18} />
</button>
}
/>
</div>
))}
</div>
<button type="button" className="secondary" onClick={addCondition}>
<Plus aria-hidden="true" size={18} />
タイミングを追加
</button>
</section>
<section className="panel">
<h2>通知方法</h2>
<label className="check-row locked">
<input type="checkbox" checked readOnly />
アプリ内アラート
</label>
<label className="check-row">
<input
type="checkbox"
checked={pushEnabled}
onChange={(event) => setPushEnabled(event.target.checked)}
/>
プッシュ通知
</label>
<div className="webhook-box">
<h3>Webhook</h3>
{availableWebhooks.length === 0 ? (
<p className="muted">登録済みのWebhookはありません</p>
) : (
availableWebhooks.map((webhook) => (
<label className="check-row" key={webhook.notificationMethodId}>
<input
type="checkbox"
checked={webhookMethodIds.includes(webhook.notificationMethodId)}
onChange={() => toggleWebhook(webhook.notificationMethodId)}
/>
<span>
<strong>{webhook.alias}</strong>
<small>{webhook.url}</small>
</span>
</label>
))
)}
</div>
</section>
<div className="settings-actions">
<button className="primary" disabled={busy}>
<Save aria-hidden="true" size={18} />
保存
</button>
<ConfirmDialog
title="サイト設定を削除"
description="このサイトの通知タイミングと通知方法の設定を削除します。"
onConfirm={deleteSettings}
disabled={busy}
trigger={
<button type="button" className="secondary danger-text" disabled={busy}>
設定を削除
</button>
}
/>
</div>
</form>
</section>
</main>
);
}

View File

@@ -0,0 +1,190 @@
import { useEffect, useState } from 'react';
import { Plus, Settings, Trash2 } from 'lucide-react';
import { request } from '../api/client.js';
import { ConfirmDialog } from '../components/ConfirmDialog.jsx';
import { Field } from '../components/Field.jsx';
import { useToast } from '../components/Toast.jsx';
function validateSiteForm(form) {
if (!form.url.trim()) {
throw new Error('監視 URL を入力してください');
}
const rawUrl = /^https?:\/\//i.test(form.url.trim())
? form.url.trim()
: `https://${form.url.trim()}`;
const url = new URL(rawUrl);
if (url.protocol !== 'https:') {
throw new Error('監視 URL は HTTPS で入力してください');
}
if (form.alias.trim().length > 120) {
throw new Error('エイリアス名は120文字以内で入力してください');
}
}
function parseCertificateDate(value) {
if (!value) return '未確認';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '未確認';
return date;
}
function formatCertificateDate(value) {
const date = parseCertificateDate(value);
if (typeof date === 'string') return date;
return new Intl.DateTimeFormat('ja-JP', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(date);
}
function formatCertificateDateTime(value) {
const date = parseCertificateDate(value);
if (typeof date === 'string') return date;
return new Intl.DateTimeFormat('ja-JP', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date);
}
function certificateTooltip(site) {
const dateTime = formatCertificateDateTime(site.certificateExpiresAt);
const lines = [`${dateTime}`];
if (site.certificateCheckError) {
lines.push(`直近の取得失敗: ${site.certificateCheckError}`);
}
return lines.join('\n');
}
export function SitesView({ user, onConfigureSite }) {
const [sites, setSites] = useState([]);
const [form, setForm] = useState({ url: '', alias: '' });
const [busy, setBusy] = useState(false);
const { showToast } = useToast();
async function loadSites() {
const data = await request('/api/sites');
setSites(data.sites);
}
useEffect(() => {
loadSites().catch((err) => showToast({ type: 'error', message: err.message }));
}, [showToast]);
async function addSite(event) {
event.preventDefault();
setBusy(true);
try {
validateSiteForm(form);
await request('/api/sites', {
method: 'POST',
body: JSON.stringify({ url: form.url.trim(), alias: form.alias.trim() }),
});
setForm({ url: '', alias: '' });
await loadSites();
} catch (err) {
showToast({ type: 'error', message: err.message });
} finally {
setBusy(false);
}
}
async function deleteSite(siteId) {
try {
await request(`/api/sites/${siteId}`, { method: 'DELETE' });
setSites((current) => current.filter((site) => site.siteId !== siteId));
} catch (err) {
showToast({ type: 'error', message: err.message });
}
}
return (
<main className="app-shell">
<header className="topbar">
<div>
<span className="eyebrow">CertRemind</span>
<h1>サイト一覧</h1>
</div>
<div className="user-box">
<span>{user.displayName}</span>
</div>
</header>
<section className="workspace">
<form className="site-form" onSubmit={addSite}>
<Field label="監視 URL">
<input
placeholder="https://example.com"
value={form.url}
onChange={(event) => setForm({ ...form, url: event.target.value })}
maxLength="2048"
required
/>
</Field>
<Field label="エイリアス名">
<input
placeholder="Production"
value={form.alias}
onChange={(event) => setForm({ ...form, alias: event.target.value })}
maxLength="120"
/>
</Field>
<button className="primary add-button" disabled={busy} aria-label="サイトを追加">
<Plus aria-hidden="true" size={18} />
追加
</button>
</form>
<div className="site-list">
{sites.length === 0 ? (
<div className="empty">監視対象のサイトはまだ登録されていません</div>
) : (
sites.map((site) => (
<article className="site-row" key={site.siteId}>
<div className="site-main">
<strong>{site.alias}</strong>
<span>{site.url}</span>
</div>
<div
className="certificate-summary"
title={certificateTooltip(site)}
aria-label={`証明書期限 ${certificateTooltip(site)}`}
>
<span>{formatCertificateDate(site.certificateExpiresAt)}</span>
{site.certificateCheckError ? <small>取得失敗</small> : null}
</div>
<div className="site-actions">
<button
className="icon-button"
onClick={() => onConfigureSite(site)}
aria-label={`${site.alias} の設定`}
title="設定"
>
<Settings aria-hidden="true" size={18} />
</button>
<ConfirmDialog
title="サイトを削除"
description={`${site.alias} と関連する通知条件を削除します。`}
onConfirm={() => deleteSite(site.siteId)}
trigger={
<button
className="icon-button danger"
aria-label={`${site.alias} を削除`}
title="削除"
>
<Trash2 aria-hidden="true" size={18} />
</button>
}
/>
</div>
</article>
))
)}
</div>
</section>
</main>
);
}

1000
src/client/styles/app.css Normal file

File diff suppressed because it is too large Load Diff

46
src/server/app.js Normal file
View File

@@ -0,0 +1,46 @@
import { Hono } from 'hono';
import { serveStatic } from '@hono/node-server/serve-static';
import { HttpError } from './utils/httpErrors.js';
import { loadUser } from './middleware/auth.js';
import { requireCsrf } from './middleware/csrf.js';
import accountRoutes from './modules/account/routes.js';
import alertRoutes from './modules/alerts/routes.js';
import authRoutes from './modules/auth/routes.js';
import notificationMethodRoutes from './modules/notificationMethods/routes.js';
import siteRoutes from './modules/sites/routes.js';
import { logger } from './utils/logger.js';
export function createApp() {
const app = new Hono();
app.use('*', loadUser);
app.use('/api/*', requireCsrf);
app.get('/api/health', (c) => c.json({ ok: true, service: 'CertRemind' }));
app.route('/api/auth', authRoutes);
app.route('/api/account', accountRoutes);
app.route('/api/sites', siteRoutes);
app.route('/api/alerts', alertRoutes);
app.route('/api/notification-methods', notificationMethodRoutes);
app.use('/assets/*', serveStatic({ root: './dist' }));
app.get('*', serveStatic({ path: './dist/index.html' }));
app.onError((error, c) => {
if (error instanceof HttpError) {
return c.json({ error: error.message, details: error.details }, error.status);
}
logger.error('api.unhandled_error', {
message: error.message,
stack: envSafeStack(error),
});
return c.json({ error: 'サーバーエラーが発生しました' }, 500);
});
return app;
}
function envSafeStack(error) {
return process.env.NODE_ENV === 'production' ? undefined : error.stack;
}

11
src/server/config/env.js Normal file
View File

@@ -0,0 +1,11 @@
export const env = {
nodeEnv: process.env.NODE_ENV ?? 'development',
port: Number.parseInt(process.env.PORT ?? '3000', 10),
databaseUrl:
process.env.DATABASE_URL ?? 'postgres://certremind:certremind@localhost:5432/certremind',
cookieSecure: process.env.NODE_ENV === 'production',
vapidPublicKey: process.env.VAPID_PUBLIC_KEY ?? '',
vapidPrivateKey: process.env.VAPID_PRIVATE_KEY ?? '',
vapidSubject: process.env.VAPID_SUBJECT ?? 'mailto:admin@example.com',
opensslPath: process.env.OPENSSL_PATH ?? 'openssl',
};

10
src/server/db/pool.js Normal file
View File

@@ -0,0 +1,10 @@
import pg from 'pg';
import { env } from '../config/env.js';
export const pool = new pg.Pool({
connectionString: env.databaseUrl,
});
export async function query(text, params = []) {
return pool.query(text, params);
}

16
src/server/index.js Normal file
View File

@@ -0,0 +1,16 @@
import { serve } from '@hono/node-server';
import { createApp } from './app.js';
import { env } from './config/env.js';
const app = createApp();
serve(
{
fetch: app.fetch,
port: env.port,
hostname: '127.0.0.1',
},
(info) => {
console.log(`CertRemind API listening on http://${info.address}:${info.port}`);
},
);

View File

@@ -0,0 +1,20 @@
import { runCertificateMonitoring } from '../modules/monitoring/monitor.js';
import { logger } from '../utils/logger.js';
try {
logger.info('monitor.start');
const result = await runCertificateMonitoring();
logger.info('monitor.finish', {
checkedSites: result.checkedSites,
alertsCreated: result.alertsCreated,
startedAt: result.startedAt,
finishedAt: result.finishedAt,
});
console.log(JSON.stringify(result, null, 2));
} catch (error) {
logger.error('monitor.failed', {
message: error.message,
stack: process.env.NODE_ENV === 'production' ? undefined : error.stack,
});
process.exitCode = 1;
}

View File

@@ -0,0 +1,61 @@
import { pool } from '../db/pool.js';
import { runCertificateMonitoring } from '../modules/monitoring/monitor.js';
import { logger } from '../utils/logger.js';
const MONITOR_INTERVAL_MS = 60 * 60 * 1000;
let timer = null;
let shuttingDown = false;
async function runOnce() {
logger.info('monitor.worker.tick.start');
const result = await runCertificateMonitoring();
logger.info('monitor.worker.tick.finish', {
checkedSites: result.checkedSites,
alertsCreated: result.alertsCreated,
startedAt: result.startedAt,
finishedAt: result.finishedAt,
});
}
function scheduleNextRun() {
if (shuttingDown) return;
const nextRunAt = new Date(Date.now() + MONITOR_INTERVAL_MS);
logger.info('monitor.worker.next_run_scheduled', { nextRunAt });
timer = setTimeout(runLoop, MONITOR_INTERVAL_MS);
}
async function runLoop() {
try {
await runOnce();
} catch (error) {
logger.error('monitor.worker.tick.failed', {
message: error.message,
stack: process.env.NODE_ENV === 'production' ? undefined : error.stack,
});
} finally {
scheduleNextRun();
}
}
async function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
if (timer) clearTimeout(timer);
logger.info('monitor.worker.shutdown', { signal });
await pool.end();
}
process.on('SIGINT', () => {
shutdown('SIGINT').finally(() => process.exit(0));
});
process.on('SIGTERM', () => {
shutdown('SIGTERM').finally(() => process.exit(0));
});
logger.info('monitor.worker.start', { intervalMs: MONITOR_INTERVAL_MS });
runLoop();

View File

@@ -0,0 +1,61 @@
import { getCookie, setCookie, deleteCookie } from 'hono/cookie';
import { query } from '../db/pool.js';
import { env } from '../config/env.js';
import { unauthorized } from '../utils/httpErrors.js';
const SESSION_COOKIE = 'certremind_session';
const SESSION_DAYS = 30;
export async function createSession(c, userId) {
const expiresAt = new Date(Date.now() + SESSION_DAYS * 24 * 60 * 60 * 1000);
const result = await query(
`INSERT INTO sessions (user_id, expires_at)
VALUES ($1, $2)
RETURNING session_id`,
[userId, expiresAt],
);
setCookie(c, SESSION_COOKIE, result.rows[0].session_id, {
path: '/',
httpOnly: true,
sameSite: 'Lax',
secure: env.cookieSecure,
expires: expiresAt,
});
}
export async function destroySession(c) {
const sessionId = getCookie(c, SESSION_COOKIE);
if (sessionId) {
await query('DELETE FROM sessions WHERE session_id = $1', [sessionId]);
}
deleteCookie(c, SESSION_COOKIE, { path: '/' });
}
export async function loadUser(c, next) {
const sessionId = getCookie(c, SESSION_COOKIE);
if (!sessionId) {
c.set('user', null);
return next();
}
const result = await query(
`SELECT u.user_id, u.username, u.display_name
FROM sessions s
JOIN users u ON u.user_id = s.user_id
WHERE s.session_id = $1
AND s.expires_at > now()`,
[sessionId],
);
c.set('user', result.rows[0] ?? null);
return next();
}
export async function requireAuth(c, next) {
const user = c.get('user');
if (!user) {
throw unauthorized();
}
return next();
}

View File

@@ -0,0 +1,33 @@
import { getCookie, setCookie } from 'hono/cookie';
import { randomBytes } from 'node:crypto';
import { forbidden } from '../utils/httpErrors.js';
import { env } from '../config/env.js';
const CSRF_COOKIE = 'certremind_csrf';
const HEADER = 'x-csrf-token';
export function issueCsrf(c) {
const existing = getCookie(c, CSRF_COOKIE);
const token = existing || randomBytes(32).toString('base64url');
setCookie(c, CSRF_COOKIE, token, {
path: '/',
httpOnly: false,
sameSite: 'Lax',
secure: env.cookieSecure,
});
return token;
}
export async function requireCsrf(c, next) {
if (['GET', 'HEAD', 'OPTIONS'].includes(c.req.method)) {
return next();
}
const cookieToken = getCookie(c, CSRF_COOKIE);
const headerToken = c.req.header(HEADER);
if (!cookieToken || !headerToken || cookieToken !== headerToken) {
throw forbidden('CSRF トークンが不正です');
}
return next();
}

View File

@@ -0,0 +1,201 @@
import { Hono } from 'hono';
import { hash, verify } from '@node-rs/argon2';
import * as otplib from 'otplib';
import { z } from 'zod';
import { pool, query } from '../../db/pool.js';
import { destroySession, requireAuth } from '../../middleware/auth.js';
import { badRequest, unauthorized } from '../../utils/httpErrors.js';
const router = new Hono();
const profileSchema = z.object({
displayName: z.string().trim().min(1).max(80),
});
const passwordSchema = z.object({
currentPassword: z.string().min(1).max(200),
newPassword: z.string().min(12).max(200),
});
const totpVerifySchema = z.object({
secret: z.string().trim().min(1).max(128),
otp: z
.string()
.trim()
.regex(/^\d{6}$/),
});
const totpDeleteSchema = z.object({
currentPassword: z.string().min(1).max(200),
otp: z
.string()
.trim()
.regex(/^\d{6}$/),
});
const deleteAccountSchema = z.object({
currentPassword: z.string().min(1).max(200),
});
function publicAccount(row) {
return {
userId: row.user_id,
username: row.username,
displayName: row.display_name,
totpEnabled: Boolean(row.otp_secret),
};
}
async function getAccount(userId) {
const result = await query(
`SELECT u.user_id, u.username, u.display_name, u.password_hash, t.otp_secret
FROM users u
LEFT JOIN user_totp t ON t.user_id = u.user_id
WHERE u.user_id = $1`,
[userId],
);
return result.rows[0] ?? null;
}
async function verifyCurrentPassword(userId, password) {
const account = await getAccount(userId);
if (!account) {
throw unauthorized();
}
const valid = await verify(account.password_hash, password);
if (!valid) {
throw badRequest('現在のパスワードが違います');
}
return account;
}
router.use('*', requireAuth);
router.get('/', async (c) => {
const account = await getAccount(c.get('user').user_id);
return c.json({ account: publicAccount(account) });
});
router.patch('/profile', async (c) => {
const body = profileSchema.safeParse(await c.req.json().catch(() => null));
if (!body.success) {
throw badRequest('入力内容を確認してください', body.error.flatten());
}
const result = await query(
`WITH updated AS (
UPDATE users
SET display_name = $2
WHERE user_id = $1
RETURNING user_id, username, display_name
)
SELECT u.user_id, u.username, u.display_name, t.otp_secret
FROM updated u
LEFT JOIN user_totp t ON t.user_id = u.user_id`,
[c.get('user').user_id, body.data.displayName],
);
return c.json({ account: publicAccount(result.rows[0]) });
});
router.patch('/password', async (c) => {
const body = passwordSchema.safeParse(await c.req.json().catch(() => null));
if (!body.success) {
throw badRequest('入力内容を確認してください', body.error.flatten());
}
const userId = c.get('user').user_id;
await verifyCurrentPassword(userId, body.data.currentPassword);
const passwordHash = await hash(body.data.newPassword, {
algorithm: 2,
memoryCost: 19456,
timeCost: 2,
parallelism: 1,
});
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('UPDATE users SET password_hash = $2 WHERE user_id = $1', [
userId,
passwordHash,
]);
await client.query('DELETE FROM sessions WHERE user_id = $1', [userId]);
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK').catch(() => {});
throw error;
} finally {
client.release();
}
await destroySession(c);
return c.json({ ok: true });
});
router.post('/totp/setup', async (c) => {
const user = c.get('user');
const account = await getAccount(user.user_id);
if (account.otp_secret) {
throw badRequest('2段階認証はすでに有効です');
}
const secret = otplib.generateSecret();
const otpauthUrl = otplib.generateURI({ issuer: 'CertRemind', label: account.username, secret });
return c.json({ secret, otpauthUrl });
});
router.post('/totp/verify', async (c) => {
const body = totpVerifySchema.safeParse(await c.req.json().catch(() => null));
if (!body.success) {
throw badRequest('認証コードを確認してください', body.error.flatten());
}
const otpValid = await otplib.verify({ token: body.data.otp, secret: body.data.secret });
if (!otpValid.valid) {
throw badRequest('認証コードが違います');
}
await query(
`INSERT INTO user_totp (user_id, otp_secret)
VALUES ($1, $2)
ON CONFLICT (user_id) DO UPDATE SET otp_secret = EXCLUDED.otp_secret`,
[c.get('user').user_id, body.data.secret],
);
return c.json({ ok: true });
});
router.delete('/totp', async (c) => {
const body = totpDeleteSchema.safeParse(await c.req.json().catch(() => null));
if (!body.success) {
throw badRequest('入力内容を確認してください', body.error.flatten());
}
const account = await verifyCurrentPassword(c.get('user').user_id, body.data.currentPassword);
if (!account.otp_secret) {
throw badRequest('2段階認証は有効ではありません');
}
const otpValid = await otplib.verify({ token: body.data.otp, secret: account.otp_secret });
if (!otpValid.valid) {
throw badRequest('認証コードが違います');
}
await query('DELETE FROM user_totp WHERE user_id = $1', [c.get('user').user_id]);
return c.json({ ok: true });
});
router.delete('/', async (c) => {
const body = deleteAccountSchema.safeParse(await c.req.json().catch(() => null));
if (!body.success) {
throw badRequest('入力内容を確認してください', body.error.flatten());
}
const userId = c.get('user').user_id;
await verifyCurrentPassword(userId, body.data.currentPassword);
await query('DELETE FROM users WHERE user_id = $1', [userId]);
await destroySession(c);
return c.json({ ok: true });
});
export default router;

View File

@@ -0,0 +1,158 @@
import { Hono } from 'hono';
import { z } from 'zod';
import { query } from '../../db/pool.js';
import { requireAuth } from '../../middleware/auth.js';
import { badRequest, notFound } from '../../utils/httpErrors.js';
const router = new Hono();
const filterSchema = z.object({
siteId: z.string().uuid().optional(),
alertType: z.string().trim().min(1).max(80).optional(),
from: z.string().datetime({ offset: true }).optional(),
to: z.string().datetime({ offset: true }).optional(),
});
function serializeAlert(row) {
return {
alertId: row.alert_id,
userId: row.user_id,
siteId: row.site_id,
siteAlias: row.site_alias,
siteUrl: row.site_url,
alertType: row.alert_type,
content: row.content,
occurredAt: row.occurred_at,
readAt: row.read_at,
deliveryChannels: row.delivery_channels ?? [],
deliveryResult: row.delivery_result ?? {},
};
}
router.use('*', requireAuth);
router.get('/', async (c) => {
const rawFilters = {
siteId: c.req.query('siteId') || undefined,
alertType: c.req.query('alertType') || undefined,
from: c.req.query('from') || undefined,
to: c.req.query('to') || undefined,
};
const filters = filterSchema.safeParse(rawFilters);
if (!filters.success) {
throw badRequest('絞り込み条件を確認してください', filters.error.flatten());
}
const conditions = ['a.user_id = $1'];
const params = [c.get('user').user_id];
if (filters.data.siteId) {
params.push(filters.data.siteId);
conditions.push(`a.site_id = $${params.length}`);
}
if (filters.data.alertType) {
params.push(filters.data.alertType);
conditions.push(`a.alert_type = $${params.length}`);
}
if (filters.data.from) {
params.push(filters.data.from);
conditions.push(`a.occurred_at >= $${params.length}`);
}
if (filters.data.to) {
params.push(filters.data.to);
conditions.push(`a.occurred_at <= $${params.length}`);
}
const result = await query(
`SELECT a.alert_id,
a.user_id,
a.site_id,
s.alias AS site_alias,
s.url AS site_url,
a.alert_type,
a.content,
a.occurred_at,
a.read_at,
a.delivery_channels,
a.delivery_result
FROM alert_history a
LEFT JOIN sites s ON s.site_id = a.site_id
WHERE ${conditions.join(' AND ')}
ORDER BY a.occurred_at DESC, a.created_at DESC
LIMIT 200`,
params,
);
return c.json({ alerts: result.rows.map(serializeAlert) });
});
router.patch('/:alertId/read', async (c) => {
const alertId = z.string().uuid().safeParse(c.req.param('alertId'));
if (!alertId.success) {
throw badRequest('アラート ID が不正です');
}
const result = await query(
`WITH updated AS (
UPDATE alert_history
SET read_at = COALESCE(read_at, now())
WHERE user_id = $1
AND alert_id = $2
RETURNING alert_id,
user_id,
site_id,
alert_type,
content,
occurred_at,
read_at,
delivery_channels,
delivery_result
)
SELECT u.alert_id,
u.user_id,
u.site_id,
s.alias AS site_alias,
s.url AS site_url,
u.alert_type,
u.content,
u.occurred_at,
u.read_at,
u.delivery_channels,
u.delivery_result
FROM updated u
LEFT JOIN sites s ON s.site_id = u.site_id`,
[c.get('user').user_id, alertId.data],
);
if (!result.rows[0]) {
throw notFound('アラートが見つかりません');
}
return c.json({ alert: serializeAlert(result.rows[0]) });
});
router.delete('/:alertId', async (c) => {
const alertId = z.string().uuid().safeParse(c.req.param('alertId'));
if (!alertId.success) {
throw badRequest('アラート ID が不正です');
}
const result = await query(
`DELETE FROM alert_history
WHERE user_id = $1
AND alert_id = $2
RETURNING alert_id`,
[c.get('user').user_id, alertId.data],
);
if (!result.rows[0]) {
throw notFound('アラートが見つかりません');
}
return c.json({ ok: true });
});
export default router;

View File

@@ -0,0 +1,118 @@
import { Hono } from 'hono';
import { hash, verify } from '@node-rs/argon2';
import * as otplib from 'otplib';
import { z } from 'zod';
import { query } from '../../db/pool.js';
import { createSession, destroySession, requireAuth } from '../../middleware/auth.js';
import { issueCsrf } from '../../middleware/csrf.js';
import { badRequest, unauthorized } from '../../utils/httpErrors.js';
const router = new Hono();
const registerSchema = z.object({
displayName: z.string().trim().min(1).max(80),
username: z
.string()
.trim()
.min(3)
.max(40)
.regex(/^[a-zA-Z0-9_.-]+$/),
password: z.string().min(12).max(200),
});
const loginSchema = z.object({
username: z.string().trim().min(1).max(80),
password: z.string().min(1).max(200),
otp: z
.string()
.trim()
.regex(/^\d{6}$/)
.optional(),
});
function publicUser(row) {
return {
userId: row.user_id,
username: row.username,
displayName: row.display_name,
};
}
router.get('/csrf', (c) => c.json({ csrfToken: issueCsrf(c) }));
router.post('/register', async (c) => {
const body = registerSchema.safeParse(await c.req.json().catch(() => null));
if (!body.success) {
throw badRequest('入力内容を確認してください', body.error.flatten());
}
const passwordHash = await hash(body.data.password, {
algorithm: 2,
memoryCost: 19456,
timeCost: 2,
parallelism: 1,
});
try {
const result = await query(
`INSERT INTO users (username, password_hash, display_name)
VALUES ($1, $2, $3)
RETURNING user_id, username, display_name`,
[body.data.username, passwordHash, body.data.displayName],
);
await createSession(c, result.rows[0].user_id);
return c.json({ user: publicUser(result.rows[0]) }, 201);
} catch (error) {
if (error?.code === '23505') {
throw badRequest('このユーザー名は使用できません');
}
throw error;
}
});
router.post('/login', async (c) => {
const body = loginSchema.safeParse(await c.req.json().catch(() => null));
if (!body.success) {
throw unauthorized('ユーザー名またはパスワードが違います');
}
const result = await query(
`SELECT u.user_id,
u.username,
u.display_name,
u.password_hash,
t.otp_secret
FROM users u
LEFT JOIN user_totp t ON t.user_id = u.user_id
WHERE u.username = $1`,
[body.data.username],
);
const user = result.rows[0];
const valid = user ? await verify(user.password_hash, body.data.password) : false;
if (!valid) {
throw unauthorized('ユーザー名またはパスワードが違います');
}
if (user.otp_secret) {
if (!body.data.otp) {
return c.json({ totpRequired: true }, 401);
}
const otpValid = await otplib.verify({ token: body.data.otp, secret: user.otp_secret });
if (!otpValid.valid) {
throw unauthorized('ユーザー名またはパスワードが違います');
}
}
await createSession(c, user.user_id);
return c.json({ user: publicUser(user) });
});
router.post('/logout', requireAuth, async (c) => {
await destroySession(c);
return c.json({ ok: true });
});
router.get('/me', requireAuth, (c) => c.json({ user: publicUser(c.get('user')) }));
export default router;

View File

@@ -0,0 +1,136 @@
import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import { env } from '../../config/env.js';
const DEFAULT_TIMEOUT_MS = 10_000;
const WINDOWS_OPENSSL_FALLBACKS = [
'C:\\Program Files\\Git\\usr\\bin\\openssl.exe',
'C:\\Program Files\\Git\\mingw64\\bin\\openssl.exe',
];
function resolveOpenSslPath() {
if (env.opensslPath !== 'openssl') return env.opensslPath;
return WINDOWS_OPENSSL_FALLBACKS.find((path) => existsSync(path)) ?? env.opensslPath;
}
function runOpenSsl(args, { input, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
return new Promise((resolve, reject) => {
const child = spawn(resolveOpenSslPath(), args, { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
child.kill('SIGKILL');
reject(new Error('OpenSSL の実行がタイムアウトしました'));
}, timeoutMs);
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk) => {
stdout += chunk;
});
child.stderr.on('data', (chunk) => {
stderr += chunk;
});
child.on('error', (error) => {
if (settled) return;
settled = true;
clearTimeout(timer);
reject(new Error(`OpenSSL を実行できませんでした: ${error.message}`));
});
child.on('close', (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (code !== 0) {
reject(new Error(stderr.trim() || `OpenSSL が終了コード ${code} で失敗しました`));
return;
}
resolve(stdout);
});
if (input) {
child.stdin.end(input);
} else {
child.stdin.end();
}
});
}
function extractCertificate(output) {
const match = output.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/);
if (!match) {
throw new Error('証明書を取得できませんでした');
}
return match[0];
}
function parseCertificateDate(value, errorMessage) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
throw new Error(errorMessage);
}
return date;
}
function parseCertificateDetails(output) {
const details = Object.fromEntries(
output
.trim()
.split(/\r?\n/)
.map((line) => {
const separatorIndex = line.indexOf('=');
return separatorIndex === -1
? [line, '']
: [line.slice(0, separatorIndex), line.slice(separatorIndex + 1)];
}),
);
if (!details.issuer) {
throw new Error('証明書の発行元を解析できませんでした');
}
if (!details.notBefore) {
throw new Error('証明書の発行日時を解析できませんでした');
}
if (!details.notAfter) {
throw new Error('証明書の期限を解析できませんでした');
}
return {
issuer: details.issuer,
issuedAt: parseCertificateDate(details.notBefore, '証明書の発行日時を解析できませんでした'),
expiresAt: parseCertificateDate(details.notAfter, '証明書の期限を解析できませんでした'),
};
}
export async function getCertificateExpiry(urlValue, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
const url = new URL(urlValue);
const host = url.hostname;
const port = url.port || '443';
const startedAt = Date.now();
const remainingTimeout = () => Math.max(1, timeoutMs - (Date.now() - startedAt));
const serverOutput = await runOpenSsl(
['s_client', '-servername', host, '-connect', `${host}:${port}`, '-showcerts'],
{ timeoutMs },
);
const certificate = extractCertificate(serverOutput);
const detailsOutput = await runOpenSsl(['x509', '-noout', '-issuer', '-startdate', '-enddate'], {
input: certificate,
timeoutMs: remainingTimeout(),
});
const details = parseCertificateDetails(detailsOutput);
return {
host,
port,
issuer: details.issuer,
issuedAt: details.issuedAt,
expiresAt: details.expiresAt,
hoursUntilExpiry: Math.floor((details.expiresAt.getTime() - Date.now()) / 3_600_000),
};
}

View File

@@ -0,0 +1,229 @@
import { pool } from '../../db/pool.js';
import { getCertificateExpiry } from './certificate.js';
import { deliverNotifications } from './notifications.js';
const DEFAULT_CONCURRENCY = 4;
function alertChannels(condition) {
const channels = ['app'];
if ((condition.webhook_method_ids ?? []).length > 0) channels.push('webhook');
if (condition.push_enabled) channels.push('push');
return channels;
}
function buildExpiryMessage(site, condition, certificate) {
return {
title: `CertRemind: ${site.alias} の証明書期限が近づいています`,
body: `${site.url} の証明書は ${certificate.expiresAt.toISOString()} に期限切れになります。通知条件: ${condition.threshold_hours} 時間前`,
severity: certificate.hoursUntilExpiry <= 24 ? 'error' : 'warning',
};
}
function buildFailureMessage(site, error) {
return {
title: `CertRemind: ${site.alias} の証明書を確認できません`,
body: `${site.url} の証明書取得に失敗しました。${error.message}`,
severity: 'error',
};
}
async function loadMonitoringTargets(client) {
const result = await client.query(
`SELECT s.site_id,
s.user_id,
s.url,
s.alias,
COALESCE(
jsonb_agg(
jsonb_build_object(
'site_alert_condition_id', c.site_alert_condition_id,
'threshold_hours', c.threshold_hours,
'webhook_method_ids', c.webhook_method_ids,
'push_enabled', c.push_enabled
)
ORDER BY c.threshold_hours ASC
) FILTER (WHERE c.site_alert_condition_id IS NOT NULL),
'[]'::jsonb
) AS conditions
FROM sites s
LEFT JOIN site_alert_conditions c ON c.site_id = s.site_id
GROUP BY s.site_id
ORDER BY s.created_at ASC`,
);
return result.rows;
}
async function loadWebhooks(client, userId, methodIds) {
if (!methodIds || methodIds.length === 0) return [];
const result = await client.query(
`SELECT notification_method_id, alias, url
FROM notification_methods
WHERE user_id = $1
AND notification_type = 'webhook'
AND notification_method_id = ANY($2::uuid[])`,
[userId, methodIds],
);
return result.rows;
}
async function loadPushSubscriptions(client, userId) {
const result = await client.query(
`SELECT notification_method_id, push_endpoint, push_p256dh, push_auth
FROM notification_methods
WHERE user_id = $1
AND notification_type = 'push'`,
[userId],
);
return result.rows;
}
async function createAlert(
client,
{ site, alertType, message, deliveryChannels, deliveryResult, dedupeKey },
) {
const result = await client.query(
`INSERT INTO alert_history
(user_id, site_id, alert_type, content, delivery_channels, delivery_result, dedupe_key)
VALUES ($1, $2, $3, $4, $5::text[], $6::jsonb, $7)
ON CONFLICT (user_id, dedupe_key) DO NOTHING
RETURNING alert_id`,
[
site.user_id,
site.site_id,
alertType,
message.body,
deliveryChannels,
JSON.stringify(deliveryResult),
dedupeKey,
],
);
return result.rows[0] ?? null;
}
async function recordCertificateSuccess(client, site, certificate) {
await client.query(
`UPDATE sites
SET certificate_issuer = $2,
certificate_issued_at = $3,
certificate_expires_at = $4,
certificate_checked_at = now(),
certificate_check_error = NULL
WHERE site_id = $1`,
[site.site_id, certificate.issuer, certificate.issuedAt, certificate.expiresAt],
);
}
async function recordCertificateFailure(client, site, error) {
await client.query(
`UPDATE sites
SET certificate_checked_at = now(),
certificate_check_error = $2
WHERE site_id = $1`,
[site.site_id, error.message],
);
}
async function processMatchingCondition(client, site, condition, certificate) {
if (certificate.hoursUntilExpiry > condition.threshold_hours) {
return { alerted: false };
}
const message = buildExpiryMessage(site, condition, certificate);
const webhooks = await loadWebhooks(client, site.user_id, condition.webhook_method_ids);
const pushSubscriptions = condition.push_enabled
? await loadPushSubscriptions(client, site.user_id)
: [];
const deliveryResult = await deliverNotifications({
webhooks,
pushSubscriptions,
pushEnabled: condition.push_enabled,
message,
});
const dedupeKey = `certificate-expiring:${site.site_id}:${condition.threshold_hours}:${certificate.expiresAt.toISOString()}`;
const alert = await createAlert(client, {
site,
alertType: 'certificate_expiring',
message,
deliveryChannels: alertChannels(condition),
deliveryResult,
dedupeKey,
});
return { alerted: Boolean(alert), dedupeKey };
}
async function processFailure(client, site, error) {
await recordCertificateFailure(client, site, error);
const message = buildFailureMessage(site, error);
const day = new Date().toISOString().slice(0, 10);
const alert = await createAlert(client, {
site,
alertType: 'certificate_check_failed',
message,
deliveryChannels: ['app'],
deliveryResult: { app: { ok: true }, error: error.message },
dedupeKey: `certificate-check-failed:${site.site_id}:${day}`,
});
return { alerted: Boolean(alert) };
}
async function processSite(client, site) {
try {
const certificate = await getCertificateExpiry(site.url);
await recordCertificateSuccess(client, site, certificate);
const results = [];
for (const condition of site.conditions) {
results.push(await processMatchingCondition(client, site, condition, certificate));
}
return {
siteId: site.site_id,
ok: true,
expiresAt: certificate.expiresAt,
alertsCreated: results.filter((result) => result.alerted).length,
};
} catch (error) {
const failure = await processFailure(client, site, error);
return {
siteId: site.site_id,
ok: false,
error: error.message,
alertsCreated: failure.alerted ? 1 : 0,
};
}
}
async function runLimited(items, concurrency, worker) {
const results = [];
let index = 0;
async function runNext() {
const currentIndex = index;
index += 1;
if (currentIndex >= items.length) return;
results[currentIndex] = await worker(items[currentIndex]);
await runNext();
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, runNext));
return results;
}
export async function runCertificateMonitoring({ concurrency = DEFAULT_CONCURRENCY } = {}) {
const client = await pool.connect();
const startedAt = new Date();
try {
const targets = await loadMonitoringTargets(client);
const results = await runLimited(targets, concurrency, (site) => processSite(client, site));
const finishedAt = new Date();
return {
startedAt,
finishedAt,
checkedSites: targets.length,
alertsCreated: results.reduce((total, result) => total + result.alertsCreated, 0),
results,
};
} finally {
client.release();
}
}

View File

@@ -0,0 +1,93 @@
import webpush from 'web-push';
import { env } from '../../config/env.js';
let webPushConfigured = false;
function configureWebPush() {
if (webPushConfigured) return true;
if (!env.vapidPublicKey || !env.vapidPrivateKey) return false;
webpush.setVapidDetails(env.vapidSubject, env.vapidPublicKey, env.vapidPrivateKey);
webPushConfigured = true;
return true;
}
export async function sendWebhookNotification(webhook, message) {
const response = await fetch(webhook.url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
text: message.title,
attachments: [
{
color: message.severity === 'error' ? 'danger' : 'warning',
text: message.body,
},
],
}),
});
if (!response.ok) {
throw new Error(`Webhook 送信に失敗しました (${response.status})`);
}
}
export async function sendPushNotification(subscription, message) {
if (!configureWebPush()) {
throw new Error('VAPID key が設定されていません');
}
await webpush.sendNotification(
{
endpoint: subscription.push_endpoint,
keys: {
p256dh: subscription.push_p256dh,
auth: subscription.push_auth,
},
},
JSON.stringify({
title: message.title,
body: message.body,
}),
);
}
export async function deliverNotifications({ webhooks, pushSubscriptions, pushEnabled, message }) {
const results = {
app: { ok: true },
webhooks: [],
push: [],
};
for (const webhook of webhooks) {
try {
await sendWebhookNotification(webhook, message);
results.webhooks.push({ notificationMethodId: webhook.notification_method_id, ok: true });
} catch (error) {
results.webhooks.push({
notificationMethodId: webhook.notification_method_id,
ok: false,
error: error.message,
});
}
}
if (pushEnabled) {
for (const subscription of pushSubscriptions) {
try {
await sendPushNotification(subscription, message);
results.push.push({
notificationMethodId: subscription.notification_method_id,
ok: true,
});
} catch (error) {
results.push.push({
notificationMethodId: subscription.notification_method_id,
ok: false,
error: error.message,
});
}
}
}
return results;
}

View File

@@ -0,0 +1,184 @@
import { Hono } from 'hono';
import { z } from 'zod';
import { env } from '../../config/env.js';
import { query } from '../../db/pool.js';
import { requireAuth } from '../../middleware/auth.js';
import { badRequest, notFound } from '../../utils/httpErrors.js';
import { normalizeHttpsUrl } from '../../utils/urlPolicy.js';
const router = new Hono();
const webhookSchema = z.object({
alias: z.string().trim().min(1).max(120),
url: z.string().trim().min(1).max(2048),
});
const pushSubscriptionSchema = z.object({
endpoint: z.string().trim().url().max(2048),
keys: z.object({
p256dh: z.string().trim().min(1).max(512),
auth: z.string().trim().min(1).max(512),
}),
});
function serializeWebhook(row) {
return {
notificationMethodId: row.notification_method_id,
alias: row.alias,
url: row.url,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function serializePushSubscription(row) {
return {
notificationMethodId: row.notification_method_id,
endpoint: row.push_endpoint,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
router.use('*', requireAuth);
router.get('/', async (c) => {
const user = c.get('user');
const result = await query(
`SELECT notification_method_id,
notification_type,
alias,
url,
push_endpoint,
created_at,
updated_at
FROM notification_methods
WHERE user_id = $1
ORDER BY created_at DESC`,
[user.user_id],
);
return c.json({
webhooks: result.rows
.filter((row) => row.notification_type === 'webhook')
.map(serializeWebhook),
pushSubscriptions: result.rows
.filter((row) => row.notification_type === 'push')
.map(serializePushSubscription),
vapidPublicKey: env.vapidPublicKey,
});
});
router.post('/webhooks', async (c) => {
const body = webhookSchema.safeParse(await c.req.json().catch(() => null));
if (!body.success) {
throw badRequest('入力内容を確認してください', body.error.flatten());
}
let normalizedUrl;
try {
normalizedUrl = normalizeHttpsUrl(body.data.url);
} catch (error) {
throw badRequest(error.message);
}
const result = await query(
`INSERT INTO notification_methods (user_id, notification_type, alias, url)
VALUES ($1, 'webhook', $2, $3)
RETURNING notification_method_id, alias, url, created_at, updated_at`,
[c.get('user').user_id, body.data.alias, normalizedUrl],
);
return c.json({ webhook: serializeWebhook(result.rows[0]) }, 201);
});
router.patch('/webhooks/:methodId', async (c) => {
const methodId = z.string().uuid().safeParse(c.req.param('methodId'));
if (!methodId.success) {
throw badRequest('通知方法 ID が不正です');
}
const body = webhookSchema.safeParse(await c.req.json().catch(() => null));
if (!body.success) {
throw badRequest('入力内容を確認してください', body.error.flatten());
}
let normalizedUrl;
try {
normalizedUrl = normalizeHttpsUrl(body.data.url);
} catch (error) {
throw badRequest(error.message);
}
const result = await query(
`UPDATE notification_methods
SET alias = $3,
url = $4
WHERE user_id = $1
AND notification_method_id = $2
AND notification_type = 'webhook'
RETURNING notification_method_id, alias, url, created_at, updated_at`,
[c.get('user').user_id, methodId.data, body.data.alias, normalizedUrl],
);
if (!result.rows[0]) {
throw notFound('Webhook が見つかりません');
}
return c.json({ webhook: serializeWebhook(result.rows[0]) });
});
router.delete('/webhooks/:methodId', async (c) => {
const methodId = z.string().uuid().safeParse(c.req.param('methodId'));
if (!methodId.success) {
throw badRequest('通知方法 ID が不正です');
}
const result = await query(
`DELETE FROM notification_methods
WHERE user_id = $1
AND notification_method_id = $2
AND notification_type = 'webhook'
RETURNING notification_method_id`,
[c.get('user').user_id, methodId.data],
);
if (!result.rows[0]) {
throw notFound('Webhook が見つかりません');
}
return c.json({ ok: true });
});
router.post('/push-subscriptions', async (c) => {
const body = pushSubscriptionSchema.safeParse(await c.req.json().catch(() => null));
if (!body.success) {
throw badRequest('購読情報を確認してください', body.error.flatten());
}
const endpoint = new URL(body.data.endpoint);
if (endpoint.protocol !== 'https:') {
throw badRequest('Push endpoint は HTTPS である必要があります');
}
const user = c.get('user');
await query(
`DELETE FROM notification_methods
WHERE user_id = $1
AND notification_type = 'push'
AND push_endpoint = $2`,
[user.user_id, body.data.endpoint],
);
const result = await query(
`INSERT INTO notification_methods
(user_id, notification_type, alias, push_endpoint, push_p256dh, push_auth)
VALUES ($1, 'push', 'Browser Push', $2, $3, $4)
RETURNING notification_method_id, push_endpoint, created_at, updated_at`,
[user.user_id, body.data.endpoint, body.data.keys.p256dh, body.data.keys.auth],
);
return c.json({ pushSubscription: serializePushSubscription(result.rows[0]) }, 201);
});
export default router;

View File

@@ -0,0 +1,360 @@
import { Hono } from 'hono';
import { z } from 'zod';
import { pool, query } from '../../db/pool.js';
import { requireAuth } from '../../middleware/auth.js';
import { getCertificateExpiry } from '../monitoring/certificate.js';
import { badRequest, notFound } from '../../utils/httpErrors.js';
import { defaultAliasForUrl, normalizeHttpsUrl } from '../../utils/urlPolicy.js';
const router = new Hono();
const INITIAL_CERTIFICATE_TIMEOUT_MS = 3_000;
const createSiteSchema = z.object({
url: z.string().trim().min(1).max(2048),
alias: z.string().trim().max(120).optional(),
});
const updateSiteSchema = z.object({
alias: z.string().trim().min(1).max(120),
});
const settingsSchema = z.object({
conditions: z
.array(
z.object({
thresholdHours: z.number().int().min(1).max(17520),
}),
)
.min(1)
.max(12),
webhookMethodIds: z.array(z.string().uuid()).max(20).default([]),
pushEnabled: z.boolean().default(false),
});
function serializeSite(row) {
return {
siteId: row.site_id,
url: row.url,
alias: row.alias,
certificateIssuer: row.certificate_issuer,
certificateIssuedAt: row.certificate_issued_at,
certificateExpiresAt: row.certificate_expires_at,
certificateCheckedAt: row.certificate_checked_at,
certificateCheckError: row.certificate_check_error,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function serializeCondition(row) {
return {
siteAlertConditionId: row.site_alert_condition_id,
conditionType: row.condition_type,
thresholdHours: row.threshold_hours,
webhookMethodIds: row.webhook_method_ids ?? [],
pushEnabled: row.push_enabled,
};
}
function serializeWebhook(row) {
return {
notificationMethodId: row.notification_method_id,
alias: row.alias,
url: row.url,
};
}
async function getSiteForUser(userId, siteId) {
const result = await query(
`SELECT site_id,
url,
alias,
certificate_issuer,
certificate_issued_at,
certificate_expires_at,
certificate_checked_at,
certificate_check_error,
created_at,
updated_at
FROM sites
WHERE user_id = $1 AND site_id = $2`,
[userId, siteId],
);
return result.rows[0] ?? null;
}
async function assertWebhookOwnership(userId, webhookMethodIds) {
if (webhookMethodIds.length === 0) return;
const result = await query(
`SELECT notification_method_id
FROM notification_methods
WHERE user_id = $1
AND notification_type = 'webhook'
AND notification_method_id = ANY($2::uuid[])`,
[userId, webhookMethodIds],
);
if (result.rowCount !== new Set(webhookMethodIds).size) {
throw badRequest('選択された Webhook が見つかりません');
}
}
router.use('*', requireAuth);
router.get('/', async (c) => {
const user = c.get('user');
const result = await query(
`SELECT site_id,
url,
alias,
certificate_issuer,
certificate_issued_at,
certificate_expires_at,
certificate_checked_at,
certificate_check_error,
created_at,
updated_at
FROM sites
WHERE user_id = $1
ORDER BY created_at DESC`,
[user.user_id],
);
return c.json({ sites: result.rows.map(serializeSite) });
});
router.get('/:siteId/settings', async (c) => {
const user = c.get('user');
const site = await getSiteForUser(user.user_id, c.req.param('siteId'));
if (!site) {
throw notFound('サイトが見つかりません');
}
const [conditions, webhooks] = await Promise.all([
query(
`SELECT site_alert_condition_id,
condition_type,
threshold_hours,
webhook_method_ids,
push_enabled
FROM site_alert_conditions
WHERE site_id = $1
ORDER BY threshold_hours ASC`,
[site.site_id],
),
query(
`SELECT notification_method_id, alias, url
FROM notification_methods
WHERE user_id = $1
AND notification_type = 'webhook'
ORDER BY created_at DESC`,
[user.user_id],
),
]);
return c.json({
site: serializeSite(site),
settings: {
appAlertEnabled: true,
conditions: conditions.rows.map(serializeCondition),
availableWebhooks: webhooks.rows.map(serializeWebhook),
},
});
});
router.put('/:siteId/settings', async (c) => {
const body = settingsSchema.safeParse(await c.req.json().catch(() => null));
if (!body.success) {
throw badRequest('入力内容を確認してください', body.error.flatten());
}
const user = c.get('user');
const site = await getSiteForUser(user.user_id, c.req.param('siteId'));
if (!site) {
throw notFound('サイトが見つかりません');
}
const uniqueThresholds = new Set(
body.data.conditions.map((condition) => condition.thresholdHours),
);
if (uniqueThresholds.size !== body.data.conditions.length) {
throw badRequest('同じ通知タイミングは重複して登録できません');
}
const webhookMethodIds = [...new Set(body.data.webhookMethodIds)];
await assertWebhookOwnership(user.user_id, webhookMethodIds);
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('DELETE FROM site_alert_conditions WHERE site_id = $1', [site.site_id]);
const inserted = [];
for (const condition of body.data.conditions) {
const result = await client.query(
`INSERT INTO site_alert_conditions
(site_id, condition_type, threshold_hours, webhook_method_ids, push_enabled)
VALUES ($1, 'expires_within_hours', $2, $3::uuid[], $4)
RETURNING site_alert_condition_id,
condition_type,
threshold_hours,
webhook_method_ids,
push_enabled`,
[site.site_id, condition.thresholdHours, webhookMethodIds, body.data.pushEnabled],
);
inserted.push(serializeCondition(result.rows[0]));
}
await client.query('COMMIT');
return c.json({
site: serializeSite(site),
settings: {
appAlertEnabled: true,
conditions: inserted,
},
});
} catch (error) {
await client.query('ROLLBACK').catch(() => {});
throw error;
} finally {
client.release();
}
});
router.delete('/:siteId/settings', async (c) => {
const user = c.get('user');
const site = await getSiteForUser(user.user_id, c.req.param('siteId'));
if (!site) {
throw notFound('サイトが見つかりません');
}
await query('DELETE FROM site_alert_conditions WHERE site_id = $1', [site.site_id]);
return c.json({ ok: true });
});
router.post('/', async (c) => {
const body = createSiteSchema.safeParse(await c.req.json().catch(() => null));
if (!body.success) {
throw badRequest('入力内容を確認してください', body.error.flatten());
}
let normalizedUrl;
try {
normalizedUrl = normalizeHttpsUrl(body.data.url);
} catch (error) {
throw badRequest(error.message);
}
const alias = body.data.alias || defaultAliasForUrl(normalizedUrl);
const user = c.get('user');
let certificate;
try {
certificate = await getCertificateExpiry(normalizedUrl, {
timeoutMs: INITIAL_CERTIFICATE_TIMEOUT_MS,
});
} catch (error) {
throw badRequest('証明書の期限を取得できませんでした', {
reason: error.message,
});
}
try {
const result = await query(
`INSERT INTO sites
(user_id, url, alias, certificate_issuer, certificate_issued_at, certificate_expires_at, certificate_checked_at)
VALUES ($1, $2, $3, $4, $5, $6, now())
RETURNING site_id,
url,
alias,
certificate_issuer,
certificate_issued_at,
certificate_expires_at,
certificate_checked_at,
certificate_check_error,
created_at,
updated_at`,
[
user.user_id,
normalizedUrl,
alias,
certificate.issuer,
certificate.issuedAt,
certificate.expiresAt,
],
);
return c.json({ site: serializeSite(result.rows[0]) }, 201);
} catch (error) {
if (error?.code === '23505') {
throw badRequest('このサイトはすでに登録されています');
}
throw error;
}
});
router.get('/:siteId', async (c) => {
const user = c.get('user');
const result = await query(
`SELECT site_id,
url,
alias,
certificate_issuer,
certificate_issued_at,
certificate_expires_at,
certificate_checked_at,
certificate_check_error,
created_at,
updated_at
FROM sites
WHERE user_id = $1 AND site_id = $2`,
[user.user_id, c.req.param('siteId')],
);
if (!result.rows[0]) {
throw notFound('サイトが見つかりません');
}
return c.json({ site: serializeSite(result.rows[0]) });
});
router.patch('/:siteId', async (c) => {
const body = updateSiteSchema.safeParse(await c.req.json().catch(() => null));
if (!body.success) {
throw badRequest('入力内容を確認してください', body.error.flatten());
}
const user = c.get('user');
const result = await query(
`UPDATE sites
SET alias = $3
WHERE user_id = $1 AND site_id = $2
RETURNING site_id,
url,
alias,
certificate_issuer,
certificate_issued_at,
certificate_expires_at,
certificate_checked_at,
certificate_check_error,
created_at,
updated_at`,
[user.user_id, c.req.param('siteId'), body.data.alias],
);
if (!result.rows[0]) {
throw notFound('サイトが見つかりません');
}
return c.json({ site: serializeSite(result.rows[0]) });
});
router.delete('/:siteId', async (c) => {
const user = c.get('user');
const result = await query(
'DELETE FROM sites WHERE user_id = $1 AND site_id = $2 RETURNING site_id',
[user.user_id, c.req.param('siteId')],
);
if (!result.rows[0]) {
throw notFound('サイトが見つかりません');
}
return c.json({ ok: true });
});
export default router;

View File

@@ -0,0 +1,23 @@
export class HttpError extends Error {
constructor(status, message, details) {
super(message);
this.status = status;
this.details = details;
}
}
export function badRequest(message, details) {
return new HttpError(400, message, details);
}
export function unauthorized(message = '認証が必要です') {
return new HttpError(401, message);
}
export function forbidden(message = '操作できません') {
return new HttpError(403, message);
}
export function notFound(message = '見つかりません') {
return new HttpError(404, message);
}

View File

@@ -0,0 +1,23 @@
function write(level, event, details = {}) {
const payload = {
level,
event,
timestamp: new Date().toISOString(),
...details,
};
const line = JSON.stringify(payload);
if (level === 'error') {
console.error(line);
} else {
console.log(line);
}
}
export const logger = {
info(event, details) {
write('info', event, details);
},
error(event, details) {
write('error', event, details);
},
};

View File

@@ -0,0 +1,46 @@
import { z } from 'zod';
const blockedHosts = new Set(['localhost', 'localhost.localdomain']);
function isPrivateIPv4(hostname) {
const parts = hostname.split('.').map((part) => Number.parseInt(part, 10));
if (parts.length !== 4 || parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)) {
return false;
}
const [a, b] = parts;
return (
a === 10 ||
a === 127 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254) ||
a === 0
);
}
export function normalizeHttpsUrl(value) {
const raw = z.string().trim().min(1).max(2048).parse(value);
const withScheme = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
const url = new URL(withScheme);
if (url.protocol !== 'https:') {
throw new Error('HTTPS の URL を指定してください');
}
url.hash = '';
url.username = '';
url.password = '';
const hostname = url.hostname.toLowerCase();
if (!hostname || blockedHosts.has(hostname) || isPrivateIPv4(hostname) || hostname === '::1') {
throw new Error('監視できないホストです');
}
return url.toString();
}
export function defaultAliasForUrl(urlValue) {
const url = new URL(urlValue);
return url.hostname;
}

293
tests/apiSecurity.test.js Normal file
View File

@@ -0,0 +1,293 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createApp } from '../src/server/app.js';
import { query } from '../src/server/db/pool.js';
import { getCertificateExpiry } from '../src/server/modules/monitoring/certificate.js';
vi.mock('../src/server/db/pool.js', () => ({
pool: {
connect: vi.fn(),
},
query: vi.fn(),
}));
vi.mock('../src/server/modules/monitoring/certificate.js', () => ({
getCertificateExpiry: vi.fn(),
}));
const USER_ID = '11111111-1111-4111-8111-111111111111';
const SITE_ID = '22222222-2222-4222-8222-222222222222';
const ALERT_ID = '33333333-3333-4333-8333-333333333333';
const WEBHOOK_ID = '44444444-4444-4444-8444-444444444444';
function authCookie() {
return 'certremind_session=session-1';
}
function csrfCookie() {
return `${authCookie()}; certremind_csrf=csrf-token`;
}
function mockAuthenticatedUser() {
query.mockImplementation(async (sql, params) => {
if (sql.includes('FROM sessions s')) {
expect(params).toEqual(['session-1']);
return {
rows: [
{
user_id: USER_ID,
username: 'alice',
display_name: 'Alice',
},
],
};
}
throw new Error(`Unexpected query: ${sql}`);
});
}
describe('API security boundaries', () => {
beforeEach(() => {
query.mockReset();
getCertificateExpiry.mockReset();
});
it('requires a CSRF token for state-changing API requests', async () => {
const app = createApp();
const response = await app.request('/api/sites', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ url: 'https://example.com', alias: 'Example' }),
});
expect(response.status).toBe(403);
await expect(response.json()).resolves.toMatchObject({
error: 'CSRF トークンが不正です',
});
expect(query).not.toHaveBeenCalled();
});
it('requires authentication for site listing', async () => {
const app = createApp();
const response = await app.request('/api/sites');
expect(response.status).toBe(401);
await expect(response.json()).resolves.toMatchObject({
error: '認証が必要です',
});
expect(query).not.toHaveBeenCalled();
});
it('uses the session user when listing sites', async () => {
mockAuthenticatedUser();
query.mockImplementationOnce(query.getMockImplementation()).mockImplementationOnce(async (sql, params) => {
expect(sql).toContain('FROM sites');
expect(params).toEqual([USER_ID]);
return {
rows: [
{
site_id: SITE_ID,
url: 'https://example.com/',
alias: 'Example',
certificate_issuer: 'C = US, O = Example CA, CN = Example Root',
certificate_issued_at: '2026-01-01T00:00:00.000Z',
certificate_expires_at: '2026-12-31T00:00:00.000Z',
certificate_checked_at: '2026-05-21T00:00:00.000Z',
certificate_check_error: null,
created_at: '2026-05-20T00:00:00.000Z',
updated_at: '2026-05-21T00:00:00.000Z',
},
],
};
});
const app = createApp();
const response = await app.request('/api/sites', {
headers: {
Cookie: authCookie(),
},
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
sites: [
{
siteId: SITE_ID,
certificateIssuer: 'C = US, O = Example CA, CN = Example Root',
certificateIssuedAt: '2026-01-01T00:00:00.000Z',
certificateExpiresAt: '2026-12-31T00:00:00.000Z',
},
],
});
});
it('rejects private webhook URLs before insertion', async () => {
mockAuthenticatedUser();
const app = createApp();
const response = await app.request('/api/notification-methods/webhooks', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: csrfCookie(),
'x-csrf-token': 'csrf-token',
},
body: JSON.stringify({ alias: 'Internal', url: 'https://127.0.0.1/hook' }),
});
expect(response.status).toBe(400);
expect(query).toHaveBeenCalledTimes(1);
});
it('stores the initial certificate metadata when creating a site', async () => {
const issuer = 'C = US, O = Example CA, CN = Example Root';
const issuedAt = new Date('2026-01-01T00:00:00.000Z');
const expiresAt = new Date('2026-12-31T00:00:00.000Z');
getCertificateExpiry.mockResolvedValue({ issuer, issuedAt, expiresAt, hoursUntilExpiry: 24 * 30 });
mockAuthenticatedUser();
query.mockImplementationOnce(query.getMockImplementation()).mockImplementationOnce(async (sql, params) => {
expect(sql).toContain('INSERT INTO sites');
expect(sql).toContain('certificate_issuer');
expect(sql).toContain('certificate_issued_at');
expect(sql).toContain('certificate_expires_at');
expect(sql).toContain('certificate_checked_at');
expect(params).toEqual([USER_ID, 'https://example.com/', 'Example', issuer, issuedAt, expiresAt]);
return {
rows: [
{
site_id: SITE_ID,
url: 'https://example.com/',
alias: 'Example',
certificate_issuer: issuer,
certificate_issued_at: issuedAt.toISOString(),
certificate_expires_at: expiresAt.toISOString(),
certificate_checked_at: '2026-05-21T00:00:00.000Z',
certificate_check_error: null,
created_at: '2026-05-20T00:00:00.000Z',
updated_at: '2026-05-21T00:00:00.000Z',
},
],
};
});
const app = createApp();
const response = await app.request('/api/sites', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: csrfCookie(),
'x-csrf-token': 'csrf-token',
},
body: JSON.stringify({ url: 'https://example.com', alias: 'Example' }),
});
expect(response.status).toBe(201);
expect(getCertificateExpiry).toHaveBeenCalledWith('https://example.com/', {
timeoutMs: 3000,
});
await expect(response.json()).resolves.toMatchObject({
site: {
siteId: SITE_ID,
certificateIssuer: issuer,
certificateIssuedAt: issuedAt.toISOString(),
certificateExpiresAt: expiresAt.toISOString(),
},
});
});
it('rejects site creation when the initial certificate expiry cannot be fetched', async () => {
getCertificateExpiry.mockRejectedValue(new Error('OpenSSL の実行がタイムアウトしました'));
mockAuthenticatedUser();
const app = createApp();
const response = await app.request('/api/sites', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: csrfCookie(),
'x-csrf-token': 'csrf-token',
},
body: JSON.stringify({ url: 'https://example.com', alias: 'Example' }),
});
expect(response.status).toBe(400);
expect(query).toHaveBeenCalledTimes(1);
await expect(response.json()).resolves.toMatchObject({
error: '証明書の期限を取得できませんでした',
details: {
reason: 'OpenSSL の実行がタイムアウトしました',
},
});
});
it('does not mark another user alert as read', async () => {
mockAuthenticatedUser();
query.mockImplementationOnce(query.getMockImplementation()).mockImplementationOnce(async (sql, params) => {
expect(sql).toContain('UPDATE alert_history');
expect(params).toEqual([USER_ID, ALERT_ID]);
return { rows: [] };
});
const app = createApp();
const response = await app.request(`/api/alerts/${ALERT_ID}/read`, {
method: 'PATCH',
headers: {
Cookie: csrfCookie(),
'x-csrf-token': 'csrf-token',
},
});
expect(response.status).toBe(404);
});
it('rejects settings that reference another user webhook', async () => {
mockAuthenticatedUser();
query
.mockImplementationOnce(query.getMockImplementation())
.mockImplementationOnce(async () => ({
rows: [
{
site_id: SITE_ID,
url: 'https://example.com/',
alias: 'Example',
certificate_issuer: null,
certificate_issued_at: null,
certificate_expires_at: null,
certificate_checked_at: null,
certificate_check_error: null,
created_at: '2026-05-20T00:00:00.000Z',
updated_at: '2026-05-21T00:00:00.000Z',
},
],
}))
.mockImplementationOnce(async (sql, params) => {
expect(sql).toContain('FROM notification_methods');
expect(params).toEqual([USER_ID, [WEBHOOK_ID]]);
return { rowCount: 0, rows: [] };
});
const app = createApp();
const response = await app.request(`/api/sites/${SITE_ID}/settings`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Cookie: csrfCookie(),
'x-csrf-token': 'csrf-token',
},
body: JSON.stringify({
conditions: [{ thresholdHours: 24 }],
webhookMethodIds: [WEBHOOK_ID],
pushEnabled: false,
}),
});
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
error: '選択された Webhook が見つかりません',
});
});
});

225
tests/monitoring.test.js Normal file
View File

@@ -0,0 +1,225 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { runCertificateMonitoring } from '../src/server/modules/monitoring/monitor.js';
import { pool } from '../src/server/db/pool.js';
import { getCertificateExpiry } from '../src/server/modules/monitoring/certificate.js';
import { deliverNotifications } from '../src/server/modules/monitoring/notifications.js';
const mocks = vi.hoisted(() => ({
client: {
query: vi.fn(),
release: vi.fn(),
},
getCertificateExpiry: vi.fn(),
deliverNotifications: vi.fn(),
}));
vi.mock('../src/server/db/pool.js', () => ({
pool: {
connect: vi.fn(async () => mocks.client),
},
}));
vi.mock('../src/server/modules/monitoring/certificate.js', () => ({
getCertificateExpiry: mocks.getCertificateExpiry,
}));
vi.mock('../src/server/modules/monitoring/notifications.js', () => ({
deliverNotifications: mocks.deliverNotifications,
}));
const SITE_ID = '22222222-2222-4222-8222-222222222222';
const USER_ID = '11111111-1111-4111-8111-111111111111';
const WEBHOOK_ID = '44444444-4444-4444-8444-444444444444';
describe('certificate monitoring', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('stores the latest certificate expiry and creates an alert when a threshold matches', async () => {
const issuer = 'C = US, O = Example CA, CN = Example Root';
const issuedAt = new Date('2026-01-01T00:00:00.000Z');
const expiresAt = new Date(Date.now() + 12 * 60 * 60 * 1000);
getCertificateExpiry.mockResolvedValue({
issuer,
issuedAt,
expiresAt,
hoursUntilExpiry: 12,
});
deliverNotifications.mockResolvedValue({
webhook: [{ ok: true }],
push: { ok: true },
});
mocks.client.query.mockImplementation(async (sql, params) => {
if (sql.includes('FROM sites s') && sql.includes('LEFT JOIN site_alert_conditions')) {
return {
rows: [
{
site_id: SITE_ID,
user_id: USER_ID,
url: 'https://example.com/',
alias: 'Example',
conditions: [
{
site_alert_condition_id: '55555555-5555-4555-8555-555555555555',
threshold_hours: 24,
webhook_method_ids: [WEBHOOK_ID],
push_enabled: true,
},
],
},
],
};
}
if (sql.includes('SET certificate_issuer')) {
expect(sql).toContain('certificate_issued_at');
expect(sql).toContain('certificate_expires_at');
expect(params).toEqual([SITE_ID, issuer, issuedAt, expiresAt]);
return { rows: [] };
}
if (sql.includes('FROM notification_methods') && sql.includes("notification_type = 'webhook'")) {
expect(params).toEqual([USER_ID, [WEBHOOK_ID]]);
return {
rows: [
{
notification_method_id: WEBHOOK_ID,
alias: 'Deploy hook',
url: 'https://hooks.example.com/',
},
],
};
}
if (sql.includes('FROM notification_methods') && sql.includes("notification_type = 'push'")) {
expect(params).toEqual([USER_ID]);
return {
rows: [
{
notification_method_id: '66666666-6666-4666-8666-666666666666',
push_endpoint: 'https://push.example.com/subscription',
push_p256dh: 'p256dh',
push_auth: 'auth',
},
],
};
}
if (sql.includes('INSERT INTO alert_history')) {
expect(params[0]).toBe(USER_ID);
expect(params[1]).toBe(SITE_ID);
expect(params[2]).toBe('certificate_expiring');
expect(params[4]).toEqual(['app', 'webhook', 'push']);
return { rows: [{ alert_id: '77777777-7777-4777-8777-777777777777' }] };
}
throw new Error(`Unexpected query: ${sql}`);
});
const result = await runCertificateMonitoring({ concurrency: 1 });
expect(pool.connect).toHaveBeenCalledOnce();
expect(getCertificateExpiry).toHaveBeenCalledWith('https://example.com/');
expect(deliverNotifications).toHaveBeenCalledOnce();
expect(mocks.client.release).toHaveBeenCalledOnce();
expect(result).toMatchObject({
checkedSites: 1,
alertsCreated: 1,
results: [{ siteId: SITE_ID, ok: true, alertsCreated: 1 }],
});
});
it('stores certificate check errors without clearing the previous expiry', async () => {
const error = new Error('openssl failed');
getCertificateExpiry.mockRejectedValue(error);
mocks.client.query.mockImplementation(async (sql, params) => {
if (sql.includes('FROM sites s') && sql.includes('LEFT JOIN site_alert_conditions')) {
return {
rows: [
{
site_id: SITE_ID,
user_id: USER_ID,
url: 'https://example.com/',
alias: 'Example',
conditions: [],
},
],
};
}
if (sql.includes('SET certificate_checked_at') && sql.includes('certificate_check_error = $2')) {
expect(sql).not.toContain('certificate_expires_at');
expect(params).toEqual([SITE_ID, error.message]);
return { rows: [] };
}
if (sql.includes('INSERT INTO alert_history')) {
expect(params[2]).toBe('certificate_check_failed');
expect(JSON.parse(params[5])).toMatchObject({ error: error.message });
return { rows: [{ alert_id: '77777777-7777-4777-8777-777777777777' }] };
}
throw new Error(`Unexpected query: ${sql}`);
});
const result = await runCertificateMonitoring({ concurrency: 1 });
expect(result).toMatchObject({
checkedSites: 1,
alertsCreated: 1,
results: [{ siteId: SITE_ID, ok: false, error: error.message }],
});
});
it('checks and stores certificate expiry for sites without alert conditions', async () => {
const issuer = 'C = US, O = Example CA, CN = Example Root';
const issuedAt = new Date('2026-01-01T00:00:00.000Z');
const expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
getCertificateExpiry.mockResolvedValue({
issuer,
issuedAt,
expiresAt,
hoursUntilExpiry: 90 * 24,
});
mocks.client.query.mockImplementation(async (sql, params) => {
if (sql.includes('FROM sites s') && sql.includes('LEFT JOIN site_alert_conditions')) {
return {
rows: [
{
site_id: SITE_ID,
user_id: USER_ID,
url: 'https://example.com/',
alias: 'Example',
conditions: [],
},
],
};
}
if (sql.includes('SET certificate_issuer')) {
expect(sql).toContain('certificate_issued_at');
expect(sql).toContain('certificate_expires_at');
expect(params).toEqual([SITE_ID, issuer, issuedAt, expiresAt]);
return { rows: [] };
}
throw new Error(`Unexpected query: ${sql}`);
});
const result = await runCertificateMonitoring({ concurrency: 1 });
expect(getCertificateExpiry).toHaveBeenCalledWith('https://example.com/');
expect(deliverNotifications).not.toHaveBeenCalled();
expect(result).toMatchObject({
checkedSites: 1,
alertsCreated: 0,
results: [{ siteId: SITE_ID, ok: true, alertsCreated: 0 }],
});
});
});

21
tests/urlPolicy.test.js Normal file
View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { defaultAliasForUrl, normalizeHttpsUrl } from '../src/server/utils/urlPolicy.js';
describe('urlPolicy', () => {
it('normalizes host-only values to https URLs', () => {
expect(normalizeHttpsUrl('example.com')).toBe('https://example.com/');
});
it('rejects non-https URLs', () => {
expect(() => normalizeHttpsUrl('http://example.com')).toThrow('HTTPS');
});
it('rejects localhost addresses', () => {
expect(() => normalizeHttpsUrl('https://localhost')).toThrow('監視できない');
expect(() => normalizeHttpsUrl('https://127.0.0.1')).toThrow('監視できない');
});
it('uses the hostname as a default alias', () => {
expect(defaultAliasForUrl('https://www.example.com/path')).toBe('www.example.com');
});
});

12
vite.config.js Normal file
View File

@@ -0,0 +1,12 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': 'http://127.0.0.1:3000',
},
},
});