Skip to content

Commit 7930213

Browse files
authored
Merge pull request #157 from denis1011101/feature/game-share-card
feat: share the game card the way the list shows it
2 parents 568f44b + aad0281 commit 7930213

9 files changed

Lines changed: 571 additions & 30 deletions

File tree

app/controllers/game_share_cards_controller.rb

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,12 @@ class GameShareCardsController < ApplicationController
22
skip_before_action :authenticate_user!, only: :show
33

44
def show
5-
game = Game.includes(:court, :participations).find(params[:game_id])
6-
data = Telegram::Helpers::GameCardRenderer.render_data(game, locale: telegram_locale)
5+
game = Game.includes(:court, :tournament, :participations).find(params[:game_id])
6+
data = Games::ShareCardRenderer.render_data(game, locale: I18n.locale)
77

88
send_data data,
99
filename: "getcourt-game-#{game.id}.png",
1010
type: "image/png",
1111
disposition: "inline"
1212
end
13-
14-
private
15-
16-
def telegram_locale
17-
locale = I18n.locale.to_s
18-
return locale if Telegram::I18n::LOCALES.include?(locale)
19-
20-
default_locale = I18n.default_locale.to_s
21-
Telegram::I18n::LOCALES.include?(default_locale) ? default_locale : Telegram::I18n::DEFAULT_LOCALE
22-
end
2313
end

app/services/games/share_card_renderer.rb

Lines changed: 433 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# frozen_string_literal: true
2+
3+
require "open3"
4+
5+
module Images
6+
# Растеризует наш собственный SVG в PNG. Работу делает отдельный процесс
7+
# (`lib/images/svg_to_png.rb`): в приложении загрузчик SVG закрыт Active Storage,
8+
# и открывать его в веб-процессе, который рядом обрабатывает чужие загрузки, нельзя.
9+
module SvgRasterizer
10+
class Error < StandardError; end
11+
12+
SCRIPT = Rails.root.join("lib/images/svg_to_png.rb").to_s
13+
14+
def self.render_png(svg, dpi:)
15+
out, err, status = Open3.capture3(child_env, RbConfig.ruby, SCRIPT, dpi.to_s, stdin_data: svg, binmode: true)
16+
raise Error, "svg render failed (#{status.exitstatus}): #{err.to_s.lines.last&.strip}" unless status.success?
17+
18+
out
19+
end
20+
21+
# Дочернему процессу нужен ruby-vips из бандла — bundler в родителе уже прописан
22+
# в окружении, но под systemd переменных может не быть, поэтому подстраховываемся.
23+
def self.child_env
24+
{
25+
"BUNDLE_GEMFILE" => ENV["BUNDLE_GEMFILE"].presence || Rails.root.join("Gemfile").to_s,
26+
"RUBYOPT" => [ ENV["RUBYOPT"], "-rbundler/setup" ].compact_blank.join(" ")
27+
}
28+
end
29+
private_class_method :child_env
30+
end
31+
end

app/services/telegram/helpers/game_card_renderer.rb

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@ def render(game, locale: Telegram::I18n::DEFAULT_LOCALE)
1212
png = Tempfile.new([ "game-share-card-", ".png" ], Rails.root.join("tmp"))
1313
png.close
1414

15-
render_image(svg_markup(game, locale: locale)).write_to_file(png.path)
15+
File.binwrite(png.path, render_data(game, locale: locale))
1616
png.path
1717
end
1818

1919
def render_data(game, locale: Telegram::I18n::DEFAULT_LOCALE)
20-
render_image(svg_markup(game, locale: locale)).write_to_buffer(".png")
20+
Images::SvgRasterizer.render_png(svg_markup(game, locale: locale), dpi: 72)
2121
end
2222

2323
private
@@ -130,15 +130,6 @@ def owner_value(game)
130130
def esc(value)
131131
CGI.escapeHTML(value.to_s)
132132
end
133-
134-
def render_image(svg)
135-
load_vips!
136-
Vips::Image.svgload_buffer(svg, dpi: 72)
137-
end
138-
139-
def load_vips!
140-
require "vips" unless defined?(Vips::Image)
141-
end
142133
end
143134
end
144135
end

lib/images/svg_to_png.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# frozen_string_literal: true
2+
3+
# Отдельный процесс: читает SVG из stdin, пишет PNG в stdout.
4+
#
5+
# Внутри приложения этого сделать нельзя. Active Storage глушит загрузчики libvips
6+
# (`Vips.block_untrusted`), чтобы librsvg не разбирал чужие файлы, а снимать блокировку
7+
# на лету — значит открыть окно, в которое соседний поток веб-процесса успеет прогнать
8+
# через тот же загрузчик пользовательскую картинку. Здесь Rails не загружается, блокировки
9+
# нет, и рендерится только наш собственный, заведомо доверенный SVG.
10+
require "vips"
11+
12+
dpi = Integer(ARGV[0] || 72)
13+
svg = $stdin.binmode.read
14+
15+
$stdout.binmode.write(Vips::Image.svgload_buffer(svg, dpi: dpi).write_to_buffer(".png"))

test/controllers/game_share_cards_controller_test.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ class GameShareCardsControllerTest < ActionDispatch::IntegrationTest
44
test "renders generated game share card image" do
55
png_header = "\x89PNG\r\n\x1A\n".b
66

7-
stub_singleton(Telegram::Helpers::GameCardRenderer, :render_data, ->(*) { png_header }) do
7+
stub_singleton(Games::ShareCardRenderer, :render_data, ->(*) { png_header }) do
88
get game_share_card_path(games(:one))
99
end
1010

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
require "test_helper"
2+
3+
class Games::ShareCardRendererTest < ActiveSupport::TestCase
4+
test "card markup repeats the badges, the comment and the free spots of the games list" do
5+
game = games(:feed_upcoming)
6+
# Корт из фикстуры разрешает не любое покрытие, а карточке важны только атрибуты игры.
7+
game.assign_attributes(
8+
kind: "training",
9+
sport: "tennis",
10+
surface: "hard",
11+
environment: "outdoor",
12+
skill_level: "intermediate",
13+
with_coach: true,
14+
recurring: true,
15+
urgent_player_search: true,
16+
comment: "Ракетки и мячи свои"
17+
)
18+
19+
svg = markup_for(game)
20+
21+
assert_includes svg, "Feed Court"
22+
assert_includes svg, "Tennis"
23+
assert_includes svg, "Intermediate"
24+
assert_includes svg, "Hard"
25+
assert_includes svg, "Outdoor"
26+
assert_includes svg, I18n.t("games.badges.training", locale: :en)
27+
assert_includes svg, I18n.t("games.badges.coach", locale: :en)
28+
assert_includes svg, I18n.t("games.badges.weekly", locale: :en)
29+
assert_includes svg, I18n.t("games.badges.player_search", locale: :en)
30+
assert_includes svg, "Ракетки и мячи свои"
31+
assert_includes svg, I18n.t("games.card.spots_left", count: 4, locale: :en)
32+
end
33+
34+
test "weather badge is skipped for indoor games" do
35+
game = games(:feed_upcoming)
36+
game.assign_attributes(environment: "indoor")
37+
38+
called = false
39+
stub_singleton(Weather::GoogleForecast, :for_game, ->(*) { called = true; nil }) do
40+
Games::ShareCardRenderer.send(:svg_markup, game, locale: :en)
41+
end
42+
43+
assert_not called, "indoor game must not go to the forecast"
44+
end
45+
46+
test "renders a png" do
47+
data = markup_free_render(games(:feed_upcoming))
48+
49+
assert_equal "\x89PNG\r\n\x1A\n".b, data[0, 8].b
50+
end
51+
52+
private
53+
54+
def markup_for(game)
55+
stub_singleton(Weather::GoogleForecast, :for_game, nil) do
56+
Games::ShareCardRenderer.send(:svg_markup, game, locale: :en)
57+
end
58+
end
59+
60+
def markup_free_render(game)
61+
stub_singleton(Weather::GoogleForecast, :for_game, nil) do
62+
Games::ShareCardRenderer.render_data(game, locale: :en)
63+
end
64+
end
65+
end
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
require "test_helper"
2+
3+
class Images::SvgRasterizerTest < ActiveSupport::TestCase
4+
SVG = %(<svg width="10" height="4" xmlns="http://www.w3.org/2000/svg"><rect width="10" height="4" fill="#000000"/></svg>).freeze
5+
6+
test "renders svg that this process is not allowed to load itself" do
7+
data = Images::SvgRasterizer.render_png(SVG, dpi: 72)
8+
9+
assert_equal "\x89PNG\r\n\x1A\n".b, data[0, 8].b
10+
end
11+
12+
test "leaves the svg loader blocked in the app process" do
13+
Images::SvgRasterizer.render_png(SVG, dpi: 72)
14+
15+
assert_raises(Vips::Error) { Vips::Image.svgload_buffer(SVG) }
16+
end
17+
18+
test "raises when the child cannot render" do
19+
assert_raises(Images::SvgRasterizer::Error) { Images::SvgRasterizer.render_png("not an svg at all", dpi: 72) }
20+
end
21+
end

test/services/telegram/helpers/game_card_renderer_test.rb

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,7 @@ class Telegram::Helpers::GameCardRendererTest < ActiveSupport::TestCase
2020
stub_singleton(Telegram::Helpers::GameFormatting, :game_title, "Tennis") do
2121
stub_singleton(Telegram::Helpers::GameFormatting, :game_datetime, "2026-04-09 22:00") do
2222
stub_singleton(User, :find_by, user) do
23-
fake_image = Object.new
24-
fake_image.define_singleton_method(:write_to_file) do |output_path|
25-
File.binwrite(output_path, "\x89PNG\r\n\x1A\nfake".b)
26-
end
27-
28-
stub_singleton(Telegram::Helpers::GameCardRenderer, :render_image, fake_image) do
23+
stub_singleton(Images::SvgRasterizer, :render_png, "\x89PNG\r\n\x1A\nfake".b) do
2924
path = Telegram::Helpers::GameCardRenderer.render(game, locale: :en)
3025
end
3126
end

0 commit comments

Comments
 (0)