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