Author SHA1 Message Date
adminsandClaude Opus 5.5 651a115ab7 Регистрация: utm с лендинга из cookie sb_utm, если в адресе меток нет
Кнопки лендингов ведут на /register без меток, поэтому utmCampaign не был
записан ни у одного пользователя. Caddy кладёт строку запроса с utm_* в cookie
sb_utm на .second-brain.ru; страница регистрации берёт метки из адреса, а если
их нет — из cookie. Значения: [A-Za-z0-9._-], до 100 символов.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:16:09 +05:00
adminsandClaude Opus 5.5 d8777a7a23 Кликабельные ссылки: история фидбека у куратора и цитаты в письмах
Куратор видел адрес в своём фидбеке мёртвым текстом — linkify там не было.
В письмах (фидбек, ответы на вопросы и комментарии) quote() только экранировал:
кликабельность зависела от почтовика. Теперь ссылки оформлены <a>.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 14:48:59 +05:00
adminsandClaude Opus 5.5 4792df274e access-check: точное сравнение почты (без ILIKE-шаблонов), заблокированным — нет, секрет за постоянное время
По фоновой проверке безопасности: mode insensitive превращался в ILIKE,
и % / _ в адресе давали «доступ есть» за чужую почту; banned-пользователь
считался с доступом; секрет сравнивался обычным ===. Почты в базе — в нижнем
регистре (0 из 1426 с заглавными), вход нормализуется.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 15:06:06 +05:00
adminsandClaude Opus 5.5 83f34ea679 Внутренний метод /api/internal/access-check: есть ли у почты доступ к курсу
Для бота выдачи SB Access (payment-router): старый покупатель Second Brain
Vault оставляет почту на /tg/link, ключ входа в бота выдаётся, только если
доступ к курсу действительно есть. Тот же общий секрет, что у grant;
ответ только да/нет, почта без учёта регистра, истёкший доступ не считается.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 15:03:24 +05:00
adminsandClaude Opus 5.5 0a5368577c баннер архетипа: кнопка «Демо-хранилище» → «Second Brain Vault»
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 13:55:12 +05:00
adminsandClaude Opus 5.5 82737ba188 ROADMAP: служебные учётки на проде — к удалению по решению владельца
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 11:05:54 +05:00
adminsandClaude Opus 5.5 9553d1e9be ROADMAP: бэклог — дефекты сброса и смены пароля (флаг, /change-password, Math.random)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 11:05:06 +05:00
12 changed files with 255 additions and 7 deletions
+8
View File
@@ -275,6 +275,14 @@
## Бэклог (после MVP)
- **⚠️ Смена и сброс пароля — три дефекта** (найдено 20261005, когда владелец менял пароль админа):
1. `resetUserPassword` (`src/app/admin/users/[userId]/actions.ts`, ~стр. 82–99) заменяет пароль временным, но **не ставит `mustChangePassword: true`** — пользователя с временным паролем никто не заставляет его сменить.
2. Ученическая зона при `mustChangePassword` делает `redirect("/change-password")` (`src/app/(student)/layout.tsx`, ~стр. 23), а **страницы `/change-password` в `src/app` нет** — ученик с флагом попадёт на 404. Нужна страница (форма уже есть: `src/app/(student)/profile/change-password-form.tsx` + `changePasswordAction`, она же снимает флаг).
3. Временный пароль генерируется через `Math.random()` — не криптостойко; заменить на `crypto.randomInt` (node:crypto).
- Заодно: у админа/куратора нет смены пароля в админке — сейчас только через ученический `/profile` (работает, проверено 20261005). Добавить ссылку или форму в админ-профиль; рассмотреть принудительную смену и для админки.
- Приёмка: сброс → вход временным паролем → принудительная форма смены (и у ученика, и у админа) → флаг снят; тест на генерацию пароля.
- Гигиена прода: остались служебные учётки `student@second-brain.ru` (`student-seed-001`, сид 20260407) и `loadtest@second-brain.ru` (`loadtest-user-001`, 20260506). Пароль из сида к ним не подходит (проверено bcrypt 20261005), но учётки не нужны — удалить или заблокировать по решению владельца.
- **Миграция email-шаблонов на React Email 6 + Resend CLI 2.0** (Resend Launch Week 6, 24.04.2026):
- React Email 6: новые шаблоны для auth и ecommerce flows (welcome, password reset, purchase confirmation, course progress) — можно взять за основу вместо своих
- Resend CLI 2.0: локальный preview и тестирование шаблонов (`resend send --local ...`), 50+ команд
+10 -3
View File
@@ -1,6 +1,8 @@
import { redirect } from "next/navigation";
import { cookies } from "next/headers";
import { getSettings } from "@/lib/settings";
import { RegisterForm } from "./register-form";
import { UTM_COOKIE, parseUtmCookie, pickUtm } from "@/lib/utm-cookie";
export default async function RegisterPage({
searchParams,
@@ -15,6 +17,11 @@ export default async function RegisterPage({
const sp = await searchParams;
const str = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v) ?? "";
// Метки: из адреса, а если их нет — из cookie лендинга (src/lib/utm-cookie.ts).
const utm = pickUtm(
{ utmSource: str(sp.utm_source), utmMedium: str(sp.utm_medium), utmCampaign: str(sp.utm_campaign) },
parseUtmCookie((await cookies()).get(UTM_COOKIE)?.value),
);
return (
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: "var(--background)" }}>
@@ -34,9 +41,9 @@ export default async function RegisterPage({
termsUrl={settings.termsUrl}
offerUrl={settings.offerUrl}
archetype={str(sp.archetype)}
utmSource={str(sp.utm_source)}
utmMedium={str(sp.utm_medium)}
utmCampaign={str(sp.utm_campaign)}
utmSource={utm.utmSource}
utmMedium={utm.utmMedium}
utmCampaign={utm.utmCampaign}
gclid={str(sp.gclid)}
/>
</div>
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { accessWhere, isAuthorized, parseAccessCheck } from "@/lib/access-check";
/**
* Internal: есть ли у почты действующий доступ к курсу.
* Auth: тот же общий секрет, что у /api/internal/grant (`x-internal-secret`).
*
* Body: { email, course_slug } → { ok: true, has_access: boolean }
*/
export async function POST(request: NextRequest) {
if (!isAuthorized(request.headers.get("x-internal-secret"), process.env.INTERNAL_GRANT_SECRET)) {
return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ ok: false, error: "bad_json" }, { status: 400 });
}
const input = parseAccessCheck(body);
if (!input) {
return NextResponse.json({ ok: false, error: "missing_fields", need: ["email", "course_slug"] }, { status: 400 });
}
const found = await prisma.courseEnrollment.findFirst({
where: accessWhere(input, new Date()),
select: { userId: true },
});
return NextResponse.json({ ok: true, has_access: Boolean(found) });
}
@@ -229,7 +229,7 @@ export default async function SubmissionPage({ params }: Props) {
{new Date(fb.createdAt).toLocaleString("ru-RU")}
</span>
</div>
<p className="text-sm whitespace-pre-wrap mb-2">{fb.text}</p>
<p className="text-sm whitespace-pre-wrap mb-2">{linkify(fb.text)}</p>
{fbFiles.length > 0 && (
<div className="space-y-1 mt-2">
{fbFiles.map((f) => (
+1 -1
View File
@@ -25,7 +25,7 @@ const ARCHETYPE_INFO: Record<
emoji: "📚",
name: "Библиотекарь",
desc: "Ты собираешь и хранишь надёжно. Начни с готового хранилища и руководств.",
cta: "Демо-хранилище",
cta: "Second Brain Vault",
href: "https://second-brain.ru/sb-vault",
},
pragmatist: {
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect } from "vitest";
import { accessWhere, isAuthorized, parseAccessCheck } from "../access-check";
describe("access-check", () => {
it("нормализует почту и требует оба поля", () => {
expect(parseAccessCheck({ email: " A@Example.COM ", course_slug: "second-brain-vault" }))
.toEqual({ email: "a@example.com", courseSlug: "second-brain-vault" });
expect(parseAccessCheck({ email: "a@example.com" })).toBeNull();
expect(parseAccessCheck({ course_slug: "x" })).toBeNull();
expect(parseAccessCheck({ email: "без-собаки", course_slug: "x" })).toBeNull();
expect(parseAccessCheck(null)).toBeNull();
expect(parseAccessCheck({ email: "a".repeat(250) + "@b.ru", course_slug: "x" })).toBeNull();
});
it("условие: точная почта (без ILIKE-шаблонов), не заблокирован, курс по slug, доступ не истёк", () => {
const now = new Date("2026-10-13T00:00:00Z");
const w = accessWhere({ email: "a@example.com", courseSlug: "second-brain-vault" }, now);
expect(w.user.email).toBe("a@example.com");
expect(JSON.stringify(w)).not.toContain("insensitive");
expect(w.user.NOT).toEqual({ banned: true, OR: [{ banExpires: null }, { banExpires: { gt: now } }] });
expect(w.course).toEqual({ slug: "second-brain-vault" });
expect(w.OR).toEqual([{ expiresAt: null }, { expiresAt: { gt: now } }]);
});
it("без секрета или с чужим — отказ", () => {
expect(isAuthorized("s", "s")).toBe(true);
expect(isAuthorized("s", undefined)).toBe(false);
expect(isAuthorized("s", "")).toBe(false);
expect(isAuthorized(null, "s")).toBe(false);
expect(isAuthorized("x", "s")).toBe(false);
expect(isAuthorized("ss", "s")).toBe(false);
});
});
@@ -83,4 +83,20 @@ describe("sendCommentReplyEmail", () => {
expect(html).not.toContain("Отвечает <strong>");
expect(html).toContain("Привет!");
});
it("делает ссылки в тексте ответа кликабельными", async () => {
await sendCommentReplyEmail(
"student@example.com",
"Екатерина",
"6.3. Холст",
"Подарок тут: https://obsidian.second-brain.ru/secret-bonus?a=1&b=2. Пишите!",
LESSON_URL
);
const html = sent[0].html;
expect(html).toContain(
'<a href="https://obsidian.second-brain.ru/secret-bonus?a=1&amp;b=2" target="_blank"'
);
expect(html).toContain("secret-bonus?a=1&amp;b=2</a>. Пишите!");
});
});
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import { cleanUtm, parseUtmCookie, pickUtm } from "../utm-cookie";
describe("utm-cookie", () => {
it("разбирает сырую строку запроса из cookie", () => {
expect(parseUtmCookie("utm_source=youtube&utm_medium=video&utm_campaign=test-20261103&fbclid=x")).toEqual({
utmSource: "youtube",
utmMedium: "video",
utmCampaign: "test-20261103",
});
});
it("понимает percent-кодирование", () => {
expect(parseUtmCookie("utm_campaign=a%2Db_c.d").utmCampaign).toBe("a-b_c.d");
});
it("пустая и битая cookie — пустые метки", () => {
expect(parseUtmCookie(undefined)).toEqual({ utmSource: "", utmMedium: "", utmCampaign: "" });
expect(parseUtmCookie("")).toEqual({ utmSource: "", utmMedium: "", utmCampaign: "" });
expect(parseUtmCookie("%%%")).toEqual({ utmSource: "", utmMedium: "", utmCampaign: "" });
});
it("отбрасывает значения не из белого списка символов и слишком длинные", () => {
expect(cleanUtm("ok-1")).toBe("ok-1");
expect(cleanUtm("<script>")).toBe("");
expect(cleanUtm("с пробелом")).toBe("");
expect(cleanUtm("a".repeat(150))).toBe("a".repeat(100));
expect(parseUtmCookie("utm_source=x;Domain=evil").utmSource).toBe("");
});
it("метки из адреса важнее cookie, поля не смешиваются", () => {
const cookie = { utmSource: "vk", utmMedium: "video", utmCampaign: "old" };
expect(pickUtm({ utmSource: "", utmMedium: "", utmCampaign: "new" }, cookie)).toEqual({
utmSource: "",
utmMedium: "",
utmCampaign: "new",
});
expect(pickUtm({ utmSource: "", utmMedium: "", utmCampaign: "" }, cookie)).toEqual(cookie);
});
});
+52
View File
@@ -0,0 +1,52 @@
/**
* Проверка «у этой почты есть действующий доступ к курсу» для внутренних
* вызовов (бот выдачи SB Access в payment-router, 20261013): старый покупатель
* Second Brain Vault оставляет почту, бот получает ключ входа, только если
* доступ к курсу действительно есть.
*
* Ответ — только да/нет: ни имени, ни дат наружу не отдаём.
*/
import { timingSafeEqual } from "crypto";
export type AccessCheckInput = { email: string; courseSlug: string };
export function parseAccessCheck(body: unknown): AccessCheckInput | null {
if (!body || typeof body !== "object") return null;
const b = body as Record<string, unknown>;
const email = typeof b.email === "string" ? b.email.trim().toLowerCase() : "";
const courseSlug = typeof b.course_slug === "string" ? b.course_slug.trim() : "";
if (!email || email.length > 254 || !email.includes("@") || !courseSlug || courseSlug.length > 100) {
return null;
}
return { email, courseSlug };
}
/**
* Условие Prisma: не истёкшая запись о доступе к курсу у незаблокированного
* пользователя с ЭТОЙ почтой.
*
* Почту сравниваем точно, без `mode: "insensitive"`: Prisma превращает такое
* сравнение в ILIKE, и `%`/`_` в адресе становятся шаблоном — по адресу-маске
* метод отвечал бы «доступ есть» за чужую почту (проверка безопасности
* 20261013). Все почты в базе хранятся в нижнем регистре (проверено на проде:
* 0 из 1426 с заглавными), вход нормализуется parseAccessCheck.
*/
export function accessWhere(input: AccessCheckInput, now: Date) {
return {
user: {
email: input.email,
NOT: { banned: true, OR: [{ banExpires: null }, { banExpires: { gt: now } }] },
},
course: { slug: input.courseSlug },
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
};
}
/** Секрет сравниваем за постоянное время: обычное === выдаёт длину совпавшего префикса по времени. */
export function isAuthorized(header: string | null, secret: string | undefined): boolean {
if (!secret || !header) return false;
const a = Buffer.from(header);
const b = Buffer.from(secret);
return a.length === b.length && timingSafeEqual(a, b);
}
+15 -1
View File
@@ -1,5 +1,6 @@
import { Resend } from "resend";
import { getSetting } from "./settings";
import { URL_RE } from "./linkify";
function getResend() {
return new Resend(process.env.RESEND_API_KEY);
@@ -97,11 +98,24 @@ function escapeHtml(text: string) {
.replace(/"/g, "&quot;");
}
// Ссылки в цитате — кликабельные: не все почтовики сами превращают голый адрес в ссылку.
// Экранируем по частям, чтобы `&` в адресе не превратился в `&amp;` внутри href дважды.
function linkifyHtml(text: string) {
return text
.split(URL_RE)
.map((part, i) =>
i % 2 === 1
? `<a href="${escapeHtml(part)}" target="_blank" style="color:#323232;text-decoration:underline;word-break:break-all;">${escapeHtml(part)}</a>`
: escapeHtml(part)
)
.join("");
}
function quote(text: string) {
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:16px 0;">
<tr>
<td style="border-left:3px solid #323232;padding:10px 14px;background-color:#E8F0D8;">
<p style="margin:0;font-family:'Courier New',Courier,monospace;font-size:13px;line-height:1.6;color:#323232;">${escapeHtml(text).replace(/\n/g, "<br/>")}</p>
<p style="margin:0;font-family:'Courier New',Courier,monospace;font-size:13px;line-height:1.6;color:#323232;">${linkifyHtml(text).replace(/\n/g, "<br/>")}</p>
</td>
</tr>
</table>`;
+1 -1
View File
@@ -1,6 +1,6 @@
import { Fragment, type ReactNode } from "react";
const URL_RE = /(https?:\/\/[^\s<>()]+[^\s<>().,;:!?])/g;
export const URL_RE = /(https?:\/\/[^\s<>()]+[^\s<>().,;:!?])/g;
export function linkify(text: string): ReactNode[] {
if (!text) return [];
+48
View File
@@ -0,0 +1,48 @@
// Метки utm с лендингов (20261103, задание CMO «utm_campaign в LMS»).
//
// Лендинги второго мозга — поддомены second-brain.ru за общим Caddy на проде.
// Caddy при заходе с utm_* в адресе кладёт сырую строку запроса в cookie
// `sb_utm` на домен .second-brain.ru (см. фрагмент `(sb_utm)` в prod.caddy).
// Кнопка лендинга ведёт на /register без меток, поэтому страница регистрации
// берёт их из адреса, а если там пусто — из этой cookie.
export const UTM_COOKIE = "sb_utm";
export type Utm = { utmSource: string; utmMedium: string; utmCampaign: string };
const EMPTY: Utm = { utmSource: "", utmMedium: "", utmCampaign: "" };
// Значение метки: только латиница, цифры, точка, дефис, подчёркивание; до 100 символов.
// Остальное (кириллица, пробелы, кавычки) отбрасываем целиком — в отчёт идут слаги.
export function cleanUtm(v: string | null | undefined): string {
if (!v) return "";
const s = v.trim().slice(0, 100);
return /^[A-Za-z0-9._-]+$/.test(s) ? s : "";
}
export function parseUtmCookie(raw: string | null | undefined): Utm {
if (!raw) return EMPTY;
let q: URLSearchParams;
try {
q = new URLSearchParams(raw.length > 2000 ? raw.slice(0, 2000) : raw);
} catch {
return EMPTY;
}
return {
utmSource: cleanUtm(q.get("utm_source")),
utmMedium: cleanUtm(q.get("utm_medium")),
utmCampaign: cleanUtm(q.get("utm_campaign")),
};
}
// Метки из адреса важнее cookie: прямой переход на /register?utm_… — самый свежий.
// Берём набор целиком из одного источника, не смешиваем поля.
export function pickUtm(fromQuery: Utm, fromCookie: Utm): Utm {
const q = {
utmSource: cleanUtm(fromQuery.utmSource),
utmMedium: cleanUtm(fromQuery.utmMedium),
utmCampaign: cleanUtm(fromQuery.utmCampaign),
};
if (q.utmSource || q.utmMedium || q.utmCampaign) return q;
return fromCookie;
}