jekyll-email-munge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +26 -0
- data/LICENSE.txt +21 -0
- data/README.md +368 -0
- data/jekyll-email-munge.gemspec +41 -0
- data/lib/jekyll-email-munge/decoder.js +1 -0
- data/lib/jekyll-email-munge/munge_email_script_tag.rb +32 -0
- data/lib/jekyll-email-munge/munge_email_tag.rb +105 -0
- data/lib/jekyll-email-munge/version.rb +5 -0
- data/lib/jekyll-email-munge.rb +3 -0
- metadata +109 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a195700b73387bb7376e55b8d78320a4e06bb9e39efd706b7d67e55b15f8dc9f
|
|
4
|
+
data.tar.gz: 7b4cfd8a031f7d330369f7fe4783d2cc4700899daf7068353b5dcaa4e6b155dc
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 96dc1c53c43e50d0b108218987b025acf51a5b42206b464e48e65d17023d806f72df38b20015c74877a7e03d21c16bb7f7050eb9ffb8b7f240915dc2aa9fccfd
|
|
7
|
+
data.tar.gz: 14fec56e4bd3a03639a6b3e76451ffdc02f444923e55386fc064e1129b21d967eaf60bbb070bd47ab1c0473a65e294a8520c35454915ad3ba7621c67f179920d
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to **jekyll-email-munge** are tracked here. The format
|
|
4
|
+
follows [Keep a Changelog](https://keepachangelog.com/) and the project adheres
|
|
5
|
+
to [Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] — 2026-05-06
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `{% munge_email "email" %}` Liquid tag — emits an `<a class="liame">` with an
|
|
13
|
+
AES-128-GCM ciphertext payload, a CSS-hidden decoy span, and an inline-SVG
|
|
14
|
+
noscript fallback. Source-code names use the readable `munge_email` form;
|
|
15
|
+
rendered HTML uses `liame` (`email` reversed) so a scraper regex for
|
|
16
|
+
`email|mail|mailto|contact` finds nothing in the served output.
|
|
17
|
+
- `{% munge_email_script %}` Liquid tag — emits the inline JS decoder (Web
|
|
18
|
+
Crypto AES-GCM) that reveals the address on click. `KEY_HEX` is interpolated
|
|
19
|
+
from `_config.yml` at build time.
|
|
20
|
+
- Configuration via `_config.yml` under the `email_munge:` key (`key_hex`,
|
|
21
|
+
`svg_color`, `decoy`).
|
|
22
|
+
- Deterministic IV derivation (per-email SHA-256) so build output is stable
|
|
23
|
+
across rebuilds without sacrificing GCM safety.
|
|
24
|
+
|
|
25
|
+
[Unreleased]: https://github.com/framallo/jekyll-email-munge/compare/v0.1.0...HEAD
|
|
26
|
+
[0.1.0]: https://github.com/framallo/jekyll-email-munge/releases/tag/v0.1.0
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Federico Ramallo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
# jekyll-email-munge
|
|
2
|
+
|
|
3
|
+
Five-layer **email address munging** for Jekyll. Drop a Liquid tag, get a
|
|
4
|
+
`mailto:` link that looks normal to humans and ciphertext to scrapers.
|
|
5
|
+
|
|
6
|
+
```liquid
|
|
7
|
+
{% munge_email "you@example.com" %}
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
renders as something like:
|
|
11
|
+
|
|
12
|
+
```html
|
|
13
|
+
<a href="#" class="liame"
|
|
14
|
+
data-liame="dDNiRy9OZGNYS3p1OWp0WnFM3VkIVJYNHNicw=="
|
|
15
|
+
rel="nofollow">Reach<span class="liame-decoy" aria-hidden="true">no-reply@spam.invalid</span> out</a>
|
|
16
|
+
<noscript> (or use the address shown here:
|
|
17
|
+
<svg ...><text>you@example.com</text></svg>)
|
|
18
|
+
</noscript>
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Click the link in a real browser → it opens `mailto:you@example.com`. View the
|
|
22
|
+
HTML source as a scraper → you see ciphertext, decoy noise, and an SVG.
|
|
23
|
+
|
|
24
|
+
> **Two namespaces, by design.** The Liquid tag (`munge_email`) and the config
|
|
25
|
+
> key (`email_munge`) are clear, discoverable names — they only ever appear in
|
|
26
|
+
> your **source code**. The **rendered HTML** uses `liame` (`email` reversed)
|
|
27
|
+
> for every class and attribute a scraper might regex for. Source code stays
|
|
28
|
+
> readable; rendered output stays scraper-hostile.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## What is "address munging"?
|
|
33
|
+
|
|
34
|
+
[Address munging](https://en.wikipedia.org/wiki/Address_munging) is the
|
|
35
|
+
canonical term — going back to early Usenet — for *defending an email address
|
|
36
|
+
on a public surface against bulk harvesters*, by any combination of encoding,
|
|
37
|
+
scrambling, encryption, decoy, or visual substitution. It deliberately covers
|
|
38
|
+
the whole spectrum from `name AT domain DOT com` to "encrypted blob revealed
|
|
39
|
+
on click," and that's exactly the spectrum this gem operates on.
|
|
40
|
+
|
|
41
|
+
We use the term over alternatives like *obfuscate* (undersells the encryption)
|
|
42
|
+
or *encrypt* (oversells it — the key is public on purpose) because munging is
|
|
43
|
+
the only word that's both technically and historically correct.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Why
|
|
48
|
+
|
|
49
|
+
Email addresses on public sites are harvested by bots within hours of being
|
|
50
|
+
indexed. Most "munging" approaches (HTML entities, percent-encoding, JavaScript
|
|
51
|
+
concatenation, image rendering) defeat *some* harvesters but not others. This
|
|
52
|
+
gem stacks **five independently-effective** techniques from
|
|
53
|
+
[Spencer Mortensen's email obfuscation study][study] — each blocked 100% of
|
|
54
|
+
tested harvesters on its own:
|
|
55
|
+
|
|
56
|
+
1. **AES-128-GCM encryption.** The HTML carries ciphertext only. Even if a
|
|
57
|
+
scraper extracts the `data-liame` attribute, decrypting it requires the key
|
|
58
|
+
*and* a JavaScript runtime.
|
|
59
|
+
2. **JS conversion.** The decryption logic runs in the browser via Web Crypto.
|
|
60
|
+
Scrapers without a JS engine see only the encrypted blob.
|
|
61
|
+
3. **User-interaction trigger.** Decryption only fires on `click`. Headless
|
|
62
|
+
bots that *do* execute JS but don't simulate user input never see the
|
|
63
|
+
address.
|
|
64
|
+
4. **CSS-hidden decoy.** A fake address (`no-reply@spam.invalid` by default)
|
|
65
|
+
sits inside the link with `display: none`, visible to scrapers that strip
|
|
66
|
+
tags but hidden from sighted users.
|
|
67
|
+
5. **SVG `<noscript>` fallback.** For visitors without JavaScript, the address
|
|
68
|
+
appears as text inside an inline `<svg><text>` element. Most regex-based
|
|
69
|
+
harvesters don't OCR or parse SVG.
|
|
70
|
+
|
|
71
|
+
[study]: https://spencermortensen.com/articles/email-obfuscation/
|
|
72
|
+
|
|
73
|
+
The goal is *defeating scrapers, not hiding the address from a determined
|
|
74
|
+
human.* The encryption key is published in plain sight (it has to be — the
|
|
75
|
+
browser needs it to decrypt). The win is that automated harvesters give up
|
|
76
|
+
long before they reach a working address.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Installation
|
|
81
|
+
|
|
82
|
+
Add to your site's `Gemfile`:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
group :jekyll_plugins do
|
|
86
|
+
gem "jekyll-email-munge"
|
|
87
|
+
end
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
…then `bundle install`.
|
|
91
|
+
|
|
92
|
+
If your site doesn't use Bundler, add to `_config.yml`:
|
|
93
|
+
|
|
94
|
+
```yaml
|
|
95
|
+
plugins:
|
|
96
|
+
- jekyll-email-munge
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
> Hosted on **GitHub Pages**? Custom plugins aren't allowed there — deploy via
|
|
100
|
+
> Cloudflare Pages, Netlify, or `gh-pages` with a CI build instead.
|
|
101
|
+
|
|
102
|
+
### Configuration
|
|
103
|
+
|
|
104
|
+
Add an `email_munge` block to `_config.yml`:
|
|
105
|
+
|
|
106
|
+
```yaml
|
|
107
|
+
email_munge:
|
|
108
|
+
# 32 hex chars = 16 bytes = AES-128 key. Generate one with:
|
|
109
|
+
# ruby -ropenssl -e 'puts OpenSSL::Random.random_bytes(16).unpack1("H*")'
|
|
110
|
+
key_hex: "84afaaa6886a7b0d195454cc559795cb"
|
|
111
|
+
|
|
112
|
+
# Optional. Color of the noscript SVG fallback text.
|
|
113
|
+
svg_color: "#9c3f1d"
|
|
114
|
+
|
|
115
|
+
# Optional. The fake address shown inside the (CSS-hidden) decoy span.
|
|
116
|
+
decoy: "no-reply@spam.invalid"
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Then add the decoder script once per page — typically just before `</body>` in
|
|
120
|
+
your default layout:
|
|
121
|
+
|
|
122
|
+
```liquid
|
|
123
|
+
<!-- _layouts/default.html -->
|
|
124
|
+
{% munge_email_script %}
|
|
125
|
+
</body>
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Finally, add a tiny CSS rule to hide the decoy span and align the SVG fallback.
|
|
129
|
+
Drop these into your stylesheet:
|
|
130
|
+
|
|
131
|
+
```css
|
|
132
|
+
.liame-decoy { display: none; }
|
|
133
|
+
.liame-svg { vertical-align: middle; pointer-events: none; }
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
That's the entire setup. You're done.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Usage
|
|
141
|
+
|
|
142
|
+
Drop the tag wherever you'd normally write a `<a href="mailto:…">`:
|
|
143
|
+
|
|
144
|
+
```liquid
|
|
145
|
+
{% munge_email "support@yourdomain.com" %}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Default visible link text is `Reach out`. To change it, pass a second argument
|
|
149
|
+
with `|` marking where the decoy span gets injected:
|
|
150
|
+
|
|
151
|
+
```liquid
|
|
152
|
+
{% munge_email "support@yourdomain.com" "Get|in touch" %}
|
|
153
|
+
{% munge_email "press@yourdomain.com" "Contact|the press team" %}
|
|
154
|
+
{% munge_email "hello@yourdomain.com" "Email|us" %}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Renders as:
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
Get<decoy> in touch
|
|
161
|
+
Contact<decoy> the press team
|
|
162
|
+
Email<decoy> us
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Without the `|`, the entire string is used as visible text and an empty decoy
|
|
166
|
+
follows — works fine, but splitting the text gives the decoy more cover.
|
|
167
|
+
|
|
168
|
+
### Why split the text?
|
|
169
|
+
|
|
170
|
+
The decoy span sits *inside* the visible link and is hidden with
|
|
171
|
+
`display: none`. A scraper that reads the rendered DOM (or strips CSS) sees:
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
Get no-reply@spam.invalid in touch
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Splitting the visible text means the decoy is interleaved with real text rather
|
|
178
|
+
than appended at the end — slightly harder for a scraper to recognize and strip.
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## How it works (under the hood)
|
|
183
|
+
|
|
184
|
+
### 1. Build time (Ruby)
|
|
185
|
+
|
|
186
|
+
When Jekyll renders `{% munge_email "user@example.com" %}`:
|
|
187
|
+
|
|
188
|
+
1. The plugin reads `email_munge.key_hex` from `_config.yml`.
|
|
189
|
+
2. It derives a deterministic IV from the email + key
|
|
190
|
+
(`SHA-256("liame-iv:<key_hex>:<email>")[0..11]`).
|
|
191
|
+
3. It encrypts the email with **AES-128-GCM**, packs `iv | ciphertext | auth_tag`,
|
|
192
|
+
and base64-encodes the result.
|
|
193
|
+
4. It emits the `<a>` element with the base64 payload in `data-liame`, the
|
|
194
|
+
decoy `<span>`, and an inline `<svg>` fallback.
|
|
195
|
+
|
|
196
|
+
The deterministic IV means rebuilds produce identical output — your git diffs
|
|
197
|
+
stay quiet and your CDN cache doesn't get invalidated on every build. IV reuse
|
|
198
|
+
with the *same* plaintext under the same key is safe under AES-GCM; reuse with
|
|
199
|
+
*different* plaintexts is what's catastrophic, and that doesn't happen here
|
|
200
|
+
because each email gets its own derived IV.
|
|
201
|
+
|
|
202
|
+
### 2. Page load (JavaScript)
|
|
203
|
+
|
|
204
|
+
`{% munge_email_script %}` emits an inline `<script>` that:
|
|
205
|
+
|
|
206
|
+
1. Reads `KEY_HEX` (baked into the script at build time).
|
|
207
|
+
2. Attaches a `click` listener to every `[data-liame]` element.
|
|
208
|
+
3. On click: base64-decodes, splits into IV / ciphertext / tag, calls
|
|
209
|
+
`crypto.subtle.decrypt` (Web Crypto API), and navigates to
|
|
210
|
+
`mailto:<decrypted>`.
|
|
211
|
+
|
|
212
|
+
The script is ~700 bytes minified and inlined — no extra request, no `defer`
|
|
213
|
+
race. It only does anything if the user actually clicks a munged link.
|
|
214
|
+
|
|
215
|
+
### 3. The `liame` naming convention (in rendered output)
|
|
216
|
+
|
|
217
|
+
This is the central trick. **Anything a scraper can see in the rendered HTML**
|
|
218
|
+
uses `liame` (`email` reversed) instead of `email` / `mail` / `mailto` /
|
|
219
|
+
`contact`, so a regex grep for those tokens finds nothing in the output. The
|
|
220
|
+
plugin guarantees this by construction:
|
|
221
|
+
|
|
222
|
+
| Element | Class / attribute name |
|
|
223
|
+
| ------------------------ | ---------------------- |
|
|
224
|
+
| Anchor | `class="liame"` |
|
|
225
|
+
| Encrypted payload | `data-liame="..."` |
|
|
226
|
+
| Hidden decoy span | `class="liame-decoy"` |
|
|
227
|
+
| Fallback SVG | `class="liame-svg"` |
|
|
228
|
+
| `aria-label` on the SVG | `contact` |
|
|
229
|
+
|
|
230
|
+
The visible link text in the *default* output (`Reach out`) deliberately avoids
|
|
231
|
+
the words "email" or "mail" — keep that property when you customize the text.
|
|
232
|
+
|
|
233
|
+
**Source code is exempt** from this convention because scrapers don't read your
|
|
234
|
+
Liquid templates or `_config.yml`. The tag (`munge_email`) and config key
|
|
235
|
+
(`email_munge`) are written for human readability:
|
|
236
|
+
|
|
237
|
+
| Source-code thing | Reads as | Visible to scrapers? |
|
|
238
|
+
| ------------------------ | -------------------------- | -------------------- |
|
|
239
|
+
| Liquid tag | `{% munge_email %}` | No (build-time only) |
|
|
240
|
+
| Config key | `email_munge:` | No |
|
|
241
|
+
| Decoder script tag | `{% munge_email_script %}` | No |
|
|
242
|
+
|
|
243
|
+
### 4. Where the key lives
|
|
244
|
+
|
|
245
|
+
There is **one** key, set in `_config.yml`. The plugin uses it to encrypt at
|
|
246
|
+
build time, and `{% munge_email_script %}` interpolates it into the inline JS
|
|
247
|
+
so the browser can decrypt. The key is therefore visible to anyone viewing the
|
|
248
|
+
page source — that's intentional. The encryption is a scraper-defeating layer,
|
|
249
|
+
not a secrecy mechanism.
|
|
250
|
+
|
|
251
|
+
If you want to **rotate the key**, generate a new one and update `key_hex` in
|
|
252
|
+
`_config.yml`. All existing payloads automatically re-encrypt on the next build
|
|
253
|
+
because the plugin re-runs.
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
## Customization
|
|
258
|
+
|
|
259
|
+
### Per-link visible text
|
|
260
|
+
|
|
261
|
+
Already covered above:
|
|
262
|
+
|
|
263
|
+
```liquid
|
|
264
|
+
{% munge_email "user@example.com" "Custom|visible text" %}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### Different decoy text
|
|
268
|
+
|
|
269
|
+
In `_config.yml`:
|
|
270
|
+
|
|
271
|
+
```yaml
|
|
272
|
+
email_munge:
|
|
273
|
+
decoy: "abuse@spam-trap.invalid"
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Or pick something amusing — it's only seen by scrapers.
|
|
277
|
+
|
|
278
|
+
### SVG fallback color
|
|
279
|
+
|
|
280
|
+
```yaml
|
|
281
|
+
email_munge:
|
|
282
|
+
svg_color: "#3dff9a" # match your accent
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Multiple sites, one key
|
|
286
|
+
|
|
287
|
+
If you run several sites and want a single key for all of them, use the same
|
|
288
|
+
`key_hex` value in each site's `_config.yml`. Encrypted payloads are
|
|
289
|
+
interchangeable; the same `munge_email_script` decoder works on all of them.
|
|
290
|
+
|
|
291
|
+
If you rotate the key on one site, you must rotate it on all of them — the
|
|
292
|
+
deterministic IV depends on the key, so old ciphertext won't decrypt with a
|
|
293
|
+
new key.
|
|
294
|
+
|
|
295
|
+
---
|
|
296
|
+
|
|
297
|
+
## Browser support
|
|
298
|
+
|
|
299
|
+
The decoder uses **Web Crypto** (`crypto.subtle`), which has been available in
|
|
300
|
+
all major browsers since 2016. On legacy browsers without it, clicking the
|
|
301
|
+
munged link does nothing — but those users see the inline SVG fallback in
|
|
302
|
+
the `<noscript>` block (or, more practically, no `<noscript>` users have a
|
|
303
|
+
browser without Web Crypto).
|
|
304
|
+
|
|
305
|
+
If you need to support pre-2016 browsers, this gem isn't the right tool.
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
## Security notes
|
|
310
|
+
|
|
311
|
+
This gem provides **scraper resistance**, not encryption-grade secrecy. In
|
|
312
|
+
particular:
|
|
313
|
+
|
|
314
|
+
- The key is published in JS. Anyone who reads the rendered HTML can decrypt
|
|
315
|
+
every payload on the page. That's intentional and required for the design.
|
|
316
|
+
- AES-128 is used because the goal is to require *any* AES decryption, not
|
|
317
|
+
because the threat model needs 256-bit keys. If you ever need to actually
|
|
318
|
+
protect the addresses, this gem is the wrong tool — don't put the addresses
|
|
319
|
+
on the public site at all.
|
|
320
|
+
- The deterministic IV is safe specifically because each email gets its own
|
|
321
|
+
derived IV. Don't modify the IV derivation to use a constant — that would
|
|
322
|
+
be a real GCM misuse.
|
|
323
|
+
|
|
324
|
+
---
|
|
325
|
+
|
|
326
|
+
## Comparison with alternatives
|
|
327
|
+
|
|
328
|
+
| Approach | Layers | Maintenance |
|
|
329
|
+
| ------------------------------------------------ | ------ | ----------------- |
|
|
330
|
+
| Plain `mailto:` link | 0 | Easiest, harvested instantly |
|
|
331
|
+
| HTML entities / hex encoding | 1 | Defeats only naïve regex |
|
|
332
|
+
| `jekyll-email-protect` (percent-encoded mailto) | 1 | One filter, low ceremony |
|
|
333
|
+
| Concatenated JS string | 2 | Harvested by JS-aware bots |
|
|
334
|
+
| **jekyll-email-munge (this gem)** | **5** | One Liquid tag, build-time encryption |
|
|
335
|
+
| Pure image (e.g., a screenshot of the email) | 1 | OCR-defeated, terrible UX |
|
|
336
|
+
|
|
337
|
+
---
|
|
338
|
+
|
|
339
|
+
## Development
|
|
340
|
+
|
|
341
|
+
```bash
|
|
342
|
+
git clone https://github.com/framallo/jekyll-email-munge.git
|
|
343
|
+
cd jekyll-email-munge
|
|
344
|
+
bundle install
|
|
345
|
+
rake build # produces pkg/jekyll-email-munge-X.Y.Z.gem
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
To install your local checkout into a Jekyll site for testing:
|
|
349
|
+
|
|
350
|
+
```ruby
|
|
351
|
+
# Gemfile in the consumer site
|
|
352
|
+
gem "jekyll-email-munge", path: "../jekyll-email-munge"
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### Releasing
|
|
356
|
+
|
|
357
|
+
1. Bump `Jekyll::EmailMunge::VERSION` in [lib/jekyll-email-munge/version.rb](lib/jekyll-email-munge/version.rb).
|
|
358
|
+
2. Add a section to [CHANGELOG.md](CHANGELOG.md).
|
|
359
|
+
3. Commit and tag: `git commit -am "release vX.Y.Z" && git tag vX.Y.Z`
|
|
360
|
+
4. `rake release` (Bundler task — builds, tags, pushes to RubyGems).
|
|
361
|
+
|
|
362
|
+
You'll need a [RubyGems.org account with MFA enabled](https://guides.rubygems.org/setting-up-multifactor-authentication/) before pushing.
|
|
363
|
+
|
|
364
|
+
---
|
|
365
|
+
|
|
366
|
+
## License
|
|
367
|
+
|
|
368
|
+
[MIT](LICENSE.txt) © Federico Ramallo
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
require_relative "lib/jekyll-email-munge/version"
|
|
2
|
+
|
|
3
|
+
Gem::Specification.new do |spec|
|
|
4
|
+
spec.name = "jekyll-email-munge"
|
|
5
|
+
spec.version = Jekyll::EmailMunge::VERSION
|
|
6
|
+
spec.authors = ["Federico Ramallo"]
|
|
7
|
+
spec.email = ["framallo@gmail.com"]
|
|
8
|
+
|
|
9
|
+
spec.summary = "Address-munge email links in Jekyll: AES-encrypted payloads, decoy text, click-to-reveal, SVG fallback."
|
|
10
|
+
spec.description = <<~DESC
|
|
11
|
+
A Jekyll plugin for address munging — defending email addresses on public
|
|
12
|
+
sites against bulk harvesters. Stacks five independently-effective
|
|
13
|
+
techniques (AES-128-GCM encryption, JS conversion, click trigger,
|
|
14
|
+
CSS-hidden decoy, SVG noscript fallback) so scrapers see ciphertext while
|
|
15
|
+
humans see a normal mailto link. Drop in a Liquid tag —
|
|
16
|
+
`{% munge_email "user@example.com" %}` — and the plugin handles encryption
|
|
17
|
+
at build time, the decoder script, and the fallback markup.
|
|
18
|
+
DESC
|
|
19
|
+
spec.homepage = "https://github.com/framallo/jekyll-email-munge"
|
|
20
|
+
spec.license = "MIT"
|
|
21
|
+
spec.required_ruby_version = ">= 2.7.0"
|
|
22
|
+
|
|
23
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
24
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
25
|
+
spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
|
|
26
|
+
spec.metadata["documentation_uri"] = "#{spec.homepage}#readme"
|
|
27
|
+
|
|
28
|
+
spec.files = Dir[
|
|
29
|
+
"lib/**/*",
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE.txt",
|
|
32
|
+
"CHANGELOG.md",
|
|
33
|
+
"jekyll-email-munge.gemspec"
|
|
34
|
+
]
|
|
35
|
+
spec.require_paths = ["lib"]
|
|
36
|
+
|
|
37
|
+
spec.add_runtime_dependency "jekyll", ">= 3.7", "< 5.0"
|
|
38
|
+
|
|
39
|
+
spec.add_development_dependency "bundler", "~> 2.0"
|
|
40
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
41
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(function(){var K='__KEY_HEX__';function h(s){var o=new Uint8Array(s.length/2);for(var i=0;i<s.length;i+=2)o[i/2]=parseInt(s.substr(i,2),16);return o}function b(s){var bin=atob(s),o=new Uint8Array(bin.length);for(var i=0;i<bin.length;i++)o[i]=bin.charCodeAt(i);return o}async function r(e){e.preventDefault();var el=e.currentTarget,p=el.getAttribute('data-liame');if(!p||!window.crypto||!window.crypto.subtle)return;try{var d=b(p),iv=d.slice(0,12),ct=d.slice(12),k=await crypto.subtle.importKey('raw',h(K),{name:'AES-GCM'},false,['decrypt']),pt=await crypto.subtle.decrypt({name:'AES-GCM',iv:iv},k,ct);window.location.href='mailto:'+new TextDecoder().decode(pt)}catch(_){}}document.addEventListener('DOMContentLoaded',function(){document.querySelectorAll('[data-liame]').forEach(function(el){el.addEventListener('click',r)})})})();
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module Jekyll
|
|
2
|
+
module EmailMunge
|
|
3
|
+
# {% munge_email_script %}
|
|
4
|
+
#
|
|
5
|
+
# Emits the inline <script> that decrypts {% munge_email %} payloads on
|
|
6
|
+
# click. Call this once per page (typically just before </body> in your
|
|
7
|
+
# default layout). It reads email_munge.key_hex from _config.yml and bakes
|
|
8
|
+
# it into the script — that key is intentionally public; the goal is
|
|
9
|
+
# defeating scrapers, not hiding the address from a determined human.
|
|
10
|
+
class MungeEmailScriptTag < ::Liquid::Tag
|
|
11
|
+
DECODER_PATH = File.expand_path("decoder.js", __dir__).freeze
|
|
12
|
+
|
|
13
|
+
def render(context)
|
|
14
|
+
key_hex = context.registers[:site].config.dig("email_munge", "key_hex")
|
|
15
|
+
unless key_hex
|
|
16
|
+
raise "jekyll-email-munge: email_munge.key_hex missing in _config.yml"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
js = decoder_source.gsub("__KEY_HEX__", key_hex)
|
|
20
|
+
%(<script>#{js}</script>)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def decoder_source
|
|
26
|
+
@@decoder_source ||= File.read(DECODER_PATH).rstrip
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
::Liquid::Template.register_tag("munge_email_script", Jekyll::EmailMunge::MungeEmailScriptTag)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
require "base64"
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module Jekyll
|
|
7
|
+
module EmailMunge
|
|
8
|
+
# {% munge_email "user@example.com" %}
|
|
9
|
+
# {% munge_email "user@example.com" "Get|in touch" %}
|
|
10
|
+
#
|
|
11
|
+
# Renders a "munged" mailto link at build time. The address is encrypted
|
|
12
|
+
# with AES-128-GCM (key from _config.yml -> email_munge.key_hex) so only
|
|
13
|
+
# ciphertext appears in the served HTML. A CSS-hidden decoy span sits inside
|
|
14
|
+
# the link and a <noscript> inline-SVG fallback handles JS-disabled visitors.
|
|
15
|
+
#
|
|
16
|
+
# The "|" in the visible text marks where the decoy span is injected.
|
|
17
|
+
# Default text is "Reach|out" -> "Reach<decoy/> out".
|
|
18
|
+
#
|
|
19
|
+
# Naming convention note:
|
|
20
|
+
# The Liquid tag name (`munge_email`) and config key (`email_munge`) only
|
|
21
|
+
# appear in source code, never in the served HTML. The rendered output
|
|
22
|
+
# uses `liame` (`email` reversed) for every class/attribute a scraper
|
|
23
|
+
# might regex for: class="liame", data-liame, .liame-decoy, .liame-svg.
|
|
24
|
+
#
|
|
25
|
+
# See README for the companion {% munge_email_script %} tag and CSS.
|
|
26
|
+
class MungeEmailTag < ::Liquid::Tag
|
|
27
|
+
SYNTAX = /^\s*"([^"]+)"(?:\s+"([^"]+)")?\s*$/.freeze
|
|
28
|
+
|
|
29
|
+
def initialize(tag_name, markup, tokens)
|
|
30
|
+
super
|
|
31
|
+
m = markup.match(SYNTAX)
|
|
32
|
+
unless m
|
|
33
|
+
raise SyntaxError, %(jekyll-email-munge: tag syntax is {% munge_email "email" ["visible|text"] %})
|
|
34
|
+
end
|
|
35
|
+
@email = m[1]
|
|
36
|
+
@text = m[2] || "Reach|out"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def render(context)
|
|
40
|
+
cfg = (context.registers[:site].config["email_munge"] || {})
|
|
41
|
+
key_hex = cfg["key_hex"]
|
|
42
|
+
unless key_hex
|
|
43
|
+
raise "jekyll-email-munge: email_munge.key_hex missing in _config.yml. " \
|
|
44
|
+
"Generate one with `ruby -ropenssl -e 'puts OpenSSL::Random.random_bytes(16).unpack1(\"H*\")'`"
|
|
45
|
+
end
|
|
46
|
+
unless key_hex =~ /\A[0-9a-fA-F]{32}\z/
|
|
47
|
+
raise "jekyll-email-munge: email_munge.key_hex must be 32 hex characters (16 bytes / AES-128)"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
decoy = cfg["decoy"] || "no-reply@spam.invalid"
|
|
51
|
+
svg_color = cfg["svg_color"] || "#9c3f1d"
|
|
52
|
+
|
|
53
|
+
payload = encrypt(@email, key_hex)
|
|
54
|
+
before, after = split_text(@text)
|
|
55
|
+
svg = inline_svg(@email, svg_color)
|
|
56
|
+
|
|
57
|
+
esc = ->(s) { CGI.escapeHTML(s) }
|
|
58
|
+
%(<a href="#" class="liame" data-liame="#{esc.call(payload)}" rel="nofollow">) +
|
|
59
|
+
%(#{esc.call(before)}) +
|
|
60
|
+
%(<span class="liame-decoy" aria-hidden="true">#{esc.call(decoy)}</span>) +
|
|
61
|
+
%(#{esc.call(after)}</a>) +
|
|
62
|
+
%(<noscript> (or use the address shown here: #{svg})</noscript>)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# "Reach|out" -> ["Reach", " out"]
|
|
68
|
+
# "Get|in touch" -> ["Get", " in touch"]
|
|
69
|
+
# "Email me" -> ["Email me", ""] # no "|" present
|
|
70
|
+
def split_text(text)
|
|
71
|
+
return [text, ""] unless text.include?("|")
|
|
72
|
+
left, right = text.split("|", 2)
|
|
73
|
+
right = " #{right}" unless right.empty? || right.start_with?(" ")
|
|
74
|
+
[left, right]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Deterministic IV per email so rebuilds produce identical ciphertext
|
|
78
|
+
# (keeps git diffs and CDN caches stable). IV reuse with the same plaintext
|
|
79
|
+
# under the same key is safe under AES-GCM; reuse with *different*
|
|
80
|
+
# plaintexts under the same key is what's catastrophic, and that doesn't
|
|
81
|
+
# happen here because each email gets its own derived IV.
|
|
82
|
+
def encrypt(email, key_hex)
|
|
83
|
+
key = [key_hex].pack("H*")
|
|
84
|
+
iv = Digest::SHA256.digest("liame-iv:#{key_hex}:#{email}")[0, 12]
|
|
85
|
+
cipher = OpenSSL::Cipher.new("aes-128-gcm").encrypt
|
|
86
|
+
cipher.key = key
|
|
87
|
+
cipher.iv = iv
|
|
88
|
+
cipher.auth_data = ""
|
|
89
|
+
ct = cipher.update(email) + cipher.final
|
|
90
|
+
Base64.strict_encode64(iv + ct + cipher.auth_tag)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def inline_svg(email, color)
|
|
94
|
+
width = email.length * 9 + 20
|
|
95
|
+
%(<svg xmlns="http://www.w3.org/2000/svg" class="liame-svg" ) +
|
|
96
|
+
%(width="#{width}" height="20" viewBox="0 0 #{width} 20" ) +
|
|
97
|
+
%(aria-label="contact" role="img">) +
|
|
98
|
+
%(<text x="0" y="14" font-family="JetBrains Mono, monospace" font-size="13" fill="#{color}">) +
|
|
99
|
+
%(#{CGI.escapeHTML(email)}</text></svg>)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
::Liquid::Template.register_tag("munge_email", Jekyll::EmailMunge::MungeEmailTag)
|
metadata
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: jekyll-email-munge
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Federico Ramallo
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: jekyll
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '3.7'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '5.0'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '3.7'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '5.0'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: bundler
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - "~>"
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '2.0'
|
|
39
|
+
type: :development
|
|
40
|
+
prerelease: false
|
|
41
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - "~>"
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '2.0'
|
|
46
|
+
- !ruby/object:Gem::Dependency
|
|
47
|
+
name: rake
|
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - "~>"
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '13.0'
|
|
53
|
+
type: :development
|
|
54
|
+
prerelease: false
|
|
55
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - "~>"
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '13.0'
|
|
60
|
+
description: |
|
|
61
|
+
A Jekyll plugin for address munging — defending email addresses on public
|
|
62
|
+
sites against bulk harvesters. Stacks five independently-effective
|
|
63
|
+
techniques (AES-128-GCM encryption, JS conversion, click trigger,
|
|
64
|
+
CSS-hidden decoy, SVG noscript fallback) so scrapers see ciphertext while
|
|
65
|
+
humans see a normal mailto link. Drop in a Liquid tag —
|
|
66
|
+
`{% munge_email "user@example.com" %}` — and the plugin handles encryption
|
|
67
|
+
at build time, the decoder script, and the fallback markup.
|
|
68
|
+
email:
|
|
69
|
+
- framallo@gmail.com
|
|
70
|
+
executables: []
|
|
71
|
+
extensions: []
|
|
72
|
+
extra_rdoc_files: []
|
|
73
|
+
files:
|
|
74
|
+
- CHANGELOG.md
|
|
75
|
+
- LICENSE.txt
|
|
76
|
+
- README.md
|
|
77
|
+
- jekyll-email-munge.gemspec
|
|
78
|
+
- lib/jekyll-email-munge.rb
|
|
79
|
+
- lib/jekyll-email-munge/decoder.js
|
|
80
|
+
- lib/jekyll-email-munge/munge_email_script_tag.rb
|
|
81
|
+
- lib/jekyll-email-munge/munge_email_tag.rb
|
|
82
|
+
- lib/jekyll-email-munge/version.rb
|
|
83
|
+
homepage: https://github.com/framallo/jekyll-email-munge
|
|
84
|
+
licenses:
|
|
85
|
+
- MIT
|
|
86
|
+
metadata:
|
|
87
|
+
homepage_uri: https://github.com/framallo/jekyll-email-munge
|
|
88
|
+
changelog_uri: https://github.com/framallo/jekyll-email-munge/blob/main/CHANGELOG.md
|
|
89
|
+
bug_tracker_uri: https://github.com/framallo/jekyll-email-munge/issues
|
|
90
|
+
documentation_uri: https://github.com/framallo/jekyll-email-munge#readme
|
|
91
|
+
rdoc_options: []
|
|
92
|
+
require_paths:
|
|
93
|
+
- lib
|
|
94
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
95
|
+
requirements:
|
|
96
|
+
- - ">="
|
|
97
|
+
- !ruby/object:Gem::Version
|
|
98
|
+
version: 2.7.0
|
|
99
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
100
|
+
requirements:
|
|
101
|
+
- - ">="
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: '0'
|
|
104
|
+
requirements: []
|
|
105
|
+
rubygems_version: 4.0.4
|
|
106
|
+
specification_version: 4
|
|
107
|
+
summary: 'Address-munge email links in Jekyll: AES-encrypted payloads, decoy text,
|
|
108
|
+
click-to-reveal, SVG fallback.'
|
|
109
|
+
test_files: []
|