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 (
+
+ );
+}
\ 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