a tool for shared writing and social publishing
1"use server";
2import { TID } from "@atproto/common";
3import { AtpBaseClient, PubLeafletPublication } from "lexicons/api";
4import {
5 restoreOAuthSession,
6 OAuthSessionError,
7} from "src/atproto-oauth";
8import { getIdentityData } from "actions/getIdentityData";
9import { supabaseServerClient } from "supabase/serverClient";
10import { Un$Typed } from "@atproto/api";
11import { Json } from "supabase/database.types";
12import { Vercel } from "@vercel/sdk";
13import { isProductionDomain } from "src/utils/isProductionDeployment";
14import { string } from "zod";
15
16const VERCEL_TOKEN = process.env.VERCEL_TOKEN;
17const vercel = new Vercel({
18 bearerToken: VERCEL_TOKEN,
19});
20let subdomainValidator = string()
21 .min(3)
22 .max(63)
23 .regex(/^[a-z0-9-]+$/);
24type CreatePublicationResult =
25 | { success: true; publication: any }
26 | { success: false; error?: OAuthSessionError };
27
28export async function createPublication({
29 name,
30 description,
31 iconFile,
32 subdomain,
33 preferences,
34}: {
35 name: string;
36 description: string;
37 iconFile: File | null;
38 subdomain: string;
39 preferences: Omit<PubLeafletPublication.Preferences, "$type">;
40}): Promise<CreatePublicationResult> {
41 let isSubdomainValid = subdomainValidator.safeParse(subdomain);
42 if (!isSubdomainValid.success) {
43 return { success: false };
44 }
45 let identity = await getIdentityData();
46 if (!identity || !identity.atp_did) {
47 return {
48 success: false,
49 error: {
50 type: "oauth_session_expired",
51 message: "Not authenticated",
52 did: "",
53 },
54 };
55 }
56
57 let domain = `${subdomain}.leaflet.pub`;
58
59 const sessionResult = await restoreOAuthSession(identity.atp_did);
60 if (!sessionResult.ok) {
61 return { success: false, error: sessionResult.error };
62 }
63 let credentialSession = sessionResult.value;
64 let agent = new AtpBaseClient(
65 credentialSession.fetchHandler.bind(credentialSession),
66 );
67 let record: Un$Typed<PubLeafletPublication.Record> = {
68 name,
69 base_path: domain,
70 preferences,
71 };
72
73 if (description) {
74 record.description = description;
75 }
76
77 // Upload the icon if provided
78 if (iconFile && iconFile.size > 0) {
79 const buffer = await iconFile.arrayBuffer();
80 const uploadResult = await agent.com.atproto.repo.uploadBlob(
81 new Uint8Array(buffer),
82 { encoding: iconFile.type },
83 );
84
85 if (uploadResult.data.blob) {
86 record.icon = uploadResult.data.blob;
87 }
88 }
89
90 let result = await agent.pub.leaflet.publication.create(
91 { repo: credentialSession.did!, rkey: TID.nextStr(), validate: false },
92 record,
93 );
94
95 //optimistically write to our db!
96 let { data: publication } = await supabaseServerClient
97 .from("publications")
98 .upsert({
99 uri: result.uri,
100 identity_did: credentialSession.did!,
101 name: record.name,
102 record: {
103 ...record,
104 $type: "pub.leaflet.publication",
105 } as unknown as Json,
106 })
107 .select()
108 .single();
109
110 // Create the custom domain
111 if (isProductionDomain()) {
112 await vercel.projects.addProjectDomain({
113 idOrName: "prj_9jX4tmYCISnm176frFxk07fF74kG",
114 teamId: "team_42xaJiZMTw9Sr7i0DcLTae9d",
115 requestBody: {
116 name: domain,
117 },
118 });
119 }
120 await supabaseServerClient
121 .from("custom_domains")
122 .insert({ domain, confirmed: true, identity: null });
123
124 await supabaseServerClient
125 .from("publication_domains")
126 .insert({ domain, publication: result.uri, identity: identity.atp_did });
127
128 return { success: true, publication };
129}