Новые правки в компоенты

This commit is contained in:
Web-serfer 2026-04-18 18:25:10 +05:00
parent e85d1ce668
commit 6f727aae7b
23 changed files with 1483 additions and 37 deletions

View file

@ -0,0 +1,109 @@
import type { APIRoute } from 'astro';
const POCKETBASE_URL = import.meta.env.POCKETBASE_URL || 'http://localhost:8090';
const PASSWORD_MIN_LENGTH = 8;
const PASSWORD_MAX_LENGTH = 12;
const PASSWORD_REGEX = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]+$/;
function validatePassword(password: string): { valid: boolean; error?: string } {
if (!password || password.length < PASSWORD_MIN_LENGTH) {
return { valid: false, error: 'Пароль должен быть не менее 8 символов' };
}
if (password.length > PASSWORD_MAX_LENGTH) {
return { valid: false, error: 'Пароль не должен превышать 12 символов' };
}
if (!PASSWORD_REGEX.test(password)) {
return { valid: false, error: 'Пароль должен содержать хотя бы одну букву и одну цифру' };
}
return { valid: true };
}
export const POST: APIRoute = async ({ request }) => {
try {
let body;
try {
body = await request.json();
} catch {
return new Response(
JSON.stringify({ error: 'Неверный формат данных' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const { token, password, passwordConfirm } = body;
if (!token) {
return new Response(
JSON.stringify({ error: 'Токен сброса пароля обязателен' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
if (!password || !passwordConfirm) {
return new Response(
JSON.stringify({ error: 'Пароль и подтверждение обязательны' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
if (password !== passwordConfirm) {
return new Response(
JSON.stringify({ error: 'Пароли не совпадают' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
return new Response(
JSON.stringify({ error: passwordValidation.error }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const confirmUrl = `${POCKETBASE_URL}/api/collections/users/confirm-password-reset`;
const response = await fetch(confirmUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password, passwordConfirm }),
});
if (response.status === 204) {
return new Response(
JSON.stringify({
success: true,
message: 'Пароль успешно изменён'
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}
let data;
try {
data = await response.json();
} catch {
return new Response(
JSON.stringify({ error: 'Ошибка обработки ответа' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
return new Response(
JSON.stringify({
error: data.message || 'Неверный или истёкший токен сброса пароля'
}),
{ status: response.status, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('[CONFIRM_RESET] Error:', error);
return new Response(
JSON.stringify({
error: 'Внутренняя ошибка сервера'
}),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};

View file

@ -0,0 +1,73 @@
import type { APIRoute } from "astro";
const POCKETBASE_URL = import.meta.env.POCKETBASE_URL || "http://localhost:8090";
export const GET: APIRoute = async ({ cookies, request }) => {
try {
const pbAuthCookie = cookies.get("pb_auth")?.value;
if (!pbAuthCookie) {
return new Response(
JSON.stringify({ authenticated: false, user: null }),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
const token = pbAuthCookie.trim();
const response = await fetch(
`${POCKETBASE_URL}/api/collections/users/auth-refresh`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
);
if (!response.ok) {
if (cookies.has("pb_auth")) {
cookies.delete("pb_auth", { path: "/" });
}
return new Response(
JSON.stringify({ authenticated: false, user: null }),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
const data = await response.json();
const isHttps = request.headers.get("x-forwarded-proto") === "https";
cookies.set("pb_auth", data.token, {
path: "/",
maxAge: 60 * 60 * 24 * 7,
httpOnly: false,
secure: isHttps,
sameSite: "lax",
});
const fullName = [data.record.firstName, data.record.lastName].filter(Boolean).join(" ") || data.record.email;
return new Response(
JSON.stringify({
authenticated: true,
user: {
id: data.record.id,
email: data.record.email,
name: fullName,
avatar: data.record.avatar,
verified: data.record.verified,
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
} catch (error) {
console.error("[AUTH ME] Error:", error);
return new Response(
JSON.stringify({ authenticated: false, error: "Ошибка сервера" }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
};

View file

@ -0,0 +1,124 @@
import type { APIRoute } from 'astro';
const POCKETBASE_URL = import.meta.env.POCKETBASE_URL || 'http://localhost:8090';
const RATE_LIMIT_MAX_REQUESTS = 3;
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
const rateLimitStore = new Map<string, number[]>();
function cleanOldRecords(email: string, now: number) {
const timestamps = rateLimitStore.get(email) || [];
const windowStart = now - RATE_LIMIT_WINDOW_MS;
const validTimestamps = timestamps.filter(ts => ts > windowStart);
if (validTimestamps.length === 0) {
rateLimitStore.delete(email);
} else {
rateLimitStore.set(email, validTimestamps);
}
}
function checkRateLimit(email: string): { allowed: boolean; remaining?: number; resetIn?: number } {
const now = Date.now();
const normalizedEmail = email.toLowerCase().trim();
cleanOldRecords(normalizedEmail, now);
const timestamps = rateLimitStore.get(normalizedEmail) || [];
if (timestamps.length >= RATE_LIMIT_MAX_REQUESTS) {
const oldestTimestamp = timestamps[0];
const resetIn = RATE_LIMIT_WINDOW_MS - (now - oldestTimestamp);
return { allowed: false, resetIn };
}
timestamps.push(now);
rateLimitStore.set(normalizedEmail, timestamps);
return { allowed: true, remaining: RATE_LIMIT_MAX_REQUESTS - timestamps.length };
}
export const POST: APIRoute = async ({ request }) => {
try {
let body;
try {
body = await request.json();
} catch {
return new Response(
JSON.stringify({ error: 'Неверный формат данных' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const { email } = body;
if (!email) {
return new Response(
JSON.stringify({ error: 'Email обязателен' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const rateLimitResult = checkRateLimit(email);
if (!rateLimitResult.allowed) {
const resetMinutes = Math.ceil((rateLimitResult.resetIn || 0) / 60000);
return new Response(
JSON.stringify({
error: 'Слишком много запросов. Попробуйте позже',
retryAfter: resetMinutes
}),
{
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': String(resetMinutes * 60)
}
}
);
}
const resetUrl = `${POCKETBASE_URL}/api/collections/users/request-password-reset`;
const response = await fetch(resetUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (response.status === 204) {
return new Response(
JSON.stringify({
success: true,
message: 'Письмо для сброса пароля отправлено'
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}
let data;
try {
data = await response.json();
} catch {
return new Response(
JSON.stringify({ error: 'Ошибка обработки ответа' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
return new Response(
JSON.stringify({
error: data.message || 'Ошибка при отправке письма для сброса пароля'
}),
{ status: response.status, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('[PASSWORD_RESET] Error:', error);
return new Response(
JSON.stringify({
error: 'Внутренняя ошибка сервера'
}),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};

View file

@ -0,0 +1,248 @@
import type { APIRoute } from "astro";
const POCKETBASE_URL = import.meta.env.POCKETBASE_URL || "http://localhost:8090";
export const GET: APIRoute = async ({ url, cookies }) => {
const postSlug = url.searchParams.get("post_slug");
const parent = url.searchParams.get("parent");
if (!postSlug) {
return new Response(
JSON.stringify({ error: "post_slug required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
const pbAuthCookie = cookies.get("pb_auth")?.value;
const token = pbAuthCookie?.trim();
// Фильтр: комментарии для поста + статус published
let filter = `post_slug = "${postSlug}" && status = "published"`;
// Если parent = null — это основные комментарии (не ответы)
// Если parent указан — это ответы на конкретный комментарий
if (parent === "null" || parent === null) {
filter += ` && parent = ""`;
} else if (parent) {
filter += ` && parent = "${parent}"`;
}
const expand = "user";
const sort = "-created";
try {
const response = await fetch(
`${POCKETBASE_URL}/api/collections/comments/records?expand=${expand}&filter=${encodeURIComponent(filter)}&sort=${sort}`,
{
headers: token ? { Authorization: `Bearer ${token}` } : {},
}
);
const data = await response.json();
return new Response(JSON.stringify(data), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("[Comments API GET] Error:", error);
return new Response(
JSON.stringify({ error: "Failed to fetch comments" }),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
);
}
};
export const POST: APIRoute = async ({ request, cookies }) => {
const pbAuthCookie = cookies.get("pb_auth")?.value;
const token = pbAuthCookie?.trim();
console.log("[Comments API POST] Токен получен:", !!token);
console.log("[Comments API POST] Длина токена:", token?.length);
console.log("[Comments API POST] Cookie:", pbAuthCookie ? "да" : "нет");
if (!token) {
console.log("[Comments API POST] Токен не найден - 401");
return new Response(
JSON.stringify({ error: "Unauthorized" }),
{
status: 401,
headers: { "Content-Type": "application/json" },
}
);
}
// Получаем данные пользователя из токена
let userId: string | null = null;
try {
const userResponse = await fetch(
`${POCKETBASE_URL}/api/collections/users/auth-refresh`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
);
if (userResponse.ok) {
const userData = await userResponse.json();
userId = userData.record?.id;
}
} catch (e) {
console.error("[Comments API POST] Не удалось получить данные пользователя:", e);
}
if (!userId) {
console.log("[Comments API POST] Не удалось получить ID пользователя");
return new Response(
JSON.stringify({ error: "Unauthorized" }),
{
status: 401,
headers: { "Content-Type": "application/json" },
}
);
}
try {
const body = await request.json();
const { content, post_slug, parent = null } = body;
// Валидация
if (!content || !post_slug) {
return new Response(
JSON.stringify({ error: "content and post_slug required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
if (content.length < 10 || content.length > 2000) {
return new Response(
JSON.stringify({ error: "content must be between 10 and 2000 characters" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
// Проверка: если это ответ на комментарий
if (parent) {
const parentCommentResponse = await fetch(
`${POCKETBASE_URL}/api/collections/comments/records/${parent}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (parentCommentResponse.ok) {
const parentComment = await parentCommentResponse.json();
// Если родительский комментарий уже сам является ответом — запрещаем (только 2 уровня)
if (parentComment.parent && parentComment.parent !== "") {
return new Response(
JSON.stringify({ error: "Нельзя отвечать на ответы. Вы можете ответить только на основные комментарии." }),
{
status: 403,
headers: { "Content-Type": "application/json" },
}
);
}
// Нельзя отвечать на свой комментарий
if (parentComment.user === userId) {
return new Response(
JSON.stringify({ error: "Нельзя отвечать на свой собственный коммен<D0B5><D0BD>арий" }),
{
status: 403,
headers: { "Content-Type": "application/json" },
}
);
}
// Проверяем: если родительский комментарий уже имеет ответы — запрещаем (только 1 уровень ответов)
const existingRepliesResponse = await fetch(
`${POCKETBASE_URL}/api/collections/comments/records?filter=parent="${parent}"&perPage=1`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (existingRepliesResponse.ok) {
const existingRepliesData = await existingRepliesResponse.json();
if (existingRepliesData.totalItems > 0) {
return new Response(
JSON.stringify({ error: "На этот комментарий уже есть ответ. Вы можете ответить только на основные комментарии." }),
{
status: 403,
headers: { "Content-Type": "application/json" },
}
);
}
}
} else {
return new Response(
JSON.stringify({ error: "Родительский комментарий не найден" }),
{
status: 404,
headers: { "Content-Type": "application/json" },
}
);
}
}
const response = await fetch(
`${POCKETBASE_URL}/api/collections/comments/records`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
content,
post_slug,
parent: parent || "",
status: "published",
user: userId, // Передаём ID текущего пользователя
}),
}
);
const data = await response.json();
console.log("[Comments API POST] Ответ PocketBase:", response.status, JSON.stringify(data));
if (!response.ok) {
return new Response(JSON.stringify(data), {
status: response.status,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify(data), {
status: 201,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("[Comments API POST] Error:", error);
return new Response(
JSON.stringify({ error: "Failed to create comment" }),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
);
}
};

View file

@ -39,11 +39,14 @@ export const POST: APIRoute = async ({ request, cookies }) => {
const authData = await authResponse.json();
console.log('[Vote API] Auth refresh full response:', JSON.stringify(authData));
// ВАЖНО: Явно берем id, а не email
const userId = authData.record?.id;
const userId = authData.record?.id || authData.record?.ID;
const userEmail = authData.record?.email;
console.log('[Vote API] Auth data:', { userId, userEmail, record: authData.record });
console.log('[Vote API] User ID extracted:', userId);
console.log('[Vote API] User Email extracted:', userEmail);
// Защита: проверяем что это действительно ID, а не email
if (!userId || EMAIL_REGEX.test(userId)) {
@ -115,9 +118,11 @@ export const POST: APIRoute = async ({ request, cookies }) => {
}
// Выполняем операцию с голосом
console.log('[Vote API] Creating vote with:', { post: post_id, user: userId, vote_type });
const voteBody = method === 'POST' ? JSON.stringify({
post: post_id,
user: userId, // Теперь точно ID, а не email
user: userId,
vote_type,
}) : method === 'PATCH' ? JSON.stringify({ vote_type }) : null;
@ -130,6 +135,15 @@ export const POST: APIRoute = async ({ request, cookies }) => {
body: voteBody,
});
console.log('[Vote API] Vote response status:', voteRes.status);
if (!voteRes.ok) {
const errorText = await voteRes.text();
console.log('[Vote API] Vote error response:', errorText);
} else {
const createdVote = await voteRes.json();
console.log('[Vote API] Created vote:', JSON.stringify(createdVote));
}
if (!voteRes.ok && method !== 'DELETE') {
const errorText = await voteRes.text();
console.error('[Vote API] Failed to save vote:', errorText);
@ -139,16 +153,13 @@ export const POST: APIRoute = async ({ request, cookies }) => {
if (method === 'POST') userVote = vote_type;
if (method === 'DELETE') userVote = null;
// Получаем актуальные счетчики
// Получаем актуальные счетчики (без авторизации)
const votesRes = await fetch(
`${POCKETBASE_URL}/api/collections/post_votes/records?` +
new URLSearchParams({
filter: `post="${post_id}"`,
fields: 'vote_type',
}),
{
headers: { 'Authorization': `Bearer ${token}` },
}
})
);
let likes = 0;
@ -160,7 +171,8 @@ export const POST: APIRoute = async ({ request, cookies }) => {
dislikes = votesData.items.filter((v: any) => v.vote_type === 'dislike').length;
}
// Обновляем счетчики в посте
// Обновляем счетчики в посте только если есть токен
if (token) {
await fetch(
`${POCKETBASE_URL}/api/collections/posts/records/${post_id}`,
{
@ -172,6 +184,7 @@ export const POST: APIRoute = async ({ request, cookies }) => {
body: JSON.stringify({ likes, dislikes }),
}
);
}
return new Response(
JSON.stringify({ likes, dislikes, userVote }),
@ -216,6 +229,24 @@ export const GET: APIRoute = async ({ url, cookies }) => {
const post = await postRes.json();
// Получаем счетчики из коллекции голосов (без авторизации)
const votesCountRes = await fetch(
`${POCKETBASE_URL}/api/collections/post_votes/records?` +
new URLSearchParams({
filter: `post="${postId}"`,
fields: 'vote_type',
})
);
let likes = post.likes || 0;
let dislikes = post.dislikes || 0;
if (votesCountRes.ok) {
const votesData = await votesCountRes.json();
likes = votesData.items.filter((v: any) => v.vote_type === 'like').length;
dislikes = votesData.items.filter((v: any) => v.vote_type === 'dislike').length;
}
// Определяем голос текущего пользователя
let userVote: 'like' | 'dislike' | null = null;
@ -232,9 +263,12 @@ export const GET: APIRoute = async ({ url, cookies }) => {
if (authRes.ok) {
const authData = await authRes.json();
const userId = authData.record?.id;
console.log('[Vote API] GET auth refresh:', JSON.stringify(authData.record));
const userId = authData.record?.id || authData.record?.ID;
if (userId && POCKETBASE_ID_REGEX.test(userId)) {
console.log('[Vote API] GET: Checking for existing vote with:', { post: postId, user: userId });
const userVoteRes = await fetch(
`${POCKETBASE_URL}/api/collections/post_votes/records?` +
new URLSearchParams({
@ -260,8 +294,8 @@ export const GET: APIRoute = async ({ url, cookies }) => {
return new Response(
JSON.stringify({
likes: post.likes || 0,
dislikes: post.dislikes || 0,
likes,
dislikes,
userVote,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' }}