summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorKen D'Ambrosio <ken@jots.org>2026-05-11 18:50:51 +0000
committerKen D'Ambrosio <ken@jots.org>2026-05-11 18:50:51 +0000
commitb40e95ca17f8c9f17af5f475d001c8ec33728e6d (patch)
tree46c242d916fca387164d760c482dda7fc6d7fd5f
parent723a9bc34c30ddb0decedd9efe64af5b91b71541 (diff)
v1.01: replace bcrypt with PBKDF2-SHA256; update README and DESIGN docsv1.01
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--.gitignore1
-rw-r--r--DESIGN.md63
-rw-r--r--Gemfile1
-rw-r--r--Gemfile.lock45
-rw-r--r--README.md52
-rw-r--r--app.rb13
-rw-r--r--scripts/set_password.rb10
7 files changed, 149 insertions, 36 deletions
diff --git a/.gitignore b/.gitignore
index 86109a1..0a734b0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,4 +6,3 @@ log/
vendor/
.bundle/
*.gem
-Gemfile.lock
diff --git a/DESIGN.md b/DESIGN.md
index a4d9108..2edcc1a 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -74,7 +74,7 @@ is set to unlimited because large video files may be uploaded via rsync.
img/audio.svg ← placeholder thumbnail for audio files
scripts/
update.rb ← post-upload scan/enrich script
- set_password.rb ← bcrypt password setter
+ set_password.rb ← PBKDF2-SHA256 password setter
cache/thumbs/ ← generated thumbnail cache (mirrored path structure)
tmp/ ← Puma pid / state files
log/ ← Puma stdout / stderr logs
@@ -103,9 +103,11 @@ is set to unlimited because large video files may be uploaded via rsync.
| `puma ~> 6.4` | Multi-threaded Rack application server |
| `mini_magick ~> 4.12` | ImageMagick wrapper — thumbnail generation, EXIF-aware auto-orient |
| `mini_exiftool ~> 2.10` | Reads EXIF `DateTimeOriginal` / `CreateDate` for chronological sorting |
-| `bcrypt ~> 3.1` | Password hashing for the single admin account |
| `rack-session ~> 2.0` | Cookie-based session support (required by Sinatra 4 separately) |
+Password hashing uses `OpenSSL::PKCS5.pbkdf2_hmac` from Ruby's standard library
+(100,000 iterations, SHA-256, 32-byte output). No native gem extension is required.
+
### System tools
| Tool | Purpose |
@@ -257,12 +259,12 @@ no server call is needed to open or navigate the lightbox.
`.hidden` (i.e. `display:none !important`).
3. The appropriate element for the media type has `.hidden` removed and its
`src` set. For video and audio, playback starts immediately.
-4. The URL hash is updated to `#photo=filename` so the link is shareable.
- On page load, if that hash is present, the matching photo is opened
- automatically.
+4. The URL is updated to `?photo=filename` so the link is shareable and
+ the server can read it. On page load, if that parameter is present,
+ the matching photo is opened automatically.
5. Navigation: ← → arrow keys, on-screen buttons, or touch swipe (>50 px).
6. Closing: `Escape`, the ✕ button, or clicking outside the media.
- Video/audio is paused; the hash is stripped from the URL.
+ Video/audio is paused; the `?photo=` param is stripped from the URL.
### Slideshow (`GET /slideshow/SomeAlbum`)
@@ -282,14 +284,41 @@ page. `slideshow.js`:
### Admin authentication
-A single bcrypt-hashed password is stored in `config.yml`
-(`admin_password_hash`). `POST /admin/login` compares the submitted
-password against the hash using `BCrypt::Password#==`. On success,
+A single PBKDF2-SHA256 password hash is stored in `config.yml`
+(`admin_password_hash`) in the format `pbkdf2_sha256$<iterations>$<salt>$<hex>`.
+`POST /admin/login` re-derives the key from the submitted password and compares
+using `OpenSSL.fixed_length_secure_compare` (constant-time). On success,
`session[:admin] = true` is set in an encrypted cookie. All admin routes
call `require_admin!` which halts with the login form on failure.
-The `return_to` parameter on the login form lets the visitor be redirected
-back to the page they were trying to reach.
+The `return_to` parameter on the login form redirects the visitor back to
+the page they came from — the "Admin" nav link passes the current album's
+edit URL so logging in lands directly in the right edit view.
+
+### Open Graph meta tags
+
+Every album page includes `og:title`, `og:url`, `og:type`, and (when
+available) `og:description` and `og:image`. The image is resolved
+deterministically in `browse_album`:
+
+- If the request includes `?photo=filename`, `og:image` points to the
+ full-resolution `/media/` URL for that specific file — so sharing a
+ photo link previews that exact photo.
+- Otherwise, the first image from `@entries` (chronological/filename order)
+ is used, falling back to `cover_candidates(dir).first` if the album has
+ no direct media files.
+
+`request.base_url` is used to build absolute URLs, so the tags are correct
+regardless of hostname or scheme.
+
+### Filtered slideshow
+
+When the album search filter is active on the root page, the Slideshow
+button href is dynamically updated (via `album.js`) to include
+`?dirs=name1,name2,...` — the `data-rel` values of the currently visible
+album cards. `slideshow_view` in `app.rb` passes these to
+`all_media_entries(top_dirs:)`, which walks only those specific
+subdirectories instead of the full media tree.
### Saving album edits (`POST /admin/edit/SomeAlbum`)
@@ -297,9 +326,9 @@ back to the page they were trying to reach.
2. Top-level fields (title, description, cover, sort_reverse, visible) are
updated from form params. `blank_to_nil` converts empty strings to `nil`
so omitted optional fields don't get stored as `""`.
-3. Per-file fields (title, caption, visible) are updated by iterating the
- `file_title[name]` / `file_caption[name]` / `file_visible[name]` param
- hashes.
+3. Per-file fields (caption, visible) are updated by iterating the
+ `file_visible[name]` param hash (present for every file via a hidden
+ input), merging `file_caption[name]` for each.
4. `atomic_write` writes the updated JSON to a `.tmp` file and renames it
into place.
5. Redirect back to the same edit page (PRG pattern — prevents double-submit
@@ -351,7 +380,7 @@ escape the media root gets a `404`.
returns `403` unless the session has admin privileges. The browse routes
filter hidden albums and files from the data sent to the browser.
-**Admin password:** Stored as a bcrypt hash in `config.yml` (not in git).
+**Admin password:** Stored as a PBKDF2-SHA256 hash in `config.yml` (not in git).
The plaintext password is never persisted. Sessions use an encrypted cookie
with a random secret also stored in `config.yml`.
@@ -389,8 +418,8 @@ upload, run `update.rb` (it generates thumbnails as part of its scan).
### Configuration (`config.yml`)
```yaml
-admin_password_hash: "$2a$12$..." # set with scripts/set_password.rb
-session_secret: "random-hex-string" # set once at install time
+admin_password_hash: "pbkdf2_sha256$100000$<salt>$<hex>" # set with scripts/set_password.rb
+session_secret: "random-hex-string" # set once at install time
```
This file is not tracked in git. All three paths (`MEDIA_ROOT`,
diff --git a/Gemfile b/Gemfile
index 826647b..bf3cbff 100644
--- a/Gemfile
+++ b/Gemfile
@@ -4,5 +4,4 @@ gem 'sinatra', '~> 4.0'
gem 'puma', '~> 6.4'
gem 'mini_magick', '~> 4.12'
gem 'mini_exiftool', '~> 2.10'
-gem 'bcrypt', '~> 3.1'
gem 'rack-session', '~> 2.0'
diff --git a/Gemfile.lock b/Gemfile.lock
new file mode 100644
index 0000000..72474d0
--- /dev/null
+++ b/Gemfile.lock
@@ -0,0 +1,45 @@
+GEM
+ remote: https://rubygems.org/
+ specs:
+ base64 (0.3.0)
+ logger (1.7.0)
+ mini_exiftool (2.14.0)
+ ostruct (>= 0.6.0)
+ pstore (>= 0.1.3)
+ mini_magick (4.13.2)
+ mustermann (3.1.1)
+ nio4r (2.7.5)
+ ostruct (0.6.3)
+ pstore (0.2.1)
+ puma (6.6.1)
+ nio4r (~> 2.0)
+ rack (3.2.6)
+ rack-protection (4.2.1)
+ base64 (>= 0.1.0)
+ logger (>= 1.6.0)
+ rack (>= 3.0.0, < 4)
+ rack-session (2.1.2)
+ base64 (>= 0.1.0)
+ rack (>= 3.0.0)
+ sinatra (4.2.1)
+ logger (>= 1.6.0)
+ mustermann (~> 3.0)
+ rack (>= 3.0.0, < 4)
+ rack-protection (= 4.2.1)
+ rack-session (>= 2.0.0, < 3)
+ tilt (~> 2.0)
+ tilt (2.7.0)
+
+PLATFORMS
+ ruby
+ x86_64-linux
+
+DEPENDENCIES
+ mini_exiftool (~> 2.10)
+ mini_magick (~> 4.12)
+ puma (~> 6.4)
+ rack-session (~> 2.0)
+ sinatra (~> 4.0)
+
+BUNDLED WITH
+ 2.6.9
diff --git a/README.md b/README.md
index 168edfd..a056891 100644
--- a/README.md
+++ b/README.md
@@ -5,6 +5,44 @@ back end, plain HTML/CSS/JS front end. Live at **https://albumen.jots.org**.
---
+## Features
+
+### Browsing
+- Nested album hierarchy — every directory is an album, unlimited nesting
+- Grid view with auto-generated 300×300 thumbnails (images, video frames, audio placeholder)
+- Chronological photo sorting when EXIF dates are present, filename order otherwise
+- Live search/filter box to narrow albums by name
+- Breadcrumb navigation
+- Lightbox with keyboard (← →, Esc) and touch-swipe navigation
+- Info panel showing filename, EXIF date, and pixel dimensions
+- Shareable per-photo URLs (`?photo=filename`) that open the lightbox directly
+- Social media link previews via Open Graph meta tags (album cover or specific photo)
+
+### Slideshow
+- Per-album or root-level (all photos across every album)
+- Shuffle and Full screen options selectable before launch
+- Respects the current album filter — filtered view launches a filtered slideshow
+- Cross-fade transitions with configurable interval (1–60 s)
+- Keyboard (← →, Space, F), touch swipe, and on-screen controls
+- Click/tap a photo during slideshow to jump to its album lightbox
+
+### Admin
+- Single admin account; password stored as a PBKDF2-SHA256 hash (no native gem needed)
+- Logging in from an album page redirects back to that album's edit view
+- Per-album: title, description, cover image (specific file or random), sub-album order, visibility
+- Per-file: caption, visibility
+- Save button at top and bottom of the edit form
+
+### Media support
+
+| Category | Extensions |
+|---|---|
+| Images | jpg jpeg png gif webp heic heif tiff bmp |
+| Videos | mp4 mov avi mkv webm m4v ogv |
+| Audio | mp3 flac ogg wav m4a aac |
+
+---
+
## Directory layout
| Path | Purpose |
@@ -80,8 +118,7 @@ You can edit these by hand or through the admin UI at `/admin`.
{
"title": "Italy 2024", // overrides the directory name in the UI
"description": "Two weeks in Rome and Florence",
- "cover": "DSC_0042.jpg", // which file to use as album thumbnail
- "cover_dynamic": false, // true = play video/animation on hover
+ "cover": "DSC_0042.jpg", // specific file, or "__random__" for a random pick
"visible": true, // false = hidden from non-admin users
"files": {
"DSC_0042.jpg": {
@@ -116,7 +153,7 @@ ssh root@albumen.jots.org
ruby /opt/albumen/scripts/set_password.rb
```
-The bcrypt hash is stored in `/opt/albumen/config.yml` (readable only by the
+The PBKDF2-SHA256 hash is stored in `/opt/albumen/config.yml` (readable only by the
`albumen` service user).
---
@@ -187,12 +224,3 @@ scp -r /home/ken/albumen/. root@albumen.jots.org:/opt/albumen/
ssh root@albumen.jots.org 'chown -R albumen:albumen /opt/albumen && systemctl restart albumen'
```
----
-
-## Supported file types
-
-| Category | Extensions |
-|---|---|
-| Images | jpg jpeg png gif webp heic heif tiff bmp |
-| Videos | mp4 mov avi mkv webm m4v ogv |
-| Audio | mp3 flac ogg wav m4a aac |
diff --git a/app.rb b/app.rb
index dca8825..2cd40b2 100644
--- a/app.rb
+++ b/app.rb
@@ -3,7 +3,7 @@
require 'sinatra'
require 'json'
require 'yaml'
-require 'bcrypt'
+require 'openssl'
require 'mini_magick'
require 'fileutils'
require 'securerandom'
@@ -164,6 +164,15 @@ helpers do
parts.each_with_index.map { |p, i| { name: p, path: parts[0..i].join('/') } }
end
+ def pbkdf2_verify(password, stored)
+ _algo, iterations, salt, expected_hex = stored.split('$')
+ actual = OpenSSL::PKCS5.pbkdf2_hmac(password, salt, iterations.to_i, 32, 'SHA256')
+ actual_hex = actual.unpack1('H*')
+ OpenSSL.fixed_length_secure_compare(actual_hex, expected_hex)
+ rescue
+ false
+ end
+
def blank_to_nil(s)
v = s.to_s.strip
v.empty? ? nil : v
@@ -322,7 +331,7 @@ end
post '/admin/login' do
hash = APP_CONFIG[:admin_password_hash].to_s
- if !hash.empty? && BCrypt::Password.new(hash) == params['password']
+ if !hash.empty? && pbkdf2_verify(params['password'].to_s, hash)
session[:admin] = true
redirect params['return_to']&.start_with?('/') ? params['return_to'] : '/admin/edit/'
else
diff --git a/scripts/set_password.rb b/scripts/set_password.rb
index 0b83861..16e7dec 100644
--- a/scripts/set_password.rb
+++ b/scripts/set_password.rb
@@ -3,19 +3,24 @@
# Usage: ruby scripts/set_password.rb
# Sets (or resets) the admin password in config.yml.
-require 'bcrypt'
+require 'openssl'
require 'yaml'
require 'securerandom'
CONFIG_PATH = ENV['CONFIG_PATH'] || '/opt/albumen/config.yml'
+ITERATIONS = 100_000
print 'New admin password: '
STDOUT.flush
password = $stdin.gets&.chomp
abort 'No password given.' if password.nil? || password.strip.empty?
+salt = SecureRandom.hex(32)
+digest = OpenSSL::PKCS5.pbkdf2_hmac(password, salt, ITERATIONS, 32, 'SHA256')
+hash = "pbkdf2_sha256$#{ITERATIONS}$#{salt}$#{digest.unpack1('H*')}"
+
config = File.exist?(CONFIG_PATH) ? (YAML.load_file(CONFIG_PATH) || {}) : {}
-config['admin_password_hash'] = BCrypt::Password.create(password).to_s
+config['admin_password_hash'] = hash
config['session_secret'] ||= SecureRandom.hex(32)
tmp = "#{CONFIG_PATH}.tmp.#{Process.pid}"
@@ -23,7 +28,6 @@ File.write(tmp, config.to_yaml)
File.rename(tmp, CONFIG_PATH)
File.chmod(0o600, CONFIG_PATH)
-# Ensure the service user can read the file even when this script is run as root.
begin
require 'etc'
pw = Etc.getpwnam('albumen')