railspress-engine 1.2.0 → 1.3.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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/app/assets/javascripts/railspress/admin.js +54 -0
  4. data/app/assets/stylesheets/railspress/admin/buttons.css +12 -0
  5. data/app/assets/stylesheets/railspress/admin/cards.css +8 -0
  6. data/app/assets/stylesheets/railspress/admin/forms.css +21 -0
  7. data/app/assets/stylesheets/railspress/admin/layout.css +88 -0
  8. data/app/assets/stylesheets/railspress/admin/responsive.css +15 -0
  9. data/app/controllers/railspress/admin/agent_bootstrap_keys_controller.rb +109 -0
  10. data/app/controllers/railspress/admin/api_keys_controller.rb +165 -0
  11. data/app/controllers/railspress/admin/base_controller.rb +61 -1
  12. data/app/controllers/railspress/api/v1/agent_key_exchanges_controller.rb +50 -0
  13. data/app/controllers/railspress/api/v1/base_controller.rb +52 -0
  14. data/app/controllers/railspress/api/v1/categories_controller.rb +89 -0
  15. data/app/controllers/railspress/api/v1/concerns/post_serialization.rb +130 -0
  16. data/app/controllers/railspress/api/v1/post_header_image_contexts_controller.rb +158 -0
  17. data/app/controllers/railspress/api/v1/post_header_image_focal_points_controller.rb +74 -0
  18. data/app/controllers/railspress/api/v1/post_header_images_controller.rb +58 -0
  19. data/app/controllers/railspress/api/v1/post_imports_controller.rb +118 -0
  20. data/app/controllers/railspress/api/v1/posts_controller.rb +127 -0
  21. data/app/controllers/railspress/api/v1/prime_controller.rb +78 -0
  22. data/app/controllers/railspress/api/v1/tags_controller.rb +85 -0
  23. data/app/helpers/railspress/admin_helper.rb +19 -0
  24. data/app/models/railspress/agent_bootstrap_key.rb +163 -0
  25. data/app/models/railspress/api_key.rb +157 -0
  26. data/app/models/railspress/post_export_processor.rb +16 -2
  27. data/app/views/railspress/admin/agent_bootstrap_keys/_form.html.erb +25 -0
  28. data/app/views/railspress/admin/agent_bootstrap_keys/new.html.erb +7 -0
  29. data/app/views/railspress/admin/agent_bootstrap_keys/reveal.html.erb +38 -0
  30. data/app/views/railspress/admin/api_keys/_form.html.erb +25 -0
  31. data/app/views/railspress/admin/api_keys/index.html.erb +142 -0
  32. data/app/views/railspress/admin/api_keys/new.html.erb +7 -0
  33. data/app/views/railspress/admin/api_keys/reveal.html.erb +40 -0
  34. data/app/views/railspress/admin/posts/_form.html.erb +1 -1
  35. data/app/views/railspress/admin/posts/_post_row.html.erb +1 -1
  36. data/app/views/railspress/admin/posts/show.html.erb +1 -1
  37. data/app/views/railspress/admin/shared/_copyable_textarea.html.erb +17 -0
  38. data/app/views/railspress/admin/shared/_sidebar.html.erb +46 -0
  39. data/config/routes.rb +33 -0
  40. data/db/migrate/20260415000001_create_railspress_api_keys.rb +40 -0
  41. data/db/migrate/20260415000002_create_railspress_agent_bootstrap_keys.rb +37 -0
  42. data/lib/generators/railspress/install/templates/initializer.rb +11 -0
  43. data/lib/railspress/version.rb +1 -1
  44. data/lib/railspress.rb +49 -1
  45. metadata +26 -1
@@ -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
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress
4
+ module Api
5
+ module V1
6
+ class PrimeController < BaseController
7
+ def show
8
+ render json: {
9
+ data: {
10
+ service: "Railspress API",
11
+ version: "v1",
12
+ authentication: {
13
+ type: "bearer",
14
+ token_types: [ "api_key", "agent_bootstrap_key" ],
15
+ agent_bootstrap_exchange_endpoint: exchange_api_v1_agent_keys_path
16
+ },
17
+ defaults: {
18
+ post_status: "draft",
19
+ publish_with_explicit_status: true
20
+ },
21
+ capabilities: capabilities,
22
+ endpoints: endpoints,
23
+ key: {
24
+ id: current_api_key.id,
25
+ name: current_api_key.name,
26
+ status: current_api_key.status,
27
+ expires_at: current_api_key.expires_at,
28
+ last_used_at: current_api_key.last_used_at
29
+ },
30
+ server_time: Time.current
31
+ }
32
+ }
33
+ end
34
+
35
+ private
36
+
37
+ def capabilities
38
+ {
39
+ posts: {
40
+ list: true,
41
+ read: true,
42
+ create: true,
43
+ update: true,
44
+ delete: true,
45
+ rich_text_html: true,
46
+ default_status: "draft",
47
+ publish_supported: true
48
+ },
49
+ post_imports: {
50
+ create: true,
51
+ read: true,
52
+ formats: %w[md markdown txt zip]
53
+ },
54
+ post_media: {
55
+ header_image: Railspress.post_images_enabled?,
56
+ focal_points: Railspress.post_images_enabled? && Railspress.focal_points_enabled?,
57
+ context_overrides: Railspress.post_images_enabled? && Railspress.focal_points_enabled?
58
+ },
59
+ taxonomies: {
60
+ categories: true,
61
+ tags: true
62
+ }
63
+ }
64
+ end
65
+
66
+ def endpoints
67
+ {
68
+ prime: api_v1_prime_path,
69
+ posts: api_v1_posts_path,
70
+ post_imports: api_v1_post_imports_path,
71
+ categories: api_v1_categories_path,
72
+ tags: api_v1_tags_path
73
+ }
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress
4
+ module Api
5
+ module V1
6
+ class TagsController < BaseController
7
+ before_action :set_tag, only: [ :show, :update, :destroy ]
8
+
9
+ def index
10
+ tags = Railspress::Tag.ordered
11
+ total_count = tags.count
12
+ tags = tags.offset((page - 1) * per_page).limit(per_page)
13
+
14
+ render json: {
15
+ data: tags.map { |tag| serialize_tag(tag) },
16
+ meta: {
17
+ page: page,
18
+ per: per_page,
19
+ total_count: total_count,
20
+ total_pages: (total_count.to_f / per_page).ceil
21
+ }
22
+ }
23
+ end
24
+
25
+ def show
26
+ render json: { data: serialize_tag(@tag) }
27
+ end
28
+
29
+ def create
30
+ tag = Railspress::Tag.new(tag_params)
31
+
32
+ if tag.save
33
+ render json: { data: serialize_tag(tag) }, status: :created
34
+ else
35
+ render_validation_errors(tag)
36
+ end
37
+ end
38
+
39
+ def update
40
+ if @tag.update(tag_params)
41
+ render json: { data: serialize_tag(@tag) }
42
+ else
43
+ render_validation_errors(@tag)
44
+ end
45
+ end
46
+
47
+ def destroy
48
+ @tag.destroy
49
+ head :no_content
50
+ end
51
+
52
+ private
53
+
54
+ def set_tag
55
+ @tag = Railspress::Tag.find(params[:id])
56
+ end
57
+
58
+ def tag_params
59
+ params.require(:tag).permit(:name, :slug)
60
+ end
61
+
62
+ def page
63
+ [ params.fetch(:page, 1).to_i, 1 ].max
64
+ end
65
+
66
+ def per_page
67
+ requested = params.fetch(:per, 20).to_i
68
+ requested = 20 if requested <= 0
69
+ [ requested, 100 ].min
70
+ end
71
+
72
+ def serialize_tag(tag)
73
+ {
74
+ id: tag.id,
75
+ name: tag.name,
76
+ slug: tag.slug,
77
+ posts_count: tag.posts.count,
78
+ created_at: tag.created_at,
79
+ updated_at: tag.updated_at
80
+ }
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -664,6 +664,25 @@ module Railspress
664
664
  content_tag(:span, text, class: "rp-badge rp-badge--#{status}")
665
665
  end
666
666
 
667
+ # Returns a safe display string for an author record in admin views.
668
+ # Falls back through common attributes if configured display method is unavailable.
669
+ def rp_author_display(author)
670
+ return nil unless author
671
+
672
+ configured_method = Railspress.author_display_method
673
+ if configured_method.present? && author.respond_to?(configured_method)
674
+ configured_value = author.public_send(configured_method)
675
+ return configured_value if configured_value.present?
676
+ end
677
+
678
+ fallback_method = [ :name, :full_name, :display_name, :email, :email_address ]
679
+ .find { |method| author.respond_to?(method) && author.public_send(method).present? }
680
+
681
+ return author.public_send(fallback_method) if fallback_method
682
+
683
+ "Author ##{author.id || "unknown"}"
684
+ end
685
+
667
686
  # Renders a hint/help text below a form input.
668
687
  # @param text [String] the hint text
669
688
  # @return [String] rendered HTML
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Railspress
6
+ class AgentBootstrapKey < ApplicationRecord
7
+ self.table_name = "railspress_agent_bootstrap_keys"
8
+
9
+ DEFAULT_TTL = 1.hour
10
+
11
+ class ExchangeError < StandardError; end
12
+
13
+ belongs_to :owner, polymorphic: true, optional: true
14
+ belongs_to :created_by, polymorphic: true, optional: true
15
+ belongs_to :revoked_by, polymorphic: true, optional: true
16
+ belongs_to :exchanged_api_key, class_name: "Railspress::ApiKey", optional: true
17
+
18
+ encrypts :secret_ciphertext
19
+
20
+ normalizes :name, with: ->(value) { value.to_s.strip }
21
+ normalizes :token_prefix, with: ->(value) { value.to_s.downcase }
22
+
23
+ validates :name, presence: true, length: { maximum: 120 }
24
+ validates :token_prefix, presence: true, length: { is: 12 }
25
+ validates :token_digest, presence: true, uniqueness: true, length: { is: 64 }
26
+ validates :secret_ciphertext, presence: true
27
+ validates :expires_at, presence: true
28
+ validates :global_uuid, presence: true, uniqueness: true
29
+
30
+ scope :recent, -> { order(created_at: :desc) }
31
+ scope :revoked, -> { where.not(revoked_at: nil) }
32
+ scope :used, -> { where.not(used_at: nil) }
33
+ scope :expired, -> { where("expires_at <= ?", Time.current) }
34
+ scope :active, -> {
35
+ where(revoked_at: nil, used_at: nil)
36
+ .where("expires_at > ?", Time.current)
37
+ }
38
+
39
+ before_validation :set_global_uuid, on: :create
40
+
41
+ class << self
42
+ def issue!(name:, actor:, owner: actor, expires_at: DEFAULT_TTL.from_now)
43
+ prefix = generate_prefix
44
+ raw_secret = generate_secret
45
+ token = build_token(prefix, raw_secret)
46
+
47
+ bootstrap_key = create!(
48
+ name: name,
49
+ token_prefix: prefix,
50
+ token_digest: digest(raw_secret),
51
+ secret_ciphertext: raw_secret,
52
+ owner: owner,
53
+ created_by: actor,
54
+ expires_at: expires_at
55
+ )
56
+
57
+ [ bootstrap_key, token ]
58
+ end
59
+
60
+ def authenticate(raw_token)
61
+ parsed = parse_token(raw_token)
62
+ return nil unless parsed
63
+
64
+ candidate = active.where(token_prefix: parsed[:prefix]).recent.first
65
+ return nil unless candidate
66
+ return nil unless secure_digest_match?(candidate.token_digest, digest(parsed[:secret]))
67
+
68
+ candidate
69
+ end
70
+
71
+ def build_token(prefix, raw_secret)
72
+ "rpb_#{Rails.env}_#{prefix}_#{raw_secret}"
73
+ end
74
+
75
+ def digest(raw_secret)
76
+ OpenSSL::HMAC.hexdigest("SHA256", digest_key, raw_secret.to_s)
77
+ end
78
+
79
+ def parse_token(raw_token)
80
+ return nil if raw_token.blank?
81
+
82
+ match = raw_token.match(/\Arpb_[a-z0-9_]+_([a-f0-9]{12})_([a-f0-9]{64})\z/)
83
+ return nil unless match
84
+
85
+ { prefix: match[1], secret: match[2] }
86
+ end
87
+
88
+ private
89
+
90
+ def digest_key
91
+ Rails.application.secret_key_base.to_s
92
+ end
93
+
94
+ def generate_prefix
95
+ SecureRandom.hex(6)
96
+ end
97
+
98
+ def generate_secret
99
+ SecureRandom.hex(32)
100
+ end
101
+
102
+ def secure_digest_match?(stored_digest, candidate_digest)
103
+ return false if stored_digest.blank? || candidate_digest.blank?
104
+ return false unless stored_digest.bytesize == candidate_digest.bytesize
105
+
106
+ ActiveSupport::SecurityUtils.secure_compare(stored_digest, candidate_digest)
107
+ end
108
+ end
109
+
110
+ def exchange!(ip_address: nil, api_key_name: default_api_key_name, api_key_expires_at: nil)
111
+ raise ExchangeError, "Bootstrap key is not active." unless active?
112
+
113
+ transaction do
114
+ api_key_actor = owner || created_by
115
+ api_key_owner = owner || api_key_actor
116
+ api_key, plain_token = Railspress::ApiKey.issue!(
117
+ name: api_key_name,
118
+ actor: api_key_actor,
119
+ owner: api_key_owner,
120
+ expires_at: api_key_expires_at
121
+ )
122
+
123
+ update!(
124
+ used_at: Time.current,
125
+ used_ip: ip_address,
126
+ exchanged_api_key: api_key
127
+ )
128
+
129
+ [ api_key, plain_token ]
130
+ end
131
+ end
132
+
133
+ def revoke!(actor:, reason: "revoked")
134
+ update!(
135
+ revoked_at: Time.current,
136
+ revoke_reason: reason,
137
+ revoked_by: actor
138
+ )
139
+ end
140
+
141
+ def active?
142
+ revoked_at.nil? && used_at.nil? && expires_at > Time.current
143
+ end
144
+
145
+ def status
146
+ return "revoked" if revoked_at.present?
147
+ return "used" if used_at.present?
148
+ return "expired" if expires_at <= Time.current
149
+
150
+ "active"
151
+ end
152
+
153
+ private
154
+
155
+ def default_api_key_name
156
+ "#{name} (API Key)"
157
+ end
158
+
159
+ def set_global_uuid
160
+ self.global_uuid ||= SecureRandom.uuid
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Railspress
6
+ class ApiKey < ApplicationRecord
7
+ self.table_name = "railspress_api_keys"
8
+
9
+ belongs_to :owner, polymorphic: true, optional: true
10
+ belongs_to :created_by, polymorphic: true, optional: true
11
+ belongs_to :rotated_by, polymorphic: true, optional: true
12
+ belongs_to :revoked_by, polymorphic: true, optional: true
13
+ belongs_to :rotated_from, class_name: "Railspress::ApiKey", optional: true
14
+
15
+ encrypts :secret_ciphertext
16
+
17
+ normalizes :name, with: ->(value) { value.to_s.strip }
18
+ normalizes :token_prefix, with: ->(value) { value.to_s.downcase }
19
+
20
+ validates :name, presence: true, length: { maximum: 120 }
21
+ validates :token_prefix, presence: true, length: { is: 12 }
22
+ validates :token_digest, presence: true, uniqueness: true, length: { is: 64 }
23
+ validates :secret_ciphertext, presence: true
24
+ validates :global_uuid, presence: true, uniqueness: true
25
+
26
+ scope :recent, -> { order(created_at: :desc) }
27
+ scope :revoked, -> { where.not(revoked_at: nil) }
28
+ scope :expired, -> { where("expires_at <= ?", Time.current) }
29
+ scope :active, -> {
30
+ where(revoked_at: nil)
31
+ .where("expires_at IS NULL OR expires_at > ?", Time.current)
32
+ }
33
+
34
+ before_validation :set_global_uuid, on: :create
35
+
36
+ class << self
37
+ def issue!(name:, actor:, owner: actor, expires_at: nil, rotated_from: nil)
38
+ prefix = generate_prefix
39
+ raw_secret = generate_secret
40
+ token = build_token(prefix, raw_secret)
41
+
42
+ api_key = create!(
43
+ name: name,
44
+ token_prefix: prefix,
45
+ token_digest: digest(raw_secret),
46
+ secret_ciphertext: raw_secret,
47
+ owner: owner,
48
+ created_by: actor,
49
+ expires_at: expires_at,
50
+ rotated_from: rotated_from
51
+ )
52
+
53
+ [ api_key, token ]
54
+ end
55
+
56
+ def authenticate(raw_token, ip_address: nil)
57
+ parsed = parse_token(raw_token)
58
+ return nil unless parsed
59
+
60
+ candidate = active.where(token_prefix: parsed[:prefix]).recent.first
61
+ return nil unless candidate
62
+ return nil unless secure_digest_match?(candidate.token_digest, digest(parsed[:secret]))
63
+
64
+ candidate.touch_usage!(ip_address: ip_address)
65
+ candidate
66
+ end
67
+
68
+ def build_token(prefix, raw_secret)
69
+ "rp_#{Rails.env}_#{prefix}_#{raw_secret}"
70
+ end
71
+
72
+ def digest(raw_secret)
73
+ OpenSSL::HMAC.hexdigest("SHA256", digest_key, raw_secret.to_s)
74
+ end
75
+
76
+ def parse_token(raw_token)
77
+ return nil if raw_token.blank?
78
+
79
+ match = raw_token.match(/\Arp_[a-z0-9_]+_([a-f0-9]{12})_([a-f0-9]{64})\z/)
80
+ return nil unless match
81
+
82
+ { prefix: match[1], secret: match[2] }
83
+ end
84
+
85
+ private
86
+
87
+ def digest_key
88
+ Rails.application.secret_key_base.to_s
89
+ end
90
+
91
+ def generate_prefix
92
+ SecureRandom.hex(6)
93
+ end
94
+
95
+ def generate_secret
96
+ SecureRandom.hex(32)
97
+ end
98
+
99
+ def secure_digest_match?(stored_digest, candidate_digest)
100
+ return false if stored_digest.blank? || candidate_digest.blank?
101
+ return false unless stored_digest.bytesize == candidate_digest.bytesize
102
+
103
+ ActiveSupport::SecurityUtils.secure_compare(stored_digest, candidate_digest)
104
+ end
105
+ end
106
+
107
+ def revoke!(actor:, reason: "revoked")
108
+ update!(
109
+ revoked_at: Time.current,
110
+ revoke_reason: reason,
111
+ revoked_by: actor
112
+ )
113
+ end
114
+
115
+ def rotate!(actor:, name: self.name, expires_at: self.expires_at)
116
+ transaction do
117
+ replacement_key, plain_token = self.class.issue!(
118
+ name: name,
119
+ actor: actor,
120
+ owner: owner,
121
+ expires_at: expires_at,
122
+ rotated_from: self
123
+ )
124
+
125
+ update!(
126
+ revoked_at: Time.current,
127
+ revoke_reason: "rotated",
128
+ revoked_by: actor,
129
+ rotated_by: actor
130
+ )
131
+
132
+ [ replacement_key, plain_token ]
133
+ end
134
+ end
135
+
136
+ def active?
137
+ revoked_at.nil? && (expires_at.nil? || expires_at > Time.current)
138
+ end
139
+
140
+ def status
141
+ return "revoked" if revoked_at.present?
142
+ return "expired" if expires_at.present? && expires_at <= Time.current
143
+
144
+ "active"
145
+ end
146
+
147
+ def touch_usage!(ip_address: nil)
148
+ update_columns(last_used_at: Time.current, last_used_ip: ip_address, updated_at: Time.current)
149
+ end
150
+
151
+ private
152
+
153
+ def set_global_uuid
154
+ self.global_uuid ||= SecureRandom.uuid
155
+ end
156
+ end
157
+ end
@@ -85,8 +85,7 @@ module Railspress
85
85
  fm["meta_description"] = post.meta_description if post.meta_description.present?
86
86
 
87
87
  if Railspress.authors_enabled? && post.respond_to?(:author) && post.author.present?
88
- display_method = Railspress.author_display_method
89
- fm["author"] = post.author.public_send(display_method)
88
+ fm["author"] = author_display_for_export(post.author)
90
89
  end
91
90
 
92
91
  if post.header_image.attached?
@@ -158,5 +157,20 @@ module Railspress
158
157
  zipfile.add(entry_name, file)
159
158
  end
160
159
  end
160
+
161
+ def author_display_for_export(author)
162
+ display_method = Railspress.author_display_method
163
+ if display_method.present? && author.respond_to?(display_method)
164
+ configured_value = author.public_send(display_method)
165
+ return configured_value if configured_value.present?
166
+ end
167
+
168
+ fallback_method = [ :name, :full_name, :display_name, :email, :email_address ]
169
+ .find { |method| author.respond_to?(method) && author.public_send(method).present? }
170
+
171
+ return author.public_send(fallback_method) if fallback_method
172
+
173
+ "Author ##{author.id || "unknown"}"
174
+ end
161
175
  end
162
176
  end
@@ -0,0 +1,25 @@
1
+ <%= form_with model: [:admin, agent_bootstrap_key], class: "rp-form rp-form--narrow" do |form| %>
2
+ <%= rp_form_errors(agent_bootstrap_key) %>
3
+
4
+ <%= rp_hint("Bootstrap keys are one-time exchange tokens for agents. They default to a 1-hour expiration.") %>
5
+
6
+ <%= rp_string_field(
7
+ form,
8
+ :name,
9
+ primary: true,
10
+ required: true,
11
+ label: "Bootstrap Key Name",
12
+ placeholder: "e.g., Claude Code",
13
+ hint: "Use a descriptive name so you can identify this bootstrap key later."
14
+ ) %>
15
+
16
+ <%= rp_datetime_field(
17
+ form,
18
+ :expires_at,
19
+ label: "Expiration",
20
+ hint: "Defaults to 1 hour from now. Set a custom date/time to override.",
21
+ step: 60
22
+ ) %>
23
+
24
+ <%= rp_form_actions(form, admin_api_keys_path, submit_text: "Create Agent Bootstrap Key") %>
25
+ <% end %>
@@ -0,0 +1,7 @@
1
+ <% content_for :title, "New Agent Key" %>
2
+
3
+ <h1 class="rp-page-title rp-page-title--standalone">New Agent Key</h1>
4
+
5
+ <div class="rp-card rp-card--padded">
6
+ <%= render "form", agent_bootstrap_key: @agent_bootstrap_key %>
7
+ </div>