feat: Introduce a web frontend with new city API routes and refactor bot city query logic into a dedicated service.
This commit is contained in:
@@ -1 +1,6 @@
|
||||
POLYWEATHER_API_BASE_URL=http://127.0.0.1:8000
|
||||
# Optional dashboard guard (Next.js middleware)
|
||||
# If set, open dashboard with: /?access_token=<token>
|
||||
POLYWEATHER_DASHBOARD_ACCESS_TOKEN=
|
||||
# Shared secret forwarded by Next API routes to backend
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -12,7 +13,7 @@ export async function GET() {
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/cities`, {
|
||||
headers: { Accept: "application/json" },
|
||||
headers: buildBackendRequestHeaders(),
|
||||
next: { revalidate: 120 },
|
||||
});
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -26,7 +27,7 @@ export async function GET(
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { Accept: "application/json" },
|
||||
headers: buildBackendRequestHeaders(),
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -19,7 +20,7 @@ export async function GET(
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { Accept: "application/json" },
|
||||
headers: buildBackendRequestHeaders(),
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -19,7 +20,7 @@ export async function GET(
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { Accept: "application/json" },
|
||||
headers: buildBackendRequestHeaders(),
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { buildBackendRequestHeaders } from "@/lib/backend-auth";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -18,7 +19,7 @@ export async function GET(
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { Accept: "application/json" },
|
||||
headers: buildBackendRequestHeaders(),
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
type Props = {
|
||||
searchParams?: Promise<{ next?: string }>;
|
||||
};
|
||||
|
||||
export default async function EntitlementRequiredPage({ searchParams }: Props) {
|
||||
const params = (await searchParams) || {};
|
||||
const nextPath = params.next || "/";
|
||||
|
||||
return (
|
||||
<main
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
background:
|
||||
"radial-gradient(circle at 20% 20%, #13264f 0%, #071127 45%, #040812 100%)",
|
||||
color: "#d6e2ff",
|
||||
padding: "24px",
|
||||
}}
|
||||
>
|
||||
<section
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 720,
|
||||
border: "1px solid rgba(68, 92, 140, 0.45)",
|
||||
borderRadius: 16,
|
||||
padding: 24,
|
||||
background: "rgba(9, 18, 36, 0.88)",
|
||||
boxShadow: "0 20px 50px rgba(0, 0, 0, 0.35)",
|
||||
}}
|
||||
>
|
||||
<h1 style={{ margin: 0, fontSize: 28, lineHeight: 1.2 }}>
|
||||
Entitlement Required
|
||||
</h1>
|
||||
<p style={{ marginTop: 12, color: "#9fb2da", lineHeight: 1.6 }}>
|
||||
This dashboard is protected. Append{" "}
|
||||
<code>?access_token=<your-token></code> to the URL once, and
|
||||
the session cookie will be set automatically.
|
||||
</p>
|
||||
<p style={{ marginTop: 12, color: "#9fb2da", lineHeight: 1.6 }}>
|
||||
Requested path: <code>{nextPath}</code>
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export const BACKEND_ENTITLEMENT_HEADER = "x-polyweather-entitlement";
|
||||
|
||||
export function buildBackendRequestHeaders(): HeadersInit {
|
||||
const headers: HeadersInit = {
|
||||
Accept: "application/json",
|
||||
};
|
||||
|
||||
const token = process.env.POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN?.trim();
|
||||
if (token) {
|
||||
headers[BACKEND_ENTITLEMENT_HEADER] = token;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const SESSION_COOKIE = "polyweather_entitlement";
|
||||
|
||||
function isStaticAsset(pathname: string) {
|
||||
return (
|
||||
pathname.startsWith("/_next/") ||
|
||||
pathname.startsWith("/favicon") ||
|
||||
pathname.startsWith("/robots.txt") ||
|
||||
pathname.startsWith("/sitemap.xml") ||
|
||||
pathname.startsWith("/icons/") ||
|
||||
pathname.startsWith("/images/") ||
|
||||
pathname.startsWith("/static/")
|
||||
);
|
||||
}
|
||||
|
||||
function isPublicPage(pathname: string) {
|
||||
return pathname === "/entitlement-required";
|
||||
}
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const requiredToken = process.env.POLYWEATHER_DASHBOARD_ACCESS_TOKEN?.trim();
|
||||
if (!requiredToken) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const { pathname, searchParams } = request.nextUrl;
|
||||
if (isStaticAsset(pathname) || isPublicPage(pathname)) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const cookieToken = request.cookies.get(SESSION_COOKIE)?.value;
|
||||
if (cookieToken && cookieToken === requiredToken) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const queryToken = searchParams.get("access_token");
|
||||
if (queryToken && queryToken === requiredToken) {
|
||||
const cleanUrl = request.nextUrl.clone();
|
||||
cleanUrl.searchParams.delete("access_token");
|
||||
|
||||
const response = NextResponse.redirect(cleanUrl);
|
||||
response.cookies.set(SESSION_COOKIE, requiredToken, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: cleanUrl.protocol === "https:",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 12,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/api/")) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized", detail: "Entitlement token required" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const deniedUrl = request.nextUrl.clone();
|
||||
deniedUrl.pathname = "/entitlement-required";
|
||||
deniedUrl.search = "";
|
||||
deniedUrl.searchParams.set("next", pathname);
|
||||
return NextResponse.redirect(deniedUrl);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image).*)"],
|
||||
};
|
||||
Reference in New Issue
Block a user