diff --git a/.env.example b/.env.example index 89e81fa..2b4738d 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,8 @@ MINEGEO_ALLOWED_ORIGINS= # Telegram: https://t.me/ (private group invite links also work). MINEGEO_DISCORD_URL= MINEGEO_TELEGRAM_URL= +# Optional HTTPS override for the hosted community forum linked in the header/footer. +MINEGEO_FORUM_URL= # Optional server-only Discord bot integration. All published parties use one channel. # Create a bot at https://discord.com/developers/applications and invite it to your server. # Channel IDs are copied with Discord Developer Mode enabled. Never use NEXT_PUBLIC_ here. diff --git a/app/api/dev/keys/route.ts b/app/api/dev/keys/route.ts new file mode 100644 index 0000000..8130f3a --- /dev/null +++ b/app/api/dev/keys/route.ts @@ -0,0 +1,8 @@ +import { developerKeys } from "@/lib/developer-key-management"; +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const GET = developerKeys; +export const POST = developerKeys; +export const DELETE = developerKeys; +export const PUT = developerKeys; +export const PATCH = developerKeys; diff --git a/app/api/dev/openapi.json/route.ts b/app/api/dev/openapi.json/route.ts new file mode 100644 index 0000000..cb96659 --- /dev/null +++ b/app/api/dev/openapi.json/route.ts @@ -0,0 +1,10 @@ +import { developerOpenApi } from "@/shared/developer-openapi"; +export const dynamic = "force-static"; +export function GET() { + return Response.json(developerOpenApi, { + headers: { + "Cache-Control": "public, max-age=3600", + "X-Content-Type-Options": "nosniff", + }, + }); +} diff --git a/app/api/dev/route.ts b/app/api/dev/route.ts new file mode 100644 index 0000000..020c242 --- /dev/null +++ b/app/api/dev/route.ts @@ -0,0 +1,3 @@ +export function GET(request: Request) { + return Response.redirect(new URL("/dev/api", request.url), 307); +} diff --git a/app/api/dev/v1/[...path]/route.ts b/app/api/dev/v1/[...path]/route.ts new file mode 100644 index 0000000..16e7282 --- /dev/null +++ b/app/api/dev/v1/[...path]/route.ts @@ -0,0 +1,17 @@ +import { developerApi } from "@/lib/developer-api"; +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const maxDuration = 30; +async function handle( + request: Request, + { params }: { params: Promise<{ path: string[] }> }, +) { + return developerApi(request, (await params).path); +} +export { + handle as GET, + handle as POST, + handle as PATCH, + handle as PUT, + handle as DELETE, +}; diff --git a/app/api/metrics/route.ts b/app/api/metrics/route.ts new file mode 100644 index 0000000..cf6e327 --- /dev/null +++ b/app/api/metrics/route.ts @@ -0,0 +1,16 @@ +import { admin, required } from "@/lib/supabase"; +import { metricsResponse } from "@/lib/prometheus-response"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const maxDuration = 30; +export function GET(request: Request) { + return metricsResponse(request, async (tokenHash) => { + const result = await admin().rpc("read_prometheus_metrics", { + p_token_hash: tokenHash, + }); + // NULL is an invalid or disabled credential, not a missing database record. + if (result.error) required(result); + return result.data; + }); +} diff --git a/app/appearance.css b/app/appearance.css index ae83a9d..8bd0aec 100644 --- a/app/appearance.css +++ b/app/appearance.css @@ -384,7 +384,7 @@ html[data-theme="light"] .site-cursor-dot span { background: #273b21; box-shadow: 0 0 1px #fff; } -@media (max-width: 1000px) { +@media (max-width: 1200px) { .site-header { height: auto; } @@ -436,7 +436,7 @@ html[data-theme="light"] .site-cursor-dot span { font-size: 17px; } } -@media (max-width: 380px) { +@media (max-width: 600px) { .header-actions { grid-row: 2; grid-column: 1 / -1; diff --git a/app/dev/api/page.module.css b/app/dev/api/page.module.css new file mode 100644 index 0000000..4cefd0f --- /dev/null +++ b/app/dev/api/page.module.css @@ -0,0 +1,225 @@ +.intro { + max-width: 780px; +} +.tabs { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin: 24px 0 36px; +} +.stack { + display: grid; + gap: 32px; +} +.panel { + padding: clamp(20px, 4vw, 32px); + border: 1px solid var(--line); + border-radius: 18px; + background: var(--panel); + min-width: 0; +} +.panel h2 { + font-size: clamp(24px, 3vw, 32px); +} +.heading { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 18px; +} +.heading h2 { + margin: 0; +} +.fields { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(140px, 200px); + gap: 20px; +} +.fieldset { + border: 0; + padding: 0; + margin: 24px 0; + min-width: 0; +} +.fieldset legend { + font-weight: 700; + margin-bottom: 12px; +} +.scopes { + display: grid; + gap: 12px; +} +.scope { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 16px; + border: 1px solid var(--line); + border-radius: 10px; +} +.scope input { + margin-top: 5px; + width: 18px; + height: 18px; + flex: 0 0 18px; + accent-color: var(--green); +} +.scope strong, +.scope small { + display: block; +} +.scope small { + color: var(--muted); + line-height: 1.6; +} +.actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin: 20px 0 0; +} +.token { + margin-bottom: 24px; + padding: 20px; + border: 1px solid var(--green); + border-radius: 12px; + background: var(--panel2); +} +.token input { + width: 100%; + font-family: monospace; + font-size: 13px; +} +.keyList { + display: grid; + gap: 16px; + padding: 0; + list-style: none; + margin: 24px 0 0; +} +.key { + border: 1px solid var(--line); + border-radius: 12px; + padding: 20px; + min-width: 0; +} +.key h3 { + overflow-wrap: anywhere; + margin: 0; + font-size: 20px; +} +.key dl { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 6px 18px; + margin: 16px 0; +} +.key dt { + color: var(--muted); +} +.key dd { + margin: 0; + overflow-wrap: anywhere; +} +.key code { + overflow-wrap: anywhere; +} +.notice { + padding: 14px 18px; + border-left: 3px solid var(--green); + background: var(--panel2); + margin: 20px 0; +} +.error { + color: var(--text); + border-left-color: #ed7878; +} +.docs { + scroll-margin-top: 120px; +} +.docs h3 { + margin-top: 32px; +} +.docs p, +.docs li { + line-height: 1.8; +} +.docs ul, +.docs ol { + padding-left: 24px; +} +.docs ul { + list-style: disc; +} +.docs ol { + list-style: decimal; +} +.docs a { + color: var(--green); + text-decoration: underline; + text-underline-offset: 3px; +} +.docs code { + overflow-wrap: anywhere; + font-family: monospace; +} +.code { + overflow-x: auto; + padding: 20px; + border-radius: 10px; + border: 1px solid var(--line); + background: var(--bg); + font-family: monospace; + font-size: 13px; + line-height: 1.7; + max-width: 100%; +} +.code code { + overflow-wrap: normal; +} +.tableWrap { + overflow-x: auto; + max-width: 100%; +} +.table { + width: 100%; + border-collapse: collapse; + text-align: left; +} +.table th, +.table td { + padding: 14px 16px; + border-bottom: 1px solid var(--line); + vertical-align: top; +} +.table th { + color: var(--muted); +} +.table td:first-child { + min-width: 150px; +} +.table td:last-child { + min-width: 200px; +} +@media (max-width: 600px) { + .fields { + grid-template-columns: minmax(0, 1fr); + } + .key dl { + grid-template-columns: minmax(0, 1fr); + gap: 2px; + } + .key dd { + margin-bottom: 10px; + } + .code { + padding: 14px; + font-size: 12px; + } + .table th, + .table td { + padding: 12px; + } +} diff --git a/app/dev/api/page.tsx b/app/dev/api/page.tsx new file mode 100644 index 0000000..7346b58 --- /dev/null +++ b/app/dev/api/page.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from "next"; +import { Header, Footer } from "@/components/Shell"; +import { DeveloperPanel } from "@/components/DeveloperPanel"; +import { user } from "@/lib/supabase"; + +export const metadata: Metadata = { + title: "Developer API", + description: "Create and manage scoped MineGeo API keys.", +}; +export const dynamic = "force-dynamic"; + +export default async function DeveloperApiPage() { + const account = await user(); + return ( + <> +
+
+ +
+