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
+232
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
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;
}
+41
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>
);
}
+10
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>
);
}
+49
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>
);
}
+104
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
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>,
);
+439
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>
);
}
+214
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>
);
}
+161
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>
);
}
@@ -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>
);
}
+313
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>
);
}
+190
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>
);
}
File diff suppressed because it is too large Load Diff
+46
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
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:[email protected]',
opensslPath: process.env.OPENSSL_PATH ?? 'openssl',
};
+10
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
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}`);
},
);
+20
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;
}
+61
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();
+61
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();
}
+33
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();
}
+201
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;
+158
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;
+118
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;
@@ -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),
};
}
+229
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();
}
}
@@ -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;
}
@@ -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;
+360
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;
+23
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);
}
+23
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);
},
};
+46
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;
}