├── .gitignore ├── package.json ├── env.sample ├── LICENSE ├── README.md └── app.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | uploads 3 | ssl 4 | package-lock.json 5 | .env -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nodecdn", 3 | "version": "1.0.0", 4 | "description": "cdn precisely aimed for uploading content with screenshot basic", 5 | "main": "app.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/MatinHQ/nodecdn.git" 12 | }, 13 | "author": "MatinHQ", 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/MatinHQ/nodecdn/issues" 17 | }, 18 | "homepage": "https://github.com/MatinHQ/nodecdn#readme", 19 | "dependencies": { 20 | "dotenv": "^16.4.5", 21 | "express": "^4.21.1", 22 | "multer": "^1.4.5-lts.1", 23 | "node-cron": "^3.0.3" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /env.sample: -------------------------------------------------------------------------------- 1 | DOMAIN=test.com 2 | PORT=8080 3 | CACHE_TIME=1800 #Time to cache serving files (in seconds) 4 | EXPIRATION_DAYS=0 #Set it 0 to disable (how long files should presist) 5 | ALLOWED_EXTENSIONS=.png,.jpg,.jpeg,.gif,.bmp,.webp,.mp4,.mkv,.mov,.avi,.flv,.wmv,.webm,.mpg,.mpeg,.3gp,.m4v,.mp3,.wav,.aac,.flac,.ogg,.wma,.m4a # Allowed extensions seprated by comma 6 | DISCORD_SCHEMA=true #Whether to return a Discord-compatible JSON response for uploads (Leave it true if you were using discord webhooks to upload) 7 | 8 | MAX_FILENAME_LENGTH = 50 # Max file name length 9 | MAX_FILE_SIZE=1048576 # Default max file size in bytes (e.g., 1MB) 10 | MAX_FILE_SIZE_IMAGE=3145728 # Image max file size in bytes (e.g., 3MB) 11 | MAX_FILE_SIZE_VIDEO=20971520 # Video max file size in bytes (e.g., 20MB) 12 | MAX_FILE_SIZE_AUDIO=3145728 # Audio max file size in bytes (e.g., 3MB) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Matin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Secure File Upload Server 3 | 4 | This is a simple Node.js server built with Express and Multer that handles file uploads over HTTPS. It ensures that files are stored securely and supports file extension validation, file size limits, and automatic file expiration. The server also integrates SSL for secure connections and can be easily configured through environment variables. 5 | 6 | This server aims to be compatible with the screenshot-basic resource in FiveM, allowing for easy integration with the FiveM framework for automatic file uploads. 7 | 8 | ## Features 9 | - **HTTPS support** for secure connections. 10 | - **File upload with Multer**. 11 | - **Cache serving files** (can be customized via `.env`). 12 | - **File extension validation** (can be customized via `.env`). 13 | - **Dynamic File size limit based on file extension** (can be customized via `.env`). 14 | - **Automatic file expiration** (files are deleted after a specified period). 15 | - **Dynamic file names** with random numbers to prevent conflicts. 16 | - **Upload folder creation** if it doesn't exist. 17 | - **SSL certificate handling** with optional chain certificate. 18 | - **File URL generation** for easy access to uploaded files. 19 | - **Redirect URL** Redirect URL if client is not requsting with correct URL. 20 | 21 | ## Prerequisites 22 | - Node.js installed on your machine. 23 | - SSL certificates (private key, certificate, and optional CA chain) stored in a folder named `ssl`. 24 | 25 | ## Getting Started 26 | 27 | ### 1. Clone the repository 28 | 29 | ```bash 30 | git clone https://github.com/MatinHQ/nodecdn.git 31 | ``` 32 | 33 | ### 2. Install dependencies 34 | 35 | ```bash 36 | npm install 37 | ``` 38 | 39 | ### 3. Set up your `.env` file 40 | 41 | Rename `.env.sample` file to `.env` in the root of your project and configure the environment variables 42 | 43 | ```env 44 | DOMAIN=test.com 45 | PORT=8080 46 | CACHE_TIME=1800 #Time to cache serving files (in seconds) 47 | EXPIRATION_DAYS=0 #Set it 0 to disable (how long files should presist) 48 | ALLOWED_EXTENSIONS=.png,.jpg,.jpeg,.gif,.bmp,.webp,.mp4,.mkv,.mov,.avi,.flv,.wmv,.webm,.mpg,.mpeg,.3gp,.m4v,.mp3,.wav,.aac,.flac,.ogg,.wma,.m4a # Allowed extensions seprated by comma 49 | DISCORD_SCHEMA=true #Whether to return a Discord-compatible JSON response for uploads (Leave it true if you were using discord webhooks to upload) 50 | 51 | MAX_FILENAME_LENGTH = 50 # Max file name length 52 | MAX_FILE_SIZE=1048576 # Default max file size in bytes (e.g., 1MB) 53 | MAX_FILE_SIZE_IMAGE=3145728 # Image max file size in bytes (e.g., 3MB) 54 | MAX_FILE_SIZE_VIDEO=20971520 # Video max file size in bytes (e.g., 20MB) 55 | MAX_FILE_SIZE_AUDIO=3145728 # Audio max file size in bytes (e.g., 3MB) 56 | ``` 57 | 58 | ### 4. Place your SSL certificates in the `ssl` folder 59 | 60 | Make sure your `ssl` folder contains the following files: 61 | - `key.pem` (Private key) 62 | - `cert.pem` (SSL certificate) 63 | - `chain.pem` (CA chain certificate) (OPTIONAL) 64 | 65 | If the `ssl` folder does not exist, the server will not start. 66 | 67 | ### 5. Run the server 68 | 69 | Start the server using the following command: 70 | 71 | ```bash 72 | node app.js 73 | ``` 74 | 75 | The server will be available over HTTPS at `https://:`, where `` is the port defined in your `.env` file. 76 | 77 | ### 6. Uploading Files 78 | 79 | To upload files, send a `POST` request to the `/upload` endpoint with the file attached under the `files[]` field. 80 | 81 | Example using **cURL**: 82 | 83 | ```bash 84 | curl -X POST -F "files[]=@/path/to/your/filename.ext" https://:/upload 85 | ``` 86 | 87 | And for replacing discord webhooks with this simply just replace it with `https://:/upload` 88 | 89 | If the upload is successful, you will receive a URL pointing to the uploaded file, either as a direct URL or as a Discord-compatible response, depending on the `DISCORD_SCHEMA` setting in your `.env`. 90 | 91 | ### 7. File Expiration 92 | 93 | Uploaded files will be automatically deleted after the number of days set in `EXPIRATION_DAYS` in the `.env` file. The server will check and delete expired files daily at midnight. 94 | 95 | ### 8. Accessing Uploaded Files 96 | 97 | You can access uploaded files via their generated URLs. For example: 98 | 99 | ``` 100 | https://:/uploads/1632549212130-438193-filename.ext 101 | ``` 102 | 103 | If you're using the Discord-compatible schema, the response will contain a JSON object with an `attachments` array: 104 | 105 | ```json 106 | { 107 | "attachments": [ 108 | { 109 | "url": "https://:/uploads/1632549212130-438193-filename.ext", 110 | "proxy_url": "https://:/uploads/1632549212130-438193-filename.ext" 111 | } 112 | ] 113 | } 114 | ``` 115 | 116 | ## Troubleshooting 117 | 118 | - **"SSL folder does not exist" error:** The SSL folder is required for HTTPS to work. Ensure that the `ssl` folder exists and contains valid SSL certificates. 119 | - **"File type not allowed" error:** Ensure that the file you're uploading matches one of the allowed extensions defined in `ALLOWED_EXTENSIONS` in the `.env` file. 120 | - **"File size exceeds limit" error:** Ensure that the file size does not exceed the `MAX_FILE_SIZE` defined in the `.env` file. 121 | 122 | ## License 123 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 124 | 125 | ## Credits 126 | - [Express](https://expressjs.com/) 127 | - [Multer](https://www.npmjs.com/package/multer) 128 | - [Node Cron](https://www.npmjs.com/package/node-cron) 129 | 130 | --- 131 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const multer = require('multer'); 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | const https = require('https'); 6 | const cron = require('node-cron'); 7 | const app = express(); 8 | require('dotenv').config(); 9 | 10 | const UPLOAD_DIR = path.join(__dirname, 'uploads') 11 | const SSL_DIR = path.join(__dirname, 'ssl') 12 | const ALLOWED_EXTENSIONS = process.env.ALLOWED_EXTENSIONS ? process.env.ALLOWED_EXTENSIONS.split(',') : [] 13 | const MAIN_DOMAIN = process.env.DOMAIN 14 | if (MAIN_DOMAIN === 'test.com') return console.log('Please change default domain name') 15 | 16 | const fileExtensions = { 17 | image: [ 18 | ".jpeg", ".jpg", ".png", ".gif", ".bmp", ".tiff", ".tif", ".webp", 19 | ".svg", ".heic", ".cr2", ".crw", ".nef", ".nrw", ".arw", ".srf", 20 | ".sr2", ".dng", ".raf", ".orf", ".rw2", ".srw" 21 | ], 22 | video: [ 23 | ".mp4", ".mov", ".avi", ".mkv", ".wmv", ".flv", ".webm", ".mpeg", 24 | ".mpg", ".mpe", ".3gp", ".ogv", ".ogg" 25 | ], 26 | audio: [ 27 | ".mp3", ".wav", ".aac", ".flac", ".ogg", ".wma", ".m4a", ".alac", 28 | ".aiff", ".pcm", ".opus" 29 | ] 30 | } 31 | 32 | // Separate limits based on file type 33 | const MAX_FILE_SIZE_IMAGE = parseInt(process.env.MAX_FILE_SIZE_IMAGE) || Infinity 34 | const MAX_FILE_SIZE_AUDIO = parseInt(process.env.MAX_FILE_SIZE_AUDIO) || Infinity 35 | const MAX_FILE_SIZE_VIDEO = parseInt(process.env.MAX_FILE_SIZE_VIDEO) || Infinity 36 | const MAX_FILE_SIZE_DEFAULT = parseInt(process.env.MAX_FILE_SIZE_DEFAULT) || Infinity 37 | 38 | // Ensure the upload directory exists 39 | if (!fs.existsSync(UPLOAD_DIR)) { 40 | fs.mkdirSync(UPLOAD_DIR, { recursive: true }) 41 | } 42 | 43 | // Ensure the SSL folder exists, create if not 44 | if (!fs.existsSync(SSL_DIR)) { 45 | fs.mkdirSync(SSL_DIR, { recursive: true }) 46 | } 47 | 48 | 49 | // Storage configuration for multer 50 | const storage = multer.diskStorage({ 51 | destination: UPLOAD_DIR, 52 | filename: (req, file, cb) => { 53 | let fileName = file.originalname.replace(/[<>:"/\\|?*\x00-\x1F]/g, '_') 54 | fileName = fileName.length > process.env.MAX_FILENAME_LENGTH ? fileName.substring(0, process.env.MAX_FILENAME_LENGTH) : fileName 55 | 56 | const randomNumber = Math.floor(Math.random() * 10000) 57 | const uniqueName = `${Date.now()}-${randomNumber}-${file.originalname}` 58 | cb(null, uniqueName) 59 | }, 60 | }) 61 | 62 | // File filter for validating extensions 63 | const fileFilter = (req, file, cb) => { 64 | if (ALLOWED_EXTENSIONS.length === 0) return cb(null, true) // No validation if empty 65 | const ext = path.extname(file.originalname).toLowerCase() 66 | if (ALLOWED_EXTENSIONS.includes(ext)) { 67 | cb(null, true) 68 | } else { 69 | cb(new Error(`File type not allowed. Allowed types: ${ALLOWED_EXTENSIONS.join(', ')}`)) 70 | } 71 | } 72 | 73 | // Dynamic file size limit based on file extension 74 | const getFileSizeLimit = (file) => { 75 | const ext = path.extname(file.originalname).toLowerCase() 76 | 77 | if (fileExtensions.image.includes(ext)) { 78 | return MAX_FILE_SIZE_IMAGE 79 | } else if (fileExtensions.video.includes(ext)) { 80 | return MAX_FILE_SIZE_VIDEO 81 | } else if (fileExtensions.audio.includes(ext)) { 82 | return MAX_FILE_SIZE_AUDIO 83 | } else { 84 | return MAX_FILE_SIZE_DEFAULT 85 | } 86 | } 87 | 88 | const upload = multer({ 89 | storage, 90 | fileFilter, 91 | limits: { 92 | fileSize: (req, file, cb) => getFileSizeLimit(file), 93 | } 94 | }) 95 | 96 | // Middleware to parse JSON data 97 | app.use(express.json()) 98 | 99 | // If the requst is not with main domain redirect it 100 | app.use((req, res, next) => { 101 | if (req.hostname !== MAIN_DOMAIN) { 102 | const redirectUrl = `https://${MAIN_DOMAIN}:${process.env.PORT}${req.url}` 103 | return res.redirect(301, redirectUrl) 104 | } 105 | next() 106 | }) 107 | 108 | // Upload route 109 | app.post('/upload', (req, res) => { 110 | upload.single('files[]')(req, res, err => { 111 | if (err) { 112 | console.log(err) 113 | const errorMsg = err.message || 'File upload failed' 114 | return res.status(400).send(errorMsg) 115 | } 116 | 117 | const imageURL = `https://${req.headers.host}/uploads/${req.file.filename}` 118 | res.status(200).json((process.env.DISCORD_SCHEMA == 'true') ? { 119 | attachments: [ 120 | { 121 | url: imageURL, 122 | proxy_url: imageURL 123 | } 124 | ] 125 | } : imageURL) 126 | }) 127 | }) 128 | 129 | // Serve uploaded files statically 130 | app.use('/uploads', express.static(UPLOAD_DIR, {maxAge: (parseInt(process.env.CACHE_TIME) ?? 0) * 1000})) 131 | 132 | // Schedule the cleanup job to run every day at midnight 133 | if (process.env.EXPIRATION_DAYS > 0) { 134 | cron.schedule('0 0 * * *', function() { 135 | const now = Date.now() 136 | const expirationTime = parseInt(process.env.EXPIRATION_DAYS) * 86_400_000 // Expiration in milliseconds 137 | 138 | fs.readdir(UPLOAD_DIR, (err, files) => { 139 | if (err) { 140 | return console.error('Error reading upload directory:', err) 141 | } 142 | 143 | files.forEach(file => { 144 | const filePath = path.join(UPLOAD_DIR, file) 145 | fs.stat(filePath, (err, stats) => { 146 | if (err) { 147 | return console.error('Error getting file stats:', err) 148 | } 149 | 150 | if (now - stats.mtimeMs > expirationTime) { 151 | fs.unlink(filePath, err => { 152 | if (err) console.error('Error deleting file:', err) 153 | else console.log('Deleted expired file:', file) 154 | }) 155 | } 156 | }) 157 | }) 158 | }) 159 | }) 160 | } 161 | 162 | const sslOptions = { 163 | key: fs.readFileSync(path.join(SSL_DIR, 'key.pem')), 164 | cert: fs.readFileSync(path.join(SSL_DIR, 'cert.pem')), 165 | ca: fs.existsSync(path.join(SSL_DIR, 'chain.pem')) ? fs.readFileSync(path.join(SSL_DIR, 'chain.pem')) : undefined, // Optional, if you have a CA chain 166 | } 167 | 168 | // Create HTTPS server and listen for requests only on the specified domain 169 | https.createServer(sslOptions, app).listen(process.env.PORT, () => { 170 | console.log(`Server is running securely at https://${MAIN_DOMAIN}:${process.env.PORT}`) 171 | console.log(`Use https://${MAIN_DOMAIN}:${process.env.PORT}/upload for uploading files`) 172 | console.log(`Use https://${MAIN_DOMAIN}:${process.env.PORT}/uploads/filename for serving files`) 173 | }) --------------------------------------------------------------------------------