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

110
index.js
View File

@@ -8,13 +8,14 @@ const sendfile = require('koa-sendfile')
const mkdirp = require('mkdirp')
const fs = require('fs')
const { spawn } = require('child_process')
const { extname, basename, dirname } = require('path')
const port = 3001
const expireDelay = 30 // 30 seconds
const maxExpireDuration = 2 * 60 * 60 // 2 hours
const maxFileSize = 1024 * 1024 * 400 // 400 MB
const keyChars = "234689ACEFGHKLMNPRTXYZ"
const keyChars = "3469ACEGHLMNPRTY"
const keyLength = 4
function randomKey () {
@@ -56,6 +57,11 @@ function expireKey (key) {
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()
app.context.keys = new Map()
app.use(logger())
@@ -70,7 +76,7 @@ const upload = multer({
filename: function (req, file, cb) {
const uniqueSuffix = Date.now() + '-' + Math.floor(Math.random() * 1E9)
console.log(file)
cb(null, file.fieldname + '-' + uniqueSuffix + '.epub')
cb(null, file.fieldname + '-' + uniqueSuffix + extname(file.originalname).toLowerCase())
}
}),
limits: {
@@ -84,8 +90,9 @@ const upload = multer({
cb(null, false)
return
}
if (!file.originalname.toLowerCase().endsWith('.epub')) {
console.error('FileFilter: Filename does not end with .epub: ' + file.originalname)
const nameLower = file.originalname.toLowerCase()
if (!nameLower.endsWith('.epub') && !nameLower.endsWith('.mobi')) {
console.error('FileFilter: Filename does not end with .epub or .mobi: ' + file.originalname)
cb(null, false)
return
}
@@ -95,8 +102,8 @@ const upload = multer({
router.post('/generate', async ctx => {
const agent = ctx.get('user-agent')
if (!agent.includes('Kobo')) {
console.error('Non-Kobo device tried to generate a key: ' + agent)
if (!agent.includes('Kobo') && !agent.includes('Kindle')) {
console.error('Non-Kobo or Kindle device tried to generate a key: ' + agent)
ctx.throw(403)
}
let key = null
@@ -122,7 +129,9 @@ router.post('/generate', async ctx => {
}
ctx.keys.set(key, info)
expireKey(key)
setTimeout(removeKey, maxExpireDuration * 1000, key)
setTimeout(() => {
if(ctx.keys.has(key)) removeKey(key)
}, maxExpireDuration * 1000)
ctx.body = key
})
@@ -138,9 +147,8 @@ router.get('/download/:key', async ctx => {
return
}
expireKey(key)
console.log('Sending file!')
console.log('Sending file', info.file.path)
await sendfile(ctx, info.file.path)
// ctx.type = 'application/epub+zip'
ctx.attachment(info.file.name)
})
@@ -148,31 +156,85 @@ router.post('/upload', upload.single('file'), async ctx => {
const key = ctx.request.body.key.toUpperCase()
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) {
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)
ctx.throw(400, 'Invalid or no file submitted')
}
if (!ctx.request.file.originalname.toLowerCase().endsWith('.epub')) {
ctx.throw(400, 'Uploaded file does not end with .epub ' + ctx.request.file.originalname)
ctx.redirect('back', '/')
return
}
const info = ctx.keys.get(key)
expireKey(key)
let data = null
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')
filename = filename.replace(/\.kepub\.epub$/i, '.epub').replace(/\.epub$/i, '.kepub.epub')
data = await new Promise((resolve, reject) => {
const kepubify = spawn('kepubify', ['-v', '-u', '-o', outname, ctx.request.file.path], {
stdio: 'inherit'
const kepubify = spawn('kepubify', ['-v', '-u', '-o', basename(outname), basename(ctx.request.file.path)], {
stdio: 'inherit',
cwd: dirname(ctx.request.file.path)
})
kepubify.once('close', (code) => {
fs.unlink(ctx.request.file.path, (err) => {
if (err) console.error(err)
else console.log('Remove file', ctx.request.file.path)
})
if (code !== 0) {
reject('kepubify error code ' + code)
@@ -183,14 +245,15 @@ router.post('/upload', upload.single('file'), async ctx => {
})
})
} else {
// No conversion
data = ctx.request.file.path
}
expireKey(key)
const info = ctx.keys.get(key)
if (info.file && info.file.path) {
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()
}))
}
@@ -201,6 +264,11 @@ router.post('/upload', upload.single('file'), async ctx => {
uploaded: new Date()
}
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', '/')
})
@@ -243,7 +311,7 @@ router.get('/style.css', async ctx => {
router.get('/', async ctx => {
const agent = ctx.get('user-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 {
margin: 0 auto;
padding: 0 20px;
max-width: 650px;
}
h1 {
font-size: 2em;
font-weight: normal;
font-style: italic;
}
#key {
font-size: 4.5em;
font-size: 5em;
display: block;
letter-spacing: 0.1em;
margin: 5px 0;
font-family: monospace;
letter-spacing: 0.2em;
margin: 10px 0;
font-family: sans-serif;
white-space: nowrap;
text-transform: uppercase;
}
.center {
text-align: center;
@@ -29,6 +37,8 @@ h1 {
padding: 0.6em 1.3em;
line-height: 1.6;
display: inline-block;
font-family: inherit;
font-size: 1.2em;
}
#keygen:focus {
background: black;
@@ -47,4 +57,32 @@ h1 {
text-align: center;
font-family: monospace;
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>
<html>
<head>
<title>Send to Kobo</title>
<title>Send to Kobo/Kindle</title>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="stylesheet" type="text/css" href="/style.css"/>
@@ -9,27 +9,84 @@
<body>
<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">
<table style="margin: 0 auto;" cellpadding=10 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">EPUB file</td><td><input type="file" name="file" accept=".epub,application/epub+zip" required /></td></tr>
<table style="margin: 0 auto;" cellpadding=0 cellspacing=0>
<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"><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></td><td><input type="submit" value="Upload" /></td></tr>
<tr><td></td><td><input type="submit" value="Upload and send" /></td></tr>
</table>
<div id="uploadstatus"></div>
</form>
<br/>
<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>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>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>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>
</div>
<hr/>
<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>
<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>
</html>