Skip to content

Commit

Permalink
Merge pull request #246 from loftwah/dl/og-image-fix
Browse files Browse the repository at this point in the history
add og image fix
  • Loading branch information
loftwah authored Dec 9, 2024
2 parents 276b426 + 8486012 commit 8045a90
Show file tree
Hide file tree
Showing 7 changed files with 155 additions and 82 deletions.
30 changes: 25 additions & 5 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 ./
Expand All @@ -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
Expand Down
Binary file added app/assets/images/default_avatar.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
29 changes: 12 additions & 17 deletions app/helpers/open_graph_helper.rb
Original file line number Diff line number Diff line change
@@ -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
106 changes: 47 additions & 59 deletions app/services/open_graph_image_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -64,29 +59,29 @@ 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

current_y += (AVATAR_SIZE + 2 * BORDER_SIZE) + spacing

# 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?
Expand All @@ -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
5 changes: 5 additions & 0 deletions db/migrate/20241209071711_add_og_image_url_to_users.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddOgImageUrlToUsers < ActiveRecord::Migration[7.1]
def change
add_column :users, :og_image_url, :string
end
end
3 changes: 2 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

64 changes: 64 additions & 0 deletions spec/services/open_graph_image_generator_spec.rb
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 8045a90

Please sign in to comment.