kindle support

This commit is contained in:
daniel-j
2020-03-05 14:36:12 +01:00
parent 3fd0464a7f
commit 7c95b98a6a
4 changed files with 219 additions and 46 deletions

View File

@@ -1,7 +1,7 @@
<!doctype html> <!doctype html>
<html> <html>
<head> <head>
<title>Send to Kobo</title> <title>Send to Kobo/Kindle</title>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="stylesheet" type="text/css" href="/style.css"/> <link rel="stylesheet" type="text/css" href="/style.css"/>
@@ -9,11 +9,11 @@
<body> <body>
<div class="wrapper"> <div class="wrapper">
<h1 class="center">Send to Kobo</h1> <h1 class="center">Send to Kobo/Kindle</h1>
<div class="center"> <div class="center">
Unique key: <div style="font-size: 1.4em;">Unique key:</div>
<div id="key">- - - -</div> <div id="key"></div>
<br/> <br/>
<button id="keygen">Generate new key</button> <button id="keygen">Generate new key</button>
</div> </div>
@@ -39,6 +39,9 @@ function xhr(method, url, cb) {
x.onload = function () { x.onload = function () {
cb(x) cb(x)
} }
x.onerror = function () {
cb(x)
}
x.open(method, url, true) x.open(method, url, true)
x.send(null) x.send(null)
} }
@@ -48,13 +51,19 @@ function pollFile () {
var data var data
try { try {
data = JSON.parse(x.responseText) data = JSON.parse(x.responseText)
} catch (err) { } } catch (err) {
if (data && data.error) { keyOutput.textContent = ''
key = null
downloads.style.display = 'none'
return
}
if (data.error) {
if (pollTimer) clearInterval(pollTimer) if (pollTimer) clearInterval(pollTimer)
key = null key = null
keyOutput.textContent = '- - - -' keyOutput.textContent = ''
downloads.style.display = 'none'
} }
if (data && data.file) { if (data.file) {
downloadlink.textContent = data.file.name downloadlink.textContent = data.file.name
downloads.style.display = 'block' downloads.style.display = 'block'
} else { } else {
@@ -64,17 +73,18 @@ function pollFile () {
} }
function generateKey () { function generateKey () {
keyOutput.textContent = '- - - -' keyOutput.textContent = ''
if (pollTimer) clearInterval(pollTimer) if (pollTimer) clearInterval(pollTimer)
downloads.style.display = 'none' downloads.style.display = 'none'
xhr('POST', '/generate', function (x) { xhr('POST', '/generate', function (x) {
keyOutput.textContent = x.responseText if (x.responseText !== 'error' && x.status === 200) {
if (x.responseText !== 'error') {
key = x.responseText key = x.responseText
keyOutput.textContent = key
downloadlink.href = '/download/' + key downloadlink.href = '/download/' + key
if (pollTimer) clearInterval(pollTimer) if (pollTimer) clearInterval(pollTimer)
pollTimer = setInterval(pollFile, 5 * 1000) pollTimer = setInterval(pollFile, 5 * 1000)
} else { } else {
keyOutput.textContent = ''
key = null key = null
downloadlink.href = '' downloadlink.href = ''
} }

110
index.js
View File

@@ -8,13 +8,14 @@ const sendfile = require('koa-sendfile')
const mkdirp = require('mkdirp') const mkdirp = require('mkdirp')
const fs = require('fs') const fs = require('fs')
const { spawn } = require('child_process') const { spawn } = require('child_process')
const { extname, basename, dirname } = require('path')
const port = 3001 const port = 3001
const expireDelay = 30 // 30 seconds const expireDelay = 30 // 30 seconds
const maxExpireDuration = 2 * 60 * 60 // 2 hours const maxExpireDuration = 2 * 60 * 60 // 2 hours
const maxFileSize = 1024 * 1024 * 400 // 400 MB const maxFileSize = 1024 * 1024 * 400 // 400 MB
const keyChars = "234689ACEFGHKLMNPRTXYZ" const keyChars = "3469ACEGHLMNPRTY"
const keyLength = 4 const keyLength = 4
function randomKey () { function randomKey () {
@@ -56,6 +57,11 @@ function expireKey (key) {
return timer return timer
} }
function flash (ctx, data) {
console.log(data)
ctx.cookies.set('flash', encodeURIComponent(JSON.stringify(data)), {overwrite: true, httpOnly: false})
}
const app = new Koa() const app = new Koa()
app.context.keys = new Map() app.context.keys = new Map()
app.use(logger()) app.use(logger())
@@ -70,7 +76,7 @@ const upload = multer({
filename: function (req, file, cb) { filename: function (req, file, cb) {
const uniqueSuffix = Date.now() + '-' + Math.floor(Math.random() * 1E9) const uniqueSuffix = Date.now() + '-' + Math.floor(Math.random() * 1E9)
console.log(file) console.log(file)
cb(null, file.fieldname + '-' + uniqueSuffix + '.epub') cb(null, file.fieldname + '-' + uniqueSuffix + extname(file.originalname).toLowerCase())
} }
}), }),
limits: { limits: {
@@ -84,8 +90,9 @@ const upload = multer({
cb(null, false) cb(null, false)
return return
} }
if (!file.originalname.toLowerCase().endsWith('.epub')) { const nameLower = file.originalname.toLowerCase()
console.error('FileFilter: Filename does not end with .epub: ' + file.originalname) if (!nameLower.endsWith('.epub') && !nameLower.endsWith('.mobi')) {
console.error('FileFilter: Filename does not end with .epub or .mobi: ' + file.originalname)
cb(null, false) cb(null, false)
return return
} }
@@ -95,8 +102,8 @@ const upload = multer({
router.post('/generate', async ctx => { router.post('/generate', async ctx => {
const agent = ctx.get('user-agent') const agent = ctx.get('user-agent')
if (!agent.includes('Kobo')) { if (!agent.includes('Kobo') && !agent.includes('Kindle')) {
console.error('Non-Kobo device tried to generate a key: ' + agent) console.error('Non-Kobo or Kindle device tried to generate a key: ' + agent)
ctx.throw(403) ctx.throw(403)
} }
let key = null let key = null
@@ -122,7 +129,9 @@ router.post('/generate', async ctx => {
} }
ctx.keys.set(key, info) ctx.keys.set(key, info)
expireKey(key) expireKey(key)
setTimeout(removeKey, maxExpireDuration * 1000, key) setTimeout(() => {
if(ctx.keys.has(key)) removeKey(key)
}, maxExpireDuration * 1000)
ctx.body = key ctx.body = key
}) })
@@ -138,9 +147,8 @@ router.get('/download/:key', async ctx => {
return return
} }
expireKey(key) expireKey(key)
console.log('Sending file!') console.log('Sending file', info.file.path)
await sendfile(ctx, info.file.path) await sendfile(ctx, info.file.path)
// ctx.type = 'application/epub+zip'
ctx.attachment(info.file.name) ctx.attachment(info.file.name)
}) })
@@ -148,31 +156,85 @@ router.post('/upload', upload.single('file'), async ctx => {
const key = ctx.request.body.key.toUpperCase() const key = ctx.request.body.key.toUpperCase()
if (!ctx.keys.has(key)) { if (!ctx.keys.has(key)) {
ctx.throw(400, 'Unknown key: ' + key) flash(ctx, {
message: 'Unknown key ' + key,
success: false
})
ctx.redirect('back', '/')
return
} }
if (!ctx.request.file || ctx.request.file.size === 0) { if (!ctx.request.file || ctx.request.file.size === 0) {
flash(ctx, {
message: 'Invalid file submitted',
success: false,
key: key
})
if (ctx.request.file) console.error(ctx.request.file)
ctx.redirect('back', '/')
return
}
const nameLower = ctx.request.file.originalname.toLowerCase()
if (!nameLower.endsWith('.epub') && !nameLower.endsWith('.mobi')) {
flash(ctx, {
message: 'Uploaded file does not end with .epub or .mobi ' + ctx.request.file.originalname,
success: false,
key: key
})
console.error(ctx.request.file) console.error(ctx.request.file)
ctx.throw(400, 'Invalid or no file submitted') ctx.redirect('back', '/')
} return
if (!ctx.request.file.originalname.toLowerCase().endsWith('.epub')) {
ctx.throw(400, 'Uploaded file does not end with .epub ' + ctx.request.file.originalname)
} }
const info = ctx.keys.get(key)
expireKey(key)
let data = null let data = null
let filename = ctx.request.file.originalname let filename = ctx.request.file.originalname
let conversion = null
if (ctx.request.body.kepubify) { if (nameLower.endsWith('.epub') && info.agent.includes('Kindle')) {
// 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')
data = await 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('close', (code) => {
fs.unlink(ctx.request.file.path, (err) => {
if (err) console.error(err)
else console.log('Remove 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) {
console.warn('kindlegen error code ' + code)
}
resolve(outname)
})
})
} else if (nameLower.endsWith('.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') const outname = ctx.request.file.path.replace(/\.epub$/i, '.kepub.epub')
filename = filename.replace(/\.kepub\.epub$/i, '.epub').replace(/\.epub$/i, '.kepub.epub') filename = filename.replace(/\.kepub\.epub$/i, '.epub').replace(/\.epub$/i, '.kepub.epub')
data = await new Promise((resolve, reject) => { data = await new Promise((resolve, reject) => {
const kepubify = spawn('kepubify', ['-v', '-u', '-o', outname, ctx.request.file.path], { const kepubify = spawn('kepubify', ['-v', '-u', '-o', basename(outname), basename(ctx.request.file.path)], {
stdio: 'inherit' stdio: 'inherit',
cwd: dirname(ctx.request.file.path)
}) })
kepubify.once('close', (code) => { kepubify.once('close', (code) => {
fs.unlink(ctx.request.file.path, (err) => { fs.unlink(ctx.request.file.path, (err) => {
if (err) console.error(err) if (err) console.error(err)
else console.log('Remove file', ctx.request.file.path)
}) })
if (code !== 0) { if (code !== 0) {
reject('kepubify error code ' + code) reject('kepubify error code ' + code)
@@ -183,14 +245,15 @@ router.post('/upload', upload.single('file'), async ctx => {
}) })
}) })
} else { } else {
// No conversion
data = ctx.request.file.path data = ctx.request.file.path
} }
expireKey(key) expireKey(key)
const info = ctx.keys.get(key)
if (info.file && info.file.path) { if (info.file && info.file.path) {
await new Promise((resolve, reject) => fs.unlink(info.file.path, (err) => { await new Promise((resolve, reject) => fs.unlink(info.file.path, (err) => {
if (err) reject(err) if (err) return reject(err)
else console.log('Removed previously uploaded file', info.file.path)
resolve() resolve()
})) }))
} }
@@ -201,6 +264,11 @@ router.post('/upload', upload.single('file'), async ctx => {
uploaded: new Date() uploaded: new Date()
} }
console.log(info.file) console.log(info.file)
flash(ctx, {
message: 'Upload successful!<br/>'+(conversion ? ' Ebook was converted with ' + conversion + ' and sent' : ' Sent')+' to a '+(info.agent.includes('Kobo') ? 'Kobo' : 'Kindle')+' device.<br/>Filename: ' + filename,
success: true,
key: key
})
ctx.redirect('back', '/') ctx.redirect('back', '/')
}) })
@@ -243,7 +311,7 @@ router.get('/style.css', async ctx => {
router.get('/', async ctx => { router.get('/', async ctx => {
const agent = ctx.get('user-agent') const agent = ctx.get('user-agent')
console.log(agent) console.log(agent)
await sendfile(ctx, agent.includes('Kobo') ? 'download.html' : 'upload.html') await sendfile(ctx, agent.includes('Kobo') || agent.includes('Kindle')? 'download.html' : 'upload.html')
}) })

View File

@@ -1,19 +1,27 @@
body {
line-height: 1.3;
font-family: serif;
}
.wrapper { .wrapper {
margin: 0 auto; margin: 0 auto;
padding: 0 20px; padding: 0 20px;
max-width: 650px; max-width: 650px;
} }
h1 { h1 {
font-size: 2em;
font-weight: normal; font-weight: normal;
font-style: italic; font-style: italic;
} }
#key { #key {
font-size: 4.5em; font-size: 5em;
display: block; display: block;
letter-spacing: 0.1em; letter-spacing: 0.2em;
margin: 5px 0; margin: 10px 0;
font-family: monospace; font-family: sans-serif;
white-space: nowrap;
text-transform: uppercase;
} }
.center { .center {
text-align: center; text-align: center;
@@ -29,6 +37,8 @@ h1 {
padding: 0.6em 1.3em; padding: 0.6em 1.3em;
line-height: 1.6; line-height: 1.6;
display: inline-block; display: inline-block;
font-family: inherit;
font-size: 1.2em;
} }
#keygen:focus { #keygen:focus {
background: black; background: black;
@@ -47,4 +57,32 @@ h1 {
text-align: center; text-align: center;
font-family: monospace; font-family: monospace;
letter-spacing: 0.1em; letter-spacing: 0.1em;
}
#uploadstatus {
opacity: 0;
transition: opacity .5s ease-in-out;
padding: 10px;
margin-top: 10px;
border-radius: 5px;
text-align: center;
cursor: pointer;
line-height: 1.7;
}
#uploadstatus.success {
background-color: #DFD;
border: 1px solid #7F7;
}
#uploadstatus.error {
background-color: #FDD;
border: 1px solid #F77;
}
td.right {
padding: 10px;
}
#fileinfo {
font-size: 0.9em;
font-style: italic;
} }

View File

@@ -1,7 +1,7 @@
<!doctype html> <!doctype html>
<html> <html>
<head> <head>
<title>Send to Kobo</title> <title>Send to Kobo/Kindle</title>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="stylesheet" type="text/css" href="/style.css"/> <link rel="stylesheet" type="text/css" href="/style.css"/>
@@ -9,27 +9,84 @@
<body> <body>
<div class="wrapper"> <div class="wrapper">
<h1 class="center">Send to Kobo</h1> <h1 class="center">Send to Kobo/Kindle</h1>
<form action="/upload" method="post" enctype="multipart/form-data"> <form action="/upload" method="post" enctype="multipart/form-data">
<table style="margin: 0 auto;" cellpadding=10 cellspacing=0> <table style="margin: 0 auto;" cellpadding=0 cellspacing=0>
<tr><td class="right">Unique key</td><td><input type="text" name="key" id="keyinput" autocomplete="off" pattern="...." placeholder="----" required style="text-transform: uppercase;" maxlength=4/></td></tr> <tr><td class="right"><label for="keyinput">Unique key</label></td><td><input type="text" name="key" id="keyinput" autocomplete="off" pattern="...." placeholder="" required style="text-transform: uppercase;" maxlength=4/></td></tr>
<tr><td class="right">EPUB file</td><td><input type="file" name="file" accept=".epub,application/epub+zip" required /></td></tr> <tr><td class="right"><label for="fileinput">EPUB/MOBI file</label></td><td><input type="file" name="file" id="fileinput" accept=".epub,.mobi,application/epub+zip,application/x-mobipocket-ebook" required /></td></tr>
<tr><td></td><td id="fileinfo"></td></tr>
<tr><td class="right"><label for="kepubify">Kepubify</label></td><td><input type="checkbox" name="kepubify" id="kepubify" checked /></td></tr> <tr><td class="right"><label for="kepubify">Kepubify</label></td><td><input type="checkbox" name="kepubify" id="kepubify" checked /></td></tr>
<tr><td></td><td><input type="submit" value="Upload" /></td></tr> <tr><td></td><td><input type="submit" value="Upload and send" /></td></tr>
</table> </table>
<div id="uploadstatus"></div>
</form> </form>
<br/>
<div style="padding: 15px; text-align: justify;"> <div style="padding: 15px; text-align: justify;">
<p>Go this this page on your Kobo ereader and you see a unique key. Enter it in this form and upload an ebook and it will appear as a download link on the ereader.</p> <p>Go this this page on your Kobo/Kindle ereader and you see a unique key. Enter it in this form and upload an ebook and it will appear as a download link on the ereader.</p>
<p>Your ebook will be stored on the server as long as the Kobo is viewing the unique key and is connected to wifi. It will be deleted irrevocably when the key expires about 30 seconds after you close the browser, generate a new key or disable wifi on your Kobo.</p> <p>If you send an EPUB to to a Kindle it will be converted to MOBI with KindleGen. If you send a MOBI file it will be sent unprocessed. If you send an EPUB and check the Kepubify option it will be converted into a Kobo EPUB using Kepubify.</p>
<p>Your ebook will be stored on the server as long as your Kobo/Kindle is viewing the unique key and is connected to wifi. It will be deleted irrevocably when the key expires about 30 seconds after you close the browser, generate a new key or disable wifi on your ereader.</p>
<p>By using this tool you agree that the ebook you upload is processed on the server and stored for a short time.</p> <p>By using this tool you agree that the ebook you upload is processed on the server and stored for a short time.</p>
</div> </div>
<hr/> <hr/>
<div class="center"> <div class="center">
Created by djazz. Powered by <a href="https://koajs.com/" target="_blank">Koa</a> and <a href="https://pgaskin.net/kepubify/" target="_blank">Kepubify</a> | Source code on <a href="https://github.com/daniel-j/sendtokobo" target="_blank">Github</a> Created by djazz. Powered by <a href="https://koajs.com/" target="_blank">Koa</a>, <a href="https://pgaskin.net/kepubify/" target="_blank">Kepubify</a> and <a href="https://www.amazon.com/gp/feature.html?ie=UTF8&docId=1000765211" target="_blank">KindleGen</a><br/>Source code on <a href="https://github.com/daniel-j/send2ereader" target="_blank">Github</a>
</div> </div>
</div> </div>
<script>
function getCookies() {
var cookieRegex = /([\w\.]+)\s*=\s*(?:"((?:\\"|[^"])*)"|(.*?))\s*(?:[;,]|$)/g
var cookies = {}
var match
while( (match = cookieRegex.exec(document.cookie)) !== null ) {
var value = match[2] || match[3]
cookies[match[1]] = decodeURIComponent(value)
try {
cookies[match[1]] = JSON.parse(cookies[match[1]])
} catch (err) {}
}
return cookies
}
function deleteCookie(name) {
document.cookie = name + "= ; expires = Thu, 01 Jan 1970 00:00:00 GMT"
}
var uploadstatus = document.getElementById('uploadstatus')
var keyinput = document.getElementById('keyinput')
var fileinput = document.getElementById('fileinput')
var fileinfo = document.getElementById('fileinfo')
var flash = getCookies().flash
deleteCookie('flash')
if (flash) {
if (flash.message) {
if (flash.success) {
uploadstatus.className = " success"
uploadstatus.innerHTML = flash.message
} else {
uploadstatus.className = " error"
uploadstatus.textContent = flash.message
}
uploadstatus.style.opacity = 1
}
keyinput.value = flash.key || ''
}
uploadstatus.addEventListener('click', function () {
uploadstatus.style.opacity = 0
setTimeout(function () {
uploadstatus.textContent = ''
uploadstatus.className = ''
}, 500)
}, false)
function fileinputChange () {
if (!fileinput.files[0]) {
fileinfo.textContent = ''
return
}
fileinfo.textContent = parseInt(fileinput.files[0].size / 1024, 10) + 'kB'
}
fileinput.addEventListener('change', fileinputChange, false)
fileinputChange()
</script>
</body> </body>
</html> </html>