Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 11 additions & 2 deletions app/(protected)/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -48,7 +50,14 @@ export default async function DashboardPage({
<div className="stack">
<section className="card stack">
<h1>ШОПОНОВОСТЯМ</h1>
<SearchRequestForm />
{newsApiKeyStatus.hasNewsApiKey ? (
<SearchRequestForm />
) : (
<div className="alert">
Чтобы создавать запросы на новости, сначала добавь NewsAPI key в{" "}
<Link href="/profile">профиле</Link>.
</div>
)}
</section>

<section className="card stack">
Expand Down
1 change: 1 addition & 0 deletions app/(protected)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export default async function ProtectedLayout({
<nav className="nav-links">
<Link href="/dashboard">Найти по слову</Link>
<Link href="/analytics">ААНАЛИТИКА</Link>
<Link href="/profile">Профиль</Link>
</nav>
<div className="user-block">
<div>
Expand Down
57 changes: 57 additions & 0 deletions app/(protected)/profile/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="stack">
<section className="card stack">
<div className="section-header">
<div>
<h1>Профиль</h1>
<p className="muted">Данные аккаунта и NewsAPI ключ</p>
</div>
</div>

<div className="profile-grid">
<div className="profile-row">
<span className="muted">Имя</span>
<strong>{user.name ?? "Не указано"}</strong>
</div>

<div className="profile-row">
<span className="muted">Почта</span>
<strong>{user.email}</strong>
</div>

<div className="profile-row">
<span className="muted">NewsAPI ключик</span>
<strong>
{newsApiKeyStatus.hasNewsApiKey
? `Сохранён, заканчивается на ${newsApiKeyStatus.last4}`
: "Не добавлен"}
</strong>
</div>
</div>
</section>

<section className="card stack">
<h2>Сохранить NewsAPI ключик</h2>
<p className="muted">
Твой ключ как парень 8 летней девочки. Тоже не тут и тоже под замком
</p>

<NewsApiKeyForm initialStatus={newsApiKeyStatus} />
</section>
</div>
);
}
88 changes: 88 additions & 0 deletions app/api/profile/news-api-key/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) : {};
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);
}
}
11 changes: 10 additions & 1 deletion app/api/searches/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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);
Expand Down
157 changes: 157 additions & 0 deletions components/profile/news-api-key-form.tsx
Original file line number Diff line number Diff line change
@@ -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<T> = {
ok: true;
data: T;
requestId: string;
};

type ApiError = {
ok: false;
error: {
code: string;
message: string;
details?: unknown;
};
requestId: string;
};

type ApiResponse<T> = ApiSuccess<T> | 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<HTMLFormElement>) {
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<NewsApiKeyStatus> | 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<NewsApiKeyStatus> | 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 (
<form className="stack" onSubmit={onSubmit}>
{status.hasNewsApiKey ? (
<p className="success-message">
Сейчас ключ сохранён. Последние 4 символа: {status.last4}
</p>
) : (
<p className="alert">
Ключ ещё не добавлен. Без него нельзя создавать запросы на новости.
</p>
)}

<div className="field">
<label htmlFor="news-api-key-input">NewsAPI key</label>
<input
autoComplete="off"
id="news-api-key-input"
maxLength={256}
onChange={(event) => setApiKey(event.target.value)}
placeholder="Вставь ключ NewsAPI"
type="password"
value={apiKey}
/>
</div>

<div className="filter-actions">
<button className="button button-primary" disabled={isSubmitting} type="submit">
{isSubmitting ? "Сохраняю..." : "Сохранить ключ"}
</button>

{status.hasNewsApiKey ? (
<button
className="button button-secondary"
disabled={isDeleting}
onClick={onDelete}
type="button"
>
{isDeleting ? "Удаляю..." : "Удалить ключ"}
</button>
) : null}
</div>

{error ? <p className="alert">{error}</p> : null}
{success ? <p className="success-message">{success}</p> : null}
</form>
);
}
Loading
Loading