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
@@ -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"/>
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
@@ -8,6 +8,7 @@ Railspress.configure do |config|
8
8
  # config.enable_authors
9
9
  # config.author_class_name = "User"
10
10
  # config.current_author_method = :current_user
11
+ # config.current_author_proc = -> { Current.user }
11
12
 
12
13
  # === CMS Content Elements (opt-in) ===
13
14
  # Adds content groups, content elements, and the cms_element/cms_value
@@ -26,4 +27,19 @@ Railspress.configure do |config|
26
27
  # config.inline_editing_check = ->(context) {
27
28
  # context.controller.current_user&.admin?
28
29
  # }
30
+
31
+ # === API (opt-in) ===
32
+ # Enables API endpoints under /railspress/api/v1 and admin API key management.
33
+ # Requires Active Record Encryption keys in your host app config.
34
+ # Uncomment to enable:
35
+ # config.enable_api
36
+ # Optional: include a host auth concern into Railspress::Admin::BaseController.
37
+ # Define your concern in app/controllers/concerns/railspress_admin_auth.rb
38
+ # and set this to its constant name (String or Symbol).
39
+ # config.admin_auth_concern = "RailspressAdminAuth"
40
+ # config.current_api_actor_method = :current_user
41
+ # config.current_api_actor_proc = -> { Current.user if Current.user&.admin? }
42
+ # Optional: force a canonical public base URL in generated API instructions.
43
+ # Falls back to Rails.application.routes.default_url_options, then request host.
44
+ # config.public_base_url = "https://blog.example.com"
29
45
  end
@@ -41,6 +41,18 @@ module Railspress
41
41
  end
42
42
  end
43
43
 
44
+ # Allow host apps to inject their auth concern into the admin base controller.
45
+ # This keeps host auth logic in app/controllers concerns while Railspress handles reload-safe wiring.
46
+ initializer "railspress.admin_auth_concern" do |app|
47
+ app.config.to_prepare do
48
+ concern = Railspress.resolved_admin_auth_concern
49
+ next unless concern
50
+ next if Railspress::Admin::BaseController < concern
51
+
52
+ Railspress::Admin::BaseController.include(concern)
53
+ end
54
+ end
55
+
44
56
  # Configure importmap for Stimulus controllers
45
57
  initializer "railspress.importmap", before: "importmap" do |app|
46
58
  if app.respond_to?(:importmap)
@@ -1,3 +1,3 @@
1
1
  module Railspress
2
- VERSION = "1.2.1"
2
+ VERSION = "1.3.1"
3
3
  end
data/lib/railspress.rb CHANGED
@@ -10,6 +10,10 @@ 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
+ :admin_auth_concern,
16
+ :public_base_url,
13
17
  :author_scope,
14
18
  :author_display_method,
15
19
  :words_per_minute,
@@ -18,19 +22,24 @@ module Railspress
18
22
  :post_image_variants,
19
23
  :inline_editing_check
20
24
 
21
- attr_reader :authors_enabled, :post_images_enabled, :focal_points_enabled, :cms_enabled, :image_contexts
25
+ attr_reader :authors_enabled, :post_images_enabled, :focal_points_enabled, :cms_enabled, :api_enabled, :image_contexts
22
26
 
23
27
  def initialize
24
28
  @authors_enabled = false
25
29
  @post_images_enabled = false
26
30
  @focal_points_enabled = false
27
31
  @cms_enabled = false
32
+ @api_enabled = false
28
33
  @image_contexts = default_image_contexts
29
34
  @post_image_variants = {}
30
35
  @inline_editing_check = nil
31
36
  @author_class_name = "User"
32
37
  @current_author_method = :current_user
33
38
  @current_author_proc = nil
39
+ @current_api_actor_method = :current_user
40
+ @current_api_actor_proc = nil
41
+ @admin_auth_concern = nil
42
+ @public_base_url = nil
34
43
  @author_scope = nil
35
44
  @author_display_method = :name
36
45
  @words_per_minute = 200
@@ -58,6 +67,11 @@ module Railspress
58
67
  @cms_enabled = true
59
68
  end
60
69
 
70
+ # Declarative setter: config.enable_api
71
+ def enable_api
72
+ @api_enabled = true
73
+ end
74
+
61
75
  # Validate configuration after the configure block completes.
62
76
  # This keeps the configure block order-independent.
63
77
  def validate!
@@ -212,6 +226,10 @@ module Railspress
212
226
  configuration.image_contexts
213
227
  end
214
228
 
229
+ def api_enabled?
230
+ configuration.api_enabled
231
+ end
232
+
215
233
  def author_class
216
234
  configuration.author_class_name.constantize
217
235
  end
@@ -231,6 +249,26 @@ module Railspress
231
249
  configuration.author_display_method
232
250
  end
233
251
 
252
+ # Returns a safe display string for an author record.
253
+ # Falls back through common attribute names when the configured display
254
+ # method is missing on the model.
255
+ def author_display_for(author)
256
+ return nil unless author
257
+
258
+ configured_method = author_display_method
259
+ if configured_method.present? && author.respond_to?(configured_method)
260
+ value = author.public_send(configured_method)
261
+ return value if value.present?
262
+ end
263
+
264
+ fallback_method = [ :name, :full_name, :display_name, :email, :email_address ]
265
+ .find { |method| author.respond_to?(method) && author.public_send(method).present? }
266
+
267
+ return author.public_send(fallback_method) if fallback_method
268
+
269
+ "Author ##{author.id || "unknown"}"
270
+ end
271
+
234
272
  def current_author_method
235
273
  configuration.current_author_method
236
274
  end
@@ -239,6 +277,40 @@ module Railspress
239
277
  configuration.current_author_proc
240
278
  end
241
279
 
280
+ def current_api_actor_method
281
+ configuration.current_api_actor_method
282
+ end
283
+
284
+ def current_api_actor_proc
285
+ configuration.current_api_actor_proc
286
+ end
287
+
288
+ def admin_auth_concern
289
+ configuration.admin_auth_concern
290
+ end
291
+
292
+ def resolved_admin_auth_concern
293
+ concern = admin_auth_concern
294
+ return nil if concern.blank?
295
+
296
+ module_value = case concern
297
+ when Module then concern
298
+ when String, Symbol
299
+ concern_name = concern.to_s
300
+ concern_name.safe_constantize || concern_name.camelize.safe_constantize
301
+ end
302
+
303
+ unless module_value.is_a?(Module)
304
+ raise ConfigurationError, "admin_auth_concern must resolve to a Module. Got: #{concern.inspect}"
305
+ end
306
+
307
+ module_value
308
+ end
309
+
310
+ def public_base_url
311
+ configuration.public_base_url
312
+ end
313
+
242
314
  def words_per_minute
243
315
  configuration.words_per_minute
244
316
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: railspress-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.1
4
+ version: 1.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Avi Flombaum
@@ -157,6 +157,8 @@ files:
157
157
  - app/assets/stylesheets/railspress/admin/utilities.css
158
158
  - app/assets/stylesheets/railspress/admin/variables.css
159
159
  - app/assets/stylesheets/railspress/application.css
160
+ - app/controllers/railspress/admin/agent_bootstrap_keys_controller.rb
161
+ - app/controllers/railspress/admin/api_keys_controller.rb
160
162
  - app/controllers/railspress/admin/base_controller.rb
161
163
  - app/controllers/railspress/admin/categories_controller.rb
162
164
  - app/controllers/railspress/admin/cms_transfers_controller.rb
@@ -171,6 +173,17 @@ files:
171
173
  - app/controllers/railspress/admin/posts_controller.rb
172
174
  - app/controllers/railspress/admin/prototypes_controller.rb
173
175
  - app/controllers/railspress/admin/tags_controller.rb
176
+ - app/controllers/railspress/api/v1/agent_key_exchanges_controller.rb
177
+ - app/controllers/railspress/api/v1/base_controller.rb
178
+ - app/controllers/railspress/api/v1/categories_controller.rb
179
+ - app/controllers/railspress/api/v1/concerns/post_serialization.rb
180
+ - app/controllers/railspress/api/v1/post_header_image_contexts_controller.rb
181
+ - app/controllers/railspress/api/v1/post_header_image_focal_points_controller.rb
182
+ - app/controllers/railspress/api/v1/post_header_images_controller.rb
183
+ - app/controllers/railspress/api/v1/post_imports_controller.rb
184
+ - app/controllers/railspress/api/v1/posts_controller.rb
185
+ - app/controllers/railspress/api/v1/prime_controller.rb
186
+ - app/controllers/railspress/api/v1/tags_controller.rb
174
187
  - app/controllers/railspress/application_controller.rb
175
188
  - app/helpers/railspress/admin_helper.rb
176
189
  - app/helpers/railspress/application_helper.rb
@@ -190,6 +203,8 @@ files:
190
203
  - app/models/concerns/railspress/has_focal_point.rb
191
204
  - app/models/concerns/railspress/soft_deletable.rb
192
205
  - app/models/concerns/railspress/taggable.rb
206
+ - app/models/railspress/agent_bootstrap_key.rb
207
+ - app/models/railspress/api_key.rb
193
208
  - app/models/railspress/application_record.rb
194
209
  - app/models/railspress/category.rb
195
210
  - app/models/railspress/content_element.rb
@@ -210,6 +225,13 @@ files:
210
225
  - app/views/layouts/action_text/contents/_content.html.erb
211
226
  - app/views/layouts/railspress/admin.html.erb
212
227
  - app/views/layouts/railspress/application.html.erb
228
+ - app/views/railspress/admin/agent_bootstrap_keys/_form.html.erb
229
+ - app/views/railspress/admin/agent_bootstrap_keys/new.html.erb
230
+ - app/views/railspress/admin/agent_bootstrap_keys/reveal.html.erb
231
+ - app/views/railspress/admin/api_keys/_form.html.erb
232
+ - app/views/railspress/admin/api_keys/index.html.erb
233
+ - app/views/railspress/admin/api_keys/new.html.erb
234
+ - app/views/railspress/admin/api_keys/reveal.html.erb
213
235
  - app/views/railspress/admin/categories/_form.html.erb
214
236
  - app/views/railspress/admin/categories/edit.html.erb
215
237
  - app/views/railspress/admin/categories/index.html.erb
@@ -244,6 +266,7 @@ files:
244
266
  - app/views/railspress/admin/posts/new.html.erb
245
267
  - app/views/railspress/admin/posts/show.html.erb
246
268
  - app/views/railspress/admin/prototypes/image_section.html.erb
269
+ - app/views/railspress/admin/shared/_copyable_textarea.html.erb
247
270
  - app/views/railspress/admin/shared/_dropzone.html.erb
248
271
  - app/views/railspress/admin/shared/_flash.html.erb
249
272
  - app/views/railspress/admin/shared/_focal_point_editor.html.erb
@@ -275,6 +298,8 @@ files:
275
298
  - db/migrate/20260207000001_add_unique_index_to_content_elements.rb
276
299
  - db/migrate/20260211112812_add_image_hint_to_railspress_content_elements.rb
277
300
  - db/migrate/20260211154040_add_required_to_railspress_content_elements.rb
301
+ - db/migrate/20260415000001_create_railspress_api_keys.rb
302
+ - db/migrate/20260415000002_create_railspress_agent_bootstrap_keys.rb
278
303
  - lib/generators/railspress/entity/entity_generator.rb
279
304
  - lib/generators/railspress/entity/templates/migration.rb.tt
280
305
  - lib/generators/railspress/entity/templates/model.rb.tt