diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..a81f5bf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,61 @@ +--- +name: Bug Report +about: Report a bug to help us improve express-storage +title: '[BUG] ' +labels: bug +assignees: '' +--- + +## Bug Description + +A clear and concise description of what the bug is. + +## Steps to Reproduce + +1. Configure storage with '...' +2. Call method '...' +3. Pass parameters '...' +4. See error + +## Expected Behavior + +What you expected to happen. + +## Actual Behavior + +What actually happened. + +## Code Sample + +```typescript +// Minimal code to reproduce the issue +import { StorageManager } from 'express-storage'; + +const storage = new StorageManager({ + driver: 'local', // or s3, gcs, azure +}); + +// Your code here +``` + +## Error Message + +``` +Paste any error messages or stack traces here +``` + +## Environment + +- **express-storage version**: +- **Node.js version**: +- **Operating System**: +- **Storage Provider**: (local / s3 / gcs / azure) +- **Express version**: + +## Additional Context + +Add any other context about the problem here (screenshots, logs, etc.). + +## Possible Solution + +If you have ideas on how to fix this, please share. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..25ab2bd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Documentation + url: https://github.com/th3hero/express-storage#readme + about: Check the README for usage examples and API documentation + - name: Stack Overflow + url: https://stackoverflow.com/questions/tagged/express-storage + about: Ask questions on Stack Overflow with the express-storage tag diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..2c1f1a4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,50 @@ +--- +name: Feature Request +about: Suggest an idea for express-storage +title: '[FEATURE] ' +labels: enhancement +assignees: '' +--- + +## Problem Statement + +A clear description of what problem this feature would solve. + +**Example:** "I'm always frustrated when..." + +## Proposed Solution + +Describe the solution you'd like to see. + +## Example Usage + +```typescript +// How would you like to use this feature? +import { StorageManager } from 'express-storage'; + +const storage = new StorageManager(); + +// Example of the new feature in action +``` + +## Alternatives Considered + +Describe any alternative solutions or features you've considered. + +## Use Case + +Explain the real-world scenario where this feature would be helpful. + +- **Who**: Who would benefit from this feature? +- **What**: What are they trying to accomplish? +- **Why**: Why is the current solution insufficient? + +## Additional Context + +Add any other context, screenshots, or examples about the feature request here. + +## Willingness to Contribute + +- [ ] I'm willing to submit a PR for this feature +- [ ] I can help with testing +- [ ] I can help with documentation diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..c88ccbc --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,56 @@ +## Description + +Brief description of the changes in this PR. + +## Type of Change + +- [ ] Bug fix (non-breaking change that fixes an issue) +- [ ] New feature (non-breaking change that adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to change) +- [ ] Documentation update +- [ ] Performance improvement +- [ ] Code refactoring +- [ ] Test coverage improvement + +## Related Issue + +Fixes #(issue number) + +## Changes Made + +- Change 1 +- Change 2 +- Change 3 + +## Testing + +Describe the tests you ran to verify your changes: + +- [ ] Unit tests pass (`npm test`) +- [ ] Linting passes (`npm run lint`) +- [ ] Type checking passes (`npm run type-check`) +- [ ] Build succeeds (`npm run build`) +- [ ] Manual testing performed + +### Test Configuration + +- **Node.js version**: +- **Storage provider tested**: + +## Screenshots (if applicable) + +Add screenshots to help explain your changes. + +## Checklist + +- [ ] My code follows the project's code style +- [ ] I have commented my code where necessary +- [ ] I have updated the documentation accordingly +- [ ] I have added tests that prove my fix/feature works +- [ ] All new and existing tests pass +- [ ] My changes generate no new warnings +- [ ] I have checked for potential security implications + +## Additional Notes + +Any additional information that reviewers should know. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c9f35ad --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,80 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Improved SEO and package discoverability +- Additional documentation and community files + +## [1.1.4] - 2025-01-XX + +### Changed +- Removed GitHub Packages publishing (npm-only distribution) +- Simplified package distribution strategy + +### Fixed +- Package naming conflicts resolved + +## [1.1.3] - 2025-01-XX + +### Added +- GitHub Packages publishing support (later removed) + +## [1.1.2] - 2025-01-XX + +### Changed +- Migrated to npm trusted publishing for secure releases + +## [1.1.1] - 2025-01-XX + +### Added +- npm trusted publishing support +- Automated CI/CD pipeline improvements + +## [1.1.0] - 2025-01-XX + +### Added +- **Multi-cloud storage support**: AWS S3, Google Cloud Storage, Azure Blob Storage, and local disk +- **Unified API**: Single interface for all storage providers +- **Security features**: + - Path traversal prevention + - Filename sanitization + - Null byte protection + - File validation (size, MIME type, extensions) +- **Presigned URLs**: Client-side direct uploads for S3, GCS, and Azure +- **Large file handling**: Automatic streaming for files >100MB +- **TypeScript support**: Full type definitions and intelligent autocomplete +- **Driver caching**: LRU cache for optimized performance +- **Batch operations**: Upload/delete multiple files with concurrency limits +- **Custom logging**: Pluggable logger interface +- **Retry mechanism**: Exponential backoff for transient failures +- **Utility functions**: File type helpers, size formatting, and more + +### Security +- Built-in protection against common file upload vulnerabilities +- Secure filename generation: `{timestamp}_{random}_{sanitized_name}` +- Azure post-upload validation for presigned URL uploads + +## [1.0.0] - 2025-01-XX + +### Added +- Initial release +- Core storage abstraction layer +- Express.js middleware integration +- Multer compatibility +- Basic file upload and management operations + +--- + +[Unreleased]: https://github.com/th3hero/express-storage/compare/V1.1.4...HEAD +[1.1.4]: https://github.com/th3hero/express-storage/compare/V1.1.3...V1.1.4 +[1.1.3]: https://github.com/th3hero/express-storage/compare/V1.1.2...V1.1.3 +[1.1.2]: https://github.com/th3hero/express-storage/compare/V1.1.1...V1.1.2 +[1.1.1]: https://github.com/th3hero/express-storage/compare/V1.1.0...V1.1.1 +[1.1.0]: https://github.com/th3hero/express-storage/compare/V1.0.0...V1.1.0 +[1.0.0]: https://github.com/th3hero/express-storage/releases/tag/V1.0.0 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..7885f87 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,48 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of + any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, + without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..db78e93 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,291 @@ +# Contributing to Express Storage + +First off, thank you for considering contributing to Express Storage! It's people like you that make this project better for everyone. + +## Table of Contents + +- [Code of Conduct](#code-of-conduct) +- [Getting Started](#getting-started) +- [Development Setup](#development-setup) +- [How to Contribute](#how-to-contribute) +- [Pull Request Process](#pull-request-process) +- [Code Style](#code-style) +- [Testing](#testing) +- [Commit Messages](#commit-messages) +- [Reporting Bugs](#reporting-bugs) +- [Suggesting Features](#suggesting-features) + +## Code of Conduct + +This project and everyone participating in it is governed by our [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. + +## Getting Started + +Express Storage is a unified file storage library for Express.js that supports AWS S3, Google Cloud Storage, Azure Blob Storage, and local disk storage. + +Before contributing, please: + +1. Check the [issue tracker](https://github.com/th3hero/express-storage/issues) to see if your issue or idea has already been discussed +2. Read through the [README](README.md) to understand the project +3. Familiarize yourself with the codebase structure + +## Development Setup + +### Prerequisites + +- Node.js >= 16.0.0 +- npm >= 8.0.0 +- Git + +### Installation + +```bash +# Clone the repository +git clone https://github.com/th3hero/express-storage.git +cd express-storage + +# Install dependencies +npm install + +# Build the project +npm run build + +# Run tests +npm test +``` + +### Project Structure + +``` +express-storage/ +├── src/ +│ ├── index.ts # Main exports +│ ├── storage-manager.ts # Core StorageManager class +│ ├── drivers/ # Storage provider implementations +│ │ ├── base.driver.ts # Abstract base class +│ │ ├── local.driver.ts # Local disk storage +│ │ ├── s3.driver.ts # AWS S3 +│ │ ├── gcs.driver.ts # Google Cloud Storage +│ │ └── azure.driver.ts # Azure Blob Storage +│ ├── factory/ +│ │ └── driver.factory.ts # Driver factory with caching +│ ├── types/ +│ │ └── storage.types.ts # TypeScript definitions +│ └── utils/ +│ ├── config.utils.ts # Configuration utilities +│ └── file.utils.ts # File handling utilities +├── tests/ # Test files +├── dist/ # Compiled output (generated) +└── package.json +``` + +### Available Scripts + +| Command | Description | +|---------|-------------| +| `npm run build` | Compile TypeScript to JavaScript | +| `npm run dev` | Watch mode for development | +| `npm run test` | Run tests | +| `npm run test:watch` | Run tests in watch mode | +| `npm run test:coverage` | Run tests with coverage report | +| `npm run lint` | Run ESLint | +| `npm run lint:fix` | Fix linting issues automatically | +| `npm run type-check` | TypeScript type checking | + +## How to Contribute + +### Types of Contributions + +We welcome many types of contributions: + +- **Bug fixes**: Found a bug? We'd love a fix! +- **New features**: Have an idea? Open an issue first to discuss +- **Documentation**: Typos, unclear explanations, missing examples +- **Tests**: More test coverage is always welcome +- **Performance**: Optimizations and benchmarks +- **New storage drivers**: Want to add support for another provider? + +### What We're Looking For + +- **Security improvements**: We take security seriously +- **Better TypeScript types**: More precise type definitions +- **Error handling**: Better error messages and handling +- **Edge cases**: Handling unusual scenarios gracefully + +## Pull Request Process + +1. **Fork the repository** and create your branch from `main` +2. **Make your changes** following our code style +3. **Add tests** for any new functionality +4. **Update documentation** if needed +5. **Run the test suite** and ensure all tests pass +6. **Run linting** and fix any issues +7. **Submit a pull request** + +### PR Checklist + +Before submitting your PR, ensure: + +- [ ] Code compiles without errors (`npm run build`) +- [ ] All tests pass (`npm test`) +- [ ] No linting errors (`npm run lint`) +- [ ] Type checking passes (`npm run type-check`) +- [ ] New code has appropriate test coverage +- [ ] Documentation is updated if needed +- [ ] Commit messages follow our conventions + +## Code Style + +We use ESLint and Prettier to maintain code consistency. + +### Guidelines + +- Use TypeScript for all new code +- Prefer `async/await` over Promise chains +- Use meaningful variable and function names +- Add JSDoc comments for public APIs +- Keep functions focused and small +- Handle errors explicitly + +### Example + +```typescript +/** + * Uploads a file to the configured storage provider. + * @param file - The file to upload + * @param options - Upload options + * @returns Upload result with file URL or error + */ +async uploadFile( + file: Express.Multer.File, + options?: UploadOptions +): Promise { + // Implementation +} +``` + +## Testing + +We use [Vitest](https://vitest.dev/) for testing. + +### Running Tests + +```bash +# Run all tests +npm test + +# Run tests in watch mode +npm run test:watch + +# Run tests with coverage +npm run test:coverage +``` + +### Writing Tests + +- Place test files in the `tests/` directory +- Name test files with `.test.ts` suffix +- Test both success and error cases +- Mock external services (S3, GCS, Azure) + +### Example Test + +```typescript +import { describe, it, expect } from 'vitest'; +import { StorageManager } from '../src'; + +describe('StorageManager', () => { + it('should upload a file successfully', async () => { + const storage = new StorageManager({ driver: 'local' }); + const result = await storage.uploadFile(mockFile); + + expect(result.success).toBe(true); + expect(result.fileName).toBeDefined(); + }); +}); +``` + +## Commit Messages + +We follow [Conventional Commits](https://www.conventionalcommits.org/): + +### Format + +``` +(): + +[optional body] + +[optional footer] +``` + +### Types + +| Type | Description | +|------|-------------| +| `feat` | New feature | +| `fix` | Bug fix | +| `docs` | Documentation only | +| `style` | Code style (formatting, etc.) | +| `refactor` | Code change that neither fixes a bug nor adds a feature | +| `perf` | Performance improvement | +| `test` | Adding or updating tests | +| `chore` | Maintenance tasks | + +### Examples + +``` +feat(s3): add support for S3 Object Lock + +fix(azure): handle connection timeout errors + +docs: add presigned URL examples + +test(local): add edge case tests for path traversal +``` + +## Reporting Bugs + +### Before Reporting + +1. Check if the issue already exists +2. Try the latest version +3. Collect relevant information + +### Bug Report Template + +When reporting a bug, please include: + +- **Description**: Clear description of the bug +- **Steps to reproduce**: How can we reproduce this? +- **Expected behavior**: What should happen? +- **Actual behavior**: What actually happens? +- **Environment**: Node.js version, OS, storage provider +- **Code sample**: Minimal reproduction if possible + +## Suggesting Features + +We love feature suggestions! Please: + +1. **Check existing issues** to avoid duplicates +2. **Open an issue** with the `enhancement` label +3. **Describe the use case**: Why is this feature needed? +4. **Propose a solution**: How might this work? + +### Feature Request Template + +- **Problem**: What problem does this solve? +- **Solution**: How would you like it to work? +- **Alternatives**: Any alternative solutions considered? +- **Context**: Any additional context or screenshots? + +## Questions? + +Feel free to open an issue with the `question` label or reach out to the maintainer: + +- **Author**: Alok Kumar ([@th3hero](https://github.com/th3hero)) +- **Issues**: [GitHub Issues](https://github.com/th3hero/express-storage/issues) + +--- + +Thank you for contributing to Express Storage! diff --git a/README.md b/README.md index 17d9fdd..3c7b142 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,12 @@ Stop writing separate upload code for every storage provider. Express Storage gives you a single, secure interface that works with AWS S3, Google Cloud Storage, Azure Blob Storage, and local disk. Switch providers by changing one environment variable. No code changes required. [![npm version](https://img.shields.io/npm/v/express-storage.svg)](https://www.npmjs.com/package/express-storage) +[![npm downloads](https://img.shields.io/npm/dm/express-storage.svg)](https://www.npmjs.com/package/express-storage) +[![npm bundle size](https://img.shields.io/bundlephobia/minzip/express-storage)](https://bundlephobia.com/package/express-storage) [![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue.svg)](https://www.typescriptlang.org/) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) +[![Node.js Version](https://img.shields.io/node/v/express-storage)](https://nodejs.org) +[![GitHub stars](https://img.shields.io/github/stars/th3hero/express-storage?style=social)](https://github.com/th3hero/express-storage) --- diff --git a/docs/articles/dev-to-article-1.md b/docs/articles/dev-to-article-1.md new file mode 100644 index 0000000..367e796 --- /dev/null +++ b/docs/articles/dev-to-article-1.md @@ -0,0 +1,232 @@ +--- +title: Stop Writing Separate Upload Code for Every Cloud Provider +published: false +description: How I built a unified storage layer for Express.js that works with S3, GCS, Azure, and local disk — and why you should stop copy-pasting cloud SDK code. +tags: javascript, nodejs, typescript, webdev +cover_image: +canonical_url: +--- + +# Stop Writing Separate Upload Code for Every Cloud Provider + +Every Express.js application needs file uploads. And every developer makes the same mistakes. + +You start simple: local storage with multer. Files go to `/uploads`. Ship it. + +Then production hits. "We need S3 for scalability." So you install `multer-s3`, rewrite your upload logic, and spend a day debugging IAM permissions. + +Six months later: "We're migrating to Azure." Another rewrite. Another day of debugging. + +Sound familiar? + +## The Problem with Provider-Specific Code + +Here's what typical Express file upload code looks like when you support multiple providers: + +```javascript +// AWS S3 +const s3Storage = multerS3({ + s3: new S3Client({ region: 'us-east-1' }), + bucket: 'my-bucket', + key: (req, file, cb) => cb(null, `uploads/${Date.now()}_${file.originalname}`) +}); + +// Google Cloud Storage +const gcsStorage = new MulterGCS({ + bucket: 'my-bucket', + projectId: 'my-project', + filename: (req, file, cb) => cb(null, `uploads/${Date.now()}_${file.originalname}`) +}); + +// Azure Blob Storage +const azureStorage = new MulterAzure({ + connectionString: process.env.AZURE_CONNECTION, + container: 'my-container' +}); + +// Local +const localStorage = multer.diskStorage({ + destination: './uploads', + filename: (req, file, cb) => cb(null, `${Date.now()}_${file.originalname}`) +}); + +// Then you need logic to switch between them... +const upload = multer({ + storage: process.env.STORAGE === 's3' ? s3Storage : + process.env.STORAGE === 'gcs' ? gcsStorage : + process.env.STORAGE === 'azure' ? azureStorage : + localStorage +}); +``` + +This approach has problems: + +1. **Duplicated logic** — Filename generation, validation, error handling repeated 4 times +2. **Inconsistent APIs** — Each package has different options and return values +3. **Security gaps** — Did you add path traversal protection to all four? +4. **Testing nightmare** — Need to mock 4 different SDKs + +## A Better Approach: Unified Storage + +What if you could write upload code once and deploy anywhere? + +```typescript +import { StorageManager } from 'express-storage'; + +const storage = new StorageManager(); // Uses FILE_DRIVER env var + +app.post('/upload', upload.single('file'), async (req, res) => { + const result = await storage.uploadFile(req.file, { + maxSize: 10 * 1024 * 1024, + allowedMimeTypes: ['image/jpeg', 'image/png', 'application/pdf'] + }); + + if (result.success) { + res.json({ url: result.fileUrl }); + } else { + res.status(400).json({ error: result.error }); + } +}); +``` + +Switch providers by changing one environment variable: + +```env +# Development +FILE_DRIVER=local + +# Production (AWS) +FILE_DRIVER=s3 + +# Or Google Cloud +FILE_DRIVER=gcs + +# Or Azure +FILE_DRIVER=azure +``` + +**Same code. Any cloud. Zero changes.** + +## Security You Don't Have to Think About + +File uploads are one of the most exploited attack vectors. When you're juggling four different upload implementations, security often slips. + +### Path Traversal Protection + +Attackers love filenames like `../../../etc/passwd`. A unified storage layer can block this everywhere: + +```typescript +// These are automatically rejected +"../secret.txt" // Path traversal +"..\\config.json" // Windows path traversal +"file\0.txt" // Null byte injection +``` + +### Automatic Filename Sanitization + +User-provided filenames are dangerous. Transform them into safe identifiers: + +``` +User uploads: "My Photo (1).jpg" +Stored as: "1706123456789_a1b2c3d4e5_my_photo_1_.jpg" +``` + +The format `{timestamp}_{random}_{sanitized}` prevents collisions and removes dangerous characters. + +### Consistent Validation + +Same rules, every provider: + +```typescript +await storage.uploadFile(file, { + maxSize: 5 * 1024 * 1024, // 5MB limit + allowedMimeTypes: ['image/jpeg'], // MIME type check + allowedExtensions: ['.jpg', '.jpeg'] // Extension check +}); +``` + +## Presigned URLs: The Right Way + +Large files shouldn't flow through your server. Presigned URLs let clients upload directly to cloud storage. + +But here's the catch: **every provider handles them differently.** + +- **S3 and GCS**: Enforce content-type and size at the URL level +- **Azure**: No URL-level enforcement — must validate after upload + +A good abstraction handles this for you: + +```typescript +// Generate upload URL (works the same for all providers) +const { uploadUrl, reference } = await storage.generateUploadUrl( + 'video.mp4', + 'video/mp4', + 100 * 1024 * 1024 // 100MB +); + +// Client uploads directly to cloud storage +// Your server never touches the bytes + +// Validate and confirm (handles Azure quirks automatically) +const result = await storage.validateAndConfirmUpload(reference, { + expectedContentType: 'video/mp4' +}); +``` + +## When to Use This Approach + +**Good fit:** +- Apps that might change cloud providers +- Teams working across AWS, GCP, and Azure projects +- Applications where security is critical +- Startups that don't want to rewrite upload code as they scale + +**Maybe not:** +- Single-cloud shops with no migration plans +- Apps needing very provider-specific features (S3 Object Lock, etc.) +- Performance-critical systems where abstraction overhead matters + +## Getting Started + +```bash +npm install express-storage +``` + +```typescript +import express from 'express'; +import multer from 'multer'; +import { StorageManager } from 'express-storage'; + +const app = express(); +const upload = multer(); +const storage = new StorageManager(); + +app.post('/upload', upload.single('file'), async (req, res) => { + const result = await storage.uploadFile(req.file); + res.json(result); +}); +``` + +Configure with environment variables: + +```env +FILE_DRIVER=s3 +BUCKET_NAME=my-bucket +AWS_REGION=us-east-1 +``` + +That's it. No rewriting when you switch clouds. + +--- + +## What's Next? + +I built [express-storage](https://github.com/th3hero/express-storage) after getting frustrated with maintaining separate upload code for different clients — some on AWS, some on Azure, some on GCP. + +The goal was simple: **write upload code once, deploy anywhere, with security built in.** + +Check it out on [GitHub](https://github.com/th3hero/express-storage) or [npm](https://www.npmjs.com/package/express-storage). Contributions welcome! + +--- + +*Have you dealt with multi-cloud storage in your apps? What's your approach? Let me know in the comments.* diff --git a/docs/articles/show-hn-post.md b/docs/articles/show-hn-post.md new file mode 100644 index 0000000..e782a2c --- /dev/null +++ b/docs/articles/show-hn-post.md @@ -0,0 +1,89 @@ +# Show HN Post Draft + +## Title Options (pick one) + +**Option 1 (Recommended):** +> Show HN: Express Storage – One API for S3, GCS, Azure, and Local Disk + +**Option 2:** +> Show HN: Unified file uploads for Express.js – switch cloud providers without code changes + +**Option 3:** +> Show HN: I built a storage abstraction layer for Express after rewriting upload code too many times + +--- + +## Post Body + +I got tired of rewriting file upload code every time a project switched cloud providers. + +Most Express apps start with local storage, then move to S3, and some eventually need to support GCS or Azure for different clients. Each migration meant: + +- Learning a new SDK +- Rewriting upload/download logic +- Re-implementing security checks (path traversal, filename sanitization) +- Updating presigned URL handling (which works differently on each provider) + +So I built express-storage: a unified storage layer that lets you write upload code once and deploy to any cloud. + +**Key features:** + +- Single API for AWS S3, Google Cloud Storage, Azure Blob Storage, and local disk +- Switch providers by changing one env var (`FILE_DRIVER=s3` → `FILE_DRIVER=azure`) +- Security built-in: path traversal prevention, filename sanitization, file validation +- Presigned URLs that handle provider quirks (Azure needs post-upload validation, S3/GCS don't) +- TypeScript native with full type safety +- Large file handling with automatic streaming for files >100MB + +**Example:** + +```typescript +const storage = new StorageManager(); // reads FILE_DRIVER from env + +const result = await storage.uploadFile(req.file, { + maxSize: 10 * 1024 * 1024, + allowedMimeTypes: ['image/jpeg', 'image/png'] +}); +``` + +Same code works whether `FILE_DRIVER` is `local`, `s3`, `gcs`, or `azure`. + +GitHub: https://github.com/th3hero/express-storage +npm: https://www.npmjs.com/package/express-storage + +Would love feedback on: +- The API design — is it intuitive? +- Security approach — anything I'm missing? +- Use cases you'd want supported + +--- + +## Tips for Posting + +1. **Best time to post**: Tuesday-Thursday, 8-10 AM EST +2. **Engage quickly**: Be ready to answer comments within the first hour +3. **Be honest about limitations**: HN appreciates candor +4. **Don't be promotional**: Focus on the technical problem/solution +5. **Ask for specific feedback**: Shows you value the community's input + +## Potential Questions to Prepare For + +**Q: Why not just use the cloud SDKs directly?** +A: You can! This is for teams who want consistent code across providers, or apps that might migrate. The abstraction adds minimal overhead. + +**Q: What's the performance overhead?** +A: Minimal — it's mostly a thin wrapper. For presigned URLs, there's essentially zero overhead since clients upload directly. + +**Q: Why not use an existing solution like pkgcloud?** +A: pkgcloud is great but hasn't been actively maintained. express-storage is TypeScript-native, has modern presigned URL support, and focuses on security. + +**Q: Does it support [specific S3 feature]?** +A: The abstraction covers common operations. For provider-specific features like S3 Object Lock, you'd use the SDK directly. + +--- + +## Alternative: Reddit Post (r/node or r/javascript) + +**Title:** I built a unified storage layer for Express.js after getting frustrated with cloud provider migrations + +**Body:** (Same as HN but slightly more casual tone, can include a few more emojis if the subreddit culture supports it) diff --git a/example.ts b/example.ts deleted file mode 100644 index 038b7e7..0000000 --- a/example.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { - StorageManager, - StorageDriver, - FileValidationOptions, -} from "./src/index.js"; - -/** - * Parse environment variable as integer with fallback - * Returns undefined if value is not a valid number (lets StorageManager use defaults) - */ -function parseEnvInt( - value: string | undefined, - fallback?: number, -): number | undefined { - if (!value) return fallback; - const parsed = parseInt(value, 10); - if (Number.isNaN(parsed)) { - console.warn( - `Warning: Invalid numeric value "${value}", using default`, - ); - return fallback; - } - return parsed; -} - -/** - * Example: Initialize storage with environment variables - * This allows switching storage providers without code changes - */ -const storage = new StorageManager({ - driver: (process.env["FILE_DRIVER"] as StorageDriver) || "local", - credentials: { - // Common - bucketName: process.env["BUCKET_NAME"], - localPath: process.env["LOCAL_PATH"] || "public/express-storage", - presignedUrlExpiry: parseEnvInt( - process.env["PRESIGNED_URL_EXPIRY"], - 600, - ), - - // AWS S3 - awsRegion: process.env["AWS_REGION"], - awsAccessKey: process.env["AWS_ACCESS_KEY"], - awsSecretKey: process.env["AWS_SECRET_KEY"], - - // Google Cloud Storage - gcsProjectId: process.env["GCS_PROJECT_ID"], - gcsCredentials: process.env["GCS_CREDENTIALS"], - - // Azure Blob Storage - azureConnectionString: process.env["AZURE_CONNECTION_STRING"], - azureAccountName: process.env["AZURE_ACCOUNT_NAME"], - azureAccountKey: process.env["AZURE_ACCOUNT_KEY"], - azureContainerName: process.env["BUCKET_NAME"], - }, -}); - -/** - * Example: Upload a file with validation - */ -async function uploadWithValidation(file: Express.Multer.File) { - const validation: FileValidationOptions = { - maxSize: 5 * 1024 * 1024, // 5MB - allowedMimeTypes: ["image/jpeg", "image/png", "image/gif"], - allowedExtensions: [".jpg", ".jpeg", ".png", ".gif"], - }; - - const result = await storage.uploadFile(file, validation); - - if (result.success) { - console.log("File uploaded:", result.fileName, result.fileUrl); - } else { - console.error("Upload failed:", result.error); - } - - return result; -} - -/** - * Example: Generate presigned URL for frontend upload - * The URL can have content type constraints - */ -async function getPresignedUploadUrl(fileName: string) { - const result = await storage.generateUploadUrl( - fileName, - "image/jpeg", // Content type constraint - 5 * 1024 * 1024, // Max size hint (for client-side validation) - ); - - if (result.success) { - console.log("Presigned URL generated:", { - uploadUrl: result.uploadUrl, - fileName: result.fileName, - contentType: result.contentType, - fileSize: result.fileSize, - expiresIn: result.expiresIn, - }); - } - - return result; -} - -/** - * Example: Generate view URL for private files - */ -async function getViewUrl(fileName: string) { - const result = await storage.generateViewUrl(fileName); - - if (result.success) { - console.log("View URL:", result.viewUrl); - } - - return result; -} - -/** - * Example: Delete a file - */ -async function removeFile(fileName: string) { - const deleted = await storage.deleteFile(fileName); - console.log("File deleted:", deleted); - return deleted; -} - -// Export for use in Express routes -export { - storage, - uploadWithValidation, - getPresignedUploadUrl, - getViewUrl, - removeFile, -}; diff --git a/package.json b/package.json index 7e76640..64cfbbf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "express-storage", "version": "1.1.4", - "description": "Secure, unified file upload and cloud storage management for Express.js. One API for AWS S3, Google Cloud Storage, Azure Blob Storage, and local disk. Built-in security with path traversal prevention, file validation, presigned URLs, and automatic filename sanitization. Switch storage providers with zero code changes — just update your environment variables.", + "description": "One API for all cloud storage: AWS S3, Google Cloud, Azure & local disk. Secure Express.js file uploads with presigned URLs, validation, and zero-config provider switching. Built-in path traversal prevention, file validation, and automatic filename sanitization.", "homepage": "https://github.com/th3hero/express-storage#readme", "bugs": { "url": "https://github.com/th3hero/express-storage/issues" @@ -58,7 +58,23 @@ "multi-cloud", "hybrid-cloud", "file-security", - "upload-security" + "upload-security", + "multipart-upload", + "file-upload-middleware", + "express-middleware", + "multer-alternative", + "unified-storage", + "storage-abstraction", + "file-streaming", + "resumable-upload", + "cloud-agnostic", + "provider-agnostic", + "s3-upload", + "gcs-upload", + "azure-upload", + "bucket-storage", + "multer-s3-alternative", + "cloud-file-storage" ], "scripts": { "build": "tsc", diff --git a/src/drivers/azure.driver.ts b/src/drivers/azure.driver.ts index eb93704..5ac83fa 100644 --- a/src/drivers/azure.driver.ts +++ b/src/drivers/azure.driver.ts @@ -165,7 +165,15 @@ export class AzureStorageDriver extends BaseStorageDriver { */ async generateUploadUrl(fileName: string, contentType?: string, fileSize?: number): Promise { // Security: Defense-in-depth validation (StorageManager also validates) - if (fileName.includes('..') || fileName.includes('\0')) { + // Decode URL-encoded characters first to catch encoded traversal attempts like %2e%2e%2f + let decodedFileName: string; + try { + decodedFileName = decodeURIComponent(fileName); + } catch { + return this.createPresignedErrorResult('Invalid fileName: malformed URL encoding'); + } + + if (decodedFileName.includes('..') || decodedFileName.includes('\0')) { return this.createPresignedErrorResult('Invalid fileName: path traversal sequences are not allowed'); } @@ -186,7 +194,7 @@ export class AzureStorageDriver extends BaseStorageDriver { ); } - const blockBlobClient = this.containerClient.getBlockBlobClient(fileName); + const blockBlobClient = this.containerClient.getBlockBlobClient(decodedFileName); const expiresOn = new Date(Date.now() + (this.getPresignedUrlExpiry() * 1000)); const resolvedContentType = contentType || 'application/octet-stream'; @@ -198,7 +206,7 @@ export class AzureStorageDriver extends BaseStorageDriver { contentType: string; } = { containerName: this.containerName, - blobName: fileName, + blobName: decodedFileName, permissions: BlobSASPermissions.parse('cw'), expiresOn, contentType: resolvedContentType, @@ -224,7 +232,15 @@ export class AzureStorageDriver extends BaseStorageDriver { */ async generateViewUrl(fileName: string): Promise { // Security: Defense-in-depth validation - if (fileName.includes('..') || fileName.includes('\0')) { + // Decode URL-encoded characters first to catch encoded traversal attempts like %2e%2e%2f + let decodedFileName: string; + try { + decodedFileName = decodeURIComponent(fileName); + } catch { + return this.createPresignedErrorResult('Invalid fileName: malformed URL encoding'); + } + + if (decodedFileName.includes('..') || decodedFileName.includes('\0')) { return this.createPresignedErrorResult('Invalid fileName: path traversal sequences are not allowed'); } @@ -235,13 +251,13 @@ export class AzureStorageDriver extends BaseStorageDriver { ); } - const blockBlobClient = this.containerClient.getBlockBlobClient(fileName); + const blockBlobClient = this.containerClient.getBlockBlobClient(decodedFileName); const expiresOn = new Date(Date.now() + (this.getPresignedUrlExpiry() * 1000)); const sasToken = generateBlobSASQueryParameters( { containerName: this.containerName, - blobName: fileName, + blobName: decodedFileName, permissions: BlobSASPermissions.parse('r'), expiresOn, }, @@ -264,11 +280,19 @@ export class AzureStorageDriver extends BaseStorageDriver { */ async delete(fileName: string): Promise { // Security: Defense-in-depth validation - if (fileName.includes('..') || fileName.includes('\0')) { + // Decode URL-encoded characters first to catch encoded traversal attempts like %2e%2e%2f + let decodedFileName: string; + try { + decodedFileName = decodeURIComponent(fileName); + } catch { + return false; + } + + if (decodedFileName.includes('..') || decodedFileName.includes('\0')) { return false; } - const blockBlobClient = this.containerClient.getBlockBlobClient(fileName); + const blockBlobClient = this.containerClient.getBlockBlobClient(decodedFileName); const exists = await blockBlobClient.exists(); if (!exists) { diff --git a/src/drivers/gcs.driver.ts b/src/drivers/gcs.driver.ts index 3eeb966..4ab23b2 100644 --- a/src/drivers/gcs.driver.ts +++ b/src/drivers/gcs.driver.ts @@ -112,6 +112,10 @@ export class GCSStorageDriver extends BaseStorageDriver { resumable: true, // Enable resumable uploads for reliability }); + // Handle errors from the source stream (fileStream) explicitly + // to ensure they propagate and don't get silently dropped + fileStream.on('error', reject); + fileStream .pipe(writeStream) .on('error', reject) @@ -130,12 +134,20 @@ export class GCSStorageDriver extends BaseStorageDriver { */ async generateUploadUrl(fileName: string, contentType?: string, fileSize?: number): Promise { // Security: Defense-in-depth validation (StorageManager also validates) - if (fileName.includes('..') || fileName.includes('\0')) { + // Decode URL-encoded characters first to catch encoded traversal attempts like %2e%2e%2f + let decodedFileName: string; + try { + decodedFileName = decodeURIComponent(fileName); + } catch { + return this.createPresignedErrorResult('Invalid fileName: malformed URL encoding'); + } + + if (decodedFileName.includes('..') || decodedFileName.includes('\0')) { return this.createPresignedErrorResult('Invalid fileName: path traversal sequences are not allowed'); } try { - const gcsFile = this.bucket.file(fileName); + const gcsFile = this.bucket.file(decodedFileName); const resolvedContentType = contentType || 'application/octet-stream'; const expiresOn = new Date(Date.now() + (this.getPresignedUrlExpiry() * 1000)); @@ -174,12 +186,20 @@ export class GCSStorageDriver extends BaseStorageDriver { */ async generateViewUrl(fileName: string): Promise { // Security: Defense-in-depth validation - if (fileName.includes('..') || fileName.includes('\0')) { + // Decode URL-encoded characters first to catch encoded traversal attempts like %2e%2e%2f + let decodedFileName: string; + try { + decodedFileName = decodeURIComponent(fileName); + } catch { + return this.createPresignedErrorResult('Invalid fileName: malformed URL encoding'); + } + + if (decodedFileName.includes('..') || decodedFileName.includes('\0')) { return this.createPresignedErrorResult('Invalid fileName: path traversal sequences are not allowed'); } try { - const gcsFile = this.bucket.file(fileName); + const gcsFile = this.bucket.file(decodedFileName); const expiresOn = new Date(Date.now() + (this.getPresignedUrlExpiry() * 1000)); const [viewUrl] = await gcsFile.getSignedUrl({ @@ -202,11 +222,19 @@ export class GCSStorageDriver extends BaseStorageDriver { */ async delete(fileName: string): Promise { // Security: Defense-in-depth validation - if (fileName.includes('..') || fileName.includes('\0')) { + // Decode URL-encoded characters first to catch encoded traversal attempts like %2e%2e%2f + let decodedFileName: string; + try { + decodedFileName = decodeURIComponent(fileName); + } catch { + return false; + } + + if (decodedFileName.includes('..') || decodedFileName.includes('\0')) { return false; } - const gcsFile = this.bucket.file(fileName); + const gcsFile = this.bucket.file(decodedFileName); const [exists] = await gcsFile.exists(); if (!exists) { diff --git a/src/drivers/local.driver.ts b/src/drivers/local.driver.ts index 7ca6523..980ce28 100644 --- a/src/drivers/local.driver.ts +++ b/src/drivers/local.driver.ts @@ -422,12 +422,20 @@ export class LocalStorageDriver extends BaseStorageDriver { */ async delete(reference: string): Promise { try { - if (reference.includes('..') || reference.includes('\0')) { + // Decode URL-encoded characters first to catch encoded traversal attempts like %2e%2e%2f + let decodedReference: string; + try { + decodedReference = decodeURIComponent(reference); + } catch { + return false; + } + + if (decodedReference.includes('..') || decodedReference.includes('\0')) { return false; } const baseDir = path.resolve(this.basePath); - const targetPath = path.join(baseDir, reference); + const targetPath = path.join(baseDir, decodedReference); const resolvedPath = path.resolve(targetPath); // Make sure we're not escaping the base directory @@ -468,11 +476,19 @@ export class LocalStorageDriver extends BaseStorageDriver { private resolveFilePath(reference: string): string | null { const baseDir = path.resolve(this.basePath); - if (reference.includes('..') || reference.includes('\0')) { + // Decode URL-encoded characters first to catch encoded traversal attempts like %2e%2e%2f + let decodedReference: string; + try { + decodedReference = decodeURIComponent(reference); + } catch { + return null; + } + + if (decodedReference.includes('..') || decodedReference.includes('\0')) { return null; } - const directPath = path.join(baseDir, reference); + const directPath = path.join(baseDir, decodedReference); const resolvedPath = path.resolve(directPath); if (!resolvedPath.startsWith(baseDir + path.sep) && resolvedPath !== baseDir) { @@ -509,7 +525,20 @@ export class LocalStorageDriver extends BaseStorageDriver { continuationToken?: string ): Promise { try { - if (prefix && (prefix.includes('..') || prefix.includes('\0'))) { + // Decode URL-encoded characters first to catch encoded traversal attempts like %2e%2e%2f + let decodedPrefix: string | undefined; + if (prefix) { + try { + decodedPrefix = decodeURIComponent(prefix); + } catch { + return { + success: false, + error: 'Invalid prefix: malformed URL encoding', + }; + } + } + + if (decodedPrefix && (decodedPrefix.includes('..') || decodedPrefix.includes('\0'))) { return { success: false, error: 'Invalid prefix: path traversal sequences are not allowed', @@ -537,6 +566,9 @@ export class LocalStorageDriver extends BaseStorageDriver { // We collect a bit more than needed for accurate hasMore detection const MAX_COLLECT = validatedMaxResults + 1; + // Use decoded prefix for file matching + const effectivePrefix = decodedPrefix; + // Skip directories that can't possibly contain matching files const couldContainPrefix = (dirRelativePath: string, targetPrefix: string): boolean => { if (!targetPrefix) return true; @@ -565,7 +597,7 @@ export class LocalStorageDriver extends BaseStorageDriver { return false; } - if (prefix && !couldContainPrefix(dirRelativePath, prefix)) { + if (effectivePrefix && !couldContainPrefix(dirRelativePath, effectivePrefix)) { return true; } @@ -620,7 +652,7 @@ export class LocalStorageDriver extends BaseStorageDriver { return false; } } else if (stat.isFile()) { - if (prefix && !relativePath.startsWith(prefix)) { + if (effectivePrefix && !relativePath.startsWith(effectivePrefix)) { continue; } diff --git a/src/drivers/s3.driver.ts b/src/drivers/s3.driver.ts index 45ccba2..f3c0150 100644 --- a/src/drivers/s3.driver.ts +++ b/src/drivers/s3.driver.ts @@ -184,7 +184,15 @@ export class S3StorageDriver extends BaseStorageDriver { */ async generateUploadUrl(fileName: string, contentType?: string, fileSize?: number): Promise { // Security: Defense-in-depth validation (StorageManager also validates) - if (fileName.includes('..') || fileName.includes('\0')) { + // Decode URL-encoded characters first to catch encoded traversal attempts like %2e%2e%2f + let decodedFileName: string; + try { + decodedFileName = decodeURIComponent(fileName); + } catch { + return this.createPresignedErrorResult('Invalid fileName: malformed URL encoding'); + } + + if (decodedFileName.includes('..') || decodedFileName.includes('\0')) { return this.createPresignedErrorResult('Invalid fileName: path traversal sequences are not allowed'); } @@ -198,7 +206,7 @@ export class S3StorageDriver extends BaseStorageDriver { ContentLength?: number; } = { Bucket: this.bucketName, - Key: fileName, + Key: decodedFileName, ContentType: resolvedContentType, }; @@ -226,14 +234,22 @@ export class S3StorageDriver extends BaseStorageDriver { */ async generateViewUrl(fileName: string): Promise { // Security: Defense-in-depth validation - if (fileName.includes('..') || fileName.includes('\0')) { + // Decode URL-encoded characters first to catch encoded traversal attempts like %2e%2e%2f + let decodedFileName: string; + try { + decodedFileName = decodeURIComponent(fileName); + } catch { + return this.createPresignedErrorResult('Invalid fileName: malformed URL encoding'); + } + + if (decodedFileName.includes('..') || decodedFileName.includes('\0')) { return this.createPresignedErrorResult('Invalid fileName: path traversal sequences are not allowed'); } try { const getCommand = new GetObjectCommand({ Bucket: this.bucketName, - Key: fileName, + Key: decodedFileName, }); const viewUrl = await getSignedUrl(this.s3Client, getCommand, { @@ -254,13 +270,21 @@ export class S3StorageDriver extends BaseStorageDriver { */ async delete(fileName: string): Promise { // Security: Defense-in-depth validation - if (fileName.includes('..') || fileName.includes('\0')) { + // Decode URL-encoded characters first to catch encoded traversal attempts like %2e%2e%2f + let decodedFileName: string; + try { + decodedFileName = decodeURIComponent(fileName); + } catch { + return false; + } + + if (decodedFileName.includes('..') || decodedFileName.includes('\0')) { return false; } const headCommand = new HeadObjectCommand({ Bucket: this.bucketName, - Key: fileName, + Key: decodedFileName, }); try { @@ -277,7 +301,7 @@ export class S3StorageDriver extends BaseStorageDriver { const deleteCommand = new DeleteObjectCommand({ Bucket: this.bucketName, - Key: fileName, + Key: decodedFileName, }); await this.s3Client.send(deleteCommand); diff --git a/tests/config.utils.test.ts b/tests/config.utils.test.ts index 6af11a5..3a47aec 100644 --- a/tests/config.utils.test.ts +++ b/tests/config.utils.test.ts @@ -4,7 +4,7 @@ * Tests for config loading, validation, and environment handling. */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { loadEnvironmentConfig, environmentToStorageConfig, diff --git a/tests/file.utils.test.ts b/tests/file.utils.test.ts index 7ce0bb4..91fdb1e 100644 --- a/tests/file.utils.test.ts +++ b/tests/file.utils.test.ts @@ -5,7 +5,7 @@ * Covers: filename generation, sanitization, validation, formatting, retry logic, concurrency */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import { generateUniqueFileName, sanitizeFileName, diff --git a/tests/regression.test.ts b/tests/regression.test.ts index 405385d..e41d210 100644 --- a/tests/regression.test.ts +++ b/tests/regression.test.ts @@ -23,7 +23,6 @@ import { import { createMockFile, createMockJpegFile, - createLargeMockFile, } from './fixtures/test-helpers.js'; const TEST_DIR = path.join(process.cwd(), 'test-regression'); diff --git a/tests/security.test.ts b/tests/security.test.ts index c377f33..cffce16 100644 --- a/tests/security.test.ts +++ b/tests/security.test.ts @@ -22,7 +22,6 @@ import { generateUniqueFileName, } from '../src/utils/file.utils.js'; import { - createMockFile, createMockJpegFile, createMockExeFile, PATH_TRAVERSAL_CASES, diff --git a/tests/storage-manager.test.ts b/tests/storage-manager.test.ts index c356876..6999b87 100644 --- a/tests/storage-manager.test.ts +++ b/tests/storage-manager.test.ts @@ -5,7 +5,7 @@ * Covers: initialization, uploads, presigned URLs, validation, deletion, listing */ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import fs from 'fs'; import path from 'path'; import { StorageManager } from '../src/storage-manager.js'; @@ -17,7 +17,6 @@ import { createMockPngFile, createMockPdfFile, createEmptyMockFile, - createLargeMockFile, PATH_TRAVERSAL_CASES, } from './fixtures/test-helpers.js'; @@ -108,7 +107,7 @@ describe('StorageManager', () => { error: (msg: string) => logs.push(`ERROR: ${msg}`), }; - const storage = new StorageManager({ + new StorageManager({ driver: 'local', credentials: { localPath: TEST_UPLOAD_DIR }, logger, @@ -918,11 +917,11 @@ describe('StorageManager', () => { describe('Driver Factory Caching', () => { it('should cache drivers', () => { - const storage1 = new StorageManager({ + new StorageManager({ driver: 'local', credentials: { localPath: TEST_UPLOAD_DIR }, }); - const storage2 = new StorageManager({ + new StorageManager({ driver: 'local', credentials: { localPath: TEST_UPLOAD_DIR }, });