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
7 changes: 4 additions & 3 deletions apps/cms-e2e/src/admin/posts.admin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@ test.describe('/admin/collections/posts', () => {
}) => {
await page.goto('/admin/collections/posts');

await page.getByRole('link', { name: 'Lunar Highlands' }).click();

await expect(page).toHaveURL(/\/admin\/collections\/posts\//);
const link = page.getByRole('link', { name: 'Lunar Highlands' });
await expect(link).toBeVisible();
await link.click();
await page.waitForURL(/\/admin\/collections\/posts\//);

const titleField = page.getByLabel('Title');
await expect(titleField).toBeVisible();
Expand Down
5 changes: 4 additions & 1 deletion apps/cms-e2e/src/site/posts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ test.describe('/posts', () => {
test('navigates to a post', async ({ page }) => {
await page.goto('/posts');

await page.getByRole('link', { name: 'Lunar Highlands' }).click();
await Promise.all([
page.waitForURL(/\/posts\/lunar-highlands/),
page.getByRole('link', { name: 'Lunar Highlands' }).click()
]);

await expect(page).toHaveURL(/\/posts\/lunar-highlands/);
});
Expand Down
16 changes: 7 additions & 9 deletions apps/cms/src/app/api/preview/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { draftMode } from 'next/headers';
import { NextResponse } from 'next/server';
import { redirect } from 'next/navigation';
import { NextRequest } from 'next/server';
import { getPayload } from 'payload';

import { isUser } from '@codeware/app-cms/util/misc';
Expand All @@ -18,18 +19,15 @@ import config from '../../../payload.config';
* Authentication: validates the Payload session cookie so only logged-in
* admin users can enable draft mode. Tenant API key clients are not allowed.
*/
export async function GET(request: Request) {
const requestUrl = new URL(request.url);
const redirectTo = requestUrl.searchParams.get('redirect');
export async function GET(request: NextRequest): Promise<Response> {
const { searchParams } = new URL(request.url);
const redirectTo = searchParams.get('redirect');

if (!redirectTo) {
return new Response('Missing redirect parameter', { status: 400 });
}

// Only allow same-origin paths to prevent open-redirect attacks.
// Scheme-relative URLs like //evil.com pass startsWith('/') but resolve externally.
const resolved = new URL(redirectTo, requestUrl);
if (resolved.origin !== requestUrl.origin) {
if (!redirectTo.startsWith('/') || redirectTo.startsWith('//')) {
return new Response('Invalid redirect path', { status: 400 });
}

Expand All @@ -51,5 +49,5 @@ export async function GET(request: Request) {
const draft = await draftMode();
draft.enable();

return NextResponse.redirect(resolved);
redirect(redirectTo);
}
89 changes: 88 additions & 1 deletion apps/cms/src/collections/media/media.collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@ import { fileURLToPath } from 'url';

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

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

import { externalOrApiKeyAccess } from './access/external-or-api-key-access';
Expand All @@ -26,6 +29,55 @@ const imageName: GenerateImageName = ({ extension, originalName, sizeName }) =>
const isImageOrVideo: Condition<TypeWithID, Media> = (_, siblingData) =>
!!siblingData.mimeType && siblingData.mimeType.match(/image|video/) !== null;

const filenameWithoutTenantPrefix: FieldHook<Media> = ({ siblingData }) => {
const { filename, prefix } = siblingData ?? {};
if (!filename || !prefix) return filename;
const tenant = `${prefix}-`;
return filename.startsWith(tenant) ? filename.slice(tenant.length) : filename;
};

const prefixFilenameWithTenant: CollectionBeforeOperationHook<
'media'
> = async ({ args, operation }) => {
if (operation !== 'create') return args;

const { data, req } = args;
const tenantId = getId(data?.tenant);
if (!req.file || !tenantId) return args;
Comment thread
hakalb marked this conversation as resolved.

try {
const tenant = await req.payload.findByID({
collection: 'tenants',
id: tenantId,
depth: 0,
req
});

if (tenant?.slug) {
// Prefix filename with tenant slug to ensure uniqueness across tenants.
// Local: keeps re-seeds idempotent (no -2/-3 accumulation on disk).
// S3: the file API endpoint resolves the S3 prefix by looking up the doc
// by filename alone — without unique filenames, cross-tenant requests
// return the wrong file.
const filenamePrefix = `${tenant.slug}-`;
if (!req.file.name.startsWith(filenamePrefix)) {
req.file.name = `${filenamePrefix}${req.file.name}`;
}
if (data?.filename && !data.filename.startsWith(filenamePrefix)) {
data.filename = `${filenamePrefix}${data.filename}`;
}

// S3: store tenant slug as prefix so files land in tenant folders
// e.g. star/star-abstract-image-1.jpg
data.prefix = tenant.slug;
}
} catch {
// Non-fatal: proceed without prefix if tenant lookup fails
}

return args;
};

// 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> = ({
Expand Down Expand Up @@ -58,7 +110,15 @@ const media: CollectionConfig = {
slug: 'media',
admin: {
group: adminGroups.fileArea,
defaultColumns: ['filename', 'mimeType', 'fileSize', 'tags', 'createdAt'],
useAsTitle: 'filenameWithoutPrefix',
defaultColumns: [
'filename',
'filenameWithoutPrefix',
'mimeType',
'fileSize',
'tags',
'createdAt'
],
components: {
beforeListTable: [
{
Expand All @@ -76,14 +136,17 @@ const media: CollectionConfig = {
read: externalOrApiKeyAccess()
},
hooks: {
beforeOperation: [prefixFilenameWithTenant],
beforeValidate: [ensureMimeType]
},
labels: {
singular: { en: 'Media', sv: 'Media' },
plural: { en: 'Media', sv: 'Media' }
},
indexes: [{ fields: ['filename', 'prefix'], unique: true }],
upload: {
mimeTypes: getMimeTypes(),
filenameCompoundIndex: ['filename', 'prefix'],
// 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 @@ -132,6 +195,17 @@ const media: CollectionConfig = {
staticDir: path.resolve(dirname, '../../../public/media')
},
fields: [
{
// Stores the filename without the tenant prefix for display in the admin UI.
// Computed from filename + prefix on every write; never edited directly.
name: 'filenameWithoutPrefix',
type: 'text',
label: { en: 'Filename', sv: 'Filnamn' },
admin: { hidden: true },
hooks: {
beforeChange: [filenameWithoutTenantPrefix]
}
},
{
name: 'alt',
type: 'text',
Expand Down Expand Up @@ -163,6 +237,19 @@ const media: CollectionConfig = {
admin: { appearance: 'select' }
}
}),
{
// Stores the tenant slug for S3 storage — used by the cloud storage plugin
// to build the S3 key: {prefix}/{filename} (e.g. star/star-abstract-image-1.jpg).
// Set automatically by the beforeOperation hook; not shown in admin UI.
// afterRead: coerce null → undefined so the S3 delete handler's `{ prefix = '' }`
// default kicks in for records created before this hook existed.
name: 'prefix',
type: 'text',
admin: { hidden: true },
hooks: {
afterRead: [({ value }) => value ?? undefined]
}
},
{
// 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.
Expand Down
31 changes: 31 additions & 0 deletions apps/cms/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';

/**
* Clear Next.js draft mode when the Payload session has expired or been cleared.
*
* `/api/preview` enables draft mode (sets `__prerender_bypass`) so Payload's
* live preview can fetch draft content. Payload's logout only clears its own
* `payload-token` cookie — the draft cookie persists, causing site pages to
* keep rendering in draft mode after logout, which produces empty or broken
* content for unauthenticated visitors.
*/
export function middleware(request: NextRequest) {
const hasDraftCookie = request.cookies.has('__prerender_bypass');
const hasPayloadToken = request.cookies.has('payload-token');

if (hasDraftCookie && !hasPayloadToken) {
const response = NextResponse.redirect(request.url);
response.cookies.delete('__prerender_bypass');
return response;
}

return NextResponse.next();
}

export const config = {
matcher: [
// Apply only to site pages — not to admin routes or Next.js internals
'/((?!admin|api|_next/static|_next/image|favicon.ico).*)'
]
};
Loading
Loading