Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"runAllTestsOnStartup": false,
"type": "on-save"
},
"nxConsole.generateAiAgentRules": true,
"vitest.filesWatcherInclude": "apps/**,libs/**,packages/**",
"[json]": {
"editor.codeActionsOnSave": {
Expand Down
8 changes: 4 additions & 4 deletions apps/cms/.env.local
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,12 @@ SENDGRID_FROM_NAME=
# Ethereal email credentials (provide all details to enable)
# Create an account at https://ethereal.email/create
# Read your email at https://ethereal.email/messages
ETHEREAL_FROM_ADDRESS=
ETHEREAL_FROM_NAME=
ETHEREAL_FROM_ADDRESS=info@ethereal.email
ETHEREAL_FROM_NAME=Codeware Ethereal
ETHEREAL_HOST=smtp.ethereal.email
ETHEREAL_PORT=587
ETHEREAL_USERNAME=
ETHEREAL_PASSWORD=
ETHEREAL_USERNAME=nestor.jones@ethereal.email
ETHEREAL_PASSWORD=1TgEpNJqdeeKpuQFF7

# Set to true to prevent database sync and behave as if it's production.
# This is required when serving the app after running migrations locally.
Expand Down
8 changes: 7 additions & 1 deletion apps/cms/src/app/(payload)/admin/importMap.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { GlobalViewRedirect as GlobalViewRedirect_d6d5f193a167989e2ee7d14202901e
import { TenantSelectionProvider as TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62 } from '@payloadcms/plugin-multi-tenant/rsc';
import { OverviewComponent as OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client';
import { MetaTitleComponent as MetaTitleComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client';
import { MetaImageComponent as MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client';
import { MetaDescriptionComponent as MetaDescriptionComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client';
import { MetaImageComponent as MetaImageComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client';
import { PreviewComponent as PreviewComponent_a8a977ebc872c5d5ea7ee689724c0860 } from '@payloadcms/plugin-seo/client';
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
import { FixedToolbarFeatureClient as FixedToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client';
Expand Down Expand Up @@ -34,13 +34,15 @@ import { S3ClientUploadHandler as S3ClientUploadHandler_f97aa6c64367fa259c5bc056

import { default as default_1b21cdd8d72b60f58886e03c7a7a4ebd } from '@codeware/app-cms/ui/blocks/card/CardBlockArrayRowLabel.client';
import { default as default_75fddbc22d1b88f24f1cec1b82919953 } from '@codeware/app-cms/ui/blocks/social-media/SocialMediaBlockArrayRowLabel.client';
import { default as default_4cdb396fdb94ce9776953fed2fe6bc6a } from '@codeware/app-cms/ui/components/Callout';
import { default as default_83b0dfab156f3636ed94b94854d15ad5 } from '@codeware/app-cms/ui/components/RedirectNotifier';
import { default as default_7925a79d2af6389df70d2dd269ffbfbb } from '@codeware/app-cms/ui/components/VerifyTenantDomain';
import { default as default_06af4458abd1296f9d6bccce90425927 } from '@codeware/app-cms/ui/fields/code/Code.client';
import { default as default_8586e6ac5ddd6a3f87ffe1dd472673f5 } from '@codeware/app-cms/ui/fields/color-picker/ColorPickerField.client';
import { default as default_ae19db27eee762af26f037dd7af0b736 } from '@codeware/app-cms/ui/fields/icon-picker/IconPickerField.client';
import { default as default_42ab7a6f795fd44e8c166a2bb6b2adc0 } from '@codeware/apps/cms/components/Logo.client';
import { default as default_d497a38447405736d600359900364450 } from '@codeware/apps/cms/components/NavigationArrayRowLabel';
import { default as default_4a3552dc4f000a1797b4eb36166f8ff8 } from '@codeware/apps/cms/components/TenantsArrayField';
import { default as default_dec1059b7bb8eb8da3a9f0fc400fffbd } from '@codeware/apps/cms/components/TenantsArrayRowLabel';

export const importMap = {
Expand Down Expand Up @@ -88,6 +90,8 @@ export const importMap = {
BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@payloadcms/richtext-lexical/client#ItalicFeatureClient':
ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
'@codeware/app-cms/ui/components/Callout#default':
default_4cdb396fdb94ce9776953fed2fe6bc6a,
'@codeware/apps/cms/components/NavigationArrayRowLabel#default':
default_d497a38447405736d600359900364450,
'@payloadcms/plugin-seo/client#OverviewComponent':
Expand All @@ -114,6 +118,8 @@ export const importMap = {
default_06af4458abd1296f9d6bccce90425927,
'@codeware/app-cms/ui/blocks/social-media/SocialMediaBlockArrayRowLabel.client#default':
default_75fddbc22d1b88f24f1cec1b82919953,
'@codeware/apps/cms/components/TenantsArrayField#default':
default_4a3552dc4f000a1797b4eb36166f8ff8,
'@codeware/apps/cms/components/TenantsArrayRowLabel#default':
default_dec1059b7bb8eb8da3a9f0fc400fffbd,
'@codeware/apps/cms/components/Logo.client#default':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const categories: CollectionConfig = {
slug: 'categories',
admin: {
group: adminGroups.content,
defaultColumns: ['name', 'slug', 'tenant'],
defaultColumns: ['name', 'slug'],
useAsTitle: 'name'
},
access: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { Access, Where } from 'payload';

import { verifyApiKeyAccess } from '@codeware/app-cms/util/access';
import type { Media } from '@codeware/shared/util/payload-types';

/**
* This access control ensures unauthenticated static file request
* must have external property enabled.
*
* For all other requests, api key access is verified via `verifyApiKeyAccess`,
* which is required for all tenant enabled collections.
*
* @param secret - The secret used to verify the api key
*/
export const externalOrApiKeyAccess =
(secret: string): Access<Media> =>
async (args) => {
const { data, isReadingStaticFile, req } = args;
const { payload, user } = req;

// If the request is for a static file and no user is authenticated,
// lookup the document via filename and check if external is enabled.
if (isReadingStaticFile && !user) {
const filename = data?.filename;
if (!filename) {
payload.logger.error(
'externalOrApiKeyAccess: Expected a filename value in data'
);
return false;
}
const { config } = payload.collections.media;

// File name can be the main name or one of the image sizes
// e.g. filename: 'image.jpg', sizes: { small: { filename: 'image-small.jpg' } }
const filenamesQuery: Array<Where> = [];

// Main filename
filenamesQuery.push({
filename: {
equals: filename
}
});

// Image sizes filenames
if (config.upload.imageSizes) {
config.upload.imageSizes.forEach(({ name }) => {
filenamesQuery.push({
[`sizes.${name}.filename`]: {
equals: filename
}
});
});
}

const doc = await payload.find({
collection: 'media',
limit: 1,
depth: 0,
where: { or: filenamesQuery },
req
});

if (!doc.totalDocs) {
return false;
}

// Allow access if the file is marked external
return doc.docs[0].external === true;
}

// Default: Resolve api key access for tenant enabled collection
return verifyApiKeyAccess({ secret })(args);
};
117 changes: 88 additions & 29 deletions apps/cms/src/collections/media/media.collection.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,95 @@
import path from 'path';
import { fileURLToPath } from 'url';

import type { CollectionConfig, GenerateImageName } from 'payload';
import mimeTypes from 'mime-types';
import type {
CollectionBeforeValidateHook,
CollectionConfig,
Condition,
GenerateImageName,
TypeWithID
} from 'payload';

import { adminGroups } from '@codeware/app-cms/util/definitions';
import { getEnv } from '@codeware/app-cms/feature/env-loader';
import { tagsSelectField } from '@codeware/app-cms/ui/fields';
import { adminGroups, getMimeTypes } from '@codeware/app-cms/util/definitions';
import { Media } from '@codeware/shared/util/payload-types';

import { externalOrApiKeyAccess } from './access/external-or-api-key-access';

const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);
const env = getEnv();

/** Custom image name */
const imageName: GenerateImageName = ({ extension, originalName, sizeName }) =>
`${originalName}-${sizeName}.${extension}`;

const isImageOrVideo: Condition<TypeWithID, Media> = (_, siblingData) =>
!!siblingData.mimeType && siblingData.mimeType.match(/image|video/) !== null;

// Extracting mime type during seed has a flaky bewhavior.
// The mime type is not always available when the file is uploaded.
const ensureMimeType: CollectionBeforeValidateHook<Media> = ({
data,
operation
}) => {
if (!data) {
return data;
}
if (data.mimeType) {
return data;
}
if (operation === 'create' || operation === 'update') {
// Try to lookup the mime type from the filename
data.mimeType = mimeTypes.lookup(data.filename ?? '') || undefined;
}
return data;
};

/**
* Media images collection.
* Media collection for files with supported mime types.
*
* Uploaded images are converted to webp format.
*
* Upload limited to `image/*` mime types and images are converted to webp format.
* **Mime types**
*
* `@codeware/app-cms/util/definitions`
*/
const media: CollectionConfig = {
slug: 'media',
admin: {
group: adminGroups.fileArea,
defaultColumns: ['filename', 'alt', 'tenant', 'updatedAt'],
description: {
en: 'Media images to use in posts and pages.',
sv: 'Bilder som kan användas i inlägg och sidor.'
defaultColumns: ['filename', 'mimeType', 'fileSize', 'tags', 'createdAt'],
components: {
beforeListTable: [
{
path: '@codeware/app-cms/ui/components/Callout',
serverProps: {
kind: 'tip',
title: 'Using tags to organize media files',
description: [
'Use tags to organize your media files and easily select them in file areas.',
'Tags can be created in the "Tags" collection.',
'You can assign multiple tags to a media file.'
]
}
}
]
}
},
access: {
// Media files like images are not fetched, hence no api key to verify.
// For admin access, the plugin appends proper permission filters.
read: () => true
read: externalOrApiKeyAccess(env.SIGNATURE_SECRET)
},
hooks: {
beforeValidate: [ensureMimeType]
},
labels: {
singular: { en: 'Media', sv: 'Media' },
plural: { en: 'Media', sv: 'Media' }
},
upload: {
mimeTypes: ['image/*'],
mimeTypes: getMimeTypes(),
// Uploaded image is converted to a backward compatible format known by all browsers.
// This image should be used as the default image in a `<picture />` element.
formatOptions: { format: 'jpeg' },
Expand Down Expand Up @@ -102,28 +154,35 @@ const media: CollectionConfig = {
type: 'richText',
localized: true,
admin: {
condition: isImageOrVideo,
description: {
en: 'The caption for the media.',
sv: 'Bildtext för media.'
en: 'Caption to display below an image or video.',
sv: 'Text som visas under en bild eller video.'
}
}
},
tagsSelectField({
buildIndex: true,
overrides: {
// TODO: Would like to use 'drawer' but it doesn't work with bulk upload media.
// Better to be safe and wait for a fix.
admin: { appearance: 'select' }
}
}),
{
type: 'tabs',
tabs: [
{
label: { en: 'Posts', sv: 'Inlägg' },
fields: [
{
name: 'relatedPosts',
label: { en: 'Posts', sv: 'Inlägg' },
type: 'join',
collection: 'posts',
on: 'heroImage'
}
]
}
]
// Media files are not fetched, hence there's no api key to verify.
// This property will be used as alternative access control for static file requests.
name: 'external',
type: 'checkbox',
label: { en: 'External access', sv: 'Extern åtkomst' },
admin: {
description: {
en: 'Allow external access to the file without authentication. For example, this is required for file areas and document images.',
sv: 'Tillåt extern åtkomst till filen utan autentisering. Detta krävs till exempel för filytor och bilder i dokument.'
},
position: 'sidebar'
},
defaultValue: false
}
]
};
Expand Down
8 changes: 6 additions & 2 deletions apps/cms/src/collections/pages/pages.collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,16 @@ const env = getEnv();
const blocks: Record<BlockSlug, boolean> = {
content: true,
card: true,
'file-area': true,
form: true,
image: true,
media: true,
code: true,
'reusable-content': true,
'social-media': true,
spacing: true
spacing: true,
// Unsupported blocks
video: false
};

/**
Expand All @@ -33,7 +37,7 @@ const pages: CollectionConfig<'pages'> = {
slug: 'pages',
admin: {
group: adminGroups.content,
defaultColumns: ['name', 'slug', 'tenant', 'updatedAt'],
defaultColumns: ['name', 'slug', 'updatedAt'],
useAsTitle: 'name',
description: {
en: 'Pages are the building blocks of the site and are used to create menus and navigation.',
Expand Down
16 changes: 9 additions & 7 deletions apps/cms/src/collections/posts/posts.collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { BlocksFeature } from '@payloadcms/richtext-lexical';
import type { CollectionConfig } from 'payload';

import { getEnv } from '@codeware/app-cms/feature/env-loader';
import { slugField } from '@codeware/app-cms/ui/fields';
import { mediaUploadField, slugField } from '@codeware/app-cms/ui/fields';
import { multiTenantLinkFeature } from '@codeware/app-cms/ui/lexical';
import { seoTab } from '@codeware/app-cms/ui/tabs';
import { verifyApiKeyAccess } from '@codeware/app-cms/util/access';
Expand All @@ -24,12 +24,15 @@ const blocks: Record<BlockSlug, boolean> = {
card: true,
media: true,
code: true,
image: true,
'social-media': true,
spacing: true,
// Unsupported blocks
'file-area': false,
form: false,
content: false,
'reusable-content': false
'reusable-content': false,
video: false
};

/**
Expand All @@ -39,7 +42,7 @@ const posts: CollectionConfig<'posts'> = {
slug: 'posts',
admin: {
group: adminGroups.content,
defaultColumns: ['title', 'tenant', 'updatedAt'],
defaultColumns: ['title', 'updatedAt'],
useAsTitle: 'title',
description: {
en: 'Posts are standalone pages such as articles or blog posts and can be categorized.',
Expand Down Expand Up @@ -73,11 +76,10 @@ const posts: CollectionConfig<'posts'> = {
{
label: { en: 'Content', sv: 'Innehåll' },
fields: [
{
mediaUploadField({
name: 'heroImage',
type: 'upload',
relationTo: 'media'
},
mimeTypeSlugs: ['image']
}),
{
name: 'content',
type: 'richText',
Expand Down
Loading
Loading