summaryrefslogtreecommitdiffstats
path: root/app.rb
diff options
context:
space:
mode:
Diffstat (limited to 'app.rb')
-rw-r--r--app.rb371
1 files changed, 371 insertions, 0 deletions
diff --git a/app.rb b/app.rb
new file mode 100644
index 0000000..be88e37
--- /dev/null
+++ b/app.rb
@@ -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