Files
send2ereader/index.js
2024-10-20 21:26:19 +02:00

593 lines
17 KiB
JavaScript

#!/usr/bin/env node
const http = require('http')
const Koa = require('koa')
const Router = require('@koa/router')
const multer = require('@koa/multer')
const logger = require('koa-logger')
const sendfile = require('koa-sendfile')
const serve = require('koa-static')
const { mkdirp } = require('mkdirp')
const fs = require('fs')
const { spawn } = require('child_process')
const { join, extname, basename, dirname } = require('path')
const resolvepath = require('path').resolve
const FileType = require('file-type')
const { transliterate } = require('transliteration')
const sanitize = require('sanitize-filename')
const port = 3001
const expireDelay = 30 // 30 seconds
const maxExpireDuration = 1 * 60 * 60 // 1 hour
const maxFileSize = 1024 * 1024 * 800 // 800 MB
const TYPE_EPUB = 'application/epub+zip'
const TYPE_MOBI = 'application/x-mobipocket-ebook'
const allowedTypes = [TYPE_EPUB, TYPE_MOBI, 'application/pdf', 'application/vnd.comicbook+zip', 'application/vnd.comicbook-rar', 'text/html', 'text/plain', 'application/zip', 'application/x-rar-compressed']
const allowedExtensions = ['epub', 'mobi', 'pdf', 'cbz', 'cbr', 'html', 'txt']
const keyChars = "23456789ACDEFGHJKLMNPRSTUVWXYZ"
const keyLength = 4
function doTransliterate(filename) {
let name = filename.split(".")
const ext = "." + name.splice(-1).join(".")
name = name.join(".")
return transliterate(name) + ext
}
function randomKey () {
const choices = Math.pow(keyChars.length, keyLength)
const rnd = Math.floor(Math.random() * choices)
return rnd.toString(keyChars.length).padStart(keyLength, '0').split('').map((chr) => {
return keyChars[parseInt(chr, keyChars.length)]
}).join('')
}
function removeKey (key) {
console.log('Removing expired key', key)
const info = app.context.keys.get(key)
if (info) {
clearTimeout(app.context.keys.get(key).timer)
if (info.file) {
console.log('Deleting file', info.file.path)
fs.unlink(info.file.path, (err) => {
if (err) console.error(err)
})
info.file = null
}
app.context.keys.delete(key)
} else {
console.log('Tried to remove non-existing key', key)
}
}
function expireKey (key) {
// console.log('key', key, 'will expire in', expireDelay, 'seconds')
const info = app.context.keys.get(key)
const timer = setTimeout(removeKey, expireDelay * 1000, key)
if (info) {
clearTimeout(info.timer)
info.timer = timer
info.alive = new Date()
}
return timer
}
function flash (ctx, data) {
console.log(data)
//ctx.cookies.set('flash', encodeURIComponent(JSON.stringify(data)), {overwrite: true, httpOnly: false, sameSite: 'strict', maxAge: 10 * 1000})
ctx.response.status = data.success ? 200 : 400
if (!data.success) {
ctx.set("Connection", "close")
}
ctx.body = data.message
}
const app = new Koa()
app.context.keys = new Map()
app.use(logger())
const router = new Router()
const upload = multer({
storage: multer.diskStorage({
destination: function (req, file, cb) {
cb(null, 'uploads')
},
filename: function (req, file, cb) {
const uniqueSuffix = Date.now() + '-' + Math.floor(Math.random() * 1E9)
cb(null, file.fieldname + '-' + uniqueSuffix + extname(file.originalname).toLowerCase())
}
}),
limits: {
fileSize: maxFileSize,
files: 1
},
fileFilter: (req, file, cb) => {
// Fixes charset
// https://github.com/expressjs/multer/issues/1104#issuecomment-1152987772
file.originalname = sanitize(Buffer.from(file.originalname, 'latin1').toString('utf8'))
console.log('Incoming file:', file)
const key = req.body.key.toUpperCase()
if (!app.context.keys.has(key)) {
console.error('FileFilter: Unknown key: ' + key)
cb("Unknown key " + key, false)
return
}
if ((!allowedTypes.includes(file.mimetype) && file.mimetype != "application/octet-stream") || !allowedExtensions.includes(extname(file.originalname.toLowerCase()).substring(1))) {
console.error('FileFilter: File is of an invalid type ', file)
cb("Invalid filetype: " + JSON.stringify(file), false)
return
}
cb(null, true)
}
})
router.post('/generate', async ctx => {
const agent = ctx.get('user-agent')
let key = null
let attempts = 0
console.log('There are currently', ctx.keys.size, 'key(s) in use.')
console.log('Generating unique key...', ctx.ip, agent)
do {
key = randomKey()
if (attempts > ctx.keys.size) {
console.error('Can\'t generate more keys, map is full.', attempts, ctx.keys.size)
ctx.body = 'error'
return
}
attempts++
} while (ctx.keys.has(key))
console.log('Generated key ' + key + ', '+attempts+' attempt(s)')
const info = {
created: new Date(),
agent: agent,
file: null,
urls: []
}
ctx.keys.set(key, info)
expireKey(key)
setTimeout(() => {
// remove if it is the same object
if(ctx.keys.get(key) === info) removeKey(key)
}, maxExpireDuration * 1000)
ctx.cookies.set('key', key, {overwrite: true, httpOnly: false, sameSite: 'strict', maxAge: expireDelay * 1000})
ctx.body = key
})
/*
router.get('/download/:key', async ctx => {
const key = ctx.cookies.get('key')
if (!key) {
await next()
return
}
const info = ctx.keys.get(key)
if (!info || !info.file) {
await next()
return
}
ctx.redirect('/' + encodeURIComponent(info.file.name));
})
*/
async function downloadFile (ctx, next) {
const key = ctx.query.key
if (!key) {
await next()
return
}
const filename = decodeURIComponent(ctx.params.filename)
const info = ctx.keys.get(key)
if (!info || !info.file || info.file.name !== filename) {
await next()
return
}
if (info.agent !== ctx.get('user-agent')) {
console.error("User Agent doesnt match: " + info.agent + " VS " + ctx.get('user-agent'))
return
}
expireKey(key)
console.log('Sending file', [info.file.path, info.file.name])
if (info.agent.includes('Kindle')) {
// Kindle needs a safe name or it thinks it's an invalid file
ctx.attachment(info.file.name)
}
await sendfile(ctx, info.file.path)
}
router.post('/upload', async (ctx, next) => {
try {
await upload.single('file')(ctx, () => {})
} catch (err) {
flash(ctx, {
message: err,
success: false
})
// ctx.throw(400, err)
// ctx.res.end(err)
await next()
return
}
ctx.res.writeContinue()
const key = ctx.request.body.key.toUpperCase()
if (ctx.request.file) {
console.log('Uploaded file:', ctx.request.file)
}
if (!ctx.keys.has(key)) {
flash(ctx, {
message: 'Unknown key ' + key,
success: false
})
if (ctx.request.file) {
fs.unlink(ctx.request.file.path, (err) => {
if (err) console.error(err)
else console.log('Removed file', ctx.request.file.path)
})
}
await next()
return
}
const info = ctx.keys.get(key)
expireKey(key)
let url = null
if (ctx.request.body.url) {
url = ctx.request.body.url.trim()
if (url.length > 0 && !info.urls.includes(url)) {
info.urls.push(url)
}
}
let conversion = null
let filename = ""
if (ctx.request.file) {
if (ctx.request.file.size === 0) {
let data = {
message: 'Invalid file submitted (empty file)',
success: false,
key: key
}
flash(ctx, data)
fs.unlink(ctx.request.file.path, (err) => {
if (err) console.error(err)
else console.log('Removed file', ctx.request.file.path)
})
await next()
return
}
let mimetype = ctx.request.file.mimetype
const type = await FileType.fromFile(ctx.request.file.path)
if (mimetype == "application/octet-stream" && type) {
mimetype = type.mime
}
if (mimetype == "application/epub") {
mimetype = TYPE_EPUB
}
if ((!type || !allowedTypes.includes(type.mime)) && !allowedTypes.includes(mimetype)) {
flash(ctx, {
message: 'Uploaded file is of an invalid type: ' + ctx.request.file.originalname + ' (' + (type? type.mime : 'unknown mimetype') + ')',
success: false,
key: key
})
fs.unlink(ctx.request.file.path, (err) => {
if (err) console.error(err)
else console.log('Removed file', ctx.request.file.path)
})
await next()
return
}
let data = null
filename = ctx.request.file.originalname
if (ctx.request.body.transliteration) {
filename = sanitize(doTransliterate(filename))
}
if (info.agent.includes('Kindle')) {
filename = filename.replace(/[^\.\w\-"'\(\)]/g, '_')
}
if (mimetype === TYPE_EPUB && info.agent.includes('Kindle') && ctx.request.body.kindlegen) {
// convert to .mobi
conversion = 'kindlegen'
const outname = ctx.request.file.path.replace(/\.epub$/i, '.mobi')
filename = filename.replace(/\.kepub\.epub$/i, '.epub').replace(/\.epub$/i, '.mobi')
let stderr = ''
let p = new Promise((resolve, reject) => {
const kindlegen = spawn('kindlegen', [basename(ctx.request.file.path), '-dont_append_source', '-c1', '-o', basename(outname)], {
// stdio: 'inherit',
cwd: dirname(ctx.request.file.path)
})
kindlegen.once('error', function (err) {
fs.unlink(ctx.request.file.path, (err) => {
if (err) console.error(err)
else console.log('Removed file', ctx.request.file.path)
})
fs.unlink(ctx.request.file.path.replace(/\.epub$/i, '.mobi8'), (err) => {
if (err) console.error(err)
else console.log('Removed file', ctx.request.file.path.replace(/\.epub$/i, '.mobi8'))
})
reject('kindlegen error: ' + err)
})
kindlegen.once('close', (code) => {
fs.unlink(ctx.request.file.path, (err) => {
if (err) console.error(err)
else console.log('Removed file', ctx.request.file.path)
})
fs.unlink(ctx.request.file.path.replace(/\.epub$/i, '.mobi8'), (err) => {
if (err) console.error(err)
else console.log('Removed file', ctx.request.file.path.replace(/\.epub$/i, '.mobi8'))
})
if (code !== 0 && code !== 1) {
reject('kindlegen error code: ' + code + '\n' + stderr)
return
}
resolve(outname)
})
kindlegen.stdout.on('data', function (str) {
stderr += str
console.log('kindlegen: ' + str)
})
kindlegen.stderr.on('data', function (str) {
stderr += str
console.log('kindlegen: ' + str)
})
})
try {
data = await p
} catch (err) {
flash(ctx, {
success: false,
message: err.replaceAll(basename(ctx.request.file.path), "infile.epub").replaceAll(basename(outname), "outfile.mobi")
})
return
}
} else if (mimetype === TYPE_EPUB && info.agent.includes('Kobo') && ctx.request.body.kepubify) {
// convert to Kobo EPUB
conversion = 'kepubify'
const outname = ctx.request.file.path.replace(/\.epub$/i, '.kepub.epub')
filename = filename.replace(/\.kepub\.epub$/i, '.epub').replace(/\.epub$/i, '.kepub.epub')
let p = new Promise((resolve, reject) => {
let stderr = ''
const kepubify = spawn('kepubify', ['-v', '-u', '-o', basename(outname), basename(ctx.request.file.path)], {
//stdio: 'inherit',
cwd: dirname(ctx.request.file.path)
})
kepubify.once('error', function (err) {
fs.unlink(ctx.request.file.path, (err) => {
if (err) console.error(err)
else console.log('Removed file', ctx.request.file.path)
})
reject('kepubify error: ' + err)
})
kepubify.once('close', (code) => {
fs.unlink(ctx.request.file.path, (err) => {
if (err) console.error(err)
else console.log('Removed file', ctx.request.file.path)
})
if (code !== 0) {
reject('Kepubify error code: ' + code + '\n' + stderr)
return
}
resolve(outname)
})
kepubify.stdout.on('data', function (str) {
stderr += str
console.log('kepubify: ' + str)
})
kepubify.stderr.on('data', function (str) {
stderr += str
console.log('kepubify: ' + str)
})
})
try {
data = await p
} catch (err) {
flash(ctx, {
success: false,
message: err.replaceAll(basename(ctx.request.file.path), "infile.epub").replaceAll(basename(outname), "outfile.kepub.epub")
})
return
}
} else if (mimetype == 'application/pdf' && ctx.request.body.pdfcropmargins) {
const dir = dirname(ctx.request.file.path)
const base = basename(ctx.request.file.path, '.pdf')
const outfile = resolvepath(join(dir, `${base}_cropped.pdf`))
let p = new Promise((resolve, reject) => {
let stderr = ''
const pdfcropmargins = spawn('pdfcropmargins', ['-s', '-u', '-o', outfile, basename(ctx.request.file.path)], {
// stdio: 'inherit',
cwd: dirname(ctx.request.file.path)
})
pdfcropmargins.once('error', function (err) {
fs.unlink(ctx.request.file.path, (err) => {
if (err) console.error(err)
else console.log('Removed file', ctx.request.file.path)
})
reject('pdfcropmargins error: ' + err)
})
pdfcropmargins.once('close', (code) => {
fs.unlink(ctx.request.file.path, (err) => {
if (err) console.error(err)
else console.log('Removed file', ctx.request.file.path)
})
if (code !== 0) {
reject('pdfcropmargins error code: ' + code + '\n' + stderr)
return
}
resolve(outfile)
})
pdfcropmargins.stdout.on('data', function (str) {
stderr += str
console.log('pdfcropmargins: ' + str)
})
pdfcropmargins.stderr.on('data', function (str) {
stderr += str
console.log('pdfcropmargins: ' + str)
})
})
try {
data = await p
} catch (err) {
flash(ctx, {
success: false,
message: err.replaceAll(basename(ctx.request.file.path), "infile.pdf").replaceAll(outfile, "outfile.pdf")
})
return
}
} else {
// No conversion
data = ctx.request.file.path
filename = filename.replace(/\.epub$/i, '.epub').replace(/\.pdf$/i, '.pdf')
}
expireKey(key)
if (info.file && info.file.path) {
await new Promise((resolve, reject) => fs.unlink(info.file.path, (err) => {
if (err) return reject(err)
else console.log('Removed previously uploaded file', info.file.path)
resolve()
}))
}
info.file = {
name: filename,
path: data,
// size: ctx.request.file.size,
uploaded: new Date()
}
}
let messages = []
if (ctx.request.file) {
ctx.request.file.skip = true
messages.push('Upload successful! ' + (conversion ? 'Ebook was converted with ' + conversion + ' and sent' : 'Sent')+' to '+(info.agent.includes('Kobo') ? 'a Kobo device.' : (info.agent.includes('Kindle') ? 'a Kindle device.' : 'a device.')))
messages.push('Filename: ' + filename)
}
if (url) {
messages.push("Added url: " + url)
}
if (messages.length === 0) {
flash(ctx, {
message: 'No file or url selected',
success: false,
key: key
})
await next()
return
}
flash(ctx, {
message: messages.join("<br/>"),
success: true,
key: key,
url: url
})
await next()
})
router.delete('/file/:key', async ctx => {
const key = ctx.params.key.toUpperCase()
const info = ctx.keys.get(key)
if (!info) {
ctx.throw(400, 'Unknown key: ' + key)
}
info.file = null
ctx.body = 'ok'
})
router.get('/status/:key', async ctx => {
const key = ctx.params.key.toUpperCase()
const info = ctx.keys.get(key)
if (!info) {
ctx.response.status = 404
ctx.body = {error: 'Unknown key'}
return
}
if (info.agent !== ctx.get('user-agent')) {
// don't send this error to client
console.error("User Agent doesnt match: " + info.agent + " VS " + ctx.get('user-agent'))
return
}
expireKey(key)
// ctx.cookies.set('key', key, {overwrite: true, httpOnly: false, sameSite: 'strict', maxAge: expireDelay * 1000})
ctx.body = {
alive: info.alive,
file: info.file ? {
name: info.file.name,
// size: info.file.size
} : null,
urls: info.urls
}
})
router.get('/receive', async ctx => {
await sendfile(ctx, 'static/download.html')
})
router.get('/', async ctx => {
const agent = ctx.get('user-agent')
console.log(ctx.ip, agent)
await sendfile(ctx, agent.includes('Kobo') || agent.includes('Kindle') || agent.toLowerCase().includes('tolino') || agent.includes('eReader') /*"eReader" is on Tolino*/ ? 'static/download.html' : 'static/upload.html')
})
router.get('/:filename', downloadFile)
app.use(serve("static"))
app.use(router.routes())
app.use(router.allowedMethods())
fs.rm('uploads', {recursive: true}, (err) => {
if (err) throw err
mkdirp('uploads').then (() => {
// app.listen(port)
const fn = app.callback()
const server = http.createServer(fn)
server.on('checkContinue', (req, res) => {
console.log("check continue!")
fn(req, res)
})
server.listen(port)
console.log('server is listening on port ' + port)
})
})