feat(planner): вход в конструктор планнеров по подписанному токену
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D1UnMjwLLs22j7rymn9vUq
This commit is contained in:
@@ -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}`);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user