diff --git a/Dockerfile b/Dockerfile index 65fab53..54e3c30 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ # Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile ARG RUBY_VERSION=3.3.4 -FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim as base +FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base # Rails app lives here WORKDIR /rails @@ -14,11 +14,22 @@ ENV RAILS_ENV="production" \ BUNDLE_WITHOUT="development test" # Throw-away build stage to reduce size of final image -FROM base as build +FROM base AS build # Install packages needed to build gems and node modules RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y build-essential git libvips pkg-config nodejs npm + apt-get install --no-install-recommends -y \ + build-essential \ + git \ + libvips \ + pkg-config \ + nodejs \ + npm \ + imagemagick \ + fonts-liberation \ + fonts-freefont-ttf \ + fonts-dejavu \ + fontconfig # Install application gems COPY Gemfile Gemfile.lock ./ @@ -45,8 +56,17 @@ FROM base # Install packages needed for deployment RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y curl libsqlite3-0 libvips imagemagick fonts-liberation sqlite3 libsqlite3-dev \ - fonts-freefont-ttf fonts-dejavu fontconfig && \ + apt-get install --no-install-recommends -y \ + curl \ + libsqlite3-0 \ + libvips \ + imagemagick \ + fonts-liberation \ + sqlite3 \ + libsqlite3-dev \ + fonts-freefont-ttf \ + fonts-dejavu \ + fontconfig && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives # Copy built artifacts: gems, application, and node modules diff --git a/app/assets/images/default_avatar.jpg b/app/assets/images/default_avatar.jpg new file mode 100644 index 0000000..4221487 Binary files /dev/null and b/app/assets/images/default_avatar.jpg differ diff --git a/app/helpers/open_graph_helper.rb b/app/helpers/open_graph_helper.rb index 006e345..c9d58ae 100644 --- a/app/helpers/open_graph_helper.rb +++ b/app/helpers/open_graph_helper.rb @@ -1,25 +1,20 @@ +# app/helpers/open_graph_helper.rb module OpenGraphHelper include Rails.application.routes.url_helpers def set_open_graph_tags(user) - # Fallback values for Open Graph default_title = 'Linkarooie - Simplify Your Online Presence' - default_description = 'Manage all your links in one place with Linkarooie. Create a central hub for your social and professional profiles.' + default_description = 'Manage all your links in one place with Linkarooie.' default_image = image_url('default_og_image.png') - default_image_alt = 'Linkarooie logo' - default_url = root_url - twitter_handle = user.username&.downcase || '@loftwah' - - # Open Graph tags with fallback values - content_for :og_title, user.full_name || default_title - content_for :og_description, (user.description || default_description).truncate(160) - content_for :og_image, user.username.present? ? url_for("/uploads/og_images/#{user.username}_og.png") : default_image - content_for :og_image_alt, user.full_name.present? ? "#{user.full_name}'s profile image" : default_image_alt - content_for :og_url, user_links_url(user.username || default_url) - - # Twitter Card tags with fallback values + + content_for :og_title, user.full_name.presence || default_title + content_for :og_description, user.description.presence&.truncate(160) || default_description + content_for :og_image, user.og_image_url.presence || default_image + content_for :og_url, user_links_url(username: user.username) + + # Twitter Card specific tags content_for :twitter_card, 'summary_large_image' - content_for :twitter_site, "@#{twitter_handle}" - content_for :twitter_creator, "@#{twitter_handle}" + content_for :twitter_site, "@#{user.username.downcase}" + content_for :twitter_creator, "@#{user.username.downcase}" end -end +end \ No newline at end of file diff --git a/app/services/open_graph_image_generator.rb b/app/services/open_graph_image_generator.rb index a0143b5..e1d5c76 100644 --- a/app/services/open_graph_image_generator.rb +++ b/app/services/open_graph_image_generator.rb @@ -5,18 +5,19 @@ class OpenGraphImageGenerator def initialize(user) @user = user + @spaces_service = DigitalOceanSpacesService.new end def generate - template_path = Rails.root.join('app', 'assets', 'images', 'og_template.png') - output_dir = Rails.root.join('public', 'uploads', 'og_images') - FileUtils.mkdir_p(output_dir) unless File.directory?(output_dir) - output_path = output_dir.join("#{@user.username}_og.png") - begin + # Create a temporary working directory + temp_dir = Dir.mktmpdir + output_path = File.join(temp_dir, "#{@user.username}_og.png") + + template_path = Rails.root.join('app', 'assets', 'images', 'og_template.png') image = MiniMagick::Image.open(template_path) - # Determine whether to use fallback avatar or download the provided one + # Download and process avatar if @user.avatar.blank? || !valid_image_url?(@user.avatar_url) avatar = MiniMagick::Image.open(Rails.root.join('public', 'avatars', 'default_avatar.jpg')) else @@ -43,17 +44,11 @@ def generate # Spacing between elements spacing = 10 - # Estimate text heights (approximated as 1.2 times point size) - name_text_height = name_pointsize * 1.2 - username_text_height = username_pointsize * 1.2 - tag_text_height = tag_pointsize * 1.2 if tag_text.present? - # Total content height calculation total_height = (AVATAR_SIZE + 2 * BORDER_SIZE) + spacing + - name_text_height + spacing + - username_text_height - - total_height += spacing + tag_text_height if tag_text.present? + name_pointsize * 1.2 + spacing + + username_pointsize * 1.2 + total_height += spacing + tag_pointsize * 1.2 if tag_text.present? # Calculate starting y-position to center content vertically template_height = image.height @@ -64,7 +59,7 @@ def generate # Add avatar to the image, centered horizontally image = image.composite(avatar) do |c| - c.gravity 'North' # Align from the top + c.gravity 'North' c.geometry "+0+#{current_y}" end @@ -72,21 +67,21 @@ def generate # Add text to the image image.combine_options do |c| - c.gravity 'North' # Align from the top - c.font 'Arial' # Use a common system font + c.gravity 'North' + c.font '/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf' # Add full name c.fill '#BEF264' c.pointsize name_pointsize.to_s c.draw "text 0,#{current_y} '#{escape_text(full_name)}'" - current_y += name_text_height + spacing + current_y += name_pointsize * 1.2 + spacing # Add username c.pointsize username_pointsize.to_s c.draw "text 0,#{current_y} '#{escape_text(username)}'" - current_y += username_text_height + spacing + current_y += username_pointsize * 1.2 + spacing # Add tags if present if tag_text.present? @@ -96,67 +91,60 @@ def generate end end - # Save the generated image + # Save the image to temp directory image.write(output_path) - output_path + + # Upload to DigitalOcean Spaces + og_image_key = "og_images/#{@user.username}_og.png" + spaces_url = @spaces_service.upload_file_from_path(og_image_key, output_path) + + # Update user's og_image_url + @user.update_column(:og_image_url, spaces_url) if spaces_url + + spaces_url rescue StandardError => e Rails.logger.error("Failed to generate OG image for user #{@user.id}: #{e.message}") - nil # Return nil to indicate failure without raising an exception + nil + ensure + FileUtils.remove_entry(temp_dir) if temp_dir && File.exist?(temp_dir) end end + private + def valid_image_url?(url) return true if url.start_with?('https://linkarooie.syd1.digitaloceanspaces.com/') return false if url.blank? - begin - uri = URI.parse(url) - return false unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS) - return false if uri.host.nil? - - response = fetch_head(uri) - return response.is_a?(Net::HTTPSuccess) && response['Content-Type'].to_s.start_with?('image/') - rescue URI::InvalidURIError, SocketError, Errno::ECONNREFUSED, Net::OpenTimeout, OpenSSL::SSL::SSLError => e - Rails.logger.error("Invalid or unreachable URL: #{url}. Error: #{e.message}.") - false + uri = URI.parse(url) + response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http| + http.head(uri.path) end + + response.is_a?(Net::HTTPSuccess) && response['Content-Type'].to_s.start_with?('image/') + rescue StandardError => e + Rails.logger.error("Error validating image URL: #{e.message}") + false end def download_image(url) - return MiniMagick::Image.open(url) if url.start_with?('https://linkarooie.syd1.digitaloceanspaces.com/') - - uri = URI.parse(url) - response = Net::HTTP.get_response(uri) - - if response.is_a?(Net::HTTPSuccess) - content_type = response['Content-Type'] - - if content_type.to_s.start_with?('image/') + if url.start_with?('https://linkarooie.syd1.digitaloceanspaces.com/') + MiniMagick::Image.open(url) + else + response = Net::HTTP.get_response(URI.parse(url)) + + if response.is_a?(Net::HTTPSuccess) && response['Content-Type'].to_s.start_with?('image/') MiniMagick::Image.read(response.body) else - handle_invalid_image("URL does not point to an image: #{url}. Content-Type: #{content_type}.") + MiniMagick::Image.open(Rails.root.join('public', 'avatars', 'default_avatar.jpg')) end - else - handle_invalid_image("Failed to download image from URL: #{url}. HTTP Error: #{response.code} #{response.message}.") end rescue StandardError => e - handle_invalid_image("Failed to download image from URL: #{url}. Error: #{e.message}.") - end - - private - - def fetch_head(uri) - Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 5, read_timeout: 5) do |http| - http.request_head(uri.path) - end - end - - def handle_invalid_image(error_message) - Rails.logger.error(error_message) + Rails.logger.error("Failed to download image: #{e.message}") MiniMagick::Image.open(Rails.root.join('public', 'avatars', 'default_avatar.jpg')) end def escape_text(text) - text.gsub("'", "\\\\'") + text.gsub("\\", "\\\\\\").gsub("'", "\\\\'") end end \ No newline at end of file diff --git a/db/migrate/20241209071711_add_og_image_url_to_users.rb b/db/migrate/20241209071711_add_og_image_url_to_users.rb new file mode 100644 index 0000000..d54b30a --- /dev/null +++ b/db/migrate/20241209071711_add_og_image_url_to_users.rb @@ -0,0 +1,5 @@ +class AddOgImageUrlToUsers < ActiveRecord::Migration[7.1] + def change + add_column :users, :og_image_url, :string + end +end \ No newline at end of file diff --git a/db/schema.rb b/db/schema.rb index a91cf36..4292a7e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2024_09_16_055252) do +ActiveRecord::Schema[7.2].define(version: 2024_12_09_071711) do create_table "achievement_views", force: :cascade do |t| t.integer "achievement_id", null: false t.integer "user_id", null: false @@ -148,6 +148,7 @@ t.boolean "community_opt_in", default: false, null: false t.string "avatar_local_path" t.string "banner_local_path" + t.string "og_image_url" t.index ["email"], name: "index_users_on_email", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true t.index ["username"], name: "index_users_on_username", unique: true diff --git a/spec/services/open_graph_image_generator_spec.rb b/spec/services/open_graph_image_generator_spec.rb new file mode 100644 index 0000000..e517bb1 --- /dev/null +++ b/spec/services/open_graph_image_generator_spec.rb @@ -0,0 +1,64 @@ +require 'rails_helper' + +RSpec.describe OpenGraphImageGenerator do + let(:user) { create(:user, username: 'testuser', full_name: 'Test User', tags: ['ruby', 'rails'].to_json) } + let(:spaces_service) { instance_double(DigitalOceanSpacesService) } + let(:spaces_url) { 'https://linkarooie.syd1.digitaloceanspaces.com/og_images/testuser_og.png' } + + before do + allow(DigitalOceanSpacesService).to receive(:new).and_return(spaces_service) + allow(spaces_service).to receive(:upload_file_from_path) + .with("og_images/#{user.username}_og.png", anything) + .and_return(spaces_url) + end + + describe '#generate' do + it 'generates and uploads the image to Spaces' do + result = described_class.new(user).generate + expect(result).to eq(spaces_url) + expect(user.reload.og_image_url).to eq(spaces_url) + end + + context 'with different avatar sources' do + it 'handles Spaces avatars' do + user.update(avatar: 'https://linkarooie.syd1.digitaloceanspaces.com/avatars/test.png') + expect(spaces_service).to receive(:upload_file_from_path) + .with("og_images/#{user.username}_og.png", anything) + .and_return(spaces_url) + + result = described_class.new(user).generate + expect(result).to eq(spaces_url) + end + + it 'uses default avatar for invalid URLs' do + user.update(avatar: 'invalid-url') + expect(spaces_service).to receive(:upload_file_from_path) + .with("og_images/#{user.username}_og.png", anything) + .and_return(spaces_url) + + result = described_class.new(user).generate + expect(result).to eq(spaces_url) + end + end + + context 'when errors occur' do + it 'handles upload failures' do + allow(spaces_service).to receive(:upload_file_from_path).and_return(nil) + result = described_class.new(user).generate + expect(result).to be_nil + expect(user.reload.og_image_url).to be_nil + end + + it 'logs errors and returns nil' do + allow(spaces_service).to receive(:upload_file_from_path) + .and_raise(StandardError.new("Upload failed")) + + expect(Rails.logger).to receive(:error) + .with("Failed to generate OG image for user #{user.id}: Upload failed") + + result = described_class.new(user).generate + expect(result).to be_nil + end + end + end +end \ No newline at end of file