diff options
Diffstat (limited to 'app.rb')
| -rw-r--r-- | app.rb | 371 |
1 files changed, 371 insertions, 0 deletions
@@ -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 |
