# 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 SEARCH_TTL = 300 # seconds before rebuilding search index MONTH_NAMES = %w[january february march april may june july august september october november december].freeze $search_cache = { index: nil, built_at: nil, 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 FACES_ENABLED = (APP_CONFIG.dig(:faces, :enabled) == true).freeze VENV_PYTHON = (ENV['VENV_PYTHON'] || '/opt/albumen/venv/bin/python3').freeze CLUSTER_SCRIPT = File.join(__dir__, 'scripts', 'cluster_faces.py').freeze PEOPLE_PATH = File.join(MEDIA_ROOT, 'people.json').freeze FACES_CACHE = (ENV['FACES_CACHE'] || '/opt/albumen/cache/faces').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 Rack::Utils.multipart_part_limit = 2000 # default 128; allow bulk photo uploads 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 load_faces(dir) path = File.join(dir, 'faces.json') return {} unless File.exist?(path) JSON.parse(File.read(path)) rescue JSON::ParserError {} end def album_files(dir, data) face_data = FACES_ENABLED ? load_faces(dir) : {} 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? next if meta['transcoded_to'] && !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'], transcoded_to: meta['transcoded_to'], faces: face_data[name], } 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 def load_people_data return {} unless File.exist?(PEOPLE_PATH) JSON.parse(File.read(PEOPLE_PATH)) rescue JSON::ParserError {} end def face_crop_cache(rel, box) File.join(FACES_CACHE, File.dirname(rel), "#{File.basename(rel)}.#{box.join('-')}.jpg") end def generate_face_crop(full_path, box, cache_path) top, right, bottom, left = box.map(&:to_i) img = MiniMagick::Image.open(full_path) iw, ih = img.width, img.height pad_y = ((bottom - top) * 0.5).to_i pad_x = ((right - left) * 0.5).to_i y1 = [top - pad_y, 0].max x1 = [left - pad_x, 0].max y2 = [bottom + pad_y, ih].min x2 = [right + pad_x, iw].min cw = [x2 - x1, 1].max ch = [y2 - y1, 1].max img.combine_options do |c| c.auto_orient c.crop "#{cw}x#{ch}+#{x1}+#{y1}" c.resize '100x100!' end img.write(cache_path) rescue StandardError => e warn "face crop error #{full_path}: #{e.message}" end end # ── Search index ────────────────────────────────────────────────────────────── def search_index $search_cache[:mutex].synchronize do if $search_cache[:index].nil? || (Time.now - $search_cache[:built_at]) > SEARCH_TTL $search_cache[:index] = build_search_index $search_cache[:built_at] = Time.now end $search_cache[:index] end end def build_search_index # people lookup: rel → [person name, ...] people_by_rel = {} (load_people_data['people'] || {}).each do |_, p| next unless p['name'] (p['members'] || []).each do |m| (people_by_rel[m['rel']] ||= []) << p['name'] end end docs = [] dirs = [MEDIA_ROOT] + Dir.glob("#{MEDIA_ROOT}/**/*/").sort dirs.each do |dir| dir_rel = dir.delete_prefix(MEDIA_ROOT).delete_prefix('/') data = load_album(dir) dir_words = dir_rel.split('/').flat_map { |s| s.downcase.split(/[-_]/) } album_title = (data['title'] || (dir_rel.empty? ? '' : File.basename(dir))).downcase album_vis = data['visible'] != false (data['files'] || {}).each do |filename, meta| meta ||= {} rel = dir_rel.empty? ? filename : "#{dir_rel}/#{filename}" parts = (dir_words + [album_title, filename.downcase, File.basename(filename, '.*').downcase, meta['title'].to_s.downcase, meta['caption'].to_s.downcase, meta['camera'].to_s.downcase]).reject(&:empty?) if (ts = meta['taken_at']) parts << ts[0..3] if ts.length >= 7 m_idx = ts[5..6].to_i - 1 parts << ts[5..6] parts << MONTH_NAMES[m_idx] if m_idx.between?(0, 11) end parts << ts[0..9] if ts.length >= 10 end names = people_by_rel[rel] || [] parts.concat(names.map(&:downcase)) docs << { rel: rel, filename: filename, dir_rel: dir_rel, text: parts.join(' '), taken_at: meta['taken_at'], people: names, visible: album_vis && meta.fetch('visible', true) != false, } end end docs end # ── Boolean query parser ─────────────────────────────────────────────────────── # Grammar: expr = or_expr # or_expr = and_expr ('OR' and_expr)* # and_expr = not_expr ('AND'? not_expr)* ← AND implicit between terms # not_expr = 'NOT' not_expr | atom # atom = TERM def search_tokenize(query) query.downcase.scan(/\S+/).map do |t| case t.upcase when 'AND' then :AND when 'OR' then :OR when 'NOT' then :NOT else t end end end def search_parse(query) toks = search_tokenize(query) pos = [0] search_or(toks, pos) end def search_or(toks, pos) left = search_and(toks, pos) while toks[pos[0]] == :OR pos[0] += 1 left = [:or, left, search_and(toks, pos)] end left end def search_and(toks, pos) left = search_not(toks, pos) loop do t = toks[pos[0]] break if t.nil? || t == :OR pos[0] += 1 if t == :AND # consume explicit AND; implicit falls through left = [:and, left, search_not(toks, pos)] end left end def search_not(toks, pos) if toks[pos[0]] == :NOT pos[0] += 1 [:not, search_not(toks, pos)] else t = toks[pos[0]] return [:lit, true] if t.nil? || %i[AND OR NOT].include?(t) pos[0] += 1 [:term, t] end end def eval_search(node, text) case node[0] when :or then eval_search(node[1], text) || eval_search(node[2], text) when :and then eval_search(node[1], text) && eval_search(node[2], text) when :not then !eval_search(node[1], text) when :term then text.include?(node[1]) when :lit then node[1] 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 get '/search' do q = params[:q].to_s.strip @title = q.empty? ? 'Search' : "Search: #{q}" @search_query = q unless q.empty? ast = search_parse(q) index = search_index all = index.select { |d| (admin? || d[:visible]) && eval_search(ast, d[:text]) } @total = all.length @results = all.first(300) end erb :search 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('/') force = params[:force].to_s == '1' job_id = SecureRandom.hex(8) script = File.join(__dir__, 'scripts', 'update.rb') args = [] args << '--force' if force args << rel unless rel.empty? cmd = ['ruby', script, *args] 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 post '/admin/upload' do require_admin! rel = params['rel'].to_s.chomp('/') sub_name = params['new_album_name'].to_s.strip sub_name = '' if sub_name.match?(%r{[/\x00]}) || %w[. ..].include?(sub_name) target_rel = if !sub_name.empty? rel.empty? ? sub_name : "#{rel}/#{sub_name}" else rel end target_dir = if target_rel.empty? MEDIA_ROOT else full = File.expand_path(target_rel, MEDIA_ROOT) halt 400, 'Invalid path' unless full.start_with?("#{MEDIA_ROOT}/") full end FileUtils.mkdir_p(target_dir) files = params['files[]'] || params['files'] files = [files] unless files.is_a?(Array) files = files.compact saved = 0 files.each do |f| next unless f.is_a?(Hash) && f[:filename].to_s.strip != '' name = File.basename(f[:filename].to_s.encode('UTF-8', invalid: :replace, undef: :replace).gsub("\x00", '')) next if name.empty? ext = File.extname(name).downcase.delete_prefix('.') next unless MEDIA_EXTS.include?(ext) dest = File.join(target_dir, name) FileUtils.cp(f[:tempfile].path, dest) saved += 1 end job_id = SecureRandom.hex(8) script = File.join(__dir__, 'scripts', 'update.rb') cmd = target_rel.empty? ? ['ruby', script] : ['ruby', script, target_rel] UPDATE_JOBS_MUTEX.synchronize { UPDATE_JOBS[job_id] = { status: :running, lines: [] } } Thread.new do begin IO.popen(cmd, err: [:child, :out]) do |io| io.each_line { |line| UPDATE_JOBS_MUTEX.synchronize { UPDATE_JOBS[job_id][:lines] << line.chomp } } end code = $?.exitstatus UPDATE_JOBS_MUTEX.synchronize { UPDATE_JOBS[job_id][:status] = code.zero? ? :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, saved: saved, album_rel: target_rel }.to_json end # ── Face crops ──────────────────────────────────────────────────────────────── get '/face/*' 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? box = params[:box].to_s.split(',').map(&:to_i) halt 400 unless box.length == 4 cache = face_crop_cache(rel, box) unless File.exist?(cache) FileUtils.mkdir_p(File.dirname(cache)) generate_face_crop(full, box, cache) end halt 404 unless File.exist?(cache) send_file cache, type: 'image/jpeg' end # ── People ───────────────────────────────────────────────────────────────────── get '/admin/people' do require_admin! data = load_people_data people = data['people'] || {} @title = 'People' @total = people.length @named_count = people.count { |_, p| p['name'] } @updated_at = data['updated_at'] pool_data = people.delete('__pool__') @pool = pool_data ? { count: (pool_data['members'] || []).length, samples: (pool_data['members'] || []).first(6).map { |m| { rel: m['rel'], box: m['box'] } } } : nil named = people.select { |_, p| p['name'] }.sort_by { |_, p| p['name'].downcase } unnamed = people.reject { |_, p| p['name'] }.sort_by { |_, p| -(p['members']&.length || 0) } @total = people.length @named_count = people.count { |_, p| p['name'] } @clusters = (named + unnamed).first(200).map do |uid, p| members = p['members'] || [] { uuid: uid, name: p['name'], slug: p['slug'], count: members.length, samples: members.first(6).map { |m| { rel: m['rel'], box: m['box'] } } } end erb :'admin/people' end post '/admin/people/recluster' do require_admin! job_id = SecureRandom.hex(8) cmd = [VENV_PYTHON, CLUSTER_SCRIPT] UPDATE_JOBS_MUTEX.synchronize { UPDATE_JOBS[job_id] = { status: :running, lines: [] } } Thread.new do begin IO.popen(cmd, err: [:child, :out]) do |io| io.each_line { |l| UPDATE_JOBS_MUTEX.synchronize { UPDATE_JOBS[job_id][:lines] << l.chomp } } end code = $?.exitstatus UPDATE_JOBS_MUTEX.synchronize { UPDATE_JOBS[job_id][:status] = code.zero? ? :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 post '/admin/people/:uuid' do require_admin! uid = params[:uuid] name = params['name'].to_s.strip data = load_people_data people = data['people'] || {} halt 404 unless people.key?(uid) slug = nil unless name.empty? base = name.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/^-+|-+$/, '') used = people.reject { |k, _| k == uid }.values.map { |p| p['slug'] }.compact if used.include?(base) i = 2 i += 1 while used.include?("#{base}-#{i}") slug = "#{base}-#{i}" else slug = base end end people[uid]['name'] = name.empty? ? nil : name people[uid]['slug'] = slug data['people'] = people atomic_write(PEOPLE_PATH, JSON.pretty_generate(data)) redirect '/admin/people' end get '/admin/people/:uuid' do require_admin! data = load_people_data people = data['people'] || {} halt 404 unless people.key?(params[:uuid]) pd = people[params[:uuid]] @is_pool = params[:uuid] == '__pool__' @title = @is_pool ? 'Unidentified pool' : (pd['name'] || 'Unnamed cluster') @uuid = params[:uuid] @name = pd['name'] @members = pd['members'] || [] @count = @members.length regular = people.reject { |k, _| k == params[:uuid] || k == '__pool__' } @named_others = regular .select { |_, v| v['name'] } .map { |k, v| { uuid: k, name: v['name'] } } .sort_by { |x| x[:name].downcase } @all_others = regular .map { |k, v| { uuid: k, name: v['name'] || "(unnamed · #{(v['members'] || []).length})" } } .sort_by { |x| x[:name].downcase } @existing_names_json = regular .select { |_, v| v['name'] } .map { |k, v| { uuid: k, name: v['name'] } } .to_json erb :'admin/person_detail' end post '/admin/people/:uuid/move' do require_admin! src = params[:uuid] rel = params['rel'] box = JSON.parse(params['box']).map(&:to_i) to = params['to'].to_s.strip return redirect "/admin/people/#{src}" if to.empty? data = load_people_data people = data['people'] || {} halt 404 unless people.key?(src) member = people[src]['members'].find { |m| m['rel'] == rel && m['box'].map(&:to_i) == box } halt 404 unless member people[src]['members'].delete(member) if to == 'blacklist' data['blacklist'] ||= [] data['blacklist'] << member elsif to == 'remove' people['__pool__'] ||= { 'name' => '__pool__', 'slug' => nil, 'members' => [] } people['__pool__']['members'] << member elsif to == 'new' new_uid = SecureRandom.uuid people[new_uid] = { 'name' => nil, 'slug' => nil, 'members' => [member] } else halt 404 unless people.key?(to) people[to]['members'] << member end people.delete(src) if people[src] && people[src]['members'].empty? data['people'] = people atomic_write(PEOPLE_PATH, JSON.pretty_generate(data)) if to == 'new' redirect "/admin/people/#{new_uid}" elsif people[src] redirect "/admin/people/#{src}" else redirect '/admin/people' end end post '/admin/people/__pool__/blacklist_all' do require_admin! data = load_people_data people = data['people'] || {} pool = people.delete('__pool__') if pool data['blacklist'] ||= [] data['blacklist'].concat(pool['members'] || []) data['people'] = people atomic_write(PEOPLE_PATH, JSON.pretty_generate(data)) end redirect '/admin/people' end post '/admin/people/:uuid/delete' do require_admin! data = load_people_data people = data['people'] || {} cluster = people.delete(params[:uuid]) if cluster data['blacklist'] ||= [] data['blacklist'].concat(cluster['members'] || []) data['people'] = people atomic_write(PEOPLE_PATH, JSON.pretty_generate(data)) end redirect '/admin/people' end post '/admin/people/:uuid/merge' do require_admin! src = params[:uuid] into = params['into'].to_s.strip return redirect '/admin/people' if into.empty? data = load_people_data people = data['people'] || {} halt 404 unless people.key?(src) && people.key?(into) people[into]['members'].concat(people[src]['members']) people.delete(src) data['people'] = people atomic_write(PEOPLE_PATH, JSON.pretty_generate(data)) redirect "/admin/people/#{into}" end get '/people' do data = load_people_data people = (data['people'] || {}).select { |_, p| p['name'] && p['slug'] } @title = 'People' @people = people.filter_map do |_, p| face = p['members']&.first next unless face { slug: p['slug'], name: p['name'], count: (p['members'] || []).length, face: { rel: face['rel'], box: face['box'] } } end.sort_by { |p| p[:name].downcase } erb :people end get '/people/:slug' do data = load_people_data person = (data['people'] || {}).values.find { |p| p['slug'] == params[:slug] } halt 404 unless person seen = {} photos = (person['members'] || []).filter_map do |m| next if seen[m['rel']] seen[m['rel']] = true parts = m['rel'].split('/') { rel: m['rel'], filename: parts.last, dir_rel: parts[0..-2].join('/') } end @title = person['name'] @person_name = person['name'] @photos = photos @count = photos.length erb :person 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