R2 storage from env + filename normalization
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export { normalizeFilename, normalizeFilenameHook } from './normalizeFilename.js'
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { buildR2Storage } from './buildR2Storage.js'
|
||||
Reference in New Issue
Block a user