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,94 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
require "commonmarker"
|
|
3
|
+
|
|
4
|
+
module OpenBlog
|
|
5
|
+
module Findings
|
|
6
|
+
module RecordChecks
|
|
7
|
+
class << self
|
|
8
|
+
def build
|
|
9
|
+
[
|
|
10
|
+
check(:description_absent, "T8", "Add a description for readers and search results.", "description") { |post, _| description(post).blank? },
|
|
11
|
+
check(:title_duplicate, "T7", "Another published post uses this title.", "title") { |post, _| other_posts(post).exists?(title: post.title) },
|
|
12
|
+
check(:description_duplicate, "T8", "Another published post uses this search description.", "search_description") { |post, _| duplicate_description?(post) },
|
|
13
|
+
check(:category_absent, "T21", "Choose a category for this post.", "category") { |post, _| primary_categories? && post.category.nil? },
|
|
14
|
+
check(:category_description_absent, "T21", "Add an introduction to this category.", "category.description") { |post, _| primary_categories? && post.category && post.category.description.blank? },
|
|
15
|
+
check(:faq_question_duplicate, nil, "An FAQ question appears more than once.", ->(_, _, index) { "faq[#{index}].question" }) { |post, _| duplicate_question(post) },
|
|
16
|
+
check(:faq_markup_in_answer, "E19", "FAQ answers display plain text; remove formatting markup.", ->(_, _, index) { "faq[#{index}].answer" }) { |post, _| markup_answer(post) },
|
|
17
|
+
check(:faq_in_body, nil, "Move this FAQ section into the post's FAQ entries to avoid duplicate content.", "body") { |post, _| post.markdown? && FaqExtraction.contains_faq_heading?(post.body_markdown) },
|
|
18
|
+
check(:author_default_used, "E1", "This post uses the configured default author.", "author") { |_, context| context[:author_default_used] },
|
|
19
|
+
check(:provenance_unknown, "E18", "Specify whether AI contributed to this post.", "provenance") { |post, _| post.provenance_unknown? },
|
|
20
|
+
check(:approval_absent, "E18", "This revision has no approval confirming its facts.", "approval") { |post, _| approval_absent?(post) },
|
|
21
|
+
check(:approval_incomplete, "E18", "The supplied approval says its facts were not checked.", "approval.facts_checked") { |_, context| context[:approval_incomplete] },
|
|
22
|
+
check(:connections_not_declared, "E21, E22", "Record any relevant relationships, including an explicit declaration of none.", "connections") { |post, _| !post.connection_declarations.exists? },
|
|
23
|
+
check(:responsible_party_absent, "E4", "Configure a page naming the person or organization responsible for the blog.") { |_, _| OpenBlog.policy_url(:responsible_party).blank? },
|
|
24
|
+
check(:canonical_off_site, "T5", "This canonical URL points outside the blog's configured origin.", "canonical_url") { |post, _| canonical_off_site?(post) },
|
|
25
|
+
check(:slug_changed, "T4", "The previous public URL now redirects to this post.", "slug") { |_, context| context[:slug_changed] },
|
|
26
|
+
check(:social_image_absent, "T17", "Choose a social image, cover image, or blog default image.", "social_image") { |post, _| post.cover_image_id.nil? && post.social_image_id.nil? && OpenBlog.config.default_social_image_url.blank? }
|
|
27
|
+
]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def check(code, rule, message, location = nil, &predicate)
|
|
33
|
+
Check.new(code: code, rule: rule, message: message, location: location, &predicate)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def other_posts(post)
|
|
37
|
+
Post.listed.where.not(id: post.id)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def description(post)
|
|
41
|
+
post.search_description.presence || post.description
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def duplicate_description?(post)
|
|
45
|
+
value = description(post)
|
|
46
|
+
value.present? && other_posts(post).pluck(:description, :search_description).any? do |editorial, search|
|
|
47
|
+
(search.presence || editorial) == value
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def primary_categories?
|
|
52
|
+
OpenBlog.config.primary_list_type == :categories
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def duplicate_question(post)
|
|
56
|
+
seen = []
|
|
57
|
+
post.faq_list.each_with_index do |entry, index|
|
|
58
|
+
question = entry[:question].to_s.unicode_normalize(:nfc).strip.downcase
|
|
59
|
+
return index if seen.include?(question)
|
|
60
|
+
seen << question
|
|
61
|
+
end
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def markup_answer(post)
|
|
66
|
+
post.faq_list.index { |entry| markup?(Commonmarker.parse(entry[:answer].to_s), entry[:answer].to_s) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def markup?(node, source)
|
|
70
|
+
if node.type == :link
|
|
71
|
+
return true if source.match?(/[\[<]/)
|
|
72
|
+
elsif !%i[document paragraph text softbreak linebreak].include?(node.type)
|
|
73
|
+
return true
|
|
74
|
+
end
|
|
75
|
+
node.each.any? { |child| markup?(child, source) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def approval_absent?(post)
|
|
79
|
+
return false if post.provenance_human_written? || (!post.published? && !post.scheduled?)
|
|
80
|
+
identifier = post.published? ? post.public_revision&.identifier : post.current_revision_identifier
|
|
81
|
+
!identifier || !post.approvals.joins(:revision).exists?(facts_checked: true, open_blog_revisions: { identifier: identifier })
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def canonical_off_site?(post)
|
|
85
|
+
return false if post.canonical_url.blank? || OpenBlog.config.public_base_url.blank?
|
|
86
|
+
canonical, base = [ post.canonical_url, OpenBlog.config.public_base_url ].map { |value| URI.parse(value) }
|
|
87
|
+
[ canonical.scheme, canonical.host&.downcase, canonical.port ] != [ base.scheme, base.host&.downcase, base.port ]
|
|
88
|
+
rescue URI::InvalidURIError
|
|
89
|
+
false
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
module Findings
|
|
3
|
+
class Registry
|
|
4
|
+
def initialize
|
|
5
|
+
@checks = {}
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def register(check)
|
|
9
|
+
@checks[check.code] = check
|
|
10
|
+
self
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def for(post, context: {})
|
|
14
|
+
@checks.values.flat_map { |check| check.call(post, context: context) }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
require_relative "findings/check"
|
|
2
|
+
require_relative "findings/registry"
|
|
3
|
+
require_relative "findings/record_checks"
|
|
4
|
+
require_relative "findings/body_checks"
|
|
5
|
+
require_relative "findings/link_check"
|
|
6
|
+
|
|
7
|
+
module OpenBlog
|
|
8
|
+
module Findings
|
|
9
|
+
class << self
|
|
10
|
+
def registry
|
|
11
|
+
@registry ||= Registry.new.tap do |registry|
|
|
12
|
+
RecordChecks.build.each { |check| registry.register(check) }
|
|
13
|
+
BodyChecks.build.each { |check| registry.register(check) }
|
|
14
|
+
registry.register(LinkCheck.new)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def register(check)
|
|
19
|
+
registry.register(check)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def for(post, context: {})
|
|
23
|
+
registry.for(post, context: context.dup)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "resolv"
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
require "timeout"
|
|
5
|
+
require "stringio"
|
|
6
|
+
require "marcel"
|
|
7
|
+
|
|
8
|
+
module OpenBlog
|
|
9
|
+
class ImageFetch
|
|
10
|
+
TIMEOUT = 10
|
|
11
|
+
REDIRECTS = [ 301, 302, 303, 307, 308 ].freeze
|
|
12
|
+
NONPUBLIC_V4 = %w[0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16
|
|
13
|
+
172.16.0.0/12 192.0.0.0/24 192.0.2.0/24 192.88.99.0/24 192.168.0.0/16
|
|
14
|
+
198.18.0.0/15 198.51.100.0/24 203.0.113.0/24 224.0.0.0/4 240.0.0.0/4].map { |range| IPAddr.new(range) }.freeze
|
|
15
|
+
GLOBAL_V6 = IPAddr.new("2000::/3")
|
|
16
|
+
NONPUBLIC_V6 = %w[2001::/23 2001:db8::/32 2002::/16 3fff::/20].map { |range| IPAddr.new(range) }.freeze
|
|
17
|
+
class DeadlineExceeded < StandardError; end
|
|
18
|
+
|
|
19
|
+
def self.call(url, max_bytes: OpenBlog.config.max_image_bytes, policy: OpenBlog.config.image_fetch_policy)
|
|
20
|
+
new(max_bytes: max_bytes, policy: policy).call(url)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(max_bytes:, policy:)
|
|
24
|
+
raise Error::ImageNotPermitted unless max_bytes.is_a?(Integer) && max_bytes.positive? && %i[public_only open].include?(policy)
|
|
25
|
+
@max_bytes, @policy = max_bytes, policy
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call(url)
|
|
29
|
+
@deadline = monotonic + TIMEOUT
|
|
30
|
+
Timeout.timeout(TIMEOUT, DeadlineExceeded) do
|
|
31
|
+
uri = parse_url(url)
|
|
32
|
+
redirects = 0
|
|
33
|
+
loop do
|
|
34
|
+
addresses = Resolv.getaddresses(uri.hostname)
|
|
35
|
+
raise Error::ImageNotPermitted if addresses.empty?
|
|
36
|
+
addresses.each { |address| permitted_address!(address) } unless @policy == :open
|
|
37
|
+
status, location, bytes = download(uri, addresses.first)
|
|
38
|
+
if REDIRECTS.include?(status)
|
|
39
|
+
raise Error::ImageNotPermitted if redirects >= 3 || location.nil?
|
|
40
|
+
uri = parse_url(URI.join(uri.to_s, location).to_s)
|
|
41
|
+
redirects += 1
|
|
42
|
+
else
|
|
43
|
+
raise Error::ImageNotPermitted unless status.between?(200, 299)
|
|
44
|
+
type = Marcel::MimeType.for(StringIO.new(bytes))
|
|
45
|
+
raise Error::ImageNotPermitted if type == "image/svg+xml" || !OpenBlog.config.image_content_types.include?(type)
|
|
46
|
+
filename = File.basename(URI::DEFAULT_PARSER.unescape(uri.path).tr("\\", "/"))
|
|
47
|
+
filename = "image" if filename.blank? || %w[/ . ..].include?(filename)
|
|
48
|
+
return { io: StringIO.new(bytes), content_type: type, filename: filename }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
rescue URI::InvalidURIError, IPAddr::InvalidAddressError, Resolv::ResolvError, SocketError,
|
|
53
|
+
SystemCallError, IOError, EOFError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError,
|
|
54
|
+
Timeout::Error, DeadlineExceeded, OpenSSL::SSL::SSLError, ArgumentError
|
|
55
|
+
raise Error::ImageNotPermitted
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def parse_url(value)
|
|
61
|
+
raise Error::ImageNotPermitted unless value.is_a?(String)
|
|
62
|
+
uri = URI.parse(value)
|
|
63
|
+
unless uri.is_a?(URI::HTTP) && uri.hostname.present? && uri.userinfo.nil? && !uri.hostname.include?("%")
|
|
64
|
+
raise Error::ImageNotPermitted
|
|
65
|
+
end
|
|
66
|
+
uri.fragment = nil
|
|
67
|
+
uri
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def permitted_address!(address)
|
|
71
|
+
ip = IPAddr.new(address).native
|
|
72
|
+
public = if ip.ipv4?
|
|
73
|
+
NONPUBLIC_V4.none? { |range| range.include?(ip) }
|
|
74
|
+
else
|
|
75
|
+
GLOBAL_V6.include?(ip) && NONPUBLIC_V6.none? { |range| range.include?(ip) }
|
|
76
|
+
end
|
|
77
|
+
raise Error::ImageNotPermitted unless public
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def download(uri, address)
|
|
81
|
+
http = Net::HTTP.new(uri.hostname, uri.port, nil)
|
|
82
|
+
http.ipaddr = address
|
|
83
|
+
http.use_ssl = uri.scheme == "https"
|
|
84
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
85
|
+
http.verify_hostname = true
|
|
86
|
+
http.max_retries = 0
|
|
87
|
+
http.open_timeout = http.read_timeout = http.write_timeout = remaining
|
|
88
|
+
request = Net::HTTP::Get.new(uri.request_uri, "Accept-Encoding" => "identity")
|
|
89
|
+
bytes = +"".b
|
|
90
|
+
result = nil
|
|
91
|
+
http.start do |connection|
|
|
92
|
+
connection.request(request) do |response|
|
|
93
|
+
response.read_body do |chunk|
|
|
94
|
+
raise Error::ImageNotPermitted if bytes.bytesize + chunk.bytesize > @max_bytes
|
|
95
|
+
bytes << chunk
|
|
96
|
+
http.read_timeout = remaining
|
|
97
|
+
end
|
|
98
|
+
result = [ response.code.to_i, response["location"], bytes ]
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
remaining
|
|
102
|
+
result
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def remaining
|
|
106
|
+
seconds = @deadline - monotonic
|
|
107
|
+
raise DeadlineExceeded unless seconds.positive?
|
|
108
|
+
seconds
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def monotonic
|
|
112
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
require "delegate"
|
|
2
|
+
require "digest"
|
|
3
|
+
require "stringio"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
require "marcel"
|
|
6
|
+
|
|
7
|
+
module OpenBlog
|
|
8
|
+
class ImageImport
|
|
9
|
+
def self.prepare(input)
|
|
10
|
+
return prepare_url(input[:url] || input["url"]) if input.is_a?(Hash) && input.keys.map(&:to_s) == [ "url" ]
|
|
11
|
+
unless input.is_a?(Hash) && input.keys.map(&:to_s) == [ "signed_id" ]
|
|
12
|
+
raise Error::ImageNotPermitted
|
|
13
|
+
end
|
|
14
|
+
signed_id = input[:signed_id] || input["signed_id"]
|
|
15
|
+
raise Error::ImageNotPermitted unless signed_id.is_a?(String) && signed_id.present?
|
|
16
|
+
blob = ActiveStorage::Blob.find_signed!(signed_id)
|
|
17
|
+
new(blob).prepare
|
|
18
|
+
rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound, ActiveStorage::FileNotFoundError
|
|
19
|
+
raise Error::ImageNotPermitted
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.prepare_url(url)
|
|
23
|
+
fetched = ImageFetch.call(url, max_bytes: OpenBlog.config.max_image_bytes, policy: OpenBlog.config.image_fetch_policy)
|
|
24
|
+
prepared = prepare_upload(fetched.fetch(:io), filename: fetched.fetch(:filename), content_type: fetched.fetch(:content_type))
|
|
25
|
+
Prepared.new(nil, prepared.attributes.merge(source_url: url), bytes: prepared.bytes)
|
|
26
|
+
ensure
|
|
27
|
+
fetched&.fetch(:io)&.close
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.prepare_upload(io, filename:, content_type:)
|
|
31
|
+
unless io.respond_to?(:read) && filename.is_a?(String) && filename.present? && !filename.include?("\0")
|
|
32
|
+
raise Error::ImageNotPermitted
|
|
33
|
+
end
|
|
34
|
+
filename = ActiveStorage::Filename.new(filename.tr("\\", "/").split("/").last.to_s).sanitized
|
|
35
|
+
raise Error::ImageNotPermitted if filename.blank?
|
|
36
|
+
bytes = +"".b
|
|
37
|
+
io.rewind if io.respond_to?(:rewind)
|
|
38
|
+
limit = OpenBlog.config.max_image_bytes
|
|
39
|
+
loop do
|
|
40
|
+
chunk = io.read([ 64 * 1024, limit - bytes.bytesize + 1 ].min)
|
|
41
|
+
break if chunk.nil? || chunk.empty?
|
|
42
|
+
bytes << chunk
|
|
43
|
+
raise Error::ImageNotPermitted if bytes.bytesize > limit
|
|
44
|
+
end
|
|
45
|
+
source = ActiveStorage::Blob.new(filename: filename, content_type: content_type, byte_size: bytes.bytesize)
|
|
46
|
+
new(source).prepare_bytes(bytes.freeze, uploaded: true)
|
|
47
|
+
rescue IOError, SystemCallError
|
|
48
|
+
raise Error::ImageNotPermitted
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def initialize(blob)
|
|
52
|
+
@blob = blob
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def prepare
|
|
56
|
+
permitted!(@blob.content_type)
|
|
57
|
+
limit = OpenBlog.config.max_image_bytes
|
|
58
|
+
raise Error::ImageNotPermitted if @blob.byte_size > limit
|
|
59
|
+
bytes = +"".b
|
|
60
|
+
@blob.download do |chunk|
|
|
61
|
+
raise Error::ImageNotPermitted if bytes.bytesize + chunk.bytesize > limit
|
|
62
|
+
bytes << chunk
|
|
63
|
+
end
|
|
64
|
+
unless bytes.bytesize == @blob.byte_size && Digest::MD5.base64digest(bytes) == @blob.checksum
|
|
65
|
+
raise Error::ImageNotPermitted
|
|
66
|
+
end
|
|
67
|
+
prepare_bytes(bytes)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def prepare_bytes(bytes, uploaded: false)
|
|
71
|
+
permitted!(@blob.content_type)
|
|
72
|
+
content_type = Marcel::MimeType.for(StringIO.new(bytes))
|
|
73
|
+
permitted!(content_type)
|
|
74
|
+
raise Error::ImageNotPermitted unless content_type == @blob.content_type
|
|
75
|
+
source = ReadOnlySource.new(@blob, bytes)
|
|
76
|
+
analyzer = ActiveStorage.analyzers.find { |candidate| candidate.accept?(source) }
|
|
77
|
+
raise Error::ImageNotPermitted unless analyzer
|
|
78
|
+
metadata = analyzer.new(source).metadata
|
|
79
|
+
dimensions = metadata.symbolize_keys.slice(:width, :height)
|
|
80
|
+
unless dimensions.size == 2 && dimensions.values.all? { |value| value.is_a?(Integer) && value.positive? }
|
|
81
|
+
raise Error::ImageNotPermitted
|
|
82
|
+
end
|
|
83
|
+
Prepared.new(uploaded ? nil : @blob, { sha256: Digest::SHA256.hexdigest(bytes), filename: @blob.filename.to_s,
|
|
84
|
+
byte_size: bytes.bytesize, content_type: content_type, **dimensions }, bytes: uploaded ? bytes : nil)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def permitted!(content_type)
|
|
90
|
+
unless content_type != "image/svg+xml" && OpenBlog.config.image_content_types.include?(content_type)
|
|
91
|
+
raise Error::ImageNotPermitted
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
class ReadOnlySource < SimpleDelegator
|
|
96
|
+
def initialize(blob, bytes)
|
|
97
|
+
super(blob)
|
|
98
|
+
@bytes = bytes
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def open(tmpdir: nil, **)
|
|
102
|
+
Tempfile.create([ "open-blog-image", File.extname(filename.to_s) ], tmpdir, binmode: true) do |file|
|
|
103
|
+
file.write(@bytes)
|
|
104
|
+
file.rewind
|
|
105
|
+
yield file
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
class Prepared
|
|
111
|
+
attr_reader :attributes, :bytes
|
|
112
|
+
|
|
113
|
+
def initialize(blob, attributes, bytes: nil)
|
|
114
|
+
@blob, @attributes, @bytes = blob, attributes.freeze, bytes
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def image
|
|
118
|
+
@image ||= Image.find_by(sha256: attributes[:sha256]) || Image.new(attributes)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def persist(uploaded_by: nil)
|
|
122
|
+
Image.transaction(requires_new: true) do
|
|
123
|
+
existing = Image.find_by(sha256: attributes[:sha256])
|
|
124
|
+
next existing if existing
|
|
125
|
+
next persist_upload(uploaded_by) unless @blob
|
|
126
|
+
blob = ActiveStorage::Blob.lock.find(@blob.id)
|
|
127
|
+
existing = Image.find_by(sha256: attributes[:sha256])
|
|
128
|
+
next existing if existing
|
|
129
|
+
fields = %w[key service_name checksum byte_size content_type]
|
|
130
|
+
unless blob.attributes.slice(*fields) == @blob.attributes.slice(*fields)
|
|
131
|
+
raise Error::ImageNotPermitted
|
|
132
|
+
end
|
|
133
|
+
blob.update!(metadata: blob.metadata.merge("identified" => true, "analyzed" => true,
|
|
134
|
+
"width" => attributes[:width], "height" => attributes[:height]))
|
|
135
|
+
record = Image.new(attributes.merge(uploaded_by: uploaded_by))
|
|
136
|
+
record.file = blob
|
|
137
|
+
record.save!
|
|
138
|
+
record
|
|
139
|
+
end
|
|
140
|
+
rescue ActiveRecord::RecordNotUnique
|
|
141
|
+
Image.find_by!(sha256: attributes[:sha256])
|
|
142
|
+
rescue ActiveRecord::RecordInvalid => error
|
|
143
|
+
errors = error.record.errors.details
|
|
144
|
+
if error.record.is_a?(Image) && errors.keys == [ :sha256 ] && errors[:sha256].all? { |detail| detail[:error] == :taken }
|
|
145
|
+
existing = Image.find_by(sha256: attributes[:sha256])
|
|
146
|
+
return existing if existing
|
|
147
|
+
end
|
|
148
|
+
raise Error::ImageNotPermitted
|
|
149
|
+
rescue ActiveRecord::RecordNotFound
|
|
150
|
+
raise Error::ImageNotPermitted
|
|
151
|
+
end
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
def persist_upload(uploaded_by)
|
|
155
|
+
record = Image.new(attributes.merge(uploaded_by: uploaded_by))
|
|
156
|
+
record.file = { io: StringIO.new(bytes), filename: attributes[:filename], content_type: attributes[:content_type],
|
|
157
|
+
identify: false, metadata: { "identified" => true, "analyzed" => true,
|
|
158
|
+
"width" => attributes[:width], "height" => attributes[:height] } }
|
|
159
|
+
record.save!
|
|
160
|
+
record
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "uri"
|
|
3
|
+
|
|
4
|
+
module OpenBlog
|
|
5
|
+
class ImageResolution
|
|
6
|
+
PAYLOAD_FIELDS = %w[url sha256 alt role].freeze
|
|
7
|
+
|
|
8
|
+
def self.prepare(attributes:, post: nil)
|
|
9
|
+
attributes = attributes.symbolize_keys
|
|
10
|
+
format = attributes.fetch(:body_format, post&.body_format || OpenBlog.config.default_body_format).to_s
|
|
11
|
+
unless OpenBlog.config.body_formats.map(&:to_s).include?(format)
|
|
12
|
+
raise Error::BodyFormatNotPermitted
|
|
13
|
+
end
|
|
14
|
+
body = attributes.key?(:body) ? attributes[:body] : (format == "rich_text" ? post&.rich_body&.body : post&.body_markdown)
|
|
15
|
+
raise Error::ValidationFailed.new(details: [ "body" ]) unless body.nil? || body.is_a?(String) || body.is_a?(ActionText::Content)
|
|
16
|
+
text = format == "rich_text" ? ActionText::Content.new(body.to_s).to_html : body.to_s
|
|
17
|
+
digest = source_digest(text, format)
|
|
18
|
+
if post && !attributes.key?(:body) && !attributes.key?(:body_format) && post.body_image_source_digest == digest
|
|
19
|
+
return Prepared.new(digest, post.body_image_manifest.deep_dup, {}, sources(text, format), reuse_current: true)
|
|
20
|
+
end
|
|
21
|
+
build(text, format, external: true)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.prepare_inputs(attributes)
|
|
25
|
+
%i[cover_image social_image].each_with_object({}) do |field, prepared|
|
|
26
|
+
value = attributes[field] || attributes[field.to_s]
|
|
27
|
+
if value.is_a?(Hash) && (value.keys.map(&:to_s) & %w[signed_id url]).any?
|
|
28
|
+
prepared[field] = ImageImport.prepare(value)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.source_digest(text, format)
|
|
34
|
+
Digest::SHA256.hexdigest("#{format}\0#{text}")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.fingerprint(post)
|
|
38
|
+
source_digest(post.body_for_payload, post.body_format)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.fetch_external(url)
|
|
42
|
+
fetched = ImageFetch.call(url)
|
|
43
|
+
Digest::SHA256.hexdigest(fetched.fetch(:io).read)
|
|
44
|
+
rescue Error::ImageNotPermitted
|
|
45
|
+
nil
|
|
46
|
+
ensure
|
|
47
|
+
fetched&.fetch(:io)&.close
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.native!(post)
|
|
51
|
+
return if post.body_image_source_digest == fingerprint(post)
|
|
52
|
+
build(post.body_for_payload, post.body_format, external: false).materialize(post)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.body_images(post)
|
|
56
|
+
entries = if post.body_image_source_digest == fingerprint(post)
|
|
57
|
+
post.body_image_manifest
|
|
58
|
+
else
|
|
59
|
+
build(post.body_for_payload, post.body_format, external: false, import: false).entries
|
|
60
|
+
end
|
|
61
|
+
entries.map { |entry| entry.slice(*PAYLOAD_FIELDS) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.image_for_attachment(blob, post: nil)
|
|
65
|
+
return unless blob.is_a?(ActiveStorage::Blob)
|
|
66
|
+
image = Image.joins(:file_attachment).find_by(active_storage_attachments: { blob_id: blob.id })
|
|
67
|
+
return image if image
|
|
68
|
+
entry = post&.body_image_manifest&.find { |candidate| candidate["blob_id"] == blob.id }
|
|
69
|
+
Image.new(entry.fetch("image_attributes")) if entry && entry["image_attributes"]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.image_for_url(url, post: nil)
|
|
73
|
+
prefix = "#{OpenBlog.mount_path.chomp('/')}/media/"
|
|
74
|
+
match = url.to_s.match(/\A#{Regexp.escape(prefix)}([0-9a-f]{64})\//)
|
|
75
|
+
return unless match
|
|
76
|
+
Image.find_by(sha256: match[1]) || begin
|
|
77
|
+
entry = post&.body_image_manifest&.find { |candidate| candidate["sha256"] == match[1] }
|
|
78
|
+
Image.new(entry.fetch("image_attributes")) if entry && entry["image_attributes"]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.build(text, format, external:, import: true)
|
|
83
|
+
imports = {}
|
|
84
|
+
source_entries = sources(text, format)
|
|
85
|
+
entries = source_entries.filter_map do |source|
|
|
86
|
+
blob = source[:blob]
|
|
87
|
+
image = blob ? image_for_attachment(blob) : image_for_url(source[:url])
|
|
88
|
+
if blob && !image && import
|
|
89
|
+
prepared = imports[blob.id] ||= ImageImport.prepare(signed_id: blob.signed_id)
|
|
90
|
+
image = prepared.image
|
|
91
|
+
end
|
|
92
|
+
next if blob && !image
|
|
93
|
+
url = image ? image.path : source[:url]
|
|
94
|
+
sha = image&.sha256 || (external && URI.parse(url).host ? fetch_external(url) : nil) || ""
|
|
95
|
+
entry = { "url" => url, "sha256" => sha, "alt" => source[:alt].to_s, "role" => "body" }
|
|
96
|
+
if blob
|
|
97
|
+
entry["blob_id"] = blob.id
|
|
98
|
+
entry["image_attributes"] = image.attributes.slice("sha256", "filename", "content_type", "byte_size", "width", "height")
|
|
99
|
+
end
|
|
100
|
+
entry
|
|
101
|
+
end
|
|
102
|
+
Prepared.new(source_digest(text, format), entries, imports, source_entries)
|
|
103
|
+
end
|
|
104
|
+
private_class_method :build
|
|
105
|
+
|
|
106
|
+
def self.sources(text, format)
|
|
107
|
+
html = format == "markdown" ? Renderer::Markdown.render(text) : text
|
|
108
|
+
fragment = Nokogiri::HTML5.fragment(html)
|
|
109
|
+
attachments = {}
|
|
110
|
+
prefix = "/open-blog-prepared-attachment/"
|
|
111
|
+
urls = fragment.css("img[src]").map { |node| node["src"] }
|
|
112
|
+
prefix += "x/" while urls.any? { |url| url.start_with?(prefix) }
|
|
113
|
+
fragment.css("action-text-attachment").each_with_index do |node, index|
|
|
114
|
+
attachable = ActionText::Attachable.from_node(node)
|
|
115
|
+
next node.remove unless attachable.is_a?(ActiveStorage::Blob) && attachable.content_type.start_with?("image/")
|
|
116
|
+
image = Nokogiri::XML::Node.new("img", fragment.document)
|
|
117
|
+
image["src"] = "#{prefix}#{index}"
|
|
118
|
+
image["alt"] = node["alt"].presence || PlainText.from_html(node["caption"].to_s)
|
|
119
|
+
attachments[image["src"]] = attachable
|
|
120
|
+
node.replace(image)
|
|
121
|
+
end
|
|
122
|
+
document = Renderer::Sanitizer.call(fragment.to_html)
|
|
123
|
+
Renderer::Sanitizer.restore!(document).css("img[src]").map do |node|
|
|
124
|
+
blob = attachments[node["src"]]
|
|
125
|
+
{ url: blob ? nil : node["src"], alt: node["alt"], blob: blob }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
private_class_method :sources
|
|
129
|
+
|
|
130
|
+
class Prepared
|
|
131
|
+
attr_reader :entries
|
|
132
|
+
|
|
133
|
+
def initialize(digest, entries, imports, sources, reuse_current: false)
|
|
134
|
+
@digest, @entries, @imports, @sources = digest, entries, imports, sources
|
|
135
|
+
@reuse_current = reuse_current
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def verify!(post)
|
|
139
|
+
current = ImageResolution.fingerprint(post)
|
|
140
|
+
return if current == @digest
|
|
141
|
+
sources = ImageResolution.send(:sources, post.body_for_payload, post.body_format)
|
|
142
|
+
unless sources == @sources
|
|
143
|
+
raise Error::ValidationFailed.new(details: [ "body" ])
|
|
144
|
+
end
|
|
145
|
+
@digest = current
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def preview(post)
|
|
149
|
+
verify!(post)
|
|
150
|
+
post.body_image_source_digest = @digest
|
|
151
|
+
post.body_image_manifest = entries.deep_dup
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def materialize(post, actor: nil)
|
|
155
|
+
if @reuse_current && post.body_image_source_digest == ImageResolution.fingerprint(post)
|
|
156
|
+
@digest = post.body_image_source_digest
|
|
157
|
+
@entries = post.body_image_manifest.deep_dup
|
|
158
|
+
@sources = ImageResolution.send(:sources, post.body_for_payload, post.body_format)
|
|
159
|
+
end
|
|
160
|
+
verify!(post)
|
|
161
|
+
images = @imports.transform_values { |prepared| prepared.persist(uploaded_by: actor) }
|
|
162
|
+
@entries = entries.map do |entry|
|
|
163
|
+
image = images[entry["blob_id"]]
|
|
164
|
+
image ? entry.merge("url" => image.path, "sha256" => image.sha256,
|
|
165
|
+
"image_attributes" => image.attributes.slice("sha256", "filename", "content_type", "byte_size", "width", "height")) : entry
|
|
166
|
+
end
|
|
167
|
+
preview(post)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
APPROVAL_INSTRUCTION = "Show the final text to the user before publication and ask once whether they approve that exact version and have checked its facts. Include the preview link for unpublished content. Record approval only from an actual answer: send the user's name, facts_checked with their stated true or false value, and the reviewed draft's revision_identifier. For an approved public edit without a saved draft identifier, send the complete reviewed content and inline approval with the user's name and facts_checked answer, omitting approval.revision_identifier so the operation binds it to the new revision. Never attach the old public identifier to changed text. Compare the returned content with the reviewed version and report any unexpected difference. If no answer was given, omit approval. Never invent a review or a fact-check. When text changes after approval, show the new version and ask again.".freeze
|
|
3
|
+
PROVENANCE_INSTRUCTION = "Set provenance to ai_assisted if AI authored or rewrote any content, or human_written only when a person wrote all of it.".freeze
|
|
4
|
+
ADOPTION_INSTRUCTION = "Ask whether the original system has an approval record for the imported article. Use imported_approval only with its reviewer, original time, evidence, and the person confirming that it covers this content. Otherwise, record a declaration only when the user supplies the reviewer, approval time, facts_checked answer, declaration date, and their own name. Ask for a declared first publication date when historical evidence is missing, and place that answer in declaration.declared_first_published_at. Do not send approval in an adoption request or invent historical evidence or declarations.".freeze
|
|
5
|
+
APPROVAL_POINTER = "This operation does not release a post revision and does not require editorial approval. Before blog_publish_post, blog_update_post, or blog_correct_post, show the final version and ask the user to approve it and confirm whether its facts were checked.".freeze
|
|
6
|
+
DRAFT_INSTRUCTION = "Saving a draft requires no approval. Share its preview link when requesting approval to publish.".freeze
|
|
7
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module OpenBlog
|
|
2
|
+
class JsonFeed
|
|
3
|
+
def initialize(posts:, base_url:, feed_path:, title:, category: nil)
|
|
4
|
+
@posts, @base_url, @feed_path, @title, @category = posts, base_url.chomp("/"), feed_path, title, category
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def to_h
|
|
8
|
+
home_path = @category ? ReaderQueries.category_path(@category) : OpenBlog.mount_path
|
|
9
|
+
{ version: "https://jsonfeed.org/version/1.1", title: @title,
|
|
10
|
+
home_page_url: "#{@base_url}#{home_path}", feed_url: "#{@base_url}#{@feed_path}",
|
|
11
|
+
authors: [ { name: OpenBlog.config.publisher&.fetch(:name, nil) || OpenBlog.config.site_name } ],
|
|
12
|
+
items: @posts.map { |post| item(post) } }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def to_json(*arguments)
|
|
16
|
+
to_h.to_json(*arguments)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def item(post)
|
|
22
|
+
url = post.url(base: @base_url)
|
|
23
|
+
entry = { id: url, url: url, title: post.title, summary: post.description,
|
|
24
|
+
authors: [ { name: post.author_name, url: "#{@base_url}#{ReaderQueries.author_path(post.author)}" } ],
|
|
25
|
+
date_modified: ReaderDates.feed_updated_at(post).iso8601 }
|
|
26
|
+
published = ReaderDates.published_at(post)
|
|
27
|
+
entry[:date_published] = published.iso8601 if published
|
|
28
|
+
if OpenBlog.config.feed_content == :full
|
|
29
|
+
entry[:content_html] = Renderer.render(post).to_s
|
|
30
|
+
else
|
|
31
|
+
entry[:content_text] = post.description
|
|
32
|
+
end
|
|
33
|
+
entry
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|