From 273cb2d1b4cb5482875e31682187f34d034a99d6 Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Mon, 14 Sep 2026 10:19:52 +0500 Subject: [PATCH] =?UTF-8?q?feat(planner):=20=D0=B2=D1=85=D0=BE=D0=B4=20?= =?UTF-8?q?=D0=B2=20=D0=BA=D0=BE=D0=BD=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82?= =?UTF-8?q?=D0=BE=D1=80=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=BD=D0=B5=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=BF=D0=BE=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=B0=D0=BD=D0=BD=D0=BE=D0=BC=D1=83=20=D1=82=D0=BE=D0=BA=D0=B5?= =?UTF-8?q?=D0=BD=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/planner/start проверяет сессию и доступ к курсу planner, подписывает короткоживущий токен (HMAC-SHA256, 15 минут) и отправляет в planner.second-brain.ru. Токен уходит во фрагменте, а не в строке запроса: так он не попадает ни в логи, ни в Referer. Алгоритм подписи — зеркало packages/web/src/auth.mjs в репозитории планнера; совместимость проверена. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01D1UnMjwLLs22j7rymn9vUq --- src/app/api/planner/start/route.ts | 60 ++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/app/api/planner/start/route.ts diff --git a/src/app/api/planner/start/route.ts b/src/app/api/planner/start/route.ts new file mode 100644 index 0000000..72c63c6 --- /dev/null +++ b/src/app/api/planner/start/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from "next/server"; +import { headers } from "next/headers"; +import { createHmac } from "crypto"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +/** + * Вход в конструктор планнеров (planner.second-brain.ru). + * + * Своей авторизации у конструктора нет: школа проверяет сессию и доступ, + * подписывает короткоживущий токен, конструктор его сверяет тем же секретом. + * Формат токена: base64url(payload).base64url(hmacSha256(payload)) — + * зеркало packages/web/src/auth.mjs в репозитории планнера. Менять оба конца. + */ + +const PLANNER_URL = process.env.PLANNER_URL ?? "https://planner.second-brain.ru"; +const COURSE_SLUG = "planner"; +const TTL_SECONDS = 900; + +const b64 = (input: Buffer | string) => Buffer.from(input).toString("base64url"); + +export async function GET(req: NextRequest) { + const secret = process.env.PLANNER_TOKEN_SECRET; + if (!secret) { + // Лучше честная ошибка, чем редирект в конструктор с заведомо негодным + // токеном: там человек увидит невнятное «подпись не сходится». + return NextResponse.json( + { error: "PLANNER_TOKEN_SECRET не задан" }, + { status: 503 }, + ); + } + + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user) { + return NextResponse.redirect(new URL("/login", req.url)); + } + + const enrolled = await prisma.courseEnrollment.findFirst({ + where: { userId: session.user.id, course: { slug: COURSE_SLUG } }, + select: { expiresAt: true }, + }); + if (!enrolled) { + return NextResponse.redirect(new URL("/dashboard", req.url)); + } + if (enrolled.expiresAt && enrolled.expiresAt < new Date()) { + return NextResponse.redirect(new URL("/dashboard", req.url)); + } + + const payload = { + userId: session.user.id, + email: session.user.email, + exp: Math.floor(Date.now() / 1000) + TTL_SECONDS, + }; + const body = b64(JSON.stringify(payload)); + const signature = b64(createHmac("sha256", secret).update(body).digest()); + + // Токен во фрагменте, а не в строке запроса: фрагмент не попадает ни в + // логи сервера, ни в заголовок Referer при переходах со страницы. + return NextResponse.redirect(`${PLANNER_URL}/build.html#token=${body}.${signature}`); +}