Upload Local Secure
Strapi local upload provider with path organization, private files, and UUID filenames.
strapi-provider-upload-local-secure
Enhanced fork of the default Strapi local upload provider. README targets v1.0.0 behavior.
README targets v1.0.0 behavior (this package’s current version).
🚀 Features
- Admin folder support:
data.pathcreates/targets Media Library folders (DB only; folders remain “virtual”) - Filesystem directory support:
data.pathDircontrols physical subdirectories underpublic/uploads - NFD + sanitization:
pathDiris sanitized for safe filesystem pathspathis sanitized for safe Admin folder names
- Multi-upload: handles
fileInfoas an array (one entry per file) - Replace support: keeps behavior consistent for
upload.replace(...)when present - Robust delete: path/url/ext inference + safe scanning under uploads root
- Optional cleanup: remove empty parent directories after deletion (
cleanupEmptyDirs) - Private folder with secure URLs: optional private uploads with access via Admin JWT, user JWT (documentId match), or HMAC-signed time-limited URLs
- Strapi v5 compatible
📦 Installation
Using yarn
yarn add strapi-provider-upload-local-secureUsing npm
npm install strapi-provider-upload-local-secure --saveUsing bun
bun add strapi-provider-upload-local-secure⚙️ Configuration
Provider Configuration
For TypeScript projects - config/plugins.ts:
export default () => ({
upload: {
config: {
// Required
provider: 'strapi-provider-upload-local-secure',
// Optional (all providerOptions below are optional)
providerOptions: {
// Optional: enable debug logs (upload/delete resolution)
// Also supports env: STRAPI_PROVIDER_UPLOAD_LOCAL_PATH_DEBUG=1
debug: false,
// Optional: if `pathDir` sanitizes to an empty string, throw instead of falling back
strictPathDir: false,
// Optional: after deleting a file, attempt to remove empty parent directories under `public/uploads`
cleanupEmptyDirs: false,
// Optional: after deleting a file, attempt to delete empty Media Library folders (Admin) as well.
// Safety rules:
// - only deletes a folder if it has no child folders
// - only deletes a folder if it has no other files (excluding the file currently being deleted)
// - then repeats the same check for the parent folder (recursive upwards)
cleanupEmptyAdminFolders: false,
// Optional (default: true): if `pathDir` is not provided, use `path` as the filesystem directory
// If you want `path` to be Admin-only and never affect filesystem paths, set this to false.
usePathAsPathDir: true,
// Optional (default: "/uploads/"): marker used to extract objectPath from `file.url` during delete
uploadsUrlMarker: '/uploads/',
// Optional (default: false): save files as <uuidv4>_<hash>.ext for unique, non-guessable filenames
renameToUuid: false,
// Optional: private folder (access via Admin JWT, user JWT with matching documentId, or HMAC-signed URL)
privateEnable: false,
privateFolder: 'private',
privateTTL: 60,
privateSecret: '',
privateUserDocumentIdField: 'id',
},
},
},
});For JavaScript projects - config/plugins.js:
module.exports = ({ env }) => ({
upload: {
config: {
// Required
provider: 'strapi-provider-upload-local-secure',
// Optional (all providerOptions below are optional)
providerOptions: {
// Optional: enable debug logs (upload/delete resolution)
// Also supports env: STRAPI_PROVIDER_UPLOAD_LOCAL_PATH_DEBUG=1
debug: false,
// Optional: if `pathDir` sanitizes to an empty string, throw instead of falling back
strictPathDir: false,
// Optional: after deleting a file, attempt to remove empty parent directories under `public/uploads`
cleanupEmptyDirs: false,
// Optional: after deleting a file, attempt to delete empty Media Library folders (Admin) as well.
// Safety rules:
// - only deletes a folder if it has no child folders
// - only deletes a folder if it has no other files (excluding the file currently being deleted)
// - then repeats the same check for the parent folder (recursive upwards)
cleanupEmptyAdminFolders: false,
// Optional (default: true): if `pathDir` is not provided, use `path` as the filesystem directory
// If you want `path` to be Admin-only and never affect filesystem paths, set this to false.
usePathAsPathDir: true,
// Optional (default: "/uploads/"): marker used to extract objectPath from `file.url` during delete
uploadsUrlMarker: '/uploads/',
// Optional (default: false): save files as <uuidv4>_<hash>.ext for unique, non-guessable filenames
renameToUuid: false,
// Optional: private folder (access via Admin JWT, user JWT with matching documentId, or HMAC-signed URL)
privateEnable: false,
privateFolder: 'private',
privateTTL: 60,
privateSecret: '',
privateUserDocumentIdField: 'id',
},
},
},
});🎯 Usage Examples
To use path, pathDir, or private, you must call the upload service from your own API (e.g. custom routes/controllers) or extend the upload plugin controller. The default Strapi upload (Admin drag-and-drop or POST /api/upload) does not pass these options to the provider unless you extend it.
Custom route example: For a full example with controller, custom route, Swagger and permissions, see Custom route with path, pathDir and private.
Optional: Swagger documentation with strapi-swagger-custom-paths
If you want to expose your custom upload routes in the API documentation (Swagger/OpenAPI), you can use strapi-swagger-custom-paths. No dependency between packages — this provider works standalone; the Swagger package is an optional extra for developers who need documentation.
Install (optional):
# npm
npm install strapi-swagger-custom-paths
# yarn
yarn add strapi-swagger-custom-paths
# bun
bun add strapi-swagger-custom-paths1. Define Swagger in your custom route — use 01-custom.ts (or 01-custom.js) so the package can discover it. Add config.swagger to each route:
src/api/my-content-type/routes/01-custom.ts:
export default {
routes: [
{
method: 'POST',
path: '/my-content-types/upload',
handler: 'my-content-type.upload',
config: {
tags: ['My content type'],
swagger: {
tags: ['My content type'],
summary: 'Upload file',
description: 'Upload a file with path, pathDir (strapi-provider-upload-local-secure).',
requestBody: {
required: true,
content: {
'multipart/form-data': {
schema: {
type: 'object',
required: ['file'],
properties: {
file: {
type: 'string',
format: 'binary',
description: 'File to upload',
},
// Add custom fields here (if necessary)
},
},
},
},
},
},
},
},
],
};2. Use getCustomSwaggerPaths() in your documentation plugin — the package scans all 01-custom.js/01-custom.ts files and merges their config.swagger into OpenAPI paths:
TypeScript (config/plugins.ts):
import { getCustomSwaggerPaths } from 'strapi-swagger-custom-paths';
export default () => ({
// ... your other plugins
documentation: {
enabled: true,
config: {
'x-strapi-config': { path: '/documentation' },
paths: getCustomSwaggerPaths(),
},
},
});JavaScript (config/plugins.js):
const { getCustomSwaggerPaths } = require('strapi-swagger-custom-paths');
module.exports = () => ({
documentation: {
enabled: true,
config: {
'x-strapi-config': { path: '/documentation' },
paths: getCustomSwaggerPaths(),
},
},
});The upload provider and the Swagger package work independently: you can use either one without the other.
Basic Usage (Backward Compatible)
All examples below use api::my-content-type.my-content-type. Replace with your own API. Ensure entry exists (create it first or get from request); use entry.documentId for refId.
TypeScript:
async upload(ctx) {
try {
const user = ctx.state.user;
if (!user) return ctx.unauthorized('User not authenticated');
const { files } = ctx.request;
if (!files?.file) return ctx.badRequest('No file uploaded');
const file = Array.isArray(files.file) ? files.file[0] : files.file;
const entry = await strapi.documents('api::my-content-type.my-content-type').create({ data: {} });
const result = await strapi.plugin('upload').service('upload').upload({
data: {
path: 'custom-folder',
pathDir: 'custom-folder',
ref: 'api::my-content-type.my-content-type',
refId: entry.id,
field: 'file',
fileInfo: {
name: file.originalFilename,
caption: 'Document file',
},
},
files: file,
});
return result;
} catch (error) {
return ctx.badRequest(error.message);
}
}JavaScript:
async upload(ctx) {
try {
const user = ctx.state.user;
if (!user) return ctx.unauthorized('User not authenticated');
const { files } = ctx.request;
if (!files?.file) return ctx.badRequest('No file uploaded');
const file = Array.isArray(files.file) ? files.file[0] : files.file;
const entry = await strapi.documents('api::my-content-type.my-content-type').create({ data: {} });
const result = await strapi.plugin('upload').service('upload').upload({
data: {
path: 'custom-folder',
pathDir: 'custom-folder',
ref: 'api::my-content-type.my-content-type',
refId: entry.id,
field: 'file',
fileInfo: {
name: file.originalFilename,
caption: 'Document file',
},
},
files: file,
});
return result;
} catch (error) {
return ctx.badRequest(error.message);
}
}Dynamic Path Organization
Use path for Admin folders and pathDir for filesystem subdirectories. See path vs pathDir in Configuration Options for details.
TypeScript:
const result = await strapi.plugin('upload').service('upload').upload({
data: {
path: `user-${userId}`,
pathDir: `user-${userId}`,
ref: 'api::my-content-type.my-content-type',
refId: entry.id,
field: 'file',
fileInfo: {
name: file.originalFilename,
caption: `Document for user ${userId}`,
alternativeText: `Document for user ${userId}`,
},
},
files: file,
});
// Files will be saved in: uploads/user-123/[hash].ext
// URL will be: /uploads/user-123/[hash].extJavaScript:
const result = await strapi.plugin('upload').service('upload').upload({
data: {
path: `user-${userId}`,
pathDir: `user-${userId}`,
ref: 'api::my-content-type.my-content-type',
refId: entry.id,
field: 'file',
fileInfo: {
name: file.originalFilename,
caption: `Document for user ${userId}`,
alternativeText: `Document for user ${userId}`,
},
},
files: file,
});Private folder (privateEnable: true)
When privateEnable is true, you can upload files as private by setting data.private: true and data.pathDir to the user's document ID (e.g. user.id or your custom documentId field). The file is stored under /uploads/<privateFolder>/<documentId>/ and appears in the Admin Media Library with full access (view, move, delete). Via URL, access is allowed only if:
- The request has a valid Admin JWT (Bearer), or
- The request has a valid users-permissions JWT (Bearer) and the user's documentId (e.g.
user.id) matches the path segment, or - The URL has a valid HMAC query (
?token=...&expires=...) signed withprivateSecretand not expired (withinprivateTTLseconds).
Otherwise the server returns 403. The same logic applies everywhere (API and Admin): use Bearer or a signed URL. The provider patches the upload service so that find and findOne return signed URLs for private files; so in the Admin Library (and anywhere that uses those endpoints) the link you open or copy already has ?token=...&expires=... and works.
TypeScript:
const user = ctx.state.user;
if (!user) return ctx.unauthorized();
const documentId = String(user.id); // or user.documentId if privateUserDocumentIdField: 'documentId'
const result = await strapi.plugin('upload').service('upload').upload({
data: {
pathDir: documentId,
private: true,
ref: 'api::my-content-type.my-content-type',
refId: entry.id,
field: 'file',
fileInfo: {
name: file.originalFilename,
caption: 'Private file',
},
},
files: file,
});
// File saved in: uploads/private/<documentId>/[hash].ext
// URL: /uploads/private/<documentId>/[hash].ext
// Access: Admin JWT, user JWT (documentId match), or HMAC-signed URL.JavaScript:
const user = ctx.state.user;
if (!user) return ctx.unauthorized();
const documentId = String(user.id); // or user.documentId if privateUserDocumentIdField: 'documentId'
const result = await strapi.plugin('upload').service('upload').upload({
data: {
pathDir: documentId,
private: true,
ref: 'api::my-content-type.my-content-type',
refId: entry.id,
field: 'file',
fileInfo: {
name: file.originalFilename,
caption: 'Private file',
},
},
files: file,
});Note: The provider registers a middleware that intercepts GET /uploads/<privateFolder>/* and checks auth before serving. If private files are served without auth, ensure this middleware runs before Strapi's static file middleware (e.g. load the upload plugin early or adjust middleware order in your app).
About moving files in the Admin Media Library
Moving a file from one folder to another in the Admin (e.g. from "Documents" to "Private", or the other way around) does not change whether the file is public or private. That is decided when the file is uploaded: private files are stored in a special protected path on the server, and public files elsewhere. Moving between folders only changes how you organize them in the Media Library—it does not move the file on the server or update its access rules.
In short: private files stay private even if you move them to another folder, and public files stay public even if you move them. To change access, you would need to re-upload the file with the desired settings.
Multi-upload (multiple files at once)
Strapi supports uploading multiple files in one call. In that case, files is an array and fileInfo should be an array with the same order:
TypeScript:
await strapi.plugin('upload').service('upload').upload({
data: {
path: 'Documents',
pathDir: 'documents',
fileInfo: [
{ name: 'a.png', caption: 'A' },
{ name: 'b.png', caption: 'B' },
],
},
files: [fileA, fileB],
});JavaScript:
await strapi.plugin('upload').service('upload').upload({
data: {
path: 'Documents',
pathDir: 'documents',
fileInfo: [
{ name: 'a.png', caption: 'A' },
{ name: 'b.png', caption: 'B' },
],
},
files: [fileA, fileB],
});Replace compatibility (upload.replace)
When replacing an existing upload (keeping the same DB entry), Strapi calls upload.replace(id, { data, file }).
This provider patches replace too (when present) so path / pathDir behave the same:
TypeScript:
await strapi.plugin('upload').service('upload').replace(fileId, {
data: {
path: 'Documents',
pathDir: 'documents/2026',
fileInfo: { name: 'new-name.png' },
},
file: newFile,
});JavaScript:
await strapi.plugin('upload').service('upload').replace(fileId, {
data: {
path: 'Documents',
pathDir: 'documents/2026',
fileInfo: { name: 'new-name.png' },
},
file: newFile,
});Organization by Document Type
TypeScript:
const result = await strapi.plugin('upload').service('upload').upload({
data: {
path: `contracts/${userId}`,
pathDir: `contracts/${userId}`,
ref: 'api::my-content-type.my-content-type',
refId: entry.id,
field: 'file',
fileInfo: {
name: file.originalFilename,
caption: 'Contract document',
alternativeText: 'Contract document',
},
},
files: file,
});
// Files will be saved in: uploads/contracts/123/[hash].extJavaScript:
const result = await strapi.plugin('upload').service('upload').upload({
data: {
path: `contracts/${userId}`,
pathDir: `contracts/${userId}`,
ref: 'api::my-content-type.my-content-type',
refId: entry.id,
field: 'file',
fileInfo: {
name: file.originalFilename,
caption: 'Contract document',
alternativeText: 'Contract document',
},
},
files: file,
});uploads/
├── user-123/
│ ├── abc123def456.jpg
│ └── ghi789jkl012.png
├── user-456/
│ └── mno345pqr678.pdf
└── user-789/
└── stu901vwx234.docxWith Dynamic Path by Type
uploads/
├── contracts/
│ ├── user-123/
│ │ └── contract-001.pdf
│ └── user-456/
│ └── contract-002.pdf
├── invoices/
│ ├── invoice-001.pdf
│ └── invoice-002.pdf
└── profiles/
└── user-123-avatar.jpg⚙️ Configuration Options
path (Admin) vs pathDir (filesystem)
This provider supports two optional fields in upload().data:
path: creates/targets a Media Library folder (Admin UI) by automatically settingfileInfo.folder(it creates the folder hierarchy if needed).pathDir: controls the physical directory underpublic/uploads(NFD normalized + sanitized) and therefore affects the finalfile.url.
Compatibility: If you do not send pathDir, the provider uses path as filesystem directory by default (can be changed with usePathAsPathDir).
See Basic Usage or Dynamic Path Organization for code examples.
Provider options (providerOptions)
All options are optional.
debug: boolean (defaultfalse)
Enable provider debug logs. Also supports envSTRAPI_PROVIDER_UPLOAD_LOCAL_PATH_DEBUG=1.strictPathDir: boolean (defaultfalse)
IfpathDirsanitizes to an empty string, throw an error instead of falling back.cleanupEmptyDirs: boolean (defaultfalse)
After deleting a file, try to remove empty parent directories underpublic/uploads.cleanupEmptyAdminFolders: boolean (defaultfalse)
After deleting a file, try to delete empty Media Library folders (Admin) too. It only deletes a folder when it has no child folders and no other files (excluding the file being deleted), then repeats the same check for the parent folder (recursive upwards).usePathAsPathDir: boolean (defaulttrue)
IfpathDiris missing, usepathas the filesystem directory (compatibility mode). Set tofalseif you wantpathto be Admin-only and never affect filesystem paths.uploadsUrlMarker: string (default"/uploads/")
Marker used to extract the objectPath fromfile.url/previewUrlduring delete.renameToUuid: boolean (defaultfalse)
Iftrue, saved file name becomes<uuidv4>_<hash><ext>(e.g.a1b2c3d4-e5f6-7890-abcd-ef1234567890_abc123hash.ext).
Strapi's hash is still present; the UUID makes the filename unique and non-guessable.privateEnable: boolean (defaultfalse)
Enables the private folder. Files under/uploads/<privateFolder>/<documentId>/are only accessible when authenticated: Admin has full access; API requires JWT user's documentId to match the path, or a valid HMAC-signed URL.privateFolder: string (default"private")
Name of the private folder in filesystem and Admin Media Library (no leading/trailing slashes). URL pattern:/uploads/<privateFolder>/<documentId>/file.ext.privateTTL: number (default60)
TTL in seconds for HMAC-signed URLs (?token=...&expires=...).privateSecret: string (optional)
Secret for signing HMAC URLs. Required if you want shareable time-limited links. Use a long random value (e.g. 32 bytes). See Generating secrets below.privateUserDocumentIdField: string (default"id")
Field on the users-permissions user entity used as documentId for path matching. The path must be/uploads/private/<documentId>/...and the authenticated user's value for this field must equal<documentId>, or 403 is returned.
For Admin and API to open/copy working links to private files, set privateSecret. The provider then patches the upload service so that find and findOne return signed URLs (?token=...&expires=...) for private files—same logic everywhere.
Generating secrets
When you need a secure random value for privateSecret (or any other secret), generate one with Node.js:
Hex (64 characters):
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"Base64:
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"Use the output in your config (e.g. in providerOptions.privateSecret) or in an environment variable; do not commit secrets to version control.
Admin folder name sanitization (v1.0.0)
Admin folder segments (from data.path) are sanitized as follows:
- NFD normalize and remove diacritics
- Allowed characters:
a-zA-Z0-9, space,_,- - Collapses multiple spaces and trims
This avoids invalid folder names in the Media Library DB.
🔄 Migration from Official Provider
This is a drop-in replacement for @strapi/provider-upload-local. To migrate:
Install the new package:
# npm npm install strapi-provider-upload-local-secure # yarn yarn add strapi-provider-upload-local-secure # bun bun add strapi-provider-upload-local-secureUpdate plugins configuration (
config/plugins.tsorconfig/plugins.js): changeprovider: 'local'toprovider: 'strapi-provider-upload-local-secure'.Add dynamic paths (optional): call the upload service from your controller or custom route and pass
path/pathDirindata:TypeScript:
const file = Array.isArray(files.file) ? files.file[0] : files.file; await strapi.plugin('upload').service('upload').upload({ data: { path: `user-${userId}`, pathDir: `user-${userId}`, ref: 'api::my-content-type.my-content-type', refId: entry.id, field: 'file', fileInfo: { name: file.originalFilename, caption: 'Document file' }, }, files: file, });JavaScript:
const file = Array.isArray(files.file) ? files.file[0] : files.file; await strapi.plugin('upload').service('upload').upload({ data: { path: `user-${userId}`, pathDir: `user-${userId}`, ref: 'api::my-content-type.my-content-type', refId: entry.id, field: 'file', fileInfo: { name: file.originalFilename, caption: 'Document file' }, }, files: file, });
For local development, testing, and publishing, see Development guide.
🧪 Testing
The provider has been tested with:
- ✅ Strapi 5.x
- ✅ Node.js 20.x
- ✅ TypeScript 5.x
- ✅ Bun package manager
- ✅ npm and yarn
To run tests locally, see Development guide.
🐛 Troubleshooting
Common Issues
- Provider not found: Ensure the provider name is
'strapi-provider-upload-local-secure'in your configuration - Permission denied: Check directory permissions for the uploads folder
- Path not working: Verify you're using
data.pathin the upload call - Build errors: Make sure you have TypeScript and required dependencies installed
Debug Mode
Add console logs to see what's happening:
// Add temporary logs to your upload controller
console.log('Upload data:', {
path: data.path,
fileInfo: data.fileInfo
});🤝 Contributing
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
Development Guidelines
- Follow TypeScript best practices
- Add tests for new features
- Update documentation for any API changes
- Ensure backward compatibility
📞 Support
- 📧 Email: daniel.adg1337@gmail.com
- 🐛 Issues: GitHub Issues
- 📖 Documentation: GitHub Repository
🙏 Acknowledgments
This provider was inspired by:
- Strapi upload-local — Official local upload provider; base structure and upload/delete flow.
strapi-provider-upload-aws-s3-advanced (zoomoid) — Patterns for
normalizePrefix,join, and provider layout (see source).Strapi Team — For the original upload provider implementation.
- Community — For feedback and suggestions.
⚠️ Important: Modified version of the official @strapi/provider-upload-local. Backward compatible; adds path organization, private folder with secure URLs, and other features not in the original.
Install now
npm install strapi-provider-upload-local-secure
Create your own plugin
Check out the available plugin resources that will help you to develop your plugin or provider and get it listed on the marketplace.