# frozen_string_literal: true require 'sinatra' require 'json' require 'yaml' require 'openssl' 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 UPDATE_JOBS = {} UPDATE_JOBS_MUTEX = Mutex.new 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'], width: meta['width'], height: meta['height'], duration: meta['duration'], camera: meta['camera'], aperture: meta['aperture'], shutter: meta['shutter'], iso: meta['iso'], } 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), count: media_count(sub_dir, sub_data), } end data['sort_reverse'] ? albums.reverse : albums end def media_count(dir, data) files = data['files'] || {} direct = Dir.children(dir) .count { |n| MEDIA_EXTS.include?(File.extname(n).downcase.delete_prefix('.')) && (admin? || (files[n] || {}).fetch('visible', true)) } sub_total = Dir.children(dir) .select { |n| !n.start_with?('.') && File.directory?(File.join(dir, n)) } .sum do |n| sub_dir = File.join(dir, n) sub_data = load_album(sub_dir) next 0 if sub_data['visible'] == false && !admin? media_count(sub_dir, sub_data) end direct + sub_total end def format_duration(secs) return nil unless secs s = secs.to_i h, s = s.divmod(3600) m, s = s.divmod(60) h > 0 ? format('%d:%02d:%02d', h, m, s) : format('%d:%02d', m, s) end def album_cover(dir, data) cover = data['cover'] return cover if cover && cover != '__random__' && File.exist?(File.join(dir, cover)) candidates = cover_candidates(dir) candidates.sample end def cover_candidates(dir) exts = (IMAGE_EXTS + VIDEO_EXTS).to_set Dir.glob(File.join(dir, '**', '*')) .select { |f| File.file?(f) && exts.include?(File.extname(f).downcase.delete_prefix('.')) } .sort .map { |f| f.delete_prefix("#{dir}/") } 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 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 end def og_image_url return nil unless @og_image_rel type = @og_use_media ? 'media' : 'thumb' "#{request.base_url}/#{type}/#{@og_image_rel}" 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) if params[:photo] && !params[:photo].empty? photo_name = File.basename(params[:photo]) @og_image_rel = rel.empty? ? photo_name : "#{rel}/#{photo_name}" @og_use_media = true elsif !rel.empty? first_img = @entries.find { |e| %i[image video].include?(e[:type]) } @og_image_rel = if first_img "#{rel}/#{first_img[:name]}" else cover = cover_candidates(dir).first cover ? "#{rel}/#{cover}" : nil end end 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 all_media_entries(top_dirs: nil) dirs = if top_dirs top_dirs.flat_map do |name| full = File.expand_path(name, MEDIA_ROOT) next [] unless full.start_with?("#{MEDIA_ROOT}/") && File.directory?(full) [full] + Dir.glob("#{full}/**/*/").sort end else [MEDIA_ROOT] + Dir.glob("#{MEDIA_ROOT}/**/*/").sort end dirs.flat_map do |dir| rel = dir.delete_prefix(MEDIA_ROOT).delete_prefix('/') data = load_album(dir) next [] if data['visible'] == false && !admin? album_files(dir, data).select { |e| %i[image video].include?(e[:type]) }.map do |e| e.merge(file_rel: rel.empty? ? e[:name] : "#{rel}/#{e[:name]}") end end 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 = if rel.empty? top_dirs = params[:dirs]&.split(',')&.map(&:strip)&.reject(&:empty?) all_media_entries(top_dirs: top_dirs&.any? ? top_dirs : nil) else all_media_entries(top_dirs: [rel]) end erb :slideshow, layout: false 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? && pbkdf2_verify(params['password'].to_s, hash) 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.reverse.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) # Handle file deletions before save so save_edits doesn't re-add them to_delete = (params['file_delete'] || {}).select { |_, v| v == '1' }.keys to_delete.each do |name| full = File.join(dir, name) thumb = File.join(CACHE_ROOT, rel.empty? ? "#{name}.th.jpg" : "#{rel}/#{name}.th.jpg") File.unlink(full) if File.exist?(full) File.unlink(thumb) if File.exist?(thumb) params['file_visible']&.delete(name) params['file_caption']&.delete(name) end unless rel.empty? new_name = params['folder_name'].to_s.strip new_name = '' if new_name.include?('/') || new_name.include?("\x00") || new_name == '.' || new_name == '..' old_name = File.basename(dir) if !new_name.empty? && new_name != old_name parent_dir = File.dirname(dir) new_dir = File.join(parent_dir, new_name) unless File.exist?(new_dir) parent_rel = rel.include?('/') ? rel.split('/')[0..-2].join('/') : '' new_rel = parent_rel.empty? ? new_name : "#{parent_rel}/#{new_name}" old_cache = File.join(CACHE_ROOT, rel) new_cache = File.join(CACHE_ROOT, new_rel) FileUtils.mv(old_cache, new_cache) if File.directory?(old_cache) FileUtils.mv(dir, new_dir) save_edits(new_rel, new_dir) redirect "/admin/edit/#{new_rel}" end end end 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'] = if params['album_cover_random'] == '1' '__random__' else blank_to_nil(params['album_cover_file']) end data['sort_reverse'] = params['album_sort_reverse'] == '1' data['visible'] = params['album_visible'] == '1' data['files'] ||= {} (params['file_visible'] || {}).each_key do |name| data['files'][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 # ── Background update ───────────────────────────────────────────────────────── post '/admin/update' do require_admin! rel = params[:rel].to_s.chomp('/') job_id = SecureRandom.hex(8) script = File.join(__dir__, 'scripts', 'update.rb') cmd = rel.empty? ? ['ruby', script] : ['ruby', script, rel] UPDATE_JOBS_MUTEX.synchronize do UPDATE_JOBS[job_id] = { status: :running, lines: [] } end Thread.new do begin IO.popen(cmd, err: [:child, :out]) do |io| io.each_line do |line| UPDATE_JOBS_MUTEX.synchronize { UPDATE_JOBS[job_id][:lines] << line.chomp } end end code = $?.exitstatus UPDATE_JOBS_MUTEX.synchronize { UPDATE_JOBS[job_id][:status] = code == 0 ? :done : :error } rescue => e UPDATE_JOBS_MUTEX.synchronize do UPDATE_JOBS[job_id][:status] = :error UPDATE_JOBS[job_id][:lines] << "Error: #{e.message}" end end end content_type :json { job_id: job_id }.to_json end get '/admin/update/:id' do require_admin! job = UPDATE_JOBS_MUTEX.synchronize { UPDATE_JOBS[params[:id]]&.dup } halt 404 unless job content_type :json { status: job[:status], lines: job[:lines] }.to_json 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