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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- 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/assets/stylesheets/railspress/admin/layout.css +88 -0
- data/app/assets/stylesheets/railspress/admin/responsive.css +15 -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 +46 -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 +11 -0
- data/lib/railspress/version.rb +1 -1
- data/lib/railspress.rb +49 -1
- 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
|
-
|
|
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 %>
|