diff --git a/actions/domains/addDomain.ts b/actions/domains/addDomain.ts deleted file mode 100644 index 9f17e85c..00000000 --- a/actions/domains/addDomain.ts +++ /dev/null @@ -1,98 +0,0 @@ -"use server"; -import { Vercel } from "@vercel/sdk"; -import { cookies } from "next/headers"; - -import { Database } from "supabase/database.types"; -import { createServerClient } from "@supabase/ssr"; -import { getIdentityData } from "actions/getIdentityData"; - -const VERCEL_TOKEN = process.env.VERCEL_TOKEN; -const vercel = new Vercel({ - bearerToken: VERCEL_TOKEN, -}); - -let supabase = createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, - process.env.SUPABASE_SERVICE_ROLE_KEY as string, - { cookies: {} }, -); - -export async function addDomain(domain: string) { - let identity = await getIdentityData(); - if (!identity || (!identity.email && !identity.atp_did)) return {}; - if ( - domain.includes("leaflet.pub") && - (!identity.email || - ![ - "celine@hyperlink.academy", - "brendan@hyperlink.academy", - "jared@hyperlink.academy", - "brendan.schlagel@gmail.com", - ].includes(identity.email)) - ) - return {}; - return await createDomain(domain, identity.email, identity.id); -} - -export async function addPublicationDomain( - domain: string, - publication_uri: string, -) { - let identity = await getIdentityData(); - if (!identity || !identity.atp_did) return {}; - let { data: publication } = await supabase - .from("publications") - .select("*") - .eq("uri", publication_uri) - .single(); - - if (publication?.identity_did !== identity.atp_did) return {}; - let { error } = await createDomain(domain, null, identity.id); - if (error) return { error }; - await supabase.from("publication_domains").insert({ - publication: publication_uri, - identity: identity.atp_did, - domain, - }); - return {}; -} - -async function createDomain( - domain: string, - email: string | null, - identity_id: string, -) { - try { - await vercel.projects.addProjectDomain({ - idOrName: "prj_9jX4tmYCISnm176frFxk07fF74kG", - teamId: "team_42xaJiZMTw9Sr7i0DcLTae9d", - requestBody: { - name: domain, - }, - }); - } catch (e) { - console.log(e); - let error: "unknown-error" | "invalid_domain" | "domain_already_in_use" = - "unknown-error"; - if ((e as any).rawValue) { - error = - (e as { rawValue?: { error?: { code?: "invalid_domain" } } })?.rawValue - ?.error?.code || "unknown-error"; - } - if ((e as any).body) { - try { - error = JSON.parse((e as any).body)?.error?.code || "unknown-error"; - } catch (e) {} - } - - return { error }; - } - - await supabase.from("custom_domains").insert({ - domain, - identity: email, - confirmed: false, - identity_id, - }); - return {}; -} diff --git a/actions/domains/addDomainPath.ts b/actions/domains/addDomainPath.ts deleted file mode 100644 index df580fc4..00000000 --- a/actions/domains/addDomainPath.ts +++ /dev/null @@ -1,39 +0,0 @@ -"use server"; -import { cookies } from "next/headers"; -import { Database } from "supabase/database.types"; -import { createServerClient } from "@supabase/ssr"; -import { getIdentityData } from "actions/getIdentityData"; - -let supabase = createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, - process.env.SUPABASE_SERVICE_ROLE_KEY as string, - { cookies: {} }, -); -export async function addDomainPath({ - domain, - view_permission_token, - edit_permission_token, - route, -}: { - domain: string; - view_permission_token: string; - edit_permission_token: string; - route: string; -}) { - let auth_data = await getIdentityData(); - if (!auth_data || !auth_data.custom_domains.find((d) => d.domain === domain)) - return null; - - await supabase - .from("custom_domain_routes") - .delete() - .eq("edit_permission_token", edit_permission_token); - - await supabase.from("custom_domain_routes").insert({ - domain, - route, - view_permission_token, - edit_permission_token, - }); - return true; -} diff --git a/actions/domains/assignDomainToDocument.ts b/actions/domains/assignDomainToDocument.ts deleted file mode 100644 index 6eaa1d10..00000000 --- a/actions/domains/assignDomainToDocument.ts +++ /dev/null @@ -1,43 +0,0 @@ -"use server"; -import { Database } from "supabase/database.types"; -import { createServerClient } from "@supabase/ssr"; -import { getIdentityData } from "actions/getIdentityData"; - -let supabase = createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, - process.env.SUPABASE_SERVICE_ROLE_KEY as string, - { cookies: {} }, -); - -export async function assignDomainToDocument({ - domain, - route, - view_permission_token, - edit_permission_token, -}: { - domain: string; - route: string; - view_permission_token: string; - edit_permission_token: string; -}) { - let identity = await getIdentityData(); - if (!identity || !identity.custom_domains.find((d) => d.domain === domain)) - return null; - - await Promise.all([ - supabase.from("publication_domains").delete().eq("domain", domain), - supabase - .from("custom_domain_routes") - .delete() - .eq("edit_permission_token", edit_permission_token), - ]); - - await supabase.from("custom_domain_routes").insert({ - domain, - route, - view_permission_token, - edit_permission_token, - }); - - return true; -} diff --git a/actions/domains/assignDomainToPublication.ts b/actions/domains/assignDomainToPublication.ts deleted file mode 100644 index 9f35e46c..00000000 --- a/actions/domains/assignDomainToPublication.ts +++ /dev/null @@ -1,42 +0,0 @@ -"use server"; -import { Database } from "supabase/database.types"; -import { createServerClient } from "@supabase/ssr"; -import { getIdentityData } from "actions/getIdentityData"; - -let supabase = createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, - process.env.SUPABASE_SERVICE_ROLE_KEY as string, - { cookies: {} }, -); - -export async function assignDomainToPublication({ - domain, - publication_uri, -}: { - domain: string; - publication_uri: string; -}) { - let identity = await getIdentityData(); - if (!identity || !identity.atp_did) return null; - if (!identity.custom_domains.find((d) => d.domain === domain)) return null; - - let { data: publication } = await supabase - .from("publications") - .select("*") - .eq("uri", publication_uri) - .single(); - if (publication?.identity_did !== identity.atp_did) return null; - - await Promise.all([ - supabase.from("custom_domain_routes").delete().eq("domain", domain), - supabase.from("publication_domains").delete().eq("domain", domain), - ]); - - await supabase.from("publication_domains").insert({ - publication: publication_uri, - identity: identity.atp_did, - domain, - }); - - return true; -} diff --git a/actions/domains/deleteDomain.ts b/actions/domains/deleteDomain.ts deleted file mode 100644 index ab7b914f..00000000 --- a/actions/domains/deleteDomain.ts +++ /dev/null @@ -1,36 +0,0 @@ -"use server"; -import { Database } from "supabase/database.types"; -import { createServerClient } from "@supabase/ssr"; -import { Vercel } from "@vercel/sdk"; -import { getIdentityData } from "actions/getIdentityData"; - -let supabase = createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, - process.env.SUPABASE_SERVICE_ROLE_KEY as string, - { cookies: {} }, -); - -const VERCEL_TOKEN = process.env.VERCEL_TOKEN; -const vercel = new Vercel({ - bearerToken: VERCEL_TOKEN, -}); -export async function deleteDomain({ domain }: { domain: string }) { - let identity = await getIdentityData(); - if (!identity || !identity.custom_domains.find((d) => d.domain === domain)) - return null; - - await Promise.all([ - supabase.from("custom_domain_routes").delete().eq("domain", domain), - supabase.from("publication_domains").delete().eq("domain", domain), - ]); - await Promise.all([ - supabase.from("custom_domains").delete().eq("domain", domain), - vercel.projects.removeProjectDomain({ - idOrName: "prj_9jX4tmYCISnm176frFxk07fF74kG", - teamId: "team_42xaJiZMTw9Sr7i0DcLTae9d", - domain, - }), - ]); - - return true; -} diff --git a/actions/domains/index.ts b/actions/domains/index.ts new file mode 100644 index 00000000..4c529182 --- /dev/null +++ b/actions/domains/index.ts @@ -0,0 +1,210 @@ +"use server"; +import { Database } from "supabase/database.types"; +import { createServerClient } from "@supabase/ssr"; +import { Vercel } from "@vercel/sdk"; +import { getIdentityData } from "actions/getIdentityData"; + +let supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, + process.env.SUPABASE_SERVICE_ROLE_KEY as string, + { cookies: {} }, +); + +const vercel = new Vercel({ + bearerToken: process.env.VERCEL_TOKEN, +}); + +const VERCEL_PROJECT = "prj_9jX4tmYCISnm176frFxk07fF74kG"; +const VERCEL_TEAM = "team_42xaJiZMTw9Sr7i0DcLTae9d"; + +// Shared helpers +// ============== + +async function assertOwnsDomain(domain: string) { + let identity = await getIdentityData(); + if (!identity || !identity.custom_domains.find((d) => d.domain === domain)) + return null; + return identity; +} + +// Clear all assignments (routes + publication links) for a domain, +// without deleting the domain itself. +async function clearAllAssignments(domain: string) { + await Promise.all([ + supabase.from("custom_domain_routes").delete().eq("domain", domain), + supabase.from("publication_domains").delete().eq("domain", domain), + ]); +} + +// Adding domains +// ============== + +export async function addDomain(domain: string) { + let identity = await getIdentityData(); + if (!identity || (!identity.email && !identity.atp_did)) return {}; + if ( + domain.includes("leaflet.pub") && + (!identity.email || + ![ + "celine@hyperlink.academy", + "brendan@hyperlink.academy", + "jared@hyperlink.academy", + "brendan.schlagel@gmail.com", + ].includes(identity.email)) + ) + return {}; + return await createDomain(domain, identity.email, identity.id); +} + +async function createDomain( + domain: string, + email: string | null, + identity_id: string, +) { + try { + await vercel.projects.addProjectDomain({ + idOrName: VERCEL_PROJECT, + teamId: VERCEL_TEAM, + requestBody: { name: domain }, + }); + } catch (e) { + console.log(e); + let error: "unknown-error" | "invalid_domain" | "domain_already_in_use" = + "unknown-error"; + if ((e as any).rawValue) { + error = + (e as { rawValue?: { error?: { code?: "invalid_domain" } } })?.rawValue + ?.error?.code || "unknown-error"; + } + if ((e as any).body) { + try { + error = JSON.parse((e as any).body)?.error?.code || "unknown-error"; + } catch (e) {} + } + return { error }; + } + + await supabase.from("custom_domains").insert({ + domain, + identity: email, + confirmed: false, + identity_id, + }); + return {}; +} + +// Assigning domains +// ================= + +// Point a domain at a leaflet document. Clears any existing assignment first, +// since a domain can only point to one thing at a time. +export async function assignDomainToDocument({ + domain, + route, + view_permission_token, + edit_permission_token, +}: { + domain: string; + route: string; + view_permission_token: string; + edit_permission_token: string; +}) { + if (!(await assertOwnsDomain(domain))) return null; + + await Promise.all([ + supabase.from("publication_domains").delete().eq("domain", domain), + supabase + .from("custom_domain_routes") + .delete() + .eq("edit_permission_token", edit_permission_token), + ]); + + await supabase.from("custom_domain_routes").insert({ + domain, + route, + view_permission_token, + edit_permission_token, + }); + + return true; +} + +// Point a domain at a publication. Clears any existing assignment first. +export async function assignDomainToPublication({ + domain, + publication_uri, +}: { + domain: string; + publication_uri: string; +}) { + let identity = await getIdentityData(); + if (!identity || !identity.atp_did) return null; + if (!identity.custom_domains.find((d) => d.domain === domain)) return null; + + let { data: publication } = await supabase + .from("publications") + .select("*") + .eq("uri", publication_uri) + .single(); + if (publication?.identity_did !== identity.atp_did) return null; + + await clearAllAssignments(domain); + + await supabase.from("publication_domains").insert({ + publication: publication_uri, + identity: identity.atp_did, + domain, + }); + + return true; +} + +// Removing assignments +// ==================== + +// Remove all assignments from a domain (routes + publication links), +// but keep the domain itself registered. +export async function removeDomainAssignment({ + domain, +}: { + domain: string; +}) { + if (!(await assertOwnsDomain(domain))) return null; + await clearAllAssignments(domain); + return true; +} + +// Remove a single route assignment by ID. +export async function removeDomainRoute({ routeId }: { routeId: string }) { + let identity = await getIdentityData(); + if (!identity) return null; + + let allRoutes = identity.custom_domains.flatMap( + (d) => d.custom_domain_routes, + ); + if (!allRoutes.find((r) => r.id === routeId)) return null; + + await supabase.from("custom_domain_routes").delete().eq("id", routeId); + + return true; +} + +// Deleting domains +// ================ + +// Fully delete a domain: clear all assignments, remove from DB, and remove from Vercel. +export async function deleteDomain({ domain }: { domain: string }) { + if (!(await assertOwnsDomain(domain))) return null; + + await clearAllAssignments(domain); + await Promise.all([ + supabase.from("custom_domains").delete().eq("domain", domain), + vercel.projects.removeProjectDomain({ + idOrName: VERCEL_PROJECT, + teamId: VERCEL_TEAM, + domain, + }), + ]); + + return true; +} diff --git a/actions/domains/removeDomainAssignment.ts b/actions/domains/removeDomainAssignment.ts deleted file mode 100644 index f35970d7..00000000 --- a/actions/domains/removeDomainAssignment.ts +++ /dev/null @@ -1,27 +0,0 @@ -"use server"; -import { Database } from "supabase/database.types"; -import { createServerClient } from "@supabase/ssr"; -import { getIdentityData } from "actions/getIdentityData"; - -let supabase = createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, - process.env.SUPABASE_SERVICE_ROLE_KEY as string, - { cookies: {} }, -); - -export async function removeDomainAssignment({ - domain, -}: { - domain: string; -}) { - let identity = await getIdentityData(); - if (!identity || !identity.custom_domains.find((d) => d.domain === domain)) - return null; - - await Promise.all([ - supabase.from("custom_domain_routes").delete().eq("domain", domain), - supabase.from("publication_domains").delete().eq("domain", domain), - ]); - - return true; -} diff --git a/actions/domains/removeDomainRoute.ts b/actions/domains/removeDomainRoute.ts deleted file mode 100644 index 6a40a5fb..00000000 --- a/actions/domains/removeDomainRoute.ts +++ /dev/null @@ -1,25 +0,0 @@ -"use server"; -import { Database } from "supabase/database.types"; -import { createServerClient } from "@supabase/ssr"; -import { getIdentityData } from "actions/getIdentityData"; - -let supabase = createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_API_URL as string, - process.env.SUPABASE_SERVICE_ROLE_KEY as string, - { cookies: {} }, -); - -export async function removeDomainRoute({ routeId }: { routeId: string }) { - let identity = await getIdentityData(); - if (!identity) return null; - - // Verify the route belongs to one of the user's domains - let allRoutes = identity.custom_domains.flatMap( - (d) => d.custom_domain_routes, - ); - if (!allRoutes.find((r) => r.id === routeId)) return null; - - await supabase.from("custom_domain_routes").delete().eq("id", routeId); - - return true; -} diff --git a/app/[leaflet_id]/actions/ShareOptions/DomainOptions.tsx b/app/[leaflet_id]/actions/ShareOptions/DomainOptions.tsx index 253ee319..0800a7a7 100644 --- a/app/[leaflet_id]/actions/ShareOptions/DomainOptions.tsx +++ b/app/[leaflet_id]/actions/ShareOptions/DomainOptions.tsx @@ -10,8 +10,10 @@ import { useDomainStatus } from "components/Domains/useDomainStatus"; import { CustomDomain } from "components/Domains/DomainList"; import { useLeafletDomains } from "components/PageSWRDataProvider"; import { useReadOnlyShareLink } from "."; -import { assignDomainToDocument } from "actions/domains/assignDomainToDocument"; -import { removeDomainRoute } from "actions/domains/removeDomainRoute"; +import { + assignDomainToDocument, + removeDomainRoute, +} from "actions/domains"; import { useReplicache } from "src/replicache"; import { AddDomainForm } from "components/Domains/AddDomainForm"; import { DomainSettingsView } from "components/Domains/DomainSettingsView"; diff --git a/components/Domains/AddDomainForm.tsx b/components/Domains/AddDomainForm.tsx index 1662b7c0..87703a93 100644 --- a/components/Domains/AddDomainForm.tsx +++ b/components/Domains/AddDomainForm.tsx @@ -4,7 +4,7 @@ import { ButtonPrimary } from "components/Buttons"; import { Input } from "components/Input"; import { useSmoker } from "components/Toast"; import { useIdentityData } from "components/IdentityProvider"; -import { addDomain } from "actions/domains/addDomain"; +import { addDomain } from "actions/domains"; import { DotLoader } from "components/utils/DotLoader"; import { GoToArrow } from "components/Icons/GoToArrow"; diff --git a/components/Domains/DomainSettingsView.tsx b/components/Domains/DomainSettingsView.tsx index fb209078..79619d5a 100644 --- a/components/Domains/DomainSettingsView.tsx +++ b/components/Domains/DomainSettingsView.tsx @@ -2,9 +2,11 @@ import { useState } from "react"; import { useDomainStatus } from "./useDomainStatus"; import { DotLoader } from "components/utils/DotLoader"; -import { deleteDomain } from "actions/domains/deleteDomain"; -import { removeDomainAssignment } from "actions/domains/removeDomainAssignment"; -import { removeDomainRoute } from "actions/domains/removeDomainRoute"; +import { + deleteDomain, + removeDomainAssignment, + removeDomainRoute, +} from "actions/domains"; import { useIdentityData, mutateIdentityData, diff --git a/components/Domains/PublicationDomains.tsx b/components/Domains/PublicationDomains.tsx index 22a94eff..802d45d5 100644 --- a/components/Domains/PublicationDomains.tsx +++ b/components/Domains/PublicationDomains.tsx @@ -13,8 +13,10 @@ import { useNormalizedPublicationRecord, } from "app/lish/[did]/[publication]/dashboard/PublicationSWRProvider"; import { updatePublicationBasePath } from "app/lish/createPub/updatePublication"; -import { assignDomainToPublication } from "actions/domains/assignDomainToPublication"; -import { removeDomainAssignment } from "actions/domains/removeDomainAssignment"; +import { + assignDomainToPublication, + removeDomainAssignment, +} from "actions/domains"; import { PubSettingsHeader } from "app/lish/[did]/[publication]/dashboard/settings/PublicationSettings"; import { AddDomainForm } from "./AddDomainForm"; import { DomainSettingsView } from "./DomainSettingsView";