├── .gitignore ├── .dockerignore ├── docker-compose.yaml ├── patches └── @koa+multer+3.0.2.patch ├── package.json ├── LICENSE ├── static ├── common.js ├── style.css ├── download.html └── upload.html ├── README.md ├── Dockerfile └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | uploads -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | /uploads 3 | /node_modules 4 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | send2ereader: 3 | build: 4 | context: . 5 | dockerfile: ./Dockerfile 6 | container_name: send2ereader 7 | # Restart means, Automatic re-start the container after a shutdown unless manual stop container 8 | restart: unless-stopped 9 | # Ports means, Bind external and internal docker ports, only change the external port, not the internal port 10 | # Format: : 11 | ports: 12 | - 3001:3001 13 | -------------------------------------------------------------------------------- /patches/@koa+multer+3.0.2.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/@koa/multer/index.js b/node_modules/@koa/multer/index.js 2 | index d5be076..252208e 100644 3 | --- a/node_modules/@koa/multer/index.js 4 | +++ b/node_modules/@koa/multer/index.js 5 | @@ -11,9 +11,7 @@ 6 | * Module dependencies. 7 | */ 8 | 9 | -let originalMulter = require('fix-esm').require('multer'); 10 | - 11 | -if (originalMulter.default) originalMulter = originalMulter.default; 12 | +let originalMulter = require('multer'); 13 | 14 | function multer(options) { 15 | const m = originalMulter(options); 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "send2ereader", 3 | "version": "1.0.0", 4 | "description": "Send ebooks to Kobo/Kindle ereaders easily", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node index", 8 | "postinstall": "patch-package" 9 | }, 10 | "author": "djazz", 11 | "license": "MIT", 12 | "dependencies": { 13 | "@koa/multer": "^3.0.2", 14 | "@koa/router": "^13.1.0", 15 | "file-type": "^16.5.4", 16 | "koa": "^2.15.3", 17 | "koa-logger": "^3.2.1", 18 | "koa-sendfile": "^3.0.0", 19 | "koa-static": "^5.0.0", 20 | "mkdirp": "^3.0.1", 21 | "multer": "^1.4.5-lts.1", 22 | "patch-package": "^8.0.0", 23 | "sanitize-filename": "^1.6.3", 24 | "transliteration": "^2.3.5" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 djazz 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /static/common.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | function log(str) { 4 | var node = document.createElement("div") 5 | node.textContent = str 6 | logs.appendChild(node) 7 | } 8 | window.addEventListener("error", function (event) { 9 | log(event.filename + ":" + event.lineno + " " + event.message) 10 | }, false) 11 | 12 | function xhr(method, url, cb) { 13 | var x = new XMLHttpRequest() 14 | x.onload = function () { 15 | cb(x) 16 | } 17 | x.onerror = function () { 18 | cb(x) 19 | } 20 | x.open(method, url, true) 21 | x.send(null) 22 | } 23 | 24 | var isIOS = /iPad|iPhone|iPod/.test(navigator.platform) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) 25 | 26 | function getCookies() { 27 | var cookieRegex = /([\w\.]+)\s*=\s*(?:"((?:\\"|[^"])*)"|(.*?))\s*(?:[;,]|$)/g 28 | var cookies = {} 29 | var match 30 | while( (match = cookieRegex.exec(document.cookie)) !== null ) { 31 | var value = match[2] || match[3] 32 | cookies[match[1]] = decodeURIComponent(value) 33 | try { 34 | cookies[match[1]] = JSON.parse(cookies[match[1]]) 35 | } catch (err) {} 36 | } 37 | return cookies 38 | } 39 | // function deleteCookie(name) { 40 | // document.cookie = name + "= ; expires = Thu, 01 Jan 1970 00:00:00 GMT" 41 | // } 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # send2ereader 2 | 3 | A self hostable service for sending ebooks to a Kobo or Kindle ereader through the built-in browser. 4 | 5 | ## How To Run 6 | 7 | ### On Your Host OS 8 | 9 | 1. Have Node.js 16 or 20 installed 10 | 2. Install this service's dependencies by running `$ npm install` 11 | 3. Install [Kepubify](https://github.com/pgaskin/kepubify), and have the kepubify executable in your PATH. 12 | 4. Install [KindleGen](http://web.archive.org/web/*/http://kindlegen.s3.amazonaws.com/kindlegen*), and have the kindlegen executable in your PATH. 13 | 5. Install [pdfCropMargins](https://github.com/abarker/pdfCropMargins), and have the pdfcropmargins executable in your PATH. 14 | 6. Start this service by running: `$ npm start` and access it on HTTP port 3001 15 | 16 | ### Containerized 17 | 1. You need [Docker](https://www.docker.com/) and [docker-compose](https://docs.docker.com/compose/) installed 18 | 2. Clone this repo (you need Dockerfile, docker-compose.yaml and package.json in the same directory) 19 | ``` 20 | git clone https://github.com/daniel-j/send2ereader.git 21 | ``` 22 | 3. Build the image 23 | ``` 24 | docker compose build 25 | ``` 26 | 4. run container (-d to keep running in the background) 27 | ``` 28 | docker compose up -d 29 | ``` 30 | 5. Access the service on HTTP, default port 3001 (http://localhost:3001) 31 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # node 20 is lts at the time of writing 2 | FROM node:lts-alpine 3 | 4 | # Create app directory 5 | WORKDIR /usr/src/app 6 | 7 | # Download and install kepubify 8 | RUN wget https://github.com/pgaskin/kepubify/releases/download/v4.0.4/kepubify-linux-64bit && \ 9 | mv kepubify-linux-64bit /usr/local/bin/kepubify && \ 10 | chmod +x /usr/local/bin/kepubify 11 | 12 | # Download and install kindlegen 13 | RUN wget https://github.com/zzet/fp-docker/raw/f2b41fb0af6bb903afd0e429d5487acc62cb9df8/kindlegen_linux_2.6_i386_v2_9.tar.gz && \ 14 | echo "9828db5a2c8970d487ada2caa91a3b6403210d5d183a7e3849b1b206ff042296 kindlegen_linux_2.6_i386_v2_9.tar.gz" | sha256sum -c && \ 15 | mkdir kindlegen && \ 16 | tar xvf kindlegen_linux_2.6_i386_v2_9.tar.gz --directory kindlegen && \ 17 | cp kindlegen/kindlegen /usr/local/bin/kindlegen && \ 18 | chmod +x /usr/local/bin/kindlegen && \ 19 | rm -rf kindlegen 20 | 21 | RUN apk add --no-cache pipx 22 | 23 | ENV PATH="$PATH:/root/.local/bin" 24 | 25 | RUN pipx install pdfCropMargins 26 | 27 | # Copy files needed by npm install 28 | COPY package*.json ./ 29 | 30 | # Install app dependencies 31 | RUN npm install --omit=dev 32 | 33 | # Copy the rest of the app files (see .dockerignore) 34 | COPY . ./ 35 | 36 | # Create uploads directory if it doesn't exist 37 | RUN mkdir uploads 38 | 39 | EXPOSE 3001 40 | CMD [ "npm", "start" ] 41 | -------------------------------------------------------------------------------- /static/style.css: -------------------------------------------------------------------------------- 1 | 2 | body { 3 | line-height: 1.3; 4 | font-family: serif; 5 | } 6 | 7 | .wrapper { 8 | margin: 0 auto; 9 | padding: 0 20px; 10 | max-width: 650px; 11 | } 12 | h1 { 13 | font-size: 2em; 14 | font-weight: normal; 15 | font-style: italic; 16 | } 17 | #keyoutput { 18 | font-size: 5em; 19 | display: inline-block; 20 | letter-spacing: 0.2em; 21 | margin: 10px 0; 22 | font-family: monospace, sans-serif; 23 | white-space: nowrap; 24 | text-transform: uppercase; 25 | vertical-align: middle; 26 | } 27 | .center { 28 | text-align: center; 29 | } 30 | .right { 31 | text-align: right; 32 | } 33 | #keygen, .downloadlink, #choosebtn, input[type="submit"] { 34 | background: #CCC; 35 | border: none; 36 | color: black; 37 | font-style: italic; 38 | padding: 0.6em 1.3em; 39 | line-height: 1.6; 40 | display: inline-block; 41 | font-family: inherit; 42 | font-size: 1.2em; 43 | margin: 0 0.5em; 44 | vertical-align: middle; 45 | cursor: pointer; 46 | font-weight: normal; 47 | } 48 | #keygen { 49 | margin-left: 1em; 50 | margin-right: -2em; 51 | padding: 1em; 52 | } 53 | #keygen:focus, #downloadlink:focus, #choosebtn:focus, input[type="submit"]:focus, 54 | #keygen:active, #downloadlink:active, #choosebtn:active, input[type="submit"]:active { 55 | background: black; 56 | color: white; 57 | } 58 | #keygen svg { 59 | display: block; 60 | width: 1.5em; 61 | height: 1.5em; 62 | } 63 | .downloadlink { 64 | margin: 0.5em; 65 | } 66 | #downloads { 67 | display: none; 68 | } 69 | 70 | 71 | 72 | 73 | #keyinput { 74 | font-size: 4em; 75 | width: 3.5em; 76 | text-align: center; 77 | font-family: monospace; 78 | letter-spacing: 0.1em; 79 | } 80 | 81 | input[type="url"], input[type="text"] { 82 | background: white; 83 | border: 1px solid #AAA; 84 | padding: 5px; 85 | font-family: serif; 86 | } 87 | 88 | #uploadstatus { 89 | opacity: 0; 90 | transition: opacity .5s ease-in-out; 91 | padding: 10px; 92 | margin-top: 20px; 93 | border-radius: 5px; 94 | text-align: center; 95 | cursor: pointer; 96 | line-height: 1.7; 97 | } 98 | 99 | #uploadstatus.success { 100 | background-color: #DFD; 101 | border: 1px solid #7F7; 102 | } 103 | 104 | #uploadstatus.error { 105 | background-color: #FDD; 106 | border: 1px solid #F77; 107 | white-space: pre; 108 | } 109 | td { 110 | padding: 10px; 111 | } 112 | 113 | td.aligntop { 114 | vertical-align: top; 115 | } 116 | 117 | #fileinput { 118 | display: none; 119 | } 120 | 121 | #fileinfo { 122 | font-size: 0.9em; 123 | font-style: italic; 124 | width: 18em; 125 | } 126 | -------------------------------------------------------------------------------- /static/download.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Send to Kobo/Kindle 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 |

Send to Kobo/Kindle

14 | 15 |
16 |
Unique key:
17 |
18 | –––– 19 | 24 |
25 |
26 | 27 |
28 |
29 |
30 |
Downloads
31 | 32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | Visit this on other devices to send ebooks to this ereader:
https://send.djazz.se
40 |
41 | Created by djazz. Source code on Github
42 | Last updated: 2024-10-20
43 |
44 |
45 | 46 |
47 | 48 | 135 | 136 | 137 | -------------------------------------------------------------------------------- /static/upload.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Send to Kobo/Kindle 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 |

Send to Kobo/Kindle

14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |

EPUB, MOBI, PDF,
TXT, CBZ, CBR


25 |
26 |
27 |
28 |

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.

29 |

If you send an EPUB file to to a Kindle it will be converted to MOBI with KindleGen. If you send a MOBI file to a Kindle it will be sent unprocessed. Files sent to Kindle eReaders will have their names stripped of special characters, a limitation of the Kindle browser. If you send an EPUB file and tick the Kepubify checkbox, it will be converted into a Kobo EPUB using Kepubify. If you send a MOBI file to a Kobo, it will not be converted.

30 |

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.

31 |

By using this tool you agree that the ebook you upload is processed on the server and stored for a short time.

32 |
33 |
34 |
35 | Created by djazz. Powered by Koa, Kepubify, KindleGen and pdfCropMargins
36 | Source code on Github - https://send.djazz.se
37 | Last updated: 2024-10-20 38 |
39 |
40 | 41 |
42 | 43 | 186 | 187 | 188 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const http = require('http') 4 | const Koa = require('koa') 5 | const Router = require('@koa/router') 6 | const multer = require('@koa/multer') 7 | const logger = require('koa-logger') 8 | const sendfile = require('koa-sendfile') 9 | const serve = require('koa-static') 10 | const { mkdirp } = require('mkdirp') 11 | const fs = require('fs') 12 | const { spawn } = require('child_process') 13 | const { join, extname, basename, dirname } = require('path') 14 | const resolvepath = require('path').resolve 15 | const FileType = require('file-type') 16 | const { transliterate } = require('transliteration') 17 | const sanitize = require('sanitize-filename') 18 | 19 | const port = 3001 20 | const expireDelay = 30 // 30 seconds 21 | const maxExpireDuration = 1 * 60 * 60 // 1 hour 22 | const maxFileSize = 1024 * 1024 * 800 // 800 MB 23 | 24 | const TYPE_EPUB = 'application/epub+zip' 25 | const TYPE_MOBI = 'application/x-mobipocket-ebook' 26 | 27 | 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'] 28 | const allowedExtensions = ['epub', 'mobi', 'pdf', 'cbz', 'cbr', 'html', 'txt'] 29 | 30 | const keyChars = "23456789ACDEFGHJKLMNPRSTUVWXYZ" 31 | const keyLength = 4 32 | 33 | 34 | function doTransliterate(filename) { 35 | let name = filename.split(".") 36 | const ext = "." + name.splice(-1).join(".") 37 | name = name.join(".") 38 | 39 | return transliterate(name) + ext 40 | } 41 | 42 | function randomKey () { 43 | const choices = Math.pow(keyChars.length, keyLength) 44 | const rnd = Math.floor(Math.random() * choices) 45 | 46 | return rnd.toString(keyChars.length).padStart(keyLength, '0').split('').map((chr) => { 47 | return keyChars[parseInt(chr, keyChars.length)] 48 | }).join('') 49 | } 50 | 51 | function removeKey (key) { 52 | console.log('Removing expired key', key) 53 | const info = app.context.keys.get(key) 54 | if (info) { 55 | clearTimeout(app.context.keys.get(key).timer) 56 | if (info.file) { 57 | console.log('Deleting file', info.file.path) 58 | fs.unlink(info.file.path, (err) => { 59 | if (err) console.error(err) 60 | }) 61 | info.file = null 62 | } 63 | app.context.keys.delete(key) 64 | } else { 65 | console.log('Tried to remove non-existing key', key) 66 | } 67 | } 68 | 69 | function expireKey (key) { 70 | // console.log('key', key, 'will expire in', expireDelay, 'seconds') 71 | const info = app.context.keys.get(key) 72 | const timer = setTimeout(removeKey, expireDelay * 1000, key) 73 | if (info) { 74 | clearTimeout(info.timer) 75 | info.timer = timer 76 | info.alive = new Date() 77 | } 78 | return timer 79 | } 80 | 81 | function flash (ctx, data) { 82 | console.log(data) 83 | //ctx.cookies.set('flash', encodeURIComponent(JSON.stringify(data)), {overwrite: true, httpOnly: false, sameSite: 'strict', maxAge: 10 * 1000}) 84 | ctx.response.status = data.success ? 200 : 400 85 | if (!data.success) { 86 | ctx.set("Connection", "close") 87 | } 88 | ctx.body = data.message 89 | } 90 | 91 | const app = new Koa() 92 | app.context.keys = new Map() 93 | app.use(logger()) 94 | 95 | const router = new Router() 96 | 97 | const upload = multer({ 98 | storage: multer.diskStorage({ 99 | destination: function (req, file, cb) { 100 | cb(null, 'uploads') 101 | }, 102 | filename: function (req, file, cb) { 103 | const uniqueSuffix = Date.now() + '-' + Math.floor(Math.random() * 1E9) 104 | cb(null, file.fieldname + '-' + uniqueSuffix + extname(file.originalname).toLowerCase()) 105 | } 106 | }), 107 | limits: { 108 | fileSize: maxFileSize, 109 | files: 1 110 | }, 111 | fileFilter: (req, file, cb) => { 112 | // Fixes charset 113 | // https://github.com/expressjs/multer/issues/1104#issuecomment-1152987772 114 | file.originalname = sanitize(Buffer.from(file.originalname, 'latin1').toString('utf8')) 115 | 116 | console.log('Incoming file:', file) 117 | const key = req.body.key.toUpperCase() 118 | if (!app.context.keys.has(key)) { 119 | console.error('FileFilter: Unknown key: ' + key) 120 | cb("Unknown key " + key, false) 121 | return 122 | } 123 | if ((!allowedTypes.includes(file.mimetype) && file.mimetype != "application/octet-stream") || !allowedExtensions.includes(extname(file.originalname.toLowerCase()).substring(1))) { 124 | console.error('FileFilter: File is of an invalid type ', file) 125 | cb("Invalid filetype: " + JSON.stringify(file), false) 126 | return 127 | } 128 | cb(null, true) 129 | } 130 | }) 131 | 132 | router.post('/generate', async ctx => { 133 | const agent = ctx.get('user-agent') 134 | 135 | let key = null 136 | let attempts = 0 137 | console.log('There are currently', ctx.keys.size, 'key(s) in use.') 138 | console.log('Generating unique key...', ctx.ip, agent) 139 | do { 140 | key = randomKey() 141 | if (attempts > ctx.keys.size) { 142 | console.error('Can\'t generate more keys, map is full.', attempts, ctx.keys.size) 143 | ctx.body = 'error' 144 | return 145 | } 146 | attempts++ 147 | } while (ctx.keys.has(key)) 148 | 149 | console.log('Generated key ' + key + ', '+attempts+' attempt(s)') 150 | 151 | const info = { 152 | created: new Date(), 153 | agent: agent, 154 | file: null, 155 | urls: [] 156 | } 157 | ctx.keys.set(key, info) 158 | expireKey(key) 159 | setTimeout(() => { 160 | // remove if it is the same object 161 | if(ctx.keys.get(key) === info) removeKey(key) 162 | }, maxExpireDuration * 1000) 163 | 164 | ctx.cookies.set('key', key, {overwrite: true, httpOnly: false, sameSite: 'strict', maxAge: expireDelay * 1000}) 165 | 166 | ctx.body = key 167 | }) 168 | 169 | /* 170 | router.get('/download/:key', async ctx => { 171 | const key = ctx.cookies.get('key') 172 | if (!key) { 173 | await next() 174 | return 175 | } 176 | 177 | const info = ctx.keys.get(key) 178 | 179 | if (!info || !info.file) { 180 | await next() 181 | return 182 | } 183 | 184 | ctx.redirect('/' + encodeURIComponent(info.file.name)); 185 | }) 186 | */ 187 | 188 | async function downloadFile (ctx, next) { 189 | const key = ctx.query.key 190 | if (!key) { 191 | await next() 192 | return 193 | } 194 | 195 | const filename = decodeURIComponent(ctx.params.filename) 196 | const info = ctx.keys.get(key) 197 | 198 | if (!info || !info.file || info.file.name !== filename) { 199 | await next() 200 | return 201 | } 202 | if (info.agent !== ctx.get('user-agent')) { 203 | console.error("User Agent doesnt match: " + info.agent + " VS " + ctx.get('user-agent')) 204 | return 205 | } 206 | expireKey(key) 207 | console.log('Sending file', [info.file.path, info.file.name]) 208 | if (info.agent.includes('Kindle')) { 209 | // Kindle needs a safe name or it thinks it's an invalid file 210 | ctx.attachment(info.file.name) 211 | } 212 | await sendfile(ctx, info.file.path) 213 | } 214 | 215 | router.post('/upload', async (ctx, next) => { 216 | 217 | try { 218 | await upload.single('file')(ctx, () => {}) 219 | } catch (err) { 220 | flash(ctx, { 221 | message: err, 222 | success: false 223 | }) 224 | // ctx.throw(400, err) 225 | // ctx.res.end(err) 226 | await next() 227 | return 228 | } 229 | 230 | ctx.res.writeContinue() 231 | 232 | const key = ctx.request.body.key.toUpperCase() 233 | 234 | if (ctx.request.file) { 235 | console.log('Uploaded file:', ctx.request.file) 236 | } 237 | 238 | if (!ctx.keys.has(key)) { 239 | flash(ctx, { 240 | message: 'Unknown key ' + key, 241 | success: false 242 | }) 243 | if (ctx.request.file) { 244 | fs.unlink(ctx.request.file.path, (err) => { 245 | if (err) console.error(err) 246 | else console.log('Removed file', ctx.request.file.path) 247 | }) 248 | } 249 | await next() 250 | return 251 | } 252 | 253 | const info = ctx.keys.get(key) 254 | expireKey(key) 255 | 256 | let url = null 257 | if (ctx.request.body.url) { 258 | url = ctx.request.body.url.trim() 259 | if (url.length > 0 && !info.urls.includes(url)) { 260 | info.urls.push(url) 261 | } 262 | } 263 | 264 | let conversion = null 265 | let filename = "" 266 | 267 | if (ctx.request.file) { 268 | if (ctx.request.file.size === 0) { 269 | let data = { 270 | message: 'Invalid file submitted (empty file)', 271 | success: false, 272 | key: key 273 | } 274 | flash(ctx, data) 275 | fs.unlink(ctx.request.file.path, (err) => { 276 | if (err) console.error(err) 277 | else console.log('Removed file', ctx.request.file.path) 278 | }) 279 | await next() 280 | return 281 | } 282 | 283 | let mimetype = ctx.request.file.mimetype 284 | 285 | const type = await FileType.fromFile(ctx.request.file.path) 286 | 287 | if (mimetype == "application/octet-stream" && type) { 288 | mimetype = type.mime 289 | } 290 | 291 | if (mimetype == "application/epub") { 292 | mimetype = TYPE_EPUB 293 | } 294 | 295 | if ((!type || !allowedTypes.includes(type.mime)) && !allowedTypes.includes(mimetype)) { 296 | flash(ctx, { 297 | message: 'Uploaded file is of an invalid type: ' + ctx.request.file.originalname + ' (' + (type? type.mime : 'unknown mimetype') + ')', 298 | success: false, 299 | key: key 300 | }) 301 | fs.unlink(ctx.request.file.path, (err) => { 302 | if (err) console.error(err) 303 | else console.log('Removed file', ctx.request.file.path) 304 | }) 305 | await next() 306 | return 307 | } 308 | 309 | let data = null 310 | filename = ctx.request.file.originalname 311 | if (ctx.request.body.transliteration) { 312 | filename = sanitize(doTransliterate(filename)) 313 | } 314 | if (info.agent.includes('Kindle')) { 315 | filename = filename.replace(/[^\.\w\-"'\(\)]/g, '_') 316 | } 317 | 318 | if (mimetype === TYPE_EPUB && info.agent.includes('Kindle') && ctx.request.body.kindlegen) { 319 | // convert to .mobi 320 | conversion = 'kindlegen' 321 | const outname = ctx.request.file.path.replace(/\.epub$/i, '.mobi') 322 | filename = filename.replace(/\.kepub\.epub$/i, '.epub').replace(/\.epub$/i, '.mobi') 323 | let stderr = '' 324 | 325 | let p = new Promise((resolve, reject) => { 326 | const kindlegen = spawn('kindlegen', [basename(ctx.request.file.path), '-dont_append_source', '-c1', '-o', basename(outname)], { 327 | // stdio: 'inherit', 328 | cwd: dirname(ctx.request.file.path) 329 | }) 330 | kindlegen.once('error', function (err) { 331 | fs.unlink(ctx.request.file.path, (err) => { 332 | if (err) console.error(err) 333 | else console.log('Removed file', ctx.request.file.path) 334 | }) 335 | fs.unlink(ctx.request.file.path.replace(/\.epub$/i, '.mobi8'), (err) => { 336 | if (err) console.error(err) 337 | else console.log('Removed file', ctx.request.file.path.replace(/\.epub$/i, '.mobi8')) 338 | }) 339 | reject('kindlegen error: ' + err) 340 | }) 341 | kindlegen.once('close', (code) => { 342 | fs.unlink(ctx.request.file.path, (err) => { 343 | if (err) console.error(err) 344 | else console.log('Removed file', ctx.request.file.path) 345 | }) 346 | fs.unlink(ctx.request.file.path.replace(/\.epub$/i, '.mobi8'), (err) => { 347 | if (err) console.error(err) 348 | else console.log('Removed file', ctx.request.file.path.replace(/\.epub$/i, '.mobi8')) 349 | }) 350 | if (code !== 0 && code !== 1) { 351 | reject('kindlegen error code: ' + code + '\n' + stderr) 352 | return 353 | } 354 | 355 | resolve(outname) 356 | }) 357 | kindlegen.stdout.on('data', function (str) { 358 | stderr += str 359 | console.log('kindlegen: ' + str) 360 | }) 361 | kindlegen.stderr.on('data', function (str) { 362 | stderr += str 363 | console.log('kindlegen: ' + str) 364 | }) 365 | }) 366 | try { 367 | data = await p 368 | } catch (err) { 369 | flash(ctx, { 370 | success: false, 371 | message: err.replaceAll(basename(ctx.request.file.path), "infile.epub").replaceAll(basename(outname), "outfile.mobi") 372 | }) 373 | return 374 | } 375 | 376 | } else if (mimetype === TYPE_EPUB && info.agent.includes('Kobo') && ctx.request.body.kepubify) { 377 | // convert to Kobo EPUB 378 | conversion = 'kepubify' 379 | const outname = ctx.request.file.path.replace(/\.epub$/i, '.kepub.epub') 380 | filename = filename.replace(/\.kepub\.epub$/i, '.epub').replace(/\.epub$/i, '.kepub.epub') 381 | 382 | let p = new Promise((resolve, reject) => { 383 | let stderr = '' 384 | const kepubify = spawn('kepubify', ['-v', '-u', '-o', basename(outname), basename(ctx.request.file.path)], { 385 | //stdio: 'inherit', 386 | cwd: dirname(ctx.request.file.path) 387 | }) 388 | kepubify.once('error', function (err) { 389 | fs.unlink(ctx.request.file.path, (err) => { 390 | if (err) console.error(err) 391 | else console.log('Removed file', ctx.request.file.path) 392 | }) 393 | reject('kepubify error: ' + err) 394 | }) 395 | kepubify.once('close', (code) => { 396 | fs.unlink(ctx.request.file.path, (err) => { 397 | if (err) console.error(err) 398 | else console.log('Removed file', ctx.request.file.path) 399 | }) 400 | if (code !== 0) { 401 | reject('Kepubify error code: ' + code + '\n' + stderr) 402 | return 403 | } 404 | 405 | resolve(outname) 406 | }) 407 | kepubify.stdout.on('data', function (str) { 408 | stderr += str 409 | console.log('kepubify: ' + str) 410 | }) 411 | kepubify.stderr.on('data', function (str) { 412 | stderr += str 413 | console.log('kepubify: ' + str) 414 | }) 415 | }) 416 | try { 417 | data = await p 418 | } catch (err) { 419 | flash(ctx, { 420 | success: false, 421 | message: err.replaceAll(basename(ctx.request.file.path), "infile.epub").replaceAll(basename(outname), "outfile.kepub.epub") 422 | }) 423 | return 424 | } 425 | 426 | } else if (mimetype == 'application/pdf' && ctx.request.body.pdfcropmargins) { 427 | const dir = dirname(ctx.request.file.path) 428 | const base = basename(ctx.request.file.path, '.pdf') 429 | const outfile = resolvepath(join(dir, `${base}_cropped.pdf`)) 430 | let p = new Promise((resolve, reject) => { 431 | let stderr = '' 432 | const pdfcropmargins = spawn('pdfcropmargins', ['-s', '-u', '-o', outfile, basename(ctx.request.file.path)], { 433 | // stdio: 'inherit', 434 | cwd: dirname(ctx.request.file.path) 435 | }) 436 | pdfcropmargins.once('error', function (err) { 437 | fs.unlink(ctx.request.file.path, (err) => { 438 | if (err) console.error(err) 439 | else console.log('Removed file', ctx.request.file.path) 440 | }) 441 | reject('pdfcropmargins error: ' + err) 442 | }) 443 | pdfcropmargins.once('close', (code) => { 444 | fs.unlink(ctx.request.file.path, (err) => { 445 | if (err) console.error(err) 446 | else console.log('Removed file', ctx.request.file.path) 447 | }) 448 | if (code !== 0) { 449 | reject('pdfcropmargins error code: ' + code + '\n' + stderr) 450 | return 451 | } 452 | 453 | resolve(outfile) 454 | }) 455 | pdfcropmargins.stdout.on('data', function (str) { 456 | stderr += str 457 | console.log('pdfcropmargins: ' + str) 458 | }) 459 | pdfcropmargins.stderr.on('data', function (str) { 460 | stderr += str 461 | console.log('pdfcropmargins: ' + str) 462 | }) 463 | }) 464 | try { 465 | data = await p 466 | } catch (err) { 467 | flash(ctx, { 468 | success: false, 469 | message: err.replaceAll(basename(ctx.request.file.path), "infile.pdf").replaceAll(outfile, "outfile.pdf") 470 | }) 471 | return 472 | } 473 | 474 | } else { 475 | // No conversion 476 | data = ctx.request.file.path 477 | filename = filename.replace(/\.epub$/i, '.epub').replace(/\.pdf$/i, '.pdf') 478 | } 479 | 480 | expireKey(key) 481 | if (info.file && info.file.path) { 482 | await new Promise((resolve, reject) => fs.unlink(info.file.path, (err) => { 483 | if (err) return reject(err) 484 | else console.log('Removed previously uploaded file', info.file.path) 485 | resolve() 486 | })) 487 | } 488 | info.file = { 489 | name: filename, 490 | path: data, 491 | // size: ctx.request.file.size, 492 | uploaded: new Date() 493 | } 494 | } 495 | 496 | let messages = [] 497 | if (ctx.request.file) { 498 | ctx.request.file.skip = true 499 | 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.'))) 500 | messages.push('Filename: ' + filename) 501 | } 502 | if (url) { 503 | messages.push("Added url: " + url) 504 | } 505 | 506 | if (messages.length === 0) { 507 | flash(ctx, { 508 | message: 'No file or url selected', 509 | success: false, 510 | key: key 511 | }) 512 | await next() 513 | return 514 | } 515 | 516 | flash(ctx, { 517 | message: messages.join("
"), 518 | success: true, 519 | key: key, 520 | url: url 521 | }) 522 | 523 | await next() 524 | }) 525 | 526 | router.delete('/file/:key', async ctx => { 527 | const key = ctx.params.key.toUpperCase() 528 | const info = ctx.keys.get(key) 529 | if (!info) { 530 | ctx.throw(400, 'Unknown key: ' + key) 531 | } 532 | info.file = null 533 | ctx.body = 'ok' 534 | }) 535 | 536 | router.get('/status/:key', async ctx => { 537 | const key = ctx.params.key.toUpperCase() 538 | const info = ctx.keys.get(key) 539 | if (!info) { 540 | ctx.response.status = 404 541 | ctx.body = {error: 'Unknown key'} 542 | return 543 | } 544 | if (info.agent !== ctx.get('user-agent')) { 545 | // don't send this error to client 546 | console.error("User Agent doesnt match: " + info.agent + " VS " + ctx.get('user-agent')) 547 | return 548 | } 549 | expireKey(key) 550 | // ctx.cookies.set('key', key, {overwrite: true, httpOnly: false, sameSite: 'strict', maxAge: expireDelay * 1000}) 551 | ctx.body = { 552 | alive: info.alive, 553 | file: info.file ? { 554 | name: info.file.name, 555 | // size: info.file.size 556 | } : null, 557 | urls: info.urls 558 | } 559 | }) 560 | 561 | router.get('/receive', async ctx => { 562 | await sendfile(ctx, 'static/download.html') 563 | }) 564 | 565 | router.get('/', async ctx => { 566 | const agent = ctx.get('user-agent') 567 | console.log(ctx.ip, agent) 568 | 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') 569 | }) 570 | 571 | router.get('/:filename', downloadFile) 572 | 573 | app.use(serve("static")) 574 | 575 | app.use(router.routes()) 576 | app.use(router.allowedMethods()) 577 | 578 | 579 | fs.rm('uploads', {recursive: true}, (err) => { 580 | if (err) throw err 581 | mkdirp('uploads').then (() => { 582 | // app.listen(port) 583 | const fn = app.callback() 584 | const server = http.createServer(fn) 585 | server.on('checkContinue', (req, res) => { 586 | console.log("check continue!") 587 | fn(req, res) 588 | }) 589 | server.listen(port) 590 | console.log('server is listening on port ' + port) 591 | }) 592 | }) 593 | --------------------------------------------------------------------------------