debshots/app/controllers/packages_controller.rb
Christoph Haas 4948fe50e3 Add semantic package search via pgvector embeddings
Package text search now combines several strategies: exact name match,
name prefix promotion, compound word splitting ("sqlite browser" finds
"sqlitebrowser") and semantic nearest neighbor search over description
embeddings computed by an external embedding service (all-MiniLM-L6-v2,
384 dims) stored with the pgvector extension. Classic PostgreSQL
full-text search remains as fallback when the vector service is
unreachable. Gibberish queries without lexical overlap with the package
data return empty results instead of random matches.

Embeddings can be backfilled with bin/rails debshots:compute_vectors.

Also drops the unused lograge gem from the Gemfile.
2026-08-22 22:07:56 +02:00

456 lines
16 KiB
Ruby

class PackagesController < ApplicationController
# Allow legacy /uploadfile URL without CSRF protection
protect_from_forgery except: :legacy_uploadfile
def list
@packages = query_packages.paginate(page: params[:page], per_page: 6)
@view_style = :list
render :browse
end
def grid
@packages = query_packages.paginate(page: params[:page], per_page: 24)
@view_style = :grid
render :browse
end
def details
@package = Package.find_by(name: params[:name])
# Highlight a certain screenshot (by ID). This is used by the logs.slim
# view to link to a certain screenshot of a package.
@highlight_id = params[:highlight].to_i if params[:highlight]
if @package.nil?
@packagename = params[:name]
render 'notfound', status: 404
else
@page = params[:page]
# @screenshots = screenshots_visible_to_user(@package).paginate(page: @page, per_page: 6)
@screenshots = @package.screenshots.accessible_by(current_ability, :view).paginate(
page: @page, per_page: 6
)
end
end
# Show upload form for new images
def upload
@package = Package.find_by!(name: params[:name])
end
# POST target of the screenshots upload form.
# Receives uploaded images. Checks if they are valid. Asks for description.
# This action saves the screenshots already if they are valid. The user is
# then given the chance to comment on and delete the screenshots again.
def upload_receive
@package = Package.find_by!(name: params[:name])
@valid_images = []
@invalid_images = []
# If Javascript is disabled a user may submit an empty selection of images.
if params[:file].nil?
flash[:error] = 'You have not selected any images.'
redirect_to upload_path
return
end
all_errors = []
files = params[:file]
# Turn into array if a single image was uploaded through AJAX
files = [files] if files.class != Array
files.each do |img|
new_screenshot = @package.screenshots.new(simage: img)
# Check if the image was valid
if new_screenshot.valid?
# new_screenshot.uploaderhash = session.id.to_s
new_screenshot.uploaderip = request.remote_ip
new_screenshot.version = @package.version
# ActiveStorage does not yet create a file checksum automatically.
# Let's do that. It helps detect duplicate uploads later.
new_screenshot.image_fingerprint = Digest::MD5.hexdigest(File.read(img.path))
# Check that this screenshot is not a duplicate for this package
if @package.screenshots.where(image_fingerprint: new_screenshot.image_fingerprint).any?
auditlog "Duplicate image with fingerprint #{new_screenshot.image_fingerprint} found. Rejecting.",
package: @package
all_errors << "Your file #{img.original_filename} is a duplicate. Sorry."
else
# TODO: Can the upload get approved automatically?
# Create a pseudo user account for the user.
# The user won't know that an account is created.
# But this makes it easier to track who screenshots belong to.
unless user_signed_in?
sign_in User.create_pseudo_user
auditlog 'New pseudo user for anonymous upload created and logged in.',
package: @package, screenshot: new_screenshot
end
new_screenshot.user = current_user
new_screenshot.approve! if can?(:approve, new_screenshot)
# Pre-render screenshot in different sizes
new_screenshot.simage_derivatives!
new_screenshot.save!
auditlog "Screenshot #{new_screenshot.id} uploaded successfully.",
screenshot: new_screenshot, package: @package
@valid_images.push new_screenshot
end
else
errors = new_screenshot.errors[:simage]
auditlog "Screenshot #{img.original_filename} invalid (#{errors}).",
package: @package
# @invalid_images.push img.original_filename
# raise
all_errors << "Your file #{img.original_filename} #{errors.join(' and ')}."
end
end
flash[:error] = all_errors if all_errors.any?
# # Redirect back to upload form if all uploads were invalid
# unless @valid_images.any?
# auditlog "No valid images uploaded. Back to upload form."
# redirect_to(upload_path, error: all_errors) and return
# end
# Show a list of invalid uploads by default. Or redirect to the review page
# if all uploads were okay.
# redirect_to upload_review_path unless @invalid_images
# TODO
# if @invalid_images…
# ' #{image.image_file_name} (#{image.errors[:image].join(' and ')})
# Inform the admins about the upload
AdminMailer.with(package: @package).new_uploads_email.deliver_now if @valid_images.any?
# Rails does not allow dots in the URL. So we cannot use the 'respond_to'
# and 'format' ways to handle parameters. Instead the 'returns' parameters
# is set in routes.rb to signal that this method was called by AJAX.
if params[:returns] == :json
# TODO: send all_errors back as JSON and make Javascript display it in #messages
render json: { errors: all_errors.join(' ') }
else
redirect_to package_path
end
end
# Legacy action to upload an image along with metadata.
# This was used in Debshots 1.x as the default upload method.
# This method allows that old-style way to upload screenshots.
# It is probably used by screenshotting tools.
#
# Parameters:
# - packagename
# - version
# - description
# - file
def legacy_uploadfile
@package = Package.find_by!(name: params[:packagename])
new_screenshot = @package.screenshots.new(image: params[:file])
# Check if the image was valid
if new_screenshot.valid?
# new_screenshot.uploaderhash = session[:token]
new_screenshot.uploaderip = session[:ip]
new_screenshot.version = @package.version
new_screenshot.save
auditlog "Screenshot #{new_screenshot.id} uploaded successfully from legacy upload form.",
screenshot: new_screenshot, package: @package
redirect_to package_path(params[:packagename])
else
auditlog "Screenshot upload rejected. Errors: #{new_screenshot.errors.to_a}",
package: @package
head :not_acceptable
end
end
def hide_screenshot
# Is the user allowed to hide this screenshot?
@screenshot = Screenshot.find(params[:id])
# Check if the user is allowed to change this screenshot
# - Is this the user's own screenshot? (anonymous)
if can? :hide, @screenshot
auditlog "Screenshot #{@screenshot.id} hidden",
package: @screenshot.package
@screenshot.hide!
flash['notice'] = 'Screenshot hidden.'
redirect_back(fallback_location: package_path(name: @screenshot.package.name))
else
head :forbidden
end
end
def unhide_screenshot
# Is the user allowed to unhide this screenshot?
@screenshot = Screenshot.find(params[:id])
# Check if the user is allowed to change this screenshot
# - Is this the user's own screenshot? (anonymous)
if can? :unhide, @screenshot
auditlog "Screenshot #{@screenshot.id} un-hidden",
package: @screenshot.package
@screenshot.unhide!
flash['notice'] = 'Screenshot un-hidden.'
redirect_back(fallback_location: package_path(name: @screenshot.package.name))
else
head :forbidden
end
end
def delete_screenshot
# Is the user allowed to delete the screenshot?
@screenshot = Screenshot.find(params[:id])
# Check if the user is allowed to change this screenshot
# - Is this the user's own screenshot? (anonymous)
if can? :destroy, @screenshot
auditlog "Screenshot #{@screenshot.id} deleted",
package: @screenshot.package
@screenshot.destroy
# Increase the rejection counter for the user (social scoring)
# if the screenshot is new and pending approval
if !@screenshot.approved && @screenshot.user
@screenshot.user.rejected_screenshots += 1
@screenshot.user.save!
end
flash['notice'] = 'Screenshot deleted.'
redirect_back(fallback_location: package_path(name: @screenshot.package.name))
else
head :forbidden
end
end
def approve_screenshot
@screenshot = Screenshot.find(params[:id])
unless can? :approve, @screenshot
head :forbidden
return
end
@screenshot.approve!
auditlog 'Screenshot approved',
package: @screenshot.package, screenshot: @screenshot
if @screenshot.user
# Increase the approval counter for the user (social scoring)
@screenshot.user.approved_screenshots += 1
@screenshot.user.save!
end
flash['notice'] = 'Screenshot approved.'
redirect_back(fallback_location: package_path(name: @screenshot.package.name))
# head :forbidden
end
# Returns either…
# - 160x120 thumbnail image
# - 320x240 small image
# - full-size screenshot
#
# If the package is not found it returns a dummy image along with status 404.
# If the package is found but has no screenshots then it also returns a
# dummy image along with status 404.
#
# Return a specific screenshot (if the screenshot_id parameter is given)
# or just the first (newest) one.
def send_image
size = params[:size] # :small, :large or :thumb
@package = Package.find_by(name: params[:name]) # package name
@screenshot_id = params[:screenshot_id] # screenshot ID (optional)
unless @package
Rails.logger.debug 'no such package -> 404'
screenshot404 if %i[large small].include?(size)
thumbnail404 if size == :thumb
return
end
@image = nil
if params[:screenshot_id]
# Called as /screenshot/:name/:screenshot_id
# TODO: 'name' is useless here
Rails.logger.debug "Called as /screenshot/#{params[:name]}/#{params[:screenshot_id]}"
@image = Screenshot.find(params[:screenshot_id])
unless can? :view, @image
return screenshot403
end
elsif params[:version]
# Called as /screenshot-with-version/:name/:version
# or /thumbnail-with-version/:name/:version
# TODO: permissions check!?
@image = @package.best_screenshot_for_version(params[:version])
else
# Called as /screenshot/:name
@image = @package.screenshots.accessible_by(current_ability, :view).first
end
# Return a 404 if the package has no screenshots or the image was not found
if @image
send_file(File.join(@image.simage.storage.directory, @image.simage(size).id),
disposition: 'inline')
else
Rails.logger.debug 'no such image -> 404'
case size
when :large
screenshot404
when :small
screenshot404
when :thumb
Rails.logger.debug 'thumb404'
thumbnail404
end
end
end
# Receives a form with a simple text field 'description' so that users can update
# the description of their screenshot.
def update_screenshot_description
# TODO: Check permissions to do that
@screenshot = Screenshot.find(params[:id])
# @screenshot.description = params[:description]
@screenshot.update params_screenshot_description
@screenshot.save!
flash['notice'] = 'Description updated.'
redirect_back(fallback_location: package_path(name: @screenshot.package.name))
end
# Show an HTML partial with reviews of this package from the Ubuntu API
# def reviews
# expires_in 1.day, public: true
# # @reviews = Package.find_by_name!(params[:name]).ubuntu_reviews
# @reviews = get_ubuntu_reviews params[:name]
# render '_reviews', layout: false
# end
private
# Send a dummy thumbnail reading "No screenshot available. Sorry."
def thumbnail404
send_file Rails.root.join('public/images/dummy/thumbnail404.png'), type: 'image/png',
disposition: 'inline', status: 404
end
# Send a dummy screenshot reading "No screenshot available. Sorry."
def screenshot404
send_file Rails.root.join('public/images/dummy/screenshot404.png'), type: 'image/png',
disposition: 'inline', status: 404
end
# Send a dummy screenshot reading "No screenshot available. Sorry."
def screenshot403
send_file Rails.root.join('public/images/dummy/screenshot403.png'), type: 'image/png',
disposition: 'inline', status: 404
end
# Return packages matching the criteria given by parameters
def query_packages
packages = Package # .order(visits: :desc)
# text search
if params[:search].present?
logger.debug "Searching for #{params[:search]}"
packages = search_packages(params[:search])
end
case params[:show]
when 'with'
# Enrich the result with the screenshots readable by the current user (CanCanCan)
packages = packages.with_public_screenshots
logger.debug 'Limiting packages to those with screenshots'
when 'without'
packages = packages.without_screenshots
logger.debug 'Limiting packages to those without screenshots'
end
packages
end
# Text search for packages: an exact package name match wins without
# touching the vector service. Otherwise packages whose name starts
# with the query (or contains all query tokens, catching compound
# words like "sqlite browser" -> "sqlitebrowser") are promoted above
# the semantic (vector) nearest neighbor results. Classic PostgreSQL
# full-text search kicks in when the vector service cannot be reached.
MAX_PROMOTED_NAME_MATCHES = 8
MAX_SEMANTIC_RESULTS = 100
private_constant :MAX_PROMOTED_NAME_MATCHES, :MAX_SEMANTIC_RESULTS
def search_packages(query)
exact_match = Package.find_by(name: query)
return Package.where(id: exact_match.id) if exact_match
tokens = query.to_s.split(/\s+/)
name_matches = (
Package.name_starts_with(query).limit(MAX_PROMOTED_NAME_MATCHES) +
Package.name_contains_all(tokens).limit(MAX_PROMOTED_NAME_MATCHES)
).uniq(&:id)
results = (name_matches + semantic_results(query)).uniq(&:id)
# Queries that share no vocabulary with our package data can produce
# no meaningful results. Skip the vector service round-trip and show
# an honest empty result instead of random packages.
results.empty? ? Package.none : results
end
# Semantic (vector) nearest neighbor search with a full-text fallback
# for when the external embedding service is unreachable.
def semantic_results(query)
unless Package.lexical_overlap?(query)
logger.debug "Query '#{query}' has no lexical overlap with package data"
return []
end
begin
Package.nearest_to_text(query, limit: MAX_SEMANTIC_RESULTS).to_a
rescue Vectorizer::Error => e
logger.error "Semantic search failed: #{e.message}"
flash.now['error'] = 'Semantic search is unavailable right now. Showing full-text matches.'
Package.general_search(query).limit(100).to_a
end
end
# Store a random identifier and the client's IP address in the session
# for later identification.
# def create_user_token
# session[:token] ||= SecureRandom.hex
# session[:ip] ||= request.remote_ip
# end
# Get reviews of this package from the Ubuntu API
# def get_ubuntu_reviews(packagename)
# # Use the URL defined in the configuration to get a JSON string
# url = Rails.configuration.ubuntu_reviews_api_url % packagename
# logger.debug "Loading Ubuntu reviews for package #{packagename} from #{url}"
# body = open(url).read
# # Turn JSON into a Ruby data structure
# json = JSON.parse(body)
# # Only show english reviews
# # TODO: Support further languages
# json = json.select { |x| x['language'] == 'en' }
# # Sort by 'usefulness_total' (how many people found this review useful)
# json.sort { |x, y| y['usefulness_total'].to_i <=> x['usefulness_total'].to_i }
# end
# def params_screenshot_description
# params.require(:screenshot).permit(:description)
# end
# def screenshots_visible_to_user(package)
# package.screenshots.accessible_by(current_ability, :view).order('created_at DESC')
# end
end