この
一方
しかし、
一気に
処理の 流れ
全体の上記が
さすがに
エラーの
- ユーザが
お問い 合わせページに アクセスする - Vercel Edge Config から
ブロックしている IP アドレスリストを 取得して 判定する - お問い
合わせページを 表示する - ユーザが
お問い 合わせフォームに 入力して 送信する - クライアント側で
reCAPTCHA トークンを 取得する - バックエンド側に
5 を 含むリクエストを 送信する - Upstash で
レートリミットを 確認する - reCAPTCHA に
リクエストの 判定を 依頼する - Google API OAuth 2.0 を
利用して アクセストークンを 取得する - Google Sheets に
お問い 合わせ内容を 記録する - Google Apps Script が
確認メールの 送信を Gmail に リクエストする - Gmail から
管理者へ メールが 送信される - LINE から
管理者へ メッセージが 送信される - ユーザが
確認メールの 送信を 希望する 場合は Resend から メールを 送信する - ユーザに
成功メッセージを 表示する - お問い
合わせ内容が 不適切だった 場合は 手動で Vercel Edge Config の ブロック IP アドレスリストに 登録する
IP アドレス制限
あまり
悪意の
以下のように
{ "blockIps": ["127.0.0.1"]}
上記で
import type { NextRequest } from 'next/server';import { NextResponse } from 'next/server';import { get } from '@vercel/edge-config';
export const config = { matcher: '/((?!api|_next|static|public|favicon).*)' };
export const middleware = async (request: NextRequest) => { const blockIps = await get<string[]>('blockIps'); const accessIp = request.ip;
if (accessIp && blockIps?.includes(accessIp)) { request.nextUrl.pathname = '/forbidden'; return NextResponse.rewrite(request.nextUrl, { status: 403 }); }};
8 行目で/forbidden
ページに

嫌がらせを
ちなみに
バリデーション
入力値のフィールドには
今回は
定義した
import { z } from 'zod';
export const ContactSchema = z.object({ email: z .string() .min(1, { message: 'メールアドレスを入力してください' }) .max(255, { message: 'メールアドレスは 255 文字以内で入力してください' }) .email({ message: 'メールアドレスの形式が正しくありません' }), name: z .string() .min(1, { message: '名前を入力してください' }) .max(255, { message: '名前は 255 文字以内で入力してください' }), type: z.enum(['jobScouting', 'projectConsultation', 'feedback', 'collaboration', 'other'], { message: 'お問い合わせ種別を選択してください', }), content: z .string() .min(1, { message: 'お問い合わせ内容を入力してください' }) .max(2000, { message: 'お問い合わせ内容は 2000 文字以内で入力してください' }), shouldSendReplyMail: z.boolean(), recaptchaToken: z.string().optional(),});
Next.js を
useActionStateを
App Router が
クライアント側の
reCAPTCHA トークンの
レートリミット
この
IP アドレスを
使用方
import { Ratelimit } from '@upstash/ratelimit';import { Redis } from '@upstash/redis';
type SlidingWindowProps = Parameters<typeof Ratelimit.slidingWindow>;
const rateLimiter = (tokens: SlidingWindowProps[0], windowValue: SlidingWindowProps[1]) => new Ratelimit({ redis: Redis.fromEnv(), limiter: Ratelimit.slidingWindow(tokens, windowValue), });
const getIpHash = async (ip: string) => { const buffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(ip)); return Array.from(new Uint8Array(buffer)) .map((b) => b.toString(16).padStart(2, '0')) .join('');};
export const contactRouter = { send: publicProcedure.input(ContactSchema).mutation(async ({ input, ctx }) => { if (!ctx.ip) { throw new TRPCError({ code: 'BAD_REQUEST' }); }
const ip = ctx.ip; const ipHash = await getIpHash(ip); const { success } = await rateLimiter(1, '1 m').limit(ipHash);
if (!success) { throw new TRPCError({ code: 'TOO_MANY_REQUESTS' }); }
// ... }),};
Upstash には@upstash/ratelimit
とrateLimiter
と
お問い
保護
Bot からの機械的に
Bot 対策と
サーバ側から
当初は
前提と
今回は
export const verifyRecaptcha = async ({ recaptchaToken, expectedAction,}: { recaptchaToken: string; expectedAction: string;}) => { const request = { event: { token: recaptchaToken, expectedAction, siteKey: env.RECAPTCHA_SITE_KEY, }, } satisfies RecaptchaRequest;
const response = await fetch( `https://recaptchaenterprise.googleapis.com/v1/projects/${env.GCP_PROJECT_ID}/assessments?key=${env.RECAPTCHA_SECRET_KEY}`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(request), }, );
if (!response.ok) { throw new Error('Failed to verify recaptcha'); }
return (await response.json()) as RecaptchaResponse;}
これで
お問い 合わせ内容を 記録
Google Sheets にユーザから
Google Sheets で
Node.js から
は
import { importPKCS8, SignJWT } from 'jose';
import { env } from '../../../env';
interface Token { access_token: string; expires_in: number; token_type: string;}
type TokenWithExpiration = Token & { expires_at: number;};
const payload = { iss: env.GCP_CLIENT_EMAIL, scope: 'https://www.googleapis.com/auth/spreadsheets', aud: 'https://www.googleapis.com/oauth2/v4/token', exp: Math.floor(Date.now() / 1000) + 60 * 60, iat: Math.floor(Date.now() / 1000),};
const createToken = async () => { const privateKey = await importPKCS8(env.GCP_PRIVATE_KEY, 'RS256');
const token = await new SignJWT(payload) .setProtectedHeader({ alg: 'RS256' }) .setIssuedAt() .setIssuer(env.GCP_CLIENT_EMAIL) .setAudience('https://www.googleapis.com/oauth2/v4/token') .setExpirationTime('1h') .sign(privateKey);
const form = { grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: token, };
const tokenResponse = await fetch('https://www.googleapis.com/oauth2/v4/token', { method: 'POST', body: JSON.stringify(form), headers: { 'Content-Type': 'application/json' }, });
if (!tokenResponse.ok) { throw new Error('Failed to get token'); }
const json = (await tokenResponse.json()) as Token;
return { ...json, expires_at: Math.floor(Date.now() / 1000) + json.expires_in, };};
let token: Promise<TokenWithExpiration> | null = null;
export const authenticate = async () => { if (token === null) { token = createToken(); }
let resolvedToken = await token;
if (resolvedToken.expires_at < Math.floor(Date.now() / 1000)) { token = createToken(); resolvedToken = await token; }
return resolvedToken;};
これで
import { env } from '../../../env';import { GoogleSheetsError } from '../../exceptions';import { authenticate } from './token';
export const appendGoogleSheets = async ({ sheetName, values }: { sheetName: string; values: string[][] }) => { const { access_token } = await authenticate();
const params = { range: `${sheetName}!A2`, insertDataOption: 'INSERT_ROWS', valueInputOption: 'USER_ENTERED', resource: { values, }, };
const response = await fetch( `https://sheets.googleapis.com/v4/spreadsheets/${env.GOOGLE_SHEETS_ID}/values/${encodeURIComponent(params.range)}:append?valueInputOption=${params.valueInputOption}&insertDataOption=${params.insertDataOption}`, { method: 'POST', headers: { Authorization: `Bearer ${access_token}`, Accept: 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify(params.resource), }, );
if (!response.ok) { throw new GoogleSheetsError('Google Sheets API request failed: ' + response.statusText); }
console.log('Append to Google Sheets was successful');};
Google Sheets の
確認メールを 管理者に 送信
Gmail で後述する
実装方
@google/clasp
を
import type { z } from 'zod';
import type { ContactSchema } from '@kkhys/validators';
type SendEmailParameters = Parameters<typeof GmailApp.sendEmail>;type ContactSchemaProps = z.infer<typeof ContactSchema>;
const sheet = SpreadsheetApp.openById(import.meta.env.VITE_GOOGLE_SHEETS_ID).getActiveSheet();const lastRow = sheet.getLastRow();
const emailColumn = 1;const nameColumn = 2;const typeColumn = 3;const contentColumn = 4;
export const sendContactEmail = () => { const recipient = 'simplelogin.alibi716@aleeas.com' satisfies SendEmailParameters[0]; const subject = 'お問い合わせがありました' satisfies SendEmailParameters[1]; const body = generateBody() satisfies SendEmailParameters[2]; const options = { // from: 'noreply@kkhys.me', name: 'Keisuke Hayashi', // htmlBody: html, } satisfies SendEmailParameters[3];
const lock = LockService.getScriptLock(); if (lock.tryLock(1000)) { GmailApp.sendEmail(recipient, subject, body, options); lock.releaseLock(); }};
const generateBody = () => { const email = sheet.getRange(lastRow, emailColumn).getValue() as ContactSchemaProps['email']; const name = sheet.getRange(lastRow, nameColumn).getValue() as ContactSchemaProps['name']; const type = sheet.getRange(lastRow, typeColumn).getValue() as ContactSchemaProps['type']; const content = sheet.getRange(lastRow, contentColumn).getValue() as ContactSchemaProps['content'];
return `kkhys.me にお問い合わせがありました。
# 名前${name}
# メールアドレス${email}
# お問い合わせ種別${type}
# お問い合わせ内容${content}`;};
Google Sheets とLockService.getScriptLock
を
デプロイ後は
確認メッセージを 管理者に 送信
LINE で残念ながら
Gmail は
LINE にも
export const sendLineMessage = async ({ message }: { message: string }) => { const response = await fetch('https://api.line.me/v2/bot/message/push', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${env.LINE_CHANNEL_ACCESS_TOKEN}`, }, body: JSON.stringify({ to: env.LINE_USER_ID, messages: [ { type: 'text', text: message, }, ], }), });
if (!response.ok) { throw new LineError('Failed to send a message to LINE'); }
console.log('Successfully sent a message to LINE');};
お問い
確認メールを ユーザに 送信
Resend でお問い
大抵の
閑話休題。
import { Resend } from 'resend';
const resend = new Resend(env.RESEND_API_KEY);const administrator = { name: 'Keisuke Hayashi', email: 'noreply@kkhys.me',};
export const sendEmail = async ({ to, subject, body: { html, text }, tags,}: { to: string; subject: string; body: Record<'html' | 'text', string>; tags?: { name: string; value: string }[];}) => { const { data, error } = await resend.emails.send({ from: `${administrator.name} <${administrator.email}>`, to, subject, html, text, tags, headers: { 'X-Entity-Ref-ID': new Date().getTime().toString(), }, });
if (error) { throw new EmailError(`Failed to send email to ${to}. Error: ${JSON.stringify(error)}`); }
console.log(`Successfully sent email to ${to}. Response: ${JSON.stringify(data)}`);};
メールヘッダにX-Entity-Ref-ID
を
メール本文には


そも
最後に
もし、
ごに
さい気軽に