Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 41 additions & 5 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,58 @@
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 <NotFoundPage />;
}
return <Navigate to={`/e/${eventId}`} replace />;
}

/**
* 旧パス /:eventId/edit から新パス /e/:eventId/edit へのリダイレクト。eventId は Nano ID 形式。
*/
function LegacyEventEditRedirect() {
const { eventId } = useParams();
if (!eventId || !NANOID_REGEX.test(eventId)) {
return <NotFoundPage />;
}
return <Navigate to={`/e/${eventId}/edit`} replace />;
}

export default function App() {
return (
<BrowserRouter>
<Routes>
<Route index element={<LandingPage />} />
<Route path="home" element={<HomePage />} />
<Route path="new" element={<ProjectPage />} />
<Route path=":eventId" element={<Outlet />}>
<Route index element={<SubmissionPage />} />
<Route path="edit" element={<ProjectPage />} />
<Route path="*" element={<NotFoundPage />} />

<Route path="e">
<Route path=":eventId" element={<Outlet />}>
<Route index element={<SubmissionPage />} />
<Route path="edit" element={<ProjectPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
</Route>

{/* /:eventId (旧形式) を /e/:eventId (新形式) にリダイレクト */}
<Route path=":eventId" element={<LegacyEventRedirect />} />
<Route path=":eventId/edit" element={<LegacyEventEditRedirect />} />

<Route path="*" element={<NotFoundPage />} />
</Routes>
</BrowserRouter>
Expand Down
2 changes: 1 addition & 1 deletion client/src/pages/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ function ProjectDashboard({ involvedProjects }: { involvedProjects: BriefProject
function ProjectCard({ project }: { project: BriefProject }) {
return (
<NavLink
to={`/${project.id}`}
to={`/e/${project.id}`}
className={`group hover:-translate-y-1 relative block transform overflow-hidden rounded-xl border-l-4 bg-white shadow-lg transition-all duration-300 hover:shadow-xl ${project.isHost ? "border-primary" : "border-secondary"} focus:outline-none focus:ring-4 focus:ring-primary/20`}
aria-label={`「${project.name}」の詳細を見る`}
>
Expand Down
10 changes: 5 additions & 5 deletions client/src/pages/Project.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ export default function ProjectPage() {
useEffect(() => {
if (!loading && project && !isHost) {
if (eventId) {
navigate(`/${eventId}`);
navigate(`/e/${eventId}`);
} else {
navigate("/");
}
Expand Down Expand Up @@ -613,7 +613,7 @@ export default function ProjectPage() {
)}
<div className="fixed bottom-0 left-0 flex max-h-18 w-full justify-between bg-white p-4 shadow-[0_-2px_8px_rgba(0,0,0,0.1)]">
{eventId ? (
<NavLink to={`/${eventId}`} className="btn btn-outline btn-primary">
<NavLink to={`/e/${eventId}`} className="btn btn-outline btn-primary">
日程調整に戻る
</NavLink>
) : (
Expand All @@ -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}`}
/>
<button
type="button"
onClick={async () => {
await navigator.clipboard.writeText(`${FRONTEND_ORIGIN}/${dialogStatus.projectId}`);
await navigator.clipboard.writeText(`${FRONTEND_ORIGIN}/e/${dialogStatus.projectId}`);
setCopied(true);
setTimeout(() => {
setCopied(false);
Expand All @@ -657,7 +657,7 @@ export default function ProjectPage() {
</button>
</div>
<div className="modal-action">
<NavLink className="btn btn-primary" to={`/${dialogStatus.projectId}`}>
<NavLink className="btn btn-primary" to={`/e/${dialogStatus.projectId}`}>
イベントへ
</NavLink>
</div>
Expand Down
8 changes: 6 additions & 2 deletions client/src/pages/eventId/Submission.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ export default function SubmissionPage() {
<>
<div className="flex h-[100dvh] flex-col">
<Header />
{loading || !selectedParticipationOptionId ? (
{loading ? (
<div className="flex w-full flex-1 items-center justify-center">
<span className="loading loading-dots loading-md text-gray-400" />
</div>
Expand All @@ -221,12 +221,16 @@ export default function SubmissionPage() {
ホームに戻る
</NavLink>
</div>
) : !selectedParticipationOptionId ? (
<div className="flex w-full flex-1 items-center justify-center">
<span className="loading loading-dots loading-md text-gray-400" />
</div>
) : (
<div className="flex h-full flex-1 flex-col overflow-y-auto p-4">
<div className="flex items-center justify-between">
<h1 className="mb-2 font-bold text-2xl text-gray-800">{project.name} の日程調整</h1>
{isHost && (
<NavLink to={`/${projectId}/edit`} className="btn btn-sm font-normal text-gray-600">
<NavLink to={`/e/${projectId}/edit`} className="btn btn-sm font-normal text-gray-600">
<HiOutlineCog />
イベント設定
</NavLink>
Expand Down
8 changes: 7 additions & 1 deletion server/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -11,14 +12,19 @@ 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({
origin: allowedOrigins,
credentials: true,
}),
)
.use("*", browserIdMiddleware)
.get("/", (c) => {
return c.json({ message: "Hello! イツヒマ?" });
})
Expand Down
72 changes: 72 additions & 0 deletions server/src/middleware/browserId.ts
Original file line number Diff line number Diff line change
@@ -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();
};
Loading