open_blog 0.1.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 +7 -0
- data/CHANGELOG.md +52 -0
- data/LICENSE.txt +21 -0
- data/README.md +101 -0
- data/app/assets/builds/open_blog/blog.css +2 -0
- data/app/assets/javascripts/open_blog/controllers/code_copy_controller.js +40 -0
- data/app/assets/javascripts/open_blog/controllers/reading_progress_controller.js +36 -0
- data/app/assets/javascripts/open_blog/controllers/search_controller.js +49 -0
- data/app/assets/javascripts/open_blog/controllers/share_controller.js +48 -0
- data/app/assets/javascripts/open_blog/controllers/theme_controller.js +51 -0
- data/app/assets/javascripts/open_blog/controllers/toc_controller.js +33 -0
- data/app/controllers/concerns/open_blog/reader_controller.rb +49 -0
- data/app/controllers/open_blog/api/v1/adoptions_controller.rb +27 -0
- data/app/controllers/open_blog/api/v1/approvals_controller.rb +15 -0
- data/app/controllers/open_blog/api/v1/authors_controller.rb +62 -0
- data/app/controllers/open_blog/api/v1/base_controller.rb +100 -0
- data/app/controllers/open_blog/api/v1/categories_controller.rb +33 -0
- data/app/controllers/open_blog/api/v1/connections_controller.rb +45 -0
- data/app/controllers/open_blog/api/v1/doctor_controller.rb +13 -0
- data/app/controllers/open_blog/api/v1/faq_extractions_controller.rb +13 -0
- data/app/controllers/open_blog/api/v1/findings_controller.rb +13 -0
- data/app/controllers/open_blog/api/v1/images_controller.rb +22 -0
- data/app/controllers/open_blog/api/v1/pages_controller.rb +67 -0
- data/app/controllers/open_blog/api/v1/posts_controller.rb +114 -0
- data/app/controllers/open_blog/api/v1/previews_controller.rb +13 -0
- data/app/controllers/open_blog/api/v1/records_controller.rb +13 -0
- data/app/controllers/open_blog/api/v1/redirects_controller.rb +51 -0
- data/app/controllers/open_blog/api/v1/report_controller.rb +20 -0
- data/app/controllers/open_blog/api/v1/resources_controller.rb +39 -0
- data/app/controllers/open_blog/api/v1/series_controller.rb +32 -0
- data/app/controllers/open_blog/api/v1/tags_controller.rb +12 -0
- data/app/controllers/open_blog/api/v1/views_controller.rb +19 -0
- data/app/controllers/open_blog/application_controller.rb +5 -0
- data/app/controllers/open_blog/authors_controller.rb +14 -0
- data/app/controllers/open_blog/categories_controller.rb +15 -0
- data/app/controllers/open_blog/errors_controller.rb +7 -0
- data/app/controllers/open_blog/feeds_controller.rb +22 -0
- data/app/controllers/open_blog/mcp_controller.rb +38 -0
- data/app/controllers/open_blog/media_controller.rb +20 -0
- data/app/controllers/open_blog/pages_controller.rb +12 -0
- data/app/controllers/open_blog/posts_controller.rb +57 -0
- data/app/controllers/open_blog/previews_controller.rb +35 -0
- data/app/controllers/open_blog/search_controller.rb +32 -0
- data/app/controllers/open_blog/series_controller.rb +15 -0
- data/app/controllers/open_blog/sitemaps_controller.rb +10 -0
- data/app/controllers/open_blog/tags_controller.rb +15 -0
- data/app/helpers/open_blog/content_helper.rb +79 -0
- data/app/helpers/open_blog/dates_helper.rb +18 -0
- data/app/helpers/open_blog/faq_helper.rb +59 -0
- data/app/helpers/open_blog/head_helper.rb +75 -0
- data/app/helpers/open_blog/icons_helper.rb +10 -0
- data/app/helpers/open_blog/notices_helper.rb +47 -0
- data/app/helpers/open_blog/pagination_helper.rb +18 -0
- data/app/helpers/open_blog/structured_data_helper.rb +63 -0
- data/app/helpers/open_blog/theme_helper.rb +52 -0
- data/app/helpers/open_blog/url_helper.rb +58 -0
- data/app/jobs/open_blog/publish_scheduled_post_job.rb +14 -0
- data/app/models/concerns/open_blog/immutable.rb +13 -0
- data/app/models/concerns/open_blog/immutable_image_attachment.rb +43 -0
- data/app/models/concerns/open_blog/previewable.rb +38 -0
- data/app/models/open_blog/api_token.rb +34 -0
- data/app/models/open_blog/application_record.rb +5 -0
- data/app/models/open_blog/approval.rb +19 -0
- data/app/models/open_blog/author.rb +11 -0
- data/app/models/open_blog/baseline.rb +26 -0
- data/app/models/open_blog/category.rb +10 -0
- data/app/models/open_blog/connection_declaration.rb +16 -0
- data/app/models/open_blog/faq.rb +22 -0
- data/app/models/open_blog/image.rb +45 -0
- data/app/models/open_blog/page.rb +23 -0
- data/app/models/open_blog/page_view.rb +7 -0
- data/app/models/open_blog/post.rb +132 -0
- data/app/models/open_blog/publication.rb +17 -0
- data/app/models/open_blog/redirect.rb +16 -0
- data/app/models/open_blog/revision.rb +12 -0
- data/app/models/open_blog/series.rb +10 -0
- data/app/models/open_blog/tag.rb +11 -0
- data/app/models/open_blog/tagging.rb +7 -0
- data/app/queries/open_blog/api_post_query.rb +54 -0
- data/app/serializers/open_blog/adoption_result_serializer.rb +9 -0
- data/app/serializers/open_blog/author_serializer.rb +10 -0
- data/app/serializers/open_blog/category_serializer.rb +7 -0
- data/app/serializers/open_blog/doctor_serializer.rb +7 -0
- data/app/serializers/open_blog/error_serializer.rb +7 -0
- data/app/serializers/open_blog/extraction_result_serializer.rb +7 -0
- data/app/serializers/open_blog/findings_serializer.rb +13 -0
- data/app/serializers/open_blog/image_serializer.rb +9 -0
- data/app/serializers/open_blog/page_serializer.rb +9 -0
- data/app/serializers/open_blog/post_serializer.rb +59 -0
- data/app/serializers/open_blog/preview_serializer.rb +11 -0
- data/app/serializers/open_blog/records_serializer.rb +28 -0
- data/app/serializers/open_blog/redirect_serializer.rb +7 -0
- data/app/serializers/open_blog/series_serializer.rb +7 -0
- data/app/serializers/open_blog/tag_serializer.rb +7 -0
- data/app/serializers/open_blog/write_result_serializer.rb +9 -0
- data/app/views/open_blog/attachments/_blob.html.erb +1 -0
- data/app/views/open_blog/feeds/atom.xml.builder +27 -0
- data/app/views/open_blog/sitemaps/show.xml.builder +9 -0
- data/config/locales/open_blog.en.yml +95 -0
- data/config/routes.rb +52 -0
- data/db/migrate/20261003012512_create_open_blog_authors.rb +16 -0
- data/db/migrate/20261003012513_create_open_blog_categories.rb +13 -0
- data/db/migrate/20261003012514_create_open_blog_series.rb +11 -0
- data/db/migrate/20261003012515_create_open_blog_images.rb +17 -0
- data/db/migrate/20261003012516_create_open_blog_posts.rb +47 -0
- data/db/migrate/20261003012517_create_open_blog_faqs.rb +13 -0
- data/db/migrate/20261003012518_create_open_blog_tags.rb +17 -0
- data/db/migrate/20261003012519_create_open_blog_redirects.rb +14 -0
- data/db/migrate/20261003012520_create_open_blog_revisions.rb +14 -0
- data/db/migrate/20261003012521_create_open_blog_approvals.rb +21 -0
- data/db/migrate/20261003012522_create_open_blog_publications.rb +18 -0
- data/db/migrate/20261003012523_create_open_blog_baselines.rb +24 -0
- data/db/migrate/20261003012524_create_open_blog_connection_declarations.rb +13 -0
- data/db/migrate/20261003031410_add_body_image_manifest_to_open_blog_posts.rb +7 -0
- data/db/migrate/20261003164418_create_open_blog_api_tokens.rb +15 -0
- data/db/migrate/20261003175454_create_open_blog_pages.rb +16 -0
- data/db/migrate/20261003182919_create_open_blog_page_views.rb +11 -0
- data/lib/generators/open_blog/admin_suite/admin_suite_generator.rb +27 -0
- data/lib/generators/open_blog/admin_suite/templates/author_resource.rb.tt +29 -0
- data/lib/generators/open_blog/admin_suite/templates/blog_portal.rb.tt +16 -0
- data/lib/generators/open_blog/admin_suite/templates/category_resource.rb.tt +28 -0
- data/lib/generators/open_blog/admin_suite/templates/initializer.rb.tt +19 -0
- data/lib/generators/open_blog/admin_suite/templates/page_resource.rb.tt +32 -0
- data/lib/generators/open_blog/admin_suite/templates/post_resource.rb.tt +58 -0
- data/lib/generators/open_blog/generator_support.rb +64 -0
- data/lib/generators/open_blog/install/install_generator.rb +188 -0
- data/lib/generators/open_blog/install/templates/initializer.rb.tt +16 -0
- data/lib/generators/open_blog/install/templates/sample_cover.png +0 -0
- data/lib/generators/open_blog/install/templates/sample_post.md +54 -0
- data/lib/generators/open_blog/install/templates/theme/blog.css +193 -0
- data/lib/generators/open_blog/install/templates/theme/open_blog_theme.css +82 -0
- data/lib/generators/open_blog/install/templates/theme/syntax.css +358 -0
- data/lib/generators/open_blog/install/templates/theme/theme.css +120 -0
- data/lib/generators/open_blog/install/templates/views/layouts/open_blog.html.erb +16 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/authors/show.html.erb +17 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/categories/show.html.erb +13 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/errors/not_found.html.erb +11 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/errors/preview_expired.html.erb +5 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_check.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_copy.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_link.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_monitor.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_moon.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_share.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/icons/_sun.svg.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/pages/show.html.erb +5 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/_faq.html.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/_highlights.html.erb +7 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/_preview_bar.html.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/_series_nav.html.erb +12 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/_tags.html.erb +5 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/_toc.html.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/index.html.erb +13 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/posts/show.html.erb +29 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/search/show.html.erb +13 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/series/show.html.erb +13 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_author_box.html.erb +6 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_breadcrumbs.html.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_call_to_action.html.erb +7 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_featured_card.html.erb +11 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_footer.html.erb +11 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_header.html.erb +7 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_pagination.html.erb +1 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_post_card.html.erb +9 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_related_posts.html.erb +10 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_search.html.erb +9 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_share.html.erb +13 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_sidebar.html.erb +38 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/shared/_theme_toggle.html.erb +10 -0
- data/lib/generators/open_blog/install/templates/views/open_blog/tags/show.html.erb +13 -0
- data/lib/generators/open_blog/views/views_generator.rb +21 -0
- data/lib/open_blog/actor.rb +7 -0
- data/lib/open_blog/admin_suite.rb +73 -0
- data/lib/open_blog/adopt/contract.rb +152 -0
- data/lib/open_blog/adopt/snapshot.rb +35 -0
- data/lib/open_blog/adopt.rb +159 -0
- data/lib/open_blog/api_fields.rb +40 -0
- data/lib/open_blog/approve.rb +18 -0
- data/lib/open_blog/authentication.rb +18 -0
- data/lib/open_blog/build_css.rb +53 -0
- data/lib/open_blog/configuration.rb +185 -0
- data/lib/open_blog/configuration_error.rb +4 -0
- data/lib/open_blog/content_guard.rb +77 -0
- data/lib/open_blog/doctor/files.rb +61 -0
- data/lib/open_blog/doctor/javascript.rb +57 -0
- data/lib/open_blog/doctor.rb +177 -0
- data/lib/open_blog/engine.rb +45 -0
- data/lib/open_blog/errors.rb +40 -0
- data/lib/open_blog/faq_extraction.rb +184 -0
- data/lib/open_blog/findings/body_checks.rb +58 -0
- data/lib/open_blog/findings/check.rb +19 -0
- data/lib/open_blog/findings/link_check.rb +78 -0
- data/lib/open_blog/findings/record_checks.rb +94 -0
- data/lib/open_blog/findings/registry.rb +18 -0
- data/lib/open_blog/findings.rb +27 -0
- data/lib/open_blog/image_fetch.rb +115 -0
- data/lib/open_blog/image_import.rb +164 -0
- data/lib/open_blog/image_resolution.rb +171 -0
- data/lib/open_blog/instructions.rb +7 -0
- data/lib/open_blog/json_feed.rb +36 -0
- data/lib/open_blog/label_policy.rb +41 -0
- data/lib/open_blog/markdown_view.rb +57 -0
- data/lib/open_blog/mcp/definition.rb +28 -0
- data/lib/open_blog/mcp/dispatcher.rb +33 -0
- data/lib/open_blog/mcp/image_input.rb +30 -0
- data/lib/open_blog/mcp/registry.rb +139 -0
- data/lib/open_blog/mcp/schemas.rb +72 -0
- data/lib/open_blog/mcp.rb +24 -0
- data/lib/open_blog/not_found.rb +4 -0
- data/lib/open_blog/operation.rb +36 -0
- data/lib/open_blog/page_views.rb +72 -0
- data/lib/open_blog/pagination.rb +18 -0
- data/lib/open_blog/plain_text.rb +36 -0
- data/lib/open_blog/policy_url.rb +9 -0
- data/lib/open_blog/post_attributes.rb +172 -0
- data/lib/open_blog/post_identity.rb +28 -0
- data/lib/open_blog/publish.rb +7 -0
- data/lib/open_blog/reader_dates.rb +21 -0
- data/lib/open_blog/reader_page.rb +21 -0
- data/lib/open_blog/reader_queries.rb +90 -0
- data/lib/open_blog/record_release.rb +66 -0
- data/lib/open_blog/redirect_target.rb +58 -0
- data/lib/open_blog/remove.rb +43 -0
- data/lib/open_blog/renderer/highlighter.rb +44 -0
- data/lib/open_blog/renderer/markdown.rb +14 -0
- data/lib/open_blog/renderer/rich_text.rb +19 -0
- data/lib/open_blog/renderer/sanitizer.rb +72 -0
- data/lib/open_blog/renderer/scrubber.rb +35 -0
- data/lib/open_blog/renderer/transform.rb +115 -0
- data/lib/open_blog/renderer.rb +51 -0
- data/lib/open_blog/result.rb +11 -0
- data/lib/open_blog/revision_payload.rb +67 -0
- data/lib/open_blog/rich_text_guard.rb +26 -0
- data/lib/open_blog/sample.rb +56 -0
- data/lib/open_blog/save_draft.rb +7 -0
- data/lib/open_blog/search.rb +21 -0
- data/lib/open_blog/sitemap_entries.rb +66 -0
- data/lib/open_blog/surface_report/expected_content.rb +27 -0
- data/lib/open_blog/surface_report/http.rb +92 -0
- data/lib/open_blog/surface_report/page_checks.rb +303 -0
- data/lib/open_blog/surface_report/reach_checks.rb +141 -0
- data/lib/open_blog/surface_report/record_checks.rb +248 -0
- data/lib/open_blog/surface_report/robots.rb +49 -0
- data/lib/open_blog/surface_report.rb +223 -0
- data/lib/open_blog/syntax_css.rb +55 -0
- data/lib/open_blog/unpublish.rb +13 -0
- data/lib/open_blog/version.rb +3 -0
- data/lib/open_blog/write_post.rb +205 -0
- data/lib/open_blog.rb +91 -0
- data/lib/tasks/open_blog.rake +71 -0
- data/skills/open-blog-adopt/SKILL.md +12 -0
- data/skills/open-blog-install/SKILL.md +10 -0
- data/skills/open-blog-policy-pages/SKILL.md +10 -0
- data/skills/open-blog-publish/SKILL.md +14 -0
- data/skills/open-blog-report/SKILL.md +10 -0
- data/skills/open-blog-update/SKILL.md +16 -0
- metadata +515 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
class Renderer
|
|
3
|
+
class Scrubber < Rails::HTML::PermitScrubber
|
|
4
|
+
def initialize(metadata)
|
|
5
|
+
super()
|
|
6
|
+
self.tags = Sanitizer::TAGS
|
|
7
|
+
self.attributes = Sanitizer::ATTRIBUTES + %w[type checked disabled] + [ Sanitizer::TOKEN ]
|
|
8
|
+
@metadata = metadata
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def scrub(node)
|
|
12
|
+
if node.element? && ((node.name == "input" && (node["type"] != "checkbox" || !node.key?("disabled"))) ||
|
|
13
|
+
(node.name == "img" && !Sanitizer.safe_url?(node["src"], image: true)))
|
|
14
|
+
node.remove
|
|
15
|
+
return STOP
|
|
16
|
+
end
|
|
17
|
+
super
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
protected
|
|
21
|
+
|
|
22
|
+
def scrub_attribute(node, attribute)
|
|
23
|
+
name = attribute.name
|
|
24
|
+
invalid_input = %w[type checked disabled].include?(name) && node.name != "input"
|
|
25
|
+
invalid_token = name == Sanitizer::TOKEN && !@metadata.key?(attribute.value)
|
|
26
|
+
invalid_url = %w[href src].include?(name) && !Sanitizer.safe_url?(attribute.value, image: name == "src")
|
|
27
|
+
if invalid_input || invalid_token || invalid_url
|
|
28
|
+
attribute.remove
|
|
29
|
+
else
|
|
30
|
+
super
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
require "set"
|
|
2
|
+
|
|
3
|
+
module OpenBlog
|
|
4
|
+
class Renderer
|
|
5
|
+
class Transform
|
|
6
|
+
def initialize(document, post: nil)
|
|
7
|
+
@fragment = Sanitizer.restore!(document)
|
|
8
|
+
@post = post
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call
|
|
12
|
+
headings = @fragment.css("h1,h2,h3,h4,h5,h6")
|
|
13
|
+
shift = headings.any? ? 2 - headings.map { |node| node.name[1].to_i }.min : 0
|
|
14
|
+
used_ids = @fragment.css("[id]").map { |node| node["id"] }.to_set
|
|
15
|
+
@fragment.css("h1,h2,h3,h4,h5,h6,pre,img,table,a").each do |node|
|
|
16
|
+
case node.name
|
|
17
|
+
when /\Ah[1-6]\z/ then heading(node, shift, used_ids)
|
|
18
|
+
when "pre" then Highlighter.call(node)
|
|
19
|
+
when "img" then image(node)
|
|
20
|
+
when "table" then table(node)
|
|
21
|
+
when "a" then link(node)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
@fragment
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def heading(node, shift, used_ids)
|
|
30
|
+
node.name = "h#{[ node.name[1].to_i + shift, 6 ].min}"
|
|
31
|
+
base = node.text.parameterize.presence || "section"
|
|
32
|
+
identifier, suffix = base, 0
|
|
33
|
+
while used_ids.include?(identifier)
|
|
34
|
+
suffix += 1
|
|
35
|
+
identifier = "#{base}-#{suffix}"
|
|
36
|
+
end
|
|
37
|
+
used_ids << identifier
|
|
38
|
+
node["id"] = identifier
|
|
39
|
+
anchor = Nokogiri::XML::Node.new("a", node.document)
|
|
40
|
+
anchor["class"] = "ob-heading-anchor"
|
|
41
|
+
anchor["href"] = "##{identifier}"
|
|
42
|
+
anchor["aria-label"] = I18n.t("open_blog.headings.link", heading: node.text, locale: OpenBlog.config.locale)
|
|
43
|
+
anchor.content = "#"
|
|
44
|
+
node.add_child(anchor)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def image(node)
|
|
48
|
+
stored = ImageResolution.image_for_url(node["src"], post: @post)
|
|
49
|
+
if stored
|
|
50
|
+
node["src"] = stored.path
|
|
51
|
+
node["width"] = stored.width.to_s if stored.width
|
|
52
|
+
node["height"] = stored.height.to_s if stored.height
|
|
53
|
+
end
|
|
54
|
+
node["alt"] ||= ""
|
|
55
|
+
node["loading"] = "lazy"
|
|
56
|
+
figure = node.ancestors("figure").first
|
|
57
|
+
unless figure
|
|
58
|
+
figure = Nokogiri::XML::Node.new("figure", node.document)
|
|
59
|
+
node.replace(figure)
|
|
60
|
+
figure.add_child(node)
|
|
61
|
+
lift_figure(figure)
|
|
62
|
+
end
|
|
63
|
+
figure["class"] = "ob-figure"
|
|
64
|
+
if node["title"].present? && !figure.at_css("figcaption")
|
|
65
|
+
caption = Nokogiri::XML::Node.new("figcaption", node.document)
|
|
66
|
+
caption.content = node["title"]
|
|
67
|
+
figure.add_child(caption)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def lift_figure(figure)
|
|
72
|
+
while figure.parent && %w[p a strong em del span sup sub].include?(figure.parent.name)
|
|
73
|
+
parent = figure.parent
|
|
74
|
+
before, after = copy_wrapper(parent), copy_wrapper(parent)
|
|
75
|
+
figure.xpath("preceding-sibling::node()").each { |sibling| before.add_child(sibling) }
|
|
76
|
+
figure.xpath("following-sibling::node()").each { |sibling| after.add_child(sibling) }
|
|
77
|
+
unless parent.name == "p"
|
|
78
|
+
wrapper = copy_wrapper(parent)
|
|
79
|
+
figure.children.each { |child| wrapper.add_child(child) }
|
|
80
|
+
figure.add_child(wrapper)
|
|
81
|
+
end
|
|
82
|
+
parent.add_previous_sibling(before) if before.children.any?
|
|
83
|
+
parent.add_previous_sibling(figure)
|
|
84
|
+
parent.add_previous_sibling(after) if after.children.any?
|
|
85
|
+
parent.remove
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def copy_wrapper(node)
|
|
90
|
+
node.dup(0).tap do |copy|
|
|
91
|
+
node.attribute_nodes.each { |attribute| copy[attribute.name] = attribute.value }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def table(node)
|
|
96
|
+
wrapper = Nokogiri::XML::Node.new("div", node.document)
|
|
97
|
+
wrapper["class"] = "ob-table"
|
|
98
|
+
wrapper["tabindex"] = "0"
|
|
99
|
+
node.replace(wrapper)
|
|
100
|
+
wrapper.add_child(node)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def link(node)
|
|
104
|
+
return if node["href"].blank?
|
|
105
|
+
uri = URI.parse(node["href"])
|
|
106
|
+
return unless uri.host
|
|
107
|
+
base = URI.parse(OpenBlog.config.public_base_url) if OpenBlog.config.public_base_url
|
|
108
|
+
return if base && uri.host.casecmp?(base.host) && uri.port == base.port
|
|
109
|
+
node["rel"] = (node["rel"].to_s.split + [ "noopener" ]).uniq.join(" ")
|
|
110
|
+
rescue URI::InvalidURIError
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
|
|
3
|
+
module OpenBlog
|
|
4
|
+
class Renderer
|
|
5
|
+
VERSION = 2
|
|
6
|
+
autoload :Markdown, "open_blog/renderer/markdown"
|
|
7
|
+
autoload :RichText, "open_blog/renderer/rich_text"
|
|
8
|
+
autoload :Sanitizer, "open_blog/renderer/sanitizer"
|
|
9
|
+
autoload :Scrubber, "open_blog/renderer/scrubber"
|
|
10
|
+
autoload :Transform, "open_blog/renderer/transform"
|
|
11
|
+
autoload :Highlighter, "open_blog/renderer/highlighter"
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def render(post, cache: true)
|
|
15
|
+
return render_string(post.body_for_payload, format: post.body_format, post: post) unless post.persisted? && cache
|
|
16
|
+
options = [ OpenBlog.config.markdown_hardbreaks, OpenBlog.config.public_base_url, OpenBlog.config.locale.to_s ]
|
|
17
|
+
configuration = Digest::SHA256.hexdigest(JSON.generate(options))
|
|
18
|
+
Rails.cache.fetch([ "open_blog/body", post.id, post.current_revision_identifier, VERSION, configuration ]) do
|
|
19
|
+
render_string(post.body_for_payload, format: post.body_format, post: post)
|
|
20
|
+
end.html_safe
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def render_string(text, format:, post: nil)
|
|
24
|
+
parsed = sanitized(text, format: format, post: post)
|
|
25
|
+
Transform.new(parsed, post: post).call.to_html.html_safe
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def document(post)
|
|
29
|
+
parsed = sanitized(post.body_for_payload, format: post.body_format, post: post)
|
|
30
|
+
Sanitizer.restore!(parsed)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def body_images(post)
|
|
34
|
+
ImageResolution.body_images(post)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def sanitized(text, format:, post:)
|
|
40
|
+
case format.to_s
|
|
41
|
+
when "markdown"
|
|
42
|
+
Sanitizer.call(Markdown.render(text), trusted: true)
|
|
43
|
+
when "rich_text"
|
|
44
|
+
Sanitizer.call(RichText.render(text, post: post))
|
|
45
|
+
else
|
|
46
|
+
raise ArgumentError, "Unsupported body format: #{format}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
Result = Data.define(:post, :created, :records, :label, :findings, :error, :dry_run) do
|
|
3
|
+
def initialize(post: nil, created: false, records: { revision: nil, publication: nil, approval: nil }, label: :ai_unknown, findings: [], error: nil, dry_run: false)
|
|
4
|
+
super
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def success?
|
|
8
|
+
error.nil?
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module OpenBlog
|
|
5
|
+
class RevisionPayload
|
|
6
|
+
VERSION = "1".freeze
|
|
7
|
+
|
|
8
|
+
def self.from_fields(title:, description:, search_title:, search_description:, body:, author:, images:, faq:)
|
|
9
|
+
new(nil, title: title, description: description, search_title: search_title,
|
|
10
|
+
search_description: search_description, body: body, author: author, images: images, faq: faq)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(post, **fields)
|
|
14
|
+
fields = fields_from_post(post) if post
|
|
15
|
+
@payload = {
|
|
16
|
+
"author" => normalize(fields[:author]),
|
|
17
|
+
"body" => normalize(fields[:body], body: true),
|
|
18
|
+
"description" => normalize(fields[:description]),
|
|
19
|
+
"faq" => fields.fetch(:faq).map { |entry| normalized_entry(entry, %w[answer question]) },
|
|
20
|
+
"images" => fields.fetch(:images).map { |entry| normalized_entry(entry, %w[alt role sha256 url]) },
|
|
21
|
+
"search_description" => normalize(fields[:search_description]),
|
|
22
|
+
"search_title" => normalize(fields[:search_title]),
|
|
23
|
+
"title" => normalize(fields[:title]),
|
|
24
|
+
"version" => VERSION
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_h
|
|
29
|
+
@payload
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def to_json(*)
|
|
33
|
+
JSON.generate(@payload, ascii_only: false, script_safe: false)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def identifier
|
|
37
|
+
Digest::SHA256.hexdigest(to_json)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def fields_from_post(post)
|
|
43
|
+
images = []
|
|
44
|
+
images << image_entry(post.cover_image, "cover", post.cover_alt) if post.cover_image
|
|
45
|
+
images.concat(Renderer.body_images(post))
|
|
46
|
+
images << image_entry(post.social_image, "social", "") if post.social_image
|
|
47
|
+
{
|
|
48
|
+
title: post.title, description: post.description, search_title: post.search_title,
|
|
49
|
+
search_description: post.search_description, body: post.body_for_payload,
|
|
50
|
+
author: post.author_name, images: images, faq: post.faq_list
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def image_entry(image, role, alt)
|
|
55
|
+
{ role: role, url: image.path, sha256: image.sha256, alt: alt }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def normalized_entry(entry, keys)
|
|
59
|
+
keys.to_h { |key| [ key, normalize(entry.key?(key) ? entry[key] : entry[key.to_sym]) ] }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def normalize(value, body: false)
|
|
63
|
+
text = value.to_s.encode(Encoding::UTF_8).unicode_normalize(:nfc).gsub(/\r\n?/, "\n")
|
|
64
|
+
body ? text.sub(/\n+\z/, "") : text.gsub(/\A[ \t\n]+|[ \t\n]+\z/, "")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
module RichTextGuard
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
include ContentGuard
|
|
5
|
+
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def content_guard_target?(attributes)
|
|
9
|
+
candidate = attributes.stringify_keys
|
|
10
|
+
candidate.fetch("record_type", record_type) == "OpenBlog::Post" && candidate.fetch("name", name) == "rich_body"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def content_guard_applies?
|
|
14
|
+
(record_type == "OpenBlog::Post" && name == "rich_body") ||
|
|
15
|
+
(attribute_in_database("record_type") == "OpenBlog::Post" && attribute_in_database("name") == "rich_body")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def content_guard_identity_changed?
|
|
19
|
+
persisted? && %w[record_type record_id name].any? { |attribute| will_save_change_to_attribute?(attribute) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def content_guard_parent
|
|
23
|
+
record
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "stringio"
|
|
3
|
+
|
|
4
|
+
module OpenBlog
|
|
5
|
+
class Sample
|
|
6
|
+
EXTERNAL_ID = "open_blog_sample".freeze
|
|
7
|
+
ASSETS = File.expand_path("../generators/open_blog/install/templates", __dir__).freeze
|
|
8
|
+
|
|
9
|
+
def self.call
|
|
10
|
+
new.call
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
existing = Post.find_by(external_id: EXTERNAL_ID)
|
|
15
|
+
return existing if existing
|
|
16
|
+
|
|
17
|
+
Post.transaction(requires_new: true) do
|
|
18
|
+
image = cover
|
|
19
|
+
markdown = File.read(File.join(ASSETS, "sample_post.md")).gsub("{{sample_image}}", image.path)
|
|
20
|
+
format = OpenBlog.config.default_body_format
|
|
21
|
+
body = format.to_s == "rich_text" ? rich_text(markdown) : markdown
|
|
22
|
+
result = Publish.call({ external_id: EXTERNAL_ID, title: "A small guide to your new blog",
|
|
23
|
+
slug: "open-blog-sample", description: "Explore headings, images, code and reader questions in this sample article.",
|
|
24
|
+
body: body, body_format: format, category: "General", provenance: "ai_assisted",
|
|
25
|
+
cover_image: image, cover_alt: "Blue and green blocks arranged as a simple landscape",
|
|
26
|
+
faq: [ { question: "Can I edit this sample?", answer: "Yes. Replace it with your own article, or remove it when you are ready." },
|
|
27
|
+
{ question: "Will running the sample task create another copy?", answer: "No. Running the task again keeps the existing sample and its content." } ] },
|
|
28
|
+
post: Post.new, actor: "OpenBlog sample")
|
|
29
|
+
raise result.error unless result.success?
|
|
30
|
+
result.post
|
|
31
|
+
end.tap { @completed = true }
|
|
32
|
+
ensure
|
|
33
|
+
@uploaded_blob.service.delete(@uploaded_blob.key) if @uploaded_blob && !@completed
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def rich_text(markdown)
|
|
39
|
+
document = Nokogiri::HTML5.fragment(Renderer::Markdown.render(markdown))
|
|
40
|
+
# Rich text removes source IDs. Keep footnote text without broken links.
|
|
41
|
+
document.css("a[data-footnote-ref]").each { |link| link.replace(link.children) }
|
|
42
|
+
document.css("a[data-footnote-backref]").remove
|
|
43
|
+
document.to_html
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def cover
|
|
47
|
+
bytes = File.binread(File.join(ASSETS, "sample_cover.png"))
|
|
48
|
+
existing = Image.find_by(sha256: Digest::SHA256.hexdigest(bytes))
|
|
49
|
+
return existing if existing
|
|
50
|
+
@uploaded_blob = ActiveStorage::Blob.create_and_upload!(io: StringIO.new(bytes), filename: "sample-cover.png",
|
|
51
|
+
content_type: "image/png", identify: false,
|
|
52
|
+
service_name: OpenBlog.config.storage_service || Rails.application.config.active_storage.service)
|
|
53
|
+
ImageImport.prepare(signed_id: @uploaded_blob.signed_id).persist(uploaded_by: "OpenBlog sample")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
class Search
|
|
3
|
+
def self.call(query, scope: Post.listed, limit: nil)
|
|
4
|
+
return scope.none unless query.is_a?(String)
|
|
5
|
+
query = query.strip
|
|
6
|
+
return scope.none unless (2..100).cover?(query.length)
|
|
7
|
+
|
|
8
|
+
relation = if Post.connection.adapter_name == "PostgreSQL"
|
|
9
|
+
vector = "to_tsvector('simple', open_blog_posts.search_text)"
|
|
10
|
+
expression = Post.sanitize_sql_array([ "websearch_to_tsquery('simple', ?)", query ])
|
|
11
|
+
scope.where("#{vector} @@ #{expression}").reorder(Arel.sql("ts_rank(#{vector}, #{expression}) DESC"))
|
|
12
|
+
else
|
|
13
|
+
query.split.reduce(scope) do |matches, word|
|
|
14
|
+
matches.where("LOWER(open_blog_posts.search_text) LIKE ? ESCAPE '\\'", "%#{Post.sanitize_sql_like(word.downcase)}%")
|
|
15
|
+
end.reorder(nil)
|
|
16
|
+
end
|
|
17
|
+
relation = relation.order(created_at: :desc, id: :desc)
|
|
18
|
+
limit ? relation.limit(limit) : relation
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module OpenBlog
|
|
4
|
+
class SitemapEntries
|
|
5
|
+
def self.call(base_url: OpenBlog.config.public_base_url)
|
|
6
|
+
raise ConfigurationError, "Set public_base_url or pass base_url to sitemap_entries" if base_url.blank?
|
|
7
|
+
new(base_url).call
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def initialize(base_url)
|
|
11
|
+
unless base_url.is_a?(String)
|
|
12
|
+
raise ConfigurationError, "Sitemap base_url must be an absolute HTTP(S) origin"
|
|
13
|
+
end
|
|
14
|
+
@origin = URI.parse(base_url)
|
|
15
|
+
unless @origin.is_a?(URI::HTTP) && @origin.host.present? && @origin.userinfo.nil? &&
|
|
16
|
+
[ "", "/" ].include?(@origin.path) && @origin.query.nil? && @origin.fragment.nil?
|
|
17
|
+
raise ConfigurationError, "Sitemap base_url must be an absolute HTTP(S) origin"
|
|
18
|
+
end
|
|
19
|
+
@base_url = base_url.chomp("/")
|
|
20
|
+
rescue URI::InvalidURIError
|
|
21
|
+
raise ConfigurationError, "Sitemap base_url must be an absolute HTTP(S) origin"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call
|
|
25
|
+
featured = ReaderQueries.featured
|
|
26
|
+
count = Post.listed.count - (featured ? 1 : 0)
|
|
27
|
+
entries = list_pages(OpenBlog.mount_path, count)
|
|
28
|
+
Post.listed.find_each do |post|
|
|
29
|
+
next if off_site?(post.canonical_url)
|
|
30
|
+
entry = { loc: post.url(base: @base_url) }
|
|
31
|
+
date = ReaderDates.modified_at(post)
|
|
32
|
+
entry[:lastmod] = date if date
|
|
33
|
+
entries << entry
|
|
34
|
+
end
|
|
35
|
+
Page.published.find_each do |page|
|
|
36
|
+
next if OpenBlog.config.policy_urls[page.kind.to_sym].present?
|
|
37
|
+
entries << { loc: page.url(base: @base_url), lastmod: page.updated_at }
|
|
38
|
+
end
|
|
39
|
+
if OpenBlog.config.primary_list_type == :tags
|
|
40
|
+
Tag.find_each do |tag|
|
|
41
|
+
entries.concat(list_pages(ReaderQueries.tag_path(tag), tag.posts.listed.count))
|
|
42
|
+
end
|
|
43
|
+
else
|
|
44
|
+
Category.find_each do |category|
|
|
45
|
+
entries.concat(list_pages(ReaderQueries.category_path(category), category.posts.listed.count))
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
entries
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def list_pages(path, count)
|
|
54
|
+
total = [ (count.to_f / OpenBlog.config.posts_per_page).ceil, 1 ].max
|
|
55
|
+
(1..total).map { |page| { loc: "#{@base_url}#{path}#{page > 1 ? "?page=#{page}" : ''}" } }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def off_site?(url)
|
|
59
|
+
return false if url.blank?
|
|
60
|
+
uri = URI.parse(url)
|
|
61
|
+
uri.host&.downcase != @origin.host.downcase || uri.port != @origin.port
|
|
62
|
+
rescue URI::InvalidURIError
|
|
63
|
+
true
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
class SurfaceReport
|
|
3
|
+
class ExpectedContent
|
|
4
|
+
class RenderingController < OpenBlog::ApplicationController
|
|
5
|
+
helper do
|
|
6
|
+
def open_blog_notices(*) = "".html_safe
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.call(post, payload, base_url:)
|
|
11
|
+
snapshot = post.dup
|
|
12
|
+
%w[title description search_title search_description].each { |field| snapshot.public_send("#{field}=", payload.fetch(field)) }
|
|
13
|
+
snapshot.author = post.author
|
|
14
|
+
snapshot.author_name = payload.fetch("author")
|
|
15
|
+
snapshot.define_singleton_method(:body_for_payload) { payload.fetch("body") }
|
|
16
|
+
snapshot.define_singleton_method(:faq_list) { payload.fetch("faq").map(&:symbolize_keys) }
|
|
17
|
+
cover = payload.fetch("images").find { |entry| entry["role"] == "cover" }
|
|
18
|
+
snapshot.cover_image = cover && Image.find_by(sha256: cover["sha256"])
|
|
19
|
+
snapshot.cover_alt = cover&.fetch("alt")
|
|
20
|
+
page = ReaderPage.new(kind: :preview, record: snapshot, path: post.path, base_url: base_url)
|
|
21
|
+
html = RenderingController.render(template: "open_blog/posts/show", layout: false,
|
|
22
|
+
assigns: { open_blog_page: page, related_posts: [], related_cache_key: [] })
|
|
23
|
+
Nokogiri::HTML5(html).at_css("[data-open-blog-content]")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "resolv"
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
require "timeout"
|
|
5
|
+
require_relative "../image_fetch"
|
|
6
|
+
|
|
7
|
+
module OpenBlog
|
|
8
|
+
class SurfaceReport
|
|
9
|
+
class Http
|
|
10
|
+
Response = Struct.new(:status, :headers, :body, :url, keyword_init: true)
|
|
11
|
+
class FetchError < StandardError
|
|
12
|
+
def initialize(_message = nil) = super("Request unavailable")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(trusted_origin: nil, max_bytes: 10 * 1024 * 1024, timeout: 10, client: nil)
|
|
16
|
+
raise ArgumentError unless max_bytes.is_a?(Integer) && max_bytes.positive? && timeout.is_a?(Numeric) && timeout.positive?
|
|
17
|
+
@trusted_origin = origin(parse(trusted_origin)) if trusted_origin
|
|
18
|
+
@max_bytes, @timeout, @client = max_bytes, timeout, client
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def get(url)
|
|
22
|
+
uri = parse(url)
|
|
23
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout
|
|
24
|
+
Timeout.timeout(@timeout, FetchError) do
|
|
25
|
+
headers = { "User-Agent" => "OpenBlogSurfaceBot", "Accept-Encoding" => "identity", "Accept" => "*/*" }
|
|
26
|
+
if @client
|
|
27
|
+
response = @client.get(uri.to_s, headers: headers)
|
|
28
|
+
body = response.body.to_s.b
|
|
29
|
+
raise FetchError if body.bytesize > @max_bytes
|
|
30
|
+
return Response.new(status: response.status.to_i, headers: normalize_headers(response.headers), body: body, url: uri.to_s)
|
|
31
|
+
end
|
|
32
|
+
addresses = Resolv.getaddresses(uri.hostname)
|
|
33
|
+
raise FetchError if addresses.empty?
|
|
34
|
+
addresses.each { |address| public_address!(address) } unless origin(uri) == @trusted_origin
|
|
35
|
+
http = Net::HTTP.new(uri.hostname, uri.port, nil)
|
|
36
|
+
http.ipaddr = addresses.first
|
|
37
|
+
http.use_ssl = uri.scheme == "https"
|
|
38
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
39
|
+
http.verify_hostname = true
|
|
40
|
+
http.max_retries = 0
|
|
41
|
+
http.open_timeout = http.read_timeout = http.write_timeout = remaining(deadline)
|
|
42
|
+
result = nil
|
|
43
|
+
http.start do |connection|
|
|
44
|
+
connection.request(Net::HTTP::Get.new(uri.request_uri, headers)) do |response|
|
|
45
|
+
bytes = +"".b
|
|
46
|
+
response.read_body do |chunk|
|
|
47
|
+
raise FetchError if bytes.bytesize + chunk.bytesize > @max_bytes
|
|
48
|
+
bytes << chunk
|
|
49
|
+
http.read_timeout = remaining(deadline)
|
|
50
|
+
end
|
|
51
|
+
result = Response.new(status: response.code.to_i, headers: normalize_headers(response.to_hash), body: bytes, url: uri.to_s)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
remaining(deadline)
|
|
55
|
+
result
|
|
56
|
+
end
|
|
57
|
+
rescue URI::InvalidURIError, IPAddr::InvalidAddressError, Resolv::ResolvError, SocketError, SystemCallError,
|
|
58
|
+
IOError, EOFError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Timeout::Error, OpenSSL::SSL::SSLError, ArgumentError
|
|
59
|
+
raise FetchError
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def parse(value)
|
|
65
|
+
raise FetchError unless value.is_a?(String)
|
|
66
|
+
uri = URI.parse(value)
|
|
67
|
+
raise FetchError unless uri.is_a?(URI::HTTP) && uri.hostname.present? && uri.userinfo.nil? && !uri.hostname.include?("%")
|
|
68
|
+
uri.fragment = nil
|
|
69
|
+
uri
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def origin(uri) = [ uri.scheme.downcase, uri.hostname.downcase, uri.port ]
|
|
73
|
+
|
|
74
|
+
def public_address!(value)
|
|
75
|
+
ip = IPAddr.new(value).native
|
|
76
|
+
valid = ip.ipv4? ? ImageFetch::NONPUBLIC_V4.none? { |range| range.include?(ip) } :
|
|
77
|
+
ImageFetch::GLOBAL_V6.include?(ip) && ImageFetch::NONPUBLIC_V6.none? { |range| range.include?(ip) }
|
|
78
|
+
raise FetchError unless valid
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def normalize_headers(headers)
|
|
82
|
+
headers.to_h.to_h { |key, value| [ key.to_s.downcase, Array(value).join(", ") ] }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def remaining(deadline)
|
|
86
|
+
seconds = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
87
|
+
raise FetchError unless seconds.positive?
|
|
88
|
+
seconds
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|