helios-press 0.2 → 0.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ff787a0ede62265b58f025dbcd37aab9b35203b691cd74252dfd6df108669fc2
4
- data.tar.gz: 505c0b5583ebbb5c8277fcd2a295abcc12747c3cb1462ec770e53ccf29acdcd4
3
+ metadata.gz: 3ea59768263242ec8f88e1bd68e8d33f67f4b5a313d32626ac735632be159672
4
+ data.tar.gz: f2aa7c604191d1e340f18c1c9cf980d6f988407deff6127953a023572bcd1ef6
5
5
  SHA512:
6
- metadata.gz: fee9b6adf0321cf285cd49c2eda7446771c62e5b57a183962efcdf513026a82187602832fa1a8597c3e5aaa47790fc9dd6ca7944170541d2eeb949cce8710486
7
- data.tar.gz: dfec9a5447b75c2536d8548daa69c530ba1db54d30e6fdffcec2bcfc0447d3ee129b57642ab0d9e67196aa33d6847f1958924aaf020bec6205fb297746ff5141
6
+ metadata.gz: fa4299b094dca0fc56ef35d1c930146cabbf6020bc4561f7482c140a1d9fac88a995f56a18a7aa53da51331f03e1b75e9d4d88f3c26c66b52968594673567752
7
+ data.tar.gz: 6e804a7772de4fe2f6b29ed41b1f111bede76ef00b86e39e3215bf07f311ac30344e28cafde50df2e11e5878dc843cd585272536bfc4bd9bb43634a1168c6352
data/README.md CHANGED
@@ -89,9 +89,12 @@ mount Helios::Press::Admin::Engine, at: "/admin/press"
89
89
 
90
90
  ## JavaScript Setup
91
91
 
92
- Register the Stimulus controllers in your host app:
92
+ Register the Stimulus controllers in your host app. You **must** also import `trix` and `@rails/actiontext` — without these imports, the rich text editor will not render in text blocks:
93
93
 
94
94
  ```javascript
95
+ import 'trix'
96
+ import '@rails/actiontext'
97
+
95
98
  import {
96
99
  HeliosPressBlocksController,
97
100
  HeliosPressTextBlockController,
@@ -107,7 +110,7 @@ application.register("helios-press-image-block", HeliosPressImageBlockController
107
110
  - `sortablejs`
108
111
  - `@hotwired/stimulus`
109
112
  - `@rails/activestorage`
110
- - `trix` and `@rails/actiontext`
113
+ - `trix` and `@rails/actiontext` — **required** for text block editing
111
114
 
112
115
  ### Vite
113
116
 
@@ -157,11 +160,31 @@ POST to your mounted API path with `X-API-Key` header:
157
160
  "slug": "my-post-title",
158
161
  "description": "Meta description",
159
162
  "keywords": "keyword1, keyword2",
160
- "body_html": "<p>Post content...</p>",
161
- "published": true
163
+ "body_html": "<p>Post content with <img src=\"https://example.com/photo.jpg\"> tags...</p>",
164
+ "published": true,
165
+ "images": [
166
+ {
167
+ "reference_key": "hero1",
168
+ "url": "https://example.com/hero.jpg",
169
+ "alt": "Hero image",
170
+ "caption": "Photo credit: Example"
171
+ }
172
+ ]
162
173
  }
163
174
  ```
164
175
 
176
+ ### Image Ingestion
177
+
178
+ When a post is ingested via the API, all images in `body_html` are automatically downloaded, stored via ActiveStorage (e.g., to S3), and the `<img>` src attributes are rewritten to local proxy URLs. This ensures no external image hotlinking in published posts.
179
+
180
+ Two modes are supported:
181
+
182
+ 1. **Explicit references**: Use `src="helios://image/<reference_key>"` in your `body_html` and provide the actual download URL in the `images` array. The ingestor matches them by `reference_key`.
183
+
184
+ 2. **Auto-import**: Any `src="https://..."` URL in `body_html` is automatically downloaded. Images are deduplicated by a SHA256 hash of the URL path (ignoring query strings/signatures), so re-ingesting the same image with different signed URLs won't create duplicates.
185
+
186
+ Images are stored as `BlockImage` records with ActiveStorage attachments and served via `rails_storage_proxy_path`. Failed downloads are logged and skipped (soft failure — the original src is preserved).
187
+
165
188
  ## License
166
189
 
167
190
  Proprietary. All rights reserved.
@@ -2,7 +2,7 @@ module Helios
2
2
  module Press
3
3
  module Api
4
4
  class PostsController < Helios::Press::Api::BaseController
5
- # POST /api/v1/posts
5
+ # POST /api/press/posts
6
6
  # Upsert a post by external_id. Payload shape:
7
7
  # {
8
8
  # "external_id": "helios-blog-post-draft-4821",
@@ -10,9 +10,18 @@ module Helios
10
10
  # "slug": "...",
11
11
  # "keywords": "...",
12
12
  # "description": "...",
13
- # "body_html": "...",
14
- # "published": true
13
+ # "body_html": "<p>Content with <img src=\"https://...\"> tags...</p>",
14
+ # "published": true,
15
+ # "images": [
16
+ # { "reference_key": "hero1", "url": "https://...", "alt": "...", "caption": "..." }
17
+ # ]
15
18
  # }
19
+ #
20
+ # Images in body_html are automatically downloaded, stored via ActiveStorage,
21
+ # and their src attributes rewritten to local proxy URLs. Two modes:
22
+ # 1. Explicit: use src="helios://image/<reference_key>" in body_html and
23
+ # provide the download URL in the images array.
24
+ # 2. Auto: any src="https://..." in body_html is downloaded automatically.
16
25
  def create
17
26
  external_id = params[:external_id].to_s
18
27
  return render json: { error: "external_id is required" }, status: :bad_request if external_id.blank?
@@ -27,19 +36,16 @@ module Helios
27
36
  published: ActiveModel::Type::Boolean.new.cast(params[:published])
28
37
  )
29
38
 
30
- # If body_html is provided, create/update a single text block with the content
39
+ post.save!
40
+
31
41
  if params[:body_html].present?
32
- if post.persisted?
33
- # Update or create the first text block
34
- text_block = post.blocks.find_by(block_type: "text") || post.blocks.build(block_type: "text", position: 0)
35
- text_block.content = params[:body_html]
36
- else
37
- post.blocks.build(block_type: "text", position: 0, content: params[:body_html])
38
- end
42
+ ingestor = ImageIngestor.new(post)
43
+ ingestor.call(
44
+ body_html: params[:body_html],
45
+ images_payload: params[:images] || []
46
+ )
39
47
  end
40
48
 
41
- post.save!
42
-
43
49
  render json: {
44
50
  ok: true,
45
51
  id: post.id,
@@ -8,7 +8,11 @@ module Helios
8
8
  has_one_attached :file
9
9
 
10
10
  validates :position, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
11
- validates :file, presence: true
11
+ validates :file, presence: true, unless: :reference_key?
12
+
13
+ def reference_key?
14
+ reference_key.present?
15
+ end
12
16
 
13
17
  acts_as_list scope: :block
14
18
 
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "nokogiri"
5
+ require "uri"
6
+ require "digest"
7
+
8
+ module Helios
9
+ module Press
10
+ # Downloads remote images referenced in ingested post HTML, stores them via
11
+ # ActiveStorage, and rewrites the body HTML so <img> src attributes point at
12
+ # locally-hosted ActiveStorage proxy URLs.
13
+ #
14
+ # Supports two modes:
15
+ # 1. Explicit references: src="helios://image/<reference_key>" paired with
16
+ # an images_payload array containing download URLs.
17
+ # 2. Auto-import: any remaining src="https://..." URLs are downloaded,
18
+ # deduplicated by a SHA256 of the URL path.
19
+ class ImageIngestor
20
+ class FetchError < StandardError; end
21
+
22
+ MAX_BYTES = 15.megabytes
23
+
24
+ def initialize(post)
25
+ @post = post
26
+ end
27
+
28
+ # images_payload: Array of Hashes
29
+ # [{ "reference_key" => "abc123", "url" => "https://...", "alt" => "...", "caption" => "..." }]
30
+ def call(body_html:, images_payload: [])
31
+ ensure_text_block!
32
+ upsert_inline_images(images_payload)
33
+ rewritten = rewrite_body_html(body_html)
34
+ text_block.content = rewritten
35
+ text_block.save!
36
+ rewritten
37
+ end
38
+
39
+ private
40
+
41
+ attr_reader :post
42
+
43
+ def text_block
44
+ @text_block ||= post.blocks.find_by(block_type: "text") ||
45
+ post.blocks.build(block_type: "text", position: 0)
46
+ end
47
+
48
+ def ensure_text_block!
49
+ text_block # trigger lazy init
50
+ end
51
+
52
+ def image_block
53
+ @image_block ||= post.blocks.find_by(block_type: "image_container") ||
54
+ post.blocks.create!(block_type: "image_container", position: 1)
55
+ end
56
+
57
+ def upsert_inline_images(payload)
58
+ return if payload.blank?
59
+
60
+ payload.each do |entry|
61
+ key = entry["reference_key"].presence
62
+ next unless key
63
+
64
+ bi = image_block.block_images.find_or_initialize_by(reference_key: key)
65
+ bi.caption = entry["caption"].to_s if entry["caption"].present?
66
+ bi.position ||= (image_block.block_images.maximum(:position) || 0) + 1
67
+
68
+ if bi.file.attached?
69
+ bi.save! if bi.changed?
70
+ next
71
+ end
72
+
73
+ blob = fetch_as_blob(entry["url"])
74
+ unless blob
75
+ Rails.logger.warn("Helios::Press::ImageIngestor: skipping #{key} — blob fetch failed")
76
+ next
77
+ end
78
+
79
+ bi.file.attach(blob)
80
+ bi.save!
81
+ end
82
+ end
83
+
84
+ def rewrite_body_html(html)
85
+ return html if html.blank?
86
+
87
+ doc = Nokogiri::HTML.fragment(html)
88
+ doc.css("img").each do |img|
89
+ src = img["src"].to_s
90
+
91
+ if src.start_with?("helios://image/")
92
+ key = src.delete_prefix("helios://image/")
93
+ bi = image_block.block_images.find_by(reference_key: key)
94
+ next unless bi&.file&.attached?
95
+
96
+ img["src"] = proxy_path(bi.file)
97
+ img["alt"] = bi.caption if bi.caption.present? && img["alt"].blank?
98
+ elsif src.match?(%r{\Ahttps?://}i)
99
+ bi = import_remote_image(src, alt: img["alt"].to_s)
100
+ next unless bi&.file&.attached?
101
+
102
+ img["src"] = proxy_path(bi.file)
103
+ end
104
+ end
105
+
106
+ doc.to_html
107
+ end
108
+
109
+ def import_remote_image(url, alt: "")
110
+ uri = URI.parse(url)
111
+ key = "auto_#{Digest::SHA256.hexdigest(uri.path.to_s)[0, 16]}"
112
+
113
+ bi = image_block.block_images.find_or_initialize_by(reference_key: key)
114
+ bi.caption = alt if alt.present? && bi.caption.blank?
115
+ bi.position ||= (image_block.block_images.maximum(:position) || 0) + 1
116
+
117
+ if bi.file.attached?
118
+ bi.save! if bi.changed?
119
+ return bi
120
+ end
121
+
122
+ blob = fetch_as_blob(url)
123
+ unless blob
124
+ Rails.logger.warn("Helios::Press::ImageIngestor: skipping remote image #{url} — blob fetch failed")
125
+ return nil
126
+ end
127
+
128
+ bi.file.attach(blob)
129
+ bi.save!
130
+ bi
131
+ rescue StandardError => e
132
+ Rails.logger.error("Helios::Press::ImageIngestor: failed to import #{url} — #{e.class}: #{e.message}")
133
+ nil
134
+ end
135
+
136
+ def proxy_path(attachment)
137
+ Rails.application.routes.url_helpers.rails_storage_proxy_path(attachment, only_path: true)
138
+ end
139
+
140
+ def fetch_as_blob(url)
141
+ uri = URI.parse(url)
142
+ return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
143
+
144
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
145
+ http.open_timeout = 10
146
+ http.read_timeout = 30
147
+ http.request(Net::HTTP::Get.new(uri))
148
+ end
149
+
150
+ raise FetchError, "HTTP #{response.code} fetching #{url}" unless response.is_a?(Net::HTTPSuccess)
151
+ raise FetchError, "Image too large: #{response.body.bytesize}" if response.body.bytesize > MAX_BYTES
152
+
153
+ filename = File.basename(uri.path.presence || "image")
154
+ filename = "image.jpg" if filename.blank? || filename == "/"
155
+
156
+ ActiveStorage::Blob.create_and_upload!(
157
+ io: StringIO.new(response.body),
158
+ filename: filename,
159
+ content_type: response["content-type"] || "application/octet-stream"
160
+ )
161
+ rescue StandardError => e
162
+ Rails.logger.error("Helios::Press::ImageIngestor: failed to fetch #{url} — #{e.class}: #{e.message}")
163
+ nil
164
+ end
165
+ end
166
+ end
167
+ end
@@ -1,6 +1,8 @@
1
1
  class CreateHeliosPressPosts < ActiveRecord::Migration[8.0]
2
2
  def change
3
- create_table :helios_press_posts do |t|
3
+ primary_key_type, foreign_key_type = primary_and_foreign_key_types
4
+
5
+ create_table :helios_press_posts, id: primary_key_type do |t|
4
6
  t.string :name, null: false
5
7
  t.string :slug, null: false
6
8
  t.text :description
@@ -15,4 +17,14 @@ class CreateHeliosPressPosts < ActiveRecord::Migration[8.0]
15
17
  add_index :helios_press_posts, :external_id, unique: true
16
18
  add_index :helios_press_posts, :published
17
19
  end
20
+
21
+ private
22
+
23
+ def primary_and_foreign_key_types
24
+ config = Rails.configuration.generators
25
+ setting = config.options[config.orm][:primary_key_type]
26
+ primary_key_type = setting || :primary_key
27
+ foreign_key_type = setting || :bigint
28
+ [primary_key_type, foreign_key_type]
29
+ end
18
30
  end
@@ -1,7 +1,9 @@
1
1
  class CreateHeliosPressBlocks < ActiveRecord::Migration[8.0]
2
2
  def change
3
- create_table :helios_press_blocks do |t|
4
- t.references :post, null: false, foreign_key: { to_table: :helios_press_posts }
3
+ primary_key_type, foreign_key_type = primary_and_foreign_key_types
4
+
5
+ create_table :helios_press_blocks, id: primary_key_type do |t|
6
+ t.references :post, null: false, foreign_key: { to_table: :helios_press_posts }, type: foreign_key_type
5
7
  t.string :block_type, null: false
6
8
  t.integer :position, null: false
7
9
  t.integer :columns, default: 3
@@ -11,4 +13,14 @@ class CreateHeliosPressBlocks < ActiveRecord::Migration[8.0]
11
13
 
12
14
  add_index :helios_press_blocks, [:post_id, :position]
13
15
  end
16
+
17
+ private
18
+
19
+ def primary_and_foreign_key_types
20
+ config = Rails.configuration.generators
21
+ setting = config.options[config.orm][:primary_key_type]
22
+ primary_key_type = setting || :primary_key
23
+ foreign_key_type = setting || :bigint
24
+ [primary_key_type, foreign_key_type]
25
+ end
14
26
  end
@@ -1,7 +1,9 @@
1
1
  class CreateHeliosPressBlockImages < ActiveRecord::Migration[8.0]
2
2
  def change
3
- create_table :helios_press_block_images do |t|
4
- t.references :block, null: false, foreign_key: { to_table: :helios_press_blocks }
3
+ primary_key_type, foreign_key_type = primary_and_foreign_key_types
4
+
5
+ create_table :helios_press_block_images, id: primary_key_type do |t|
6
+ t.references :block, null: false, foreign_key: { to_table: :helios_press_blocks }, type: foreign_key_type
5
7
  t.integer :position, null: false
6
8
  t.text :caption
7
9
 
@@ -10,4 +12,14 @@ class CreateHeliosPressBlockImages < ActiveRecord::Migration[8.0]
10
12
 
11
13
  add_index :helios_press_block_images, [:block_id, :position]
12
14
  end
15
+
16
+ private
17
+
18
+ def primary_and_foreign_key_types
19
+ config = Rails.configuration.generators
20
+ setting = config.options[config.orm][:primary_key_type]
21
+ primary_key_type = setting || :primary_key
22
+ foreign_key_type = setting || :bigint
23
+ [primary_key_type, foreign_key_type]
24
+ end
13
25
  end
@@ -0,0 +1,6 @@
1
+ class AddImageIngestionToHeliosPress < ActiveRecord::Migration[8.0]
2
+ def change
3
+ add_column :helios_press_block_images, :reference_key, :string
4
+ add_index :helios_press_block_images, [:block_id, :reference_key], unique: true
5
+ end
6
+ end
@@ -1,5 +1,5 @@
1
1
  module Helios
2
2
  module Press
3
- VERSION = "0.2"
3
+ VERSION = "0.4"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: helios-press
3
3
  version: !ruby/object:Gem::Version
4
- version: '0.2'
4
+ version: '0.4'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jason Fleetwood-Boldt
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-05-28 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: rails
@@ -37,6 +38,20 @@ dependencies:
37
38
  - - ">="
38
39
  - !ruby/object:Gem::Version
39
40
  version: '1.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: nokogiri
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '1.13'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '1.13'
40
55
  - !ruby/object:Gem::Dependency
41
56
  name: helios-videos
42
57
  requirement: !ruby/object:Gem::Requirement
@@ -97,6 +112,7 @@ files:
97
112
  - app/models/helios/press/block.rb
98
113
  - app/models/helios/press/block_image.rb
99
114
  - app/models/helios/press/post.rb
115
+ - app/services/helios/press/image_ingestor.rb
100
116
  - app/views/helios/press/admin/blocks/_block.html.erb
101
117
  - app/views/helios/press/admin/blocks/_image_block.html.erb
102
118
  - app/views/helios/press/admin/blocks/_image_item.html.erb
@@ -121,6 +137,7 @@ files:
121
137
  - db/migrate/20250510000001_create_helios_press_posts.rb
122
138
  - db/migrate/20250510000002_create_helios_press_blocks.rb
123
139
  - db/migrate/20250510000003_create_helios_press_block_images.rb
140
+ - db/migrate/20250526000001_add_image_ingestion_to_helios_press.rb
124
141
  - lib/helios-press.rb
125
142
  - lib/helios/press.rb
126
143
  - lib/helios/press/configuration.rb
@@ -133,6 +150,7 @@ licenses:
133
150
  metadata:
134
151
  homepage_uri: https://github.com/heliosdev-shop/helios-press
135
152
  source_code_uri: https://github.com/heliosdev-shop/helios-press
153
+ post_install_message:
136
154
  rdoc_options: []
137
155
  require_paths:
138
156
  - lib
@@ -147,7 +165,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
147
165
  - !ruby/object:Gem::Version
148
166
  version: '0'
149
167
  requirements: []
150
- rubygems_version: 3.6.9
168
+ rubygems_version: 3.4.19
169
+ signing_key:
151
170
  specification_version: 4
152
171
  summary: Multi-block blog post editor for Rails with drag-to-reorder
153
172
  test_files: []