Add file upload (POST /send-file) and drag-and-drop UI
Adds a second flow alongside the URL-to-EPUB pipeline: - New POST /send-file endpoint accepts multipart/form-data with a `file` and `readerEmail`, attaches the file to an email, and ships it to the reader as-is. No conversion — the device decides what it accepts (EPUB, PDF, MOBI, FB2, ...). - 25 MB cap enforced both client-side and server-side (multer). - Shares the same 20/hour rate limit as /send-article (now sendLimiter). - UI: dropzone with drag/drop + click-to-pick + visible filename feedback. - Shared e-reader email input across both flows. - Codeberg source link added to the footer. - Version bumped 0.1.0 → 0.2.0.
This commit is contained in:
parent
356e2c8ee6
commit
39292bfee9
5 changed files with 489 additions and 61 deletions
39
README.md
39
README.md
|
|
@ -6,10 +6,18 @@ Built with Express 5 + TypeScript. Hosted at [read.atanasov.fi](https://read.ata
|
|||
|
||||
## How it works
|
||||
|
||||
Two ways to send something to your e-reader:
|
||||
|
||||
**URL → EPUB**
|
||||
1. You paste an article URL and your e-reader's email address.
|
||||
2. The server fetches the page, runs Mozilla Readability over it to strip ads/chrome, builds an EPUB, and emails it to the address you gave.
|
||||
3. The EPUB is deleted from the server immediately after sending.
|
||||
|
||||
**Upload a file**
|
||||
1. Drag (or pick) a file — EPUB, PDF, MOBI, anything your device accepts.
|
||||
2. The server attaches it to an email and sends it to your e-reader as-is, no conversion.
|
||||
3. The file is deleted from the server immediately after sending. Max size: 25 MB.
|
||||
|
||||
## Supported devices
|
||||
|
||||
Any device that accepts files via email works — the app doesn't restrict by domain.
|
||||
|
|
@ -33,7 +41,7 @@ You'll need to add the **sender** address (the Gmail account this app sends from
|
|||
git clone <your-fork>
|
||||
cd send_to_kindle
|
||||
npm install
|
||||
cp .env.example .env # then fill in GMAIL_USER and GMAIL_APP_PASSWORD
|
||||
cp .env.example .env # then fill in SMTP_PASSWORD (Resend API key) and MAIL_FROM
|
||||
npm run dev
|
||||
```
|
||||
|
||||
|
|
@ -51,7 +59,7 @@ npm run start
|
|||
| Variable | Required | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `SMTP_HOST` | no | `smtp.resend.com` | Any SMTP server works |
|
||||
| `SMTP_PORT` | no | `465` | `465` = SSL, `587` = STARTTLS |
|
||||
| `SMTP_PORT` | no | `587` | `587` = STARTTLS (preferred — port 465 is blocked by most VPS providers) |
|
||||
| `SMTP_USER` | yes | — | For Resend, literally the string `resend` |
|
||||
| `SMTP_PASSWORD` | yes | — | For Resend, your API key (`re_…`) |
|
||||
| `MAIL_FROM` | yes | — | Sender address, e.g. `read@atanasov.fi`. Must be from a verified domain on your provider. |
|
||||
|
|
@ -73,8 +81,9 @@ For a different provider, just point `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `
|
|||
|
||||
## API
|
||||
|
||||
`POST /send-article`
|
||||
### `POST /send-article`
|
||||
|
||||
Body (JSON):
|
||||
```json
|
||||
{
|
||||
"url": "https://example.com/article",
|
||||
|
|
@ -83,7 +92,6 @@ For a different provider, just point `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `
|
|||
```
|
||||
|
||||
Response on success:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
|
|
@ -92,6 +100,25 @@ Response on success:
|
|||
}
|
||||
```
|
||||
|
||||
### `POST /send-file`
|
||||
|
||||
Body (multipart/form-data):
|
||||
- `file` — the binary file (max 25 MB)
|
||||
- `readerEmail` — destination address
|
||||
|
||||
Response on success:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "File sent to your e-reader",
|
||||
"title": "original-filename.epub"
|
||||
}
|
||||
```
|
||||
|
||||
### Rate limit
|
||||
|
||||
Both endpoints share a limit of **20 sends per IP per hour**.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Articles must be publicly accessible — no paywalled or login-required pages.
|
||||
|
|
@ -104,7 +131,9 @@ Response on success:
|
|||
- [@mozilla/readability](https://github.com/mozilla/readability) — article extraction
|
||||
- [jsdom](https://github.com/jsdom/jsdom) — DOM for Readability
|
||||
- [@lesjoursfr/html-to-epub](https://github.com/lesjoursfr/html-to-epub) — EPUB generation
|
||||
- [nodemailer](https://nodemailer.com/) — SMTP via Gmail
|
||||
- [nodemailer](https://nodemailer.com/) — SMTP client
|
||||
- [multer](https://github.com/expressjs/multer) — multipart/form-data upload parsing
|
||||
- [express-rate-limit](https://github.com/express-rate-limit/express-rate-limit) — per-IP rate limiting
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
140
package-lock.json
generated
140
package-lock.json
generated
|
|
@ -15,6 +15,7 @@
|
|||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"jsdom": "^29.1.1",
|
||||
"multer": "^2.1.1",
|
||||
"nodemailer": "^8.0.7",
|
||||
"sanitize-filename": "^1.6.4",
|
||||
"tsx": "^4.22.3"
|
||||
|
|
@ -22,6 +23,7 @@
|
|||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jsdom": "^28.0.3",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/sanitize-filename": "^1.1.28",
|
||||
|
|
@ -798,6 +800,16 @@
|
|||
"@types/unist": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/multer": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz",
|
||||
"integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
||||
|
|
@ -947,6 +959,12 @@
|
|||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/append-field": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/archiver": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz",
|
||||
|
|
@ -1223,6 +1241,23 @@
|
|||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||
"dependencies": {
|
||||
"streamsearch": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
|
|
@ -1347,6 +1382,35 @@
|
|||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream/node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
||||
|
|
@ -2721,6 +2785,68 @@
|
|||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
|
||||
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"type-is": "^1.6.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||
|
|
@ -3267,6 +3393,14 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/streamx": {
|
||||
"version": "2.25.0",
|
||||
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz",
|
||||
|
|
@ -3562,6 +3696,12 @@
|
|||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "send-to-ereader",
|
||||
"version": "0.1.0",
|
||||
"description": "Convert web articles to EPUB and email them to your e-reader.",
|
||||
"version": "0.2.0",
|
||||
"description": "Convert web articles to EPUB and email them to your e-reader, or upload a file (EPUB, PDF, etc.) directly.",
|
||||
"homepage": "https://atanasov.fi",
|
||||
"main": "app.js",
|
||||
"type": "module",
|
||||
|
|
@ -21,6 +21,7 @@
|
|||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"jsdom": "^29.1.1",
|
||||
"multer": "^2.1.1",
|
||||
"nodemailer": "^8.0.7",
|
||||
"sanitize-filename": "^1.6.4",
|
||||
"tsx": "^4.22.3"
|
||||
|
|
@ -28,6 +29,7 @@
|
|||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jsdom": "^28.0.3",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/sanitize-filename": "^1.1.28",
|
||||
|
|
|
|||
|
|
@ -87,6 +87,17 @@
|
|||
padding: 2px 8px 2px 0;
|
||||
}
|
||||
|
||||
.send-section {
|
||||
border: 1px solid #828282;
|
||||
padding: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.send-section h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
|
@ -106,6 +117,7 @@
|
|||
font-size: 14px;
|
||||
background: #fff;
|
||||
margin-bottom: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input[type="url"]:focus,
|
||||
|
|
@ -133,6 +145,34 @@
|
|||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.dropzone {
|
||||
border: 2px dashed #828282;
|
||||
background: #fff;
|
||||
padding: 24px 12px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #555;
|
||||
cursor: pointer;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dropzone.dragover {
|
||||
border-color: #00b45a;
|
||||
background: #eaffea;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.dropzone .picked {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.dropzone .picked.empty {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-top: 12px;
|
||||
padding: 8px;
|
||||
|
|
@ -187,15 +227,19 @@
|
|||
before you send the first article.
|
||||
</li>
|
||||
<li>
|
||||
<strong>PocketBook</strong>: no dashboard step needed. Send the
|
||||
first article — PocketBook will email you a confirmation with
|
||||
an "add to whitelist" link. Click it once, and every future
|
||||
article will be delivered automatically.
|
||||
<strong>PocketBook</strong>: no dashboard step needed. Send
|
||||
the first article — PocketBook will email you a confirmation
|
||||
with an "add to whitelist" link. Click it once, and every
|
||||
future article will be delivered automatically.
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>enter your e-reader's email below</li>
|
||||
<li>paste the article URL and hit send</li>
|
||||
<li>
|
||||
<strong>either</strong> paste an article URL and hit Send Article,
|
||||
<strong>or</strong> drop / pick a file (EPUB, PDF, etc.) and hit
|
||||
Send File
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<h3>e-reader email formats</h3>
|
||||
|
|
@ -218,7 +262,9 @@
|
|||
<ul>
|
||||
<li>any email works — domain isn't checked, use whatever your device gave you</li>
|
||||
<li>article should be publicly accessible (no paywalls / login required)</li>
|
||||
<li>EPUB is generated server-side and deleted immediately after sending</li>
|
||||
<li>uploaded files are passed through as-is — your device decides what formats it accepts (EPUB, PDF, MOBI, etc.)</li>
|
||||
<li>max file size: 25 MB</li>
|
||||
<li>articles are converted to EPUB and files are stored on the server only until they're emailed, then deleted immediately</li>
|
||||
<li>
|
||||
your e-reader email is saved in your browser
|
||||
<code>localStorage</code> for future use; nothing is stored on the server
|
||||
|
|
@ -226,11 +272,7 @@
|
|||
</ul>
|
||||
</section>
|
||||
|
||||
<form
|
||||
id="readerForm"
|
||||
class="reader-form"
|
||||
aria-label="Article to e-reader conversion form"
|
||||
>
|
||||
<section class="send-section">
|
||||
<div class="input-group">
|
||||
<label for="readerEmail">E-Reader Email:</label>
|
||||
<input
|
||||
|
|
@ -243,48 +285,88 @@
|
|||
aria-required="true"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="articleUrl">Article URL:</label>
|
||||
<section class="send-section">
|
||||
<h3>send an article URL</h3>
|
||||
<form id="urlForm" aria-label="Send article URL">
|
||||
<div class="input-group">
|
||||
<label for="articleUrl">Article URL:</label>
|
||||
<input
|
||||
type="url"
|
||||
id="articleUrl"
|
||||
name="articleUrl"
|
||||
placeholder="https://example.com/article"
|
||||
autocomplete="off"
|
||||
aria-required="true"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" id="sendUrlButton">Send Article</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="send-section">
|
||||
<h3>send a file</h3>
|
||||
<form id="fileForm" aria-label="Send file">
|
||||
<div
|
||||
id="dropzone"
|
||||
class="dropzone"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-label="Drop a file here, or click to pick one"
|
||||
>
|
||||
<div>drop a file here or click to pick one</div>
|
||||
<div id="picked" class="picked empty">no file selected</div>
|
||||
</div>
|
||||
<input
|
||||
type="url"
|
||||
id="articleUrl"
|
||||
name="articleUrl"
|
||||
placeholder="https://example.com/article"
|
||||
autocomplete="off"
|
||||
required
|
||||
aria-required="true"
|
||||
type="file"
|
||||
id="fileInput"
|
||||
name="file"
|
||||
hidden
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" id="sendFileButton">Send File</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<button type="submit" id="sendButton">Send</button>
|
||||
<div
|
||||
id="message"
|
||||
class="message"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
</form>
|
||||
<div
|
||||
id="message"
|
||||
class="message"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
</article>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p id="credits">
|
||||
v 0.1.0 •
|
||||
v 0.2.0 •
|
||||
<a
|
||||
href="https://atanasov.fi"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>atanasov.fi</a
|
||||
>
|
||||
•
|
||||
<a
|
||||
href="https://codeberg.org/atanasoff/send-to-ereader"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>source</a
|
||||
>
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
const urlInput = document.getElementById("articleUrl");
|
||||
const emailInput = document.getElementById("readerEmail");
|
||||
const sendButton = document.getElementById("sendButton");
|
||||
const sendUrlButton = document.getElementById("sendUrlButton");
|
||||
const sendFileButton = document.getElementById("sendFileButton");
|
||||
const messageDiv = document.getElementById("message");
|
||||
const fileInput = document.getElementById("fileInput");
|
||||
const dropzone = document.getElementById("dropzone");
|
||||
const picked = document.getElementById("picked");
|
||||
|
||||
const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
// Migrate any previously-saved key from the old version
|
||||
const legacy = localStorage.getItem("kindleEmail");
|
||||
|
|
@ -302,38 +384,37 @@
|
|||
messageDiv.className = `message ${type}`;
|
||||
}
|
||||
|
||||
async function sendArticle(event) {
|
||||
if (event) event.preventDefault();
|
||||
|
||||
const url = urlInput.value.trim();
|
||||
function getEmailOrShow() {
|
||||
const email = emailInput.value.trim();
|
||||
if (!email) {
|
||||
showMessage("please enter your e-reader email", "error");
|
||||
return null;
|
||||
}
|
||||
localStorage.setItem("readerEmail", email);
|
||||
return email;
|
||||
}
|
||||
|
||||
async function sendArticle(event) {
|
||||
event.preventDefault();
|
||||
const url = urlInput.value.trim();
|
||||
const email = getEmailOrShow();
|
||||
if (!email) return;
|
||||
if (!url) {
|
||||
showMessage("please enter a valid url", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
showMessage("please enter your e-reader email", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
sendButton.disabled = true;
|
||||
sendUrlButton.disabled = true;
|
||||
showMessage("processing article...", "loading");
|
||||
|
||||
localStorage.setItem("readerEmail", email);
|
||||
|
||||
const response = await fetch("/send-article", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url, readerEmail: email }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showMessage(`success! "${data.title}" sent to your e-reader`, "success");
|
||||
urlInput.value = "";
|
||||
|
|
@ -343,13 +424,113 @@
|
|||
} catch (error) {
|
||||
showMessage(error.message || "error sending article", "error");
|
||||
} finally {
|
||||
sendButton.disabled = false;
|
||||
sendUrlButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
document
|
||||
.getElementById("readerForm")
|
||||
.addEventListener("submit", sendArticle);
|
||||
function setPickedFile(file) {
|
||||
if (!file) {
|
||||
picked.textContent = "no file selected";
|
||||
picked.classList.add("empty");
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_UPLOAD_BYTES) {
|
||||
showMessage(`file too large (${(file.size / 1024 / 1024).toFixed(1)} MB — max 25 MB)`, "error");
|
||||
fileInput.value = "";
|
||||
picked.textContent = "no file selected";
|
||||
picked.classList.add("empty");
|
||||
return;
|
||||
}
|
||||
picked.textContent = `${file.name} (${(file.size / 1024).toFixed(0)} KB)`;
|
||||
picked.classList.remove("empty");
|
||||
}
|
||||
|
||||
async function sendFile(event) {
|
||||
event.preventDefault();
|
||||
const email = getEmailOrShow();
|
||||
if (!email) return;
|
||||
const file = fileInput.files && fileInput.files[0];
|
||||
if (!file) {
|
||||
showMessage("please pick or drop a file first", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
sendFileButton.disabled = true;
|
||||
showMessage(`sending "${file.name}"...`, "loading");
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("readerEmail", email);
|
||||
|
||||
const response = await fetch("/send-file", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showMessage(`success! "${data.title}" sent to your e-reader`, "success");
|
||||
fileInput.value = "";
|
||||
setPickedFile(null);
|
||||
} else {
|
||||
throw new Error(data.error || data.message || "failed to send file");
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage(error.message || "error sending file", "error");
|
||||
} finally {
|
||||
sendFileButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// URL form
|
||||
document.getElementById("urlForm").addEventListener("submit", sendArticle);
|
||||
|
||||
// File form
|
||||
document.getElementById("fileForm").addEventListener("submit", sendFile);
|
||||
|
||||
// Dropzone click → trigger file picker
|
||||
dropzone.addEventListener("click", () => fileInput.click());
|
||||
dropzone.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
fileInput.click();
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.addEventListener("change", () => {
|
||||
setPickedFile(fileInput.files && fileInput.files[0]);
|
||||
});
|
||||
|
||||
// Drag-and-drop
|
||||
["dragenter", "dragover"].forEach((ev) => {
|
||||
dropzone.addEventListener(ev, (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dropzone.classList.add("dragover");
|
||||
});
|
||||
});
|
||||
|
||||
["dragleave", "drop"].forEach((ev) => {
|
||||
dropzone.addEventListener(ev, (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dropzone.classList.remove("dragover");
|
||||
});
|
||||
});
|
||||
|
||||
dropzone.addEventListener("drop", (e) => {
|
||||
const dt = e.dataTransfer;
|
||||
if (!dt || !dt.files || dt.files.length === 0) return;
|
||||
// Assign dropped file to the input so form submission works uniformly
|
||||
fileInput.files = dt.files;
|
||||
setPickedFile(dt.files[0]);
|
||||
});
|
||||
|
||||
// Prevent the browser from navigating away if a file is dropped outside the dropzone
|
||||
["dragover", "drop"].forEach((ev) => {
|
||||
window.addEventListener(ev, (e) => e.preventDefault());
|
||||
});
|
||||
</script>
|
||||
|
||||
<script type="application/ld+json">
|
||||
|
|
@ -368,7 +549,7 @@
|
|||
"name": "Stefan Atanasov",
|
||||
"url": "https://atanasov.fi"
|
||||
},
|
||||
"description": "Convert web articles to EPUB and email them to your e-reader.",
|
||||
"description": "Convert web articles to EPUB and email them to your e-reader, or upload any file (EPUB, PDF) directly.",
|
||||
"operatingSystem": "All",
|
||||
"browserRequirements": "Requires JavaScript"
|
||||
}
|
||||
|
|
|
|||
80
src/app.ts
80
src/app.ts
|
|
@ -7,6 +7,7 @@ import fs from "fs";
|
|||
import sanitizeFilename from "sanitize-filename";
|
||||
import { EpubOptions, EPub } from "@lesjoursfr/html-to-epub";
|
||||
import { rateLimit } from "express-rate-limit";
|
||||
import multer from "multer";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
|
|
@ -34,7 +35,7 @@ app.set("trust proxy", 1);
|
|||
app.use(express.json());
|
||||
app.use(express.static("public"));
|
||||
|
||||
const sendArticleLimiter = rateLimit({
|
||||
const sendLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
limit: 20,
|
||||
standardHeaders: "draft-7",
|
||||
|
|
@ -42,6 +43,13 @@ const sendArticleLimiter = rateLimit({
|
|||
message: { error: "Too many send requests. Try again in an hour." },
|
||||
});
|
||||
|
||||
const MAX_UPLOAD_BYTES = 25 * 1024 * 1024; // 25 MB
|
||||
|
||||
const upload = multer({
|
||||
dest: "uploads/",
|
||||
limits: { fileSize: MAX_UPLOAD_BYTES },
|
||||
});
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port: SMTP_PORT,
|
||||
|
|
@ -178,7 +186,7 @@ const emailShape = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|||
|
||||
app.post(
|
||||
"/send-article",
|
||||
sendArticleLimiter,
|
||||
sendLimiter,
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
const { url, readerEmail } = req.body;
|
||||
|
||||
|
|
@ -213,6 +221,74 @@ app.post(
|
|||
},
|
||||
);
|
||||
|
||||
async function sendFileToReader(
|
||||
filePath: string,
|
||||
filename: string,
|
||||
readerEmail: string,
|
||||
): Promise<void> {
|
||||
const safeName = sanitizeFilename(filename) || "document";
|
||||
const mailOptions = {
|
||||
from: MAIL_FROM,
|
||||
to: readerEmail,
|
||||
subject: safeName,
|
||||
text: `File: ${safeName}\n\nSent to your e-reader`,
|
||||
attachments: [{ filename: safeName, path: filePath }],
|
||||
};
|
||||
|
||||
try {
|
||||
await transporter.sendMail(mailOptions);
|
||||
} finally {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.post(
|
||||
"/send-file",
|
||||
sendLimiter,
|
||||
upload.single("file"),
|
||||
async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
const file = req.file;
|
||||
const { readerEmail } = req.body;
|
||||
|
||||
const cleanup = () => {
|
||||
if (file && fs.existsSync(file.path)) {
|
||||
fs.unlinkSync(file.path);
|
||||
}
|
||||
};
|
||||
|
||||
if (!file) {
|
||||
res.status(400).json({ error: "No file provided" });
|
||||
return;
|
||||
}
|
||||
if (!readerEmail || typeof readerEmail !== "string") {
|
||||
cleanup();
|
||||
res.status(400).json({ error: "No e-reader email provided" });
|
||||
return;
|
||||
}
|
||||
if (!emailShape.test(readerEmail)) {
|
||||
cleanup();
|
||||
res.status(400).json({ error: "Invalid e-reader email address" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sendFileToReader(file.path, file.originalname, readerEmail);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "File sent to your e-reader",
|
||||
title: file.originalname,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error sending file:", error);
|
||||
cleanup();
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.use((req: Request, res: Response) => {
|
||||
res.status(404).json({ error: "Page doesn't exist" });
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue