このコミットが含まれているのは:
2026-07-09 23:33:10 +09:00
コミット 84fe36a405
7個のファイルの変更20行の追加1259行の削除
+8 -7
ファイルの表示
@@ -6,7 +6,7 @@ class PreviewController < ApplicationController
render json: { title: Preview::ThumbnailFetcher.title(params[:url]) }
rescue Preview::UrlSafety::UnsafeUrl => e
render_preview_error(e.message, :unprocessable_entity)
render_bad_request(e.message)
rescue Preview::HttpFetcher::FetchTimeout => e
render_preview_error(e.message, :gateway_timeout)
rescue Preview::HttpFetcher::FetchFailed => e
@@ -20,17 +20,18 @@ class PreviewController < ApplicationController
image.auto_orient
image.resize '180x180>'
image.format 'png'
width, height = image.dimensions
raise Preview::ThumbnailFetcher::GenerationFailed, 'サムネール画像の変換に失敗しました.' if width > 180 || height > 180
send_data image.to_blob, type: 'image/png', disposition: 'inline'
rescue Preview::UrlSafety::UnsafeUrl => e
render_preview_error(e.message, :unprocessable_entity)
rescue Preview::Screenshot::Busy => e
render_preview_error(e.message, :too_many_requests)
rescue Preview::Screenshot::TimedOut, Preview::HttpFetcher::FetchTimeout => e
render_bad_request(e.message)
rescue Preview::HttpFetcher::FetchTimeout => e
render_preview_error(e.message, :gateway_timeout)
rescue Preview::HttpFetcher::FetchFailed => e
render_preview_error(e.message, :bad_gateway)
rescue Preview::Screenshot::Unavailable, MiniMagick::Error => e
render_preview_error(e.message, :unprocessable_entity)
rescue Preview::ThumbnailFetcher::GenerationFailed, MiniMagick::Error => e
render_unprocessable_entity(e.message)
end
private
-64
ファイルの表示
@@ -1,64 +0,0 @@
module Preview
class Screenshot
class Unavailable < StandardError; end
class Busy < Unavailable; end
class TimedOut < Unavailable; end
LIMIT = ENV.fetch('PREVIEW_SCREENSHOT_CONCURRENCY', 2).to_i.clamp(1, 10)
TIMEOUT = ENV.fetch('PREVIEW_SCREENSHOT_TIMEOUT', 20).to_i.clamp(5, 60)
@mutex = Mutex.new
@active = 0
def self.capture(url)
acquire!
tempfile = nil
pid = nil
begin
tempfile = Tempfile.new(['preview-thumbnail', '.png'])
tempfile.close
status = nil
pid = Process.spawn(
'node', Rails.root.join('lib/screenshot.js').to_s,
url, tempfile.path,
out: File::NULL, err: File::NULL, pgroup: true)
Timeout.timeout(TIMEOUT) do
_finished_pid, status = Process.wait2(pid)
end
raise Unavailable, 'スクリーンショットを生成できませんでした.' unless status.success?
raise Unavailable, 'スクリーンショットを生成できませんでした.' unless File.size?(tempfile.path)
File.binread(tempfile.path)
rescue Timeout::Error
terminate(pid)
raise TimedOut, 'スクリーンショット生成が timeout しました.'
ensure
tempfile&.unlink
release!
end
end
def self.acquire!
@mutex.synchronize do
raise Busy, 'スクリーンショット生成が混雑してゐます.' if @active >= LIMIT
@active += 1
end
end
def self.release!
@mutex.synchronize { @active -= 1 }
end
def self.terminate(pid)
return unless pid
Process.kill('TERM', -pid)
Process.wait(pid)
rescue Errno::ESRCH, Errno::ECHILD
nil
end
private_class_method :acquire!, :release!, :terminate
end
end
+11 -10
ファイルの表示
@@ -4,18 +4,15 @@ module Preview
def self.fetch(raw_url)
uri, = UrlSafety.validate(raw_url)
page = nil
known_url = KnownSiteExtractor.thumbnail_url(uri)
image = fetch_image(known_url) if known_url
return fetch_image(known_url) if known_url
unless image
page = HttpFetcher.fetch(uri.to_s)
metadata = HtmlMetadataExtractor.extract(page)
image = fetch_image(metadata[:image_url]) if metadata[:image_url]
end
page = HttpFetcher.fetch(uri.to_s)
metadata = HtmlMetadataExtractor.extract(page)
raise GenerationFailed, 'サムネール画像が見つかりませんでした.' if metadata[:image_url].blank?
image || Screenshot.capture(uri.to_s)
fetch_image(metadata[:image_url])
end
def self.title(raw_url)
@@ -25,11 +22,15 @@ module Preview
def self.fetch_image(url)
response = HttpFetcher.fetch(url)
return unless response.content_type.downcase.start_with?('image/')
unless response.content_type.downcase.start_with?('image/')
raise GenerationFailed, 'サムネール画像が見つかりませんでした.'
end
response.body
rescue HttpFetcher::FetchTimeout
raise
rescue HttpFetcher::FetchFailed
nil
raise GenerationFailed, 'サムネール画像を取得できませんでした.'
end
private_class_method :fetch_image
+1 -1
ファイルの表示
@@ -22,7 +22,7 @@ module Preview
raise UnsafeUrl, 'http または https の URL を指定してください.'
end
else
value = "https://#{ value }"
value = "http://#{ value }"
end
uri = URI.parse(value)
-1102
ファイルの表示
ファイル差分が大きすぎるため省略します 差分を読込み
-15
ファイルの表示
@@ -1,15 +0,0 @@
{
"name": "lib",
"version": "1.0.0",
"main": "screenshot.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"puppeteer": "^24.10.0"
}
}
-60
ファイルの表示
@@ -1,60 +0,0 @@
const puppeteer = require ('puppeteer')
const dns = require ('dns').promises
const net = require ('net')
const forbiddenNetworks = [
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10],
['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12],
['192.0.0.0', 24], ['192.0.2.0', 24], ['192.168.0.0', 16],
['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24],
['224.0.0.0', 4], ['240.0.0.0', 4], ['::', 128], ['::1', 128],
['fc00::', 7], ['fe80::', 10], ['ff00::', 8], ['2001:db8::', 32],
['::ffff:0:0', 96]]
const blockList = new net.BlockList ()
forbiddenNetworks.forEach (([address, prefix]) => {
blockList.addSubnet (address, prefix, net.isIPv6 (address) ? 'ipv6' : 'ipv4')
})
const safeUrl = async rawUrl => {
const url = new URL (rawUrl)
if (!['http:', 'https:'].includes (url.protocol) || url.username || url.password)
return false
const addresses = await dns.lookup (url.hostname, { all: true, verbatim: true })
return addresses.length > 0 && addresses.every (
({ address, family }) => !blockList.check (address, family === 6 ? 'ipv6' : 'ipv4'))
}
void (async () => {
const url = process.argv[2]
const output = process.argv[3]
if (!(await safeUrl (url)))
throw new Error ('Unsafe page URL')
const browser = await puppeteer.launch ({
args: ['--no-sandbox', '--disable-setuid-sandbox'] })
try
{
const page = await browser.newPage ()
await page.setRequestInterception (true)
page.on ('request', request => {
void safeUrl (request.url ())
.then (safe => safe ? request.continue () : request.abort ())
.catch (() => request.abort ())
})
await page.setViewport ({ width: 960, height: 960 })
await page.goto (url, { waitUntil: 'domcontentloaded', timeout: 12000 })
await new Promise (resolve => setTimeout (resolve, 1000))
await page.screenshot ({ path: output })
}
finally
{
await browser.close ()
}
}) ().catch (error => {
console.error (error)
process.exitCode = 1
})