railspress-engine 1.2.1 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/javascripts/railspress/admin.js +54 -0
  3. data/app/assets/stylesheets/railspress/admin/buttons.css +12 -0
  4. data/app/assets/stylesheets/railspress/admin/cards.css +8 -0
  5. data/app/assets/stylesheets/railspress/admin/forms.css +21 -0
  6. data/app/controllers/railspress/admin/agent_bootstrap_keys_controller.rb +109 -0
  7. data/app/controllers/railspress/admin/api_keys_controller.rb +165 -0
  8. data/app/controllers/railspress/admin/base_controller.rb +61 -1
  9. data/app/controllers/railspress/api/v1/agent_key_exchanges_controller.rb +50 -0
  10. data/app/controllers/railspress/api/v1/base_controller.rb +52 -0
  11. data/app/controllers/railspress/api/v1/categories_controller.rb +89 -0
  12. data/app/controllers/railspress/api/v1/concerns/post_serialization.rb +130 -0
  13. data/app/controllers/railspress/api/v1/post_header_image_contexts_controller.rb +158 -0
  14. data/app/controllers/railspress/api/v1/post_header_image_focal_points_controller.rb +74 -0
  15. data/app/controllers/railspress/api/v1/post_header_images_controller.rb +58 -0
  16. data/app/controllers/railspress/api/v1/post_imports_controller.rb +118 -0
  17. data/app/controllers/railspress/api/v1/posts_controller.rb +127 -0
  18. data/app/controllers/railspress/api/v1/prime_controller.rb +78 -0
  19. data/app/controllers/railspress/api/v1/tags_controller.rb +85 -0
  20. data/app/helpers/railspress/admin_helper.rb +19 -0
  21. data/app/models/railspress/agent_bootstrap_key.rb +163 -0
  22. data/app/models/railspress/api_key.rb +157 -0
  23. data/app/models/railspress/post_export_processor.rb +16 -2
  24. data/app/views/railspress/admin/agent_bootstrap_keys/_form.html.erb +25 -0
  25. data/app/views/railspress/admin/agent_bootstrap_keys/new.html.erb +7 -0
  26. data/app/views/railspress/admin/agent_bootstrap_keys/reveal.html.erb +38 -0
  27. data/app/views/railspress/admin/api_keys/_form.html.erb +25 -0
  28. data/app/views/railspress/admin/api_keys/index.html.erb +142 -0
  29. data/app/views/railspress/admin/api_keys/new.html.erb +7 -0
  30. data/app/views/railspress/admin/api_keys/reveal.html.erb +40 -0
  31. data/app/views/railspress/admin/posts/_form.html.erb +1 -1
  32. data/app/views/railspress/admin/posts/_post_row.html.erb +1 -1
  33. data/app/views/railspress/admin/posts/show.html.erb +1 -1
  34. data/app/views/railspress/admin/shared/_copyable_textarea.html.erb +17 -0
  35. data/app/views/railspress/admin/shared/_sidebar.html.erb +14 -0
  36. data/config/routes.rb +33 -0
  37. data/db/migrate/20260415000001_create_railspress_api_keys.rb +40 -0
  38. data/db/migrate/20260415000002_create_railspress_agent_bootstrap_keys.rb +37 -0
  39. data/lib/generators/railspress/install/templates/initializer.rb +16 -0
  40. data/lib/railspress/engine.rb +12 -0
  41. data/lib/railspress/version.rb +1 -1
  42. data/lib/railspress.rb +73 -1
  43. metadata +26 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 620cf1a81acefe35aedfda0894686721073bdad83c405be7b9fcb967df04829d
4
- data.tar.gz: 6ffab1d40c3dade6d4fed057174754d1caecc854e0415d634d49e160ab8bcc3f
3
+ metadata.gz: adb5b3a9ff81defec7ccd844d41afeb903c50aa1d1e44b3c1b871f3c69c19b1c
4
+ data.tar.gz: b54129ab3b358b28f59f058b4d5c73adc4325335f48a818b486f6ccb257bd7ee
5
5
  SHA512:
6
- metadata.gz: dfe7e288c00fefbfbb8d23999bdff5211154ec68e00b79a3e6d7269b957fd99a2d784ea414243399fdfff43a684f635dd3d3be99e05fa31d628341c480e9a3aa
7
- data.tar.gz: 3ff12ea74a82a33ebb1fe504944540e8ddb72dd599f7e85042c27ea144764ab6ba7116aba693cbabb037ebe474d52b23529f8509dd8db04a155289f37701a232
6
+ metadata.gz: a6664f4fbd5ec1bed6a923e86fb601452e28f4dd9c1ef36f45a0fbe66a78581a340b039537e8d6c3c3f902fa73d79eb3dcb478dea842d4f5bc110ac14523b24e
7
+ data.tar.gz: 94ecb2fef2a98448c8470e4ee9bc2b450852719b5c68a7a7dd3533043e979cf2b82a57d61f6a9ebe5f41648defe83d85f493a66499d4fa58179c0ef8ae0990c8
@@ -227,6 +227,59 @@
227
227
  });
228
228
  }
229
229
 
230
+ // ============================================
231
+ // Copy-to-Clipboard Buttons
232
+ // ============================================
233
+
234
+ function initCopyButtons() {
235
+ const copyButtons = document.querySelectorAll('[data-copy-target]');
236
+
237
+ if (!copyButtons.length) return;
238
+
239
+ function copyText(text) {
240
+ if (navigator.clipboard && navigator.clipboard.writeText) {
241
+ return navigator.clipboard.writeText(text);
242
+ }
243
+
244
+ return new Promise(function(resolve, reject) {
245
+ const textarea = document.createElement('textarea');
246
+ textarea.value = text;
247
+ textarea.setAttribute('readonly', '');
248
+ textarea.style.position = 'absolute';
249
+ textarea.style.left = '-9999px';
250
+ document.body.appendChild(textarea);
251
+ textarea.select();
252
+ textarea.setSelectionRange(0, textarea.value.length);
253
+
254
+ try {
255
+ const copied = document.execCommand('copy');
256
+ document.body.removeChild(textarea);
257
+ copied ? resolve() : reject(new Error('Copy failed'));
258
+ } catch (error) {
259
+ document.body.removeChild(textarea);
260
+ reject(error);
261
+ }
262
+ });
263
+ }
264
+
265
+ copyButtons.forEach(function(button) {
266
+ button.addEventListener('click', function() {
267
+ const targetId = button.dataset.copyTarget;
268
+ const source = document.getElementById(targetId);
269
+ if (!source) return;
270
+
271
+ const originalLabel = button.textContent;
272
+ copyText(source.value || source.textContent || '').then(function() {
273
+ button.textContent = 'Copied';
274
+ setTimeout(function() { button.textContent = originalLabel; }, 1500);
275
+ }).catch(function() {
276
+ button.textContent = 'Copy failed';
277
+ setTimeout(function() { button.textContent = originalLabel; }, 1500);
278
+ });
279
+ });
280
+ });
281
+ }
282
+
230
283
  // ============================================
231
284
  // Initialize on DOM Ready
232
285
  // ============================================
@@ -238,6 +291,7 @@
238
291
  initDeleteConfirmation();
239
292
  initFlashMessages();
240
293
  initFormValidation();
294
+ initCopyButtons();
241
295
  }
242
296
 
243
297
  if (document.readyState === 'loading') {
@@ -59,6 +59,18 @@
59
59
  background: #8f3436;
60
60
  }
61
61
 
62
+ .rp-btn--accent {
63
+ background: var(--rp-success-light);
64
+ color: var(--rp-success);
65
+ border-color: rgba(74, 124, 89, 0.28);
66
+ }
67
+
68
+ .rp-btn--accent:hover {
69
+ background: #dbeadf;
70
+ border-color: rgba(74, 124, 89, 0.5);
71
+ color: #365f45;
72
+ }
73
+
62
74
  .rp-btn--text {
63
75
  background: none;
64
76
  border: none;
@@ -14,6 +14,14 @@
14
14
  padding: var(--rp-space-xl);
15
15
  }
16
16
 
17
+ .rp-card--spaced {
18
+ margin-bottom: var(--rp-space-2xl);
19
+ }
20
+
21
+ .rp-card--section + .rp-card--section {
22
+ margin-top: var(--rp-space-2xl);
23
+ }
24
+
17
25
  /* Detail list for show views */
18
26
  .rp-detail-list {
19
27
  padding: var(--rp-space-lg);
@@ -9,6 +9,10 @@
9
9
  flex-direction: column;
10
10
  }
11
11
 
12
+ .rp-form--spacious {
13
+ gap: var(--rp-space-lg);
14
+ }
15
+
12
16
  .rp-form--narrow {
13
17
  max-width: 560px;
14
18
  }
@@ -132,6 +136,23 @@
132
136
  font-size: 0.875rem;
133
137
  }
134
138
 
139
+ .rp-input--code {
140
+ background: var(--rp-sidebar-bg);
141
+ color: var(--rp-sidebar-text-active);
142
+ border-color: var(--rp-sidebar-hover);
143
+ box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18);
144
+ line-height: 1.6;
145
+ }
146
+
147
+ .rp-input--code::placeholder {
148
+ color: var(--rp-sidebar-text);
149
+ }
150
+
151
+ .rp-input--code:focus {
152
+ border-color: var(--rp-primary);
153
+ box-shadow: 0 0 0 3px var(--rp-primary-light), inset 0 1px 2px rgba(0, 0, 0, 0.18);
154
+ }
155
+
135
156
  textarea.rp-input {
136
157
  min-height: 100px;
137
158
  resize: vertical;
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress
4
+ module Admin
5
+ class AgentBootstrapKeysController < BaseController
6
+ DEFAULT_BOOTSTRAP_TTL = 1.hour
7
+
8
+ before_action :ensure_api_enabled!
9
+ before_action :require_api_actor!
10
+ before_action :set_agent_bootstrap_key, only: [ :revoke ]
11
+
12
+ def new
13
+ @agent_bootstrap_key = AgentBootstrapKey.new(expires_at: default_bootstrap_expires_at)
14
+ end
15
+
16
+ def create
17
+ @agent_bootstrap_key, @plain_bootstrap_token = AgentBootstrapKey.issue!(
18
+ name: agent_bootstrap_key_params[:name],
19
+ actor: current_api_actor,
20
+ owner: current_api_actor,
21
+ expires_at: parsed_expires_at || default_bootstrap_expires_at
22
+ )
23
+ set_bootstrap_instructions
24
+
25
+ render :reveal, status: :created
26
+ rescue ActiveRecord::RecordInvalid => e
27
+ @agent_bootstrap_key = e.record
28
+ render :new, status: :unprocessable_content
29
+ end
30
+
31
+ def revoke
32
+ @agent_bootstrap_key.revoke!(
33
+ actor: current_api_actor,
34
+ reason: params[:reason].presence || "revoked"
35
+ )
36
+
37
+ redirect_to admin_api_keys_path, notice: "Agent bootstrap key revoked."
38
+ end
39
+
40
+ private
41
+
42
+ def ensure_api_enabled!
43
+ raise ActionController::RoutingError, "Not Found" unless Railspress.api_enabled?
44
+ end
45
+
46
+ def require_api_actor!
47
+ return if current_api_actor.present?
48
+
49
+ redirect_to admin_root_path, alert: "You must be signed in to manage API keys."
50
+ end
51
+
52
+ def set_agent_bootstrap_key
53
+ @agent_bootstrap_key = AgentBootstrapKey.find(params[:id])
54
+ end
55
+
56
+ def agent_bootstrap_key_params
57
+ params.require(:agent_bootstrap_key).permit(:name, :expires_at)
58
+ end
59
+
60
+ def parsed_expires_at
61
+ value = agent_bootstrap_key_params[:expires_at]
62
+ return nil if value.blank?
63
+
64
+ Time.zone.parse(value)
65
+ rescue ArgumentError
66
+ nil
67
+ end
68
+
69
+ def default_bootstrap_expires_at
70
+ DEFAULT_BOOTSTRAP_TTL.from_now
71
+ end
72
+
73
+ def set_bootstrap_instructions
74
+ base_url = instruction_base_url
75
+
76
+ @bootstrap_quick_start = <<~TEXT
77
+ export RAILSPRESS_BOOTSTRAP_TOKEN="#{@plain_bootstrap_token}"
78
+ export RAILSPRESS_TOKEN=$(curl -s -X POST -H "Authorization: Bearer $RAILSPRESS_BOOTSTRAP_TOKEN" #{base_url}#{exchange_api_v1_agent_keys_path} | ruby -rjson -e 'print JSON.parse(STDIN.read).dig("data","api_key","token")')
79
+ printf '%s\n' "$RAILSPRESS_TOKEN" > ~/.railspress_token
80
+ chmod 600 ~/.railspress_token
81
+ curl -H "Authorization: Bearer $RAILSPRESS_TOKEN" #{base_url}#{api_v1_prime_path}
82
+ TEXT
83
+
84
+ @bootstrap_instructions = <<~TEXT
85
+ I use Railspress for blog publishing. Here's the secure agent setup:
86
+
87
+ Host: #{base_url}
88
+ Bootstrap Token (one-time): #{@plain_bootstrap_token}
89
+ Exchange Endpoint: #{base_url}#{exchange_api_v1_agent_keys_path}
90
+
91
+ 1) Exchange bootstrap for API token:
92
+ export RAILSPRESS_BOOTSTRAP_TOKEN="#{@plain_bootstrap_token}"
93
+ export RAILSPRESS_TOKEN=$(curl -s -X POST -H "Authorization: Bearer $RAILSPRESS_BOOTSTRAP_TOKEN" #{base_url}#{exchange_api_v1_agent_keys_path} | ruby -rjson -e 'print JSON.parse(STDIN.read).dig("data","api_key","token")')
94
+
95
+ 2) Optional local token file:
96
+ printf '%s\n' "$RAILSPRESS_TOKEN" > ~/.railspress_token
97
+ chmod 600 ~/.railspress_token
98
+ export RAILSPRESS_TOKEN="$(cat ~/.railspress_token)"
99
+
100
+ 3) Verify connectivity:
101
+ curl -H "Authorization: Bearer $RAILSPRESS_TOKEN" #{base_url}#{api_v1_prime_path}
102
+
103
+ 4) Create a draft post:
104
+ curl -X POST -H "Authorization: Bearer $RAILSPRESS_TOKEN" -H "Content-Type: application/json" -d '{"post":{"title":"Agent draft","content":"<p>Hello</p>"}}' #{base_url}#{api_v1_posts_path}
105
+ TEXT
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress
4
+ module Admin
5
+ class ApiKeysController < BaseController
6
+ before_action :ensure_api_enabled!
7
+ before_action :require_api_actor!
8
+ before_action :set_api_key, only: [ :rotate, :revoke ]
9
+ before_action :set_generic_agent_instructions, only: [ :index ]
10
+
11
+ def index
12
+ @api_keys = ApiKey.recent
13
+ @agent_bootstrap_keys = AgentBootstrapKey.recent
14
+ end
15
+
16
+ def new
17
+ @api_key = ApiKey.new
18
+ end
19
+
20
+ def create
21
+ @api_key, @plain_token = ApiKey.issue!(
22
+ name: api_key_params[:name],
23
+ actor: current_api_actor,
24
+ owner: current_api_actor,
25
+ expires_at: parsed_expires_at
26
+ )
27
+ set_api_key_instructions
28
+
29
+ render :reveal, status: :created
30
+ rescue ActiveRecord::RecordInvalid => e
31
+ @api_key = e.record
32
+ render :new, status: :unprocessable_content
33
+ end
34
+
35
+ def rotate
36
+ @api_key, @plain_token = @api_key.rotate!(
37
+ actor: current_api_actor,
38
+ expires_at: @api_key.expires_at
39
+ )
40
+ set_api_key_instructions
41
+ render :reveal, status: :created
42
+ end
43
+
44
+ def revoke
45
+ @api_key.revoke!(
46
+ actor: current_api_actor,
47
+ reason: params[:reason].presence || "revoked"
48
+ )
49
+
50
+ redirect_to admin_api_keys_path, notice: "API key revoked."
51
+ end
52
+
53
+ private
54
+
55
+ def ensure_api_enabled!
56
+ raise ActionController::RoutingError, "Not Found" unless Railspress.api_enabled?
57
+ end
58
+
59
+ def require_api_actor!
60
+ return if current_api_actor.present?
61
+
62
+ redirect_to admin_root_path, alert: "You must be signed in to manage API keys."
63
+ end
64
+
65
+ def set_api_key
66
+ @api_key = ApiKey.find(params[:id])
67
+ end
68
+
69
+ def api_key_params
70
+ params.require(:api_key).permit(:name, :expires_at)
71
+ end
72
+
73
+ def parsed_expires_at
74
+ value = api_key_params[:expires_at]
75
+ return nil if value.blank?
76
+
77
+ Time.zone.parse(value)
78
+ rescue ArgumentError
79
+ nil
80
+ end
81
+
82
+ def set_api_key_instructions
83
+ base_url = instruction_base_url
84
+
85
+ @api_key_quick_start = <<~TEXT
86
+ export RAILSPRESS_TOKEN="#{@plain_token}"
87
+ curl -H "Authorization: Bearer $RAILSPRESS_TOKEN" #{base_url}#{api_v1_prime_path}
88
+ TEXT
89
+
90
+ @api_key_instructions = <<~TEXT
91
+ I use Railspress for blog publishing. This is a direct API key.
92
+
93
+ Host: #{base_url}
94
+ API Key: #{@plain_token}
95
+ API Base: #{base_url}#{api_v1_posts_path.delete_suffix("/posts")}
96
+
97
+ Set environment variable:
98
+ export RAILSPRESS_TOKEN="#{@plain_token}"
99
+
100
+ Optional local token file:
101
+ printf '%s\n' "#{@plain_token}" > ~/.railspress_token
102
+ chmod 600 ~/.railspress_token
103
+ export RAILSPRESS_TOKEN="$(cat ~/.railspress_token)"
104
+
105
+ Quick start:
106
+ #{api_key_quick_start_line}
107
+
108
+ Create a draft post (default behavior):
109
+ curl -X POST -H "Authorization: Bearer $RAILSPRESS_TOKEN" -H "Content-Type: application/json" -d '{"post":{"title":"Agent draft","content":"<p>Hello</p>"}}' #{base_url}#{api_v1_posts_path}
110
+
111
+ Publish explicitly:
112
+ set "post.status" to "published" (optionally also set "post.published_at")
113
+ TEXT
114
+ end
115
+
116
+ def set_generic_agent_instructions
117
+ bootstrap_token = latest_active_bootstrap_token || "<YOUR_BOOTSTRAP_TOKEN>"
118
+ base_url = instruction_base_url
119
+
120
+ @generic_agent_quick_start = <<~TEXT
121
+ export RAILSPRESS_BOOTSTRAP_TOKEN="#{bootstrap_token}"
122
+ export RAILSPRESS_TOKEN=$(curl -s -X POST -H "Authorization: Bearer ${RAILSPRESS_BOOTSTRAP_TOKEN}" #{base_url}#{exchange_api_v1_agent_keys_path} | ruby -rjson -e 'print JSON.parse(STDIN.read).dig("data","api_key","token")')
123
+ curl -H "Authorization: Bearer $RAILSPRESS_TOKEN" #{base_url}#{api_v1_prime_path}
124
+ TEXT
125
+
126
+ @generic_agent_instructions = <<~TEXT
127
+ I use Railspress for blog publishing. Here's the secure agent flow:
128
+
129
+ Host: #{base_url}
130
+ Bootstrap Token: #{bootstrap_token}
131
+ API Base: #{base_url}#{api_v1_posts_path.delete_suffix("/posts")}
132
+
133
+ Exchange bootstrap token for a real API key (one-time bootstrap):
134
+ export RAILSPRESS_BOOTSTRAP_TOKEN="#{bootstrap_token}"
135
+ export RAILSPRESS_TOKEN=$(curl -s -X POST -H "Authorization: Bearer ${RAILSPRESS_BOOTSTRAP_TOKEN}" #{base_url}#{exchange_api_v1_agent_keys_path} | ruby -rjson -e 'print JSON.parse(STDIN.read).dig("data","api_key","token")')
136
+
137
+ Optional local token file:
138
+ printf '%s\n' "$RAILSPRESS_TOKEN" > ~/.railspress_token
139
+ chmod 600 ~/.railspress_token
140
+ export RAILSPRESS_TOKEN="$(cat ~/.railspress_token)"
141
+
142
+ Quick start:
143
+ curl -H "Authorization: Bearer $RAILSPRESS_TOKEN" #{base_url}#{api_v1_prime_path}
144
+
145
+ Create a draft post (default behavior):
146
+ curl -X POST -H "Authorization: Bearer $RAILSPRESS_TOKEN" -H "Content-Type: application/json" -d '{"post":{"title":"Agent draft","content":"<p>Hello</p>"}}' #{base_url}#{api_v1_posts_path}
147
+
148
+ Publish explicitly:
149
+ set "post.status" to "published" (optionally also set "post.published_at")
150
+ TEXT
151
+ end
152
+
153
+ def api_key_quick_start_line
154
+ @api_key_quick_start.strip
155
+ end
156
+
157
+ def latest_active_bootstrap_token
158
+ bootstrap_key = AgentBootstrapKey.active.recent.first
159
+ return nil unless bootstrap_key
160
+
161
+ AgentBootstrapKey.build_token(bootstrap_key.token_prefix, bootstrap_key.secret_ciphertext)
162
+ end
163
+ end
164
+ end
165
+ end
@@ -1,3 +1,5 @@
1
+ require "uri"
2
+
1
3
  module Railspress
2
4
  module Admin
3
5
  class BaseController < ActionController::Base
@@ -6,7 +8,7 @@ module Railspress
6
8
  layout "railspress/admin"
7
9
  helper Railspress::AdminHelper
8
10
 
9
- helper_method :current_author, :available_authors, :authors_enabled?, :post_images_enabled?
11
+ helper_method :current_author, :available_authors, :authors_enabled?, :post_images_enabled?, :current_api_actor
10
12
 
11
13
  # Authentication hook - to be configured later
12
14
  # before_action :authenticate_admin!
@@ -41,6 +43,64 @@ module Railspress
41
43
  return [] unless authors_enabled?
42
44
  Railspress.available_authors
43
45
  end
46
+
47
+ def current_api_actor
48
+ if Railspress.current_api_actor_proc
49
+ instance_exec(&Railspress.current_api_actor_proc)
50
+ elsif respond_to?(Railspress.current_api_actor_method, true)
51
+ send(Railspress.current_api_actor_method)
52
+ end
53
+ end
54
+
55
+ def instruction_base_url
56
+ configured = Railspress.public_base_url.to_s.strip
57
+ return configured.delete_suffix("/") if configured.present?
58
+
59
+ route_default_base_url || request.base_url
60
+ end
61
+
62
+ def route_default_base_url
63
+ options = Rails.application.routes.default_url_options.to_h.symbolize_keys
64
+ host_value = options[:host].presence
65
+ return nil if host_value.blank?
66
+
67
+ fallback_protocol = request.protocol.delete_suffix("://")
68
+ parsed_uri = parse_host_uri(host_value, fallback_protocol)
69
+ return nil if parsed_uri.nil? || parsed_uri.host.blank?
70
+
71
+ protocol = options[:protocol].presence&.to_s&.delete_suffix("://") || parsed_uri.scheme || fallback_protocol
72
+ port = options.key?(:port) ? options[:port] : parsed_uri.port
73
+ script_name = options[:script_name].presence || parsed_uri.path.presence
74
+
75
+ base = +"#{protocol}://#{parsed_uri.host}"
76
+ base << ":#{port}" if non_default_port?(protocol, port)
77
+ base << normalize_script_name(script_name) if script_name.present?
78
+ base.delete_suffix("/")
79
+ end
80
+
81
+ def parse_host_uri(host_value, protocol)
82
+ host_string = host_value.to_s
83
+ host_string = "#{protocol}://#{host_string}" unless host_string.match?(/\Ahttps?:\/\//i)
84
+ URI.parse(host_string)
85
+ rescue URI::InvalidURIError
86
+ nil
87
+ end
88
+
89
+ def non_default_port?(protocol, port)
90
+ return false if port.blank?
91
+
92
+ port_int = port.to_i
93
+ return false if port_int.zero?
94
+
95
+ !((protocol == "http" && port_int == 80) || (protocol == "https" && port_int == 443))
96
+ end
97
+
98
+ def normalize_script_name(script_name)
99
+ value = script_name.to_s
100
+ return "" if value.blank? || value == "/"
101
+
102
+ value.start_with?("/") ? value : "/#{value}"
103
+ end
44
104
  end
45
105
  end
46
106
  end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress
4
+ module Api
5
+ module V1
6
+ class AgentKeyExchangesController < BaseController
7
+ include ActionController::HttpAuthentication::Token::ControllerMethods
8
+
9
+ skip_before_action :authenticate_api_key!
10
+ before_action :authenticate_bootstrap_key!
11
+
12
+ def create
13
+ api_key, plain_api_token = current_agent_bootstrap_key.exchange!(ip_address: request.remote_ip)
14
+
15
+ render json: {
16
+ data: {
17
+ api_key: {
18
+ id: api_key.id,
19
+ name: api_key.name,
20
+ token: plain_api_token,
21
+ expires_at: api_key.expires_at
22
+ },
23
+ bootstrap: {
24
+ id: current_agent_bootstrap_key.id,
25
+ used_at: current_agent_bootstrap_key.used_at
26
+ }
27
+ }
28
+ }, status: :created
29
+ rescue Railspress::AgentBootstrapKey::ExchangeError
30
+ render_error("Unauthorized", status: :unauthorized)
31
+ rescue ActiveRecord::RecordInvalid => e
32
+ render_validation_errors(e.record)
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :current_agent_bootstrap_key
38
+
39
+ def authenticate_bootstrap_key!
40
+ authenticated = authenticate_with_http_token do |token, _opts|
41
+ @current_agent_bootstrap_key = Railspress::AgentBootstrapKey.authenticate(token)
42
+ @current_agent_bootstrap_key.present?
43
+ end
44
+
45
+ render_error("Unauthorized", status: :unauthorized) unless authenticated
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress
4
+ module Api
5
+ module V1
6
+ class BaseController < ActionController::API
7
+ include ActionController::HttpAuthentication::Token::ControllerMethods
8
+
9
+ before_action :ensure_api_enabled!
10
+ before_action :authenticate_api_key!
11
+
12
+ rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
13
+
14
+ attr_reader :current_api_key
15
+
16
+ private
17
+
18
+ def ensure_api_enabled!
19
+ render_error("API is not enabled.", status: :not_found) unless Railspress.api_enabled?
20
+ end
21
+
22
+ def authenticate_api_key!
23
+ return if authenticate_with_http_token { |token, _opts| authenticate_with_token(token) }
24
+
25
+ render_error("Unauthorized", status: :unauthorized)
26
+ end
27
+
28
+ def authenticate_with_token(token)
29
+ @current_api_key = Railspress::ApiKey.authenticate(token, ip_address: request.remote_ip)
30
+ @current_api_key.present?
31
+ end
32
+
33
+ def render_not_found
34
+ render_error("Resource not found.", status: :not_found)
35
+ end
36
+
37
+ def render_validation_errors(record)
38
+ render json: {
39
+ error: {
40
+ message: "Validation failed.",
41
+ details: record.errors.full_messages
42
+ }
43
+ }, status: :unprocessable_content
44
+ end
45
+
46
+ def render_error(message, status:)
47
+ render json: { error: { message: message } }, status: status
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railspress
4
+ module Api
5
+ module V1
6
+ class CategoriesController < BaseController
7
+ before_action :set_category, only: [ :show, :update, :destroy ]
8
+
9
+ def index
10
+ categories = Railspress::Category.ordered
11
+ total_count = categories.count
12
+ categories = categories.offset((page - 1) * per_page).limit(per_page)
13
+
14
+ render json: {
15
+ data: categories.map { |category| serialize_category(category) },
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_category(@category) }
27
+ end
28
+
29
+ def create
30
+ category = Railspress::Category.new(category_params)
31
+
32
+ if category.save
33
+ render json: { data: serialize_category(category) }, status: :created
34
+ else
35
+ render_validation_errors(category)
36
+ end
37
+ end
38
+
39
+ def update
40
+ if @category.update(category_params)
41
+ render json: { data: serialize_category(@category) }
42
+ else
43
+ render_validation_errors(@category)
44
+ end
45
+ end
46
+
47
+ def destroy
48
+ if @category.destroy
49
+ head :no_content
50
+ else
51
+ render_validation_errors(@category)
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def set_category
58
+ @category = Railspress::Category.find(params[:id])
59
+ end
60
+
61
+ def category_params
62
+ params.require(:category).permit(:name, :slug, :description)
63
+ end
64
+
65
+ def page
66
+ [ params.fetch(:page, 1).to_i, 1 ].max
67
+ end
68
+
69
+ def per_page
70
+ requested = params.fetch(:per, 20).to_i
71
+ requested = 20 if requested <= 0
72
+ [ requested, 100 ].min
73
+ end
74
+
75
+ def serialize_category(category)
76
+ {
77
+ id: category.id,
78
+ name: category.name,
79
+ slug: category.slug,
80
+ description: category.description,
81
+ posts_count: category.posts.count,
82
+ created_at: category.created_at,
83
+ updated_at: category.updated_at
84
+ }
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end