postnhost 0.1.0 → 0.2.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/CHANGELOG.md +36 -0
- data/README.md +22 -9
- data/Rakefile +28 -0
- data/app/assets/builds/postnhost/application.css +1 -1
- data/app/assets/stylesheets/application.tailwind.css +5 -1
- data/app/controllers/postnhost/api/base_controller.rb +29 -0
- data/app/controllers/postnhost/api/v1/commands_controller.rb +27 -0
- data/app/controllers/postnhost/api/v1/mcp_controller.rb +49 -0
- data/app/controllers/postnhost/api_tokens_controller.rb +34 -0
- data/app/controllers/postnhost/articles/translations_controller.rb +2 -9
- data/app/controllers/postnhost/categories/translations_controller.rb +2 -9
- data/app/controllers/postnhost/management_preview_controller.rb +17 -0
- data/app/controllers/postnhost/pages/translations_controller.rb +2 -9
- data/app/helpers/postnhost/application_helper.rb +0 -11
- data/app/models/postnhost/api_token.rb +26 -0
- data/app/models/postnhost/user.rb +2 -0
- data/app/services/postnhost/api/v1/openapi.rb +27 -0
- data/app/services/postnhost/management/catalog.rb +151 -0
- data/app/services/postnhost/management/command.rb +174 -0
- data/app/services/postnhost/management/content_actions.rb +75 -0
- data/app/services/postnhost/management/input.rb +49 -0
- data/app/services/postnhost/management/mcp.rb +54 -0
- data/app/services/postnhost/management/preview.rb +25 -0
- data/app/services/postnhost/management/resources.rb +30 -0
- data/app/services/postnhost/management/schema_fields.rb +25 -0
- data/app/services/postnhost/management/serializer.rb +40 -0
- data/app/services/postnhost/management/settings_actions.rb +64 -0
- data/app/services/postnhost/management/upload.rb +18 -0
- data/app/services/postnhost/translation/enqueue.rb +59 -0
- data/app/views/layouts/postnhost/_sidebar.html.erb +7 -0
- data/app/views/layouts/postnhost/application.html.erb +2 -2
- data/app/views/layouts/postnhost/articles.html.erb +3 -3
- data/app/views/layouts/postnhost/product.html.erb +3 -3
- data/app/views/layouts/postnhost/public/templates/default.html.erb +2 -2
- data/app/views/layouts/postnhost/public/templates/swiss-editorial.html.erb +2 -2
- data/app/views/layouts/postnhost/public/templates/workspace-journal.html.erb +2 -2
- data/app/views/postnhost/api_tokens/index.html.erb +92 -0
- data/app/views/postnhost/onboarding/_post_install_checklist.html.erb +8 -8
- data/app/views/postnhost/pages/index.html.erb +1 -1
- data/app/views/postnhost/templates/edit.html.erb +1 -1
- data/config/routes.rb +12 -0
- data/db/migrate/20261008120000_create_api_tokens.rb +12 -0
- data/lib/generators/postnhost/install/install_generator.rb +11 -12
- data/lib/generators/postnhost/locale/locale_generator.rb +1 -1
- data/lib/generators/postnhost/static_pages/static_pages_generator.rb +1 -1
- data/lib/generators/postnhost/tailwindcss/install_generator.rb +28 -25
- data/lib/generators/postnhost/tailwindcss/templates/host.tailwind.css +0 -3
- data/lib/generators/postnhost/user/user_generator.rb +1 -1
- data/lib/generators/postnhost/views/views_generator.rb +5 -7
- data/lib/postnhost/css_build.rb +68 -0
- data/lib/postnhost/css_scope.rb +77 -0
- data/lib/postnhost/sample_data.rb +1 -1
- data/lib/postnhost/tailwind_compiler.rb +75 -0
- data/lib/postnhost/tailwind_input.rb +32 -0
- data/lib/postnhost/version.rb +1 -1
- data/lib/tasks/postnhost_tasks.rake +40 -65
- metadata +40 -2
- data/lib/generators/postnhost/tailwindcss/templates/tailwind.config.js +0 -8
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Postnhost
|
|
2
|
+
module Api
|
|
3
|
+
module V1
|
|
4
|
+
class McpController < Api::BaseController
|
|
5
|
+
before_action :validate_transport!
|
|
6
|
+
|
|
7
|
+
def create
|
|
8
|
+
message = JSON.parse(request.raw_post)
|
|
9
|
+
unless message.is_a?(Hash)
|
|
10
|
+
render json: { jsonrpc: "2.0", id: nil, error: { code: -32_600, message: "Invalid Request" } }, status: :bad_request
|
|
11
|
+
return
|
|
12
|
+
end
|
|
13
|
+
result = Management::Mcp.new(user: api_user, script_name: request.script_name).call(message)
|
|
14
|
+
result ? render(json: result) : head(:accepted)
|
|
15
|
+
rescue JSON::ParserError
|
|
16
|
+
render json: { jsonrpc: "2.0", id: nil, error: { code: -32_700, message: "Parse error" } }, status: :bad_request
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def unsupported
|
|
20
|
+
response.set_header("Allow", "POST")
|
|
21
|
+
head :method_not_allowed
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def validate_transport!
|
|
27
|
+
origin = request.headers["Origin"]
|
|
28
|
+
if origin.present? && origin != request.base_url
|
|
29
|
+
head :forbidden
|
|
30
|
+
return
|
|
31
|
+
end
|
|
32
|
+
version = request.headers["MCP-Protocol-Version"]
|
|
33
|
+
if version.present? && Management::Mcp::VERSIONS.exclude?(version)
|
|
34
|
+
head :bad_request
|
|
35
|
+
return
|
|
36
|
+
end
|
|
37
|
+
return unless request.post?
|
|
38
|
+
|
|
39
|
+
unless request.media_type == "application/json"
|
|
40
|
+
head :unsupported_media_type
|
|
41
|
+
return
|
|
42
|
+
end
|
|
43
|
+
accept = request.headers["Accept"].to_s
|
|
44
|
+
head :not_acceptable unless accept.include?("application/json") && accept.include?("text/event-stream")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module Postnhost
|
|
2
|
+
class ApiTokensController < ApplicationController
|
|
3
|
+
before_action :authenticate_user!
|
|
4
|
+
before_action :prevent_caching
|
|
5
|
+
layout "postnhost/product"
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
@api_tokens = current_user.api_tokens.order(created_at: :desc)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def create
|
|
12
|
+
@api_token, @secret = ApiToken.issue!(user: current_user, **token_params.to_h.symbolize_keys)
|
|
13
|
+
@api_tokens = current_user.api_tokens.order(created_at: :desc)
|
|
14
|
+
render :index
|
|
15
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
16
|
+
redirect_to api_tokens_path, alert: e.record.errors.full_messages.to_sentence
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def destroy
|
|
20
|
+
current_user.api_tokens.find(params[:id]).revoke!
|
|
21
|
+
redirect_to api_tokens_path, notice: "API token revoked."
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def prevent_caching
|
|
27
|
+
response.set_header("Cache-Control", "no-store")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def token_params
|
|
31
|
+
params.require(:api_token).permit(:name)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -6,15 +6,8 @@ module Postnhost
|
|
|
6
6
|
def new; end
|
|
7
7
|
|
|
8
8
|
def create
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
language_ids.each do |language_id|
|
|
12
|
-
Postnhost::Translation::ArticleVariantJob.perform_later(@article.id, language_id.to_i)
|
|
13
|
-
|
|
14
|
-
@article.article_variants.create!(language: Postnhost::Language.find_by(id: language_id.to_i), generating: true)
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
redirect_to article_variants_path(@article)
|
|
9
|
+
result = Postnhost::Translation::Enqueue.call(record: @article, language_ids: Array(params[:language_ids]).map(&:to_i).uniq)
|
|
10
|
+
redirect_to article_variants_path(@article), **(result.success? ? {} : { alert: result.errors.to_sentence })
|
|
18
11
|
end
|
|
19
12
|
|
|
20
13
|
private
|
|
@@ -4,15 +4,8 @@ module Postnhost
|
|
|
4
4
|
before_action :load_category
|
|
5
5
|
|
|
6
6
|
def create
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
language_ids.each do |language_id|
|
|
10
|
-
Postnhost::Translation::CategoryVariantJob.perform_later(@category.id, language_id.to_i)
|
|
11
|
-
|
|
12
|
-
@category.category_variants.create!(language: Postnhost::Language.find_by(id: language_id.to_i), generating: true)
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
redirect_to category_variants_path(@category)
|
|
7
|
+
result = Postnhost::Translation::Enqueue.call(record: @category, language_ids: Array(params[:language_ids]).map(&:to_i).uniq)
|
|
8
|
+
redirect_to category_variants_path(@category), **(result.success? ? {} : { alert: result.errors.to_sentence })
|
|
16
9
|
end
|
|
17
10
|
|
|
18
11
|
private
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Postnhost
|
|
2
|
+
# Used only by Management::Preview for an authenticated in-process render.
|
|
3
|
+
# There are no routes to this controller.
|
|
4
|
+
class ManagementPreviewController < Public::ArticlesController
|
|
5
|
+
helper_method :postnhost
|
|
6
|
+
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def postnhost
|
|
10
|
+
@postnhost ||= ActionDispatch::Routing::RoutesProxy.new(Engine.routes, self, Engine.routes.url_helpers)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def current_user
|
|
14
|
+
request.env.fetch("postnhost.management_user")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -6,15 +6,8 @@ module Postnhost
|
|
|
6
6
|
def new; end
|
|
7
7
|
|
|
8
8
|
def create
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
language_ids.each do |language_id|
|
|
12
|
-
Postnhost::Translation::PageVariantJob.perform_later(@page.id, language_id.to_i)
|
|
13
|
-
|
|
14
|
-
@page.page_variants.create!(language: Postnhost::Language.find_by(id: language_id.to_i), generating: true)
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
redirect_to page_variants_path(@page)
|
|
9
|
+
result = Postnhost::Translation::Enqueue.call(record: @page, language_ids: Array(params[:language_ids]).map(&:to_i).uniq)
|
|
10
|
+
redirect_to page_variants_path(@page), **(result.success? ? {} : { alert: result.errors.to_sentence })
|
|
18
11
|
end
|
|
19
12
|
|
|
20
13
|
private
|
|
@@ -13,17 +13,6 @@ module Postnhost
|
|
|
13
13
|
{ field: :bluesky_url, label: "Bluesky", icon: "bluesky" }
|
|
14
14
|
].freeze
|
|
15
15
|
|
|
16
|
-
def postnhost_stylesheet_assets
|
|
17
|
-
assets = ["postnhost/application"]
|
|
18
|
-
assets << "postnhost/host" if postnhost_host_tailwind_enabled?
|
|
19
|
-
assets
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def postnhost_host_tailwind_enabled?
|
|
23
|
-
Rails.root.join("postnhost.tailwind.config.js").exist? &&
|
|
24
|
-
Rails.root.join("app/assets/stylesheets/postnhost/host.tailwind.css").exist?
|
|
25
|
-
end
|
|
26
|
-
|
|
27
16
|
def postnhost_openai_api_key_configured?
|
|
28
17
|
Postnhost.config.openai_api_key.present?
|
|
29
18
|
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
|
|
4
|
+
module Postnhost
|
|
5
|
+
class ApiToken < ApplicationRecord
|
|
6
|
+
belongs_to :user, class_name: "Postnhost::User"
|
|
7
|
+
validates :name, :token_digest, presence: true
|
|
8
|
+
validates :token_digest, uniqueness: true
|
|
9
|
+
|
|
10
|
+
def self.issue!(user:, name:)
|
|
11
|
+
secret = "pnh_#{SecureRandom.urlsafe_base64(32)}"
|
|
12
|
+
token = create!(user:, name:, token_digest: Digest::SHA256.hexdigest(secret))
|
|
13
|
+
[token, secret]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.authenticate(secret)
|
|
17
|
+
return if secret.blank?
|
|
18
|
+
|
|
19
|
+
find_by(token_digest: Digest::SHA256.hexdigest(secret), revoked_at: nil)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def revoke!
|
|
23
|
+
update!(revoked_at: Time.current)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Postnhost
|
|
2
|
+
module Api
|
|
3
|
+
module V1
|
|
4
|
+
module Openapi
|
|
5
|
+
def self.document(server:)
|
|
6
|
+
{ openapi: "3.1.0", info: { title: "PostnHost CMS management API", version: "1" }, servers: [{ url: server }],
|
|
7
|
+
security: [{ bearerAuth: [] }], components: { securitySchemes: { bearerAuth: { type: "http", scheme: "bearer" } } },
|
|
8
|
+
paths: Management::Catalog.tools.to_h { |tool| ["/commands/#{tool[:name]}", { post: operation(tool) }] } }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.operation(tool)
|
|
12
|
+
{ operationId: tool[:name], summary: tool[:description],
|
|
13
|
+
requestBody: { required: true, content: { "application/json" => { schema: { type: "object", required: ["arguments"], properties: { arguments: tool[:inputSchema] } } } } },
|
|
14
|
+
responses: {
|
|
15
|
+
"200" => { description: "Command completed. JSON contains data and errors." },
|
|
16
|
+
"202" => { description: "Translation queued. Poll the variant's generating field." },
|
|
17
|
+
"400" => { description: "Invalid arguments." }, "401" => { description: "Invalid bearer token." },
|
|
18
|
+
"404" => { description: "Record not found in the requested scope." },
|
|
19
|
+
"409" => { description: "Conflicting data or protected action." },
|
|
20
|
+
"422" => { description: "Validation or publication failure. Bulk results contain per-record failures." },
|
|
21
|
+
"503" => { description: "Translation queue unavailable. Data identifies queued work and failed_language_ids to retry." }
|
|
22
|
+
} }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
module Postnhost
|
|
2
|
+
module Management
|
|
3
|
+
module Catalog
|
|
4
|
+
INTEGER = { type: "integer", minimum: 1 }.freeze
|
|
5
|
+
IDS = { type: "array", items: INTEGER, minItems: 1, maxItems: 100, uniqueItems: true }.freeze
|
|
6
|
+
FILE = { type: "object", properties: { filename: { type: "string" }, content_type: { type: "string" }, base64: { type: "string" } }, required: %w[filename content_type base64], additionalProperties: false }.freeze
|
|
7
|
+
READ_ACTIONS = %w[list get versions version_preview preview get_navigation get_translations].freeze
|
|
8
|
+
|
|
9
|
+
def self.tools
|
|
10
|
+
@tools ||= freeze_schema(build_tools)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.build_tools
|
|
14
|
+
Resources::DEFINITIONS.flat_map do |resource, definition|
|
|
15
|
+
actions = definition[:singleton] ? %w[get update] : %w[list get create update delete]
|
|
16
|
+
actions.delete("update") if resource == "api_tokens"
|
|
17
|
+
actions += %w[publish unpublish] if %w[articles pages article_variants page_variants].include?(resource)
|
|
18
|
+
actions += %w[translate] if %w[articles pages categories].include?(resource)
|
|
19
|
+
actions += %w[bulk_publish bulk_unpublish bulk_delete] if %w[article_variants page_variants].include?(resource)
|
|
20
|
+
actions += %w[versions version_preview restore preview upload_image upload_cover remove_cover] if resource == "articles"
|
|
21
|
+
actions += %w[preview upload_image] if resource == "pages"
|
|
22
|
+
actions += %w[get_navigation replace_navigation get_translations update_translations upload_logo upload_og_image remove_logo remove_og_image] if resource == "settings"
|
|
23
|
+
actions += %w[preview] if resource == "template"
|
|
24
|
+
actions += %w[upload_avatar] if resource == "users"
|
|
25
|
+
actions.map { |action| tool(resource, action, definition) }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.fetch(name)
|
|
30
|
+
@tools_by_name ||= tools.index_by { |tool| tool[:name] }.freeze
|
|
31
|
+
@tools_by_name.fetch(name) { raise ArgumentError, "Unknown command: #{name}" }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.tool(resource, action, definition)
|
|
35
|
+
read = READ_ACTIONS.include?(action)
|
|
36
|
+
detail = if action == "list"
|
|
37
|
+
"Returns compact summaries; use get for full content, relationships and draft comparisons."
|
|
38
|
+
elsif read
|
|
39
|
+
"Reads CMS draft data."
|
|
40
|
+
else
|
|
41
|
+
"Uses CMS validations and publication workflows."
|
|
42
|
+
end
|
|
43
|
+
{ name: "#{resource}_#{action}", description: "#{action.tr('_', ' ').capitalize} #{resource.tr('_', ' ')}. #{detail}",
|
|
44
|
+
inputSchema: schema(resource, action, definition),
|
|
45
|
+
annotations: { readOnlyHint: read, destructiveHint: !read, idempotentHint: read || %w[update unpublish delete].include?(action) || action.start_with?("remove_"), openWorldHint: action == "translate" } }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.schema(resource, action, definition)
|
|
49
|
+
properties = {}
|
|
50
|
+
required = []
|
|
51
|
+
if definition[:parent]
|
|
52
|
+
properties["parent_id"] = INTEGER
|
|
53
|
+
required << "parent_id"
|
|
54
|
+
end
|
|
55
|
+
unless definition[:singleton] || %w[list create translate].include?(action) || action.start_with?("bulk_")
|
|
56
|
+
properties["id"] = INTEGER
|
|
57
|
+
required << "id"
|
|
58
|
+
end
|
|
59
|
+
case action
|
|
60
|
+
when "list"
|
|
61
|
+
properties["page"] = INTEGER
|
|
62
|
+
properties["limit"] = { type: "integer", minimum: 1, maximum: 100 }
|
|
63
|
+
properties["language_id"] = INTEGER if definition[:fields].include?("language_id")
|
|
64
|
+
properties.merge!("status" => { type: "string", enum: Article::STATUSES }, "sort" => { type: "string", enum: Article::SORT_OPTIONS.keys }, "mine" => { type: "boolean" }, "category_id" => INTEGER, "user_id" => INTEGER) if resource == "articles"
|
|
65
|
+
when "preview"
|
|
66
|
+
properties["name"] = { type: "string", enum: Template::NAMES } if resource == "template"
|
|
67
|
+
required << "name" if resource == "template"
|
|
68
|
+
when "create", "update"
|
|
69
|
+
properties["attributes"] = { type: "object", properties: definition[:fields].index_with { |field| field_schema(field, resource) }, additionalProperties: false }
|
|
70
|
+
required << "attributes"
|
|
71
|
+
when "translate"
|
|
72
|
+
properties["id"] = INTEGER
|
|
73
|
+
properties["language_ids"] = IDS
|
|
74
|
+
required += %w[id language_ids]
|
|
75
|
+
when "bulk_publish", "bulk_unpublish", "bulk_delete"
|
|
76
|
+
properties["ids"] = IDS
|
|
77
|
+
required << "ids"
|
|
78
|
+
when "restore", "version_preview"
|
|
79
|
+
properties["version_id"] = INTEGER
|
|
80
|
+
required << "version_id"
|
|
81
|
+
when "replace_navigation"
|
|
82
|
+
properties["tree"] = { type: "object", properties: { header: { type: "array", items: navigation_item_schema, maxItems: 100 }, footer: { type: "array", items: navigation_item_schema, maxItems: 100 } }, required: %w[header footer], additionalProperties: false }
|
|
83
|
+
required << "tree"
|
|
84
|
+
properties["locale"] = { type: "string" }
|
|
85
|
+
required << "locale"
|
|
86
|
+
when "get_navigation", "get_translations", "update_translations"
|
|
87
|
+
properties["locale"] = { type: "string" }
|
|
88
|
+
required << "locale"
|
|
89
|
+
if action == "update_translations"
|
|
90
|
+
properties["translations"] = { type: "object", additionalProperties: { type: "string" } }
|
|
91
|
+
required << "translations"
|
|
92
|
+
end
|
|
93
|
+
else
|
|
94
|
+
if action.start_with?("upload_")
|
|
95
|
+
properties["file"] = FILE
|
|
96
|
+
required << "file"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
{ type: "object", properties:, required:, additionalProperties: false }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.navigation_item_schema(depth = 0)
|
|
103
|
+
properties = {
|
|
104
|
+
id: INTEGER, kind: { type: "string", enum: NavigationItem.kinds.keys },
|
|
105
|
+
target_kind: { type: %w[string null], enum: NavigationItem.target_kinds.keys + [nil] },
|
|
106
|
+
target_id: { type: %w[integer null] }, target_slug: { type: %w[string null] },
|
|
107
|
+
url: { type: %w[string null] }, label: { type: %w[string null] }, nofollow: { type: "boolean" }
|
|
108
|
+
}
|
|
109
|
+
properties[:children] = { type: "array", items: navigation_item_schema(depth + 1), maxItems: 100 } if depth < 2
|
|
110
|
+
{ type: "object", properties:, required: ["kind"], additionalProperties: false }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# This module is reloadable: schemas and their name index disappear with it.
|
|
114
|
+
# Freeze nested values so one consumer cannot modify another client's contract.
|
|
115
|
+
def self.freeze_schema(value)
|
|
116
|
+
case value
|
|
117
|
+
when Hash then value.each do |key, child|
|
|
118
|
+
freeze_schema(key)
|
|
119
|
+
freeze_schema(child)
|
|
120
|
+
end
|
|
121
|
+
when Array then value.each { |child| freeze_schema(child) }
|
|
122
|
+
end
|
|
123
|
+
value.freeze
|
|
124
|
+
end
|
|
125
|
+
private_class_method :build_tools, :freeze_schema
|
|
126
|
+
|
|
127
|
+
def self.field_schema(field, resource)
|
|
128
|
+
return { type: "string", enum: Template::NAMES } if resource == "template" && field == "name"
|
|
129
|
+
return { type: "string", enum: SiteScript::PLACEMENTS } if field == "placement"
|
|
130
|
+
|
|
131
|
+
return { type: %w[string null], format: "date-time" } if field == "scheduled_at"
|
|
132
|
+
|
|
133
|
+
if field.end_with?("_ids")
|
|
134
|
+
{ type: "array", items: INTEGER, maxItems: 100, uniqueItems: true }
|
|
135
|
+
elsif field.end_with?("_id") || field == "public_page_size"
|
|
136
|
+
{ type: %w[integer null] }
|
|
137
|
+
elsif %w[default top_pick use_excerpt_as_meta_description author_pages_enabled search_enabled show_powered_by use_auto_header_navigation use_auto_footer_navigation].include?(field)
|
|
138
|
+
{ type: "boolean" }
|
|
139
|
+
elsif field == "schema_settings"
|
|
140
|
+
SchemaFields.settings
|
|
141
|
+
elsif field == "schema_profile"
|
|
142
|
+
SchemaFields.profile
|
|
143
|
+
elsif field == "schema_locale_overrides"
|
|
144
|
+
SchemaFields.overrides(resource)
|
|
145
|
+
else
|
|
146
|
+
{ type: %w[string null] }
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
module Postnhost
|
|
2
|
+
module Management
|
|
3
|
+
class Command < BaseService
|
|
4
|
+
def initialize(name:, arguments:, user:, script_name: "")
|
|
5
|
+
@name = name
|
|
6
|
+
@arguments = arguments.deep_stringify_keys
|
|
7
|
+
@user = user
|
|
8
|
+
@script_name = script_name
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call
|
|
12
|
+
return failure("Authentication required", status: :unauthorized) unless user
|
|
13
|
+
|
|
14
|
+
tool = Catalog.fetch(name)
|
|
15
|
+
Input.validate!(arguments, tool[:inputSchema])
|
|
16
|
+
resource = Resources::DEFINITIONS.keys.find { |key| name.start_with?("#{key}_") }
|
|
17
|
+
action = name.delete_prefix("#{resource}_")
|
|
18
|
+
result = PaperTrail.request(whodunnit: user.id.to_s) do
|
|
19
|
+
Time.use_zone(Setting.current.effective_timezone_name) { execute(resource, action) }
|
|
20
|
+
end
|
|
21
|
+
result.is_a?(ServiceResult) ? serialize_result(result) : success(Serializer.call(result), status: :ok)
|
|
22
|
+
rescue ActiveRecord::RecordNotFound
|
|
23
|
+
failure("Record not found", status: :not_found)
|
|
24
|
+
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotDestroyed => e
|
|
25
|
+
failure(e.record.errors.full_messages)
|
|
26
|
+
rescue ActiveRecord::RecordNotUnique, ActiveRecord::InvalidForeignKey
|
|
27
|
+
failure("Record conflicts with existing or referenced data", status: :conflict)
|
|
28
|
+
rescue ActiveRecord::StaleObjectError
|
|
29
|
+
failure("Record changed; reload and retry", status: :conflict)
|
|
30
|
+
rescue ArgumentError, TypeError => e
|
|
31
|
+
failure(e.message, status: :bad_request)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
attr_reader :name, :arguments, :user, :script_name
|
|
37
|
+
|
|
38
|
+
def serialize_result(result)
|
|
39
|
+
ServiceResult.new(value: Serializer.call(result.value), errors: result.errors, status: result.status || :ok)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def execute(resource, action)
|
|
43
|
+
definition = Resources::DEFINITIONS.fetch(resource)
|
|
44
|
+
scope = scope_for(resource, definition)
|
|
45
|
+
return list(scope, resource) if action == "list"
|
|
46
|
+
return create(scope, resource, definition) if action == "create"
|
|
47
|
+
return ContentActions.call(resource:, action:, scope:, arguments:) if action.start_with?("bulk_")
|
|
48
|
+
|
|
49
|
+
scope = scope.select(:id) if action == "versions"
|
|
50
|
+
record = definition[:singleton] ? Resources.model(resource).current : scope.find(arguments.fetch("id"))
|
|
51
|
+
case action
|
|
52
|
+
when "get" then record
|
|
53
|
+
when "update" then update(record)
|
|
54
|
+
when "delete" then delete(record)
|
|
55
|
+
when "preview"
|
|
56
|
+
if resource == "template"
|
|
57
|
+
Preview.call(user:, template: arguments.fetch("name"), script_name:)
|
|
58
|
+
elsif resource == "articles"
|
|
59
|
+
Preview.call(user:, record:, script_name:).merge(draft: record)
|
|
60
|
+
else
|
|
61
|
+
ContentActions.call(resource:, action:, scope:, record:, arguments:)
|
|
62
|
+
end
|
|
63
|
+
else ContentActions.call(resource:, action:, scope:, record:, arguments:)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def scope_for(resource, definition)
|
|
68
|
+
if definition[:parent]
|
|
69
|
+
parent = Resources.model(definition[:parent]).find(arguments.fetch("parent_id"))
|
|
70
|
+
parent.public_send(resource)
|
|
71
|
+
elsif resource == "api_tokens"
|
|
72
|
+
user.api_tokens
|
|
73
|
+
elsif resource == "site_scripts"
|
|
74
|
+
Setting.current.site_scripts
|
|
75
|
+
else
|
|
76
|
+
Resources.model(resource).all
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def list(scope, resource)
|
|
81
|
+
scope = scope.where(language_id: arguments["language_id"]) if arguments["language_id"] && scope.klass.column_names.include?("language_id")
|
|
82
|
+
if resource == "articles"
|
|
83
|
+
scope = scope.where(user_id: user.id) if arguments["mine"]
|
|
84
|
+
scope = scope.joins(:article_categories).where(postnhost_article_categories: { category_id: arguments["category_id"] }) if arguments["category_id"]
|
|
85
|
+
scope = scope.where(user_id: arguments["user_id"]) if arguments["user_id"]
|
|
86
|
+
scope = scope.with_status(arguments["status"]).sorted_by(arguments["sort"])
|
|
87
|
+
else
|
|
88
|
+
scope = scope.order(:id)
|
|
89
|
+
end
|
|
90
|
+
limit = arguments.fetch("limit", 25)
|
|
91
|
+
page = arguments.fetch("page", 1)
|
|
92
|
+
records = summary_scope(scope, resource).limit(limit).offset((page - 1) * limit)
|
|
93
|
+
result = { records: records.map { |record| Serializer.summary(record) }, total: scope.count, page:, limit: }
|
|
94
|
+
result[:static_pages] = static_page_slugs if resource == "pages"
|
|
95
|
+
result
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def summary_scope(scope, resource)
|
|
99
|
+
table = scope.klass.arel_table
|
|
100
|
+
fields = Serializer::SUMMARY_FIELDS & scope.klass.column_names
|
|
101
|
+
records = scope.select(*fields.map { |field| table[field] })
|
|
102
|
+
snapshot = Resources::DEFINITIONS.fetch(resource)[:snapshot]
|
|
103
|
+
return records unless snapshot
|
|
104
|
+
|
|
105
|
+
association = scope.klass.reflect_on_association(snapshot)
|
|
106
|
+
snapshot_table = association.klass.arel_table
|
|
107
|
+
snapshot_id = association.klass.where(snapshot_table[association.foreign_key].eq(table[:id])).select(:id).limit(1)
|
|
108
|
+
records.select(snapshot_id.arel.as("management_snapshot_id"))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def static_page_slugs
|
|
112
|
+
[Rails.root, Engine.root].flat_map { |root| Dir[root.join("app/views/postnhost/static_pages/*.html.erb").to_s] }
|
|
113
|
+
.map { |path| File.basename(path, ".html.erb") }.uniq.sort
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def create(scope, resource, definition)
|
|
117
|
+
attrs = arguments.fetch("attributes")
|
|
118
|
+
if resource == "api_tokens"
|
|
119
|
+
token, secret = ApiToken.issue!(user:, **attrs.symbolize_keys)
|
|
120
|
+
return { token:, secret: }
|
|
121
|
+
end
|
|
122
|
+
if %w[articles pages].include?(resource)
|
|
123
|
+
attrs = { "language_id" => Language.blog_default&.id }.merge(attrs).merge("user_id" => user.id)
|
|
124
|
+
elsif definition[:parent] && resource != "category_variants"
|
|
125
|
+
parent = Resources.model(definition[:parent]).find(arguments.fetch("parent_id"))
|
|
126
|
+
copied = parent.attributes.slice(*definition[:fields]).except("language_id")
|
|
127
|
+
attrs = copied.merge(attrs)
|
|
128
|
+
end
|
|
129
|
+
validate_schema_locales!(attrs)
|
|
130
|
+
record = scope.create!(attrs)
|
|
131
|
+
record.sync_publication_schedule! if record.is_a?(Article)
|
|
132
|
+
record
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def update(record)
|
|
136
|
+
raise ArgumentError, "Translation is still generating" if record.respond_to?(:generating?) && record.generating?
|
|
137
|
+
|
|
138
|
+
attrs = arguments.fetch("attributes")
|
|
139
|
+
attrs = attrs.except("password", "password_confirmation") if record.is_a?(User) && attrs["password"].blank?
|
|
140
|
+
validate_schema_locales!(attrs)
|
|
141
|
+
record.update!(attrs)
|
|
142
|
+
record.sync_publication_schedule! if record.is_a?(Article)
|
|
143
|
+
record
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def validate_schema_locales!(attributes)
|
|
147
|
+
return unless attributes["schema_locale_overrides"]
|
|
148
|
+
|
|
149
|
+
unknown = attributes["schema_locale_overrides"].keys - Language.pluck(:html_lang)
|
|
150
|
+
raise ArgumentError, "Schema locales must belong to configured languages" if unknown.any?
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def delete(record)
|
|
154
|
+
if record.is_a?(ApiToken)
|
|
155
|
+
record.revoke!
|
|
156
|
+
return { revoked: true, id: record.id }
|
|
157
|
+
end
|
|
158
|
+
if record.is_a?(User)
|
|
159
|
+
return failure("You cannot remove your own account", status: :conflict) if record == user
|
|
160
|
+
return failure("Author owns pages; reassign or remove them first", status: :conflict) if record.pages.exists?
|
|
161
|
+
|
|
162
|
+
User.transaction do
|
|
163
|
+
Article.where(user_id: record.id).update_all(user_id: nil, updated_at: Time.current)
|
|
164
|
+
record.article_authors.delete_all
|
|
165
|
+
record.destroy!
|
|
166
|
+
end
|
|
167
|
+
else
|
|
168
|
+
record.destroy!
|
|
169
|
+
end
|
|
170
|
+
{ deleted: true, id: record.id }
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
module Postnhost
|
|
2
|
+
module Management
|
|
3
|
+
class ContentActions < BaseService
|
|
4
|
+
def initialize(resource:, action:, scope:, arguments:, record: nil)
|
|
5
|
+
@resource = resource
|
|
6
|
+
@action = action
|
|
7
|
+
@scope = scope
|
|
8
|
+
@arguments = arguments
|
|
9
|
+
@record = record
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
case action
|
|
14
|
+
when "publish", "unpublish" then transition
|
|
15
|
+
when "bulk_publish", "bulk_unpublish" then bulk_transition
|
|
16
|
+
when "bulk_delete" then bulk_delete
|
|
17
|
+
when "translate" then Translation::Enqueue.call(record:, language_ids: arguments.fetch("language_ids"))
|
|
18
|
+
when "versions" then versions
|
|
19
|
+
when "version_preview" then record.versions.find(arguments.fetch("version_id")).reify
|
|
20
|
+
when "restore" then Publishing::Versions::RestoreDraft.call(record:, version: record.versions.find(arguments.fetch("version_id")))
|
|
21
|
+
when "preview" then { draft: record, html: record.content }
|
|
22
|
+
when "remove_cover"
|
|
23
|
+
record.update_column(:cover_image, nil)
|
|
24
|
+
record.reload
|
|
25
|
+
else
|
|
26
|
+
SettingsActions.call(action:, record:, arguments:)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
attr_reader :resource, :action, :scope, :arguments, :record
|
|
33
|
+
|
|
34
|
+
def versions
|
|
35
|
+
columns = record.versions.klass.column_names - ["object"]
|
|
36
|
+
record.versions.select(*columns).order(id: :desc).map(&:attributes)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def transition
|
|
40
|
+
return failure("Translation is still generating", status: :conflict) if record.respond_to?(:generating?) && record.generating?
|
|
41
|
+
|
|
42
|
+
service = publishing_module.const_get(action.classify, false)
|
|
43
|
+
result = service.call(resource.singularize.to_sym => record)
|
|
44
|
+
result.success? ? success(record.reload, status: result.status) : result
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def bulk_transition
|
|
48
|
+
parent_resource = Resources::DEFINITIONS.fetch(resource).fetch(:parent)
|
|
49
|
+
parent = Resources.model(parent_resource).find(arguments.fetch("parent_id"))
|
|
50
|
+
service = publishing_module.const_get("#{action.delete_prefix('bulk_').classify}Many", false)
|
|
51
|
+
service.call(parent_resource.singularize.to_sym => parent, ids: arguments.fetch("ids"))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def publishing_module
|
|
55
|
+
Publishing.const_get(resource.camelize, false)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def bulk_delete
|
|
59
|
+
records = scope.where(id: arguments.fetch("ids")).order(:id)
|
|
60
|
+
failures = (arguments.fetch("ids") - records.pluck(:id)).index_with { ["Translation not found"] }
|
|
61
|
+
deleted = []
|
|
62
|
+
records.each do |variant|
|
|
63
|
+
if variant.generating?
|
|
64
|
+
failures[variant.id] = ["Translation is still generating"]
|
|
65
|
+
elsif variant.destroy
|
|
66
|
+
deleted << variant.id
|
|
67
|
+
else
|
|
68
|
+
failures[variant.id] = variant.errors.full_messages
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
success({ deleted:, failures: }, status: failures.empty? ? :ok : :unprocessable_content)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|