a tool for shared writing and social publishing
at main 176 lines 5.2 kB view raw
1"use server"; 2import { TID } from "@atproto/common"; 3import { 4 AtpBaseClient, 5 PubLeafletPublication, 6 SiteStandardPublication, 7} from "lexicons/api"; 8import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; 9import { getIdentityData } from "actions/getIdentityData"; 10import { supabaseServerClient } from "supabase/serverClient"; 11import { Json } from "supabase/database.types"; 12import { Vercel } from "@vercel/sdk"; 13import { isProductionDomain } from "src/utils/isProductionDeployment"; 14import { string } from "zod"; 15import { getPublicationType } from "src/utils/collectionHelpers"; 16import { PubThemeDefaultsRGB } from "components/ThemeManager/themeDefaults"; 17 18const VERCEL_TOKEN = process.env.VERCEL_TOKEN; 19const vercel = new Vercel({ 20 bearerToken: VERCEL_TOKEN, 21}); 22let subdomainValidator = string() 23 .min(3) 24 .max(63) 25 .regex(/^[a-z0-9-]+$/); 26type CreatePublicationResult = 27 | { success: true; publication: any } 28 | { success: false; error?: OAuthSessionError }; 29 30export async function createPublication({ 31 name, 32 description, 33 iconFile, 34 subdomain, 35 preferences, 36}: { 37 name: string; 38 description: string; 39 iconFile: File | null; 40 subdomain: string; 41 preferences: Omit<PubLeafletPublication.Preferences, "$type">; 42}): Promise<CreatePublicationResult> { 43 let isSubdomainValid = subdomainValidator.safeParse(subdomain); 44 if (!isSubdomainValid.success) { 45 return { success: false }; 46 } 47 let identity = await getIdentityData(); 48 if (!identity || !identity.atp_did) { 49 return { 50 success: false, 51 error: { 52 type: "oauth_session_expired", 53 message: "Not authenticated", 54 did: "", 55 }, 56 }; 57 } 58 59 let domain = `${subdomain}.leaflet.pub`; 60 61 const sessionResult = await restoreOAuthSession(identity.atp_did); 62 if (!sessionResult.ok) { 63 return { success: false, error: sessionResult.error }; 64 } 65 let credentialSession = sessionResult.value; 66 let agent = new AtpBaseClient( 67 credentialSession.fetchHandler.bind(credentialSession), 68 ); 69 70 // Use site.standard.publication for new publications 71 const publicationType = getPublicationType(); 72 const url = `https://${domain}`; 73 74 // Build record based on publication type 75 let record: SiteStandardPublication.Record | PubLeafletPublication.Record; 76 let iconBlob: 77 | Awaited< 78 ReturnType<typeof agent.com.atproto.repo.uploadBlob> 79 >["data"]["blob"] 80 | undefined; 81 82 // Upload the icon if provided 83 if (iconFile && iconFile.size > 0) { 84 const buffer = await iconFile.arrayBuffer(); 85 const uploadResult = await agent.com.atproto.repo.uploadBlob( 86 new Uint8Array(buffer), 87 { encoding: iconFile.type }, 88 ); 89 iconBlob = uploadResult.data.blob; 90 } 91 92 if (publicationType === "site.standard.publication") { 93 record = { 94 $type: "site.standard.publication", 95 name, 96 url, 97 ...(description && { description }), 98 ...(iconBlob && { icon: iconBlob }), 99 basicTheme: { 100 $type: "site.standard.theme.basic", 101 background: { 102 $type: "site.standard.theme.color#rgb", 103 ...PubThemeDefaultsRGB.background, 104 }, 105 foreground: { 106 $type: "site.standard.theme.color#rgb", 107 ...PubThemeDefaultsRGB.foreground, 108 }, 109 accent: { 110 $type: "site.standard.theme.color#rgb", 111 ...PubThemeDefaultsRGB.accent, 112 }, 113 accentForeground: { 114 $type: "site.standard.theme.color#rgb", 115 ...PubThemeDefaultsRGB.accentForeground, 116 }, 117 }, 118 preferences: { 119 showInDiscover: preferences.showInDiscover, 120 showComments: preferences.showComments, 121 showMentions: preferences.showMentions, 122 showPrevNext: preferences.showPrevNext, 123 showRecommends: preferences.showRecommends, 124 }, 125 } satisfies SiteStandardPublication.Record; 126 } else { 127 record = { 128 $type: "pub.leaflet.publication", 129 name, 130 base_path: domain, 131 ...(description && { description }), 132 ...(iconBlob && { icon: iconBlob }), 133 preferences, 134 } satisfies PubLeafletPublication.Record; 135 } 136 137 let { data: result } = await agent.com.atproto.repo.putRecord({ 138 repo: credentialSession.did!, 139 rkey: TID.nextStr(), 140 collection: publicationType, 141 record, 142 validate: false, 143 }); 144 145 //optimistically write to our db! 146 let { data: publication } = await supabaseServerClient 147 .from("publications") 148 .upsert({ 149 uri: result.uri, 150 identity_did: credentialSession.did!, 151 name, 152 record: record as unknown as Json, 153 }) 154 .select() 155 .single(); 156 157 // Create the custom domain 158 if (isProductionDomain()) { 159 await vercel.projects.addProjectDomain({ 160 idOrName: "prj_9jX4tmYCISnm176frFxk07fF74kG", 161 teamId: "team_42xaJiZMTw9Sr7i0DcLTae9d", 162 requestBody: { 163 name: domain, 164 }, 165 }); 166 } 167 await supabaseServerClient 168 .from("custom_domains") 169 .insert({ domain, confirmed: true, identity: null }); 170 171 await supabaseServerClient 172 .from("publication_domains") 173 .insert({ domain, publication: result.uri, identity: identity.atp_did }); 174 175 return { success: true, publication }; 176}