diff --git a/client/src/App.tsx b/client/src/App.tsx index 75d944e..b1b1125 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,10 +1,38 @@ -import { BrowserRouter, Outlet, Route, Routes } from "react-router"; +import { BrowserRouter, Navigate, Outlet, Route, Routes, useParams } from "react-router"; import SubmissionPage from "./pages/eventId/Submission.tsx"; import HomePage from "./pages/Home.tsx"; import LandingPage from "./pages/Landing.tsx"; import NotFoundPage from "./pages/NotFound.tsx"; import ProjectPage from "./pages/Project.tsx"; +/** + * Nano ID 形式の正規表現。 + * 現在はハイフン・アンダースコアを含まないが、初期は含んでいたため URL の検証時は両方許容する。 + */ +const NANOID_REGEX = /^[A-Za-z0-9_-]{21}$/; + +/** + * 旧パス /:eventId から新パス /e/:eventId へのリダイレクト。eventId は Nano ID 形式。 + */ +function LegacyEventRedirect() { + const { eventId } = useParams(); + if (!eventId || !NANOID_REGEX.test(eventId)) { + return ; + } + return ; +} + +/** + * 旧パス /:eventId/edit から新パス /e/:eventId/edit へのリダイレクト。eventId は Nano ID 形式。 + */ +function LegacyEventEditRedirect() { + const { eventId } = useParams(); + if (!eventId || !NANOID_REGEX.test(eventId)) { + return ; + } + return ; +} + export default function App() { return ( @@ -12,11 +40,19 @@ export default function App() { } /> } /> } /> - }> - } /> - } /> - } /> + + + }> + } /> + } /> + } /> + + + {/* /:eventId (旧形式) を /e/:eventId (新形式) にリダイレクト */} + } /> + } /> + } /> diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index 79700df..9c3c7b7 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -107,7 +107,7 @@ function ProjectDashboard({ involvedProjects }: { involvedProjects: BriefProject function ProjectCard({ project }: { project: BriefProject }) { return ( diff --git a/client/src/pages/Project.tsx b/client/src/pages/Project.tsx index 86331ce..722ccf2 100644 --- a/client/src/pages/Project.tsx +++ b/client/src/pages/Project.tsx @@ -242,7 +242,7 @@ export default function ProjectPage() { useEffect(() => { if (!loading && project && !isHost) { if (eventId) { - navigate(`/${eventId}`); + navigate(`/e/${eventId}`); } else { navigate("/"); } @@ -613,7 +613,7 @@ export default function ProjectPage() { )}
{eventId ? ( - + 日程調整に戻る ) : ( @@ -639,12 +639,12 @@ export default function ProjectPage() { type="text" disabled className="input input-info w-full" - value={`${FRONTEND_ORIGIN}/${dialogStatus.projectId}`} + value={`${FRONTEND_ORIGIN}/e/${dialogStatus.projectId}`} />
- + イベントへ
diff --git a/client/src/pages/eventId/Submission.tsx b/client/src/pages/eventId/Submission.tsx index a422e60..e199edf 100644 --- a/client/src/pages/eventId/Submission.tsx +++ b/client/src/pages/eventId/Submission.tsx @@ -210,7 +210,7 @@ export default function SubmissionPage() { <>
- {loading || !selectedParticipationOptionId ? ( + {loading ? (
@@ -221,12 +221,16 @@ export default function SubmissionPage() { ホームに戻る
+ ) : !selectedParticipationOptionId ? ( +
+ +
) : (

{project.name} の日程調整

{isHost && ( - + イベント設定 diff --git a/server/src/main.ts b/server/src/main.ts index 5813fd3..2898e46 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -3,6 +3,7 @@ import { PrismaClient } from "@prisma/client"; import dotenv from "dotenv"; import { Hono } from "hono"; import { cors } from "hono/cors"; +import { browserIdMiddleware } from "./middleware/browserId.js"; import projectsRoutes from "./routes/projects.js"; dotenv.config(); @@ -11,7 +12,11 @@ export const prisma = new PrismaClient(); const port = process.env.PORT || 3000; const allowedOrigins = process.env.CORS_ALLOW_ORIGINS?.split(",") || []; -const app = new Hono() +type AppVariables = { + browserId: string; +}; + +const app = new Hono<{ Variables: AppVariables }>() .use( "*", cors({ @@ -19,6 +24,7 @@ const app = new Hono() credentials: true, }), ) + .use("*", browserIdMiddleware) .get("/", (c) => { return c.json({ message: "Hello! イツヒマ?" }); }) diff --git a/server/src/middleware/browserId.ts b/server/src/middleware/browserId.ts new file mode 100644 index 0000000..cdde0c0 --- /dev/null +++ b/server/src/middleware/browserId.ts @@ -0,0 +1,72 @@ +import crypto from "node:crypto"; +import type { Context, MiddlewareHandler } from "hono"; +import { getCookie, getSignedCookie, setSignedCookie } from "hono/cookie"; +import { cookieOptions } from "../main.js"; + +const COOKIE_NAME = "browserId"; + +/** + * Express の署名付きクッキー(s:value.signature 形式)を検証して値を取り出す + */ +function unsignExpressCookie(signedValue: string, secret: string): string | null { + if (!signedValue.startsWith("s:")) return null; + + const raw = signedValue.slice(2); // `s:` を削除 + const lastDotIndex = raw.lastIndexOf("."); + if (lastDotIndex === -1) return null; + + const value = raw.slice(0, lastDotIndex); // 署名前の値(uuid) + const signature = raw.slice(lastDotIndex + 1); + + // Express と同じアルゴリズムで署名を検証 + const expectedSignature = crypto.createHmac("sha256", secret).update(value).digest("base64").replace(/=+$/, ""); + + if (signature === expectedSignature) { + return value; + } + + return null; +} + +/** + * Express → Hono の移行で signed cookie 形式が変わったため両方に対応するミドルウェア + */ +export const browserIdMiddleware: MiddlewareHandler = async (c: Context, next) => { + const cookieSecret = process.env.COOKIE_SECRET; + if (!cookieSecret) { + console.error("COOKIE_SECRET is not set"); + return c.json({ message: "サーバー設定エラー" }, 500); + } + + let browserId: string | undefined; + let needsReissue = false; + + // 新形式 (Hono) を試す + browserId = (await getSignedCookie(c, cookieSecret, COOKIE_NAME)) || undefined; + + if (!browserId) { + const rawCookie = getCookie(c, COOKIE_NAME); + + if (rawCookie?.startsWith("s:")) { + const legacy = unsignExpressCookie(rawCookie, cookieSecret); + if (legacy) { + browserId = legacy; + needsReissue = true; + } + } + } + + if (browserId && needsReissue) { + await setSignedCookie(c, COOKIE_NAME, browserId, cookieSecret, cookieOptions); + } + + if (!browserId) { + browserId = crypto.randomUUID(); + await setSignedCookie(c, COOKIE_NAME, browserId, cookieSecret, cookieOptions); + } + + // コンテキストに保存(後続のハンドラで c.get('browserId') で取得可能) + c.set("browserId", browserId); + + await next(); +}; diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index a74c6bc..948ce5d 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -1,25 +1,28 @@ import { zValidator } from "@hono/zod-validator"; import dotenv from "dotenv"; import { Hono } from "hono"; -import { getSignedCookie, setSignedCookie } from "hono/cookie"; -import { nanoid } from "nanoid"; +import { customAlphabet } from "nanoid"; import { z } from "zod"; import { editReqSchema, projectReqSchema, submitReqSchema } from "../../../common/validators.js"; -import { cookieOptions, prisma } from "../main.js"; +import { prisma } from "../main.js"; dotenv.config(); +/** + * ハイフン・アンダースコアを含まない Nano ID 形式。 + */ +const nanoid = customAlphabet("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", 21); + const projectIdParamsSchema = z.object({ projectId: z.string().length(21) }); -const router = new Hono() +type AppVariables = { + browserId: string; +}; + +const router = new Hono<{ Variables: AppVariables }>() // プロジェクト作成 .post("/", zValidator("json", projectReqSchema), async (c) => { - const cookieSecret = process.env.COOKIE_SECRET; - if (!cookieSecret) { - console.error("COOKIE_SECRET is not set"); - return c.json({ message: "サーバー設定エラー" }, 500); - } - const browserId = (await getSignedCookie(c, cookieSecret, "browserId")) || undefined; + const browserId = c.get("browserId"); try { const data = c.req.valid("json"); @@ -51,9 +54,7 @@ const router = new Hono() }, include: { hosts: true, participationOptions: true }, }); - const host = event.hosts[0]; - await setSignedCookie(c, "browserId", host.browserId, cookieSecret, cookieOptions); return c.json({ id: event.id, name: event.name }, 201); } catch (_err) { return c.json({ message: "イベント作成時にエラーが発生しました" }, 500); @@ -62,16 +63,7 @@ const router = new Hono() // 自分が関連するプロジェクト取得 .get("/mine", async (c) => { - const cookieSecret = process.env.COOKIE_SECRET; - if (!cookieSecret) { - console.error("COOKIE_SECRET is not set"); - return c.json({ message: "サーバー側でエラーが発生しています。" }, 500); - } - - const browserId = await getSignedCookie(c, cookieSecret, "browserId"); - if (!browserId) { - return c.json({ message: "認証されていません。" }, 401); - } + const browserId = c.get("browserId"); try { const involvedProjects = await prisma.project.findMany({ @@ -118,13 +110,7 @@ const router = new Hono() // プロジェクト取得 .get("/:projectId", zValidator("param", projectIdParamsSchema), async (c) => { - const cookieSecret = process.env.COOKIE_SECRET; - if (!cookieSecret) { - console.error("COOKIE_SECRET is not set"); - return c.json({ message: "サーバー側でエラーが発生しています。" }, 500); - } - - const browserId = await getSignedCookie(c, cookieSecret, "browserId"); + const browserId = c.get("browserId"); try { const { projectId } = c.req.valid("param"); @@ -157,8 +143,8 @@ const router = new Hono() const { browserId: _, ...rest } = g; return rest; }), - isHost: browserId ? projectRow.hosts.some((h) => h.browserId === browserId) : false, - meAsGuest: browserId ? (projectRow.guests.find((g) => g.browserId === browserId) ?? null) : null, + isHost: projectRow.hosts.some((h) => h.browserId === browserId), + meAsGuest: projectRow.guests.find((g) => g.browserId === browserId) ?? null, }; return c.json(data, 200); } catch (error) { @@ -169,12 +155,7 @@ const router = new Hono() // プロジェクト編集 .put("/:projectId", zValidator("param", projectIdParamsSchema), zValidator("json", editReqSchema), async (c) => { - const cookieSecret = process.env.COOKIE_SECRET; - if (!cookieSecret) { - console.error("COOKIE_SECRET is not set"); - return c.json({ message: "サーバー設定エラー" }, 500); - } - const browserId = (await getSignedCookie(c, cookieSecret, "browserId")) || undefined; + const browserId = c.get("browserId"); try { const { projectId } = c.req.valid("param"); const data = c.req.valid("json"); @@ -278,15 +259,7 @@ const router = new Hono() // プロジェクト削除 .delete("/:projectId", zValidator("param", projectIdParamsSchema), async (c) => { - const cookieSecret = process.env.COOKIE_SECRET; - if (!cookieSecret) { - console.error("COOKIE_SECRET is not set"); - return c.json({ message: "サーバー設定エラー" }, 500); - } - const browserId = await getSignedCookie(c, cookieSecret, "browserId"); - if (!browserId) { - return c.json({ message: "認証されていません。" }, 401); - } + const browserId = c.get("browserId"); try { const { projectId } = c.req.valid("param"); // Host 認証 @@ -314,26 +287,20 @@ const router = new Hono() zValidator("param", projectIdParamsSchema), zValidator("json", submitReqSchema), async (c) => { - const cookieSecret = process.env.COOKIE_SECRET; - if (!cookieSecret) { - console.error("COOKIE_SECRET is not set"); - return c.json({ message: "サーバー設定エラー" }, 500); - } const { projectId } = c.req.valid("param"); - const browserId = (await getSignedCookie(c, cookieSecret, "browserId")) || undefined; + const browserId = c.get("browserId"); - if (browserId) { - const existingGuest = await prisma.guest.findFirst({ - where: { projectId, browserId }, - }); - if (existingGuest) { - return c.json({ message: "すでに登録済みです" }, 403); - } + const existingGuest = await prisma.guest.findFirst({ + where: { projectId, browserId }, + }); + if (existingGuest) { + return c.json({ message: "すでに登録済みです" }, 403); } + const { name, slots } = c.req.valid("json"); try { - const guest = await prisma.guest.create({ + await prisma.guest.create({ data: { name, browserId, @@ -349,7 +316,6 @@ const router = new Hono() }, include: { slots: true }, }); - await setSignedCookie(c, "browserId", guest.browserId, cookieSecret, cookieOptions); return c.json("日時が登録されました!", 201); } catch (error) { console.error("登録エラー:", error); @@ -364,17 +330,9 @@ const router = new Hono() zValidator("param", projectIdParamsSchema), zValidator("json", submitReqSchema), async (c) => { - const cookieSecret = process.env.COOKIE_SECRET; - if (!cookieSecret) { - console.error("COOKIE_SECRET is not set"); - return c.json({ message: "サーバー設定エラー" }, 500); - } const { projectId } = c.req.valid("param"); - const browserId = (await getSignedCookie(c, cookieSecret, "browserId")) || undefined; + const browserId = c.get("browserId"); - if (!browserId) { - return c.json({ message: "認証されていません。" }, 401); - } const { name, slots } = c.req.valid("json"); try {