R2 storage from env + filename normalization

This commit is contained in:
2026-08-27 12:31:09 +02:00
parent 6a8361d710
commit 9acb22e2f9
16 changed files with 820 additions and 60 deletions
@@ -1,44 +0,0 @@
import type { Field } from 'payload'
/**
* Cloudflare R2 storage credentials.
* Reserved for future use — media offloading to R2.
*
* Protected at the global level (SiteIntegrations requires an authenticated
* user), so the access keys stay editable in the admin panel while remaining
* inaccessible to anonymous API requests.
*/
export const storageFields: Field[] = [
{
name: 'r2Bucket',
type: 'text',
admin: {
description: 'R2 bucket name.',
},
},
{
name: 'r2Endpoint',
type: 'text',
admin: {
description: 'R2 S3-compatible endpoint URL.',
},
},
{
name: 'r2AccessKeyId',
type: 'text',
admin: {
description: 'R2 access key ID.',
},
},
{
name: 'r2SecretAccessKey',
type: 'text',
admin: {
description: 'R2 secret access key.',
// Masked in the UI (••••) — stored plaintext, readable for R2 auth.
components: {
Field: '@intecion/ipal-kit/client#MaskedField',
},
},
},
]
+4 -2
View File
@@ -3,7 +3,6 @@ import type { Field, GlobalConfig } from 'payload'
import { isAdmin } from '../../modules/access/index.js'
import { analyticsFields } from './fields/analytics.js'
import { smtpFields } from './fields/smtp.js'
import { storageFields } from './fields/storage.js'
import { turnstileFields } from './fields/turnstile.js'
type BuildSiteIntegrationsArgs = {
@@ -22,6 +21,10 @@ type BuildSiteIntegrationsArgs = {
* impossible to enter.)
*
* Unnamed tabs keep data flat (siteIntegrations.ga4MeasurementId).
*
* Note: R2 storage credentials are NOT here — storage is infrastructure and
* binds at boot, so its config lives in .env (R2_BUCKET, R2_ENDPOINT, ...),
* consumed by buildR2Storage. See docs/storage.md.
*/
export function buildSiteIntegrations({
additionalFields,
@@ -44,7 +47,6 @@ export function buildSiteIntegrations({
{ fields: analyticsFields, label: 'Analytics' },
{ fields: turnstileFields, label: 'Turnstile' },
{ fields: smtpFields, label: 'SMTP' },
{ fields: storageFields, label: 'Storage' },
...(additionalFields?.length ? [{ fields: additionalFields, label: 'Custom' }] : []),
],
},
+14 -12
View File
@@ -75,6 +75,18 @@ export {
} from './modules/i18n/index.js'
export type { LocaleMiddlewareResult } from './modules/i18n/index.js'
export { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from './modules/i18n/index.js'
// Media — filename normalization hook for upload collections (Media).
export { normalizeFilename, normalizeFilenameHook } from './modules/media/index.js'
export {
getNotificationTexts,
NOTIFICATION_FALLBACK,
resolveFormMessage,
} from './modules/notifications/index.js'
export type {
FormNotificationTexts,
NotificationsData,
NotificationTexts,
} from './modules/notifications/index.js'
export type { PagesOption, SystemPageRole } from './modules/pages/index.js'
export { ALL_SYSTEM_PAGE_ROLES, getSystemPagePath } from './modules/pages/index.js'
export type { GlobalQueryOptions } from './modules/payload/index.js'
@@ -99,18 +111,8 @@ export {
injectAutoFillMeta,
} from './modules/seo/index.js'
export { buildSlugField, toSlug } from './modules/slug/index.js'
export {
NOTIFICATION_FALLBACK,
getNotificationTexts,
resolveFormMessage,
} from './modules/notifications/index.js'
export type {
FormNotificationTexts,
NotificationsData,
NotificationTexts,
} from './modules/notifications/index.js'
// Storage — Cloudflare R2 media offload, configured from .env.
export { buildR2Storage } from './modules/storage/index.js'
export { ipalKit } from './plugin.js'
export type { IpalOptions } from './types.js'
+1
View File
@@ -0,0 +1 @@
export { normalizeFilename, normalizeFilenameHook } from './normalizeFilename.js'
+53
View File
@@ -0,0 +1,53 @@
import type { CollectionBeforeOperationHook } from 'payload'
import slugify from 'slugify'
/**
* Normalizes a filename: slugifies the NAME part (diacritics, spaces, case)
* while preserving the extension. Keeps uploaded media URLs clean and portable.
*
* "Zdjęcie jeden nad morzem.jpg" → "zdjecie-jeden-nad-morzem.jpg"
* "Faktura #12 (2024).PDF" → "faktura-12-2024.pdf"
* "already-clean.webp" → "already-clean.webp"
*
* Why not toSlug(): toSlug uses strict:true, which would strip the dot and
* merge name+extension. Here we split on the LAST dot, slug the stem, lowercase
* the extension, and rejoin.
*/
export function normalizeFilename(filename: string): string {
const lastDot = filename.lastIndexOf('.')
// No extension (or leading-dot dotfile) → slug the whole thing.
if (lastDot <= 0) {
return slugify(filename, { lower: true, strict: true, trim: true })
}
const stem = filename.slice(0, lastDot)
const ext = filename.slice(lastDot + 1).toLowerCase()
const cleanStem = slugify(stem, { lower: true, strict: true, trim: true })
const cleanExt = slugify(ext, { lower: true, strict: true, trim: true })
// Stem could slug to empty (e.g. filename was all symbols) — fall back so we
// never produce a nameless file.
const safeStem = cleanStem || 'plik'
return cleanExt ? `${safeStem}.${cleanExt}` : safeStem
}
/**
* beforeOperation hook for an upload collection (e.g. Media). Rewrites the
* incoming file's name to its normalized form before Payload stores it, so both
* the stored file and its DB filename are clean. Works with local disk and with
* cloud storage adapters (R2/S3) — it runs before the storage layer.
*
* Wire into your Media collection:
* import { normalizeFilenameHook } from '@intecion/ipal-kit'
* hooks: { beforeOperation: [normalizeFilenameHook] }
*/
export const normalizeFilenameHook: CollectionBeforeOperationHook = ({ req, operation }) => {
if (operation !== 'create' && operation !== 'update') return
const file = req.file
if (file?.name) {
file.name = normalizeFilename(file.name)
}
}
+53
View File
@@ -0,0 +1,53 @@
import type { Plugin } from 'payload'
import { s3Storage } from '@payloadcms/storage-s3'
/**
* Cloudflare R2 media storage — configured from environment variables (agency
* infrastructure, not per-project panel data). R2 is S3-compatible, so we use
* @payloadcms/storage-s3 pointed at the R2 endpoint.
*
* Storage is infrastructure (like the database or PAYLOAD_SECRET): it binds at
* boot, and its credentials are agency-owned — so it lives in .env, not the
* panel. See docs/storage.md for the required variables.
*
* Returns the storage plugin when all R2 vars are present; otherwise returns a
* no-op passthrough so the project falls back to Payload's default local disk
* storage (useful in dev without R2). This mirrors how mailAdapter degrades
* gracefully when a transport isn't configured.
*
* @param collections - slugs of upload collections to offload to R2 (e.g. ['media'])
*/
export const buildR2Storage = (collections: string[] = ['media']): Plugin => {
const bucket = process.env.R2_BUCKET
const endpoint = process.env.R2_ENDPOINT
const accessKeyId = process.env.R2_ACCESS_KEY_ID
const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY
// Any missing → skip R2, fall back to local disk. Warn so it's not silent.
if (!bucket || !endpoint || !accessKeyId || !secretAccessKey) {
return (config) => {
// Only warn when SOME vars are set (partial config = likely a mistake).
if (bucket || endpoint || accessKeyId || secretAccessKey) {
console.warn(
'[ipal] R2 storage: incomplete env (need R2_BUCKET, R2_ENDPOINT, ' +
'R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY). Falling back to local disk.',
)
}
return config
}
}
const collectionsConfig = Object.fromEntries(collections.map((slug) => [slug, true]))
return s3Storage({
collections: collectionsConfig,
bucket,
config: {
endpoint,
region: 'auto', // R2 uses 'auto'
credentials: { accessKeyId, secretAccessKey },
// R2 requires path-style addressing for S3 compatibility.
forcePathStyle: true,
},
})
}
+1
View File
@@ -0,0 +1 @@
export { buildR2Storage } from './buildR2Storage.js'