UNPKG

studiocms

Version:

Astro Native CMS for AstroDB. Built from the ground up by the Astro community.

402 lines (326 loc) 12.2 kB
import { apiResponseLogger } from 'studiocms:logger'; import { Notifications } from 'studiocms:notifier'; import plugins from 'studiocms:plugins'; import { apiEndpoints } from 'studiocms:plugins/endpoints'; import { SDKCore } from 'studiocms:sdk'; import type { CombinedInsertContent, tsPageContentSelect, tsPageDataSelect, } from 'studiocms:sdk/types'; import type { APIRoute } from 'astro'; import { AllResponse, createEffectAPIRoutes, createJsonResponse, Effect, genLogger, OptionsResponse, readAPIContextFormData, readAPIContextJson, } from '../../../../effect.js'; type ApiEndpoints = { onCreate?: APIRoute | null; onEdit?: APIRoute | null; onDelete?: APIRoute | null; }; type PageTypeOutput = { identifier: string; label: string; description?: string | undefined; pageContentComponent?: string | undefined; apiEndpoint?: string; apiEndpoints?: ApiEndpoints | undefined; }; type UpdatePageData = Partial<tsPageDataSelect>; type UpdatePageContent = Partial<tsPageContentSelect>; const pageTypeOptions = plugins.flatMap(({ pageTypes }) => { const pageTypeOutput: PageTypeOutput[] = []; if (!pageTypes) return pageTypeOutput; for (const pageType of pageTypes) { pageTypeOutput.push({ ...pageType, apiEndpoints: apiEndpoints.find((endpoint) => endpoint.identifier === pageType.identifier), }); } return pageTypeOutput; }); function getPageTypeEndpoints(pkg: string, type: 'onCreate' | 'onEdit' | 'onDelete') { const currentPageType = pageTypeOptions.find((pageType) => pageType.identifier === pkg); if (!currentPageType) return undefined; return currentPageType.apiEndpoints?.[type]; } function getParentFolderValue(value?: string) { if (value === 'null') return null; return value; } function isValidString(value: unknown): value is string { return typeof value === 'string' && value.trim() !== ''; } function validString(field: FormDataEntryValue | null): string | undefined { if (!isValidString(field)) { return undefined; } return field.toString(); } function validateStringField(field: FormDataEntryValue | null, fieldName: string): string { if (!isValidString(field)) { throw new Error(`Invalid form data, ${fieldName} is required`); } return field.toString(); } function validateSlugField(field: FormDataEntryValue | null, fieldName: string): string { const slug = validateStringField(field, fieldName); /** * Regex breakdown: * * ^[a-z0-9]+ : starts with one or more lowercase letters or numbers * * (?:-[a-z0-9]+)* : followed by zero or more groups of a hyphen and one * or more lowercase letters or numbers * * $ : end of the string * * This ensures the slug is lowercase and only contains letters, numbers, * and hyphens, without consecutive hyphens or leading/trailing hyphens. */ const slugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; if (!slugRegex.test(slug)) { throw new Error( `Invalid form data, ${fieldName} must be lowercase and can only contain letters, numbers, and hyphens` ); } return slug; } function buildPageDataObject( formData: FormData, pageId?: string ): UpdatePageData | { error: string } { try { // TODO Fix validation logic. Move over to JSON and Effect validation const data: UpdatePageData = { title: validateStringField(formData.get('page-title'), 'title'), slug: validateSlugField(formData.get('page-slug'), 'slug'), description: validateStringField(formData.get('page-description'), 'description'), package: validateStringField(formData.get('page-type'), 'page type'), showOnNav: validateStringField(formData.get('show-in-nav'), 'show in nav') === 'true', heroImage: validString(formData.get('page-hero-image')), parentFolder: getParentFolderValue(formData.get('parent-folder')?.toString()), showAuthor: validString(formData.get('show-author')) === 'true', showContributors: validString(formData.get('show-contributors')) === 'true', draft: validString(formData.get('draft')) === 'true', }; if (pageId) { data.id = pageId; } return data; } catch (error) { if (error instanceof Error) { return { error: error.message }; } return { error: 'An unknown error occurred while validating form data' }; } } export const { POST, PATCH, DELETE, OPTIONS, ALL } = createEffectAPIRoutes( { POST: (ctx) => genLogger('studiocms/routes/api/dashboard/content/page.POST')(function* () { const [sdk, notify] = yield* Effect.all([SDKCore, Notifications]); // Get user data const userData = ctx.locals.StudioCMS.security?.userSessionData; // Check if user is logged in if (!userData?.isLoggedIn) { return apiResponseLogger(403, 'Unauthorized'); } // Check if user has permission const isAuthorized = ctx.locals.StudioCMS.security?.userPermissionLevel.isEditor; if (!isAuthorized) { return apiResponseLogger(403, 'Unauthorized'); } const formData = yield* readAPIContextFormData(ctx); const data = buildPageDataObject(formData); if ('error' in data) { return apiResponseLogger(400, data.error); } const content = { id: crypto.randomUUID(), // content is no longer supported during page creation due to current editor setup // look into options in the future for how we can do this correctly. // Requires being able to swap in editors which currently does not work. content: '', } as UpdatePageContent; if (!data.title) { return apiResponseLogger(400, 'Invalid form data, title is required'); } const dataId = crypto.randomUUID(); // biome-ignore lint/style/noNonNullAssertion: this is a valid use case for non-null assertion const apiRoute = getPageTypeEndpoints(data.package!, 'onCreate'); const pageContent: CombinedInsertContent = { contentLang: 'default', content: content.content || '', }; yield* sdk.POST.page({ pageData: { id: dataId, // biome-ignore lint/style/noNonNullAssertion: this is a valid use case for non-null assertion title: data.title!, slug: data.slug || data.title.toLowerCase().replace(/\s/g, '-'), description: data.description || '', authorId: userData.user?.id || null, updatedAt: new Date(), categories: [], tags: [], ...data, }, pageContent: pageContent, }); if (apiRoute) { yield* Effect.tryPromise(() => Promise.resolve().then(() => apiRoute(ctx))); } yield* Effect.all([ sdk.CLEAR.pages(), notify.sendEditorNotification('new_page', data.title), ]); return apiResponseLogger(200, 'Page created successfully'); }).pipe(Notifications.Provide), PATCH: (ctx) => genLogger('studiocms/routes/api/dashboard/content/page.PATCH')(function* () { const [sdk, notify] = yield* Effect.all([SDKCore, Notifications]); // Get user data const userData = ctx.locals.StudioCMS.security?.userSessionData; // Check if user is logged in if (!userData?.isLoggedIn) { return apiResponseLogger(403, 'Unauthorized'); } // Check if user has permission const isAuthorized = ctx.locals.StudioCMS.security?.userPermissionLevel.isEditor; if (!isAuthorized) { return apiResponseLogger(403, 'Unauthorized'); } const formData = yield* readAPIContextFormData(ctx); const data = buildPageDataObject(formData, validString(formData.get('page-id'))); if ('error' in data) { return apiResponseLogger(400, data.error); } const content = { id: validString(formData.get('page-content-id')), content: validString(formData.get('page-content')), }; if (!data.id) { return apiResponseLogger(400, 'Invalid form data, id is required'); } if (!content.id) { return apiResponseLogger(400, 'Invalid form data, contentId is required'); } const currentPageData = yield* sdk.GET.page.byId(data.id); if (!currentPageData) { return apiResponseLogger(404, 'Page not found'); } const { authorId, contributorIds, defaultContent } = currentPageData.data; let AuthorId = authorId; if (!authorId) { AuthorId = userData.user?.id || null; } const ContributorIds = contributorIds || []; // biome-ignore lint/style/noNonNullAssertion: this is a valid use case for non-null assertion if (!ContributorIds.includes(userData.user!.id)) { // biome-ignore lint/style/noNonNullAssertion: this is a valid use case for non-null assertion ContributorIds.push(userData.user!.id); } data.authorId = AuthorId; data.contributorIds = ContributorIds; data.updatedAt = new Date(); const startMetaData = (yield* sdk.GET.databaseTable.pageData()).find( (metaData) => metaData.id === data.id ); // biome-ignore lint/style/noNonNullAssertion: this is a valid use case for non-null assertion const apiRoute = getPageTypeEndpoints(data.package!, 'onEdit'); yield* sdk.UPDATE.page.byId(data.id, { pageData: data as tsPageDataSelect, pageContent: content as tsPageContentSelect, }); const updatedMetaData = (yield* sdk.GET.databaseTable.pageData()).find( (metaData) => metaData.id === data.id ); const { enableDiffs, diffPerPage = 10 } = ctx.locals.StudioCMS.siteConfig.data; if (enableDiffs) { yield* sdk.diffTracking.insert( // biome-ignore lint/style/noNonNullAssertion: this is a valid use case for non-null assertion userData.user!.id, data.id, { content: { start: defaultContent?.content || '', end: content.content || '', }, // biome-ignore lint/style/noNonNullAssertion: this is a valid use case for non-null assertion metaData: { start: startMetaData!, end: updatedMetaData! }, }, diffPerPage ); } if (apiRoute) { yield* Effect.tryPromise(() => Promise.resolve().then(() => apiRoute(ctx))); } yield* Effect.all([ sdk.CLEAR.pages(), notify.sendEditorNotification('page_updated', data.title || startMetaData?.title || ''), ]); return apiResponseLogger(200, 'Page updated successfully'); }).pipe(Notifications.Provide), DELETE: (ctx) => genLogger('studiocms/routes/api/dashboard/content/page.DELETE')(function* () { const [sdk, notify] = yield* Effect.all([SDKCore, Notifications]); // Get user data const userData = ctx.locals.StudioCMS.security?.userSessionData; // Check if user is logged in if (!userData?.isLoggedIn) { return apiResponseLogger(403, 'Unauthorized'); } // Check if user has permission const isAuthorized = ctx.locals.StudioCMS.security?.userPermissionLevel.isAdmin; if (!isAuthorized) { return apiResponseLogger(403, 'Unauthorized'); } const { id, slug } = yield* readAPIContextJson<{ id: string; slug?: string }>(ctx); try { validateStringField(id, 'id'); validateSlugField(slug ?? null, 'slug'); } catch (err) { return apiResponseLogger(400, (err as Error).message); } const pageToDelete = yield* sdk.GET.page.byId(id); if (!pageToDelete) { return apiResponseLogger(404, 'Page not found'); } if (pageToDelete.data.slug !== slug) { return apiResponseLogger(400, 'Invalid request'); } const apiRoute = getPageTypeEndpoints(pageToDelete.data.package, 'onDelete'); yield* sdk.DELETE.page(id); if (apiRoute) { yield* Effect.tryPromise(() => Promise.resolve().then(() => apiRoute(ctx))); } yield* Effect.all([ sdk.CLEAR.pages(), notify.sendEditorNotification('page_deleted', pageToDelete.data.title), ]); return apiResponseLogger(200, 'Page deleted successfully'); }).pipe(Notifications.Provide), OPTIONS: () => Effect.try(() => OptionsResponse({ allowedMethods: ['POST', 'PATCH', 'DELETE'] })), ALL: () => Effect.try(() => AllResponse()), }, { cors: { methods: ['POST', 'PATCH', 'DELETE', 'OPTIONS'] }, onError: (error) => { console.error('API Error:', error); return createJsonResponse( { error: 'Internal Server Error' }, { status: 500, } ); }, } );