このコミットが含まれているのは:
@@ -6,7 +6,7 @@ class PreviewController < ApplicationController
|
|||||||
|
|
||||||
render json: { title: Preview::ThumbnailFetcher.title(params[:url]) }
|
render json: { title: Preview::ThumbnailFetcher.title(params[:url]) }
|
||||||
rescue Preview::UrlSafety::UnsafeUrl => e
|
rescue Preview::UrlSafety::UnsafeUrl => e
|
||||||
render_preview_error(e.message, :unprocessable_entity)
|
render_bad_request(e.message)
|
||||||
rescue Preview::HttpFetcher::FetchTimeout => e
|
rescue Preview::HttpFetcher::FetchTimeout => e
|
||||||
render_preview_error(e.message, :gateway_timeout)
|
render_preview_error(e.message, :gateway_timeout)
|
||||||
rescue Preview::HttpFetcher::FetchFailed => e
|
rescue Preview::HttpFetcher::FetchFailed => e
|
||||||
@@ -20,17 +20,18 @@ class PreviewController < ApplicationController
|
|||||||
image.auto_orient
|
image.auto_orient
|
||||||
image.resize '180x180>'
|
image.resize '180x180>'
|
||||||
image.format 'png'
|
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'
|
send_data image.to_blob, type: 'image/png', disposition: 'inline'
|
||||||
rescue Preview::UrlSafety::UnsafeUrl => e
|
rescue Preview::UrlSafety::UnsafeUrl => e
|
||||||
render_preview_error(e.message, :unprocessable_entity)
|
render_bad_request(e.message)
|
||||||
rescue Preview::Screenshot::Busy => e
|
rescue Preview::HttpFetcher::FetchTimeout => e
|
||||||
render_preview_error(e.message, :too_many_requests)
|
|
||||||
rescue Preview::Screenshot::TimedOut, Preview::HttpFetcher::FetchTimeout => e
|
|
||||||
render_preview_error(e.message, :gateway_timeout)
|
render_preview_error(e.message, :gateway_timeout)
|
||||||
rescue Preview::HttpFetcher::FetchFailed => e
|
rescue Preview::HttpFetcher::FetchFailed => e
|
||||||
render_preview_error(e.message, :bad_gateway)
|
render_preview_error(e.message, :bad_gateway)
|
||||||
rescue Preview::Screenshot::Unavailable, MiniMagick::Error => e
|
rescue Preview::ThumbnailFetcher::GenerationFailed, MiniMagick::Error => e
|
||||||
render_preview_error(e.message, :unprocessable_entity)
|
render_unprocessable_entity(e.message)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -4,18 +4,15 @@ module Preview
|
|||||||
|
|
||||||
def self.fetch(raw_url)
|
def self.fetch(raw_url)
|
||||||
uri, = UrlSafety.validate(raw_url)
|
uri, = UrlSafety.validate(raw_url)
|
||||||
page = nil
|
|
||||||
|
|
||||||
known_url = KnownSiteExtractor.thumbnail_url(uri)
|
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)
|
page = HttpFetcher.fetch(uri.to_s)
|
||||||
metadata = HtmlMetadataExtractor.extract(page)
|
metadata = HtmlMetadataExtractor.extract(page)
|
||||||
image = fetch_image(metadata[:image_url]) if metadata[:image_url]
|
raise GenerationFailed, 'サムネール画像が見つかりませんでした.' if metadata[:image_url].blank?
|
||||||
end
|
|
||||||
|
|
||||||
image || Screenshot.capture(uri.to_s)
|
fetch_image(metadata[:image_url])
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.title(raw_url)
|
def self.title(raw_url)
|
||||||
@@ -25,11 +22,15 @@ module Preview
|
|||||||
|
|
||||||
def self.fetch_image(url)
|
def self.fetch_image(url)
|
||||||
response = HttpFetcher.fetch(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
|
response.body
|
||||||
|
rescue HttpFetcher::FetchTimeout
|
||||||
|
raise
|
||||||
rescue HttpFetcher::FetchFailed
|
rescue HttpFetcher::FetchFailed
|
||||||
nil
|
raise GenerationFailed, 'サムネール画像を取得できませんでした.'
|
||||||
end
|
end
|
||||||
|
|
||||||
private_class_method :fetch_image
|
private_class_method :fetch_image
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ module Preview
|
|||||||
raise UnsafeUrl, 'http または https の URL を指定してください.'
|
raise UnsafeUrl, 'http または https の URL を指定してください.'
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
value = "https://#{ value }"
|
value = "http://#{ value }"
|
||||||
end
|
end
|
||||||
uri = URI.parse(value)
|
uri = URI.parse(value)
|
||||||
|
|
||||||
|
|||||||
-1102
ファイル差分が大きすぎるため省略します
差分を読込み
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
})
|
|
||||||
新しい課題から参照
ユーザをブロックする