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,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
|
-
|
|
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,38 @@
|
|
|
1
|
+
<% content_for :title, "Agent Bootstrap Key Created" %>
|
|
2
|
+
|
|
3
|
+
<h1 class="rp-page-title rp-page-title--standalone">Agent Bootstrap Key Created</h1>
|
|
4
|
+
|
|
5
|
+
<div class="rp-card rp-card--padded">
|
|
6
|
+
<div class="rp-form rp-form--spacious">
|
|
7
|
+
<p class="rp-hint">
|
|
8
|
+
Copy this bootstrap key now. It is one-time use and will not be shown again.
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
<%= render "railspress/admin/shared/copyable_textarea",
|
|
12
|
+
id: "rp-bootstrap-key-token",
|
|
13
|
+
label: "Bootstrap Token",
|
|
14
|
+
value: @plain_bootstrap_token,
|
|
15
|
+
rows: 4,
|
|
16
|
+
hint: "Use this once at #{exchange_api_v1_agent_keys_path} to mint a real API key.",
|
|
17
|
+
button_text: "Copy Bootstrap Token" %>
|
|
18
|
+
|
|
19
|
+
<%= render "railspress/admin/shared/copyable_textarea",
|
|
20
|
+
id: "rp-bootstrap-quick-start",
|
|
21
|
+
label: "Bootstrap Quick Start",
|
|
22
|
+
value: @bootstrap_quick_start,
|
|
23
|
+
rows: 6,
|
|
24
|
+
button_text: "Copy Quick Start" %>
|
|
25
|
+
|
|
26
|
+
<%= render "railspress/admin/shared/copyable_textarea",
|
|
27
|
+
id: "rp-bootstrap-instructions",
|
|
28
|
+
label: "Agent Setup Instructions",
|
|
29
|
+
value: @bootstrap_instructions,
|
|
30
|
+
rows: 15,
|
|
31
|
+
button_text: "Copy Instructions" %>
|
|
32
|
+
|
|
33
|
+
<div class="rp-form-actions">
|
|
34
|
+
<%= link_to "Create Another Bootstrap Key", new_admin_agent_bootstrap_key_path, class: "rp-btn rp-btn--primary" %>
|
|
35
|
+
<%= link_to "Back to Agents & API", admin_api_keys_path, class: "rp-btn rp-btn--secondary" %>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<%= form_with model: [:admin, api_key], class: "rp-form rp-form--narrow" do |form| %>
|
|
2
|
+
<%= rp_form_errors(api_key) %>
|
|
3
|
+
|
|
4
|
+
<%= rp_hint("API keys currently grant full access to API resources that are exposed in this version.") %>
|
|
5
|
+
|
|
6
|
+
<%= rp_string_field(
|
|
7
|
+
form,
|
|
8
|
+
:name,
|
|
9
|
+
primary: true,
|
|
10
|
+
required: true,
|
|
11
|
+
label: "API Key Name",
|
|
12
|
+
placeholder: "e.g., Production Sync Worker",
|
|
13
|
+
hint: "Use a descriptive name so you can identify this key later."
|
|
14
|
+
) %>
|
|
15
|
+
|
|
16
|
+
<%= rp_datetime_field(
|
|
17
|
+
form,
|
|
18
|
+
:expires_at,
|
|
19
|
+
label: "Expiration",
|
|
20
|
+
hint: "Default is no expiration. Set a custom date/time if you want this key to expire.",
|
|
21
|
+
step: 60
|
|
22
|
+
) %>
|
|
23
|
+
|
|
24
|
+
<%= rp_form_actions(form, admin_api_keys_path, submit_text: "Create API Key") %>
|
|
25
|
+
<% end %>
|