From b40e95ca17f8c9f17af5f475d001c8ec33728e6d Mon Sep 17 00:00:00 2001 From: Ken D'Ambrosio Date: Mon, 11 May 2026 18:50:51 +0000 Subject: v1.01: replace bcrypt with PBKDF2-SHA256; update README and DESIGN docs Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 - DESIGN.md | 63 ++++++++++++++++++++++++++++++++++++------------- Gemfile | 1 - Gemfile.lock | 45 +++++++++++++++++++++++++++++++++++ README.md | 52 ++++++++++++++++++++++++++++++---------- app.rb | 13 ++++++++-- scripts/set_password.rb | 10 +++++--- 7 files changed, 149 insertions(+), 36 deletions(-) create mode 100644 Gemfile.lock 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$$$`. +`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$$" # 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') -- cgit v1.2.3