-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathog_controller.cr
More file actions
139 lines (118 loc) · 5.2 KB
/
Copy pathog_controller.cr
File metadata and controls
139 lines (118 loc) · 5.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
require "kemal"
require "../services/tokei_service"
require "../services/og_image_service"
require "../models/analysis"
module Tokei::Api::Controllers
class OgController
CACHE_TTL = (ENV["OG_CACHE_TTL_SECONDS"]? || "21600").to_i64 # 6 hours
PLACEHOLDER_TTL = (ENV["OG_PLACEHOLDER_CACHE_TTL_SECONDS"]? || "300").to_i64
PLACEHOLDER_JSON = "{}"
SAFE = /^[A-Za-z0-9._-]+$/
def self.cache_dir : String
base = Tokei::Api::Services::TokeiService::TEMP_DIR_BASE
dir = File.join(base, "og-cache")
Dir.exists?(dir) ? dir : (FileUtils.mkdir_p(dir); dir)
end
def self.rsvg_path : String
return ENV["RSVG_CONVERT_PATH"] if ENV["RSVG_CONVERT_PATH"]?
status = Process.run("which", {"rsvg-convert"}, output: Process::Redirect::Close, error: Process::Redirect::Close)
raise "rsvg-convert not found. Install librsvg." unless status.success?
"rsvg-convert"
end
# Content negotiation helper: query param > Accept header > default png
def self.wants_svg?(env : HTTP::Server::Context) : Bool
fmt = env.params.query["format"]?
return true if fmt && fmt.downcase == "svg"
return false if fmt && fmt.downcase == "png"
accept = env.request.headers["Accept"]?
!!(accept && accept.includes?("image/svg+xml"))
end
private def self.sanitize_cache_key(s : String) : String
s.gsub(/[^A-Za-z0-9._-]+/, "_")
end
private def self.og_data_json(repo_url : String) : String
if analysis = Tokei::Api::Models::Analysis.find_latest_full_by_repo_url(repo_url)
return analysis.result.to_json
end
PLACEHOLDER_JSON
end
private def self.serve_cached_png?(env : HTTP::Server::Context, cache : String) : Bool
return false unless File.exists?(cache)
mtime = File.info(cache).modification_time
return false if (Time.utc - mtime) > CACHE_TTL.seconds
env.response.content_type = "image/png"
env.response.headers["Cache-Control"] = "public, max-age=#{CACHE_TTL}"
env.response.headers["Vary"] = "Accept"
bytes = File.open(cache, "rb", &.getb_to_end)
env.response.content_length = bytes.size
env.response.write bytes
true
end
private def self.serve_og(env : HTTP::Server::Context, cache_key : String, owner : String, repo : String, repo_url : String)
svg_mode = wants_svg?(env)
cache = File.join(cache_dir, "#{cache_key}.png")
if !svg_mode && serve_cached_png?(env, cache)
return ""
end
json = og_data_json(repo_url)
cacheable = json != PLACEHOLDER_JSON
max_age = cacheable ? CACHE_TTL : PLACEHOLDER_TTL
svg = Tokei::Api::Services::OgImageService.generate_svg(owner, repo, json)
if svg_mode
env.response.content_type = "image/svg+xml; charset=utf-8"
env.response.headers["Cache-Control"] = "public, max-age=#{max_age}"
env.response.headers["Vary"] = "Accept"
return svg
end
tmp_svg = File.join(Tokei::Api::Services::TokeiService::TEMP_DIR_BASE, "og-#{Random::Secure.hex(8)}.svg")
tmp_png = File.join(Tokei::Api::Services::TokeiService::TEMP_DIR_BASE, "og-#{Random::Secure.hex(8)}.png")
File.write(tmp_svg, svg)
result = Process.run(rsvg_path, ["-w", "1200", "-h", "630", tmp_svg, "-o", tmp_png],
output: Process::Redirect::Inherit,
error: Process::Redirect::Inherit)
unless result.success? && File.exists?(tmp_png) && File.size(tmp_png) > 0
FileUtils.rm_rf(tmp_svg)
FileUtils.rm_rf(tmp_png)
env.response.status_code = 500
return "failed to render png"
end
if cacheable
FileUtils.mkdir_p(File.dirname(cache)) unless Dir.exists?(File.dirname(cache))
FileUtils.mv(tmp_png, cache)
end
FileUtils.rm_rf(tmp_svg)
env.response.content_type = "image/png"
env.response.headers["Cache-Control"] = "public, max-age=#{max_age}"
env.response.headers["Vary"] = "Accept"
bytes = File.open(cacheable ? cache : tmp_png, "rb", &.getb_to_end)
FileUtils.rm_rf(tmp_png) unless cacheable
env.response.content_length = bytes.size
env.response.write bytes
""
end
def self.setup
# Unified GitHub route (no extension)
get "/og/github/:owner/:repo" do |env|
owner = env.params.url["owner"]
repo = env.params.url["repo"]
halt env, status_code: 400, response: "invalid owner" unless owner.matches?(SAFE)
halt env, status_code: 400, response: "invalid repo" unless repo.matches?(SAFE)
url = "https://github.com/#{owner}/#{repo}"
serve_og(env, "#{owner}--#{repo}", owner, repo, url)
end
# Generic route by repo URL (?url=...)
get "/og" do |env|
repo_url = env.params.query["url"]?
unless repo_url && Tokei::Api::Services::TokeiService.valid_repo_url?(repo_url)
env.response.status_code = 400
next "invalid url"
end
owner_repo = Tokei::Api::Services::TokeiService.extract_github_info(repo_url)
owner = owner_repo ? owner_repo[0] : "repo"
repo = owner_repo ? owner_repo[1] : "analysis"
cache_key = sanitize_cache_key(repo_url)
serve_og(env, cache_key, owner, repo, repo_url)
end
end
end
end