postnhost 0.1.1 → 0.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -0
- data/README.md +27 -0
- data/app/assets/builds/postnhost/application.css +1 -1
- data/app/controllers/postnhost/api/base_controller.rb +34 -0
- data/app/controllers/postnhost/api/v1/commands_controller.rb +27 -0
- data/app/controllers/postnhost/api/v1/mcp_controller.rb +53 -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/articles_controller.rb +5 -6
- data/app/controllers/postnhost/categories/translations_controller.rb +2 -9
- data/app/controllers/postnhost/management_preview_controller.rb +17 -0
- data/app/controllers/postnhost/oauth/authorizations_controller.rb +50 -0
- data/app/controllers/postnhost/oauth/metadata_controller.rb +25 -0
- data/app/controllers/postnhost/oauth/public_controller.rb +16 -0
- data/app/controllers/postnhost/oauth/registrations_controller.rb +29 -0
- data/app/controllers/postnhost/oauth/tokens_controller.rb +19 -0
- data/app/controllers/postnhost/pages/translations_controller.rb +2 -9
- data/app/controllers/postnhost/sessions_controller.rb +2 -1
- data/app/models/postnhost/api_token.rb +26 -0
- data/app/models/postnhost/oauth_client.rb +29 -0
- data/app/models/postnhost/oauth_grant.rb +32 -0
- data/app/models/postnhost/user.rb +3 -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/postnhost/api_tokens/index.html.erb +92 -0
- data/app/views/postnhost/oauth/authorizations/new.html.erb +18 -0
- data/config/routes.rb +21 -0
- data/db/migrate/20261008120000_create_api_tokens.rb +12 -0
- data/db/migrate/20261025120000_add_mcp_oauth.rb +23 -0
- data/lib/postnhost/engine.rb +7 -0
- data/lib/postnhost/oauth_discovery.rb +22 -0
- data/lib/postnhost/version.rb +1 -1
- metadata +32 -1
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module Postnhost
|
|
2
|
+
module Api
|
|
3
|
+
# Bearer-only boundary intentionally excludes host session callbacks.
|
|
4
|
+
# rubocop:disable-next Rails/ApplicationController
|
|
5
|
+
class BaseController < ActionController::Base
|
|
6
|
+
skip_forgery_protection
|
|
7
|
+
before_action :prevent_caching
|
|
8
|
+
before_action :authenticate_token!
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
attr_reader :api_user
|
|
13
|
+
|
|
14
|
+
def authenticate_token!
|
|
15
|
+
scheme, secret = request.authorization.to_s.split(" ", 2)
|
|
16
|
+
token = ApiToken.authenticate(secret) if scheme&.casecmp?("Bearer")
|
|
17
|
+
token = nil if token&.resource.present? && token.resource != request.base_url + request.path
|
|
18
|
+
@api_user = token&.user
|
|
19
|
+
return if api_user
|
|
20
|
+
|
|
21
|
+
response.set_header("WWW-Authenticate", bearer_challenge)
|
|
22
|
+
render json: { errors: ["A valid bearer token is required"] }, status: :unauthorized
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def bearer_challenge
|
|
26
|
+
'Bearer realm="PostnHost"'
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def prevent_caching
|
|
30
|
+
response.set_header("Cache-Control", "no-store")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Postnhost
|
|
2
|
+
module Api
|
|
3
|
+
module V1
|
|
4
|
+
class CommandsController < Api::BaseController
|
|
5
|
+
def index
|
|
6
|
+
render json: { commands: Management::Catalog.tools }
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def schema
|
|
10
|
+
server = request.base_url + api_v1_commands_path.delete_suffix("/commands")
|
|
11
|
+
render json: Openapi.document(server:)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def create
|
|
15
|
+
input = params.fetch(:arguments, ActionController::Parameters.new)
|
|
16
|
+
unless input.is_a?(ActionController::Parameters)
|
|
17
|
+
render json: { errors: ["arguments must be an object"] }, status: :bad_request
|
|
18
|
+
return
|
|
19
|
+
end
|
|
20
|
+
arguments = input.to_unsafe_h
|
|
21
|
+
result = Management::Command.call(name: params[:command], arguments:, user: api_user, script_name: request.script_name)
|
|
22
|
+
render json: { data: result.value, errors: result.errors }, status: result.status
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
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 bearer_challenge
|
|
27
|
+
%(Bearer resource_metadata="#{postnhost.oauth_resource_metadata_url}", scope="mcp")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def validate_transport!
|
|
31
|
+
origin = request.headers["Origin"]
|
|
32
|
+
if origin.present? && origin != request.base_url
|
|
33
|
+
head :forbidden
|
|
34
|
+
return
|
|
35
|
+
end
|
|
36
|
+
version = request.headers["MCP-Protocol-Version"]
|
|
37
|
+
if version.present? && Management::Mcp::VERSIONS.exclude?(version)
|
|
38
|
+
head :bad_request
|
|
39
|
+
return
|
|
40
|
+
end
|
|
41
|
+
return unless request.post?
|
|
42
|
+
|
|
43
|
+
unless request.media_type == "application/json"
|
|
44
|
+
head :unsupported_media_type
|
|
45
|
+
return
|
|
46
|
+
end
|
|
47
|
+
accept = request.headers["Accept"].to_s
|
|
48
|
+
head :not_acceptable unless accept.include?("application/json") && accept.include?("text/event-stream")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
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
|
|
@@ -11,10 +11,9 @@ module Postnhost
|
|
|
11
11
|
@status = Postnhost::Article.normalize_status(params[:status])
|
|
12
12
|
@sort = Postnhost::Article.normalize_sort(params[:sort])
|
|
13
13
|
|
|
14
|
-
articles =
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
.sorted_by(@sort)
|
|
14
|
+
articles = Postnhost::Article.includes(:language, :article_snapshot)
|
|
15
|
+
.with_status(@status)
|
|
16
|
+
.sorted_by(@sort)
|
|
18
17
|
|
|
19
18
|
@pagy, @articles = pagy(:offset, articles, limit: PAGE_SIZE)
|
|
20
19
|
end
|
|
@@ -30,7 +29,7 @@ module Postnhost
|
|
|
30
29
|
@categories = Postnhost::Category.all
|
|
31
30
|
@users = Postnhost::User.order(:name, :email)
|
|
32
31
|
@languages = Postnhost::Language.order(default: :desc, name: :asc)
|
|
33
|
-
@articles =
|
|
32
|
+
@articles = Postnhost::Article.where.not(id: @article.id).order(:title)
|
|
34
33
|
end
|
|
35
34
|
|
|
36
35
|
def update
|
|
@@ -45,7 +44,7 @@ module Postnhost
|
|
|
45
44
|
@categories = Postnhost::Category.all
|
|
46
45
|
@users = Postnhost::User.order(:name, :email)
|
|
47
46
|
@languages = Postnhost::Language.order(default: :desc, name: :asc)
|
|
48
|
-
@articles =
|
|
47
|
+
@articles = Postnhost::Article.where.not(id: @article.id).order(:title)
|
|
49
48
|
render :edit, status: :unprocessable_content
|
|
50
49
|
end
|
|
51
50
|
end
|
|
@@ -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
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Postnhost
|
|
2
|
+
module Oauth
|
|
3
|
+
class AuthorizationsController < ApplicationController
|
|
4
|
+
protect_from_forgery with: :exception
|
|
5
|
+
before_action :prevent_caching
|
|
6
|
+
before_action :validate_request!
|
|
7
|
+
before_action :require_oauth_user!
|
|
8
|
+
layout "postnhost/product"
|
|
9
|
+
|
|
10
|
+
def new; end
|
|
11
|
+
|
|
12
|
+
def create
|
|
13
|
+
result = if params[:decision] == "approve"
|
|
14
|
+
{ code: OauthGrant.issue!(oauth_client: @client, user: current_user,
|
|
15
|
+
**@oauth_request.slice(:redirect_uri, :resource, :code_challenge).symbolize_keys) }
|
|
16
|
+
else
|
|
17
|
+
{ error: "access_denied" }
|
|
18
|
+
end
|
|
19
|
+
uri = URI.parse(@oauth_request[:redirect_uri])
|
|
20
|
+
uri.query = [uri.query.presence, { **result, state: @oauth_request[:state] }.to_query].compact.join("&")
|
|
21
|
+
redirect_to uri.to_s, allow_other_host: true
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def prevent_caching
|
|
27
|
+
response.set_header("Cache-Control", "no-store")
|
|
28
|
+
response.set_header("Referrer-Policy", "no-referrer")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def validate_request!
|
|
32
|
+
@oauth_request = params.permit(:client_id, :redirect_uri, :response_type, :scope, :state,
|
|
33
|
+
:code_challenge, :code_challenge_method, :resource).to_h.with_indifferent_access
|
|
34
|
+
@client = OauthClient.find_by(client_id: @oauth_request[:client_id])
|
|
35
|
+
valid = @client&.redirect_uris&.include?(@oauth_request[:redirect_uri]) &&
|
|
36
|
+
@oauth_request[:response_type] == "code" && @oauth_request[:code_challenge_method] == "S256" &&
|
|
37
|
+
@oauth_request[:code_challenge].to_s.match?(/\A[A-Za-z0-9_-]{43}\z/) &&
|
|
38
|
+
@oauth_request[:scope].to_s.in?(["", "mcp"]) && @oauth_request[:resource] == postnhost.api_v1_mcp_url
|
|
39
|
+
render plain: "Invalid OAuth authorization request", status: :bad_request unless valid
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def require_oauth_user!
|
|
43
|
+
return if user_signed_in?
|
|
44
|
+
|
|
45
|
+
session[:oauth_return_to] = postnhost.oauth_authorize_path(@oauth_request)
|
|
46
|
+
authenticate_user!
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Postnhost
|
|
2
|
+
module Oauth
|
|
3
|
+
class MetadataController < PublicController
|
|
4
|
+
def resource
|
|
5
|
+
render json: {
|
|
6
|
+
resource: postnhost.api_v1_mcp_url,
|
|
7
|
+
authorization_servers: [request.base_url + request.script_name],
|
|
8
|
+
scopes_supported: ["mcp"], bearer_methods_supported: ["header"]
|
|
9
|
+
}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def server
|
|
13
|
+
render json: {
|
|
14
|
+
issuer: request.base_url + request.script_name,
|
|
15
|
+
authorization_endpoint: postnhost.oauth_authorize_url,
|
|
16
|
+
token_endpoint: postnhost.oauth_token_url,
|
|
17
|
+
registration_endpoint: postnhost.oauth_register_url,
|
|
18
|
+
response_types_supported: ["code"], grant_types_supported: ["authorization_code"],
|
|
19
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
20
|
+
code_challenge_methods_supported: ["S256"], scopes_supported: ["mcp"]
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Postnhost
|
|
2
|
+
module Oauth
|
|
3
|
+
# OAuth protocol endpoints do not use CMS sessions for authentication.
|
|
4
|
+
# rubocop:disable-next Rails/ApplicationController
|
|
5
|
+
class PublicController < ActionController::Base
|
|
6
|
+
skip_forgery_protection
|
|
7
|
+
before_action :prevent_caching
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def prevent_caching
|
|
12
|
+
response.set_header("Cache-Control", "no-store")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Postnhost
|
|
2
|
+
module Oauth
|
|
3
|
+
class RegistrationsController < PublicController
|
|
4
|
+
def create
|
|
5
|
+
unless params[:token_endpoint_auth_method].to_s.in?(["", "none"]) &&
|
|
6
|
+
supported_grants? &&
|
|
7
|
+
(params[:response_types].nil? || params[:response_types] == ["code"])
|
|
8
|
+
render json: { error: "invalid_client_metadata" }, status: :bad_request
|
|
9
|
+
return
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
client = OauthClient.create!(name: params[:client_name].presence || "MCP client", redirect_uris: params[:redirect_uris])
|
|
13
|
+
render json: {
|
|
14
|
+
client_id: client.client_id, client_name: client.name, redirect_uris: client.redirect_uris,
|
|
15
|
+
token_endpoint_auth_method: "none", grant_types: ["authorization_code"], response_types: ["code"]
|
|
16
|
+
}, status: :created
|
|
17
|
+
rescue ActiveRecord::RecordInvalid
|
|
18
|
+
render json: { error: "invalid_client_metadata" }, status: :bad_request
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def supported_grants?
|
|
24
|
+
grants = params[:grant_types] || ["authorization_code"]
|
|
25
|
+
grants.is_a?(Array) && grants.include?("authorization_code") && (grants - %w[authorization_code refresh_token]).empty?
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Postnhost
|
|
2
|
+
module Oauth
|
|
3
|
+
class TokensController < PublicController
|
|
4
|
+
def create
|
|
5
|
+
unless params[:grant_type] == "authorization_code"
|
|
6
|
+
render json: { error: "unsupported_grant_type" }, status: :bad_request
|
|
7
|
+
return
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
secret = OauthGrant.exchange(**%i[code client_id redirect_uri resource code_verifier].index_with { |key| params[key].to_s })
|
|
11
|
+
if secret
|
|
12
|
+
render json: { access_token: secret, token_type: "Bearer", scope: "mcp" }
|
|
13
|
+
else
|
|
14
|
+
render json: { error: "invalid_grant" }, status: :bad_request
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
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
|
|
@@ -9,8 +9,9 @@ module Postnhost
|
|
|
9
9
|
user = Postnhost::User.find_by(email: session_params[:email].to_s.downcase.strip)
|
|
10
10
|
|
|
11
11
|
if user&.authenticate(session_params[:password].to_s)
|
|
12
|
+
oauth_return_to = session[:oauth_return_to]
|
|
12
13
|
sign_in(user)
|
|
13
|
-
redirect_to articles_path
|
|
14
|
+
redirect_to oauth_return_to || articles_path
|
|
14
15
|
else
|
|
15
16
|
flash.now[:alert] = "Invalid email or password."
|
|
16
17
|
render :new, status: :unprocessable_content
|
|
@@ -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:, resource: nil)
|
|
11
|
+
secret = "pnh_#{SecureRandom.urlsafe_base64(32)}"
|
|
12
|
+
token = create!(user:, name:, resource:, 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,29 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module Postnhost
|
|
4
|
+
class OauthClient < ApplicationRecord
|
|
5
|
+
has_many :oauth_grants, class_name: "Postnhost::OauthGrant", dependent: :destroy
|
|
6
|
+
has_secure_token :client_id, length: 32
|
|
7
|
+
|
|
8
|
+
validates :name, presence: true, length: { maximum: 100 }
|
|
9
|
+
validate :safe_redirect_uris
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def safe_redirect_uris
|
|
14
|
+
return if redirect_uris.is_a?(Array) && redirect_uris.size.between?(1, 10) && redirect_uris.all? { |value| safe_redirect_uri?(value) }
|
|
15
|
+
|
|
16
|
+
errors.add(:redirect_uris, "must contain HTTPS URLs or HTTP loopback URLs")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def safe_redirect_uri?(value)
|
|
20
|
+
return false unless value.is_a?(String) && value.length <= 2048
|
|
21
|
+
|
|
22
|
+
uri = URI.parse(value)
|
|
23
|
+
uri.host.present? && uri.userinfo.nil? && uri.fragment.nil? &&
|
|
24
|
+
(uri.scheme == "https" || (uri.scheme == "http" && %w[localhost 127.0.0.1 [::1]].include?(uri.host)))
|
|
25
|
+
rescue URI::InvalidURIError
|
|
26
|
+
false
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "digest"
|
|
3
|
+
|
|
4
|
+
module Postnhost
|
|
5
|
+
class OauthGrant < ApplicationRecord
|
|
6
|
+
belongs_to :oauth_client, class_name: "Postnhost::OauthClient"
|
|
7
|
+
belongs_to :user, class_name: "Postnhost::User"
|
|
8
|
+
|
|
9
|
+
def self.issue!(**attributes)
|
|
10
|
+
code = SecureRandom.urlsafe_base64(32)
|
|
11
|
+
create!(**attributes, code_digest: Digest::SHA256.hexdigest(code), expires_at: 5.minutes.from_now)
|
|
12
|
+
code
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.exchange(code:, client_id:, redirect_uri:, resource:, code_verifier:)
|
|
16
|
+
return unless code_verifier.match?(/\A[A-Za-z0-9._~-]{43,128}\z/)
|
|
17
|
+
|
|
18
|
+
transaction do
|
|
19
|
+
grant = lock.find_by(code_digest: Digest::SHA256.hexdigest(code))
|
|
20
|
+
return unless grant&.expires_at&.future? && grant.oauth_client.client_id == client_id &&
|
|
21
|
+
grant.redirect_uri == redirect_uri && grant.resource == resource
|
|
22
|
+
|
|
23
|
+
challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
|
|
24
|
+
return unless ActiveSupport::SecurityUtils.secure_compare(grant.code_challenge, challenge)
|
|
25
|
+
|
|
26
|
+
token = ApiToken.issue!(user: grant.user, name: grant.oauth_client.name, resource: grant.resource)
|
|
27
|
+
grant.destroy!
|
|
28
|
+
token.last
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -5,6 +5,9 @@ module Postnhost
|
|
|
5
5
|
|
|
6
6
|
self.table_name = "postnhost_users"
|
|
7
7
|
|
|
8
|
+
has_many :api_tokens, class_name: "Postnhost::ApiToken", dependent: :destroy
|
|
9
|
+
has_many :oauth_grants, class_name: "Postnhost::OauthGrant", dependent: :destroy
|
|
10
|
+
|
|
8
11
|
has_secure_password
|
|
9
12
|
|
|
10
13
|
mount_uploader :avatar_file, Postnhost::AvatarUploader
|
|
@@ -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
|