studiocms
Version:
Astro Native CMS for AstroDB. Built from the ground up by the Astro community.
402 lines (326 loc) • 12.2 kB
text/typescript
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,
}
);
},
}
);