Skip to content
This repository was archived by the owner on Mar 14, 2025. It is now read-only.

Commit 8045a90

Browse files
authored
Merge pull request #246 from loftwah/dl/og-image-fix
add og image fix
2 parents 276b426 + 8486012 commit 8045a90

File tree

7 files changed

+155
-82
lines changed

7 files changed

+155
-82
lines changed

Dockerfile

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile
44
ARG RUBY_VERSION=3.3.4
5-
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim as base
5+
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base
66

77
# Rails app lives here
88
WORKDIR /rails
@@ -14,11 +14,22 @@ ENV RAILS_ENV="production" \
1414
BUNDLE_WITHOUT="development test"
1515

1616
# Throw-away build stage to reduce size of final image
17-
FROM base as build
17+
FROM base AS build
1818

1919
# Install packages needed to build gems and node modules
2020
RUN apt-get update -qq && \
21-
apt-get install --no-install-recommends -y build-essential git libvips pkg-config nodejs npm
21+
apt-get install --no-install-recommends -y \
22+
build-essential \
23+
git \
24+
libvips \
25+
pkg-config \
26+
nodejs \
27+
npm \
28+
imagemagick \
29+
fonts-liberation \
30+
fonts-freefont-ttf \
31+
fonts-dejavu \
32+
fontconfig
2233

2334
# Install application gems
2435
COPY Gemfile Gemfile.lock ./
@@ -45,8 +56,17 @@ FROM base
4556

4657
# Install packages needed for deployment
4758
RUN apt-get update -qq && \
48-
apt-get install --no-install-recommends -y curl libsqlite3-0 libvips imagemagick fonts-liberation sqlite3 libsqlite3-dev \
49-
fonts-freefont-ttf fonts-dejavu fontconfig && \
59+
apt-get install --no-install-recommends -y \
60+
curl \
61+
libsqlite3-0 \
62+
libvips \
63+
imagemagick \
64+
fonts-liberation \
65+
sqlite3 \
66+
libsqlite3-dev \
67+
fonts-freefont-ttf \
68+
fonts-dejavu \
69+
fontconfig && \
5070
rm -rf /var/lib/apt/lists /var/cache/apt/archives
5171

5272
# Copy built artifacts: gems, application, and node modules

app/assets/images/default_avatar.jpg

23.3 KB
Loading

app/helpers/open_graph_helper.rb

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,20 @@
1+
# app/helpers/open_graph_helper.rb
12
module OpenGraphHelper
23
include Rails.application.routes.url_helpers
34

45
def set_open_graph_tags(user)
5-
# Fallback values for Open Graph
66
default_title = 'Linkarooie - Simplify Your Online Presence'
7-
default_description = 'Manage all your links in one place with Linkarooie. Create a central hub for your social and professional profiles.'
7+
default_description = 'Manage all your links in one place with Linkarooie.'
88
default_image = image_url('default_og_image.png')
9-
default_image_alt = 'Linkarooie logo'
10-
default_url = root_url
11-
twitter_handle = user.username&.downcase || '@loftwah'
12-
13-
# Open Graph tags with fallback values
14-
content_for :og_title, user.full_name || default_title
15-
content_for :og_description, (user.description || default_description).truncate(160)
16-
content_for :og_image, user.username.present? ? url_for("/uploads/og_images/#{user.username}_og.png") : default_image
17-
content_for :og_image_alt, user.full_name.present? ? "#{user.full_name}'s profile image" : default_image_alt
18-
content_for :og_url, user_links_url(user.username || default_url)
19-
20-
# Twitter Card tags with fallback values
9+
10+
content_for :og_title, user.full_name.presence || default_title
11+
content_for :og_description, user.description.presence&.truncate(160) || default_description
12+
content_for :og_image, user.og_image_url.presence || default_image
13+
content_for :og_url, user_links_url(username: user.username)
14+
15+
# Twitter Card specific tags
2116
content_for :twitter_card, 'summary_large_image'
22-
content_for :twitter_site, "@#{twitter_handle}"
23-
content_for :twitter_creator, "@#{twitter_handle}"
17+
content_for :twitter_site, "@#{user.username.downcase}"
18+
content_for :twitter_creator, "@#{user.username.downcase}"
2419
end
25-
end
20+
end

app/services/open_graph_image_generator.rb

Lines changed: 47 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,19 @@ class OpenGraphImageGenerator
55

66
def initialize(user)
77
@user = user
8+
@spaces_service = DigitalOceanSpacesService.new
89
end
910

1011
def generate
11-
template_path = Rails.root.join('app', 'assets', 'images', 'og_template.png')
12-
output_dir = Rails.root.join('public', 'uploads', 'og_images')
13-
FileUtils.mkdir_p(output_dir) unless File.directory?(output_dir)
14-
output_path = output_dir.join("#{@user.username}_og.png")
15-
1612
begin
13+
# Create a temporary working directory
14+
temp_dir = Dir.mktmpdir
15+
output_path = File.join(temp_dir, "#{@user.username}_og.png")
16+
17+
template_path = Rails.root.join('app', 'assets', 'images', 'og_template.png')
1718
image = MiniMagick::Image.open(template_path)
1819

19-
# Determine whether to use fallback avatar or download the provided one
20+
# Download and process avatar
2021
if @user.avatar.blank? || !valid_image_url?(@user.avatar_url)
2122
avatar = MiniMagick::Image.open(Rails.root.join('public', 'avatars', 'default_avatar.jpg'))
2223
else
@@ -43,17 +44,11 @@ def generate
4344
# Spacing between elements
4445
spacing = 10
4546

46-
# Estimate text heights (approximated as 1.2 times point size)
47-
name_text_height = name_pointsize * 1.2
48-
username_text_height = username_pointsize * 1.2
49-
tag_text_height = tag_pointsize * 1.2 if tag_text.present?
50-
5147
# Total content height calculation
5248
total_height = (AVATAR_SIZE + 2 * BORDER_SIZE) + spacing +
53-
name_text_height + spacing +
54-
username_text_height
55-
56-
total_height += spacing + tag_text_height if tag_text.present?
49+
name_pointsize * 1.2 + spacing +
50+
username_pointsize * 1.2
51+
total_height += spacing + tag_pointsize * 1.2 if tag_text.present?
5752

5853
# Calculate starting y-position to center content vertically
5954
template_height = image.height
@@ -64,29 +59,29 @@ def generate
6459

6560
# Add avatar to the image, centered horizontally
6661
image = image.composite(avatar) do |c|
67-
c.gravity 'North' # Align from the top
62+
c.gravity 'North'
6863
c.geometry "+0+#{current_y}"
6964
end
7065

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

7368
# Add text to the image
7469
image.combine_options do |c|
75-
c.gravity 'North' # Align from the top
76-
c.font 'Arial' # Use a common system font
70+
c.gravity 'North'
71+
c.font '/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf'
7772

7873
# Add full name
7974
c.fill '#BEF264'
8075
c.pointsize name_pointsize.to_s
8176
c.draw "text 0,#{current_y} '#{escape_text(full_name)}'"
8277

83-
current_y += name_text_height + spacing
78+
current_y += name_pointsize * 1.2 + spacing
8479

8580
# Add username
8681
c.pointsize username_pointsize.to_s
8782
c.draw "text 0,#{current_y} '#{escape_text(username)}'"
8883

89-
current_y += username_text_height + spacing
84+
current_y += username_pointsize * 1.2 + spacing
9085

9186
# Add tags if present
9287
if tag_text.present?
@@ -96,67 +91,60 @@ def generate
9691
end
9792
end
9893

99-
# Save the generated image
94+
# Save the image to temp directory
10095
image.write(output_path)
101-
output_path
96+
97+
# Upload to DigitalOcean Spaces
98+
og_image_key = "og_images/#{@user.username}_og.png"
99+
spaces_url = @spaces_service.upload_file_from_path(og_image_key, output_path)
100+
101+
# Update user's og_image_url
102+
@user.update_column(:og_image_url, spaces_url) if spaces_url
103+
104+
spaces_url
102105
rescue StandardError => e
103106
Rails.logger.error("Failed to generate OG image for user #{@user.id}: #{e.message}")
104-
nil # Return nil to indicate failure without raising an exception
107+
nil
108+
ensure
109+
FileUtils.remove_entry(temp_dir) if temp_dir && File.exist?(temp_dir)
105110
end
106111
end
107112

113+
private
114+
108115
def valid_image_url?(url)
109116
return true if url.start_with?('https://linkarooie.syd1.digitaloceanspaces.com/')
110117
return false if url.blank?
111118

112-
begin
113-
uri = URI.parse(url)
114-
return false unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
115-
return false if uri.host.nil?
116-
117-
response = fetch_head(uri)
118-
return response.is_a?(Net::HTTPSuccess) && response['Content-Type'].to_s.start_with?('image/')
119-
rescue URI::InvalidURIError, SocketError, Errno::ECONNREFUSED, Net::OpenTimeout, OpenSSL::SSL::SSLError => e
120-
Rails.logger.error("Invalid or unreachable URL: #{url}. Error: #{e.message}.")
121-
false
119+
uri = URI.parse(url)
120+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
121+
http.head(uri.path)
122122
end
123+
124+
response.is_a?(Net::HTTPSuccess) && response['Content-Type'].to_s.start_with?('image/')
125+
rescue StandardError => e
126+
Rails.logger.error("Error validating image URL: #{e.message}")
127+
false
123128
end
124129

125130
def download_image(url)
126-
return MiniMagick::Image.open(url) if url.start_with?('https://linkarooie.syd1.digitaloceanspaces.com/')
127-
128-
uri = URI.parse(url)
129-
response = Net::HTTP.get_response(uri)
130-
131-
if response.is_a?(Net::HTTPSuccess)
132-
content_type = response['Content-Type']
133-
134-
if content_type.to_s.start_with?('image/')
131+
if url.start_with?('https://linkarooie.syd1.digitaloceanspaces.com/')
132+
MiniMagick::Image.open(url)
133+
else
134+
response = Net::HTTP.get_response(URI.parse(url))
135+
136+
if response.is_a?(Net::HTTPSuccess) && response['Content-Type'].to_s.start_with?('image/')
135137
MiniMagick::Image.read(response.body)
136138
else
137-
handle_invalid_image("URL does not point to an image: #{url}. Content-Type: #{content_type}.")
139+
MiniMagick::Image.open(Rails.root.join('public', 'avatars', 'default_avatar.jpg'))
138140
end
139-
else
140-
handle_invalid_image("Failed to download image from URL: #{url}. HTTP Error: #{response.code} #{response.message}.")
141141
end
142142
rescue StandardError => e
143-
handle_invalid_image("Failed to download image from URL: #{url}. Error: #{e.message}.")
144-
end
145-
146-
private
147-
148-
def fetch_head(uri)
149-
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 5, read_timeout: 5) do |http|
150-
http.request_head(uri.path)
151-
end
152-
end
153-
154-
def handle_invalid_image(error_message)
155-
Rails.logger.error(error_message)
143+
Rails.logger.error("Failed to download image: #{e.message}")
156144
MiniMagick::Image.open(Rails.root.join('public', 'avatars', 'default_avatar.jpg'))
157145
end
158146

159147
def escape_text(text)
160-
text.gsub("'", "\\\\'")
148+
text.gsub("\\", "\\\\\\").gsub("'", "\\\\'")
161149
end
162150
end
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class AddOgImageUrlToUsers < ActiveRecord::Migration[7.1]
2+
def change
3+
add_column :users, :og_image_url, :string
4+
end
5+
end

db/schema.rb

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
require 'rails_helper'
2+
3+
RSpec.describe OpenGraphImageGenerator do
4+
let(:user) { create(:user, username: 'testuser', full_name: 'Test User', tags: ['ruby', 'rails'].to_json) }
5+
let(:spaces_service) { instance_double(DigitalOceanSpacesService) }
6+
let(:spaces_url) { 'https://linkarooie.syd1.digitaloceanspaces.com/og_images/testuser_og.png' }
7+
8+
before do
9+
allow(DigitalOceanSpacesService).to receive(:new).and_return(spaces_service)
10+
allow(spaces_service).to receive(:upload_file_from_path)
11+
.with("og_images/#{user.username}_og.png", anything)
12+
.and_return(spaces_url)
13+
end
14+
15+
describe '#generate' do
16+
it 'generates and uploads the image to Spaces' do
17+
result = described_class.new(user).generate
18+
expect(result).to eq(spaces_url)
19+
expect(user.reload.og_image_url).to eq(spaces_url)
20+
end
21+
22+
context 'with different avatar sources' do
23+
it 'handles Spaces avatars' do
24+
user.update(avatar: 'https://linkarooie.syd1.digitaloceanspaces.com/avatars/test.png')
25+
expect(spaces_service).to receive(:upload_file_from_path)
26+
.with("og_images/#{user.username}_og.png", anything)
27+
.and_return(spaces_url)
28+
29+
result = described_class.new(user).generate
30+
expect(result).to eq(spaces_url)
31+
end
32+
33+
it 'uses default avatar for invalid URLs' do
34+
user.update(avatar: 'invalid-url')
35+
expect(spaces_service).to receive(:upload_file_from_path)
36+
.with("og_images/#{user.username}_og.png", anything)
37+
.and_return(spaces_url)
38+
39+
result = described_class.new(user).generate
40+
expect(result).to eq(spaces_url)
41+
end
42+
end
43+
44+
context 'when errors occur' do
45+
it 'handles upload failures' do
46+
allow(spaces_service).to receive(:upload_file_from_path).and_return(nil)
47+
result = described_class.new(user).generate
48+
expect(result).to be_nil
49+
expect(user.reload.og_image_url).to be_nil
50+
end
51+
52+
it 'logs errors and returns nil' do
53+
allow(spaces_service).to receive(:upload_file_from_path)
54+
.and_raise(StandardError.new("Upload failed"))
55+
56+
expect(Rails.logger).to receive(:error)
57+
.with("Failed to generate OG image for user #{user.id}: Upload failed")
58+
59+
result = described_class.new(user).generate
60+
expect(result).to be_nil
61+
end
62+
end
63+
end
64+
end

0 commit comments

Comments
 (0)