railspress-engine 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/app/assets/javascripts/railspress/admin.js +54 -0
  4. data/app/assets/stylesheets/railspress/admin/buttons.css +12 -0
  5. data/app/assets/stylesheets/railspress/admin/cards.css +8 -0
  6. data/app/assets/stylesheets/railspress/admin/forms.css +21 -0
  7. data/app/assets/stylesheets/railspress/admin/layout.css +88 -0
  8. data/app/assets/stylesheets/railspress/admin/responsive.css +15 -0
  9. data/app/controllers/railspress/admin/agent_bootstrap_keys_controller.rb +109 -0
  10. data/app/controllers/railspress/admin/api_keys_controller.rb +165 -0
  11. data/app/controllers/railspress/admin/base_controller.rb +61 -1
  12. data/app/controllers/railspress/api/v1/agent_key_exchanges_controller.rb +50 -0
  13. data/app/controllers/railspress/api/v1/base_controller.rb +52 -0
  14. data/app/controllers/railspress/api/v1/categories_controller.rb +89 -0
  15. data/app/controllers/railspress/api/v1/concerns/post_serialization.rb +130 -0
  16. data/app/controllers/railspress/api/v1/post_header_image_contexts_controller.rb +158 -0
  17. data/app/controllers/railspress/api/v1/post_header_image_focal_points_controller.rb +74 -0
  18. data/app/controllers/railspress/api/v1/post_header_images_controller.rb +58 -0
  19. data/app/controllers/railspress/api/v1/post_imports_controller.rb +118 -0
  20. data/app/controllers/railspress/api/v1/posts_controller.rb +127 -0
  21. data/app/controllers/railspress/api/v1/prime_controller.rb +78 -0
  22. data/app/controllers/railspress/api/v1/tags_controller.rb +85 -0
  23. data/app/helpers/railspress/admin_helper.rb +19 -0
  24. data/app/models/railspress/agent_bootstrap_key.rb +163 -0
  25. data/app/models/railspress/api_key.rb +157 -0
  26. data/app/models/railspress/post_export_processor.rb +16 -2
  27. data/app/views/railspress/admin/agent_bootstrap_keys/_form.html.erb +25 -0
  28. data/app/views/railspress/admin/agent_bootstrap_keys/new.html.erb +7 -0
  29. data/app/views/railspress/admin/agent_bootstrap_keys/reveal.html.erb +38 -0
  30. data/app/views/railspress/admin/api_keys/_form.html.erb +25 -0
  31. data/app/views/railspress/admin/api_keys/index.html.erb +142 -0
  32. data/app/views/railspress/admin/api_keys/new.html.erb +7 -0
  33. data/app/views/railspress/admin/api_keys/reveal.html.erb +40 -0
  34. data/app/views/railspress/admin/posts/_form.html.erb +1 -1
  35. data/app/views/railspress/admin/posts/_post_row.html.erb +1 -1
  36. data/app/views/railspress/admin/posts/show.html.erb +1 -1
  37. data/app/views/railspress/admin/shared/_copyable_textarea.html.erb +17 -0
  38. data/app/views/railspress/admin/shared/_sidebar.html.erb +46 -0
  39. data/config/routes.rb +33 -0
  40. data/db/migrate/20260415000001_create_railspress_api_keys.rb +40 -0
  41. data/db/migrate/20260415000002_create_railspress_agent_bootstrap_keys.rb +37 -0
  42. data/lib/generators/railspress/install/templates/initializer.rb +11 -0
  43. data/lib/railspress/version.rb +1 -1
  44. data/lib/railspress.rb +49 -1
  45. metadata +26 -1
@@ -0,0 +1,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 %>
@@ -0,0 +1,142 @@
1
+ <% content_for :title, "Agents & API" %>
2
+
3
+ <%= rp_page_header "Agents & API",
4
+ "New Agent Key" => [new_admin_agent_bootstrap_key_path, class: "rp-btn rp-btn--secondary"],
5
+ "New API Key" => new_admin_api_key_path %>
6
+
7
+ <div class="rp-card rp-card--padded rp-card--section">
8
+ <div class="rp-section">
9
+ <div class="rp-section-header">
10
+ <h2 class="rp-section-title">Agent Setup (Generic)</h2>
11
+ </div>
12
+
13
+ <div class="rp-form rp-form--spacious">
14
+ <p class="rp-hint">Use a short-lived bootstrap key in agent instructions. The agent exchanges it once for a real API key.</p>
15
+
16
+ <%= render "railspress/admin/shared/copyable_textarea",
17
+ id: "rp-generic-agent-quick-start",
18
+ label: "Bootstrap Quick Start",
19
+ value: @generic_agent_quick_start,
20
+ rows: 10,
21
+ button_text: "Copy Quick Start" %>
22
+
23
+ <%= render "railspress/admin/shared/copyable_textarea",
24
+ id: "rp-generic-agent-instructions",
25
+ label: "Agent Instructions",
26
+ value: @generic_agent_instructions,
27
+ rows: 10,
28
+ button_text: "Copy Instructions" %>
29
+ </div>
30
+ </div>
31
+ </div>
32
+
33
+ <div class="rp-card rp-card--padded rp-card--section">
34
+ <div class="rp-section">
35
+ <div class="rp-section-header">
36
+ <h2 class="rp-section-title">API Keys</h2>
37
+ </div>
38
+
39
+ <% if @api_keys.any? %>
40
+ <div class="rp-table--responsive">
41
+ <table class="rp-table">
42
+ <thead>
43
+ <tr>
44
+ <th><%= rp_table_header("Name") %></th>
45
+ <th><%= rp_table_header("Prefix") %></th>
46
+ <th><%= rp_table_header("Status") %></th>
47
+ <th><%= rp_table_header("Last Used") %></th>
48
+ <th><%= rp_table_header("Expires") %></th>
49
+ <th class="rp-table-actions"><%= rp_table_header("Actions") %></th>
50
+ </tr>
51
+ </thead>
52
+ <tbody>
53
+ <% @api_keys.each do |api_key| %>
54
+ <% api_status_type = case api_key.status
55
+ when "active" then :published
56
+ when "expired" then :draft
57
+ else :failed
58
+ end %>
59
+ <tr>
60
+ <td class="rp-table-primary"><%= api_key.name %></td>
61
+ <td class="rp-table-secondary"><code><%= api_key.token_prefix %></code></td>
62
+ <td><%= rp_status_badge(api_key.status, type: api_status_type) %></td>
63
+ <td><%= api_key.last_used_at ? l(api_key.last_used_at, format: :short) : "Never" %></td>
64
+ <td><%= api_key.expires_at ? l(api_key.expires_at, format: :short) : "Never" %></td>
65
+ <td class="rp-table-actions">
66
+ <%= button_to "Rotate",
67
+ rotate_admin_api_key_path(api_key),
68
+ method: :post,
69
+ class: "rp-btn rp-btn--secondary rp-btn--sm" %>
70
+ <% unless api_key.revoked_at %>
71
+ <%= button_to "Revoke",
72
+ revoke_admin_api_key_path(api_key),
73
+ method: :post,
74
+ class: "rp-btn rp-btn--danger rp-btn--sm",
75
+ data: { turbo_confirm: "Revoke this API key?" } %>
76
+ <% end %>
77
+ </td>
78
+ </tr>
79
+ <% end %>
80
+ </tbody>
81
+ </table>
82
+ </div>
83
+ <% else %>
84
+ <%= rp_empty_state("No API keys yet.", link_text: "Create your first API key", link_path: new_admin_api_key_path) %>
85
+ <% end %>
86
+ </div>
87
+ </div>
88
+
89
+ <div class="rp-card rp-card--padded rp-card--section">
90
+ <div class="rp-section">
91
+ <div class="rp-section-header">
92
+ <h2 class="rp-section-title">Agent Bootstrap Keys</h2>
93
+ </div>
94
+
95
+ <% if @agent_bootstrap_keys.any? %>
96
+ <div class="rp-table--responsive">
97
+ <table class="rp-table">
98
+ <thead>
99
+ <tr>
100
+ <th><%= rp_table_header("Name") %></th>
101
+ <th><%= rp_table_header("Prefix") %></th>
102
+ <th><%= rp_table_header("Status") %></th>
103
+ <th><%= rp_table_header("Used") %></th>
104
+ <th><%= rp_table_header("Expires") %></th>
105
+ <th class="rp-table-actions"><%= rp_table_header("Actions") %></th>
106
+ </tr>
107
+ </thead>
108
+ <tbody>
109
+ <% @agent_bootstrap_keys.each do |bootstrap_key| %>
110
+ <% bootstrap_status_type = case bootstrap_key.status
111
+ when "active" then :published
112
+ when "used" then :completed
113
+ when "expired" then :draft
114
+ else :failed
115
+ end %>
116
+ <tr>
117
+ <td class="rp-table-primary"><%= bootstrap_key.name %></td>
118
+ <td class="rp-table-secondary"><code><%= bootstrap_key.token_prefix %></code></td>
119
+ <td><%= rp_status_badge(bootstrap_key.status, type: bootstrap_status_type) %></td>
120
+ <td><%= bootstrap_key.used_at ? l(bootstrap_key.used_at, format: :short) : "No" %></td>
121
+ <td><%= l(bootstrap_key.expires_at, format: :short) %></td>
122
+ <td class="rp-table-actions">
123
+ <% if bootstrap_key.active? %>
124
+ <%= button_to "Revoke",
125
+ revoke_admin_agent_bootstrap_key_path(bootstrap_key),
126
+ method: :post,
127
+ class: "rp-btn rp-btn--danger rp-btn--sm",
128
+ data: { turbo_confirm: "Revoke this bootstrap key?" } %>
129
+ <% else %>
130
+ <span class="rp-hint">No actions</span>
131
+ <% end %>
132
+ </td>
133
+ </tr>
134
+ <% end %>
135
+ </tbody>
136
+ </table>
137
+ </div>
138
+ <% else %>
139
+ <%= rp_empty_state("No bootstrap keys yet.", link_text: "Create your first bootstrap key", link_path: new_admin_agent_bootstrap_key_path) %>
140
+ <% end %>
141
+ </div>
142
+ </div>
@@ -0,0 +1,7 @@
1
+ <% content_for :title, "New API Key" %>
2
+
3
+ <h1 class="rp-page-title rp-page-title--standalone">New API Key</h1>
4
+
5
+ <div class="rp-card rp-card--padded">
6
+ <%= render "form", api_key: @api_key %>
7
+ </div>
@@ -0,0 +1,40 @@
1
+ <% content_for :title, "API Key Created" %>
2
+
3
+ <h1 class="rp-page-title rp-page-title--standalone">API 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 key now. For security reasons it will not be shown again.
9
+ </p>
10
+
11
+ <%= render "railspress/admin/shared/copyable_textarea",
12
+ id: "rp-api-key-token",
13
+ label: "Bearer Token",
14
+ value: @plain_token,
15
+ rows: 4,
16
+ hint: "Use this as Authorization: Bearer <token> (or save it to ~/.railspress_token for reuse).",
17
+ button_text: "Copy Token" %>
18
+
19
+ <%= render "railspress/admin/shared/copyable_textarea",
20
+ id: "rp-api-key-quick-start",
21
+ label: "API Quick Start",
22
+ value: @api_key_quick_start,
23
+ rows: 3,
24
+ hint: "This verifies connectivity and capabilities via /api/v1/prime.",
25
+ button_text: "Copy Quick Start" %>
26
+
27
+ <%= render "railspress/admin/shared/copyable_textarea",
28
+ id: "rp-api-key-instructions",
29
+ label: "API Key Instructions",
30
+ value: @api_key_instructions,
31
+ rows: 14,
32
+ hint: "Draft is default; publishing is supported when explicitly requested.",
33
+ button_text: "Copy Instructions" %>
34
+
35
+ <div class="rp-form-actions">
36
+ <%= link_to "Create Another API Key", new_admin_api_key_path, class: "rp-btn rp-btn--primary" %>
37
+ <%= link_to "Back to Agents & API", admin_api_keys_path, class: "rp-btn rp-btn--secondary" %>
38
+ </div>
39
+ </div>
40
+ </div>
@@ -159,7 +159,7 @@
159
159
  <% if authors_enabled? %>
160
160
  <div class="rp-form-group">
161
161
  <%= form.label :author_id, "Author", class: "rp-label" %>
162
- <%= form.collection_select :author_id, available_authors, :id, Railspress.author_display_method,
162
+ <%= form.collection_select :author_id, available_authors, :id, ->(author) { rp_author_display(author) },
163
163
  { prompt: "No author", selected: @post.author_id },
164
164
  { class: "rp-select" } %>
165
165
  </div>
@@ -11,7 +11,7 @@
11
11
  </td>
12
12
  <td><%= post.category&.name || "—" %></td>
13
13
  <% if authors_enabled? %>
14
- <td><%= post.author&.public_send(Railspress.author_display_method) || "—" %></td>
14
+ <td><%= post.author ? rp_author_display(post.author) : "—" %></td>
15
15
  <% end %>
16
16
  <td>
17
17
  <span class="rp-badge rp-badge--<%= post.display_status %>">
@@ -2,7 +2,7 @@
2
2
  <div>
3
3
  <h1 class="rp-page-title"><%= @post.title %></h1>
4
4
  <% if authors_enabled? && @post.author %>
5
- <p class="rp-byline">by <%= @post.author.public_send(Railspress.author_display_method) %></p>
5
+ <p class="rp-byline">by <%= rp_author_display(@post.author) %></p>
6
6
  <% end %>
7
7
  </div>
8
8
  <div class="rp-page-actions">
@@ -0,0 +1,17 @@
1
+ <div class="rp-form-group">
2
+ <label class="rp-label"><%= label %></label>
3
+ <% textarea_classes = ["rp-input", "rp-input--mono", "rp-input--code", local_assigns[:textarea_class]].compact.join(" ") %>
4
+ <textarea id="<%= id %>"
5
+ class="<%= textarea_classes %>"
6
+ rows="<%= local_assigns.fetch(:rows, 4) %>"
7
+ readonly><%= value %></textarea>
8
+ <% if local_assigns[:hint].present? %>
9
+ <p class="rp-hint"><%= hint %></p>
10
+ <% end %>
11
+ <% button_variant = local_assigns.fetch(:button_class, "rp-btn--accent") %>
12
+ <button type="button"
13
+ class="rp-btn rp-btn--sm <%= button_variant %>"
14
+ data-copy-target="<%= id %>">
15
+ <%= local_assigns.fetch(:button_text, "Copy") %>
16
+ </button>
17
+ </div>
@@ -94,6 +94,20 @@
94
94
  <% end %>
95
95
 
96
96
  <div class="rp-nav-divider"></div>
97
+ <% if Railspress.api_enabled? %>
98
+ <%= link_to railspress.admin_api_keys_path,
99
+ class: "rp-nav-link #{controller_name == 'api_keys' ? 'rp-nav-link--active' : ''}" do %>
100
+ <svg class="rp-nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
101
+ <path d="M12 3 13.6 6.9 17.5 8.5 13.6 10.1 12 14 10.4 10.1 6.5 8.5 10.4 6.9z"/>
102
+ <path d="M19 13 19.8 14.9 21.7 15.7 19.8 16.5 19 18.4 18.2 16.5 16.3 15.7 18.2 14.9z"/>
103
+ <path d="M5 15 5.8 16.9 7.7 17.7 5.8 18.5 5 20.4 4.2 18.5 2.3 17.7 4.2 16.9z"/>
104
+ </svg>
105
+ <span class="rp-nav-text">Agents &amp; API</span>
106
+ <% end %>
107
+
108
+ <div class="rp-nav-divider"></div>
109
+ <% end %>
110
+
97
111
  <%= link_to "/", class: "rp-nav-link" do %>
98
112
  <svg class="rp-nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
99
113
  <circle cx="12" cy="12" r="10"/>
@@ -103,4 +117,36 @@
103
117
  <span class="rp-nav-text">Main Site</span>
104
118
  <% end %>
105
119
  </nav>
120
+
121
+ <div class="rp-sidebar-footer">
122
+ <div class="rp-sidebar-version">v<%= Railspress::VERSION %></div>
123
+
124
+ <%= link_to "https://railspress.org/guide/", class: "rp-sidebar-footer-link", target: "_blank", rel: "noopener noreferrer", title: "Guide" do %>
125
+ <svg class="rp-sidebar-footer-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
126
+ <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
127
+ <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5V4.5A2.5 2.5 0 0 1 6.5 2z"/>
128
+ </svg>
129
+ <span class="rp-sidebar-footer-text">Guide</span>
130
+ <% end %>
131
+
132
+ <%= link_to "https://railspress.org/docs/", class: "rp-sidebar-footer-link", target: "_blank", rel: "noopener noreferrer", title: "Docs" do %>
133
+ <svg class="rp-sidebar-footer-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
134
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
135
+ <polyline points="14 2 14 8 20 8"/>
136
+ </svg>
137
+ <span class="rp-sidebar-footer-text">Docs</span>
138
+ <% end %>
139
+
140
+ <%= link_to "https://github.com/aviflombaum/railspress-engine", class: "rp-sidebar-footer-link", target: "_blank", rel: "noopener noreferrer", title: "GitHub" do %>
141
+ <svg class="rp-sidebar-footer-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
142
+ <path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"/>
143
+ </svg>
144
+ <span class="rp-sidebar-footer-text">GitHub</span>
145
+ <% end %>
146
+
147
+ <p class="rp-sidebar-credit">
148
+ Made with ❤️ by
149
+ <%= link_to "Avi.nyc", "https://avi.nyc", target: "_blank", rel: "noopener noreferrer", class: "rp-sidebar-credit-link" %>
150
+ </p>
151
+ </div>
106
152
  </div>
data/config/routes.rb CHANGED
@@ -4,6 +4,17 @@ Railspress::Engine.routes.draw do
4
4
  root "dashboard#index"
5
5
  resources :categories, except: [ :show ]
6
6
  resources :tags, except: [ :show ]
7
+ resources :api_keys, only: [ :index, :new, :create ] do
8
+ member do
9
+ post :rotate
10
+ post :revoke
11
+ end
12
+ end
13
+ resources :agent_bootstrap_keys, only: [ :new, :create ] do
14
+ member do
15
+ post :revoke
16
+ end
17
+ end
7
18
 
8
19
  # Content Element CMS (opt-in via config.enable_cms)
9
20
  if Railspress.cms_enabled?
@@ -67,4 +78,26 @@ Railspress::Engine.routes.draw do
67
78
  end
68
79
  end
69
80
  end
81
+
82
+ namespace :api do
83
+ namespace :v1 do
84
+ resource :prime, only: [ :show ], controller: "prime"
85
+ resources :agent_keys, only: [] do
86
+ collection do
87
+ post :exchange, to: "agent_key_exchanges#create"
88
+ end
89
+ end
90
+ resources :post_imports, path: "posts/imports", only: [ :create, :show ]
91
+ resources :posts, only: [ :index, :show, :create, :update, :destroy ] do
92
+ resource :header_image, only: [ :show, :update, :destroy ], controller: "post_header_images" do
93
+ resource :focal_point, only: [ :show, :update ], controller: "post_header_image_focal_points"
94
+ resources :contexts, only: [ :index, :show, :update, :destroy ],
95
+ controller: "post_header_image_contexts",
96
+ param: :context
97
+ end
98
+ end
99
+ resources :categories, only: [ :index, :show, :create, :update, :destroy ]
100
+ resources :tags, only: [ :index, :show, :create, :update, :destroy ]
101
+ end
102
+ end
70
103
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateRailspressApiKeys < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :railspress_api_keys do |t|
6
+ t.string :name, null: false
7
+ t.string :global_uuid, null: false
8
+ t.string :token_prefix, null: false
9
+ t.string :token_digest, null: false
10
+ t.text :secret_ciphertext, null: false
11
+ t.datetime :expires_at
12
+ t.datetime :last_used_at
13
+ t.string :last_used_ip
14
+ t.datetime :revoked_at
15
+ t.string :revoke_reason
16
+ t.integer :rotated_from_id
17
+
18
+ t.string :owner_type
19
+ t.bigint :owner_id
20
+
21
+ t.string :created_by_type
22
+ t.bigint :created_by_id
23
+ t.string :rotated_by_type
24
+ t.bigint :rotated_by_id
25
+ t.string :revoked_by_type
26
+ t.bigint :revoked_by_id
27
+
28
+ t.timestamps
29
+ end
30
+
31
+ add_index :railspress_api_keys, :global_uuid, unique: true
32
+ add_index :railspress_api_keys, :token_prefix
33
+ add_index :railspress_api_keys, :token_digest, unique: true
34
+ add_index :railspress_api_keys, :rotated_from_id
35
+ add_index :railspress_api_keys, [ :owner_type, :owner_id ]
36
+ add_index :railspress_api_keys, [ :created_by_type, :created_by_id ]
37
+ add_index :railspress_api_keys, [ :rotated_by_type, :rotated_by_id ]
38
+ add_index :railspress_api_keys, [ :revoked_by_type, :revoked_by_id ]
39
+ end
40
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateRailspressAgentBootstrapKeys < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :railspress_agent_bootstrap_keys do |t|
6
+ t.string :name, null: false
7
+ t.string :global_uuid, null: false
8
+ t.string :token_prefix, null: false
9
+ t.string :token_digest, null: false
10
+ t.text :secret_ciphertext, null: false
11
+ t.datetime :expires_at, null: false
12
+ t.datetime :used_at
13
+ t.string :used_ip
14
+ t.datetime :revoked_at
15
+ t.string :revoke_reason
16
+ t.bigint :exchanged_api_key_id
17
+
18
+ t.string :owner_type
19
+ t.bigint :owner_id
20
+
21
+ t.string :created_by_type
22
+ t.bigint :created_by_id
23
+ t.string :revoked_by_type
24
+ t.bigint :revoked_by_id
25
+
26
+ t.timestamps
27
+ end
28
+
29
+ add_index :railspress_agent_bootstrap_keys, :global_uuid, unique: true
30
+ add_index :railspress_agent_bootstrap_keys, :token_prefix
31
+ add_index :railspress_agent_bootstrap_keys, :token_digest, unique: true
32
+ add_index :railspress_agent_bootstrap_keys, :exchanged_api_key_id
33
+ add_index :railspress_agent_bootstrap_keys, [ :owner_type, :owner_id ], name: "idx_rp_agent_bootstrap_keys_owner"
34
+ add_index :railspress_agent_bootstrap_keys, [ :created_by_type, :created_by_id ], name: "idx_rp_agent_bootstrap_keys_created_by"
35
+ add_index :railspress_agent_bootstrap_keys, [ :revoked_by_type, :revoked_by_id ], name: "idx_rp_agent_bootstrap_keys_revoked_by"
36
+ end
37
+ end
@@ -26,4 +26,15 @@ Railspress.configure do |config|
26
26
  # config.inline_editing_check = ->(context) {
27
27
  # context.controller.current_user&.admin?
28
28
  # }
29
+
30
+ # === API (opt-in) ===
31
+ # Enables API endpoints under /railspress/api/v1 and admin API key management.
32
+ # Requires Active Record Encryption keys in your host app config.
33
+ # Uncomment to enable:
34
+ # config.enable_api
35
+ # config.current_api_actor_method = :current_user
36
+ # config.current_api_actor_proc = -> { Current.user }
37
+ # Optional: force a canonical public base URL in generated API instructions.
38
+ # Falls back to Rails.application.routes.default_url_options, then request host.
39
+ # config.public_base_url = "https://blog.example.com"
29
40
  end
@@ -1,3 +1,3 @@
1
1
  module Railspress
2
- VERSION = "1.2.0"
2
+ VERSION = "1.3.0"
3
3
  end
data/lib/railspress.rb CHANGED
@@ -10,6 +10,9 @@ module Railspress
10
10
  attr_accessor :author_class_name,
11
11
  :current_author_method,
12
12
  :current_author_proc,
13
+ :current_api_actor_method,
14
+ :current_api_actor_proc,
15
+ :public_base_url,
13
16
  :author_scope,
14
17
  :author_display_method,
15
18
  :words_per_minute,
@@ -18,19 +21,23 @@ module Railspress
18
21
  :post_image_variants,
19
22
  :inline_editing_check
20
23
 
21
- attr_reader :authors_enabled, :post_images_enabled, :focal_points_enabled, :cms_enabled, :image_contexts
24
+ attr_reader :authors_enabled, :post_images_enabled, :focal_points_enabled, :cms_enabled, :api_enabled, :image_contexts
22
25
 
23
26
  def initialize
24
27
  @authors_enabled = false
25
28
  @post_images_enabled = false
26
29
  @focal_points_enabled = false
27
30
  @cms_enabled = false
31
+ @api_enabled = false
28
32
  @image_contexts = default_image_contexts
29
33
  @post_image_variants = {}
30
34
  @inline_editing_check = nil
31
35
  @author_class_name = "User"
32
36
  @current_author_method = :current_user
33
37
  @current_author_proc = nil
38
+ @current_api_actor_method = :current_user
39
+ @current_api_actor_proc = nil
40
+ @public_base_url = nil
34
41
  @author_scope = nil
35
42
  @author_display_method = :name
36
43
  @words_per_minute = 200
@@ -58,6 +65,11 @@ module Railspress
58
65
  @cms_enabled = true
59
66
  end
60
67
 
68
+ # Declarative setter: config.enable_api
69
+ def enable_api
70
+ @api_enabled = true
71
+ end
72
+
61
73
  # Validate configuration after the configure block completes.
62
74
  # This keeps the configure block order-independent.
63
75
  def validate!
@@ -212,6 +224,10 @@ module Railspress
212
224
  configuration.image_contexts
213
225
  end
214
226
 
227
+ def api_enabled?
228
+ configuration.api_enabled
229
+ end
230
+
215
231
  def author_class
216
232
  configuration.author_class_name.constantize
217
233
  end
@@ -231,6 +247,26 @@ module Railspress
231
247
  configuration.author_display_method
232
248
  end
233
249
 
250
+ # Returns a safe display string for an author record.
251
+ # Falls back through common attribute names when the configured display
252
+ # method is missing on the model.
253
+ def author_display_for(author)
254
+ return nil unless author
255
+
256
+ configured_method = author_display_method
257
+ if configured_method.present? && author.respond_to?(configured_method)
258
+ value = author.public_send(configured_method)
259
+ return value if value.present?
260
+ end
261
+
262
+ fallback_method = [ :name, :full_name, :display_name, :email, :email_address ]
263
+ .find { |method| author.respond_to?(method) && author.public_send(method).present? }
264
+
265
+ return author.public_send(fallback_method) if fallback_method
266
+
267
+ "Author ##{author.id || "unknown"}"
268
+ end
269
+
234
270
  def current_author_method
235
271
  configuration.current_author_method
236
272
  end
@@ -239,6 +275,18 @@ module Railspress
239
275
  configuration.current_author_proc
240
276
  end
241
277
 
278
+ def current_api_actor_method
279
+ configuration.current_api_actor_method
280
+ end
281
+
282
+ def current_api_actor_proc
283
+ configuration.current_api_actor_proc
284
+ end
285
+
286
+ def public_base_url
287
+ configuration.public_base_url
288
+ end
289
+
242
290
  def words_per_minute
243
291
  configuration.words_per_minute
244
292
  end