// Node.js built-in modules
import fs, { createWriteStream } from 'node:fs'
import path from 'node:path'
import { exec } from 'child_process'
import { promisify } from 'node:util'
import { pipeline } from 'node:stream/promises'
// Third-party modules
import dotenv from 'dotenv'
import Fastify from 'fastify'
import fastifyMultipart from '@fastify/multipart'
import fastifyStatic from '@fastify/static'
import mime from 'mime-types'
import sanitize from 'sanitize-filename'
import sharp from 'sharp'
import sqlite3 from 'sqlite3'
const dbFile = path.join("data", 'database.db')
const db = new sqlite3.Database(dbFile)
const execAsync = promisify(exec)
// Load environment variables
dotenv.config()
const fastify = Fastify({
logger: true
})
let uploadDir = process.env.UPLOAD_DIR || 'uploads'
//Convert to absolute path
if (!path.isAbsolute(uploadDir)) {
uploadDir = path.join(process.cwd(), uploadDir)
}
fastify.register(fastifyStatic, {
root: uploadDir,
prefix: '/f'
});
// Handle CSS file, by sending `styles.css` file from the current directory
fastify.get('/styles.css', async (request, reply) => {
const cssContent = await fs.promises.readFile('assets/styles.css', 'utf8')
reply.header('Content-Type', 'text/css').send(cssContent)
});
fastify.get('/music.jpg', async (request, reply) => {
const musicImage = await fs.promises.readFile('assets/music.jpg')
reply.header('Content-Type', 'image/jpeg').send(musicImage)
});
//Find the width and height of a file either an image or video, and cache the result in the db
async function getAssetSize(filePath) {
//Check if the asset size is already in the cache
const cachedSize = await new Promise((resolve, reject) => {
db.get('SELECT * FROM asset_size_cache WHERE path = ?', [filePath], (err, row) => {
if (err) {
reject(err)
} else {
resolve(row)
}
})
})
if (cachedSize) {
return { width: cachedSize.width, height: cachedSize.height }
}
const mimeType = mime.lookup(filePath) || 'application/octet-stream'
let assetSize = { width: 0, height: 0 }
//Use ffprobe to get the width and height of a video file
if (mimeType.startsWith('video/')) {
try {
const { stdout } = await execAsync(`ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=s=x:p=0 ${filePath}`)
const [width, height] = stdout.trim().split('x').map(Number)
assetSize = { width, height }
} catch (error) {
console.error(error)
}
//Use sharp to get the width and height of an image file
} else if (mimeType.startsWith('image/')) {
try {
const metadata = await sharp(filePath).metadata()
assetSize = { width: metadata.width, height: metadata.height }
} catch (error) {
console.error(error)
}
}
//Cache the result
db.run('INSERT INTO asset_size_cache (path, width, height) VALUES (?, ?, ?)', [filePath, assetSize.width, assetSize.height])
return assetSize
}
// Serve HTML page with embedded file (public)
fastify.get('/s/:date/:filename', async (request, reply) => {
let { filename, date } = request.params
//Log the user agent
console.log(request.headers['user-agent'])
// Sanitize filename & date
filename = sanitize(filename)
date = sanitize(date)
const uploadDir = process.env.UPLOAD_DIR || 'uploads'
const filePath = path.join(uploadDir, date, filename)
const joinedPath = path.join(date, filename)
console.log(filePath)
try {
//Check if the file exists, if not, return 404
await fs.promises.access(filePath)
const { width, height } = await getAssetSize(filePath);
const mimeType = mime.lookup(filename) || 'application/octet-stream'
let fileContent = '';
let ogTags = `
\n`;
if (mimeType.startsWith('image/')) {
fileContent = ``
ogTags += `
`
} else if (mimeType.startsWith('video/')) {
fileContent = ``
ogTags += `
`
} else if (mimeType.startsWith('audio/')) {
fileContent = ``
ogTags += `
`
} else if (mimeType.startsWith('text/')) {
const fileData = await fs.promises.readFile(filePath, 'utf8');
fileContent = `
${fileData}` } else { fileContent = `Download File` } const htmlContent = await fs.promises.readFile('assets/view.html', 'utf8'); //Replace placeholders with actual content const html = htmlContent .replaceAll('{{fileContent}}', fileContent) .replaceAll('{{ogTags}}', ogTags) .replaceAll('{{filename}}', filename) .replaceAll('{{joinedPath}}', joinedPath) reply.code(200).header('Content-Type', 'text/html').send(html) } catch (error) { console.error(error) reply.code(404).send({ error: 'File not found' }) } }) //Route which will redirect to the original URL fastify.get('/l/:shortId', async (request, reply) => { const { shortId } = request.params const url = await new Promise((resolve, reject) => { db.get('SELECT url FROM short_urls WHERE short_id = ?', [shortId], (err, row) => { if (err) { reject(err) } else { resolve(row) } }) }) if (url) { reply.redirect(url.url) } else { reply.code(404).send({ error: 'URL not found' }) } }); const sizeLimit = process.env.FILE_SIZE_LIMIT || 16; //Size limit in GB // Register multipart support fastify.register(fastifyMultipart, { limits: { files: 1, fileSize: sizeLimit * 1024 * 1024 * 1024, // 8GB } }) // Upload endpoint (requires API key) fastify.put('/upload', { preHandler: async (request, reply) => { const apiKey = request.headers.authorization if (!apiKey || apiKey !== process.env.API_KEY) { reply.code(401).send({ error: 'Unauthorized' }) } } }, async function (request, reply) { const data = await request.file() if (!data) { reply.code(400).send({ error: 'No file provided' }) return } const uploadDir = process.env.UPLOAD_DIR || 'uploads' const date = new Date() const dateFolder = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` const fileName = data.filename const fullUploadDir = path.join(uploadDir, dateFolder) const filePath = path.join(fullUploadDir, fileName) try { // Create directory if it doesn't exist await fs.promises.mkdir(fullUploadDir, { recursive: true }) await pipeline( data.file, createWriteStream(filePath) ) const host = process.env.HOST || '' reply.code(200).send({ message: 'File uploaded successfully', fileName: host.replace(/\/$/, '') + '/s/' + path.join(dateFolder, fileName) }) //If the file is a video, generate a thumbnail const mimeType = mime.lookup(fileName) || 'application/octet-stream' if (mimeType.startsWith('video/')) { const thumbnailPath = path.join(fullUploadDir, fileName + '.thumb.jpg') try { await execAsync(`ffmpeg -i ${filePath} -ss 00:00:01 -vframes 1 ${thumbnailPath}`) } catch (error) { console.error(error) } } } catch (err) { reply.code(500).send({ error: 'Error uploading file' }) } }) //Route which will shorten a URL fastify.put('/shorten', { preHandler: async (request, reply) => { const apiKey = request.headers.authorization if (!apiKey || apiKey !== process.env.API_KEY) { reply.code(401).send({ error: 'Unauthorized' }) } } }, async (request, reply) => { const { url } = request.body if (!url) { reply.code(400).send({ error: 'No URL provided' }) return } //Short URL should be base62, 8 characters long const shortId = Math.random().toString(36).substring(2, 10) //Insert the short URL into the database db.run('INSERT INTO short_urls (short_id, url) VALUES (?, ?)', [shortId, url]) const host = process.env.HOST || '' reply.code(200).send({ url: host.replace(/\/$/, '') + '/l/' + shortId }) }); const initDb = async () => { //Create asset_size_cache table await db.run(`CREATE TABLE IF NOT EXISTS asset_size_cache ( path TEXT PRIMARY KEY, width INTEGER, height INTEGER )`); //Create short_urls table await db.run(`CREATE TABLE IF NOT EXISTS short_urls ( short_id TEXT PRIMARY KEY, url TEXT )`); } // Start server const start = async () => { try { await initDb(); await fastify.listen({ host: process.env.LISTEN_HOST || '0.0.0.0', port: process.env.LISTEN_PORT || 3000 }) } catch (err) { fastify.log.error(err) process.exit(1) } } start()