railspress-engine 1.4.2 → 1.4.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 31c5fcfdceafda233cf359ad98c33f48ea0275bff7f2b03231ec8c677da4b386
4
- data.tar.gz: 82630b73922aec435c33001b58502c7e269dbfe0a1e11d9f48426efea2790bda
3
+ metadata.gz: 48860d966cd3cdc8832ba51098ea94bf8b7264608105231af1b7871d4576d1c8
4
+ data.tar.gz: e4e9e1631fa9127c0c221e08c081e5280e85a7f7f7f3716814033f722aac921b
5
5
  SHA512:
6
- metadata.gz: 842759409eb11ee54e5ff0195461c2e652d73769b853e3f083592ab59d67f79a5cf4612fb92be2ab15a4d0cca59105edcda6c06912bafc30b4c63052261a0269
7
- data.tar.gz: be4b34483192cd1254c4f7bd58495a4de38ef7a40893ef58b664af2cf8ac20c98f23ab4adf50c87237c590ed8dfddfcd73e1ae4e14a9933ff38202c2eb199468
6
+ metadata.gz: 0523661e660f0d5490b95282ed786669282ea2edfe29a84397fafd10c22e1c599d383a9a40c4b3e2c1e9efa4476a82e24a5236a231c44d27041355986a100803
7
+ data.tar.gz: 699ae2eba634ef60e19f7c18b8f98bbd3470c61dcaa750eeff7d6ece5a07436bd33ab19e37740540f93f3621c3961bb3066abcb650717d1658cbc8b24232a97d
data/README.md CHANGED
@@ -157,6 +157,16 @@ end
157
157
 
158
158
  See [CONFIGURING.md](docs/CONFIGURING.md) for more authentication patterns including Devise integration.
159
159
 
160
+ ## Import Security
161
+
162
+ RailsPress stores imported files inside engine-owned temporary directories.
163
+
164
+ ZIP image references cannot escape their extraction directory.
165
+
166
+ Remote header images accept public HTTP and HTTPS destinations. Private and redirected destinations are rejected.
167
+
168
+ Configure request and worker limits for additional availability protection.
169
+
160
170
  ## Quick Start
161
171
 
162
172
  Access the admin at `/railspress/admin`. From there you can manage posts, entities, and blocks.
@@ -1,6 +1,8 @@
1
1
  module Railspress
2
2
  module Admin
3
3
  class ImportsController < BaseController
4
+ SUPPORTED_IMPORT_EXTENSIONS = %w[.md .markdown .txt .zip].freeze
5
+
4
6
  before_action :validate_import_type, only: [ :show ]
5
7
 
6
8
  def show
@@ -53,11 +55,18 @@ module Railspress
53
55
  FileUtils.mkdir_p(upload_dir)
54
56
 
55
57
  uploaded_files.map do |file|
56
- path = upload_dir.join(file.original_filename)
58
+ path = upload_dir.join(stored_upload_name(file))
57
59
  File.open(path, "wb") { |f| f.write(file.read) }
58
60
  path.to_s
59
61
  end
60
62
  end
63
+
64
+ def stored_upload_name(file)
65
+ extension = File.extname(file.original_filename.to_s).downcase
66
+ extension = "" unless SUPPORTED_IMPORT_EXTENSIONS.include?(extension)
67
+
68
+ "#{SecureRandom.hex(16)}#{extension}"
69
+ end
61
70
  end
62
71
  end
63
72
  end
@@ -9,15 +9,15 @@ module Railspress
9
9
  @direction = params[:direction].presence || "desc"
10
10
 
11
11
  @posts = Post.includes(:category, :tags)
12
- .search(params[:q])
12
+ .rp_search(params[:q])
13
13
  .by_category(params[:category_id])
14
14
  .by_status(params[:status])
15
15
  .sorted_by(@sort, @direction)
16
16
 
17
17
  @total_count = @posts.count
18
18
  @page = [ params[:page].to_i, 1 ].max
19
- @total_pages = (@total_count.to_f / Post::PER_PAGE).ceil
20
- @posts = @posts.page(@page)
19
+ @total_pages = (@total_count.to_f / Post.rp_per_page_count).ceil
20
+ @posts = @posts.rp_page(@page)
21
21
 
22
22
  @categories = Category.ordered
23
23
  end
@@ -102,8 +102,8 @@ module Railspress
102
102
  end
103
103
 
104
104
  def per_page
105
- requested = params.fetch(:per, Railspress::Post.per_page_count).to_i
106
- requested = Railspress::Post.per_page_count if requested <= 0
105
+ requested = params.fetch(:per, Railspress::Post.rp_per_page_count).to_i
106
+ requested = Railspress::Post.rp_per_page_count if requested <= 0
107
107
  [ requested, 100 ].min
108
108
  end
109
109
 
@@ -30,12 +30,15 @@ module Railspress
30
30
  end
31
31
 
32
32
  def cleanup_uploaded_files(file_paths)
33
- tmp_dir = Rails.root.join("tmp").to_s
33
+ tmp_dir = File.realpath(Rails.root.join("tmp"))
34
34
 
35
35
  file_paths.each do |path|
36
- # Only cleanup files in the tmp directory to avoid deleting source files
37
- next unless path.start_with?(tmp_dir)
38
- FileUtils.rm_f(path) if File.exist?(path)
36
+ next unless File.exist?(path)
37
+
38
+ resolved_path = File.realpath(path)
39
+ next unless resolved_path.start_with?("#{tmp_dir}#{File::SEPARATOR}")
40
+
41
+ FileUtils.rm_f(resolved_path)
39
42
  end
40
43
  rescue => e
41
44
  Rails.logger.warn "Failed to cleanup uploaded import files: #{e.message}"
@@ -74,14 +74,22 @@ module Railspress
74
74
  before_save :set_published_at
75
75
  before_save :set_reading_time, if: -> { reading_time.blank? && content.present? }
76
76
 
77
- # Generic scopes (ordered, recent) and pagination (page) provided by Entity concern
77
+ # Generic scopes (ordered, recent) and pagination (rp_page) provided by Entity concern
78
78
  # Post-specific scopes below:
79
79
  scope :published, -> { where(status: :published).where(published_at: ..Time.current) }
80
80
  scope :drafts, -> { where(status: :draft) }
81
81
  scope :scheduled, -> { where(status: :published).where("published_at > ?", Time.current) }
82
82
  scope :live, -> { published } # Alias for semantic clarity
83
83
  scope :by_author, ->(author) { where(author_id: author.id) }
84
- scope :search, ->(query) { where("title ILIKE ?", "%#{query}%") if query.present? }
84
+ scope :rp_search, ->(query) {
85
+ query.present? ? where(arel_table[:title].matches("%#{query}%", nil, false)) : all
86
+ }
87
+ unless respond_to?(:search)
88
+ scope :search, ->(query) {
89
+ Railspress.deprecator.warn("search is deprecated and will be removed in RailsPress 2.0; use rp_search instead")
90
+ rp_search(query)
91
+ }
92
+ end
85
93
  scope :by_category, ->(category_id) { where(category_id: category_id) if category_id.present? }
86
94
  scope :by_status, ->(status) {
87
95
  case status.to_s
@@ -1,8 +1,9 @@
1
1
  require "yaml"
2
2
  require "zip"
3
- require "open-uri"
4
3
  require "fileutils"
5
4
  require "redcarpet"
5
+ require "ssrf_filter"
6
+ require "stringio"
6
7
 
7
8
  module Railspress
8
9
  class PostImportProcessor
@@ -211,29 +212,20 @@ module Railspress
211
212
  filename = File.basename(uri.path)
212
213
  filename = "header_image#{File.extname(uri.path)}" if filename.blank?
213
214
 
214
- downloaded = URI.open(url)
215
+ response = SsrfFilter.get(url, max_redirects: 0)
216
+ response.value
215
217
  post.header_image.attach(
216
- io: downloaded,
218
+ io: StringIO.new(response.body),
217
219
  filename: filename,
218
- content_type: downloaded.content_type
220
+ content_type: response.content_type
219
221
  )
220
- rescue OpenURI::HTTPError, URI::InvalidURIError => e
222
+ rescue SsrfFilter::Error, URI::InvalidURIError, Net::HTTPError => e
221
223
  Rails.logger.warn "Failed to download header image from #{url}: #{e.message}"
222
224
  end
223
225
 
224
226
  def attach_image_from_zip(post, relative_path, source_path, base_dir)
225
- # Resolve relative path from the markdown file's directory or from base_dir
226
- source_dir = File.dirname(source_path)
227
-
228
- # Try relative to the markdown file first
229
- image_path = File.expand_path(relative_path, source_dir)
230
-
231
- # If not found, try relative to base_dir
232
- unless File.exist?(image_path)
233
- image_path = File.expand_path(relative_path, base_dir)
234
- end
235
-
236
- return unless File.exist?(image_path)
227
+ image_path = imported_image_path(relative_path, source_path, base_dir)
228
+ return unless image_path
237
229
 
238
230
  ext = File.extname(image_path).downcase
239
231
  return unless IMAGE_EXTENSIONS.include?(ext)
@@ -253,6 +245,23 @@ module Railspress
253
245
  )
254
246
  end
255
247
 
248
+ def imported_image_path(relative_path, source_path, base_dir)
249
+ import_root = File.realpath(base_dir)
250
+ source_dir = File.dirname(source_path)
251
+
252
+ [ source_dir, import_root ].each do |directory|
253
+ candidate = File.expand_path(relative_path.to_s, directory)
254
+ next unless File.file?(candidate)
255
+
256
+ resolved_candidate = File.realpath(candidate)
257
+ return resolved_candidate if resolved_candidate.start_with?("#{import_root}#{File::SEPARATOR}")
258
+ end
259
+
260
+ nil
261
+ rescue Errno::EACCES, Errno::ENOENT
262
+ nil
263
+ end
264
+
256
265
  def extract_title_from_body(body)
257
266
  # Look for first H1: # Title
258
267
  if match = body.match(/^#\s+(.+)$/)
@@ -2,6 +2,10 @@ module Railspress
2
2
  class Engine < ::Rails::Engine
3
3
  isolate_namespace Railspress
4
4
 
5
+ initializer "railspress.deprecator" do |app|
6
+ app.deprecators[:railspress] = Railspress.deprecator
7
+ end
8
+
5
9
  # Register engine assets with the asset pipeline (Propshaft)
6
10
  initializer "railspress.assets" do |app|
7
11
  if app.config.respond_to?(:assets)
@@ -149,6 +149,22 @@ module Railspress
149
149
  # Default scopes available to all entities
150
150
  scope :ordered, -> { order(created_at: :desc) }
151
151
  scope :recent, -> { ordered.limit(10) }
152
+
153
+ unless respond_to?(:page)
154
+ scope :page, ->(page_number) {
155
+ Railspress.deprecator.warn("page is deprecated and will be removed in RailsPress 2.0; use rp_page instead")
156
+ rp_page(page_number)
157
+ }
158
+ end
159
+
160
+ unless respond_to?(:per_page_count)
161
+ define_singleton_method(:per_page_count) do
162
+ Railspress.deprecator.warn(
163
+ "per_page_count is deprecated and will be removed in RailsPress 2.0; use rp_per_page_count instead"
164
+ )
165
+ rp_per_page_count
166
+ end
167
+ end
152
168
  end
153
169
 
154
170
  # Default pagination - can be overridden in model
@@ -156,13 +172,13 @@ module Railspress
156
172
 
157
173
  class_methods do
158
174
  # Simple pagination for index views
159
- def page(page_number)
175
+ def rp_page(page_number)
160
176
  page_number = [ page_number.to_i, 1 ].max
161
- offset((page_number - 1) * per_page_count).limit(per_page_count)
177
+ offset((page_number - 1) * rp_per_page_count).limit(rp_per_page_count)
162
178
  end
163
179
 
164
180
  # Allow models to override PER_PAGE
165
- def per_page_count
181
+ def rp_per_page_count
166
182
  const_defined?(:PER_PAGE, false) ? const_get(:PER_PAGE) : Railspress::Entity::PER_PAGE
167
183
  end
168
184
 
@@ -1,3 +1,3 @@
1
1
  module Railspress
2
- VERSION = "1.4.2"
2
+ VERSION = "1.4.4"
3
3
  end
data/lib/railspress.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require "railspress/version"
2
+ require "active_support/deprecation"
2
3
  require "railspress/cms_helper"
3
4
  require "railspress/engine"
4
5
  require "railspress/entity"
@@ -195,6 +196,10 @@ module Railspress
195
196
  end
196
197
 
197
198
  class << self
199
+ def deprecator
200
+ @deprecator ||= ActiveSupport::Deprecation.new("2.0", "RailsPress")
201
+ end
202
+
198
203
  def configuration
199
204
  @configuration ||= Configuration.new
200
205
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: railspress-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.2
4
+ version: 1.4.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Avi Flombaum
@@ -85,6 +85,20 @@ dependencies:
85
85
  - - ">="
86
86
  - !ruby/object:Gem::Version
87
87
  version: '3.6'
88
+ - !ruby/object:Gem::Dependency
89
+ name: ssrf_filter
90
+ requirement: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - "~>"
93
+ - !ruby/object:Gem::Version
94
+ version: '1.5'
95
+ type: :runtime
96
+ prerelease: false
97
+ version_requirements: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - "~>"
100
+ - !ruby/object:Gem::Version
101
+ version: '1.5'
88
102
  - !ruby/object:Gem::Dependency
89
103
  name: rspec-rails
90
104
  requirement: !ruby/object:Gem::Requirement
@@ -372,7 +386,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
372
386
  - !ruby/object:Gem::Version
373
387
  version: '0'
374
388
  requirements: []
375
- rubygems_version: 4.0.14
389
+ rubygems_version: 4.0.18
376
390
  specification_version: 4
377
391
  summary: A mountable blog + CMS engine for Rails 8+
378
392
  test_files: []