davideisinger.com

My personal website
Log | Files | Refs | README

index.md (9271B)


      1 ---
      2 title: "Encrypt and Dither Photos in Hugo"
      3 date: 2024-02-06T23:00:00-05:00
      4 draft: false
      5 tags:
      6 - meta
      7 references:
      8 - title: "Ditherpunk — The article I wish I had about monochrome image dithering — surma.dev"
      9   url: https://surma.dev/things/ditherpunk/
     10   date: 2024-02-05T14:50:25Z
     11   file: surma-dev-e4sfuv.txt
     12 - title: "About the Solar Powered Website | LOW←TECH MAGAZINE"
     13   url: https://solar.lowtechmagazine.com/about/the-solar-website/
     14   date: 2024-02-05T14:50:28Z
     15   file: solar-lowtechmagazine-com-vj7kk5.txt
     16 - title: "Elliot Jay Stocks  | 2023 in review"
     17   url: https://elliotjaystocks.com/blog/2023-in-review
     18   date: 2024-02-02T15:51:48Z
     19   file: elliotjaystocks-com-fcit8u.txt
     20 - title: "Encrypt and decrypt a file using SSH keys"
     21   url: https://www.bjornjohansen.com/encrypt-file-using-ssh-key
     22   date: 2024-02-05T14:50:24Z
     23   file: www-bjornjohansen-com-hqud3x.txt
     24 ---
     25 
     26 I encrypted all the photos on this site and wrote a tiny image server that decrypts and dithers the photos, then created a Hugo shortcode to display dithered images in posts. It keeps high-res photos of my kid off the web, and it looks cool.
     27 
     28 <!--more-->
     29 
     30 ***
     31 
     32 **Update 2024-02-12:** [Hugo will support native dithering in the next release.][1] If you're after the lo-fi look and don't need encryption, that'll be a lot cleaner than the approach outlined below.
     33 
     34 [1]: https://github.com/gohugoio/hugo/pull/12016
     35 
     36 When I was first setting up this site, I considered giving all the photos a monochrome [dithered][2] treatment à la [Low-tech Magazine][3]. Hugo has impressive [image manipulation functionality][4] but doesn't include dithering and [seems unlikely to add it][5]. I opted for full-color photos and went on with my life.
     37 
     38 [2]: https://surma.dev/things/ditherpunk/
     39 [3]: https://solar.lowtechmagazine.com/about/the-solar-website/#dithered-images
     40 [4]: https://gohugo.io/content-management/image-processing/
     41 [5]: https://github.com/gohugoio/hugo/issues/8598
     42 
     43 Most of what I post on this site are these monthly [dispatches][6] that start with what my family's been up to in the last month and include several high-resolution photos. Last week, I was reading Elliot Jay Stocks' "[2023 in review][7]," and he's adamant about not posting photos of his kids. That inspired me to take another crack at getting dithered images working -- I take a lot of joy out of documenting our family life, and low-res, dithered images strike a good balance between giant full-color photos and not showing people in photos at all. And to add another wrinkle: this site is [open source][8], so I also needed to ensure that the source images wouldn't be available on SourceHut.
     44 
     45 [6]: /tags/dispatch/
     46 [7]: https://elliotjaystocks.com/blog/2023-in-review
     47 [8]: https://git.sr.ht/~dce/davideisinger.com
     48 
     49 I tried treating the full-size images with ImageMagick on the command line and then letting Hugo resize the result, but I wasn't happy with the output -- there's still way too much data in a dithered full-sized image, so when you scale it down, it just looks like a crappy black-and-white photo. Furthermore, the encoding wasn't properly optimizing for two-color images and so the files were larger than I wanted.
     50 
     51 I needed to find some way to scale the images to the appropriate size and _then_ apply the dither. Fortunately, Hugo has the ability to [fetch remote images][9], which got me thinking about a separate image processing service. After a late night of coding, I've got a solution I'm quite pleased with.
     52 
     53 Here's a picture of me, before and after dithering:
     54 
     55 {{<thumbnail race_121539.jpg "782x900" />}}
     56 {{<dither race_121539.jpg "782x900">}}Runner No. 534 pushes through the final stretch, focused and strong as he nears the finish line in an overcast parking lot race.{{</dither>}}
     57 
     58  Read on for more details, and if you want to follow along, you'll need to have Ruby installed (I recommend [asdf][10] if you're on a Unix-y OS) as well as ImageMagick and OpenSSL.
     59 
     60 [9]: https://gohugo.io/content-management/image-processing/#remote-resource
     61 [10]: https://asdf-vm.com/
     62 
     63 ### 1. Encrypt all images
     64 
     65 
     66 We'll use OpenSSL to encrypt our images ([here's a guide][11]). First, we'll generate a secret key (the `-hex` option gives us something we can easily copy/paste):
     67 
     68 [11]: https://www.bjornjohansen.com/encrypt-file-using-ssh-key
     69 
     70 ```sh
     71 openssl rand -hex -out secret.key 32
     72 ```
     73 
     74 [Make a backup][12] of the key and then `gitignore` it:
     75 
     76 [12]: https://bitwarden.com/
     77 
     78 ```sh
     79 echo secret.key >> .gitignore
     80 ```
     81 
     82 Then we'll use the key to encrypt all the images in the `content` folder. I use an interactive Ruby shell for this sort of thing because I'm not very good at shell scripting:
     83 
     84 ```ruby
     85 Dir.glob("content/**/*.{jpg,jpeg,png}").each do |path|
     86   %x(
     87     openssl \
     88       aes-256-cbc \
     89       -in #{path} \
     90       -out #{path}.enc \
     91       -pass file:secret.key \
     92       -iter 1000000
     93   )
     94 end
     95 ```
     96 
     97 ### 2. Build a tiny image server
     98 
     99 I made a [standalone image server][13] using [Sinatra][14] and [MiniMagick][15] that takes a path to an encrypted image and an optional geometry string and returns a dithered image. I won't paste the entire file here but it's really pretty short and simple.
    100 
    101 [13]: https://git.sr.ht/~dce/davideisinger.com/tree/bf5238dd56b6dfe9ee2f1d629d017b2075750663/bin/dither/dither.rb
    102 [14]: https://sinatrarb.com/
    103 [15]: https://github.com/minimagick/minimagick
    104 
    105 If you want to run it yourself, copy down everything in the [`bin/dither`][16] folder and then run the following:
    106 
    107 [16]: https://git.sr.ht/~dce/davideisinger.com/tree/bf5238dd56b6dfe9ee2f1d629d017b2075750663/bin/dither
    108 
    109 
    110 ```sh
    111 > cd bin/dither
    112 > bundle install
    113 > ROOT=../../content \
    114   KEY=../../secret.key \
    115   bundle exec ruby dither.rb
    116 ```
    117 
    118 Then, assuming you have an encrypted image at `content/path/to/file.jpg.enc`, you should be able to visit [localhost:4567/path/to/file.jpg?geo=400x300](http://localhost:4567/path/to/file.jpg?geo=400x300) in your browser to see it working.
    119 
    120 ### 3. Create a Hugo shortcode to fetch dithered images
    121 
    122 We need to tell Hugo where to find our image server, which we'll supply with an environment variable. First, we'll give Hugo access to `DITHER_SERVER` in `config.toml`:
    123 
    124 ```toml
    125 [security.funcs]
    126 getenv = ['DITHER_SERVER']
    127 ```
    128 
    129 Then start Hugo like this:
    130 
    131 ```sh
    132 DITHER_SERVER=http://localhost:4567 hugo server
    133 ```
    134 
    135 Now we'll create the shortcode ([`layouts/shortcodes/dither.html`][17]):
    136 
    137 ```html
    138 {{ $file := printf "%s%s" .Page.File.Dir (.Get 0) }}
    139 {{ $geo := .Get 1 }}
    140 {{ $img := resources.GetRemote (printf "%s/%s?geo=%s" (getenv "DITHER_SERVER") $file $geo) }}
    141 {{ $imgClass := .Get 2 }}
    142 
    143 <a href="{{ $img.RelPermalink }}">
    144   <img src="{{ $img.RelPermalink }}"
    145     width="{{ $img.Width }}"
    146     height="{{ $img.Height }}"
    147     class="{{ $imgClass }}"
    148   >
    149   {{ with .Inner }}
    150     <figcaption>
    151       {{ . }}
    152     </figcaption>
    153   {{ end }}
    154 </a>
    155 ```
    156 
    157 Adjust for your needs, but the gist is:
    158 
    159 1. Construct a URL from `DITHER_SERVER`, the directory that the page lives in, the supplied file name, and the (optional) geometry string
    160 2. Use `resources.GetRemote` to fetch the image
    161 3. Display as appropriate
    162 
    163 [17]: https://git.sr.ht/~dce/davideisinger.com/tree/2cda4b8f4e98bb9df84747da283d13075aac4d41/themes/v2/layouts/shortcodes/dither.html
    164 
    165 Use it like this:
    166 
    167 ```
    168 {{</*dither IMG_2374.jpeg "782x1200" /*/>}}
    169 ```
    170 
    171 ### 4. Delete the unencrypted images from the repository
    172 
    173 Now that everything's working, let's remove all the unencrypted images from the repository. It's not enough to just `git rm` them, since they'd still be present in the history, so we'll use [`git filter-repo`][18] to rewrite the history as if they never existed.
    174 
    175 ```ruby
    176 Dir.glob("content/**/*.{jpg,jpeg,png}") do |path|
    177   `git filter-repo --invert-paths --force --path #{path}`
    178 end
    179 ```
    180 
    181 [18]: https://github.com/newren/git-filter-repo
    182 
    183 ### 5. Tweak site styles
    184 
    185 The resulting images will be entirely black and white, and this site doesn't use a pure white background color. We can improve the display of the dithered images with some CSS that sets `mix-blend-mode` to `multiply`:
    186 
    187 ```css
    188 img {
    189   mix-blend-mode: multiply;
    190 }
    191 ```
    192 
    193 The blacks will still show as black, but the whites will now be the background color of the site.
    194 
    195 ### 6. Update the deploy workflow
    196 
    197 This site uses [SourceHut Builds][19] to deploy on pushes to the `main` branch, and we need to make a few updates to our workflow to generate the static site with dithered images:
    198 
    199 * Add the decryption key as a secret
    200 * Add workflow steps to
    201   * Install Ruby and the required Gem dependencies
    202   * Start the dither server as a background task (using `rackup` with the `-D` option)
    203 * Add the `DITHER_SERVER` environment variable to the build step so that Hugo knows where to find it
    204 
    205 [Here's the deploy workflow for this site][20] for reference.
    206 
    207 [19]: https://builds.sr.ht/
    208 [20]: https://git.sr.ht/~dce/davideisinger.com/tree/main/.build.yml
    209 
    210 ***
    211 
    212 This was super fun to build, and I'm really happy with [the result][21]. It makes the local authoring and deploy processes a bit more complicated since we have to run the separate image server, but I think the result is worth it. Hope you found this interesting, and please [reach out](mailto:[email protected]) if you have any thoughts or questions.
    213 
    214 [21]: /journal/dispatch-12-february-2024/