a tool for shared writing and social publishing
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}