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.
Files changed (257) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +52 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +101 -0
  5. data/app/assets/builds/open_blog/blog.css +2 -0
  6. data/app/assets/javascripts/open_blog/controllers/code_copy_controller.js +40 -0
  7. data/app/assets/javascripts/open_blog/controllers/reading_progress_controller.js +36 -0
  8. data/app/assets/javascripts/open_blog/controllers/search_controller.js +49 -0
  9. data/app/assets/javascripts/open_blog/controllers/share_controller.js +48 -0
  10. data/app/assets/javascripts/open_blog/controllers/theme_controller.js +51 -0
  11. data/app/assets/javascripts/open_blog/controllers/toc_controller.js +33 -0
  12. data/app/controllers/concerns/open_blog/reader_controller.rb +49 -0
  13. data/app/controllers/open_blog/api/v1/adoptions_controller.rb +27 -0
  14. data/app/controllers/open_blog/api/v1/approvals_controller.rb +15 -0
  15. data/app/controllers/open_blog/api/v1/authors_controller.rb +62 -0
  16. data/app/controllers/open_blog/api/v1/base_controller.rb +100 -0
  17. data/app/controllers/open_blog/api/v1/categories_controller.rb +33 -0
  18. data/app/controllers/open_blog/api/v1/connections_controller.rb +45 -0
  19. data/app/controllers/open_blog/api/v1/doctor_controller.rb +13 -0
  20. data/app/controllers/open_blog/api/v1/faq_extractions_controller.rb +13 -0
  21. data/app/controllers/open_blog/api/v1/findings_controller.rb +13 -0
  22. data/app/controllers/open_blog/api/v1/images_controller.rb +22 -0
  23. data/app/controllers/open_blog/api/v1/pages_controller.rb +67 -0
  24. data/app/controllers/open_blog/api/v1/posts_controller.rb +114 -0
  25. data/app/controllers/open_blog/api/v1/previews_controller.rb +13 -0
  26. data/app/controllers/open_blog/api/v1/records_controller.rb +13 -0
  27. data/app/controllers/open_blog/api/v1/redirects_controller.rb +51 -0
  28. data/app/controllers/open_blog/api/v1/report_controller.rb +20 -0
  29. data/app/controllers/open_blog/api/v1/resources_controller.rb +39 -0
  30. data/app/controllers/open_blog/api/v1/series_controller.rb +32 -0
  31. data/app/controllers/open_blog/api/v1/tags_controller.rb +12 -0
  32. data/app/controllers/open_blog/api/v1/views_controller.rb +19 -0
  33. data/app/controllers/open_blog/application_controller.rb +5 -0
  34. data/app/controllers/open_blog/authors_controller.rb +14 -0
  35. data/app/controllers/open_blog/categories_controller.rb +15 -0
  36. data/app/controllers/open_blog/errors_controller.rb +7 -0
  37. data/app/controllers/open_blog/feeds_controller.rb +22 -0
  38. data/app/controllers/open_blog/mcp_controller.rb +38 -0
  39. data/app/controllers/open_blog/media_controller.rb +20 -0
  40. data/app/controllers/open_blog/pages_controller.rb +12 -0
  41. data/app/controllers/open_blog/posts_controller.rb +57 -0
  42. data/app/controllers/open_blog/previews_controller.rb +35 -0
  43. data/app/controllers/open_blog/search_controller.rb +32 -0
  44. data/app/controllers/open_blog/series_controller.rb +15 -0
  45. data/app/controllers/open_blog/sitemaps_controller.rb +10 -0
  46. data/app/controllers/open_blog/tags_controller.rb +15 -0
  47. data/app/helpers/open_blog/content_helper.rb +79 -0
  48. data/app/helpers/open_blog/dates_helper.rb +18 -0
  49. data/app/helpers/open_blog/faq_helper.rb +59 -0
  50. data/app/helpers/open_blog/head_helper.rb +75 -0
  51. data/app/helpers/open_blog/icons_helper.rb +10 -0
  52. data/app/helpers/open_blog/notices_helper.rb +47 -0
  53. data/app/helpers/open_blog/pagination_helper.rb +18 -0
  54. data/app/helpers/open_blog/structured_data_helper.rb +63 -0
  55. data/app/helpers/open_blog/theme_helper.rb +52 -0
  56. data/app/helpers/open_blog/url_helper.rb +58 -0
  57. data/app/jobs/open_blog/publish_scheduled_post_job.rb +14 -0
  58. data/app/models/concerns/open_blog/immutable.rb +13 -0
  59. data/app/models/concerns/open_blog/immutable_image_attachment.rb +43 -0
  60. data/app/models/concerns/open_blog/previewable.rb +38 -0
  61. data/app/models/open_blog/api_token.rb +34 -0
  62. data/app/models/open_blog/application_record.rb +5 -0
  63. data/app/models/open_blog/approval.rb +19 -0
  64. data/app/models/open_blog/author.rb +11 -0
  65. data/app/models/open_blog/baseline.rb +26 -0
  66. data/app/models/open_blog/category.rb +10 -0
  67. data/app/models/open_blog/connection_declaration.rb +16 -0
  68. data/app/models/open_blog/faq.rb +22 -0
  69. data/app/models/open_blog/image.rb +45 -0
  70. data/app/models/open_blog/page.rb +23 -0
  71. data/app/models/open_blog/page_view.rb +7 -0
  72. data/app/models/open_blog/post.rb +132 -0
  73. data/app/models/open_blog/publication.rb +17 -0
  74. data/app/models/open_blog/redirect.rb +16 -0
  75. data/app/models/open_blog/revision.rb +12 -0
  76. data/app/models/open_blog/series.rb +10 -0
  77. data/app/models/open_blog/tag.rb +11 -0
  78. data/app/models/open_blog/tagging.rb +7 -0
  79. data/app/queries/open_blog/api_post_query.rb +54 -0
  80. data/app/serializers/open_blog/adoption_result_serializer.rb +9 -0
  81. data/app/serializers/open_blog/author_serializer.rb +10 -0
  82. data/app/serializers/open_blog/category_serializer.rb +7 -0
  83. data/app/serializers/open_blog/doctor_serializer.rb +7 -0
  84. data/app/serializers/open_blog/error_serializer.rb +7 -0
  85. data/app/serializers/open_blog/extraction_result_serializer.rb +7 -0
  86. data/app/serializers/open_blog/findings_serializer.rb +13 -0
  87. data/app/serializers/open_blog/image_serializer.rb +9 -0
  88. data/app/serializers/open_blog/page_serializer.rb +9 -0
  89. data/app/serializers/open_blog/post_serializer.rb +59 -0
  90. data/app/serializers/open_blog/preview_serializer.rb +11 -0
  91. data/app/serializers/open_blog/records_serializer.rb +28 -0
  92. data/app/serializers/open_blog/redirect_serializer.rb +7 -0
  93. data/app/serializers/open_blog/series_serializer.rb +7 -0
  94. data/app/serializers/open_blog/tag_serializer.rb +7 -0
  95. data/app/serializers/open_blog/write_result_serializer.rb +9 -0
  96. data/app/views/open_blog/attachments/_blob.html.erb +1 -0
  97. data/app/views/open_blog/feeds/atom.xml.builder +27 -0
  98. data/app/views/open_blog/sitemaps/show.xml.builder +9 -0
  99. data/config/locales/open_blog.en.yml +95 -0
  100. data/config/routes.rb +52 -0
  101. data/db/migrate/20261003012512_create_open_blog_authors.rb +16 -0
  102. data/db/migrate/20261003012513_create_open_blog_categories.rb +13 -0
  103. data/db/migrate/20261003012514_create_open_blog_series.rb +11 -0
  104. data/db/migrate/20261003012515_create_open_blog_images.rb +17 -0
  105. data/db/migrate/20261003012516_create_open_blog_posts.rb +47 -0
  106. data/db/migrate/20261003012517_create_open_blog_faqs.rb +13 -0
  107. data/db/migrate/20261003012518_create_open_blog_tags.rb +17 -0
  108. data/db/migrate/20261003012519_create_open_blog_redirects.rb +14 -0
  109. data/db/migrate/20261003012520_create_open_blog_revisions.rb +14 -0
  110. data/db/migrate/20261003012521_create_open_blog_approvals.rb +21 -0
  111. data/db/migrate/20261003012522_create_open_blog_publications.rb +18 -0
  112. data/db/migrate/20261003012523_create_open_blog_baselines.rb +24 -0
  113. data/db/migrate/20261003012524_create_open_blog_connection_declarations.rb +13 -0
  114. data/db/migrate/20261003031410_add_body_image_manifest_to_open_blog_posts.rb +7 -0
  115. data/db/migrate/20261003164418_create_open_blog_api_tokens.rb +15 -0
  116. data/db/migrate/20261003175454_create_open_blog_pages.rb +16 -0
  117. data/db/migrate/20261003182919_create_open_blog_page_views.rb +11 -0
  118. data/lib/generators/open_blog/admin_suite/admin_suite_generator.rb +27 -0
  119. data/lib/generators/open_blog/admin_suite/templates/author_resource.rb.tt +29 -0
  120. data/lib/generators/open_blog/admin_suite/templates/blog_portal.rb.tt +16 -0
  121. data/lib/generators/open_blog/admin_suite/templates/category_resource.rb.tt +28 -0
  122. data/lib/generators/open_blog/admin_suite/templates/initializer.rb.tt +19 -0
  123. data/lib/generators/open_blog/admin_suite/templates/page_resource.rb.tt +32 -0
  124. data/lib/generators/open_blog/admin_suite/templates/post_resource.rb.tt +58 -0
  125. data/lib/generators/open_blog/generator_support.rb +64 -0
  126. data/lib/generators/open_blog/install/install_generator.rb +188 -0
  127. data/lib/generators/open_blog/install/templates/initializer.rb.tt +16 -0
  128. data/lib/generators/open_blog/install/templates/sample_cover.png +0 -0
  129. data/lib/generators/open_blog/install/templates/sample_post.md +54 -0
  130. data/lib/generators/open_blog/install/templates/theme/blog.css +193 -0
  131. data/lib/generators/open_blog/install/templates/theme/open_blog_theme.css +82 -0
  132. data/lib/generators/open_blog/install/templates/theme/syntax.css +358 -0
  133. data/lib/generators/open_blog/install/templates/theme/theme.css +120 -0
  134. data/lib/generators/open_blog/install/templates/views/layouts/open_blog.html.erb +16 -0
  135. data/lib/generators/open_blog/install/templates/views/open_blog/authors/show.html.erb +17 -0
  136. data/lib/generators/open_blog/install/templates/views/open_blog/categories/show.html.erb +13 -0
  137. data/lib/generators/open_blog/install/templates/views/open_blog/errors/not_found.html.erb +11 -0
  138. data/lib/generators/open_blog/install/templates/views/open_blog/errors/preview_expired.html.erb +5 -0
  139. data/lib/generators/open_blog/install/templates/views/open_blog/icons/_check.svg.erb +1 -0
  140. data/lib/generators/open_blog/install/templates/views/open_blog/icons/_copy.svg.erb +1 -0
  141. data/lib/generators/open_blog/install/templates/views/open_blog/icons/_link.svg.erb +1 -0
  142. data/lib/generators/open_blog/install/templates/views/open_blog/icons/_monitor.svg.erb +1 -0
  143. data/lib/generators/open_blog/install/templates/views/open_blog/icons/_moon.svg.erb +1 -0
  144. data/lib/generators/open_blog/install/templates/views/open_blog/icons/_share.svg.erb +1 -0
  145. data/lib/generators/open_blog/install/templates/views/open_blog/icons/_sun.svg.erb +1 -0
  146. data/lib/generators/open_blog/install/templates/views/open_blog/pages/show.html.erb +5 -0
  147. data/lib/generators/open_blog/install/templates/views/open_blog/posts/_faq.html.erb +1 -0
  148. data/lib/generators/open_blog/install/templates/views/open_blog/posts/_highlights.html.erb +7 -0
  149. data/lib/generators/open_blog/install/templates/views/open_blog/posts/_preview_bar.html.erb +1 -0
  150. data/lib/generators/open_blog/install/templates/views/open_blog/posts/_series_nav.html.erb +12 -0
  151. data/lib/generators/open_blog/install/templates/views/open_blog/posts/_tags.html.erb +5 -0
  152. data/lib/generators/open_blog/install/templates/views/open_blog/posts/_toc.html.erb +1 -0
  153. data/lib/generators/open_blog/install/templates/views/open_blog/posts/index.html.erb +13 -0
  154. data/lib/generators/open_blog/install/templates/views/open_blog/posts/show.html.erb +29 -0
  155. data/lib/generators/open_blog/install/templates/views/open_blog/search/show.html.erb +13 -0
  156. data/lib/generators/open_blog/install/templates/views/open_blog/series/show.html.erb +13 -0
  157. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_author_box.html.erb +6 -0
  158. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_breadcrumbs.html.erb +1 -0
  159. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_call_to_action.html.erb +7 -0
  160. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_featured_card.html.erb +11 -0
  161. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_footer.html.erb +11 -0
  162. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_header.html.erb +7 -0
  163. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_pagination.html.erb +1 -0
  164. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_post_card.html.erb +9 -0
  165. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_related_posts.html.erb +10 -0
  166. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_search.html.erb +9 -0
  167. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_share.html.erb +13 -0
  168. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_sidebar.html.erb +38 -0
  169. data/lib/generators/open_blog/install/templates/views/open_blog/shared/_theme_toggle.html.erb +10 -0
  170. data/lib/generators/open_blog/install/templates/views/open_blog/tags/show.html.erb +13 -0
  171. data/lib/generators/open_blog/views/views_generator.rb +21 -0
  172. data/lib/open_blog/actor.rb +7 -0
  173. data/lib/open_blog/admin_suite.rb +73 -0
  174. data/lib/open_blog/adopt/contract.rb +152 -0
  175. data/lib/open_blog/adopt/snapshot.rb +35 -0
  176. data/lib/open_blog/adopt.rb +159 -0
  177. data/lib/open_blog/api_fields.rb +40 -0
  178. data/lib/open_blog/approve.rb +18 -0
  179. data/lib/open_blog/authentication.rb +18 -0
  180. data/lib/open_blog/build_css.rb +53 -0
  181. data/lib/open_blog/configuration.rb +185 -0
  182. data/lib/open_blog/configuration_error.rb +4 -0
  183. data/lib/open_blog/content_guard.rb +77 -0
  184. data/lib/open_blog/doctor/files.rb +61 -0
  185. data/lib/open_blog/doctor/javascript.rb +57 -0
  186. data/lib/open_blog/doctor.rb +177 -0
  187. data/lib/open_blog/engine.rb +45 -0
  188. data/lib/open_blog/errors.rb +40 -0
  189. data/lib/open_blog/faq_extraction.rb +184 -0
  190. data/lib/open_blog/findings/body_checks.rb +58 -0
  191. data/lib/open_blog/findings/check.rb +19 -0
  192. data/lib/open_blog/findings/link_check.rb +78 -0
  193. data/lib/open_blog/findings/record_checks.rb +94 -0
  194. data/lib/open_blog/findings/registry.rb +18 -0
  195. data/lib/open_blog/findings.rb +27 -0
  196. data/lib/open_blog/image_fetch.rb +115 -0
  197. data/lib/open_blog/image_import.rb +164 -0
  198. data/lib/open_blog/image_resolution.rb +171 -0
  199. data/lib/open_blog/instructions.rb +7 -0
  200. data/lib/open_blog/json_feed.rb +36 -0
  201. data/lib/open_blog/label_policy.rb +41 -0
  202. data/lib/open_blog/markdown_view.rb +57 -0
  203. data/lib/open_blog/mcp/definition.rb +28 -0
  204. data/lib/open_blog/mcp/dispatcher.rb +33 -0
  205. data/lib/open_blog/mcp/image_input.rb +30 -0
  206. data/lib/open_blog/mcp/registry.rb +139 -0
  207. data/lib/open_blog/mcp/schemas.rb +72 -0
  208. data/lib/open_blog/mcp.rb +24 -0
  209. data/lib/open_blog/not_found.rb +4 -0
  210. data/lib/open_blog/operation.rb +36 -0
  211. data/lib/open_blog/page_views.rb +72 -0
  212. data/lib/open_blog/pagination.rb +18 -0
  213. data/lib/open_blog/plain_text.rb +36 -0
  214. data/lib/open_blog/policy_url.rb +9 -0
  215. data/lib/open_blog/post_attributes.rb +172 -0
  216. data/lib/open_blog/post_identity.rb +28 -0
  217. data/lib/open_blog/publish.rb +7 -0
  218. data/lib/open_blog/reader_dates.rb +21 -0
  219. data/lib/open_blog/reader_page.rb +21 -0
  220. data/lib/open_blog/reader_queries.rb +90 -0
  221. data/lib/open_blog/record_release.rb +66 -0
  222. data/lib/open_blog/redirect_target.rb +58 -0
  223. data/lib/open_blog/remove.rb +43 -0
  224. data/lib/open_blog/renderer/highlighter.rb +44 -0
  225. data/lib/open_blog/renderer/markdown.rb +14 -0
  226. data/lib/open_blog/renderer/rich_text.rb +19 -0
  227. data/lib/open_blog/renderer/sanitizer.rb +72 -0
  228. data/lib/open_blog/renderer/scrubber.rb +35 -0
  229. data/lib/open_blog/renderer/transform.rb +115 -0
  230. data/lib/open_blog/renderer.rb +51 -0
  231. data/lib/open_blog/result.rb +11 -0
  232. data/lib/open_blog/revision_payload.rb +67 -0
  233. data/lib/open_blog/rich_text_guard.rb +26 -0
  234. data/lib/open_blog/sample.rb +56 -0
  235. data/lib/open_blog/save_draft.rb +7 -0
  236. data/lib/open_blog/search.rb +21 -0
  237. data/lib/open_blog/sitemap_entries.rb +66 -0
  238. data/lib/open_blog/surface_report/expected_content.rb +27 -0
  239. data/lib/open_blog/surface_report/http.rb +92 -0
  240. data/lib/open_blog/surface_report/page_checks.rb +303 -0
  241. data/lib/open_blog/surface_report/reach_checks.rb +141 -0
  242. data/lib/open_blog/surface_report/record_checks.rb +248 -0
  243. data/lib/open_blog/surface_report/robots.rb +49 -0
  244. data/lib/open_blog/surface_report.rb +223 -0
  245. data/lib/open_blog/syntax_css.rb +55 -0
  246. data/lib/open_blog/unpublish.rb +13 -0
  247. data/lib/open_blog/version.rb +3 -0
  248. data/lib/open_blog/write_post.rb +205 -0
  249. data/lib/open_blog.rb +91 -0
  250. data/lib/tasks/open_blog.rake +71 -0
  251. data/skills/open-blog-adopt/SKILL.md +12 -0
  252. data/skills/open-blog-install/SKILL.md +10 -0
  253. data/skills/open-blog-policy-pages/SKILL.md +10 -0
  254. data/skills/open-blog-publish/SKILL.md +14 -0
  255. data/skills/open-blog-report/SKILL.md +10 -0
  256. data/skills/open-blog-update/SKILL.md +16 -0
  257. metadata +515 -0
@@ -0,0 +1,62 @@
1
+ module OpenBlog
2
+ module Api
3
+ module V1
4
+ class AuthorsController < ResourcesController
5
+ def index
6
+ require_scope!(:read)
7
+ list(Author.all, :authors, AuthorSerializer)
8
+ end
9
+
10
+ def create
11
+ require_scope!(:write)
12
+ save_author(Author.new, status: :created)
13
+ end
14
+
15
+ def update
16
+ require_scope!(:write)
17
+ save_author(Author.find(params[:id]), status: :ok)
18
+ end
19
+
20
+ private
21
+
22
+ def save_author(record, status:)
23
+ input = input_fields!(*ApiFields::AUTHOR)
24
+ text_fields!(input, :name, :slug, :type, :bio, :url, :host_reference)
25
+ if input.key?(:profile_urls)
26
+ invalid!(:profile_urls) unless input[:profile_urls].is_a?(Array) && input[:profile_urls].all? { |url| url.is_a?(String) }
27
+ end
28
+ avatar_sent = input.key?(:avatar)
29
+ avatar = input.delete(:avatar)
30
+ input[:author_type] = input.delete(:type) if input.key?(:type)
31
+ record.assign_attributes(input)
32
+ raise ActiveRecord::RecordInvalid, record unless record.valid?
33
+ prepared, image = prepare_avatar(avatar) if avatar_sent && avatar
34
+ Author.transaction do
35
+ record.reload.lock! if record.persisted?
36
+ record.assign_attributes(input)
37
+ image = prepared.persist(uploaded_by: actor.name) if prepared
38
+ if avatar_sent
39
+ invalid!(:avatar) if image && !image.file.attached?
40
+ record.avatar = image&.file&.blob
41
+ end
42
+ record.save!
43
+ end
44
+ render json: AuthorSerializer.call(record), status: status
45
+ end
46
+
47
+ def prepare_avatar(input)
48
+ invalid!(:avatar) unless input.is_a?(Hash)
49
+ unknown = input.keys - %i[image_id signed_id url]
50
+ raise Error::UnknownField.new(details: unknown.map { |field| "avatar.#{field}" }) if unknown.any?
51
+ invalid!(:avatar) unless input.size == 1
52
+ if input.key?(:image_id)
53
+ invalid!(:avatar) unless input[:image_id].is_a?(Integer)
54
+ [ nil, Image.find(input[:image_id]) ]
55
+ else
56
+ [ ImageImport.prepare(input), nil ]
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,100 @@
1
+ module OpenBlog
2
+ module Api
3
+ module V1
4
+ class BaseController < ActionController::API
5
+ include ActionController::HttpAuthentication::Token::ControllerMethods
6
+ before_action :prevent_caching!
7
+ before_action :authenticate!
8
+ before_action :limit_requests!
9
+ wrap_parameters false
10
+
11
+ rescue_from OpenBlog::Error, with: :render_error
12
+ rescue_from ActiveRecord::RecordNotFound do
13
+ render_error(Error::NotFound.new)
14
+ end
15
+ rescue_from ActionDispatch::Http::Parameters::ParseError, ActionController::BadRequest do
16
+ render_error(Error::ValidationFailed.new(details: [ "body" ]))
17
+ end
18
+
19
+ # Rails 8.0 has no limiter scope keyword. Only the exact current API
20
+ # controller prefix is replaced; all other cache keys pass through.
21
+ class SharedLimitStore
22
+ def initialize(store, controller_path)
23
+ @store = store
24
+ @prefix = "rate-limit:#{controller_path}:"
25
+ end
26
+
27
+ def increment(key, amount, **options)
28
+ normalized = key.start_with?(@prefix) ? "rate-limit:open_blog/api:#{key.delete_prefix(@prefix)}" : key
29
+ @store.increment(normalized, amount, **options)
30
+ end
31
+ end
32
+
33
+ # Used by trusted Ruby callers on a fresh controller instance. This state
34
+ # is never read from HTTP parameters, headers or a caller-provided Rack env.
35
+ def dispatch_internal(action, request, response, actor:)
36
+ raise Error::Unauthenticated unless actor
37
+ @internal_actor = actor
38
+ dispatch(action, request, response)
39
+ ensure
40
+ @internal_actor = nil
41
+ end
42
+ private :dispatch_internal
43
+
44
+ private
45
+
46
+ attr_reader :actor
47
+
48
+ def prevent_caching!
49
+ response.headers["Cache-Control"] = "no-store"
50
+ end
51
+
52
+ def authenticate!
53
+ @actor = @internal_actor || Authentication.actor_for(request)
54
+ raise Error::Unauthenticated unless actor
55
+ end
56
+
57
+ def require_scope!(scope)
58
+ raise Error::ScopeRequired.new(details: [ scope.to_s ]) unless actor.scopes.include?(scope.to_s)
59
+ end
60
+
61
+ def limit_requests!
62
+ return if @internal_actor
63
+ options = { to: OpenBlog.config.api_rate_limit.fetch(:to), within: OpenBlog.config.api_rate_limit.fetch(:within),
64
+ by: -> { actor.id }, with: -> { raise Error::RateLimited }, store: OpenBlog.config.rate_limit_store, name: nil }
65
+ if method(:rate_limiting).parameters.any? { |kind, name| kind == :keyreq && name == :scope }
66
+ options[:scope] = "open_blog/api"
67
+ else
68
+ options[:store] = SharedLimitStore.new(options[:store], controller_path)
69
+ end
70
+ rate_limiting(**options)
71
+ end
72
+
73
+ def input_fields!(*allowed)
74
+ input = request.query_parameters.merge(request.request_parameters)
75
+ unknown = input.keys.map(&:to_s) - allowed.flatten.map(&:to_s)
76
+ raise Error::UnknownField.new(details: unknown) if unknown.any?
77
+ input.deep_symbolize_keys
78
+ end
79
+
80
+ def find_post!
81
+ identity = (params[:post_id] || params[:id]).to_s
82
+ post = Post.find_by(id: identity) if identity.match?(/\A\d+\z/)
83
+ post ||= Post.find_by(slug: identity)
84
+ post || raise(Error::NotFound)
85
+ end
86
+
87
+ def render_result(result, status: nil)
88
+ return render_error(result.error) unless result.success?
89
+ status ||= result.post&.scheduled? ? :accepted : (result.created ? :created : :ok)
90
+ render json: WriteResultSerializer.call(result, base_url: request.base_url), status: status
91
+ end
92
+
93
+ def render_error(error)
94
+ prevent_caching!
95
+ render json: ErrorSerializer.call(error), status: error.status
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,33 @@
1
+ module OpenBlog
2
+ module Api
3
+ module V1
4
+ class CategoriesController < ResourcesController
5
+ def index
6
+ require_scope!(:read)
7
+ list(Category.all, :categories, CategorySerializer)
8
+ end
9
+
10
+ def create
11
+ require_scope!(:write)
12
+ render json: CategorySerializer.call(Category.create!(attributes)), status: :created
13
+ end
14
+
15
+ def update
16
+ require_scope!(:write)
17
+ record = Category.find(params[:id])
18
+ record.update!(attributes)
19
+ render json: CategorySerializer.call(record)
20
+ end
21
+
22
+ private
23
+
24
+ def attributes
25
+ input = input_fields!(*ApiFields::CATEGORY)
26
+ text_fields!(input, :name, :slug, :description)
27
+ invalid!(:position) if input.key?(:position) && (!input[:position].is_a?(Integer) || !(-2_147_483_648..2_147_483_647).cover?(input[:position]))
28
+ input
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,45 @@
1
+ module OpenBlog
2
+ module Api
3
+ module V1
4
+ class ConnectionsController < BaseController
5
+ def create
6
+ require_scope!(:publish)
7
+ input = input_fields!(*ApiFields::CONNECTION)
8
+ validate_input!(input)
9
+ now = Time.current
10
+ result = Operation.run(post: find_post!, actor: actor.name, now: now) do |post, _created|
11
+ post.connection_declarations.create!(input.merge(declared_on: input.fetch(:declared_on, now.to_date), recorded_by: actor.name))
12
+ { revision: nil, publication: nil, approval: nil }
13
+ end
14
+ render_result(result, status: :ok)
15
+ end
16
+
17
+ private
18
+
19
+ def validate_input!(input)
20
+ invalid!(:connections) unless input[:connections].is_a?(Array)
21
+ input[:connections].each do |entry|
22
+ invalid!(:connections) unless entry.is_a?(Hash)
23
+ unknown = entry.keys - %i[party relation]
24
+ raise Error::UnknownField.new(details: unknown.map { |key| "connections.#{key}" }) if unknown.any?
25
+ invalid!(:connections) unless %i[party relation].all? { |key| entry[key].is_a?(String) && entry[key].present? }
26
+ end
27
+ invalid!(:third_party_paid) unless [ true, false ].include?(input[:third_party_paid])
28
+ invalid!(:declared_by) unless input[:declared_by].is_a?(String) && input[:declared_by].present?
29
+ if input.key?(:declared_on)
30
+ invalid!(:declared_on) unless input[:declared_on].is_a?(String) && input[:declared_on].match?(/\A\d{4}-\d{2}-\d{2}\z/)
31
+ begin
32
+ input[:declared_on] = Date.iso8601(input[:declared_on])
33
+ rescue Date::Error
34
+ invalid!(:declared_on)
35
+ end
36
+ end
37
+ end
38
+
39
+ def invalid!(field)
40
+ raise Error::ValidationFailed.new(details: [ field.to_s ])
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,13 @@
1
+ module OpenBlog
2
+ module Api
3
+ module V1
4
+ class DoctorController < BaseController
5
+ def show
6
+ require_scope!(:read)
7
+ input_fields!
8
+ render json: DoctorSerializer.call(Doctor.run)
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ module OpenBlog
2
+ module Api
3
+ module V1
4
+ class FaqExtractionsController < BaseController
5
+ def create
6
+ require_scope!(:read)
7
+ input = input_fields!(*ApiFields::EXTRACTION)
8
+ render json: ExtractionResultSerializer.call(FaqExtraction.call(body: input[:body], standalone_questions: input.fetch(:standalone_questions, [])))
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ module OpenBlog
2
+ module Api
3
+ module V1
4
+ class FindingsController < BaseController
5
+ def show
6
+ require_scope!(:read)
7
+ input_fields!
8
+ render json: FindingsSerializer.call(Findings.for(find_post!))
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,22 @@
1
+ module OpenBlog
2
+ module Api
3
+ module V1
4
+ class ImagesController < BaseController
5
+ def create
6
+ require_scope!(:write)
7
+ input = input_fields!(*ApiFields::IMAGE)
8
+ raise Error::ImageNotPermitted unless input.size == 1
9
+ image = if input.key?(:url)
10
+ Image.from_url(input[:url], uploaded_by: actor.name)
11
+ else
12
+ file = input[:file]
13
+ raise Error::ImageNotPermitted unless file.is_a?(ActionDispatch::Http::UploadedFile)
14
+ Image.from_upload(file.tempfile, filename: file.original_filename,
15
+ content_type: file.content_type, uploaded_by: actor.name)
16
+ end
17
+ render json: ImageSerializer.call(image), status: :created
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,67 @@
1
+ module OpenBlog
2
+ module Api
3
+ module V1
4
+ class PagesController < ResourcesController
5
+ def index
6
+ require_scope!(:read)
7
+ list(Page.all, :pages, ->(page) { PageSerializer.call(page, base_url: request.base_url) })
8
+ end
9
+
10
+ def show
11
+ require_scope!(:read)
12
+ input_fields!
13
+ page = Page.find_by!(kind: params[:kind])
14
+ render json: PageSerializer.call(page, base_url: request.base_url)
15
+ end
16
+
17
+ def update
18
+ require_scope!(:write)
19
+ input = page_attributes
20
+ now = Time.current
21
+ attempts = 0
22
+ begin
23
+ page, created = Page.transaction(requires_new: true) do
24
+ current = Page.lock.find_by(kind: params[:kind]) || Page.new(kind: params[:kind])
25
+ require_scope!(:publish) if current.status == "published" || input[:status] == "published"
26
+ created = current.new_record?
27
+ current.assign_attributes(input)
28
+ current.approved_on = now.to_date if input[:approved_by].present? && !input.key?(:approved_on)
29
+ current.save!
30
+ [ current, created ]
31
+ end
32
+ rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => error
33
+ collision = error.is_a?(ActiveRecord::RecordNotUnique) ||
34
+ (error.record.new_record? && error.record.errors.details[:kind].any? { |detail| detail[:error] == :taken })
35
+ raise unless collision
36
+ attempts += 1
37
+ retry if attempts == 1
38
+ raise Error::ValidationFailed.new(details: [ "kind" ])
39
+ end
40
+ render json: PageSerializer.call(page, base_url: request.base_url), status: created ? :created : :ok
41
+ end
42
+
43
+ private
44
+
45
+ def page_attributes
46
+ input = input_fields!(*ApiFields::SITE_PAGE)
47
+ %i[title body slug].each do |field|
48
+ invalid!(field) if input.key?(field) && !input[field].is_a?(String)
49
+ end
50
+ invalid!(:approved_by) if input.key?(:approved_by) && !input[:approved_by].nil? && !input[:approved_by].is_a?(String)
51
+ invalid!(:status) if input.key?(:status) && !%w[draft published].include?(input[:status])
52
+ if input.key?(:approved_on) && !input[:approved_on].nil?
53
+ value = input[:approved_on]
54
+ invalid!(:approved_on) unless value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
55
+ begin
56
+ input[:approved_on] = Date.iso8601(value)
57
+ rescue Date::Error
58
+ invalid!(:approved_on)
59
+ end
60
+ end
61
+ input[:body_markdown] = input.delete(:body) if input.key?(:body)
62
+ input
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,114 @@
1
+ module OpenBlog
2
+ module Api
3
+ module V1
4
+ class PostsController < BaseController
5
+ WRITE_FIELDS = ApiFields::POST_WRITE
6
+ NESTED_FIELDS = ApiFields::POST_NESTED
7
+
8
+ def index
9
+ require_scope!(:read)
10
+ render json: ApiPostQuery.call(input_fields!(*ApiPostQuery::FILTERS), base_url: request.base_url)
11
+ end
12
+
13
+ def show
14
+ require_scope!(:read)
15
+ input_fields!
16
+ render json: PostSerializer.call(find_post!, base_url: request.base_url)
17
+ end
18
+
19
+ def create
20
+ attributes = write_attributes(publish_field: true)
21
+ publishing = attributes.delete(:publish) == true
22
+ operation = publishing ? Publish : SaveDraft
23
+ render_result operation.call(attributes, actor: actor.name, authorize: authorization(publishing: publishing))
24
+ end
25
+
26
+ def update
27
+ attributes = write_attributes
28
+ result = WritePost.call(attributes, post: find_post!, actor: actor.name, now: Time.current,
29
+ publish: :preserve, authorize: authorization)
30
+ render_result(result, status: :ok)
31
+ end
32
+
33
+ def publish
34
+ require_scope!(:publish)
35
+ render_result Publish.call(write_attributes, post: find_post!, actor: actor.name,
36
+ authorize: authorization(publishing: true))
37
+ end
38
+
39
+ def unpublish
40
+ require_scope!(:publish)
41
+ input_fields!
42
+ render_result Unpublish.call(find_post!, actor: actor.name)
43
+ end
44
+
45
+ def destroy
46
+ require_scope!(:publish)
47
+ attributes = input_fields!(*ApiFields::REMOVE)
48
+ if attributes[:redirect_to].is_a?(String) && attributes[:redirect_to].match?(Post::SLUG_PATTERN)
49
+ attributes[:redirect_to] = "#{OpenBlog.mount_path.chomp('/')}/#{attributes[:redirect_to]}"
50
+ end
51
+ result = Remove.call(find_post!, actor: actor.name, **attributes)
52
+ if result.success? && result.post.destroyed?
53
+ head :no_content
54
+ else
55
+ render_result(result, status: :ok)
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def authorization(publishing: false)
62
+ ->(post) { require_scope!(publishing || post.scheduled? || post.published? ? :publish : :write) }
63
+ end
64
+
65
+ def write_attributes(publish_field: false)
66
+ attributes = input_fields!(*(publish_field ? WRITE_FIELDS + [ :publish ] : WRITE_FIELDS))
67
+ if attributes.key?(:publish) && ![ true, false ].include?(attributes[:publish])
68
+ raise Error::ValidationFailed.new(details: [ "publish" ])
69
+ end
70
+ if attributes.key?(:author) && !attributes[:author].is_a?(String) && !attributes[:author].is_a?(Hash)
71
+ raise Error::ValidationFailed.new(details: [ "author" ])
72
+ end
73
+ NESTED_FIELDS.each do |field, allowed|
74
+ values = field == :faq && attributes[field].is_a?(Array) ? attributes[field] : [ attributes[field] ]
75
+ values.each do |value|
76
+ next unless value.is_a?(Hash)
77
+ unknown = value.keys - allowed
78
+ raise Error::UnknownField.new(details: unknown.map { |key| "#{field}.#{key}" }) if unknown.any?
79
+ end
80
+ end
81
+ connections = attributes.dig(:connections, :connections) if attributes[:connections].is_a?(Hash)
82
+ if connections.is_a?(Array)
83
+ connections.each do |entry|
84
+ next unless entry.is_a?(Hash)
85
+ unknown = entry.keys - %i[party relation]
86
+ raise Error::UnknownField.new(details: unknown.map { |key| "connections.connections.#{key}" }) if unknown.any?
87
+ end
88
+ end
89
+ ApiFields.validate_post_images!(attributes)
90
+ validate_connections!(attributes[:connections]) if attributes.key?(:connections)
91
+ attributes
92
+ end
93
+
94
+ def validate_connections!(input)
95
+ invalid = -> { raise Error::ValidationFailed.new(details: [ "connections" ]) }
96
+ invalid.call unless input.is_a?(Hash) && input[:connections].is_a?(Array)
97
+ invalid.call unless input[:declared_by].is_a?(String) && input[:declared_by].present?
98
+ input[:connections].each do |entry|
99
+ invalid.call unless entry.is_a?(Hash) && %i[party relation].all? { |key| entry[key].is_a?(String) && entry[key].present? }
100
+ end
101
+ if input.key?(:declared_on)
102
+ value = input[:declared_on]
103
+ invalid.call unless value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
104
+ begin
105
+ input[:declared_on] = Date.iso8601(value)
106
+ rescue Date::Error
107
+ invalid.call
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,13 @@
1
+ module OpenBlog
2
+ module Api
3
+ module V1
4
+ class PreviewsController < BaseController
5
+ def show
6
+ require_scope!(:read)
7
+ input_fields!
8
+ render json: PreviewSerializer.call(find_post!, base_url: request.base_url)
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ module OpenBlog
2
+ module Api
3
+ module V1
4
+ class RecordsController < BaseController
5
+ def show
6
+ require_scope!(:read)
7
+ input_fields!
8
+ render json: RecordsSerializer.call(find_post!)
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,51 @@
1
+ module OpenBlog
2
+ module Api
3
+ module V1
4
+ class RedirectsController < ResourcesController
5
+ def index
6
+ require_scope!(:read)
7
+ list(Redirect.all, :redirects, RedirectSerializer)
8
+ end
9
+
10
+ def create
11
+ require_scope!(:publish)
12
+ input = input_fields!(*ApiFields::REDIRECT)
13
+ text_fields!(input, :old_path, :new_path, :occurred_on)
14
+ old = input[:old_path]
15
+ invalid!(:old_path) unless old.is_a?(String) && old.start_with?("/") && !old.start_with?("//") && !old.match?(/[\s?#]/)
16
+ invalid!(:post_id) if input.key?(:post_id) && !input[:post_id].nil? && !input[:post_id].is_a?(Integer)
17
+ date = input.key?(:occurred_on) ? parse_date(input[:occurred_on]) : Date.current
18
+ record = Redirect.transaction do
19
+ Post.lock.find(input[:post_id]) if input[:post_id]
20
+ RedirectTarget.synchronize do
21
+ prefix = "#{OpenBlog.mount_path.chomp('/')}/"
22
+ slug = old.delete_prefix(prefix)
23
+ raise Error::SlugReserved if old.start_with?(prefix) && Post.exists?(slug: slug)
24
+ target = RedirectTarget.call(input[:new_path], from: old)
25
+ row = Redirect.create!(old_path: old, new_path: target, occurred_on: date, source: "manual", post_id: input[:post_id])
26
+ Redirect.where(new_path: old).where.not(id: row.id).find_each { |incoming| incoming.update!(new_path: target) }
27
+ row
28
+ end
29
+ end
30
+ render json: RedirectSerializer.call(record), status: :created
31
+ end
32
+
33
+ def destroy
34
+ require_scope!(:publish)
35
+ input_fields!
36
+ RedirectTarget.synchronize { Redirect.find(params[:id]).destroy! }
37
+ head :no_content
38
+ end
39
+
40
+ private
41
+
42
+ def parse_date(value)
43
+ invalid!(:occurred_on) unless value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
44
+ Date.iso8601(value)
45
+ rescue Date::Error
46
+ invalid!(:occurred_on)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,20 @@
1
+ module OpenBlog
2
+ module Api
3
+ module V1
4
+ class ReportController < BaseController
5
+ def show
6
+ require_scope!(:read)
7
+ input = input_fields!(*ApiFields::REPORT, :format)
8
+ format = input.delete(:format) || params[:format]
9
+ raise Error::ValidationFailed.new(details: [ "format" ]) unless format.nil? || %w[json text].include?(format)
10
+ report = SurfaceReport.run(**input)
11
+ if format == "text" || (format.nil? && request.accepts.first == Mime[:text])
12
+ render plain: report.to_text
13
+ else
14
+ render json: report.to_h
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,39 @@
1
+ module OpenBlog
2
+ module Api
3
+ module V1
4
+ class ResourcesController < BaseController
5
+ rescue_from ActiveRecord::RecordInvalid do |error|
6
+ render_error(Error::ValidationFailed.new(details: error.record.errors.attribute_names.map(&:to_s)))
7
+ end
8
+ rescue_from ActiveRecord::RecordNotUnique do
9
+ render_error(Error::ValidationFailed.new(details: [ "slug" ]))
10
+ end
11
+
12
+ private
13
+
14
+ def list(relation, name, serializer)
15
+ input = input_fields!(*ApiFields::PAGE)
16
+ page = integer_parameter(input, :page, 1)
17
+ per_page = [ integer_parameter(input, :per_page, 25), 100 ].min
18
+ render json: { name => relation.order(:id).offset((page - 1) * per_page).limit(per_page).map { |record| serializer.call(record) },
19
+ page: page, per_page: per_page, total: relation.count }
20
+ end
21
+
22
+ def integer_parameter(input, name, default)
23
+ return default unless input.key?(name)
24
+ value = input[name]
25
+ invalid!(name) unless (value.is_a?(String) || value.is_a?(Integer)) && value.to_s.match?(/\A[1-9]\d{0,8}\z/)
26
+ value.to_i
27
+ end
28
+
29
+ def text_fields!(input, *fields)
30
+ fields.each { |field| invalid!(field) if input.key?(field) && !input[field].nil? && !input[field].is_a?(String) }
31
+ end
32
+
33
+ def invalid!(field)
34
+ raise Error::ValidationFailed.new(details: [ field.to_s ])
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,32 @@
1
+ module OpenBlog
2
+ module Api
3
+ module V1
4
+ class SeriesController < ResourcesController
5
+ def index
6
+ require_scope!(:read)
7
+ list(Series.all, :series, SeriesSerializer)
8
+ end
9
+
10
+ def create
11
+ require_scope!(:write)
12
+ render json: SeriesSerializer.call(Series.create!(attributes)), status: :created
13
+ end
14
+
15
+ def update
16
+ require_scope!(:write)
17
+ record = Series.find(params[:id])
18
+ record.update!(attributes)
19
+ render json: SeriesSerializer.call(record)
20
+ end
21
+
22
+ private
23
+
24
+ def attributes
25
+ input = input_fields!(*ApiFields::SERIES)
26
+ text_fields!(input, :name, :slug, :description)
27
+ input
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,12 @@
1
+ module OpenBlog
2
+ module Api
3
+ module V1
4
+ class TagsController < ResourcesController
5
+ def index
6
+ require_scope!(:read)
7
+ list(Tag.all, :tags, TagSerializer)
8
+ end
9
+ end
10
+ end
11
+ end
12
+ end