iron-cms 0.18.2 → 0.19.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/README.md +15 -0
- data/app/assets/builds/iron.css +255 -218
- data/app/controllers/concerns/iron/api/token_authentication.rb +2 -7
- data/app/controllers/concerns/iron/bounded_request_body.rb +48 -0
- data/app/controllers/iron/api/base_controller.rb +3 -0
- data/app/controllers/iron/api/mcp_controller.rb +119 -0
- data/app/controllers/iron/api/openapi_controller.rb +1 -8
- data/app/controllers/iron/oauth/authorizations_controller.rb +120 -0
- data/app/controllers/iron/oauth/metadata_controller.rb +38 -0
- data/app/controllers/iron/oauth/registrations_controller.rb +54 -0
- data/app/controllers/iron/oauth/tokens_controller.rb +92 -0
- data/app/mcp/iron/mcp/error.rb +5 -0
- data/app/mcp/iron/mcp/forbidden.rb +9 -0
- data/app/mcp/iron/mcp/invalid_request.rb +5 -0
- data/app/mcp/iron/mcp/paginator.rb +19 -0
- data/app/mcp/iron/mcp/server.rb +38 -0
- data/app/mcp/iron/mcp/tool_context.rb +114 -0
- data/app/mcp/iron/mcp/tools/base.rb +61 -0
- data/app/mcp/iron/mcp/tools/create_entry.rb +28 -0
- data/app/mcp/iron/mcp/tools/delete_entry.rb +25 -0
- data/app/mcp/iron/mcp/tools/describe_schema.rb +20 -0
- data/app/mcp/iron/mcp/tools/get_entry.rb +25 -0
- data/app/mcp/iron/mcp/tools/list_content_types.rb +20 -0
- data/app/mcp/iron/mcp/tools/list_entries.rb +27 -0
- data/app/mcp/iron/mcp/tools/search.rb +25 -0
- data/app/mcp/iron/mcp/tools/update_entry.rb +28 -0
- data/app/mcp/iron/mcp/tools/upload_asset.rb +27 -0
- data/app/models/iron/api/openapi_spec.rb +7 -0
- data/app/models/iron/content/download_budget.rb +31 -0
- data/app/models/iron/content.rb +201 -32
- data/app/models/iron/field_definition.rb +7 -1
- data/app/models/iron/field_definitions/block.rb +3 -1
- data/app/models/iron/field_definitions/block_list.rb +3 -1
- data/app/models/iron/integration.rb +11 -0
- data/app/models/iron/oauth/client.rb +63 -0
- data/app/models/iron/oauth/grant.rb +67 -0
- data/app/models/iron/oauth/resource.rb +27 -0
- data/app/models/iron/oauth/scope.rb +13 -0
- data/app/models/iron/oauth/secret.rb +13 -0
- data/app/models/iron/oauth/token.rb +123 -0
- data/app/models/iron/ssrf_protection.rb +82 -0
- data/app/views/iron/authentication/_brandmark.html.erb +5 -0
- data/app/views/iron/oauth/authorizations/new.html.erb +31 -0
- data/app/views/iron/sessions/new.html.erb +1 -5
- data/app/views/layouts/iron/application.html.erb +3 -0
- data/app/views/layouts/iron/authentication.html.erb +3 -0
- data/config/locales/en.yml +12 -0
- data/config/locales/it.yml +12 -0
- data/config/routes.rb +1 -0
- data/db/migrate/20260626090000_create_iron_oauth_tables.rb +50 -0
- data/lib/generators/iron/install/install_generator.rb +19 -0
- data/lib/iron/engine.rb +6 -0
- data/lib/iron/oauth_body_limit.rb +52 -0
- data/lib/iron/version.rb +1 -1
- metadata +50 -2
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
module Iron
|
|
2
|
+
module Mcp
|
|
3
|
+
class ToolContext
|
|
4
|
+
def list_content_types
|
|
5
|
+
ContentType.order(:handle).map do |content_type|
|
|
6
|
+
{
|
|
7
|
+
"handle" => content_type.handle,
|
|
8
|
+
"name" => content_type.name,
|
|
9
|
+
"kind" => content_type.single? ? "single" : "collection",
|
|
10
|
+
"web_published" => content_type.web_publishing_enabled?,
|
|
11
|
+
"base_path" => content_type.base_path.presence,
|
|
12
|
+
"title_field" => content_type.title_field_definition&.handle
|
|
13
|
+
}.compact
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def describe_schema
|
|
18
|
+
Rails.cache.fetch(Iron::Api::OpenapiSpec.cache_key, expires_in: 1.hour) do
|
|
19
|
+
Iron::Api::OpenapiSpec.new.to_h
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def list_entries(handle:, route: nil, after: nil, per_page: nil)
|
|
24
|
+
return Iron::Content.get(handle, route:, locale: locale_code) if route.present?
|
|
25
|
+
|
|
26
|
+
content_type = ContentType.find_by!(handle:)
|
|
27
|
+
serialize_collection(Paginator.new(after:, per_page:).call(content_type.entries))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def get_entry(handle:, id: nil, route: nil)
|
|
31
|
+
Iron::Content.get(handle, id:, route:, locale: locale_code)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def search(query:, after: nil, per_page: nil)
|
|
35
|
+
raise InvalidRequest, "query is required" if query.blank?
|
|
36
|
+
|
|
37
|
+
scope = Entry.search(query, locale: Current.locale)
|
|
38
|
+
serialize_collection(Paginator.new(after:, per_page:).call(scope))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def create_entry(handle:, content:, route: nil)
|
|
42
|
+
authorize_write!
|
|
43
|
+
Iron::Content.create(handle, content, route:, locale: locale_code, actor: Current.user, allow_local_files: false)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def update_entry(handle:, content:, id: nil, route: nil)
|
|
47
|
+
authorize_write!
|
|
48
|
+
Iron::Content.update(handle, content, id:, route:, locale: locale_code, actor: Current.user, allow_local_files: false)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def delete_entry(handle:, id: nil, route: nil)
|
|
52
|
+
authorize_write!
|
|
53
|
+
Iron::Content.delete(handle, id:, route:, actor: Current.user)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def upload_asset(url: nil, data: nil, filename: nil, content_type: nil)
|
|
57
|
+
authorize_write!
|
|
58
|
+
blob = build_blob(url:, data:, filename:, content_type:)
|
|
59
|
+
|
|
60
|
+
{
|
|
61
|
+
"signed_id" => blob.signed_id,
|
|
62
|
+
"filename" => blob.filename.to_s,
|
|
63
|
+
"content_type" => blob.content_type,
|
|
64
|
+
"byte_size" => blob.byte_size
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def authorize_write!
|
|
71
|
+
raise Forbidden unless Current.user&.can_write?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def locale_code
|
|
75
|
+
Current.locale&.code
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def serialize_collection(page)
|
|
79
|
+
{
|
|
80
|
+
"data" => page.records.map { |entry| Iron::Content.serialize(entry) },
|
|
81
|
+
"_pagination" => {
|
|
82
|
+
"per_page" => page.pagination.per_page,
|
|
83
|
+
"next_cursor" => page.pagination.next_cursor,
|
|
84
|
+
"has_more" => page.pagination.has_more
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def build_blob(url:, data:, filename:, content_type:)
|
|
90
|
+
if url.present?
|
|
91
|
+
Iron::Content.download(url, filename:, content_type:)
|
|
92
|
+
elsif data.present?
|
|
93
|
+
blob_from_base64(data, filename:, content_type:)
|
|
94
|
+
else
|
|
95
|
+
raise InvalidRequest, "Provide either a url or base64 data"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def blob_from_base64(data, filename:, content_type:)
|
|
100
|
+
payload = data.sub(/\Adata:[^;,]*;base64,/, "").gsub(/\s/, "")
|
|
101
|
+
decoded = Base64.strict_decode64(payload)
|
|
102
|
+
raise InvalidRequest, "data exceeds #{Iron::Content::MAX_DOWNLOAD_BYTES} bytes" if decoded.bytesize > Iron::Content::MAX_DOWNLOAD_BYTES
|
|
103
|
+
|
|
104
|
+
ActiveStorage::Blob.create_and_upload!(
|
|
105
|
+
io: StringIO.new(decoded),
|
|
106
|
+
filename: filename.presence || "upload",
|
|
107
|
+
content_type:
|
|
108
|
+
)
|
|
109
|
+
rescue ArgumentError
|
|
110
|
+
raise InvalidRequest, "data must be valid base64"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module Iron
|
|
2
|
+
module Mcp
|
|
3
|
+
module Tools
|
|
4
|
+
class Base < ::MCP::Tool
|
|
5
|
+
class << self
|
|
6
|
+
def call(server_context:, **arguments)
|
|
7
|
+
structured = perform(context(server_context), **arguments)
|
|
8
|
+
success(structured)
|
|
9
|
+
rescue Iron::Mcp::Error, Iron::Content::Error, ActiveRecord::RecordNotFound => error
|
|
10
|
+
failure(error)
|
|
11
|
+
rescue => error
|
|
12
|
+
Rails.error.report(error, handled: true, source: "iron.mcp")
|
|
13
|
+
internal_failure
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def perform(_context, **_arguments)
|
|
17
|
+
raise NotImplementedError, "#{name} must implement .perform"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def context(server_context)
|
|
23
|
+
server_context[:iron]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def success(structured)
|
|
27
|
+
::MCP::Tool::Response.new(
|
|
28
|
+
[ { type: "text", text: as_text(structured) } ],
|
|
29
|
+
structured_content: structured
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def failure(error)
|
|
34
|
+
::MCP::Tool::Response.new(
|
|
35
|
+
[ { type: "text", text: error.message } ],
|
|
36
|
+
structured_content: error_details(error),
|
|
37
|
+
error: true
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def internal_failure
|
|
42
|
+
::MCP::Tool::Response.new(
|
|
43
|
+
[ { type: "text", text: "Something went wrong while handling this request." } ],
|
|
44
|
+
error: true
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def error_details(error)
|
|
49
|
+
return { "errors" => error.errors } if error.is_a?(Iron::Content::InvalidContent)
|
|
50
|
+
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def as_text(structured)
|
|
55
|
+
structured.is_a?(String) ? structured : JSON.pretty_generate(structured)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Iron
|
|
2
|
+
module Mcp
|
|
3
|
+
module Tools
|
|
4
|
+
class CreateEntry < Base
|
|
5
|
+
tool_name "create_entry"
|
|
6
|
+
description <<~TEXT
|
|
7
|
+
Create a new entry of a collection content type. `content` is a JSON object of
|
|
8
|
+
field handles to values, matching the type's write schema from `describe_schema`.
|
|
9
|
+
File fields accept an upload via `{ "_file": "https://..." }` (a URL or a
|
|
10
|
+
`signed_id` from `upload_asset`). Returns the created entry, or per-field errors
|
|
11
|
+
when validation fails.
|
|
12
|
+
TEXT
|
|
13
|
+
input_schema(
|
|
14
|
+
properties: {
|
|
15
|
+
handle: { type: "string", description: "Collection content type handle" },
|
|
16
|
+
content: { type: "object", description: "Field handles to values (see describe_schema)" },
|
|
17
|
+
route: { type: "string", description: "Optional route for web-published types" }
|
|
18
|
+
},
|
|
19
|
+
required: [ "handle", "content" ]
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def self.perform(context, handle:, content:, route: nil)
|
|
23
|
+
context.create_entry(handle:, content:, route:)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Iron
|
|
2
|
+
module Mcp
|
|
3
|
+
module Tools
|
|
4
|
+
class DeleteEntry < Base
|
|
5
|
+
tool_name "delete_entry"
|
|
6
|
+
description <<~TEXT
|
|
7
|
+
Delete an entry, purging its file attachments. Identify a collection entry by `id`
|
|
8
|
+
or `route`. This cannot be undone.
|
|
9
|
+
TEXT
|
|
10
|
+
input_schema(
|
|
11
|
+
properties: {
|
|
12
|
+
handle: { type: "string", description: "Content type handle" },
|
|
13
|
+
id: { type: "integer", description: "Entry id (collection types)" },
|
|
14
|
+
route: { type: "string", description: "Entry route (web-published types)" }
|
|
15
|
+
},
|
|
16
|
+
required: [ "handle" ]
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
def self.perform(context, handle:, id: nil, route: nil)
|
|
20
|
+
context.delete_entry(handle:, id:, route:)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Iron
|
|
2
|
+
module Mcp
|
|
3
|
+
module Tools
|
|
4
|
+
class DescribeSchema < Base
|
|
5
|
+
tool_name "describe_schema"
|
|
6
|
+
description <<~TEXT
|
|
7
|
+
Return the OpenAPI schema describing every content type's read and write shape
|
|
8
|
+
(fields, types, validations). This is the source of truth for the `content`
|
|
9
|
+
payloads accepted by `create_entry` and `update_entry`. The schema reflects the
|
|
10
|
+
current CMS configuration and changes when the content model changes.
|
|
11
|
+
TEXT
|
|
12
|
+
input_schema(properties: {})
|
|
13
|
+
|
|
14
|
+
def self.perform(context)
|
|
15
|
+
context.describe_schema
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Iron
|
|
2
|
+
module Mcp
|
|
3
|
+
module Tools
|
|
4
|
+
class GetEntry < Base
|
|
5
|
+
tool_name "get_entry"
|
|
6
|
+
description <<~TEXT
|
|
7
|
+
Fetch a single entry's full content. Identify it by `id` or by `route` for a
|
|
8
|
+
collection type; for a single-type content type, omit both.
|
|
9
|
+
TEXT
|
|
10
|
+
input_schema(
|
|
11
|
+
properties: {
|
|
12
|
+
handle: { type: "string", description: "Content type handle" },
|
|
13
|
+
id: { type: "integer", description: "Entry id (collection types)" },
|
|
14
|
+
route: { type: "string", description: "Entry route (web-published types)" }
|
|
15
|
+
},
|
|
16
|
+
required: [ "handle" ]
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
def self.perform(context, handle:, id: nil, route: nil)
|
|
20
|
+
context.get_entry(handle:, id:, route:)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Iron
|
|
2
|
+
module Mcp
|
|
3
|
+
module Tools
|
|
4
|
+
class ListContentTypes < Base
|
|
5
|
+
tool_name "list_content_types"
|
|
6
|
+
description <<~TEXT
|
|
7
|
+
List the content types defined in this Iron CMS, with their handle, kind
|
|
8
|
+
(single or collection), whether they are web-published, and their title field.
|
|
9
|
+
Use this first to discover what content exists, then `describe_schema` for the
|
|
10
|
+
exact fields of a type.
|
|
11
|
+
TEXT
|
|
12
|
+
input_schema(properties: {})
|
|
13
|
+
|
|
14
|
+
def self.perform(context)
|
|
15
|
+
context.list_content_types
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Iron
|
|
2
|
+
module Mcp
|
|
3
|
+
module Tools
|
|
4
|
+
class ListEntries < Base
|
|
5
|
+
tool_name "list_entries"
|
|
6
|
+
description <<~TEXT
|
|
7
|
+
List entries of a content type (newest first), paginated. Pass `after` with the
|
|
8
|
+
`next_cursor` from a previous response to page forward. Pass `route` instead to
|
|
9
|
+
fetch a single web-published entry by its route rather than listing.
|
|
10
|
+
TEXT
|
|
11
|
+
input_schema(
|
|
12
|
+
properties: {
|
|
13
|
+
handle: { type: "string", description: "Content type handle, e.g. \"post\"" },
|
|
14
|
+
route: { type: "string", description: "Fetch one entry by route instead of listing" },
|
|
15
|
+
after: { type: "string", description: "Pagination cursor from a previous response" },
|
|
16
|
+
per_page: { type: "integer", description: "Entries per page (1-100, default 25)" }
|
|
17
|
+
},
|
|
18
|
+
required: [ "handle" ]
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
def self.perform(context, handle:, route: nil, after: nil, per_page: nil)
|
|
22
|
+
context.list_entries(handle:, route:, after:, per_page:)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Iron
|
|
2
|
+
module Mcp
|
|
3
|
+
module Tools
|
|
4
|
+
class Search < Base
|
|
5
|
+
tool_name "search"
|
|
6
|
+
description <<~TEXT
|
|
7
|
+
Search entries across all content types by text, paginated. Pass `after` with the
|
|
8
|
+
`next_cursor` from a previous response to page forward.
|
|
9
|
+
TEXT
|
|
10
|
+
input_schema(
|
|
11
|
+
properties: {
|
|
12
|
+
query: { type: "string", description: "Text to search for" },
|
|
13
|
+
after: { type: "string", description: "Pagination cursor from a previous response" },
|
|
14
|
+
per_page: { type: "integer", description: "Results per page (1-100, default 25)" }
|
|
15
|
+
},
|
|
16
|
+
required: [ "query" ]
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
def self.perform(context, query:, after: nil, per_page: nil)
|
|
20
|
+
context.search(query:, after:, per_page:)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Iron
|
|
2
|
+
module Mcp
|
|
3
|
+
module Tools
|
|
4
|
+
class UpdateEntry < Base
|
|
5
|
+
tool_name "update_entry"
|
|
6
|
+
description <<~TEXT
|
|
7
|
+
Update an entry's content. Identify a collection entry by `id` or `route`; for a
|
|
8
|
+
single-type content type, omit both to upsert it. `content` is a partial JSON
|
|
9
|
+
object of field handles to new values (see `describe_schema`); omitted fields are
|
|
10
|
+
left unchanged. Returns the updated entry, or per-field errors when validation fails.
|
|
11
|
+
TEXT
|
|
12
|
+
input_schema(
|
|
13
|
+
properties: {
|
|
14
|
+
handle: { type: "string", description: "Content type handle" },
|
|
15
|
+
content: { type: "object", description: "Field handles to new values" },
|
|
16
|
+
id: { type: "integer", description: "Entry id (collection types)" },
|
|
17
|
+
route: { type: "string", description: "Entry route (web-published types)" }
|
|
18
|
+
},
|
|
19
|
+
required: [ "handle", "content" ]
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def self.perform(context, handle:, content:, id: nil, route: nil)
|
|
23
|
+
context.update_entry(handle:, content:, id:, route:)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Iron
|
|
2
|
+
module Mcp
|
|
3
|
+
module Tools
|
|
4
|
+
class UploadAsset < Base
|
|
5
|
+
tool_name "upload_asset"
|
|
6
|
+
description <<~TEXT
|
|
7
|
+
Upload a file and get back a `signed_id` to assign to a file field in
|
|
8
|
+
`create_entry`/`update_entry`. Provide either a `url` to fetch, or base64 `data`
|
|
9
|
+
for inline binary (a `data:` URI prefix is accepted). When you already have a URL,
|
|
10
|
+
you can skip this and pass `{ "_file": "https://..." }` directly in the content.
|
|
11
|
+
TEXT
|
|
12
|
+
input_schema(
|
|
13
|
+
properties: {
|
|
14
|
+
url: { type: "string", description: "http(s) URL to download" },
|
|
15
|
+
data: { type: "string", description: "Base64-encoded file contents" },
|
|
16
|
+
filename: { type: "string", description: "Filename to store (recommended)" },
|
|
17
|
+
content_type: { type: "string", description: "MIME type, e.g. image/png" }
|
|
18
|
+
}
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
def self.perform(context, url: nil, data: nil, filename: nil, content_type: nil)
|
|
22
|
+
context.upload_asset(url:, data:, filename:, content_type:)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
module Iron
|
|
2
2
|
module Api
|
|
3
3
|
class OpenapiSpec
|
|
4
|
+
def self.cache_key
|
|
5
|
+
sources = [ ContentType, FieldDefinition, BlockDefinition ]
|
|
6
|
+
latest = sources.filter_map { |model| model.maximum(:updated_at) }.max
|
|
7
|
+
revisions = sources.sum(&:count)
|
|
8
|
+
"iron/openapi/#{latest&.utc&.strftime('%s%6N')}-#{revisions}"
|
|
9
|
+
end
|
|
10
|
+
|
|
4
11
|
def to_h
|
|
5
12
|
{
|
|
6
13
|
openapi: "3.1.0",
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Iron
|
|
2
|
+
class Content
|
|
3
|
+
# A single write request shares one download budget: a cap on how many
|
|
4
|
+
# _file directives it may ingest and a wall-clock deadline across all of
|
|
5
|
+
# its downloads. Per-read timeouts alone would let a hostile server drip
|
|
6
|
+
# a byte per interval and hold a worker indefinitely.
|
|
7
|
+
class DownloadBudget
|
|
8
|
+
MAX_FILES = 25
|
|
9
|
+
MAX_SECONDS = 60
|
|
10
|
+
|
|
11
|
+
def initialize(files: MAX_FILES, seconds: MAX_SECONDS)
|
|
12
|
+
@files = files
|
|
13
|
+
@deadline = now + seconds
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def charge_file!
|
|
17
|
+
@files -= 1
|
|
18
|
+
raise Error, "too many _file directives in one request (max #{MAX_FILES})" if @files.negative?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def verify_deadline!(url)
|
|
22
|
+
raise Error, "could not download #{url}: download deadline exceeded" if now > @deadline
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
def now
|
|
27
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|