From 0bae4f82aa2369d34ab571dc1982400b8c693363 Mon Sep 17 00:00:00 2001 From: Jack Merrill Date: Fri, 6 Oct 2023 21:12:12 -0400 Subject: [PATCH] feat: add custom domain setting --- components/Form.tsx | 13 +- lib/domains.ts | 209 ++++++++++++++++++ pages/[slug]/settings.tsx | 85 ++++++- .../api/organizer/hackathons/[slug]/update.ts | 17 +- 4 files changed, 316 insertions(+), 8 deletions(-) create mode 100644 lib/domains.ts diff --git a/components/Form.tsx b/components/Form.tsx index 6f3d21d..a609ce5 100644 --- a/components/Form.tsx +++ b/components/Form.tsx @@ -1,5 +1,5 @@ import { AutoComplete, Button, Input, Select, Text } from "@geist-ui/core"; -import React, { CSSProperties, useState } from "react"; +import React, { CSSProperties, ReactNode, useState } from "react"; function Required() { return ( @@ -101,7 +101,7 @@ interface BaseFormElement { interface FormInput extends BaseFormElement { validate?: (value: string) => boolean; - description?: string; + description?: JSX.Element | (() => JSX.Element) | ReactNode | string; required?: boolean; name: string; } @@ -347,6 +347,14 @@ export const Form = React.forwardRef( {element.required && } )} + + {element.description && ( + + {typeof element.description == "function" + ? element.description() + : element.description} + + )} ); } else if (formElement.type == "tuple") { @@ -425,6 +433,7 @@ export const Form = React.forwardRef( options={options} crossOrigin mb={1} + aria-label={element.label} width="100%" onChange={(v) => updateValue(element.name, v)} onSearch={searchHandler} diff --git a/lib/domains.ts b/lib/domains.ts new file mode 100644 index 0000000..c0b95a8 --- /dev/null +++ b/lib/domains.ts @@ -0,0 +1,209 @@ +// From https://github.com/vercel/platforms/blob/main/lib/domains.ts +export type DomainVerificationStatusProps = + | "Valid Configuration" + | "Invalid Configuration" + | "Pending Verification" + | "Domain Not Found" + | "Unknown Error"; + +// From https://vercel.com/docs/rest-api/endpoints#get-a-project-domain +export interface DomainResponse { + name: string; + apexName: string; + projectId: string; + redirect?: string | null; + redirectStatusCode?: (307 | 301 | 302 | 308) | null; + gitBranch?: string | null; + updatedAt?: number; + createdAt?: number; + /** `true` if the domain is verified for use with the project. If `false` it will not be used as an alias on this project until the challenge in `verification` is completed. */ + verified: boolean; + /** A list of verification challenges, one of which must be completed to verify the domain for use on the project. After the challenge is complete `POST /projects/:idOrName/domains/:domain/verify` to verify the domain. Possible challenges: - If `verification.type = TXT` the `verification.domain` will be checked for a TXT record matching `verification.value`. */ + verification: { + type: string; + domain: string; + value: string; + reason: string; + }[]; +} + +// From https://vercel.com/docs/rest-api/endpoints#get-a-domain-s-configuration +export interface DomainConfigResponse { + /** How we see the domain's configuration. - `CNAME`: Domain has a CNAME pointing to Vercel. - `A`: Domain's A record is resolving to Vercel. - `http`: Domain is resolving to Vercel but may be behind a Proxy. - `null`: Domain is not resolving to Vercel. */ + configuredBy?: ("CNAME" | "A" | "http") | null; + /** Which challenge types the domain can use for issuing certs. */ + acceptedChallenges?: ("dns-01" | "http-01")[]; + /** Whether or not the domain is configured AND we can automatically generate a TLS certificate. */ + misconfigured: boolean; +} + +// From https://vercel.com/docs/rest-api/endpoints#verify-project-domain +export interface DomainVerificationResponse { + name: string; + apexName: string; + projectId: string; + redirect?: string | null; + redirectStatusCode?: (307 | 301 | 302 | 308) | null; + gitBranch?: string | null; + updatedAt?: number; + createdAt?: number; + /** `true` if the domain is verified for use with the project. If `false` it will not be used as an alias on this project until the challenge in `verification` is completed. */ + verified: boolean; + /** A list of verification challenges, one of which must be completed to verify the domain for use on the project. After the challenge is complete `POST /projects/:idOrName/domains/:domain/verify` to verify the domain. Possible challenges: - If `verification.type = TXT` the `verification.domain` will be checked for a TXT record matching `verification.value`. */ + verification?: { + type: string; + domain: string; + value: string; + reason: string; + }[]; +} + +export const addDomainToVercel = async ( + domain: string +): Promise => { + return await fetch( + `https://api.vercel.com/v10/projects/${ + process.env.PROJECT_ID_VERCEL + }/domains${ + process.env.TEAM_ID_VERCEL + ? `?teamId=${process.env.TEAM_ID_VERCEL}` + : "" + }`, + { + method: "POST", + headers: { + Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}`, + "Content-Type": "application/json" + }, + body: JSON.stringify({ + name: domain + // Optional: Redirect www. to root domain + // ...(domain.startsWith("www.") && { + // redirect: domain.replace("www.", ""), + // }), + }) + } + ).then((res) => res.json()); +}; + +export const removeDomainFromVercelProject = async (domain: string) => { + return await fetch( + `https://api.vercel.com/v9/projects/${ + process.env.PROJECT_ID_VERCEL + }/domains/${domain}${ + process.env.TEAM_ID_VERCEL + ? `?teamId=${process.env.TEAM_ID_VERCEL}` + : "" + }`, + { + headers: { + Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}` + }, + method: "DELETE" + } + ).then((res) => res.json()); +}; + +export const removeDomainFromVercelTeam = async (domain: string) => { + return await fetch( + `https://api.vercel.com/v6/domains/${domain}${ + process.env.TEAM_ID_VERCEL + ? `?teamId=${process.env.TEAM_ID_VERCEL}` + : "" + }`, + { + headers: { + Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}` + }, + method: "DELETE" + } + ).then((res) => res.json()); +}; + +export const getDomainResponse = async ( + domain: string +): Promise => { + return await fetch( + `https://api.vercel.com/v9/projects/${ + process.env.PROJECT_ID_VERCEL + }/domains/${domain}${ + process.env.TEAM_ID_VERCEL + ? `?teamId=${process.env.TEAM_ID_VERCEL}` + : "" + }`, + { + method: "GET", + headers: { + Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}`, + "Content-Type": "application/json" + } + } + ).then((res) => { + return res.json(); + }); +}; + +export const getConfigResponse = async ( + domain: string +): Promise => { + return await fetch( + `https://api.vercel.com/v6/domains/${domain}/config${ + process.env.TEAM_ID_VERCEL + ? `?teamId=${process.env.TEAM_ID_VERCEL}` + : "" + }`, + { + method: "GET", + headers: { + Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}`, + "Content-Type": "application/json" + } + } + ).then((res) => res.json()); +}; + +export const verifyDomain = async ( + domain: string +): Promise => { + return await fetch( + `https://api.vercel.com/v9/projects/${ + process.env.PROJECT_ID_VERCEL + }/domains/${domain}/verify${ + process.env.TEAM_ID_VERCEL + ? `?teamId=${process.env.TEAM_ID_VERCEL}` + : "" + }`, + { + method: "POST", + headers: { + Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}`, + "Content-Type": "application/json" + } + } + ).then((res) => res.json()); +}; + +export const getSubdomain = (name: string, apexName: string) => { + if (name === apexName) return null; + return name.slice(0, name.length - apexName.length - 1); +}; + +export const getApexDomain = (url: string) => { + let domain; + try { + domain = new URL(url).hostname; + } catch (e) { + return ""; + } + const parts = domain.split("."); + if (parts.length > 2) { + // if it's a subdomain (e.g. dub.vercel.app), return the last 2 parts + return parts.slice(-2).join("."); + } + // if it's a normal domain (e.g. dub.sh), we return the domain + return domain; +}; + +export const validDomainRegex = new RegExp( + /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/ +); diff --git a/pages/[slug]/settings.tsx b/pages/[slug]/settings.tsx index 4930420..deba86f 100644 --- a/pages/[slug]/settings.tsx +++ b/pages/[slug]/settings.tsx @@ -1,19 +1,33 @@ import prisma from "@/lib/prisma"; import { getAuth } from "@clerk/nextjs/server"; -import { Card, Page, Text } from "@geist-ui/core"; +import { + Card, + Code, + Dot, + Modal, + Page, + Tag, + Text, + useModal +} from "@geist-ui/core"; import type { GetServerSideProps } from "next"; import { Form } from "@/components/Form"; import HackathonLayout from "@/components/layouts/organizer/OrganizerLayout"; +import { DomainResponse, getDomainResponse } from "@/lib/domains"; import { delay } from "@/lib/utils"; import type { Hackathon } from "@prisma/client"; import { useRouter } from "next/router"; import type { ReactElement } from "react"; +type HackathonWithDomainResponse = Hackathon & { + domainResponse?: DomainResponse; +}; + export default function Hackathon({ hackathon }: { - hackathon: Hackathon | null; + hackathon: HackathonWithDomainResponse | null; }): any { const router = useRouter(); @@ -105,6 +119,55 @@ export default function Hackathon({ label: "Venue & Location", name: "location", defaultValue: hackathon.location + }, + { + type: "text", + label: "Custom Domain", + name: "customDomain", + defaultValue: + hackathon.customDomain ?? `${hackathon.slug}.hackathon.zip`, + inlineLabel: "https://", + validate(value) { + // allow only apex domains or subdomains, no paths or protocols + const regex = + /^((?!-)[A-Za-z0-9-]{1,63}(? { + const { visible, setVisible, bindings } = useModal(); + return ( + <> + {hackathon.domainResponse?.verified ? ( + + Verified + + ) : ( + <> + setVisible(true)} + > + Unverified + + + Verify Domain + + {hackathon.domainResponse} + + setVisible(false)} + > + Cancel + + Check + + + )} + + ); + } } ], submitText: "Save" @@ -148,7 +211,7 @@ export const getServerSideProps = (async (context) => { console.log({ userId }); if (context.params?.slug) { - const hackathon = await prisma.hackathon.findUnique({ + const h = await prisma.hackathon.findUnique({ where: { slug: context.params?.slug.toString(), OR: [ @@ -163,9 +226,21 @@ export const getServerSideProps = (async (context) => { ] } }); + + if (!h) return { props: { hackathon: null } }; + + if (h.customDomain) { + const domainResponse = await getDomainResponse(h.customDomain); + + const hackathon: HackathonWithDomainResponse = { + ...h, + domainResponse + }; + } + return { props: { - hackathon + hackathon: h } }; } else { @@ -176,5 +251,5 @@ export const getServerSideProps = (async (context) => { }; } }) satisfies GetServerSideProps<{ - hackathon: Hackathon | null; + hackathon: HackathonWithDomainResponse | null; }>; diff --git a/pages/api/organizer/hackathons/[slug]/update.ts b/pages/api/organizer/hackathons/[slug]/update.ts index 432cc67..e0187c7 100644 --- a/pages/api/organizer/hackathons/[slug]/update.ts +++ b/pages/api/organizer/hackathons/[slug]/update.ts @@ -1,3 +1,7 @@ +import { + addDomainToVercel, + removeDomainFromVercelProject +} from "@/lib/domains"; import prisma from "@/lib/prisma"; import { permitParams } from "@/lib/utils"; import { getAuth } from "@clerk/nextjs/server"; @@ -68,7 +72,18 @@ export default async function handler( } }); - console.log({ hackathon }); + if (!newData.customDomain && hackathon.customDomain) { + await removeDomainFromVercelProject(hackathon.customDomain); + } else if (newData.customDomain && !hackathon.customDomain) { + await addDomainToVercel(newData.customDomain); + } else if ( + newData.customDomain && + hackathon.customDomain && + newData.customDomain !== hackathon.customDomain + ) { + await removeDomainFromVercelProject(hackathon.customDomain); + await addDomainToVercel(newData.customDomain); + } res.redirect(`/${hackathon.slug}`); } catch (error) {