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.
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BlogVerification
4
+ VERSION = '1.0.0'
5
+ 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: []