Something went wrong. Try again.
leaflet fork for copy edit / bug fixes forked from leaflet.pub/leaflet
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261"use server";
import { sql } from "drizzle-orm";import { v7 } from "uuid";import { generateKeyBetween } from "fractional-indexing";import { getAuthIdentity } from "src/auth";import { supabaseServerClient } from "supabase/serverClient";import { Ok, Err, type Result } from "src/result";import { isAdminEmail } from "src/adminAllowlist";import { restoreOAuthSession } from "src/atproto-oauth";import { appendToLeaflet, insertLeaflet } from "src/utils/insertLeaflet";import { publishLeaflet } from "src/utils/publishLeaflet";import { publishPublicationPages, routeShape,} from "src/utils/publishPublicationPages";import { readNavEntries } from "src/utils/publicationNavEntries";import { createPublicationDraftLeaflet } from "actions/createPublicationDraftLeaflet";import { normalizePublicationRecord } from "src/utils/normalizeRecords";import { resolvePublicationTheme } from "lexicons/src/normalize";import type { Fact } from "src/replicache";import type { Attribute } from "src/replicache/attributes";import type { GhostPost } from "src/ghostImport/parseGhostExport";import { ghostPostToLeaflet, ghostPostToPage, previewImage, type GhostLeaflet,} from "src/ghostImport/ghostPostToLeaflet";import { uploadRemoteImage } from "src/ghostImport/uploadRemoteImage";
// Errors are returned as messages rather than thrown: Next redacts thrown// server-action errors in production, and the admin needs the real reason.async function asAdmin<T>(fn: () => Promise<T>): Promise<Result<T, string>> { let identity = await getAuthIdentity(); if (!identity || !isAdminEmail(identity.email)) return Err("You're not allowed to do that."); try { return Ok(await fn()); } catch (e) { console.error("[admin/import-ghost]", e); return Err(e instanceof Error ? e.message : String(e)); }}
export type GhostPostPreview = GhostLeaflet & { // The draft exactly as importGhostPost would write it, with images left at // their Ghost URLs; the client renders it with the editor's block components. facts: Fact<Attribute>[];};
export async function previewGhostImport(args: { post: GhostPost; siteUrl: string;}): Promise<Result<GhostPostPreview, string>> { return asAdmin(async () => { let leaflet = await ghostPostToLeaflet( args.post, args.siteUrl, previewImage, ); return { ...leaflet, facts: leaflet.facts.map((f) => ({ id: v7(), ...f }) as Fact<Attribute>), }; });}
export type GhostImportMode = "draft" | "publish";
export type GhostImportResult = | { kind: "post"; leafletId: string; rkey: string | null } | { kind: "page"; route: string };
// Record keys must be valid AT Protocol rkeys; Ghost slugs almost always are.const RKEY_RE = /^[a-zA-Z0-9._:~-]{1,512}$/;
// Import one Ghost post as a draft in the publication (and, in publish mode,// publish it as the owner under its Ghost slug; a post that fails to publish// is left as a draft), or one Ghost page as a page in the publication's nav.// Pages land in the publication's draft leaflet whatever the mode; publish// mode publishes them all at once afterwards via publishGhostPages.export async function importGhostPost(args: { post: GhostPost; publicationUri: string; siteUrl: string; mode: GhostImportMode; showInDiscover: boolean;}): Promise<Result<GhostImportResult, string>> { return asAdmin(async () => { let { data: pub } = await supabaseServerClient .from("publications") .select("uri, identity_did, record, draft_leaflet") .eq("uri", args.publicationUri) .maybeSingle() .throwOnError(); if (!pub) throw new Error("Publication not found"); let rkey = args.post.slug;
if (args.mode === "publish") { if (!RKEY_RE.test(rkey) || rkey === "." || rkey === "..") throw new Error(`Slug "${rkey}" is not a valid record key`); // Publishing writes to the owner's PDS with their stored session; check // it before creating a draft that couldn't be published. let session = await restoreOAuthSession(pub.identity_did); if (!session.ok) throw new Error(session.error.message); } // Published pages take precedence over posts at the same /slug, so a // clash either way would hide one of them. let { data: existing } = await supabaseServerClient .from("documents") .select("uri") .in("uri", [ `at://${pub.identity_did}/site.standard.document/${rkey}`, `at://${pub.identity_did}/pub.leaflet.document/${rkey}`, ]) .throwOnError(); if (existing && existing.length > 0) throw new Error(`A post with slug "${rkey}" is already published`);
// Fetch every image before touching the database, so a draft never // references an upload that hasn't happened. let cache = new Map< string, Promise<Awaited<ReturnType<typeof uploadRemoteImage>>> >(); let resolveImage = (img: { url: string }) => { let p = cache.get(img.url) ?? uploadRemoteImage(img.url); cache.set(img.url, p); return p; };
if (args.post.type === "page") { let route = `/${rkey}`; if (!routeShape.test(route)) throw new Error( `Slug "${rkey}" can't be a page path (lowercase letters, digits, and dashes only)`, ); let pubRecord = normalizePublicationRecord(pub.record); let draftLeaflet = pub.draft_leaflet ?? (await createPublicationDraftLeaflet({ publication_uri: pub.uri, did: pub.identity_did, description: pubRecord?.description, theme: resolvePublicationTheme(pubRecord), })); let [{ data: token }, { data: rights }] = await Promise.all([ supabaseServerClient .from("permission_tokens") .select("root_entity") .eq("id", draftLeaflet) .single() .throwOnError(), supabaseServerClient .from("permission_token_rights") .select("entity_set") .eq("token", draftLeaflet) .eq("write", true) .throwOnError(), ]); let entitySet = rights?.[0]?.entity_set; if (!token || !entitySet) throw new Error("Draft leaflet has no writable set"); let rootEntity = token.root_entity; let { data: factData } = await supabaseServerClient .rpc("get_facts", { root: rootEntity }) .throwOnError(); let nav = readNavEntries( (factData as unknown as Fact<Attribute>[]) ?? [], rootEntity, ); let clash = nav.find((e) => e.route === route); if (clash) throw new Error( `The publication already has a page at ${route} ("${clash.title}")`, );
let page = await ghostPostToPage(args.post, args.siteUrl, resolveImage); await appendToLeaflet({ entitySetId: entitySet, entityIds: page.entities, facts: [ { entity: rootEntity, attribute: "root/page", data: { type: "ordered-reference", value: page.pageId, position: generateKeyBetween(nav.at(-1)?.position ?? null, null), }, }, ...page.facts, ], }); return { kind: "page", route }; }
let leaflet = await ghostPostToLeaflet( args.post, args.siteUrl, resolveImage, ); let { permTokenId } = await insertLeaflet({ rootEntityId: leaflet.rootEntityId, entityIds: leaflet.entities, facts: leaflet.facts, tailCte: ({ permTokenId }) => sql`, link AS ( INSERT INTO leaflets_in_publications (publication, leaflet, doc, title, description, tags) VALUES (${args.publicationUri}, ${permTokenId}, NULL, ${leaflet.title}, ${leaflet.description}, ARRAY(SELECT jsonb_array_elements_text(${JSON.stringify(leaflet.tags)}::jsonb))) )`, }); if (args.mode !== "publish") return { kind: "post", leafletId: permTokenId, rkey: null };
let published = await publishLeaflet({ actorDid: pub.identity_did, root_entity: leaflet.rootEntityId, publication_uri: args.publicationUri, leaflet_id: permTokenId, title: leaflet.title, description: leaflet.description, tags: leaflet.tags, publishedAt: leaflet.publishedAt, // Imported posts are back-catalogue: never email subscribers about them. sendEmail: false, showInDiscover: args.showInDiscover, rkey, }); if (!published.success) throw new Error( `Draft ${permTokenId} created but publishing failed: ${published.error.message}`, ); return { kind: "post", leafletId: permTokenId, rkey: published.rkey }; });}
// Publish the publication's draft pages as the owner. This publishes the// whole draft — imported pages along with any unpublished edits the owner// had pending.export async function publishGhostPages(args: { publicationUri: string;}): Promise<Result<{ published: number }, string>> { return asAdmin(async () => { let { data: pub } = await supabaseServerClient .from("publications") .select("identity_did") .eq("uri", args.publicationUri) .maybeSingle() .throwOnError(); if (!pub) throw new Error("Publication not found"); let result = await publishPublicationPages({ publication_uri: args.publicationUri, actorDid: pub.identity_did, }); if (!result.success) throw new Error(result.error.message); return { published: result.published.length }; });}