@robmcelhinney/qr-file-share
Version:
Node.js http server allowing for file transfers over local area network made easier with QR code output on start.
845 lines (735 loc) • 25.7 kB
JavaScript
const crypto = require("crypto")
const fs = require("fs")
const path = require("path")
const os = require("os")
const qrcode = require("qrcode-terminal")
const yazl = require("yazl")
const fileUpload = require("express-fileupload")
const express = require("express")
const CLIENT_DIST_DIR = path.join(__dirname, "client", "dist")
const DEFAULT_MAX_FILE_SIZE_BYTES = Number(
process.env.QR_FILE_SHARE_MAX_FILE_SIZE_BYTES || 1024 * 1024 * 1024,
)
const DEFAULT_MAX_FILES_PER_UPLOAD = Number(
process.env.QR_FILE_SHARE_MAX_FILES_PER_UPLOAD || 32,
)
const DEFAULT_REQUEST_TIMEOUT_MS = Number(
process.env.QR_FILE_SHARE_REQUEST_TIMEOUT_MS || 5 * 60 * 1000,
)
const DEFAULT_TEMP_UPLOAD_DIR =
process.env.QR_FILE_SHARE_TEMP_UPLOAD_DIR || "/tmp/qr-file-share-upload"
const DEFAULT_READ_ONLY = process.env.QR_FILE_SHARE_READ_ONLY === "true"
const DEFAULT_DELETE_ENABLED =
process.env.QR_FILE_SHARE_ENABLE_DELETE === "true"
const DEFAULT_SHOW_ALL_ADDRESSES =
process.env.QR_FILE_SHARE_SHOW_ALL_ADDRESSES === "true"
const DEFAULT_UPLOAD_TOKEN = process.env.QR_FILE_SHARE_UPLOAD_TOKEN || ""
const DEFAULT_UPLOAD_TOKEN_MODE =
process.env.QR_FILE_SHARE_UPLOAD_TOKEN_MODE || "static"
const FILE_KIND_MAP = {
archive: new Set(["zip", "rar", "7z", "tar", "gz", "bz2"]),
pdf: new Set(["pdf"]),
audio: new Set(["mp3", "wav", "ogg", "flac", "m4a", "aac"]),
picture: new Set(["jpg", "jpeg", "png", "gif", "webp", "svg"]),
video: new Set(["mp4", "mov", "avi", "mkv", "webm"]),
code: new Set([
"js",
"jsx",
"ts",
"tsx",
"json",
"html",
"css",
"py",
"go",
"rs",
"java",
"c",
"cpp",
"h",
"sh",
"yml",
"yaml",
]),
document: new Set(["txt", "md", "rtf", "doc", "docx", "odt"]),
}
function toSafeRelativePath(requestedPath = "") {
return String(requestedPath)
.replace(/\\/g, "/")
.replace(/^\/+/, "")
.replace(/\/+$/, "")
}
function sanitizeFilename(filename = "") {
return String(filename)
.split(/[\\/]/)
.pop()
.replace(/^\.+$/, "file")
.replace(/[<>:"|?*\u0000-\u001f]/g, "_")
}
function ensureInsideBaseDir(baseDir, requestedPath = "") {
const resolvedBaseDir = path.resolve(baseDir)
const safeRelativePath = toSafeRelativePath(requestedPath)
const resolvedPath = path.resolve(resolvedBaseDir, safeRelativePath)
const relativeToBase = path.relative(resolvedBaseDir, resolvedPath)
if (relativeToBase.startsWith("..") || path.isAbsolute(relativeToBase)) {
const error = new Error("Requested path escapes the shared directory")
error.code = "INVALID_PATH"
throw error
}
return {
relativePath: safeRelativePath,
resolvedPath,
}
}
function getRootLabel(baseDir) {
return path.basename(baseDir) || "Shared files"
}
function createResolvedUploadToken({ uploadToken, uploadTokenMode }) {
if (uploadToken) {
return {
value: uploadToken,
mode: uploadTokenMode === "session" ? "session" : "static",
}
}
if (uploadTokenMode === "session") {
return {
value: crypto.randomBytes(4).toString("hex"),
mode: "session",
}
}
return {
value: "",
mode: "none",
}
}
function buildCapabilities({ readOnly, deleteEnabled, resolvedUploadToken }) {
const uploadTokenRequired = Boolean(resolvedUploadToken.value)
return {
readOnly,
deleteEnabled,
uploadTokenRequired,
uploadTokenMode: resolvedUploadToken.mode,
shareMode: readOnly
? "read-only"
: uploadTokenRequired
? resolvedUploadToken.mode === "session"
? "session-token"
: "protected-upload"
: "open-upload",
}
}
function getSuppliedUploadToken(req) {
const headerToken = req.get("x-upload-token")
if (headerToken) {
return headerToken
}
if (typeof req.body?.upload_token === "string") {
return req.body.upload_token
}
return ""
}
function getParentPath(relativePath) {
if (!relativePath) {
return null
}
const parentPath = path.posix.dirname(relativePath.replace(/\\/g, "/"))
return parentPath === "." ? "" : parentPath
}
function flattenUploadedFiles(fileMap) {
if (!fileMap) {
return []
}
return Object.values(fileMap).flatMap((value) =>
Array.isArray(value) ? value : [value],
)
}
function inferFileKind(name, isDirectory) {
if (isDirectory) {
return "folder"
}
const extension = name.includes(".")
? name.split(".").pop().toLowerCase()
: ""
for (const [kind, extensions] of Object.entries(FILE_KIND_MAP)) {
if (extensions.has(extension)) {
return kind
}
}
return "file"
}
function logMutation(event, details = {}) {
const suffix = Object.entries(details)
.filter(([, value]) => value !== undefined && value !== "")
.map(([key, value]) => `${key}=${JSON.stringify(value)}`)
.join(" ")
console.log(`[qr-file-share] ${event}${suffix ? " " + suffix : ""}`)
}
function formatByteCount(bytes) {
if (!Number.isFinite(bytes) || bytes < 0) {
return "unknown"
}
if (bytes < 1024) {
return `${bytes} B`
}
const units = ["KB", "MB", "GB", "TB"]
let value = bytes
let unitIndex = -1
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024
unitIndex += 1
}
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`
}
function isPrivateLanAddress(address) {
if (/^192\.168\.\d{1,3}\.\d{1,3}$/.test(address)) {
return true
}
if (/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(address)) {
return true
}
const match = address.match(/^172\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/)
return Boolean(match && Number(match[1]) >= 16 && Number(match[1]) <= 31)
}
function getAddressPriority(details) {
if (!details || details.family !== "IPv4" || details.internal) {
return Number.POSITIVE_INFINITY
}
const interfaceName = String(details.name || "").toLowerCase()
const address = details.address
if (!isPrivateLanAddress(address)) {
return 100
}
if (interfaceName.includes("tailscale")) {
return 90
}
if (
interfaceName.includes("docker") ||
interfaceName.includes("wsl") ||
interfaceName.includes("hyper-v") ||
interfaceName.includes("vethernet") ||
interfaceName.includes("virtual") ||
interfaceName.includes("vmware")
) {
return 80
}
if (/^192\.168\./.test(address)) {
return 1
}
if (/^10\./.test(address)) {
return 2
}
if (/^172\./.test(address)) {
return 3
}
return 50
}
function selectListenAddresses(
interfaces,
{ showAllAddresses = DEFAULT_SHOW_ALL_ADDRESSES } = {},
) {
const addresses = []
for (const [deviceName, entries] of Object.entries(interfaces)) {
for (const details of entries || []) {
if (details.family === "IPv4" && !details.internal) {
addresses.push({
name: deviceName,
address: details.address,
family: details.family,
internal: details.internal,
})
}
}
}
const uniqueAddresses = Array.from(
new Map(
addresses.map((details) => [details.address, details]),
).values(),
)
uniqueAddresses.sort(
(left, right) => getAddressPriority(left) - getAddressPriority(right),
)
if (showAllAddresses) {
return uniqueAddresses
}
const preferredAddress =
uniqueAddresses.find((details) => getAddressPriority(details) < 80) ||
uniqueAddresses[0]
return preferredAddress ? [preferredAddress] : []
}
function getStartupMutationSummary(capabilities) {
if (capabilities.readOnly) {
return "read only"
}
if (capabilities.uploadTokenRequired) {
return capabilities.deleteEnabled
? "token required for uploads and deletes"
: "token required for uploads"
}
return capabilities.deleteEnabled
? "uploads and deletes enabled"
: "uploads enabled, deletes disabled"
}
async function uniqueDestinationPath(directory, filename) {
const ext = path.extname(filename)
const baseName = path.basename(filename, ext) || "file"
let attempt = 0
while (true) {
const candidateName =
attempt === 0 ? `${baseName}${ext}` : `${baseName}_${attempt}${ext}`
const candidatePath = path.join(directory, candidateName)
try {
await fs.promises.access(candidatePath)
attempt += 1
} catch {
return {
filename: candidateName,
fullPath: candidatePath,
}
}
}
}
async function addDirectoryToZip(
zipFile,
sourceDir,
archiveRoot,
compressionEnabled,
) {
const entries = await fs.promises.readdir(sourceDir, {
withFileTypes: true,
})
for (const entry of entries) {
const sourcePath = path.join(sourceDir, entry.name)
const archivePath = path.posix.join(archiveRoot, entry.name)
if (entry.isDirectory()) {
zipFile.addEmptyDirectory(archivePath)
await addDirectoryToZip(
zipFile,
sourcePath,
archivePath,
compressionEnabled,
)
} else if (entry.isFile()) {
zipFile.addFile(sourcePath, archivePath, {
compress: compressionEnabled,
})
}
}
}
function logServerAddresses(
port,
capabilities,
resolvedUploadToken,
{
baseDir,
maxFileSizeBytes,
tempUploadDir,
} = {},
) {
const interfaces = os.networkInterfaces()
const addresses = selectListenAddresses(interfaces)
const primaryUrl = addresses[0]
? `http://${addresses[0].address}:${port}`
: `http://localhost:${port}`
console.log("QR File Share")
console.log(` URL: ${primaryUrl}`)
console.log(` Share dir: ${baseDir || process.cwd()}`)
console.log(` Access: ${getStartupMutationSummary(capabilities)}`)
console.log(` Max upload: ${formatByteCount(maxFileSizeBytes)}`)
console.log(` Temp uploads: ${tempUploadDir || DEFAULT_TEMP_UPLOAD_DIR}`)
if (resolvedUploadToken.mode === "session") {
console.log(` Session token: ${resolvedUploadToken.value}`)
}
console.log("")
console.log("Scan QR code or open:")
console.log(primaryUrl)
qrcode.generate(primaryUrl)
if (DEFAULT_SHOW_ALL_ADDRESSES && addresses.length > 1) {
console.log("")
console.log("Other interfaces:")
for (const details of addresses.slice(1)) {
console.log(` http://${details.address}:${port}`)
}
}
if (!DEFAULT_SHOW_ALL_ADDRESSES && addresses.length === 1) {
console.log(
"[qr-file-share] set QR_FILE_SHARE_SHOW_ALL_ADDRESSES=true to list every interface",
)
}
console.log(`[qr-file-share] share_mode=${capabilities.shareMode}`)
if (resolvedUploadToken.mode === "session") {
console.log(
`[qr-file-share] session_upload_token=${resolvedUploadToken.value}`,
)
}
}
function createEntry(relativePath, entry, stats) {
const entryRelativePath = relativePath
? path.posix.join(relativePath, entry.name)
: entry.name
return {
name: entry.name,
path: entryRelativePath,
isDirectory: entry.isDirectory(),
kind: inferFileKind(entry.name, entry.isDirectory()),
size: entry.isDirectory() ? null : stats.size,
modifiedAt: stats.mtime.toISOString(),
}
}
function createApp({
base_path,
compression,
maxFileSizeBytes = DEFAULT_MAX_FILE_SIZE_BYTES,
maxFilesPerUpload = DEFAULT_MAX_FILES_PER_UPLOAD,
tempUploadDir = DEFAULT_TEMP_UPLOAD_DIR,
readOnly = DEFAULT_READ_ONLY,
deleteEnabled = DEFAULT_DELETE_ENABLED,
uploadToken = DEFAULT_UPLOAD_TOKEN,
uploadTokenMode = DEFAULT_UPLOAD_TOKEN_MODE,
}) {
const app = express()
const baseDir = path.resolve(base_path || process.cwd())
const resolvedTempUploadDir = path.resolve(tempUploadDir)
const resolvedUploadToken = createResolvedUploadToken({
uploadToken,
uploadTokenMode,
})
const capabilities = buildCapabilities({
readOnly,
deleteEnabled,
resolvedUploadToken,
})
fs.mkdirSync(resolvedTempUploadDir, { recursive: true })
app.use(express.json())
app.use(express.urlencoded({ extended: false }))
app.use(
fileUpload({
useTempFiles: true,
tempFileDir: resolvedTempUploadDir,
abortOnLimit: true,
limits: {
fileSize: maxFileSizeBytes,
files: maxFilesPerUpload,
},
}),
)
app.get("/api/files", async (req, res) => {
try {
const { relativePath, resolvedPath } = ensureInsideBaseDir(
baseDir,
req.query.path,
)
const entries = await fs.promises.readdir(resolvedPath, {
withFileTypes: true,
})
const detailedEntries = await Promise.all(
entries
.filter((entry) => !entry.name.startsWith("."))
.map(async (entry) => {
const entryPath = path.join(resolvedPath, entry.name)
const stats = await fs.promises.stat(entryPath)
return createEntry(relativePath, entry, stats)
}),
)
detailedEntries.sort((left, right) => {
if (left.isDirectory !== right.isDirectory) {
return left.isDirectory ? -1 : 1
}
return left.name.localeCompare(right.name)
})
return res.send({
rootLabel: getRootLabel(baseDir),
currentPath: relativePath,
parentPath: getParentPath(relativePath),
entries: detailedEntries,
capabilities,
})
} catch (error) {
if (error.code === "INVALID_PATH") {
return res.status(403).send("Can't go back up path")
}
if (error.code === "ENOENT" || error.code === "ENOTDIR") {
return res.sendStatus(404)
}
console.error("error browsing files", error)
return res.sendStatus(500)
}
})
app.get("/api/download", async (req, res) => {
try {
const { resolvedPath } = ensureInsideBaseDir(
baseDir,
req.query.file,
)
const stats = await fs.promises.stat(resolvedPath)
if (stats.isDirectory()) {
return res
.status(400)
.send("Use /api/downloadDir for directories")
}
return res.download(resolvedPath)
} catch (error) {
if (error.code === "INVALID_PATH") {
return res.status(403).send("Can't go back up path")
}
if (error.code === "ENOENT") {
return res.sendStatus(404)
}
console.error("error downloading file", error)
return res.sendStatus(500)
}
})
app.get("/api/downloadDir", async (req, res) => {
try {
const { relativePath, resolvedPath } = ensureInsideBaseDir(
baseDir,
req.query.dir,
)
const stats = await fs.promises.stat(resolvedPath)
if (!stats.isDirectory()) {
return res.status(400).send("Requested path is not a directory")
}
const archiveRoot = path.basename(
relativePath || getRootLabel(baseDir),
)
const downloadName = `${archiveRoot}.zip`
const zipFile = new yazl.ZipFile()
res.attachment(downloadName)
zipFile.outputStream.on("error", (error) => {
console.error("zip stream error", error)
if (!res.headersSent) {
res.sendStatus(500)
} else {
res.destroy(error)
}
})
zipFile.outputStream.pipe(res)
zipFile.addEmptyDirectory(archiveRoot)
await addDirectoryToZip(
zipFile,
resolvedPath,
archiveRoot,
compression !== 0,
)
zipFile.end()
} catch (error) {
if (error.code === "INVALID_PATH") {
return res.status(403).send("Can't go back up path")
}
if (error.code === "ENOENT") {
return res.sendStatus(404)
}
console.error("error downloading directory", error)
return res.sendStatus(500)
}
})
app.post("/api/upload", async (req, res) => {
try {
if (readOnly) {
logMutation("upload_rejected", { reason: "read_only" })
return res.status(403).send({
status: false,
message: "This share is read-only. Uploads are disabled.",
})
}
if (
resolvedUploadToken.value &&
getSuppliedUploadToken(req) !== resolvedUploadToken.value
) {
logMutation("upload_rejected", { reason: "invalid_token" })
return res.status(403).send({
status: false,
message: "Upload token required.",
})
}
if (!req.files || !req.files.file) {
return res.status(400).send({
status: false,
message: "No file uploaded",
})
}
const { resolvedPath: destinationDir } = ensureInsideBaseDir(
baseDir,
req.body.rel_dir,
)
const destinationStats = await fs.promises.stat(destinationDir)
if (!destinationStats.isDirectory()) {
return res.status(400).send({
status: false,
message: "Upload target must be a directory",
})
}
const files = flattenUploadedFiles(req.files)
if (files.length > maxFilesPerUpload) {
return res.status(413).send({
status: false,
message: `Too many files in one upload. Limit is ${maxFilesPerUpload}.`,
})
}
const uploadedFiles = await Promise.all(
files.map(async (file) => {
const safeFilename = sanitizeFilename(file.name)
const { filename, fullPath } = await uniqueDestinationPath(
destinationDir,
safeFilename,
)
await file.mv(fullPath)
const stats = await fs.promises.stat(fullPath)
return {
name: filename,
originalName: file.name,
path: req.body.rel_dir
? path.posix.join(
toSafeRelativePath(req.body.rel_dir),
filename,
)
: filename,
isDirectory: false,
kind: inferFileKind(filename, false),
size: stats.size,
modifiedAt: stats.mtime.toISOString(),
}
}),
)
logMutation("upload", {
files: uploadedFiles.map((file) => file.path),
})
return res.send({
status: true,
message:
uploadedFiles.length > 1
? "Files uploaded successfully."
: "File uploaded successfully.",
data: uploadedFiles,
})
} catch (error) {
if (error.code === "INVALID_PATH") {
return res.status(403).send("Can't go back up path")
}
if (error.code === "ENOENT") {
return res.sendStatus(404)
}
if (error.code === "LIMIT_FILE_SIZE") {
return res.status(413).send({
status: false,
message: `File exceeds the size limit of ${Math.round(maxFileSizeBytes / (1024 * 1024))} MB.`,
})
}
console.error("upload error", error)
return res.status(500).send({
status: false,
message: "Upload failed.",
})
}
})
app.delete("/api/files", async (req, res) => {
try {
if (readOnly) {
logMutation("delete_rejected", { reason: "read_only" })
return res.status(403).send({
status: false,
message: "This share is read-only. Deletions are disabled.",
})
}
if (!deleteEnabled) {
logMutation("delete_rejected", { reason: "disabled" })
return res.status(403).send({
status: false,
message: "Deletion is disabled for this share.",
})
}
if (
resolvedUploadToken.value &&
getSuppliedUploadToken(req) !== resolvedUploadToken.value
) {
logMutation("delete_rejected", { reason: "invalid_token" })
return res.status(403).send({
status: false,
message: "Upload token required.",
})
}
const requestedPath = req.body?.path || req.query?.path
const { relativePath, resolvedPath } = ensureInsideBaseDir(
baseDir,
requestedPath,
)
const stats = await fs.promises.stat(resolvedPath)
if (stats.isDirectory()) {
await fs.promises.rm(resolvedPath, {
recursive: true,
force: true,
})
} else {
await fs.promises.unlink(resolvedPath)
}
logMutation("delete", { path: relativePath })
return res.send({
status: true,
message: `${stats.isDirectory() ? "Folder" : "File"} deleted successfully.`,
})
} catch (error) {
if (error.code === "INVALID_PATH") {
return res.status(403).send("Can't go back up path")
}
if (error.code === "ENOENT") {
return res.sendStatus(404)
}
console.error("delete error", error)
return res.status(500).send({
status: false,
message: "Delete failed.",
})
}
})
app.get("/api/baseDir", (req, res) => {
res.send({
rootLabel: getRootLabel(baseDir),
capabilities,
})
})
app.use(express.static(CLIENT_DIST_DIR))
app.use((req, res, next) => {
if (req.path.startsWith("/api/")) {
return next()
}
if (!fs.existsSync(CLIENT_DIST_DIR)) {
return res.status(404).send("Client build not found")
}
return res.sendFile(path.join(CLIENT_DIST_DIR, "index.html"))
})
return app
}
function startServer({ base_path, compression, port }) {
const resolvedUploadToken = createResolvedUploadToken({
uploadToken: DEFAULT_UPLOAD_TOKEN,
uploadTokenMode: DEFAULT_UPLOAD_TOKEN_MODE,
})
const capabilities = buildCapabilities({
readOnly: DEFAULT_READ_ONLY,
deleteEnabled: DEFAULT_DELETE_ENABLED,
resolvedUploadToken,
})
const app = createApp({
base_path,
compression,
readOnly: DEFAULT_READ_ONLY,
deleteEnabled: DEFAULT_DELETE_ENABLED,
uploadToken: resolvedUploadToken.value,
uploadTokenMode: resolvedUploadToken.mode,
})
const server = app.listen(port, () => {
logServerAddresses(port, capabilities, resolvedUploadToken, {
baseDir: path.resolve(base_path || process.cwd()),
maxFileSizeBytes: DEFAULT_MAX_FILE_SIZE_BYTES,
tempUploadDir: path.resolve(DEFAULT_TEMP_UPLOAD_DIR),
})
})
server.requestTimeout = DEFAULT_REQUEST_TIMEOUT_MS
server.headersTimeout = DEFAULT_REQUEST_TIMEOUT_MS + 1000
return server
}
module.exports = startServer
module.exports.createApp = createApp
module.exports.startServer = startServer
module.exports.selectListenAddresses = selectListenAddresses
module.exports.getStartupMutationSummary = getStartupMutationSummary