First commit
This commit is contained in:
@@ -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} />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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 Authenticator、1Password、Microsoft 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}`);
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user