blog_verification 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +413 -0
- data/blog_verification.gemspec +40 -0
- data/lib/blog_verification/adapters/base_adapter.rb +30 -0
- data/lib/blog_verification/adapters/devto_adapter.rb +98 -0
- data/lib/blog_verification/adapters/medium_adapter.rb +271 -0
- data/lib/blog_verification/configuration.rb +29 -0
- data/lib/blog_verification/constants/error_codes.rb +65 -0
- data/lib/blog_verification/constants/taxonomy.rb +248 -0
- data/lib/blog_verification/errors.rb +34 -0
- data/lib/blog_verification/rails/railtie.rb +13 -0
- data/lib/blog_verification/rails/validator.rb +79 -0
- data/lib/blog_verification/services/validation_service.rb +468 -0
- data/lib/blog_verification/services/verification_service.rb +274 -0
- data/lib/blog_verification/utils/date_helper.rb +167 -0
- data/lib/blog_verification/utils/normalizer.rb +104 -0
- data/lib/blog_verification/utils/url_security.rb +314 -0
- data/lib/blog_verification/version.rb +5 -0
- data/lib/blog_verification.rb +56 -0
- metadata +143 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'openssl'
|
|
6
|
+
require 'resolv'
|
|
7
|
+
require 'ipaddr'
|
|
8
|
+
require 'json'
|
|
9
|
+
require_relative '../constants/error_codes'
|
|
10
|
+
require_relative '../errors'
|
|
11
|
+
|
|
12
|
+
module BlogVerification
|
|
13
|
+
module Utils
|
|
14
|
+
module UrlSecurity
|
|
15
|
+
ALLOWED_HOSTS = {
|
|
16
|
+
'devto' => ['dev.to', 'www.dev.to'].freeze,
|
|
17
|
+
'medium' => ['medium.com', 'www.medium.com'].freeze
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
PROHIBITED_IPV4_RANGES = [
|
|
21
|
+
IPAddr.new('0.0.0.0/8'),
|
|
22
|
+
IPAddr.new('10.0.0.0/8'),
|
|
23
|
+
IPAddr.new('100.64.0.0/10'),
|
|
24
|
+
IPAddr.new('127.0.0.0/8'),
|
|
25
|
+
IPAddr.new('169.254.0.0/16'),
|
|
26
|
+
IPAddr.new('172.16.0.0/12'),
|
|
27
|
+
IPAddr.new('192.0.0.0/24'),
|
|
28
|
+
IPAddr.new('192.168.0.0/16'),
|
|
29
|
+
IPAddr.new('198.18.0.0/15'),
|
|
30
|
+
IPAddr.new('224.0.0.0/4'),
|
|
31
|
+
IPAddr.new('240.0.0.0/4'),
|
|
32
|
+
IPAddr.new('255.255.255.255/32')
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
PROHIBITED_IPV6_RANGES = [
|
|
36
|
+
IPAddr.new('::/128'),
|
|
37
|
+
IPAddr.new('::1/128'),
|
|
38
|
+
IPAddr.new('fc00::/7'),
|
|
39
|
+
IPAddr.new('fe80::/10'),
|
|
40
|
+
IPAddr.new('ff00::/8')
|
|
41
|
+
].freeze
|
|
42
|
+
|
|
43
|
+
class HttpResponse
|
|
44
|
+
attr_reader :status_code, :body, :headers, :uri
|
|
45
|
+
|
|
46
|
+
def initialize(status_code, body, headers = {}, uri = nil)
|
|
47
|
+
@status_code = status_code.to_i
|
|
48
|
+
@body = body.to_s
|
|
49
|
+
@headers = headers || {}
|
|
50
|
+
@uri = uri
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def ok?
|
|
54
|
+
@status_code >= 200 && @status_code < 300
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def status
|
|
58
|
+
@status_code
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def text
|
|
62
|
+
@body
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def json
|
|
66
|
+
JSON.parse(@body)
|
|
67
|
+
rescue JSON::ParserError => e
|
|
68
|
+
raise UpstreamError.new(
|
|
69
|
+
ErrorCodes::SOURCE_RESPONSE_INVALID,
|
|
70
|
+
"Failed to parse upstream JSON response: #{e.message}"
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
class << self
|
|
76
|
+
def ip_address_safe?(ip_string)
|
|
77
|
+
return false if ip_string.nil? || ip_string.to_s.strip.empty?
|
|
78
|
+
|
|
79
|
+
ip = IPAddr.new(ip_string.to_s.strip)
|
|
80
|
+
if ip.ipv4?
|
|
81
|
+
PROHIBITED_IPV4_RANGES.none? { |range| range.include?(ip) }
|
|
82
|
+
elsif ip.ipv6?
|
|
83
|
+
# Check IPv4-mapped IPv6 address (e.g. ::ffff:192.168.1.1)
|
|
84
|
+
if ip.ipv4_mapped?
|
|
85
|
+
ipv4 = ip.native
|
|
86
|
+
PROHIBITED_IPV4_RANGES.none? { |range| range.include?(ipv4) }
|
|
87
|
+
else
|
|
88
|
+
PROHIBITED_IPV6_RANGES.none? { |range| range.include?(ip) }
|
|
89
|
+
end
|
|
90
|
+
else
|
|
91
|
+
false
|
|
92
|
+
end
|
|
93
|
+
rescue IPAddr::Error
|
|
94
|
+
false
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def host_allowed_for_platform?(hostname, platform)
|
|
98
|
+
return false if hostname.nil? || hostname.to_s.strip.empty?
|
|
99
|
+
|
|
100
|
+
lower_host = hostname.to_s.strip.downcase
|
|
101
|
+
plat = platform.to_s.downcase
|
|
102
|
+
|
|
103
|
+
if plat == 'devto'
|
|
104
|
+
return ALLOWED_HOSTS['devto'].include?(lower_host)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
if plat == 'medium'
|
|
108
|
+
return true if ALLOWED_HOSTS['medium'].include?(lower_host)
|
|
109
|
+
|
|
110
|
+
if lower_host.end_with?('.medium.com')
|
|
111
|
+
subdomain = lower_host[0...-'.medium.com'.length]
|
|
112
|
+
return subdomain.match?(/\A[a-z0-9-]+\z/)
|
|
113
|
+
end
|
|
114
|
+
return false
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
false
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def validate_article_url(url_string, platform)
|
|
121
|
+
raise ValidationError.new(ErrorCodes::INVALID_URL, 'URL cannot be empty.', 'url') if url_string.nil? || url_string.to_s.strip.empty?
|
|
122
|
+
|
|
123
|
+
begin
|
|
124
|
+
parsed_uri = URI.parse(url_string.to_s.strip)
|
|
125
|
+
rescue URI::InvalidURIError
|
|
126
|
+
raise ValidationError.new(ErrorCodes::INVALID_URL, 'Malformed URL provided.', 'url')
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
unless parsed_uri.is_a?(URI::HTTP) || parsed_uri.is_a?(URI::HTTPS)
|
|
130
|
+
raise ValidationError.new(ErrorCodes::INVALID_URL, 'Malformed URL provided.', 'url')
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
require_https = BlogVerification.configuration.require_https
|
|
134
|
+
if require_https && parsed_uri.scheme != 'https'
|
|
135
|
+
raise ValidationError.new(ErrorCodes::INVALID_URL, 'URL must use HTTPS protocol.', 'url')
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
hostname = parsed_uri.host
|
|
139
|
+
unless host_allowed_for_platform?(hostname, platform)
|
|
140
|
+
raise ValidationError.new(
|
|
141
|
+
ErrorCodes::INVALID_URL,
|
|
142
|
+
"URL host '#{hostname}' is not supported for platform '#{platform}'.",
|
|
143
|
+
'url'
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
path = (parsed_uri.path || '').gsub(%r{/+\z}, '')
|
|
148
|
+
segments = path.split('/').reject(&:empty?)
|
|
149
|
+
|
|
150
|
+
if segments.empty?
|
|
151
|
+
raise ValidationError.new(
|
|
152
|
+
ErrorCodes::INVALID_URL,
|
|
153
|
+
'Homepage URLs are not allowed. Provide a specific article URL.',
|
|
154
|
+
'url'
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
plat = platform.to_s.downcase
|
|
159
|
+
if plat == 'devto'
|
|
160
|
+
reserved_dev_paths = %w[t tags top latest search listings podcasts videos enter admin about privacy terms]
|
|
161
|
+
if segments.length < 2 || reserved_dev_paths.include?(segments[0].downcase)
|
|
162
|
+
raise ValidationError.new(
|
|
163
|
+
ErrorCodes::INVALID_URL,
|
|
164
|
+
'Profile or index DEV.to URLs are not allowed. An article slug is required.',
|
|
165
|
+
'url'
|
|
166
|
+
)
|
|
167
|
+
end
|
|
168
|
+
elsif plat == 'medium'
|
|
169
|
+
is_subdomain = hostname.downcase.end_with?('.medium.com') &&
|
|
170
|
+
!ALLOWED_HOSTS['medium'].include?(hostname.downcase)
|
|
171
|
+
reserved_medium_paths = %w[tag topics m search plans creators about policy]
|
|
172
|
+
|
|
173
|
+
if is_subdomain
|
|
174
|
+
if segments.empty? || reserved_medium_paths.include?(segments[0].downcase)
|
|
175
|
+
raise ValidationError.new(
|
|
176
|
+
ErrorCodes::INVALID_URL,
|
|
177
|
+
'Subdomain index URLs are not allowed. An article slug is required.',
|
|
178
|
+
'url'
|
|
179
|
+
)
|
|
180
|
+
end
|
|
181
|
+
else
|
|
182
|
+
if segments.length == 1 && segments[0].start_with?('@')
|
|
183
|
+
raise ValidationError.new(
|
|
184
|
+
ErrorCodes::INVALID_URL,
|
|
185
|
+
'Profile URLs are not allowed. An article slug is required.',
|
|
186
|
+
'url'
|
|
187
|
+
)
|
|
188
|
+
end
|
|
189
|
+
if segments.length < 2 || reserved_medium_paths.include?(segments[0].downcase)
|
|
190
|
+
raise ValidationError.new(
|
|
191
|
+
ErrorCodes::INVALID_URL,
|
|
192
|
+
'Invalid Medium article URL format.',
|
|
193
|
+
'url'
|
|
194
|
+
)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
parsed_uri
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def assert_safe_destination(hostname)
|
|
203
|
+
return if BlogVerification.configuration.skip_dns_check
|
|
204
|
+
|
|
205
|
+
# Disallow explicit IP literals or loopback hostnames
|
|
206
|
+
if hostname == 'localhost' || hostname.start_with?('127.') || hostname == '::1' || hostname == '0.0.0.0'
|
|
207
|
+
raise SSRFError.new('URL targets a loopback or internal address.')
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
begin
|
|
211
|
+
addresses = Resolv.getaddresses(hostname)
|
|
212
|
+
rescue StandardError => e
|
|
213
|
+
raise ValidationError.new(ErrorCodes::INVALID_URL, "Unable to resolve host: #{hostname} (#{e.message})", 'url')
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
if addresses.empty?
|
|
217
|
+
raise ValidationError.new(ErrorCodes::INVALID_URL, "No DNS records found for host: #{hostname}", 'url')
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
addresses.each do |addr|
|
|
221
|
+
unless ip_address_safe?(addr)
|
|
222
|
+
raise SSRFError.new("Resolved destination IP '#{addr}' violates outbound request security policy.")
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Outbound HTTP client preventing SSRF, following redirects securely, and enforcing timeouts
|
|
228
|
+
def safe_fetch(url_string, options = {}, platform = nil)
|
|
229
|
+
current_url = url_string.to_s
|
|
230
|
+
redirect_count = 0
|
|
231
|
+
max_redirects = BlogVerification.configuration.max_redirects
|
|
232
|
+
timeout = BlogVerification.configuration.request_timeout
|
|
233
|
+
|
|
234
|
+
while redirect_count <= max_redirects
|
|
235
|
+
uri = URI.parse(current_url)
|
|
236
|
+
|
|
237
|
+
if platform && !host_allowed_for_platform?(uri.host, platform)
|
|
238
|
+
raise SSRFError.new("Redirect to unapproved host '#{uri.host}' is not permitted.")
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
assert_safe_destination(uri.host)
|
|
242
|
+
|
|
243
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
244
|
+
http.open_timeout = timeout
|
|
245
|
+
http.read_timeout = timeout
|
|
246
|
+
|
|
247
|
+
if uri.scheme == 'https'
|
|
248
|
+
http.use_ssl = true
|
|
249
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
req_method = options[:method] || 'GET'
|
|
253
|
+
request = case req_method.to_s.upcase
|
|
254
|
+
when 'GET' then Net::HTTP::Get.new(uri.request_uri)
|
|
255
|
+
when 'HEAD' then Net::HTTP::Head.new(uri.request_uri)
|
|
256
|
+
else Net::HTTP::Get.new(uri.request_uri)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
request['User-Agent'] = options[:user_agent] || BlogVerification.configuration.user_agent
|
|
260
|
+
request['Accept'] = options[:accept] || 'application/json, text/html; charset=utf-8'
|
|
261
|
+
|
|
262
|
+
if options[:headers].is_a?(Hash)
|
|
263
|
+
options[:headers].each { |k, v| request[k.to_s] = v.to_s }
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
begin
|
|
267
|
+
response = http.request(request)
|
|
268
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Timeout::Error
|
|
269
|
+
raise UpstreamError.new(
|
|
270
|
+
ErrorCodes::SOURCE_TIMEOUT,
|
|
271
|
+
'The source platform did not respond within the allowed time.'
|
|
272
|
+
)
|
|
273
|
+
rescue StandardError => e
|
|
274
|
+
raise UpstreamError.new(
|
|
275
|
+
ErrorCodes::SOURCE_UNAVAILABLE,
|
|
276
|
+
"Upstream network error: #{e.message}"
|
|
277
|
+
)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
status = response.code.to_i
|
|
281
|
+
|
|
282
|
+
# Handle redirects
|
|
283
|
+
if [301, 302, 303, 307, 308].include?(status)
|
|
284
|
+
location = response['location']
|
|
285
|
+
if location.nil? || location.strip.empty?
|
|
286
|
+
raise UpstreamError.new(
|
|
287
|
+
ErrorCodes::SOURCE_RESPONSE_INVALID,
|
|
288
|
+
'Redirect response missing Location header.'
|
|
289
|
+
)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
redirect_count += 1
|
|
293
|
+
if redirect_count > max_redirects
|
|
294
|
+
raise UpstreamError.new(
|
|
295
|
+
ErrorCodes::SOURCE_RESPONSE_INVALID,
|
|
296
|
+
'Too many redirects encountered.'
|
|
297
|
+
)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
current_url = URI.join(current_url, location).to_s
|
|
301
|
+
next
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
headers_hash = {}
|
|
305
|
+
response.each_header { |k, v| headers_hash[k.downcase] = v }
|
|
306
|
+
return HttpResponse.new(status, response.body, headers_hash, uri)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
raise UpstreamError.new(ErrorCodes::SOURCE_RESPONSE_INVALID, 'Exceeded maximum redirect depth.')
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'blog_verification/version'
|
|
4
|
+
require_relative 'blog_verification/configuration'
|
|
5
|
+
require_relative 'blog_verification/errors'
|
|
6
|
+
require_relative 'blog_verification/constants/error_codes'
|
|
7
|
+
require_relative 'blog_verification/constants/taxonomy'
|
|
8
|
+
require_relative 'blog_verification/utils/date_helper'
|
|
9
|
+
require_relative 'blog_verification/utils/normalizer'
|
|
10
|
+
require_relative 'blog_verification/utils/url_security'
|
|
11
|
+
require_relative 'blog_verification/adapters/base_adapter'
|
|
12
|
+
require_relative 'blog_verification/adapters/devto_adapter'
|
|
13
|
+
require_relative 'blog_verification/adapters/medium_adapter'
|
|
14
|
+
require_relative 'blog_verification/services/validation_service'
|
|
15
|
+
require_relative 'blog_verification/services/verification_service'
|
|
16
|
+
|
|
17
|
+
if defined?(Rails::Railtie)
|
|
18
|
+
require_relative 'blog_verification/rails/railtie'
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
module BlogVerification
|
|
22
|
+
class << self
|
|
23
|
+
# Top-level verification method
|
|
24
|
+
#
|
|
25
|
+
# @example Verify DEV Community article
|
|
26
|
+
# result = BlogVerification.verify(
|
|
27
|
+
# platform: 'devto',
|
|
28
|
+
# url: 'https://dev.to/janedoe/building-a-rest-api-with-nodejs-123',
|
|
29
|
+
# username: 'janedoe',
|
|
30
|
+
# published_at: '2026-10-11',
|
|
31
|
+
# tags: ['nodejs', 'api'],
|
|
32
|
+
# topic: 'Backend Development',
|
|
33
|
+
# summary: 'A comprehensive guide to building clean REST APIs...',
|
|
34
|
+
# min_word_count: 500
|
|
35
|
+
# )
|
|
36
|
+
# if result['status'] == 'valid'
|
|
37
|
+
# puts "Article verified successfully!"
|
|
38
|
+
# end
|
|
39
|
+
#
|
|
40
|
+
# @param [Hash] params Input attributes
|
|
41
|
+
# @return [Hash] Structured JSON-compliant result
|
|
42
|
+
def verify(params)
|
|
43
|
+
default_service.verify(params)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Same as verify, but raises BlogVerification::ValidationError on invalid input
|
|
47
|
+
def verify!(params)
|
|
48
|
+
default_service.verify!(params)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Shared default VerificationService instance
|
|
52
|
+
def default_service
|
|
53
|
+
@default_service ||= Services::VerificationService.new
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: blog_verification
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Ankitkumar Singh (thedevankit)
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-10-29 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: nokogiri
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: 1.13.0
|
|
20
|
+
- - "<"
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: '2.0'
|
|
23
|
+
type: :runtime
|
|
24
|
+
prerelease: false
|
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
26
|
+
requirements:
|
|
27
|
+
- - ">="
|
|
28
|
+
- !ruby/object:Gem::Version
|
|
29
|
+
version: 1.13.0
|
|
30
|
+
- - "<"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.0'
|
|
33
|
+
- !ruby/object:Gem::Dependency
|
|
34
|
+
name: bundler
|
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.1'
|
|
40
|
+
type: :development
|
|
41
|
+
prerelease: false
|
|
42
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '2.1'
|
|
47
|
+
- !ruby/object:Gem::Dependency
|
|
48
|
+
name: rake
|
|
49
|
+
requirement: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '13.0'
|
|
54
|
+
type: :development
|
|
55
|
+
prerelease: false
|
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '13.0'
|
|
61
|
+
- !ruby/object:Gem::Dependency
|
|
62
|
+
name: rspec
|
|
63
|
+
requirement: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '3.12'
|
|
68
|
+
type: :development
|
|
69
|
+
prerelease: false
|
|
70
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '3.12'
|
|
75
|
+
- !ruby/object:Gem::Dependency
|
|
76
|
+
name: webmock
|
|
77
|
+
requirement: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '3.18'
|
|
82
|
+
type: :development
|
|
83
|
+
prerelease: false
|
|
84
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '3.18'
|
|
89
|
+
description: Verifies whether a submitted blog URL belongs to the claimed author,
|
|
90
|
+
matches publication date and quarter, meets minimum word length, and matches expected
|
|
91
|
+
topic and summary metadata.
|
|
92
|
+
email:
|
|
93
|
+
- thedevankit@gmail.com
|
|
94
|
+
executables: []
|
|
95
|
+
extensions: []
|
|
96
|
+
extra_rdoc_files: []
|
|
97
|
+
files:
|
|
98
|
+
- LICENSE.txt
|
|
99
|
+
- README.md
|
|
100
|
+
- blog_verification.gemspec
|
|
101
|
+
- lib/blog_verification.rb
|
|
102
|
+
- lib/blog_verification/adapters/base_adapter.rb
|
|
103
|
+
- lib/blog_verification/adapters/devto_adapter.rb
|
|
104
|
+
- lib/blog_verification/adapters/medium_adapter.rb
|
|
105
|
+
- lib/blog_verification/configuration.rb
|
|
106
|
+
- lib/blog_verification/constants/error_codes.rb
|
|
107
|
+
- lib/blog_verification/constants/taxonomy.rb
|
|
108
|
+
- lib/blog_verification/errors.rb
|
|
109
|
+
- lib/blog_verification/rails/railtie.rb
|
|
110
|
+
- lib/blog_verification/rails/validator.rb
|
|
111
|
+
- lib/blog_verification/services/validation_service.rb
|
|
112
|
+
- lib/blog_verification/services/verification_service.rb
|
|
113
|
+
- lib/blog_verification/utils/date_helper.rb
|
|
114
|
+
- lib/blog_verification/utils/normalizer.rb
|
|
115
|
+
- lib/blog_verification/utils/url_security.rb
|
|
116
|
+
- lib/blog_verification/version.rb
|
|
117
|
+
homepage: https://github.com/thedevankit/blog_verification
|
|
118
|
+
licenses:
|
|
119
|
+
- MIT
|
|
120
|
+
metadata:
|
|
121
|
+
homepage_uri: https://github.com/thedevankit/blog_verification
|
|
122
|
+
source_code_uri: https://github.com/thedevankit/blog_verification
|
|
123
|
+
changelog_uri: https://github.com/thedevankit/blog_verification/blob/main/CHANGELOG.md
|
|
124
|
+
post_install_message:
|
|
125
|
+
rdoc_options: []
|
|
126
|
+
require_paths:
|
|
127
|
+
- lib
|
|
128
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
129
|
+
requirements:
|
|
130
|
+
- - ">="
|
|
131
|
+
- !ruby/object:Gem::Version
|
|
132
|
+
version: 2.7.8
|
|
133
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
134
|
+
requirements:
|
|
135
|
+
- - ">="
|
|
136
|
+
- !ruby/object:Gem::Version
|
|
137
|
+
version: '0'
|
|
138
|
+
requirements: []
|
|
139
|
+
rubygems_version: 3.4.19
|
|
140
|
+
signing_key:
|
|
141
|
+
specification_version: 4
|
|
142
|
+
summary: Lightweight blog verification library for Medium and DEV Community
|
|
143
|
+
test_files: []
|