railspress-engine 1.2.1 → 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/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 +11 -0
- data/lib/railspress/version.rb +1 -1
- data/lib/railspress.rb +49 -1
- metadata +26 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 24a6b9398ebd465a15fd0d1346127c45d2264c1f479cc9d866ebd52565ed6a7e
|
|
4
|
+
data.tar.gz: 6b410cc6ee303f2c2ef33f438517f1bab2850a695ca31c82e1ec035acbcd2e5b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cd5cb3183c073a9b35234add1862126b62c8055faf799ff28209cb896d563c020ccf0ed6dc06e2ba8ce65a30f727e3627c237cabebae13b717630a7403ddfd73
|
|
7
|
+
data.tar.gz: dd7e0c8a24cb6b1bc7d9f32c184c3b1806b19bd68a51010c63789b639baef1b99abed3de8306ff380445bca9c3287772a749d64aa3f90cb440642881c747a42e
|
|
@@ -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
|