diff --git a/.env.example b/.env.example index e90810b..a288753 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,5 @@ AUTH_GOOGLE_ID=replace_me AUTH_GOOGLE_SECRET=replace_me # Optional when deployed behind a reverse proxy AUTH_TRUST_HOST=false +# must be same in NEWS_API_ETL_project +NEWS_API_KEY_ENCRYPTION_SECRET=replace_me_with_32+_random_chars diff --git a/app/(protected)/dashboard/page.tsx b/app/(protected)/dashboard/page.tsx index 4fc215d..cbaec7e 100644 --- a/app/(protected)/dashboard/page.tsx +++ b/app/(protected)/dashboard/page.tsx @@ -3,6 +3,7 @@ import { redirect } from "next/navigation"; import { FilterForm } from "@/components/dashboard/filter-form"; import { NewsTable } from "@/components/dashboard/news-table"; import { SearchRequestForm } from "@/components/dashboard/search-request-form"; +import { getNewsApiKeyStatusForUser } from "@/lib/user-news-api-key"; import { getUserNewsFilterOptions, listNewsForUser } from "@/lib/news"; import { parseNewsFiltersFromSearchParams, type ParsedNewsFilters } from "@/lib/news-filters"; import { listSearchRequestsForUser } from "@/lib/searches"; @@ -35,10 +36,11 @@ export default async function DashboardPage({ const resolvedSearchParams = (await searchParams) ?? {}; const filters = parseNewsFiltersFromSearchParams(resolvedSearchParams); - const [newsData, searches, filterOptions] = await Promise.all([ + const [newsData, searches, filterOptions, newsApiKeyStatus] = await Promise.all([ listNewsForUser(appUserId, filters), listSearchRequestsForUser(appUserId, 20), getUserNewsFilterOptions(appUserId), + getNewsApiKeyStatusForUser(appUserId) ]); const hasPrevPage = filters.page > 1; @@ -48,7 +50,14 @@ export default async function DashboardPage({

ШОПОНОВОСТЯМ

- + {newsApiKeyStatus.hasNewsApiKey ? ( + + ) : ( +
+ Чтобы создавать запросы на новости, сначала добавь NewsAPI key в{" "} + профиле. +
+ )}
diff --git a/app/(protected)/layout.tsx b/app/(protected)/layout.tsx index f2b29f0..e441fee 100644 --- a/app/(protected)/layout.tsx +++ b/app/(protected)/layout.tsx @@ -26,6 +26,7 @@ export default async function ProtectedLayout({
diff --git a/app/(protected)/profile/page.tsx b/app/(protected)/profile/page.tsx new file mode 100644 index 0000000..58b6298 --- /dev/null +++ b/app/(protected)/profile/page.tsx @@ -0,0 +1,57 @@ +import { redirect } from "next/navigation"; +import { NewsApiKeyForm } from "@/components/profile/news-api-key-form"; +import { getNewsApiKeyStatusForUser } from "@/lib/user-news-api-key"; +import { getCurrentAppUser } from "@/lib/users"; + +export default async function ProfilePage() { + const user = await getCurrentAppUser(); + + if (!user) { + redirect("/login"); + } + + const newsApiKeyStatus = await getNewsApiKeyStatusForUser(user.id); + + return ( +
+
+
+
+

Профиль

+

Данные аккаунта и NewsAPI ключ

+
+
+ +
+
+ Имя + {user.name ?? "Не указано"} +
+ +
+ Почта + {user.email} +
+ +
+ NewsAPI ключик + + {newsApiKeyStatus.hasNewsApiKey + ? `Сохранён, заканчивается на ${newsApiKeyStatus.last4}` + : "Не добавлен"} + +
+
+
+ +
+

Сохранить NewsAPI ключик

+

+ Твой ключ как парень 8 летней девочки. Тоже не тут и тоже под замком +

+ + +
+
+ ); +} \ No newline at end of file diff --git a/app/api/profile/news-api-key/route.ts b/app/api/profile/news-api-key/route.ts new file mode 100644 index 0000000..cb41e72 --- /dev/null +++ b/app/api/profile/news-api-key/route.ts @@ -0,0 +1,88 @@ +import { AppError } from "@/lib/app-error"; +import { requireCurrentAppUserId } from "@/lib/api-auth"; +import { getRequestId, handleApiError, jsonOk } from "@/lib/api-response"; +import { ensureSameOrigin } from "@/lib/request-security"; +import { + deleteNewsApiKeyForUser, + getNewsApiKeyStatusForUser, + saveNewsApiKeyForUser, +} from "@/lib/user-news-api-key"; +import { parseRequiredText } from "@/lib/validation"; + +const NEWS_API_KEY_MAX_LENGTH = 256; +const NEWS_API_KEY_MIN_LENGTH = 20; + +function parseNewsApiKey(input: unknown) { + const apiKey = parseRequiredText(input, { + field: "apiKey", + maxLength: NEWS_API_KEY_MAX_LENGTH, + }); + + if (apiKey.length < NEWS_API_KEY_MIN_LENGTH) { + throw new AppError(400, "VALIDATION_ERROR", "apiKey is too short"); + } + + return apiKey; +} + +export async function GET(request: Request) { + const requestId = getRequestId(request); + + try { + const appUserIdOrResponse = await requireCurrentAppUserId(requestId); + if (appUserIdOrResponse instanceof Response) { + return appUserIdOrResponse; + } + + const status = await getNewsApiKeyStatusForUser(appUserIdOrResponse); + return jsonOk(requestId, status); + } catch (error) { + return handleApiError(error, requestId); + } +} + +export async function POST(request: Request) { + const requestId = getRequestId(request); + + try { + const appUserIdOrResponse = await requireCurrentAppUserId(requestId); + if (appUserIdOrResponse instanceof Response) { + return appUserIdOrResponse; + } + + ensureSameOrigin(request); + + let body: unknown; + try { + body = await request.json(); + } catch { + throw new AppError(400, "INVALID_JSON", "Request body must be valid JSON"); + } + + const payload = typeof body === "object" && body !== null ? (body as Record) : {}; + const apiKey = parseNewsApiKey(payload.apiKey); + + const status = await saveNewsApiKeyForUser(appUserIdOrResponse, apiKey); + return jsonOk(requestId, status); + } catch (error) { + return handleApiError(error, requestId); + } +} + +export async function DELETE(request: Request) { + const requestId = getRequestId(request); + + try { + const appUserIdOrResponse = await requireCurrentAppUserId(requestId); + if (appUserIdOrResponse instanceof Response) { + return appUserIdOrResponse; + } + + ensureSameOrigin(request); + + const status = await deleteNewsApiKeyForUser(appUserIdOrResponse); + return jsonOk(requestId, status); + } catch (error) { + return handleApiError(error, requestId); + } +} \ No newline at end of file diff --git a/app/api/searches/route.ts b/app/api/searches/route.ts index 44eec50..9ee09f1 100644 --- a/app/api/searches/route.ts +++ b/app/api/searches/route.ts @@ -1,5 +1,6 @@ import { createSearchRequest } from "@/lib/searches"; import { AppError } from "@/lib/app-error"; +import { hasNewsApiKeyForUser } from "@/lib/user-news-api-key"; import { getRequestId, handleApiError, jsonError, jsonOk } from "@/lib/api-response"; import { requireCurrentAppUserId } from "@/lib/api-auth"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -26,7 +27,15 @@ export async function POST(request: Request) { return appUserIdOrResponse; } const appUserId = appUserIdOrResponse; - + const hasNewsApiKey = await hasNewsApiKeyForUser(appUserId); + if (!hasNewsApiKey) { + return jsonError( + requestId, + 403, + "NEWS_API_KEY_REQUIRED", + "Add NewsAPI key in your profile before creating news requests", + ); + } ensureSameOrigin(request); const clientIp = getClientIp(request); diff --git a/components/profile/news-api-key-form.tsx b/components/profile/news-api-key-form.tsx new file mode 100644 index 0000000..9fe2cd3 --- /dev/null +++ b/components/profile/news-api-key-form.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { type FormEvent, useState } from "react"; +import { useRouter } from "next/navigation"; +import type { NewsApiKeyStatus } from "@/lib/user-news-api-key"; + +type ApiSuccess = { + ok: true; + data: T; + requestId: string; +}; + +type ApiError = { + ok: false; + error: { + code: string; + message: string; + details?: unknown; + }; + requestId: string; +}; + +type ApiResponse = ApiSuccess | ApiError; + +type Props = { + initialStatus: NewsApiKeyStatus; +}; + +export function NewsApiKeyForm({ initialStatus }: Props) { + const router = useRouter(); + const [apiKey, setApiKey] = useState(""); + const [status, setStatus] = useState(initialStatus); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + + async function onSubmit(event: FormEvent) { + event.preventDefault(); + setError(""); + setSuccess(""); + + const normalizedKey = apiKey.trim(); + + if (!normalizedKey) { + setError("Введите NewsAPI key"); + return; + } + + if (normalizedKey.length < 20 || normalizedKey.length > 256) { + setError("Ключ выглядит некорректно"); + return; + } + + setIsSubmitting(true); + + try { + const response = await fetch("/api/profile/news-api-key", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ apiKey: normalizedKey }), + }); + + const payload = (await response.json().catch(() => null)) as ApiResponse | null; + + if (!response.ok || !payload || payload.ok === false) { + const message = payload && payload.ok === false ? payload.error.message : "Не удалось сохранить ключ"; + setError(message); + return; + } + + setApiKey(""); + setStatus(payload.data); + setSuccess("Ключ сохранён"); + router.refresh(); + } catch { + setError("Ошибка сети. Попробуй ещё раз."); + } finally { + setIsSubmitting(false); + } + } + + async function onDelete() { + setError(""); + setSuccess(""); + setIsDeleting(true); + + try { + const response = await fetch("/api/profile/news-api-key", { + method: "DELETE", + }); + + const payload = (await response.json().catch(() => null)) as ApiResponse | null; + + if (!response.ok || !payload || payload.ok === false) { + const message = payload && payload.ok === false ? payload.error.message : "Не удалось удалить ключ"; + setError(message); + return; + } + + setStatus(payload.data); + setSuccess("Ключ удалён"); + router.refresh(); + } catch { + setError("Ошибка сети. Попробуй ещё раз."); + } finally { + setIsDeleting(false); + } + } + + return ( +
+ {status.hasNewsApiKey ? ( +

+ Сейчас ключ сохранён. Последние 4 символа: {status.last4} +

+ ) : ( +

+ Ключ ещё не добавлен. Без него нельзя создавать запросы на новости. +

+ )} + +
+ + setApiKey(event.target.value)} + placeholder="Вставь ключ NewsAPI" + type="password" + value={apiKey} + /> +
+ +
+ + + {status.hasNewsApiKey ? ( + + ) : null} +
+ + {error ?

{error}

: null} + {success ?

{success}

: null} +
+ ); +} \ No newline at end of file diff --git a/lib/user-news-api-key.ts b/lib/user-news-api-key.ts index 0203162..2b98640 100644 --- a/lib/user-news-api-key.ts +++ b/lib/user-news-api-key.ts @@ -3,6 +3,7 @@ import { pool } from "@/lib/db"; const ALGORITHM = "aes-256-gcm"; const IV_LENGTH = 12; +const NEWSAPI_SERVICE = "news_api"; export type NewsApiKeyStatus = { hasNewsApiKey: boolean; @@ -10,13 +11,13 @@ export type NewsApiKeyStatus = { updatedAt: string | null; }; -type EncryptedNewsApiKey = { +type EncryptedUserKey = { encryptedKey: string; iv: string; authTag: string; }; -type NewsApiKeyRow = { +type UserKeyRow = { encrypted_key: string; iv: string; auth_tag: string; @@ -26,16 +27,21 @@ function getEncryptionKey() { const secret = process.env.NEWS_API_KEY_ENCRYPTION_SECRET ?? process.env.AUTH_SECRET; if (!secret) { - throw new Error("NEWS_API_KEY_ENCRYPTION_SECRET or AUTH_SECRET must be set to store NewsAPI keys."); + throw new Error("NEWS_API_KEY_ENCRYPTION_SECRET or AUTH_SECRET must be set to store user keys."); } return createHash("sha256").update(secret).digest(); } -function encryptNewsApiKey(apiKey: string): EncryptedNewsApiKey { +function encryptUserKey(apiKey: string): EncryptedUserKey { const iv = randomBytes(IV_LENGTH); const cipher = createCipheriv(ALGORITHM, getEncryptionKey(), iv); - const encrypted = Buffer.concat([cipher.update(apiKey, "utf8"), cipher.final()]); + + const encrypted = Buffer.concat([ + cipher.update(apiKey, "utf8"), + cipher.final(), + ]); + const authTag = cipher.getAuthTag(); return { @@ -45,7 +51,7 @@ function encryptNewsApiKey(apiKey: string): EncryptedNewsApiKey { }; } -export function decryptNewsApiKey(row: NewsApiKeyRow): string { +export function decryptUserKey(row: UserKeyRow): string { const decipher = createDecipheriv( ALGORITHM, getEncryptionKey(), @@ -67,11 +73,11 @@ export async function getNewsApiKeyStatusForUser(userId: number): Promise { ` SELECT EXISTS ( SELECT 1 - FROM user_news_api_keys - WHERE user_id = $1 + FROM users_keys + WHERE user_id = $1 AND service = $2 ) AS exists `, - [userId], + [userId, NEWSAPI_SERVICE], ); return rows[0]?.exists ?? false; @@ -96,21 +102,22 @@ export async function saveNewsApiKeyForUser( userId: number, apiKey: string, ): Promise { - const encrypted = encryptNewsApiKey(apiKey); + const encrypted = encryptUserKey(apiKey); const last4 = apiKey.slice(-4); const { rows } = await pool.query( ` - INSERT INTO user_news_api_keys ( + INSERT INTO users_keys ( user_id, + service, encrypted_key, iv, auth_tag, key_last4, updated_at ) - VALUES ($1, $2, $3, $4, $5, NOW()) - ON CONFLICT (user_id) DO UPDATE + VALUES ($1, $2, $3, $4, $5, $6, NOW()) + ON CONFLICT (user_id, service) DO UPDATE SET encrypted_key = EXCLUDED.encrypted_key, iv = EXCLUDED.iv, @@ -122,7 +129,14 @@ export async function saveNewsApiKeyForUser( key_last4 AS "last4", updated_at::text AS "updatedAt" `, - [userId, encrypted.encryptedKey, encrypted.iv, encrypted.authTag, last4], + [ + userId, + NEWSAPI_SERVICE, + encrypted.encryptedKey, + encrypted.iv, + encrypted.authTag, + last4, + ], ); return rows[0]; @@ -131,26 +145,26 @@ export async function saveNewsApiKeyForUser( export async function deleteNewsApiKeyForUser(userId: number): Promise { await pool.query( ` - DELETE FROM user_news_api_keys - WHERE user_id = $1 + DELETE FROM users_keys + WHERE user_id = $1 AND service = $2 `, - [userId], + [userId, NEWSAPI_SERVICE], ); return { hasNewsApiKey: false, last4: null, updatedAt: null }; } export async function getDecryptedNewsApiKeyForUser(userId: number): Promise { - const { rows } = await pool.query( + const { rows } = await pool.query( ` SELECT encrypted_key, iv, auth_tag - FROM user_news_api_keys - WHERE user_id = $1 + FROM users_keys + WHERE user_id = $1 AND service = $2 LIMIT 1 `, - [userId], + [userId, NEWSAPI_SERVICE], ); const row = rows[0]; - return row ? decryptNewsApiKey(row) : null; -} + return row ? decryptUserKey(row) : null; +} \ No newline at end of file