diff options
| -rw-r--r-- | .gitignore | 9 | ||||
| -rw-r--r-- | Gemfile | 8 | ||||
| -rw-r--r-- | README.md | 198 | ||||
| -rw-r--r-- | app.rb | 371 | ||||
| -rw-r--r-- | config.ru | 2 | ||||
| -rw-r--r-- | config/albumen.service | 19 | ||||
| -rw-r--r-- | config/nginx-albumen.conf | 18 | ||||
| -rw-r--r-- | config/puma.rb | 9 | ||||
| -rw-r--r-- | public/css/style.css | 351 | ||||
| -rw-r--r-- | public/js/album.js | 111 | ||||
| -rw-r--r-- | public/js/slideshow.js | 113 | ||||
| -rw-r--r-- | scripts/set_password.rb | 26 | ||||
| -rw-r--r-- | scripts/update.rb | 221 | ||||
| -rw-r--r-- | setup.sh | 52 | ||||
| -rw-r--r-- | views/admin/album.erb | 101 | ||||
| -rw-r--r-- | views/admin/login.erb | 14 | ||||
| -rw-r--r-- | views/album.erb | 99 | ||||
| -rw-r--r-- | views/layout.erb | 25 | ||||
| -rw-r--r-- | views/slideshow.erb | 41 |
19 files changed, 1788 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..86109a1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +config.yml +config.yml.tmp.* +cache/ +tmp/ +log/ +vendor/ +.bundle/ +*.gem +Gemfile.lock @@ -0,0 +1,8 @@ +source 'https://rubygems.org' + +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/README.md b/README.md new file mode 100644 index 0000000..168edfd --- /dev/null +++ b/README.md @@ -0,0 +1,198 @@ +# Albumen — Photo Album Server + +Self-hosted photo/video album. Directory hierarchy = album hierarchy. Ruby/Sinatra +back end, plain HTML/CSS/JS front end. Live at **https://albumen.jots.org**. + +--- + +## Directory layout + +| Path | Purpose | +|---|---| +| `/opt/albumen/` | Application root | +| `/opt/albumen/app.rb` | Sinatra application | +| `/opt/albumen/views/` | ERB templates | +| `/opt/albumen/public/` | CSS, JS, static assets | +| `/opt/albumen/scripts/` | CLI utilities | +| `/opt/albumen/cache/thumbs/` | Auto-generated thumbnails (safe to delete/regenerate) | +| `/opt/albumen/config.yml` | Admin password hash + session secret (mode 600) | +| `/opt/albumen/log/` | Puma stdout/stderr logs | +| `/var/albumen/` | **Your photos live here** | + +Each subdirectory under `/var/albumen/` is an album. Nesting is unlimited. +The directory name is the album name; the admin UI lets you assign a prettier title. + +--- + +## Uploading photos + +Drop files into `/var/albumen/` (or any subdirectory) via `rsync` or `scp` +from your local machine: + +```bash +rsync -av ~/Pictures/2024-Italy/ root@albumen.jots.org:/var/albumen/2024-Italy/ + +# or a single file +scp photo.jpg root@albumen.jots.org:/var/albumen/2024-Italy/ +``` + +After any upload **run the update script** (see below). + +--- + +## Running the update script + +The update script walks the media tree, creates/updates `album.json` files with +EXIF dates and image dimensions, and pre-generates thumbnails. + +```bash +# On the server — process the entire tree +ruby /opt/albumen/scripts/update.rb + +# Process only one album (and its sub-albums) +ruby /opt/albumen/scripts/update.rb 2024-Italy + +# With an absolute path +ruby /opt/albumen/scripts/update.rb /var/albumen/2024-Italy +``` + +**Resilience guarantees — safe to interrupt and re-run at any point:** +- `album.json` is written atomically (temp file + rename); no partial writes. +- Thumbnails that already exist are skipped entirely. +- EXIF metadata already recorded is not re-extracted. +- Deleted files are pruned from `album.json` automatically. + +Typical workflow: + +```bash +rsync -av ~/Pictures/trip/ root@albumen.jots.org:/var/albumen/trip/ +ssh root@albumen.jots.org 'ruby /opt/albumen/scripts/update.rb trip' +``` + +--- + +## album.json reference + +Each directory gets an `album.json` created by the update script. +You can edit these by hand or through the admin UI at `/admin`. + +```jsonc +{ + "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 + "visible": true, // false = hidden from non-admin users + "files": { + "DSC_0042.jpg": { + "title": "Colosseum", // shown in lightbox (defaults to filename) + "caption": "Just after sunrise, no crowds yet.", + "visible": true, + "taken_at": "2024-06-03T06:14:00", // from EXIF; set by update script + "width": 6000, + "height": 4000 + } + } +} +``` + +Fields set by the update script (`taken_at`, `width`, `height`) are not +overwritten if already present — safe to correct by hand. + +--- + +## Admin interface + +Go to `https://albumen.jots.org/admin` and log in. From there: + +- Edit album title, description, cover image, visibility +- Edit per-file title, caption, visibility +- Navigate into sub-albums + +### Changing the admin password + +```bash +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 +`albumen` service user). + +--- + +## Service management + +```bash +systemctl status albumen # is it running? +systemctl restart albumen # restart (e.g. after editing app.rb) +journalctl -u albumen -f # live service logs +tail -f /opt/albumen/log/puma.stdout.log # Puma access log +tail -f /opt/albumen/log/puma.stderr.log # Puma error log +``` + +The service runs as the `albumen` user. App code lives in `/opt/albumen/`. + +--- + +## Thumbnail cache + +Thumbnails are stored in `/opt/albumen/cache/thumbs/`, mirroring the media +tree. The cache is fully regenerable — delete any or all of it and the app +regenerates on demand (or run the update script to pre-generate). + +```bash +# Regenerate all thumbnails for one album +rm -rf /opt/albumen/cache/thumbs/2024-Italy +ruby /opt/albumen/scripts/update.rb 2024-Italy +``` + +--- + +## Infrastructure + +| Component | Location | Notes | +|---|---|---| +| App server | `192.168.10.245` | Puma on port 4567, nginx on 80 | +| Reverse proxy | `192.168.10.1` (prouter) | Apache, handles TLS termination | +| DNS | `mirkwood.jots.org` (209.141.48.158) | BIND 9, zone `/etc/bind/zones/jots.org.zone` | +| TLS cert | `/etc/letsencrypt/live/albumen.jots.org/` on prouter | Expires 2026-08-07; auto-renewed by certbot | +| Public URL | `https://albumen.jots.org` | → prouter → 192.168.10.245:80 | + +### Sinatra settings required for the proxy setup + +Two settings in `app.rb` are necessary when running behind an HTTPS reverse proxy: + +- `set :absolute_redirects, false` — Sinatra redirects use relative paths (`/foo`) + so the browser stays on HTTPS rather than following an `http://` Location header. +- `set :protection, except: :http_origin` — prevents Rack::Protection from + dropping the admin session when the `Origin` header's scheme (`https://`) + doesn't match the backend connection scheme (`http://`). + +### Cert renewal + +Certbot auto-renewal is managed by the system cron on prouter. To test: + +```bash +ssh root@192.168.10.1 'certbot renew --dry-run' +``` + +--- + +## Re-deploying after code changes + +```bash +# From your workstation +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 | @@ -0,0 +1,371 @@ +# frozen_string_literal: true + +require 'sinatra' +require 'json' +require 'yaml' +require 'bcrypt' +require 'mini_magick' +require 'fileutils' +require 'securerandom' + +# ── Constants ────────────────────────────────────────────────────────────────── + +MEDIA_ROOT = (ENV['MEDIA_ROOT'] || '/var/albumen').freeze +CACHE_ROOT = (ENV['CACHE_ROOT'] || '/opt/albumen/cache/thumbs').freeze +CONFIG_PATH = (ENV['CONFIG_PATH'] || '/opt/albumen/config.yml').freeze +THUMB_SIZE = 300 + +IMAGE_EXTS = %w[jpg jpeg png gif webp heic heif tiff bmp].freeze +VIDEO_EXTS = %w[mp4 mov avi mkv webm m4v ogv].freeze +AUDIO_EXTS = %w[mp3 flac ogg wav m4a aac].freeze +MEDIA_EXTS = (IMAGE_EXTS + VIDEO_EXTS + AUDIO_EXTS).freeze + +APP_CONFIG = (File.exist?(CONFIG_PATH) ? YAML.load_file(CONFIG_PATH, symbolize_names: true) : {}).freeze + +# ── Sinatra config ───────────────────────────────────────────────────────────── + +configure do + enable :sessions + set :session_secret, APP_CONFIG[:session_secret] || SecureRandom.hex(32) + set :protection, except: :http_origin + set :absolute_redirects, false + set :views, File.join(__dir__, 'views') + set :public_folder, File.join(__dir__, 'public') + set :bind, '127.0.0.1' + set :port, 4567 + set :logging, true +end + +configure :production do + set :show_exceptions, false +end + +# Trust the scheme the upstream proxy reports so redirects use https:// +# and Rack::Protection::HttpOrigin sees the correct origin. +before do + if request.env['HTTP_X_FORWARDED_PROTO'] == 'https' + request.env['rack.url_scheme'] = 'https' + end +end + +# ── Helpers ──────────────────────────────────────────────────────────────────── + +helpers do + def admin? + session[:admin] == true + end + + def require_admin! + halt 401, erb(:'admin/login') unless admin? + end + + def media_type_for(name) + ext = File.extname(name).downcase.delete_prefix('.') + return :image if IMAGE_EXTS.include?(ext) + return :video if VIDEO_EXTS.include?(ext) + return :audio if AUDIO_EXTS.include?(ext) + nil + end + + def load_album(dir) + path = File.join(dir, 'album.json') + return default_album unless File.exist?(path) + JSON.parse(File.read(path)) + rescue JSON::ParserError + default_album + end + + def default_album + { 'files' => {}, 'visible' => true } + end + + def album_files(dir, data) + files = Dir.children(dir) + .sort + .select { |n| MEDIA_EXTS.include?(File.extname(n).downcase.delete_prefix('.')) } + + entries = files.filter_map do |name| + meta = (data['files'] || {})[name] || {} + next if meta['visible'] == false && !admin? + { + name: name, + title: meta['title'] || name, + caption: meta['caption'], + visible: meta.fetch('visible', true), + type: media_type_for(name), + taken_at: meta['taken_at'], + } + end + + if entries.any? { |e| e[:taken_at] } + entries.sort_by { |e| [e[:taken_at] ? 0 : 1, e[:taken_at].to_s, e[:name]] } + else + entries + end + end + + def child_albums(dir, data) + albums = Dir.children(dir) + .sort + .select { |n| !n.start_with?('.') && File.directory?(File.join(dir, n)) } + .filter_map do |name| + sub_dir = File.join(dir, name) + sub_data = load_album(sub_dir) + next if sub_data['visible'] == false && !admin? + { + name: name, + title: sub_data['title'] || name, + cover: album_cover(sub_dir, sub_data), + dynamic: sub_data['cover_dynamic'] == true, + } + end + data['sort_reverse'] ? albums.reverse : albums + end + + def album_cover(dir, data) + if (cover = data['cover']) + return cover if File.exist?(File.join(dir, cover)) + end + Dir.children(dir).sort.find { |n| (IMAGE_EXTS + VIDEO_EXTS).include?(File.extname(n).downcase.delete_prefix('.')) } + end + + # Returns the absolute path or halts with 404. + # rel may be empty (means MEDIA_ROOT itself). + def resolve_dir(rel) + return MEDIA_ROOT if rel.empty? + full = File.expand_path(rel, MEDIA_ROOT) + halt 404 unless full.start_with?("#{MEDIA_ROOT}/") + full + end + + def resolve_file(rel) + halt 404 if rel.empty? + full = File.expand_path(rel, MEDIA_ROOT) + halt 404 unless full.start_with?("#{MEDIA_ROOT}/") + full + end + + def thumb_cache_path(rel) + File.join(CACHE_ROOT, "#{rel}.th.jpg") + end + + def breadcrumbs(rel) + return [] if rel.empty? + parts = rel.split('/') + parts.each_with_index.map { |p, i| { name: p, path: parts[0..i].join('/') } } + end + + def blank_to_nil(s) + v = s.to_s.strip + v.empty? ? nil : v + end +end + +# ── Public routes ────────────────────────────────────────────────────────────── + +get '/' do + redirect '/browse/' +end + +get '/browse' do + redirect '/browse/' +end + +get '/browse/' do + browse_album('') +end + +get '/browse/*' do + browse_album(params[:splat].first.chomp('/')) +end + +def browse_album(rel) + dir = resolve_dir(rel) + halt 404 unless File.directory?(dir) + data = load_album(dir) + halt 403 if data['visible'] == false && !admin? + + @rel = rel + @title = data['title'] || (rel.empty? ? 'Albums' : File.basename(dir)) + @desc = data['description'] + @albums = child_albums(dir, data) + @entries = album_files(dir, data) + @crumbs = breadcrumbs(rel) + erb :album +end + +get '/thumb/*' do + rel = params[:splat].first + full = resolve_file(rel) + halt 404 unless File.file?(full) + + # Audio: serve static placeholder + ext = File.extname(full).downcase.delete_prefix('.') + if AUDIO_EXTS.include?(ext) + send_file File.join(settings.public_folder, 'img', 'audio.svg'), type: 'image/svg+xml' + return + end + + dir = File.dirname(full) + name = File.basename(full) + meta = (load_album(dir)['files'] || {})[name] || {} + halt 403 if meta['visible'] == false && !admin? + + cache = thumb_cache_path(rel) + unless File.exist?(cache) + FileUtils.mkdir_p(File.dirname(cache)) + generate_thumb(full, cache, ext) + end + + send_file cache, type: 'image/jpeg' +end + +get '/media/*' do + rel = params[:splat].first + full = resolve_file(rel) + halt 404 unless File.file?(full) + + dir = File.dirname(full) + name = File.basename(full) + meta = (load_album(dir)['files'] || {})[name] || {} + halt 403 if meta['visible'] == false && !admin? + + send_file full +end + +get '/slideshow/' do + slideshow_view('') +end + +get '/slideshow/*' do + slideshow_view(params[:splat].first.chomp('/')) +end + +def slideshow_view(rel) + dir = resolve_dir(rel) + halt 404 unless File.directory?(dir) + data = load_album(dir) + halt 403 if data['visible'] == false && !admin? + + @rel = rel + @title = data['title'] || (rel.empty? ? 'Albums' : File.basename(dir)) + @entries = album_files(dir, data).select { |e| %i[image video].include?(e[:type]) } + erb :slideshow +end + +# ── Admin routes ─────────────────────────────────────────────────────────────── + +get '/admin' do + redirect(admin? ? '/admin/edit/' : '/admin/login') +end + +get '/admin/login' do + erb :'admin/login' +end + +post '/admin/login' do + hash = APP_CONFIG[:admin_password_hash].to_s + if !hash.empty? && BCrypt::Password.new(hash) == params['password'] + session[:admin] = true + redirect params['return_to']&.start_with?('/') ? params['return_to'] : '/admin/edit/' + else + @error = 'Invalid password' + erb :'admin/login' + end +end + +get '/admin/logout' do + session.clear + redirect '/' +end + +get '/admin/edit' do + require_admin! + redirect '/admin/edit/' +end + +get '/admin/edit/' do + require_admin! + edit_album('') +end + +get '/admin/edit/*' do + require_admin! + edit_album(params[:splat].first.chomp('/')) +end + +def edit_album(rel) + @dir = resolve_dir(rel) + halt 404 unless File.directory?(@dir) + @rel = rel + @data = load_album(@dir) + @title = @data['title'] || (rel.empty? ? 'Root' : File.basename(@dir)) + @files = Dir.children(@dir).sort.select { |n| MEDIA_EXTS.include?(File.extname(n).downcase.delete_prefix('.')) } + @sub_dirs = Dir.children(@dir).sort.select { |n| !n.start_with?('.') && File.directory?(File.join(@dir, n)) } + erb :'admin/album' +end + +post '/admin/edit/' do + require_admin! + save_edits('', MEDIA_ROOT) + redirect '/admin/edit/' +end + +post '/admin/edit/*' do + require_admin! + rel = params[:splat].first.chomp('/') + dir = resolve_dir(rel) + save_edits(rel, dir) + redirect "/admin/edit/#{rel}" +end + +def save_edits(rel, dir) + data = load_album(dir) + data['title'] = blank_to_nil(params['album_title']) + data['description'] = blank_to_nil(params['album_description']) + data['cover'] = blank_to_nil(params['album_cover']) + data['cover_dynamic'] = params['album_cover_dynamic'] == '1' + data['sort_reverse'] = params['album_sort_reverse'] == '1' + data['visible'] = params['album_visible'] == '1' + + data['files'] ||= {} + (params['file_title'] || {}).each_key do |name| + data['files'][name] ||= {} + data['files'][name]['title'] = blank_to_nil((params['file_title'] || {})[name]) + data['files'][name]['caption'] = blank_to_nil((params['file_caption'] || {})[name]) + data['files'][name]['visible'] = (params['file_visible'] || {})[name] == '1' + end + + atomic_write(File.join(dir, 'album.json'), JSON.pretty_generate(data)) +end + +# ── Thumbnail generation ─────────────────────────────────────────────────────── + +def generate_thumb(source, dest, ext) + if VIDEO_EXTS.include?(ext) + system('ffmpeg', '-y', '-ss', '2', '-i', source, + '-vframes', '1', + '-vf', "scale=#{THUMB_SIZE}:#{THUMB_SIZE}:force_original_aspect_ratio=increase,crop=#{THUMB_SIZE}:#{THUMB_SIZE}", + dest, %i[out err] => '/dev/null') + else + img = MiniMagick::Image.open(source) + img.combine_options do |c| + c.auto_orient + c.thumbnail "#{THUMB_SIZE}x#{THUMB_SIZE}^" + c.gravity 'center' + c.extent "#{THUMB_SIZE}x#{THUMB_SIZE}" + end + img.write(dest) + end +rescue StandardError => e + warn "thumb failed for #{source}: #{e.message}" +end + +def atomic_write(path, content) + tmp = "#{path}.tmp.#{Process.pid}" + File.write(tmp, content) + File.rename(tmp, path) +rescue StandardError + File.unlink(tmp) rescue nil + raise +end diff --git a/config.ru b/config.ru new file mode 100644 index 0000000..665f125 --- /dev/null +++ b/config.ru @@ -0,0 +1,2 @@ +require_relative 'app' +run Sinatra::Application diff --git a/config/albumen.service b/config/albumen.service new file mode 100644 index 0000000..9fea12f --- /dev/null +++ b/config/albumen.service @@ -0,0 +1,19 @@ +[Unit] +Description=Albumen photo album (Puma) +After=network.target + +[Service] +Type=simple +User=albumen +Group=albumen +WorkingDirectory=/opt/albumen +Environment=RACK_ENV=production +Environment=MEDIA_ROOT=/var/albumen +Environment=CACHE_ROOT=/opt/albumen/cache/thumbs +Environment=CONFIG_PATH=/opt/albumen/config.yml +ExecStart=/usr/local/bin/bundle exec puma -C config/puma.rb +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/config/nginx-albumen.conf b/config/nginx-albumen.conf new file mode 100644 index 0000000..d540bcd --- /dev/null +++ b/config/nginx-albumen.conf @@ -0,0 +1,18 @@ +server { + listen 80; + server_name albumen.jots.org; + + client_max_body_size 0; # no limit for large video uploads via rsync + proxy_read_timeout 300s; + proxy_send_timeout 300s; + + location / { + proxy_pass http://127.0.0.1:4567; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + } +} diff --git a/config/puma.rb b/config/puma.rb new file mode 100644 index 0000000..d60c643 --- /dev/null +++ b/config/puma.rb @@ -0,0 +1,9 @@ +workers 1 +threads 4, 8 + +bind 'tcp://127.0.0.1:4567' +environment ENV.fetch('RACK_ENV', 'development') + +pidfile '/opt/albumen/tmp/puma.pid' +state_path '/opt/albumen/tmp/puma.state' +stdout_redirect '/opt/albumen/log/puma.stdout.log', '/opt/albumen/log/puma.stderr.log', true diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..1f31e42 --- /dev/null +++ b/public/css/style.css @@ -0,0 +1,351 @@ +/* ── Reset & base ─────────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #111; + --bg2: #1c1c1c; + --bg3: #2a2a2a; + --border: #333; + --text: #e0e0e0; + --text-dim: #888; + --accent: #4a90d9; + --accent-hv: #6aaff7; + --radius: 6px; + --thumb: 220px; + --gap: 12px; + --font: system-ui, -apple-system, sans-serif; +} + +body { + background: var(--bg); + color: var(--text); + font-family: var(--font); + font-size: 15px; + line-height: 1.5; + min-height: 100vh; +} + +a { color: var(--accent); text-decoration: none; } +a:hover { color: var(--accent-hv); text-decoration: underline; } + +/* ── Site header ───────────────────────────────────────────────────────── */ +.site-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 24px; + background: var(--bg2); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 100; +} + +.site-logo { + font-size: 1.2rem; + font-weight: 700; + letter-spacing: .04em; + color: var(--text); +} +.site-logo:hover { color: var(--accent-hv); text-decoration: none; } + +.site-nav { display: flex; gap: 16px; align-items: center; } +.site-nav a { color: var(--text-dim); font-size: .9rem; } +.site-nav a:hover { color: var(--text); text-decoration: none; } + +/* ── Buttons ───────────────────────────────────────────────────────────── */ +.btn { + display: inline-block; + padding: 6px 16px; + background: var(--bg3); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + cursor: pointer; + font-size: .9rem; + transition: background .15s; +} +.btn:hover { background: var(--accent); border-color: var(--accent); color: #fff; text-decoration: none; } +.btn-sm { padding: 4px 10px; font-size: .8rem; } + +/* ── Main content ──────────────────────────────────────────────────────── */ +main { max-width: 1400px; margin: 0 auto; padding: 24px; } + +/* ── Album header ──────────────────────────────────────────────────────── */ +.album-header { margin-bottom: 24px; } +.album-header h1 { font-size: 1.6rem; font-weight: 600; margin-bottom: 6px; } + +.breadcrumbs { font-size: .85rem; color: var(--text-dim); margin-bottom: 8px; } +.breadcrumbs a { color: var(--text-dim); } +.breadcrumbs a:hover { color: var(--text); } +.breadcrumbs .sep { margin: 0 4px; } + +.album-desc { color: var(--text-dim); margin-bottom: 10px; } +.album-actions { margin-top: 10px; } + +/* ── Grid ──────────────────────────────────────────────────────────────── */ +.grid-section { margin-bottom: 32px; } +.section-label { font-size: .85rem; text-transform: uppercase; letter-spacing: .08em; color: var(--text-dim); margin-bottom: 12px; } + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(var(--thumb), 1fr)); + gap: var(--gap); +} + +/* ── Cards ─────────────────────────────────────────────────────────────── */ +.card { + border-radius: var(--radius); + overflow: hidden; + background: var(--bg2); + cursor: pointer; + transition: transform .15s, box-shadow .15s; +} +.card:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(0,0,0,.5); } + +.thumb-wrap { + position: relative; + width: 100%; + aspect-ratio: 1; + overflow: hidden; + background: var(--bg3); +} +.thumb-wrap img { + width: 100%; height: 100%; + object-fit: cover; + display: block; + transition: transform .2s; +} +.card:hover .thumb-wrap img { transform: scale(1.04); } + +.thumb-placeholder { + width: 100%; height: 100%; + display: flex; align-items: center; justify-content: center; + font-size: 3rem; + background: var(--bg3); +} + +.card-overlay { + position: absolute; + bottom: 0; left: 0; right: 0; + padding: 24px 8px 8px; + background: linear-gradient(transparent, rgba(0,0,0,.7)); + opacity: 0; + transition: opacity .2s; +} +.card:hover .card-overlay { opacity: 1; } +.card-label { color: #fff; font-size: .85rem; font-weight: 500; } + +.type-badge { + position: absolute; + top: 6px; right: 6px; + background: rgba(0,0,0,.6); + border-radius: 4px; + padding: 2px 6px; + font-size: .8rem; + color: #fff; + pointer-events: none; +} + +.album-label { padding: 6px 8px; font-size: .88rem; font-weight: 500; color: var(--text); } +.card-meta { padding: 6px 8px; } +.card-caption { font-size: .8rem; color: var(--text-dim); } + +.hidden-item { opacity: .45; outline: 1px dashed var(--border); } +.empty-album { color: var(--text-dim); text-align: center; padding: 60px 0; } + +/* ── Lightbox ──────────────────────────────────────────────────────────── */ +.lightbox { + position: fixed; inset: 0; + z-index: 1000; + background: rgba(0,0,0,.92); + display: flex; flex-direction: column; + align-items: center; justify-content: center; +} +.lightbox.hidden { display: none; } + +.lb-stage { + flex: 1; + display: flex; align-items: center; justify-content: center; + width: 100%; + cursor: zoom-out; + overflow: hidden; + padding: 16px; +} +/* inline-block shrink-wraps to the rendered image size; + arrows inside are then anchored to the photo's actual edges */ +.lb-media { + position: relative; + display: inline-block; + line-height: 0; + cursor: default; +} +#lb-img { max-width: calc(100vw - 32px); max-height: calc(100vh - 130px); object-fit: contain; display: block; } +#lb-video { max-width: calc(100vw - 32px); max-height: calc(100vh - 130px); display: block; } +#lb-audio { width: 400px; max-width: 90vw; display: block; } + +.lb-caption-bar { + padding: 8px 80px; + text-align: center; + font-size: .9rem; + color: var(--text-dim); + display: flex; gap: 16px; justify-content: center; align-items: baseline; + flex-shrink: 0; + width: 100%; +} +#lb-title { color: var(--text); font-weight: 500; } +#lb-counter { margin-left: auto; font-size: .8rem; } +.lb-action { font-size: .78rem; padding: 3px 10px; opacity: .7; } +.lb-action:hover { opacity: 1; } + +.lb-btn { + position: absolute; + background: rgba(0,0,0,.45); + border: none; + color: #fff; + cursor: pointer; + border-radius: var(--radius); + transition: background .15s, opacity .15s; + z-index: 10; +} +.lb-btn:hover { background: rgba(0,0,0,.75); } + +/* Close stays fixed to the viewport corner */ +.lb-close { + position: fixed; + top: 12px; right: 16px; + font-size: 1.1rem; + padding: 6px 12px; + z-index: 1001; +} + +/* Prev/next are anchored to the photo's own edges */ +.lb-prev, .lb-next { + top: 50%; transform: translateY(-50%); + font-size: 2.8rem; + padding: 20px 12px; + opacity: 0; + transition: opacity .2s, background .15s; +} +.lb-media:hover .lb-prev, +.lb-media:hover .lb-next { opacity: .85; } +.lb-prev:hover, .lb-next:hover { opacity: 1 !important; } +.lb-prev { left: 0; border-radius: 0 var(--radius) var(--radius) 0; } +.lb-next { right: 0; border-radius: var(--radius) 0 0 var(--radius); } + +/* ── Slideshow ─────────────────────────────────────────────────────────── */ +.slideshow-page { background: #000; overflow: hidden; height: 100vh; display: flex; } + +#slideshow { + width: 100%; height: 100vh; + display: flex; flex-direction: column; + background: #000; +} +#ss-stage { + flex: 1; + display: flex; align-items: center; justify-content: center; + overflow: hidden; + position: relative; +} +#ss-img, #ss-video { + max-width: 100%; max-height: 100%; + object-fit: contain; + opacity: 1; + transition: opacity .5s ease; +} +#ss-img.fading, #ss-video.fading { opacity: 0; } + +#ss-caption-bar { + position: absolute; + bottom: 0; left: 0; right: 0; + padding: 32px 24px 8px; + background: linear-gradient(transparent, rgba(0,0,0,.7)); + color: #fff; + text-align: center; + font-size: .95rem; + pointer-events: none; +} +#ss-title { font-weight: 600; display: block; } + +#ss-controls { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 20px; + background: rgba(0,0,0,.7); + flex-shrink: 0; +} +.ss-interval-label { color: var(--text-dim); font-size: .85rem; display: flex; align-items: center; gap: 4px; margin-left: auto; } +.ss-interval-label input { width: 48px; background: var(--bg3); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius); padding: 2px 6px; } + +#ss-counter { position: absolute; top: 12px; right: 16px; color: rgba(255,255,255,.5); font-size: .8rem; pointer-events: none; } + +/* ── Admin ─────────────────────────────────────────────────────────────── */ +.admin-login { + max-width: 360px; margin: 80px auto; + padding: 32px; + background: var(--bg2); + border: 1px solid var(--border); + border-radius: var(--radius); +} +.admin-login h1 { margin-bottom: 20px; font-size: 1.3rem; } +.admin-login label { display: block; margin-bottom: 16px; color: var(--text-dim); font-size: .9rem; } +.admin-login input { display: block; width: 100%; margin-top: 4px; padding: 8px 12px; background: var(--bg3); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 1rem; } +.form-error { color: #f55; margin-bottom: 12px; font-size: .9rem; } + +.admin-album { max-width: 1200px; } +.admin-album h1 { font-size: 1.4rem; margin: 16px 0; } +.admin-nav { display: flex; gap: 8px; margin-bottom: 8px; } + +fieldset.album-settings { + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; + margin-bottom: 24px; +} +legend { padding: 0 8px; color: var(--text-dim); font-size: .85rem; } + +.form-row { margin-bottom: 12px; } +.form-row label { display: block; color: var(--text-dim); font-size: .85rem; margin-bottom: 4px; } +.form-row input[type=text], .form-row textarea, .form-row select { + width: 100%; + background: var(--bg3); border: 1px solid var(--border); + border-radius: var(--radius); color: var(--text); + padding: 7px 10px; font-size: .9rem; +} +.form-row textarea { resize: vertical; } +.form-row-inline { display: flex; gap: 20px; align-items: flex-end; flex-wrap: wrap; } +.form-row-inline label { flex: 1; min-width: 180px; } +.checkbox-label { display: flex !important; align-items: center; gap: 6px; flex: 0 0 auto !important; color: var(--text) !important; } +.checkbox-label input { width: auto !important; } + +.files-section h2 { font-size: 1rem; color: var(--text-dim); margin-bottom: 10px; } +.files-table { width: 100%; border-collapse: collapse; font-size: .88rem; } +.files-table th { text-align: left; padding: 6px 10px; border-bottom: 2px solid var(--border); color: var(--text-dim); font-weight: 500; } +.files-table td { padding: 6px 10px; border-bottom: 1px solid var(--border); vertical-align: middle; } +.files-table tr:hover td { background: var(--bg2); } +.filename { color: var(--text-dim); font-size: .8rem; max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.visible-cell { text-align: center; } +.files-table input[type=text] { + width: 100%; + background: var(--bg3); border: 1px solid transparent; + border-radius: 4px; color: var(--text); + padding: 4px 8px; font-size: .85rem; +} +.files-table input[type=text]:focus { border-color: var(--accent); outline: none; } + +.form-actions { margin-top: 20px; } +.sub-albums-section { margin-top: 32px; } +.sub-albums-section h2 { font-size: 1rem; color: var(--text-dim); margin-bottom: 10px; } +.sub-album-list { list-style: none; display: flex; flex-wrap: wrap; gap: 8px; } +.sub-album-list a { padding: 4px 12px; background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); font-size: .9rem; } + +/* ── Responsive ────────────────────────────────────────────────────────── */ +@media (max-width: 600px) { + :root { --thumb: 140px; --gap: 8px; } + main { padding: 12px; } + .lb-prev { left: 4px; } + .lb-next { right: 4px; } + .lb-stage { padding: 48px 40px; } + .files-table { font-size: .78rem; } +} diff --git a/public/js/album.js b/public/js/album.js new file mode 100644 index 0000000..74d524c --- /dev/null +++ b/public/js/album.js @@ -0,0 +1,111 @@ +'use strict'; + +let lbIndex = 0; + +function openLightbox(i) { + lbIndex = i; + renderLightbox(); + document.getElementById('lightbox').classList.remove('hidden'); + document.body.style.overflow = 'hidden'; + document.addEventListener('keydown', lbKey); +} + +function closeLightbox() { + document.getElementById('lightbox').classList.add('hidden'); + document.body.style.overflow = ''; + document.removeEventListener('keydown', lbKey); + ['lb-video', 'lb-audio'].forEach(id => { + const el = document.getElementById(id); + el.pause && el.pause(); + }); + history.replaceState(null, '', location.pathname + location.search); +} + +function lbNav(delta) { + let next = lbIndex + delta; + while (next >= 0 && next < ENTRIES.length && !ENTRIES[next].type) next += delta; + if (next >= 0 && next < ENTRIES.length) { + lbIndex = next; + renderLightbox(); + } +} + +function renderLightbox() { + const e = ENTRIES[lbIndex]; + const img = document.getElementById('lb-img'); + const vid = document.getElementById('lb-video'); + const aud = document.getElementById('lb-audio'); + + [img, vid, aud].forEach(el => { el.classList.add('hidden'); el.pause && el.pause(); }); + + if (e.type === 'image') { + img.src = e.src; img.alt = e.title; + img.classList.remove('hidden'); + } else if (e.type === 'video') { + vid.src = e.src; + vid.classList.remove('hidden'); + vid.play().catch(() => {}); + } else if (e.type === 'audio') { + aud.src = e.src; + aud.classList.remove('hidden'); + aud.play().catch(() => {}); + } + + document.getElementById('lb-title').textContent = e.title !== e.name ? e.title : ''; + document.getElementById('lb-caption').textContent = e.caption || ''; + document.getElementById('lb-counter').textContent = `${lbIndex + 1} / ${ENTRIES.length}`; + + const dl = document.getElementById('lb-download'); + dl.href = e.src; + dl.download = e.name; + + // Update URL hash so the address bar is the shareable link + history.replaceState(null, '', location.pathname + location.search + '#photo=' + encodeURIComponent(e.name)); +} + +function lbCopyLink() { + navigator.clipboard.writeText(location.href).then(() => { + const btn = document.getElementById('lb-copylink'); + const orig = btn.textContent; + btn.textContent = '✓ Copied!'; + setTimeout(() => { btn.textContent = orig; }, 1800); + }).catch(() => { + // Fallback: select a temporary input + const tmp = document.createElement('input'); + tmp.value = location.href; + document.body.appendChild(tmp); + tmp.select(); + document.execCommand('copy'); + document.body.removeChild(tmp); + }); +} + +function lbKey(ev) { + if (ev.key === 'Escape') closeLightbox(); + else if (ev.key === 'ArrowLeft') lbNav(-1); + else if (ev.key === 'ArrowRight') lbNav(1); +} + +// Restore lightbox from URL hash on page load +window.addEventListener('DOMContentLoaded', () => { + const m = location.hash.match(/^#photo=(.+)$/); + if (m) { + const name = decodeURIComponent(m[1]); + const idx = ENTRIES.findIndex(e => e.name === name); + if (idx >= 0) openLightbox(idx); + } +}); + +// Touch swipe +(function () { + let startX = null; + const lb = document.getElementById('lightbox'); + if (!lb) return; + lb.addEventListener('touchstart', e => { startX = e.changedTouches[0].clientX; }, { passive: true }); + lb.addEventListener('touchend', e => { + if (startX === null) return; + const dx = e.changedTouches[0].clientX - startX; + if (Math.abs(dx) > 50) lbNav(dx < 0 ? 1 : -1); + startX = null; + }, { passive: true }); +})(); diff --git a/public/js/slideshow.js b/public/js/slideshow.js new file mode 100644 index 0000000..2260034 --- /dev/null +++ b/public/js/slideshow.js @@ -0,0 +1,113 @@ +'use strict'; + +let ssIdx = 0; +let ssTimer = null; +let ssPlaying = true; + +const img = document.getElementById('ss-img'); +const vid = document.getElementById('ss-video'); +const title = document.getElementById('ss-title'); +const cap = document.getElementById('ss-caption'); +const ctr = document.getElementById('ss-counter'); +const btn = document.getElementById('ss-play-btn'); + +function ssInterval() { + return (parseFloat(document.getElementById('ss-interval').value) || 4) * 1000; +} + +function ssShow(i, instant) { + const e = SS_ENTRIES[i]; + if (!e) return; + ssIdx = i; + + title.textContent = e.title !== e.name ? e.title : ''; + cap.textContent = e.caption || ''; + ctr.textContent = `${i + 1} / ${SS_ENTRIES.length}`; + + if (instant) { + // First load — show without transition + applyEntry(e); + return; + } + + if (e.type === 'video') { + // No preload possible for video; fade out then swap + crossFade(() => applyEntry(e)); + } else { + // Preload the image so the fade-in shows it immediately + const preload = new Image(); + preload.onload = preload.onerror = () => crossFade(() => applyEntry(e)); + preload.src = e.src; + } +} + +function applyEntry(e) { + if (e.type === 'video') { + img.style.display = 'none'; + vid.style.display = ''; + vid.src = e.src; + void vid.offsetWidth; // flush so transition fires + vid.classList.remove('fading'); + vid.play().catch(() => {}); + } else { + vid.pause && vid.pause(); + vid.style.display = 'none'; + img.style.display = ''; + img.src = e.src; + img.alt = e.title; + void img.offsetWidth; // flush so transition fires + img.classList.remove('fading'); + } +} + +function crossFade(cb) { + img.classList.add('fading'); + vid.classList.add('fading'); + setTimeout(cb, 500); // matches CSS transition duration +} + +function ssSchedule() { + clearTimeout(ssTimer); + if (ssPlaying && SS_ENTRIES.length > 1) { + ssTimer = setTimeout(() => { + ssShow((ssIdx + 1) % SS_ENTRIES.length); + ssSchedule(); + }, ssInterval()); + } +} + +function ssNext() { + ssShow((ssIdx + 1) % SS_ENTRIES.length); + if (ssPlaying) ssSchedule(); +} + +function ssPrev() { + ssShow((ssIdx - 1 + SS_ENTRIES.length) % SS_ENTRIES.length); + if (ssPlaying) ssSchedule(); +} + +function ssToggle() { + ssPlaying = !ssPlaying; + btn.textContent = ssPlaying ? '⏸ Pause' : '▶ Play'; + if (ssPlaying) ssSchedule(); else clearTimeout(ssTimer); +} + +document.addEventListener('keydown', e => { + if (e.key === 'ArrowRight') ssNext(); + if (e.key === 'ArrowLeft') ssPrev(); + if (e.key === ' ') { e.preventDefault(); ssToggle(); } +}); + +let swipeX = null; +document.addEventListener('touchstart', e => { swipeX = e.changedTouches[0].clientX; }, { passive: true }); +document.addEventListener('touchend', e => { + if (swipeX === null) return; + const dx = e.changedTouches[0].clientX - swipeX; + if (Math.abs(dx) > 50) (dx < 0 ? ssNext : ssPrev)(); + swipeX = null; +}, { passive: true }); + +if (SS_ENTRIES.length > 0) { + ssShow(0, true); + ssSchedule(); +} diff --git a/scripts/set_password.rb b/scripts/set_password.rb new file mode 100644 index 0000000..71bdc41 --- /dev/null +++ b/scripts/set_password.rb @@ -0,0 +1,26 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# Usage: ruby scripts/set_password.rb +# Sets (or resets) the admin password in config.yml. + +require 'bcrypt' +require 'yaml' +require 'securerandom' + +CONFIG_PATH = ENV['CONFIG_PATH'] || '/opt/albumen/config.yml' + +print 'New admin password: ' +STDOUT.flush +password = $stdin.gets&.chomp +abort 'No password given.' if password.nil? || password.strip.empty? + +config = File.exist?(CONFIG_PATH) ? (YAML.load_file(CONFIG_PATH) || {}) : {} +config['admin_password_hash'] = BCrypt::Password.create(password).to_s +config['session_secret'] ||= SecureRandom.hex(32) + +tmp = "#{CONFIG_PATH}.tmp.#{Process.pid}" +File.write(tmp, config.to_yaml) +File.rename(tmp, CONFIG_PATH) +File.chmod(0o600, CONFIG_PATH) + +puts "Password set. Config written to #{CONFIG_PATH}" diff --git a/scripts/update.rb b/scripts/update.rb new file mode 100644 index 0000000..7ff0007 --- /dev/null +++ b/scripts/update.rb @@ -0,0 +1,221 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Usage: ruby update.rb [relative/path] +# Without argument: process entire MEDIA_ROOT tree. +# With argument: process only that subdirectory (and its children). +# +# Resilience guarantees: +# - album.json is written atomically (temp-file + rename), so a crash +# mid-write never corrupts an existing file. +# - Thumbnails are checked before generation; already-done work is skipped. +# - EXIF and dimension extraction are skipped if already recorded. +# - Safe to re-run at any time; all operations are idempotent. + +require 'json' +require 'yaml' +require 'fileutils' +require 'mini_magick' +require 'mini_exiftool' + +MEDIA_ROOT = (ENV['MEDIA_ROOT'] || '/var/albumen').freeze +CACHE_ROOT = (ENV['CACHE_ROOT'] || '/opt/albumen/cache/thumbs').freeze +THUMB_SIZE = 300 + +IMAGE_EXTS = %w[jpg jpeg png gif webp heic heif tiff bmp].freeze +VIDEO_EXTS = %w[mp4 mov avi mkv webm m4v ogv].freeze +AUDIO_EXTS = %w[mp3 flac ogg wav m4a aac].freeze +MEDIA_EXTS = (IMAGE_EXTS + VIDEO_EXTS + AUDIO_EXTS).freeze + +# ── Directory processing ─────────────────────────────────────────────────────── + +def process_dir(dir) + rel = dir.delete_prefix(MEDIA_ROOT).delete_prefix('/') + label = rel.empty? ? '(root)' : rel + puts "Scanning #{label}" + + json_path = File.join(dir, 'album.json') + data = load_json(json_path) + data['files'] ||= {} + data['visible'] = true unless data.key?('visible') + + # Enumerate current media files + current = Dir.children(dir) + .select { |n| MEDIA_EXTS.include?(File.extname(n).downcase.delete_prefix('.')) } + .sort + + # Remove stale entries for deleted files + removed = data['files'].keys - current + if removed.any? + puts " Removing stale entries: #{removed.join(', ')}" + removed.each { |n| data['files'].delete(n) } + end + + # Process each file + current.each do |name| + full = File.join(dir, name) + ext = File.extname(name).downcase.delete_prefix('.') + data['files'][name] ||= {} + meta = data['files'][name] + meta['visible'] = true unless meta.key?('visible') + + if IMAGE_EXTS.include?(ext) + enrich_image(full, name, meta) + elsif VIDEO_EXTS.include?(ext) + enrich_video(full, name, meta) + end + + generate_thumb_if_needed(full, rel, name, ext) + end + + atomic_write_json(json_path, data) +end + +# ── Metadata enrichment ──────────────────────────────────────────────────────── + +def enrich_image(full, name, meta) + # EXIF date (skip if already recorded) + if meta['taken_at'].nil? + begin + exif = MiniExiftool.new(full, numerical: false) + raw = exif.date_time_original || exif.create_date || exif.date_time + if raw + meta['taken_at'] = raw.respond_to?(:iso8601) ? raw.iso8601 : raw.to_s + puts " #{name}: taken_at = #{meta['taken_at']}" + end + rescue StandardError => e + warn " #{name}: EXIF error — #{e.message}" + end + end + + # Dimensions (skip if already recorded) + if meta['width'].nil? + begin + img = MiniMagick::Image.open(full) + meta['width'] = img.width + meta['height'] = img.height + rescue StandardError => e + warn " #{name}: dimension error — #{e.message}" + end + end +end + +def enrich_video(full, name, meta) + return unless meta['duration'].nil? + begin + out = `ffprobe -v error -select_streams v:0 -show_entries stream=duration -of csv=p=0 "#{full}" 2>/dev/null`.strip + meta['duration'] = out.to_f.round unless out.empty? + rescue StandardError => e + warn " #{name}: ffprobe error — #{e.message}" + end +end + +# ── Thumbnail generation ─────────────────────────────────────────────────────── + +def generate_thumb_if_needed(full, rel, name, ext) + return if AUDIO_EXTS.include?(ext) # audio uses a static icon + + cache = File.join(CACHE_ROOT, rel.empty? ? "#{name}.th.jpg" : "#{rel}/#{name}.th.jpg") + return if File.exist?(cache) + + puts " Generating thumb: #{name}" + FileUtils.mkdir_p(File.dirname(cache)) + + if VIDEO_EXTS.include?(ext) + generate_video_thumb(full, cache) + else + generate_image_thumb(full, cache) + end +end + +def generate_image_thumb(source, dest) + img = MiniMagick::Image.open(source) + img.combine_options do |c| + c.auto_orient + c.thumbnail "#{THUMB_SIZE}x#{THUMB_SIZE}^" + c.gravity 'center' + c.extent "#{THUMB_SIZE}x#{THUMB_SIZE}" + end + img.write(dest) +rescue StandardError => e + warn " Thumb error (image): #{e.message}" +end + +def generate_video_thumb(source, dest) + system( + 'ffmpeg', '-y', '-ss', '2', '-i', source, + '-vframes', '1', + '-vf', "scale=#{THUMB_SIZE}:#{THUMB_SIZE}:force_original_aspect_ratio=increase,crop=#{THUMB_SIZE}:#{THUMB_SIZE}", + dest, + %i[out err] => '/dev/null' + ) +rescue StandardError => e + warn " Thumb error (video): #{e.message}" +end + +# ── Helpers ──────────────────────────────────────────────────────────────────── + +def load_json(path) + return {} unless File.exist?(path) + JSON.parse(File.read(path)) +rescue JSON::ParserError => e + warn " Warning: could not parse #{path} — #{e.message}. Starting fresh." + {} +end + +# Fields the admin controls — never overwrite with stale values from our earlier read. +ADMIN_ALBUM_KEYS = %w[title description cover cover_dynamic sort_reverse visible].freeze +ADMIN_FILE_KEYS = %w[title caption visible].freeze + +def atomic_write_json(path, data) + # Re-read the file right before writing so any admin saves that happened + # while we were processing (EXIF, thumbnails) are preserved. + if File.exist?(path) + fresh = JSON.parse(File.read(path)) + ADMIN_ALBUM_KEYS.each { |k| data[k] = fresh[k] if fresh.key?(k) } + (fresh['files'] || {}).each do |name, meta| + next unless data['files'].key?(name) + ADMIN_FILE_KEYS.each { |k| data['files'][name][k] = meta[k] if meta.key?(k) } + end + end + tmp = "#{path}.tmp.#{Process.pid}" + File.write(tmp, JSON.pretty_generate(data)) + File.rename(tmp, path) +rescue StandardError + File.unlink(tmp) rescue nil + raise +end + +# ── Entry point ──────────────────────────────────────────────────────────────── + +start = if ARGV[0] + arg = ARGV[0].chomp('/') + arg.start_with?('/') ? arg : File.join(MEDIA_ROOT, arg) +else + MEDIA_ROOT +end + +unless File.directory?(start) + abort "Error: #{start} is not a directory" +end + +# Fix ownership so the web app (albumen user) can read everything we just scanned. +# Safe no-op when already correct; only meaningful when run as root after an rsync. +if Process.uid == 0 + service_user = 'albumen' + begin + require 'etc' + pw = Etc.getpwnam(service_user) + puts "Fixing ownership of #{start} → #{service_user}" + FileUtils.chown_R(pw.uid, pw.gid, start) + rescue ArgumentError + warn "Warning: user '#{service_user}' not found; skipping chown" + end +end + +# Walk the tree: process each directory (depth-first, parent before children) +dirs = [start] +dirs += Dir.glob("#{start}/**/*/").sort +dirs.uniq.each { |d| process_dir(d) } + +puts 'Done.' diff --git a/setup.sh b/setup.sh new file mode 100644 index 0000000..5c83d11 --- /dev/null +++ b/setup.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Run on the server as root after rsyncing the app files. +set -euo pipefail + +APP_DIR=/opt/albumen +MEDIA_DIR=/var/albumen + +echo "==> Creating directories" +mkdir -p "$APP_DIR"/{cache/thumbs,tmp,log,public/img} +mkdir -p "$MEDIA_DIR" + +echo "==> Creating service user" +id albumen &>/dev/null || useradd -r -s /usr/sbin/nologin -d "$APP_DIR" albumen + +echo "==> Installing gems" +cd "$APP_DIR" +bundle install --without development test + +echo "==> Generating audio placeholder SVG" +cat > "$APP_DIR/public/img/audio.svg" <<'SVG' +<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300" viewBox="0 0 300 300"> + <rect width="300" height="300" fill="#1c1c1c"/> + <text x="150" y="175" font-size="120" text-anchor="middle" fill="#555">♪</text> +</svg> +SVG + +echo "==> Setting permissions" +chown -R albumen:albumen "$APP_DIR" "$MEDIA_DIR" +chmod +x "$APP_DIR/scripts/"*.rb + +echo "==> Setting up nginx" +cp "$APP_DIR/config/nginx-albumen.conf" /etc/nginx/sites-available/albumen +ln -sf /etc/nginx/sites-available/albumen /etc/nginx/sites-enabled/albumen +rm -f /etc/nginx/sites-enabled/default +nginx -t +systemctl enable nginx +systemctl restart nginx + +echo "==> Setting up systemd service" +cp "$APP_DIR/config/albumen.service" /etc/systemd/system/ +systemctl daemon-reload +systemctl enable albumen + +echo "" +echo "==> Setup complete!" +echo "" +echo "Next steps:" +echo " 1. Set admin password: ruby $APP_DIR/scripts/set_password.rb" +echo " 2. Start the service: systemctl start albumen" +echo " 3. Check logs: journalctl -u albumen -f" +echo " 4. Drop photos into: $MEDIA_DIR" +echo " 5. Run update script: ruby $APP_DIR/scripts/update.rb" diff --git a/views/admin/album.erb b/views/admin/album.erb new file mode 100644 index 0000000..f6b80d6 --- /dev/null +++ b/views/admin/album.erb @@ -0,0 +1,101 @@ +<div class="admin-album"> + <div class="admin-nav"> + <% if @rel.empty? %> + <a href="/browse/" class="btn btn-sm">View Root</a> + <% else %> + <% parent = @rel.include?('/') ? @rel.split('/')[0..-2].join('/') : '' %> + <a href="/admin/edit/<%= parent %>" class="btn btn-sm">← Parent</a> + <a href="/browse/<%= @rel %>" class="btn btn-sm">View Album</a> + <% end %> + </div> + + <h1>Edit: <%= @title %></h1> + + <form method="post" action="/admin/edit/<%= @rel %>"> + <fieldset class="album-settings"> + <legend>Album</legend> + <div class="form-row"> + <label>Title override + <input type="text" name="album_title" value="<%= @data['title'] %>" placeholder="(use directory name)"> + </label> + </div> + <div class="form-row"> + <label>Description + <textarea name="album_description" rows="2"><%= @data['description'] %></textarea> + </label> + </div> + <div class="form-row form-row-inline"> + <label>Cover image + <select name="album_cover"> + <option value="">— auto (first image) —</option> + <% @files.each do |name| %> + <option value="<%= name %>"<%= ' selected' if @data['cover'] == name %>><%= name %></option> + <% end %> + </select> + </label> + <label class="checkbox-label"> + <input type="checkbox" name="album_cover_dynamic" value="1"<%= ' checked' if @data['cover_dynamic'] %>> + Dynamic cover + </label> + <label class="checkbox-label"> + <input type="checkbox" name="album_sort_reverse" value="1"<%= ' checked' if @data['sort_reverse'] %>> + Reverse sub-album order + </label> + <label class="checkbox-label"> + <input type="hidden" name="album_visible" value="0"> + <input type="checkbox" name="album_visible" value="1"<%= ' checked' if @data['visible'] != false %>> + Visible + </label> + </div> + </fieldset> + + <% unless @files.empty? %> + <div class="files-section"> + <h2>Files</h2> + <table class="files-table"> + <thead> + <tr> + <th>Thumb</th> + <th>Filename</th> + <th>Title</th> + <th>Caption</th> + <th>Visible</th> + </tr> + </thead> + <tbody> + <% @files.each do |name| %> + <% meta = (@data['files'] || {})[name] || {} %> + <% file_rel = @rel.empty? ? name : "#{@rel}/#{name}" %> + <tr> + <td><img src="/thumb/<%= file_rel %>" width="60" height="60" loading="lazy" style="object-fit:cover"></td> + <td class="filename"><code><%= name %></code></td> + <td><input type="text" name="file_title[<%= name %>]" value="<%= ERB::Util.html_escape(meta['title'].to_s) %>" placeholder="<%= ERB::Util.html_escape(name) %>"></td> + <td><input type="text" name="file_caption[<%= name %>]" value="<%= ERB::Util.html_escape(meta['caption'].to_s) %>" placeholder="caption…"></td> + <td class="visible-cell"> + <input type="hidden" name="file_visible[<%= name %>]" value="0"> + <input type="checkbox" name="file_visible[<%= name %>]" value="1"<%= ' checked' if meta['visible'] != false %>> + </td> + </tr> + <% end %> + </tbody> + </table> + </div> + <% end %> + + <div class="form-actions"> + <button type="submit" class="btn">Save</button> + </div> + </form> + + <% unless @sub_dirs.empty? %> + <section class="sub-albums-section"> + <h2>Sub-albums</h2> + <ul class="sub-album-list"> + <% @sub_dirs.each do |name| %> + <% sub_rel = @rel.empty? ? name : "#{@rel}/#{name}" %> + <li><a href="/admin/edit/<%= sub_rel %>"><%= name %></a></li> + <% end %> + </ul> + </section> + <% end %> +</div> diff --git a/views/admin/login.erb b/views/admin/login.erb new file mode 100644 index 0000000..16f12d2 --- /dev/null +++ b/views/admin/login.erb @@ -0,0 +1,14 @@ +<div class="admin-login"> + <h1>Admin Login</h1> + <% if defined?(@error) && @error %> + <p class="form-error"><%= @error %></p> + <% end %> + <form method="post" action="/admin/login"> + <input type="hidden" name="return_to" value="<%= ERB::Util.html_escape(params['return_to'].to_s) %>"> + <label> + Password + <input type="password" name="password" autofocus autocomplete="current-password"> + </label> + <button type="submit" class="btn">Login</button> + </form> +</div> diff --git a/views/album.erb b/views/album.erb new file mode 100644 index 0000000..077e119 --- /dev/null +++ b/views/album.erb @@ -0,0 +1,99 @@ +<div class="album-header"> + <% unless @crumbs.empty? %> + <nav class="breadcrumbs"> + <a href="/browse/">Home</a> + <% @crumbs.each do |c| %> + <span class="sep">›</span> + <a href="/browse/<%= c[:path] %>"><%= c[:name] %></a> + <% end %> + </nav> + <% end %> + <h1><%= @title %></h1> + <% if @desc %><p class="album-desc"><%= @desc %></p><% end %> + <div class="album-actions"> + <% if @entries.any? { |e| %i[image video].include?(e[:type]) } %> + <a href="/slideshow/<%= @rel %>" class="btn">Slideshow</a> + <% end %> + </div> +</div> + +<% unless @albums.empty? %> +<section class="grid-section"> + <% if @entries.any? %><h2 class="section-label">Albums</h2><% end %> + <div class="grid"> + <% @albums.each do |a| %> + <% href = @rel.empty? ? "/browse/#{a[:name]}" : "/browse/#{@rel}/#{a[:name]}" %> + <a href="<%= href %>" class="card album-card"> + <div class="thumb-wrap"> + <% cover_rel = @rel.empty? ? "#{a[:name]}/#{a[:cover]}" : "#{@rel}/#{a[:name]}/#{a[:cover]}" %> + <% if a[:cover] %> + <img src="/thumb/<%= cover_rel %>" alt="<%= a[:title] %>" loading="lazy"> + <% else %> + <div class="thumb-placeholder">📁</div> + <% end %> + </div> + <div class="album-label"><%= a[:title] %></div> + </a> + <% end %> + </div> +</section> +<% end %> + +<% unless @entries.empty? %> +<section class="grid-section"> + <% if @albums.any? %><h2 class="section-label">Photos & Videos</h2><% end %> + <div class="grid" id="photo-grid"> + <% @entries.each_with_index do |e, i| %> + <% file_rel = @rel.empty? ? e[:name] : "#{@rel}/#{e[:name]}" %> + <div class="card media-card<%= ' hidden-item' unless e[:visible] %>" + data-index="<%= i %>" + data-type="<%= e[:type] %>" + data-src="/media/<%= file_rel %>" + data-title="<%= ERB::Util.html_escape(e[:title]) %>" + data-caption="<%= ERB::Util.html_escape(e[:caption].to_s) %>" + onclick="openLightbox(<%= i %>)"> + <div class="thumb-wrap"> + <img src="/thumb/<%= file_rel %>" alt="<%= ERB::Util.html_escape(e[:title]) %>" loading="lazy"> + <% if e[:type] == :video %><span class="type-badge video-badge">▶</span><% end %> + <% if e[:type] == :audio %><span class="type-badge audio-badge">♪</span><% end %> + </div> + <% if e[:caption] %> + <div class="card-meta"><p class="card-caption"><%= e[:caption] %></p></div> + <% end %> + </div> + <% end %> + </div> +</section> +<% end %> + +<% if @albums.empty? && @entries.empty? %> + <p class="empty-album">This album is empty.</p> +<% end %> + +<div id="lightbox" class="lightbox hidden" role="dialog" aria-modal="true"> + <button class="lb-btn lb-close" onclick="closeLightbox()" aria-label="Close">✕</button> + <div class="lb-stage" onclick="closeLightbox()"> + <div class="lb-media" onclick="event.stopPropagation()"> + <img id="lb-img" src="" alt="" class="hidden"> + <video id="lb-video" controls class="hidden"></video> + <audio id="lb-audio" controls class="hidden"></audio> + <button class="lb-btn lb-prev" onclick="lbNav(-1)" aria-label="Previous">‹</button> + <button class="lb-btn lb-next" onclick="lbNav(1)" aria-label="Next">›</button> + </div> + </div> + <div class="lb-caption-bar"> + <span id="lb-title"></span> + <span id="lb-caption"></span> + <a id="lb-download" href="" download class="btn btn-sm lb-action">↓ Original</a> + <button id="lb-copylink" onclick="lbCopyLink()" class="btn btn-sm lb-action">⧉ Copy link</button> + <span id="lb-counter"></span> + </div> +</div> + +<script> +const ENTRIES = <%= @entries.map { |e| + file_rel = @rel.empty? ? e[:name] : "#{@rel}/#{e[:name]}" + e.merge(src: "/media/#{file_rel}") +}.to_json %>; +</script> +<script src="/js/album.js"></script> diff --git a/views/layout.erb b/views/layout.erb new file mode 100644 index 0000000..3e798c4 --- /dev/null +++ b/views/layout.erb @@ -0,0 +1,25 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title><%= @title %> — Albumen</title> + <link rel="stylesheet" href="/css/style.css"> +</head> +<body> + <header class="site-header"> + <a href="/browse/" class="site-logo">Albumen</a> + <nav class="site-nav"> + <% if admin? %> + <a href="/admin/edit/<%= defined?(@rel) ? @rel : '' %>">Edit Album</a> + <a href="/admin/logout">Logout</a> + <% else %> + <a href="/admin/login" class="nav-admin">Admin</a> + <% end %> + </nav> + </header> + <main> + <%= yield %> + </main> +</body> +</html> diff --git a/views/slideshow.erb b/views/slideshow.erb new file mode 100644 index 0000000..847da65 --- /dev/null +++ b/views/slideshow.erb @@ -0,0 +1,41 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title><%= @title %> — Slideshow</title> + <link rel="stylesheet" href="/css/style.css"> +</head> +<body class="slideshow-page"> + <div id="slideshow"> + <div id="ss-stage"> + <img id="ss-img" src="" alt="" style="display:none"> + <video id="ss-video" playsinline style="display:none"></video> + <div id="ss-caption-bar"> + <span id="ss-title"></span> + <span id="ss-caption"></span> + </div> + </div> + <div id="ss-controls"> + <a href="/browse/<%= @rel %>" class="btn btn-sm">← Album</a> + <button onclick="ssPrev()" class="btn btn-sm">‹ Prev</button> + <button onclick="ssToggle()" id="ss-play-btn" class="btn btn-sm">⏸ Pause</button> + <button onclick="ssNext()" class="btn btn-sm">Next ›</button> + <label class="ss-interval-label"> + Interval + <input type="number" id="ss-interval" value="4" min="1" max="60" step="1"> s + </label> + </div> + <div id="ss-counter"></div> + </div> + + <script> + const SS_ENTRIES = <%= @entries.map { |e| + file_rel = @rel.empty? ? e[:name] : "#{@rel}/#{e[:name]}" + e.merge(src: "/media/#{file_rel}") + }.to_json %>; + const SS_REL = <%= @rel.to_json %>; + </script> + <script src="/js/slideshow.js"></script> +</body> +</html> |
