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.
- checksums.yaml +4 -4
- data/app/assets/javascripts/railspress/admin.js +54 -0
- data/app/assets/stylesheets/railspress/admin/buttons.css +12 -0
- data/app/assets/stylesheets/railspress/admin/cards.css +8 -0
- data/app/assets/stylesheets/railspress/admin/forms.css +21 -0
- data/app/controllers/railspress/admin/agent_bootstrap_keys_controller.rb +109 -0
- data/app/controllers/railspress/admin/api_keys_controller.rb +165 -0
- data/app/controllers/railspress/admin/base_controller.rb +61 -1
- data/app/controllers/railspress/api/v1/agent_key_exchanges_controller.rb +50 -0
- data/app/controllers/railspress/api/v1/base_controller.rb +52 -0
- data/app/controllers/railspress/api/v1/categories_controller.rb +89 -0
- data/app/controllers/railspress/api/v1/concerns/post_serialization.rb +130 -0
- data/app/controllers/railspress/api/v1/post_header_image_contexts_controller.rb +158 -0
- data/app/controllers/railspress/api/v1/post_header_image_focal_points_controller.rb +74 -0
- data/app/controllers/railspress/api/v1/post_header_images_controller.rb +58 -0
- data/app/controllers/railspress/api/v1/post_imports_controller.rb +118 -0
- data/app/controllers/railspress/api/v1/posts_controller.rb +127 -0
- data/app/controllers/railspress/api/v1/prime_controller.rb +78 -0
- data/app/controllers/railspress/api/v1/tags_controller.rb +85 -0
- data/app/helpers/railspress/admin_helper.rb +19 -0
- data/app/models/railspress/agent_bootstrap_key.rb +163 -0
- data/app/models/railspress/api_key.rb +157 -0
- data/app/models/railspress/post_export_processor.rb +16 -2
- data/app/views/railspress/admin/agent_bootstrap_keys/_form.html.erb +25 -0
- data/app/views/railspress/admin/agent_bootstrap_keys/new.html.erb +7 -0
- data/app/views/railspress/admin/agent_bootstrap_keys/reveal.html.erb +38 -0
- data/app/views/railspress/admin/api_keys/_form.html.erb +25 -0
- data/app/views/railspress/admin/api_keys/index.html.erb +142 -0
- data/app/views/railspress/admin/api_keys/new.html.erb +7 -0
- data/app/views/railspress/admin/api_keys/reveal.html.erb +40 -0
- data/app/views/railspress/admin/posts/_form.html.erb +1 -1
- data/app/views/railspress/admin/posts/_post_row.html.erb +1 -1
- data/app/views/railspress/admin/posts/show.html.erb +1 -1
- data/app/views/railspress/admin/shared/_copyable_textarea.html.erb +17 -0
- data/app/views/railspress/admin/shared/_sidebar.html.erb +14 -0
- data/config/routes.rb +33 -0
- data/db/migrate/20260415000001_create_railspress_api_keys.rb +40 -0
- data/db/migrate/20260415000002_create_railspress_agent_bootstrap_keys.rb +37 -0
- data/lib/generators/railspress/install/templates/initializer.rb +16 -0
- data/lib/railspress/engine.rb +12 -0
- data/lib/railspress/version.rb +1 -1
- data/lib/railspress.rb +73 -1
- 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
|