# 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), } end data['sort_reverse'] ? albums.reverse : albums 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) cover == '__random__' ? candidates.sample : candidates.first 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 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['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