railspress-engine 1.2.1 → 1.3.1

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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/javascripts/railspress/admin.js +54 -0
  3. data/app/assets/stylesheets/railspress/admin/buttons.css +12 -0
  4. data/app/assets/stylesheets/railspress/admin/cards.css +8 -0
  5. data/app/assets/stylesheets/railspress/admin/forms.css +21 -0
  6. data/app/controllers/railspress/admin/agent_bootstrap_keys_controller.rb +109 -0
  7. data/app/controllers/railspress/admin/api_keys_controller.rb +165 -0
  8. data/app/controllers/railspress/admin/base_controller.rb +61 -1
  9. data/app/controllers/railspress/api/v1/agent_key_exchanges_controller.rb +50 -0
  10. data/app/controllers/railspress/api/v1/base_controller.rb +52 -0
  11. data/app/controllers/railspress/api/v1/categories_controller.rb +89 -0
  12. data/app/controllers/railspress/api/v1/concerns/post_serialization.rb +130 -0
  13. data/app/controllers/railspress/api/v1/post_header_image_contexts_controller.rb +158 -0
  14. data/app/controllers/railspress/api/v1/post_header_image_focal_points_controller.rb +74 -0
  15. data/app/controllers/railspress/api/v1/post_header_images_controller.rb +58 -0
  16. data/app/controllers/railspress/api/v1/post_imports_controller.rb +118 -0
  17. data/app/controllers/railspress/api/v1/posts_controller.rb +127 -0
  18. data/app/controllers/railspress/api/v1/prime_controller.rb +78 -0
  19. data/app/controllers/railspress/api/v1/tags_controller.rb +85 -0
  20. data/app/helpers/railspress/admin_helper.rb +19 -0
  21. data/app/models/railspress/agent_bootstrap_key.rb +163 -0
  22. data/app/models/railspress/api_key.rb +157 -0
  23. data/app/models/railspress/post_export_processor.rb +16 -2
  24. data/app/views/railspress/admin/agent_bootstrap_keys/_form.html.erb +25 -0
  25. data/app/views/railspress/admin/agent_bootstrap_keys/new.html.erb +7 -0
  26. data/app/views/railspress/admin/agent_bootstrap_keys/reveal.html.erb +38 -0
  27. data/app/views/railspress/admin/api_keys/_form.html.erb +25 -0
  28. data/app/views/railspress/admin/api_keys/index.html.erb +142 -0
  29. data/app/views/railspress/admin/api_keys/new.html.erb +7 -0
  30. data/app/views/railspress/admin/api_keys/reveal.html.erb +40 -0
  31. data/app/views/railspress/admin/posts/_form.html.erb +1 -1
  32. data/app/views/railspress/admin/posts/_post_row.html.erb +1 -1
  33. data/app/views/railspress/admin/posts/show.html.erb +1 -1
  34. data/app/views/railspress/admin/shared/_copyable_textarea.html.erb +17 -0
  35. data/app/views/railspress/admin/shared/_sidebar.html.erb +14 -0
  36. data/config/routes.rb +33 -0
  37. data/db/migrate/20260415000001_create_railspress_api_keys.rb +40 -0
  38. data/db/migrate/20260415000002_create_railspress_agent_bootstrap_keys.rb +37 -0
  39. data/lib/generators/railspress/install/templates/initializer.rb +16 -0
  40. data/lib/railspress/engine.rb +12 -0
  41. data/lib/railspress/version.rb +1 -1
  42. data/lib/railspress.rb +73 -1
  43. metadata +26 -1
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress
4
+ module Api
5
+ module V1
6
+ module Concerns
7
+ module PostSerialization
8
+ private
9
+
10
+ def serialize_post(post)
11
+ {
12
+ id: post.id,
13
+ title: post.title,
14
+ slug: post.slug,
15
+ status: post.status,
16
+ published_at: post.published_at,
17
+ reading_time: post.reading_time_display,
18
+ meta_title: post.meta_title,
19
+ meta_description: post.meta_description,
20
+ category_id: post.category_id,
21
+ author_id: post.author_id,
22
+ author_display: serialize_author_display(post),
23
+ tag_list: post.tag_list,
24
+ content: post.content&.to_s,
25
+ header_image: serialize_header_image(post),
26
+ header_image_focal_point: serialize_header_image_focal_point(post),
27
+ created_at: post.created_at,
28
+ updated_at: post.updated_at
29
+ }
30
+ end
31
+
32
+ def serialize_header_image(post)
33
+ return nil unless Railspress.post_images_enabled?
34
+ return { attached: false } unless post.header_image.attached?
35
+
36
+ blob = post.header_image.blob
37
+
38
+ {
39
+ attached: true,
40
+ blob_id: blob.id,
41
+ signed_blob_id: blob.signed_id,
42
+ filename: blob.filename.to_s,
43
+ byte_size: blob.byte_size,
44
+ content_type: blob.content_type
45
+ }
46
+ end
47
+
48
+ def serialize_focal_point(focal_point)
49
+ {
50
+ id: focal_point.id,
51
+ attachment_name: focal_point.attachment_name,
52
+ focal_x: focal_point.focal_x.to_f,
53
+ focal_y: focal_point.focal_y.to_f,
54
+ overrides: focal_point.overrides || {}
55
+ }
56
+ end
57
+
58
+ def serialize_header_image_focal_point(post)
59
+ return nil unless Railspress.post_images_enabled? && Railspress.focal_points_enabled?
60
+ return nil unless post.header_image.attached?
61
+
62
+ focal_point = post.header_image_focal_point
63
+ focal_point.save! if focal_point.new_record?
64
+ serialize_focal_point(focal_point)
65
+ end
66
+
67
+ def serialize_header_image_context(post, context_name, context_config: nil)
68
+ context_name = context_name.to_s
69
+ context_config ||= Railspress.image_contexts[context_name.to_sym]
70
+ override = post.image_override(context_name, :header_image)
71
+
72
+ {
73
+ name: context_name,
74
+ label: context_config&.dig(:label) || context_name.humanize,
75
+ aspect: context_config&.dig(:aspect),
76
+ sizes: context_config&.dig(:sizes) || [],
77
+ has_override: post.has_image_override?(context_name, :header_image),
78
+ override: serialize_context_override(override),
79
+ image_css: post.image_css_for(context_name, :header_image),
80
+ image: serialize_context_image(post, context_name, override)
81
+ }
82
+ end
83
+
84
+ def serialize_author_display(post)
85
+ return nil unless Railspress.authors_enabled?
86
+ return nil unless post.author.present?
87
+
88
+ configured_method = Railspress.author_display_method
89
+ if configured_method.present? && post.author.respond_to?(configured_method)
90
+ configured_value = post.author.public_send(configured_method)
91
+ return configured_value if configured_value.present?
92
+ end
93
+
94
+ fallback_method = [ :name, :full_name, :display_name, :email, :email_address ]
95
+ .find { |method| post.author.respond_to?(method) && post.author.public_send(method).present? }
96
+
97
+ fallback_method ? post.author.public_send(fallback_method) : "Author ##{post.author.id || "unknown"}"
98
+ end
99
+
100
+ def serialize_context_override(override)
101
+ return { type: "focal" } if override.blank?
102
+
103
+ override.to_h.deep_stringify_keys
104
+ end
105
+
106
+ def serialize_context_image(post, context_name, override)
107
+ image = post.image_for(context_name, :header_image)
108
+ blob = if image.is_a?(ActiveStorage::Blob)
109
+ image
110
+ elsif image.respond_to?(:blob)
111
+ image.blob
112
+ end
113
+ return nil unless blob.present?
114
+
115
+ override_type = override&.dig(:type) || override&.dig("type")
116
+
117
+ {
118
+ source: override_type == "upload" ? "upload_override" : "header_image",
119
+ blob_id: blob.id,
120
+ signed_blob_id: blob.signed_id,
121
+ filename: blob.filename.to_s,
122
+ byte_size: blob.byte_size,
123
+ content_type: blob.content_type
124
+ }
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress
4
+ module Api
5
+ module V1
6
+ class PostHeaderImageContextsController < BaseController
7
+ include Railspress::Api::V1::Concerns::PostSerialization
8
+
9
+ rescue_from ActionController::BadRequest, with: :render_unprocessable_request
10
+
11
+ before_action :ensure_post_images_enabled!
12
+ before_action :ensure_focal_points_enabled!
13
+ before_action :set_post
14
+ before_action :ensure_header_image_attached!
15
+ before_action :set_context, only: [ :show, :update, :destroy ]
16
+
17
+ def index
18
+ render json: {
19
+ data: available_contexts.map do |context_name, context_config|
20
+ serialize_header_image_context(@post, context_name, context_config: context_config)
21
+ end
22
+ }
23
+ end
24
+
25
+ def show
26
+ render json: { data: serialize_header_image_context(@post, @context_name, context_config: @context_config) }
27
+ end
28
+
29
+ def update
30
+ apply_context_override!
31
+ return if performed?
32
+
33
+ render json: {
34
+ data: serialize_header_image_context(@post, @context_name, context_config: @context_config),
35
+ post: serialize_post(@post.reload)
36
+ }
37
+ rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound
38
+ render_error("Invalid signed blob id.", status: :unprocessable_content)
39
+ end
40
+
41
+ def destroy
42
+ @post.clear_image_override(@context_name, :header_image)
43
+ @post.save!
44
+
45
+ render json: {
46
+ data: serialize_header_image_context(@post, @context_name, context_config: @context_config),
47
+ post: serialize_post(@post.reload)
48
+ }
49
+ end
50
+
51
+ private
52
+
53
+ def ensure_post_images_enabled!
54
+ render_error("Post images are not enabled.", status: :not_found) unless Railspress.post_images_enabled?
55
+ end
56
+
57
+ def ensure_focal_points_enabled!
58
+ render_error("Focal points are not enabled.", status: :not_found) unless Railspress.focal_points_enabled?
59
+ end
60
+
61
+ def set_post
62
+ @post = Railspress::Post.find(params[:post_id])
63
+ end
64
+
65
+ def ensure_header_image_attached!
66
+ render_error("Header image is not attached.", status: :unprocessable_content) unless @post.header_image.attached?
67
+ end
68
+
69
+ def set_context
70
+ @context_name = params[:context].to_s
71
+ @context_config = available_contexts[@context_name.to_sym]
72
+ return if @context_config.present?
73
+
74
+ render_error("Image context not found.", status: :not_found)
75
+ end
76
+
77
+ def available_contexts
78
+ @available_contexts ||= Railspress.image_contexts || {}
79
+ end
80
+
81
+ def apply_context_override!
82
+ override = override_params
83
+
84
+ case override[:type]
85
+ when "focal"
86
+ @post.clear_image_override(@context_name, :header_image)
87
+ when "crop"
88
+ @post.set_image_override(@context_name, {
89
+ "type" => "crop",
90
+ "region" => normalize_crop_region(override[:region])
91
+ }, :header_image)
92
+ when "upload"
93
+ @post.set_image_override(@context_name, {
94
+ "type" => "upload",
95
+ "blob_signed_id" => signed_blob_id_for_upload(override)
96
+ }, :header_image)
97
+ else
98
+ return render_error("Override type must be one of: focal, crop, upload.", status: :unprocessable_content)
99
+ end
100
+
101
+ return if @post.save
102
+
103
+ render_validation_errors(@post.header_image_focal_point)
104
+ end
105
+
106
+ def override_params
107
+ params.require(:override).permit(:type, :signed_blob_id, :blob_signed_id, region: [ :x, :y, :width, :height ])
108
+ end
109
+
110
+ def normalize_crop_region(raw_region)
111
+ unless raw_region.present?
112
+ raise ActionController::BadRequest, "Crop overrides require a region hash."
113
+ end
114
+
115
+ region = raw_region.to_h.transform_values { |value| Float(value) }
116
+ validate_crop_region!(region)
117
+
118
+ {
119
+ "x" => region.fetch("x").to_f,
120
+ "y" => region.fetch("y").to_f,
121
+ "width" => region.fetch("width").to_f,
122
+ "height" => region.fetch("height").to_f
123
+ }
124
+ rescue KeyError
125
+ raise ActionController::BadRequest, "Crop region must include x, y, width, and height."
126
+ rescue ArgumentError
127
+ raise ActionController::BadRequest, "Crop region values must be numeric."
128
+ end
129
+
130
+ def validate_crop_region!(region)
131
+ values = [ region.fetch("x"), region.fetch("y"), region.fetch("width"), region.fetch("height") ]
132
+ if values.any? { |value| value.negative? || value > 1 }
133
+ raise ActionController::BadRequest, "Crop region values must stay within 0..1."
134
+ end
135
+
136
+ if region.fetch("width").zero? || region.fetch("height").zero?
137
+ raise ActionController::BadRequest, "Crop region width and height must be greater than 0."
138
+ end
139
+
140
+ if region.fetch("x") + region.fetch("width") > 1 || region.fetch("y") + region.fetch("height") > 1
141
+ raise ActionController::BadRequest, "Crop region must fit within the source image bounds."
142
+ end
143
+ end
144
+
145
+ def signed_blob_id_for_upload(override)
146
+ signed_blob_id = override[:signed_blob_id].presence || override[:blob_signed_id].presence
147
+ raise ActionController::BadRequest, "Upload overrides require signed_blob_id." if signed_blob_id.blank?
148
+
149
+ ActiveStorage::Blob.find_signed!(signed_blob_id).signed_id
150
+ end
151
+
152
+ def render_unprocessable_request(error)
153
+ render_error(error.message, status: :unprocessable_content)
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress
4
+ module Api
5
+ module V1
6
+ class PostHeaderImageFocalPointsController < BaseController
7
+ include Railspress::Api::V1::Concerns::PostSerialization
8
+
9
+ before_action :ensure_post_images_enabled!
10
+ before_action :ensure_focal_points_enabled!
11
+ before_action :set_post
12
+ before_action :ensure_header_image_attached!
13
+
14
+ def show
15
+ render json: { data: serialize_focal_point(current_focal_point) }
16
+ end
17
+
18
+ def update
19
+ focal_point = current_focal_point
20
+
21
+ if focal_point.update(focal_point_params)
22
+ render json: {
23
+ data: serialize_focal_point(focal_point),
24
+ post: serialize_post(@post.reload)
25
+ }
26
+ else
27
+ render_validation_errors(focal_point)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def ensure_post_images_enabled!
34
+ render_error("Post images are not enabled.", status: :not_found) unless Railspress.post_images_enabled?
35
+ end
36
+
37
+ def ensure_focal_points_enabled!
38
+ render_error("Focal points are not enabled.", status: :not_found) unless Railspress.focal_points_enabled?
39
+ end
40
+
41
+ def set_post
42
+ @post = Railspress::Post.find(params[:post_id])
43
+ end
44
+
45
+ def ensure_header_image_attached!
46
+ render_error("Header image is not attached.", status: :unprocessable_content) unless @post.header_image.attached?
47
+ end
48
+
49
+ def current_focal_point
50
+ focal_point = @post.header_image_focal_point
51
+ focal_point.save! if focal_point.new_record?
52
+ focal_point
53
+ end
54
+
55
+ def focal_point_params
56
+ permitted = params.require(:focal_point).permit(:focal_x, :focal_y, overrides: {})
57
+ permitted[:overrides] = normalize_overrides(permitted[:overrides])
58
+ permitted
59
+ end
60
+
61
+ def normalize_overrides(overrides)
62
+ return {} if overrides.blank?
63
+ return overrides.to_unsafe_h if overrides.is_a?(ActionController::Parameters)
64
+ return overrides if overrides.is_a?(Hash)
65
+ return JSON.parse(overrides) if overrides.is_a?(String)
66
+
67
+ {}
68
+ rescue JSON::ParserError
69
+ {}
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress
4
+ module Api
5
+ module V1
6
+ class PostHeaderImagesController < BaseController
7
+ include Railspress::Api::V1::Concerns::PostSerialization
8
+
9
+ before_action :ensure_post_images_enabled!
10
+ before_action :set_post
11
+
12
+ def show
13
+ return render_error("Header image not found.", status: :not_found) unless @post.header_image.attached?
14
+
15
+ render json: {
16
+ data: {
17
+ post_id: @post.id,
18
+ header_image: serialize_header_image(@post),
19
+ header_image_focal_point: serialize_header_image_focal_point(@post)
20
+ }
21
+ }
22
+ end
23
+
24
+ def update
25
+ image = header_image_param
26
+ return render_error("Either image or signed_blob_id is required.", status: :unprocessable_content) if image.blank?
27
+
28
+ @post.header_image.attach(image)
29
+ render json: { data: serialize_post(@post.reload) }
30
+ rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound
31
+ render_error("Invalid signed blob id.", status: :unprocessable_content)
32
+ end
33
+
34
+ def destroy
35
+ @post.header_image.purge if @post.header_image.attached?
36
+ head :no_content
37
+ end
38
+
39
+ private
40
+
41
+ def ensure_post_images_enabled!
42
+ render_error("Post images are not enabled.", status: :not_found) unless Railspress.post_images_enabled?
43
+ end
44
+
45
+ def set_post
46
+ @post = Railspress::Post.find(params[:post_id])
47
+ end
48
+
49
+ def header_image_param
50
+ return params[:image] if params[:image].present?
51
+ return if params[:signed_blob_id].blank?
52
+
53
+ ActiveStorage::Blob.find_signed!(params[:signed_blob_id])
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress
4
+ module Api
5
+ module V1
6
+ class PostImportsController < BaseController
7
+ SUPPORTED_EXTENSIONS = %w[.md .markdown .txt .zip].freeze
8
+
9
+ before_action :set_import, only: [ :show ]
10
+
11
+ def create
12
+ source = import_source
13
+ return render_error("Either file or signed_blob_id is required.", status: :unprocessable_content) unless source
14
+
15
+ unless supported_file?(source[:filename])
16
+ return render_error("Unsupported import file type. Allowed: .md, .markdown, .txt, .zip.", status: :unprocessable_content)
17
+ end
18
+
19
+ import = Railspress::Import.create!(
20
+ import_type: "posts",
21
+ filename: source[:filename],
22
+ content_type: source[:content_type],
23
+ status: "pending",
24
+ user_id: current_api_key&.owner_id
25
+ )
26
+
27
+ file_path = persist_import_file(import, source)
28
+ Railspress::ImportPostsJob.perform_later(import.id, [ file_path ])
29
+
30
+ render json: { data: serialize_import(import.reload) }, status: :accepted
31
+ rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound
32
+ render_error("Invalid signed blob id.", status: :unprocessable_content)
33
+ rescue => error
34
+ Rails.logger.warn("Failed to create post import via API: #{error.class} #{error.message}")
35
+ render_error("Failed to queue import.", status: :unprocessable_content)
36
+ end
37
+
38
+ def show
39
+ render json: { data: serialize_import(@import) }
40
+ end
41
+
42
+ private
43
+
44
+ def set_import
45
+ @import = Railspress::Import.where(import_type: "posts").find(params[:id])
46
+ end
47
+
48
+ def import_source
49
+ upload = params[:file] || params.dig(:import, :file)
50
+ return upload_source(upload) if upload.present?
51
+
52
+ signed_blob_id = params[:signed_blob_id] || params.dig(:import, :signed_blob_id)
53
+ return nil if signed_blob_id.blank?
54
+
55
+ blob_source(ActiveStorage::Blob.find_signed!(signed_blob_id))
56
+ end
57
+
58
+ def upload_source(upload)
59
+ {
60
+ type: :upload,
61
+ value: upload,
62
+ filename: upload.original_filename,
63
+ content_type: upload.content_type
64
+ }
65
+ end
66
+
67
+ def blob_source(blob)
68
+ {
69
+ type: :blob,
70
+ value: blob,
71
+ filename: blob.filename.to_s,
72
+ content_type: blob.content_type
73
+ }
74
+ end
75
+
76
+ def supported_file?(filename)
77
+ extension = File.extname(filename.to_s).downcase
78
+ SUPPORTED_EXTENSIONS.include?(extension)
79
+ end
80
+
81
+ def persist_import_file(import, source)
82
+ upload_dir = Rails.root.join("tmp", "uploads", "import_#{import.id}")
83
+ FileUtils.mkdir_p(upload_dir)
84
+
85
+ filename = File.basename(source[:filename].to_s)
86
+ destination = upload_dir.join(filename)
87
+
88
+ case source[:type]
89
+ when :upload
90
+ FileUtils.cp(source[:value].path, destination)
91
+ when :blob
92
+ source[:value].open do |file|
93
+ FileUtils.cp(file.path, destination)
94
+ end
95
+ end
96
+
97
+ destination.to_s
98
+ end
99
+
100
+ def serialize_import(import)
101
+ {
102
+ id: import.id,
103
+ import_type: import.import_type,
104
+ filename: import.filename,
105
+ content_type: import.content_type,
106
+ status: import.status,
107
+ total_count: import.total_count,
108
+ success_count: import.success_count,
109
+ error_count: import.error_count,
110
+ error_messages: import.parsed_errors,
111
+ created_at: import.created_at,
112
+ updated_at: import.updated_at
113
+ }
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress
4
+ module Api
5
+ module V1
6
+ class PostsController < BaseController
7
+ include Railspress::Api::V1::Concerns::PostSerialization
8
+
9
+ before_action :set_post, only: [ :show, :update, :destroy ]
10
+
11
+ def index
12
+ posts = Railspress::Post.includes(:category, :tags).sorted_by(sort_column, sort_direction)
13
+ total_count = posts.count
14
+
15
+ posts = posts.offset((page - 1) * per_page).limit(per_page)
16
+
17
+ render json: {
18
+ data: posts.map { |post| serialize_post(post) },
19
+ meta: {
20
+ page: page,
21
+ per: per_page,
22
+ total_count: total_count,
23
+ total_pages: (total_count.to_f / per_page).ceil
24
+ }
25
+ }
26
+ end
27
+
28
+ def show
29
+ render json: { data: serialize_post(@post) }
30
+ end
31
+
32
+ def create
33
+ post = Railspress::Post.new(post_params.except(:header_image_signed_blob_id))
34
+ attach_header_image_from_signed_blob(post, post_params[:header_image_signed_blob_id])
35
+
36
+ if post.save
37
+ render json: { data: serialize_post(post) }, status: :created
38
+ else
39
+ render_validation_errors(post)
40
+ end
41
+ rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound
42
+ post.errors.add(:header_image, "signed blob id is invalid")
43
+ render_validation_errors(post)
44
+ end
45
+
46
+ def update
47
+ @post.assign_attributes(post_params.except(:header_image_signed_blob_id))
48
+ attach_header_image_from_signed_blob(@post, post_params[:header_image_signed_blob_id])
49
+
50
+ if @post.save
51
+ render json: { data: serialize_post(@post) }
52
+ else
53
+ render_validation_errors(@post)
54
+ end
55
+ rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound
56
+ @post.errors.add(:header_image, "signed blob id is invalid")
57
+ render_validation_errors(@post)
58
+ end
59
+
60
+ def destroy
61
+ @post.destroy
62
+ head :no_content
63
+ end
64
+
65
+ private
66
+
67
+ def set_post
68
+ @post = Railspress::Post.find(params[:id])
69
+ end
70
+
71
+ def post_params
72
+ permitted = [
73
+ :title,
74
+ :slug,
75
+ :category_id,
76
+ :content,
77
+ :status,
78
+ :published_at,
79
+ :reading_time,
80
+ :meta_title,
81
+ :meta_description,
82
+ :tag_list
83
+ ]
84
+
85
+ permitted << :author_id if Railspress.authors_enabled?
86
+
87
+ if Railspress.post_images_enabled?
88
+ permitted << :header_image
89
+ permitted << :header_image_signed_blob_id
90
+ permitted << :remove_header_image
91
+
92
+ if Railspress.focal_points_enabled?
93
+ permitted << { header_image_focal_point_attributes: [ :focal_x, :focal_y, { overrides: {} } ] }
94
+ end
95
+ end
96
+
97
+ params.require(:post).permit(*permitted)
98
+ end
99
+
100
+ def page
101
+ [ params.fetch(:page, 1).to_i, 1 ].max
102
+ end
103
+
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
107
+ [ requested, 100 ].min
108
+ end
109
+
110
+ def sort_column
111
+ params[:sort].presence || "created_at"
112
+ end
113
+
114
+ def sort_direction
115
+ params[:direction].presence || "desc"
116
+ end
117
+
118
+ def attach_header_image_from_signed_blob(post, signed_blob_id)
119
+ return if signed_blob_id.blank?
120
+ return unless Railspress.post_images_enabled?
121
+
122
+ post.header_image.attach(ActiveStorage::Blob.find_signed!(signed_blob_id))
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end