journeybook 0.1.6.pre.alpha
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 +9 -0
- data/MIT-LICENSE +20 -0
- data/README.md +354 -0
- data/Rakefile +6 -0
- data/app/assets/images/journeybook/.keep +0 -0
- data/app/assets/images/journeybook/logo.svg +5 -0
- data/app/assets/javascripts/journeybook/sortable.js +96 -0
- data/app/assets/stylesheets/journeybook/application.css +1556 -0
- data/app/controllers/concerns/.keep +0 -0
- data/app/controllers/concerns/journeybook/public_pages.rb +31 -0
- data/app/controllers/journeybook/admin/ai_pages_controller.rb +65 -0
- data/app/controllers/journeybook/admin/ai_style_prompts_controller.rb +51 -0
- data/app/controllers/journeybook/admin/components_controller.rb +57 -0
- data/app/controllers/journeybook/admin/dashboard_controller.rb +13 -0
- data/app/controllers/journeybook/admin/media_assets_controller.rb +59 -0
- data/app/controllers/journeybook/admin/navigation_items_controller.rb +91 -0
- data/app/controllers/journeybook/admin/navigation_menus_controller.rb +59 -0
- data/app/controllers/journeybook/admin/pages_controller.rb +304 -0
- data/app/controllers/journeybook/admin/redirects_controller.rb +51 -0
- data/app/controllers/journeybook/admin/sites_controller.rb +51 -0
- data/app/controllers/journeybook/application_controller.rb +52 -0
- data/app/controllers/journeybook/form_submissions_controller.rb +55 -0
- data/app/helpers/journeybook/application_helper.rb +4 -0
- data/app/helpers/journeybook/navigation_helper.rb +11 -0
- data/app/javascript/journeybook/application.js +5 -0
- data/app/javascript/journeybook/component_search_controller.js +13 -0
- data/app/jobs/journeybook/application_job.rb +4 -0
- data/app/models/concerns/.keep +0 -0
- data/app/models/journeybook/ai_style_prompt.rb +6 -0
- data/app/models/journeybook/application_record.rb +5 -0
- data/app/models/journeybook/media_asset.rb +26 -0
- data/app/models/journeybook/navigation_item.rb +45 -0
- data/app/models/journeybook/navigation_menu.rb +23 -0
- data/app/models/journeybook/page.rb +96 -0
- data/app/models/journeybook/redirect.rb +31 -0
- data/app/models/journeybook/site.rb +62 -0
- data/app/services/journeybook/ai/markdown_generator.rb +15 -0
- data/app/services/journeybook/ai/openai_client.rb +54 -0
- data/app/services/journeybook/ai/prompt_builder.rb +154 -0
- data/app/services/journeybook/ai/template_validator.rb +17 -0
- data/app/services/journeybook/component_parser.rb +53 -0
- data/app/services/journeybook/component_renderer.rb +108 -0
- data/app/services/journeybook/component_tag_updater.rb +132 -0
- data/app/services/journeybook/component_validator.rb +111 -0
- data/app/services/journeybook/form_mailer.rb +69 -0
- data/app/services/journeybook/html_sanitizer.rb +46 -0
- data/app/services/journeybook/letter_opener_client.rb +14 -0
- data/app/services/journeybook/markdown_renderer.rb +29 -0
- data/app/services/journeybook/page_renderer.rb +58 -0
- data/app/services/journeybook/page_resolver.rb +55 -0
- data/app/services/journeybook/postmark_client.rb +48 -0
- data/app/services/journeybook/storage/s3_client.rb +96 -0
- data/app/views/journeybook/admin/ai_pages/_draft_form.html.erb +73 -0
- data/app/views/journeybook/admin/ai_pages/new.html.erb +43 -0
- data/app/views/journeybook/admin/ai_style_prompts/_form.html.erb +16 -0
- data/app/views/journeybook/admin/ai_style_prompts/edit.html.erb +2 -0
- data/app/views/journeybook/admin/ai_style_prompts/index.html.erb +26 -0
- data/app/views/journeybook/admin/ai_style_prompts/new.html.erb +2 -0
- data/app/views/journeybook/admin/components/_component.html.erb +17 -0
- data/app/views/journeybook/admin/components/_reference.html.erb +9 -0
- data/app/views/journeybook/admin/components/index.html.erb +46 -0
- data/app/views/journeybook/admin/components/preview.html.erb +13 -0
- data/app/views/journeybook/admin/dashboard/show.html.erb +34 -0
- data/app/views/journeybook/admin/media_assets/_reference.html.erb +16 -0
- data/app/views/journeybook/admin/media_assets/index.html.erb +43 -0
- data/app/views/journeybook/admin/media_assets/new.html.erb +41 -0
- data/app/views/journeybook/admin/navigation_items/_form.html.erb +46 -0
- data/app/views/journeybook/admin/navigation_items/edit.html.erb +2 -0
- data/app/views/journeybook/admin/navigation_items/new.html.erb +2 -0
- data/app/views/journeybook/admin/navigation_menus/_form.html.erb +10 -0
- data/app/views/journeybook/admin/navigation_menus/edit.html.erb +38 -0
- data/app/views/journeybook/admin/navigation_menus/index.html.erb +28 -0
- data/app/views/journeybook/admin/navigation_menus/new.html.erb +2 -0
- data/app/views/journeybook/admin/pages/_collection_field.html.erb +58 -0
- data/app/views/journeybook/admin/pages/_form.html.erb +77 -0
- data/app/views/journeybook/admin/pages/_image_picker_modal.html.erb +23 -0
- data/app/views/journeybook/admin/pages/_table.html.erb +37 -0
- data/app/views/journeybook/admin/pages/_url_field.html.erb +25 -0
- data/app/views/journeybook/admin/pages/edit.html.erb +547 -0
- data/app/views/journeybook/admin/pages/errors.html.erb +25 -0
- data/app/views/journeybook/admin/pages/index.html.erb +52 -0
- data/app/views/journeybook/admin/pages/new.html.erb +27 -0
- data/app/views/journeybook/admin/pages/new_component.html.erb +70 -0
- data/app/views/journeybook/admin/pages/public_preview.html.erb +27 -0
- data/app/views/journeybook/admin/pages/source.html.erb +23 -0
- data/app/views/journeybook/admin/redirects/_form.html.erb +9 -0
- data/app/views/journeybook/admin/redirects/edit.html.erb +2 -0
- data/app/views/journeybook/admin/redirects/index.html.erb +15 -0
- data/app/views/journeybook/admin/redirects/new.html.erb +2 -0
- data/app/views/journeybook/admin/shared/_drag_handle.html.erb +3 -0
- data/app/views/journeybook/admin/shared/_errors.html.erb +10 -0
- data/app/views/journeybook/admin/sites/_form.html.erb +12 -0
- data/app/views/journeybook/admin/sites/edit.html.erb +2 -0
- data/app/views/journeybook/admin/sites/index.html.erb +26 -0
- data/app/views/journeybook/admin/sites/new.html.erb +2 -0
- data/app/views/layouts/journeybook/application.html.erb +57 -0
- data/config/importmap.rb +3 -0
- data/config/routes.rb +39 -0
- data/db/migrate/20260627000001_create_journeybook_sites.rb +13 -0
- data/db/migrate/20260627000002_create_journeybook_pages.rb +23 -0
- data/db/migrate/20260627000003_create_journeybook_redirects.rb +13 -0
- data/db/migrate/20260628000004_add_context_fields_to_journeybook_sites.rb +38 -0
- data/db/migrate/20260628000005_create_journeybook_media_assets.rb +24 -0
- data/db/migrate/20260629000006_create_journeybook_navigation.rb +28 -0
- data/db/migrate/20260731000007_add_open_in_new_window_to_journeybook_navigation_items.rb +5 -0
- data/db/migrate/20260731000008_create_journeybook_ai_style_prompts.rb +12 -0
- data/db/migrate/20260801000009_add_blog_fields_to_journeybook_pages.rb +9 -0
- data/db/migrate/20260801000010_add_knowledge_base_fields_to_journeybook_pages.rb +8 -0
- data/db/migrate/20260801000011_add_position_to_journeybook_pages.rb +6 -0
- data/lib/generators/journeybook/install/install_generator.rb +72 -0
- data/lib/generators/journeybook/install/templates/add_blog_fields_to_journeybook_pages.rb +9 -0
- data/lib/generators/journeybook/install/templates/add_context_fields_to_journeybook_sites.rb +38 -0
- data/lib/generators/journeybook/install/templates/add_knowledge_base_fields_to_journeybook_pages.rb +8 -0
- data/lib/generators/journeybook/install/templates/add_open_in_new_window_to_journeybook_navigation_items.rb +5 -0
- data/lib/generators/journeybook/install/templates/add_position_to_journeybook_pages.rb +6 -0
- data/lib/generators/journeybook/install/templates/create_journeybook_ai_style_prompts.rb +12 -0
- data/lib/generators/journeybook/install/templates/create_journeybook_media_assets.rb +24 -0
- data/lib/generators/journeybook/install/templates/create_journeybook_navigation.rb +28 -0
- data/lib/generators/journeybook/install/templates/create_journeybook_pages.rb +33 -0
- data/lib/generators/journeybook/install/templates/create_journeybook_redirects.rb +13 -0
- data/lib/generators/journeybook/install/templates/create_journeybook_sites.rb +13 -0
- data/lib/generators/journeybook/install/templates/journeybook.rb +42 -0
- data/lib/generators/journeybook/install/templates/pages_controller.rb +3 -0
- data/lib/generators/journeybook/install/templates/show.html.erb +10 -0
- data/lib/journeybook/component_registry.rb +136 -0
- data/lib/journeybook/configuration.rb +55 -0
- data/lib/journeybook/engine.rb +19 -0
- data/lib/journeybook/page_template_registry.rb +67 -0
- data/lib/journeybook/public_page_constraint.rb +23 -0
- data/lib/journeybook/version.rb +3 -0
- data/lib/journeybook.rb +62 -0
- data/lib/tasks/journeybook_tasks.rake +8 -0
- metadata +266 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Journeybook
|
|
4
|
+
class ComponentTagUpdater
|
|
5
|
+
def initialize(parser: ComponentParser.new)
|
|
6
|
+
@parser = parser
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def update(markdown, index, props)
|
|
10
|
+
components = @parser.parse(markdown)
|
|
11
|
+
component = components.fetch(index)
|
|
12
|
+
replace_component(markdown, index, component.name, component.props.merge(normalized_props(props)))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def move(markdown, index, direction)
|
|
16
|
+
tags = component_tags(markdown)
|
|
17
|
+
tag = fetch_tag(tags, index)
|
|
18
|
+
target_index = direction.to_s == "up" ? index - 1 : index + 1
|
|
19
|
+
target = fetch_tag(tags, target_index)
|
|
20
|
+
|
|
21
|
+
replace_sources(markdown, {
|
|
22
|
+
tag => target.source,
|
|
23
|
+
target => tag.source
|
|
24
|
+
})
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def reorder(markdown, indexes)
|
|
28
|
+
tags = component_tags(markdown)
|
|
29
|
+
indexes = Array(indexes).map(&:to_i)
|
|
30
|
+
raise ArgumentError unless indexes.sort == (0...tags.length).to_a
|
|
31
|
+
|
|
32
|
+
replace_sources(markdown, tags.zip(indexes.map { |index| tags.fetch(index).source }).to_h)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def delete(markdown, index)
|
|
36
|
+
tag = fetch_tag(component_tags(markdown), index)
|
|
37
|
+
replace_sources(markdown, tag => "").gsub(/\n{3,}/, "\n\n")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def toggle_visibility(markdown, index)
|
|
41
|
+
components = @parser.parse(markdown)
|
|
42
|
+
component = components.fetch(index)
|
|
43
|
+
props = component.props.dup
|
|
44
|
+
|
|
45
|
+
if component.hidden?
|
|
46
|
+
props.delete(ComponentParser::HIDDEN_PROP)
|
|
47
|
+
else
|
|
48
|
+
props[ComponentParser::HIDDEN_PROP] = "true"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
replace_component(markdown, index, component.name, props)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def append(markdown, name, props = {})
|
|
55
|
+
source = serialize(name, normalized_props(props))
|
|
56
|
+
body = markdown.to_s.rstrip
|
|
57
|
+
|
|
58
|
+
return source if body.blank?
|
|
59
|
+
|
|
60
|
+
"#{body}\n\n#{source}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
ComponentTag = Data.define(:source, :begin_index, :end_index)
|
|
66
|
+
|
|
67
|
+
def fetch_tag(tags, index)
|
|
68
|
+
raise IndexError if index.negative? || index >= tags.length
|
|
69
|
+
|
|
70
|
+
tags.fetch(index)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def component_tags(markdown)
|
|
74
|
+
markdown.to_s.enum_for(:scan, ComponentParser::COMPONENT_PATTERN).map do
|
|
75
|
+
match = Regexp.last_match
|
|
76
|
+
ComponentTag.new(match[0], match.begin(0), match.end(0))
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def replace_component(markdown, index, name, props)
|
|
81
|
+
updated_source = serialize(name, props)
|
|
82
|
+
current_index = -1
|
|
83
|
+
|
|
84
|
+
markdown.to_s.gsub(ComponentParser::COMPONENT_PATTERN) do |source|
|
|
85
|
+
current_index += 1
|
|
86
|
+
current_index == index ? updated_source : source
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def replace_sources(markdown, replacements)
|
|
91
|
+
replacements.sort_by { |tag, _source| tag.begin_index }.reverse_each.reduce(markdown.to_s.dup) do |updated, (tag, source)|
|
|
92
|
+
updated[tag.begin_index...tag.end_index] = source
|
|
93
|
+
updated
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def normalized_props(props)
|
|
98
|
+
props.to_h.each_with_object({}) do |(name, value), normalized|
|
|
99
|
+
normalized[name.to_sym] = parsed_collection(value) || value.to_s
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def serialize(name, props)
|
|
104
|
+
serialized_props = props.map do |key, value|
|
|
105
|
+
%(#{key}=#{serialize_value(value)})
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
"{{ #{([ name ] + serialized_props).join(" ")} }}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def escape_value(value)
|
|
112
|
+
value.to_s.gsub('"', "'")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def serialize_value(value)
|
|
116
|
+
collection = parsed_collection(value)
|
|
117
|
+
return %('#{JSON.generate(collection).gsub("'", "\\u0027")}') if collection
|
|
118
|
+
|
|
119
|
+
%("#{escape_value(value)}")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def parsed_collection(value)
|
|
123
|
+
return value if value.is_a?(Array) || value.is_a?(Hash)
|
|
124
|
+
return unless value.to_s.strip.start_with?("[", "{")
|
|
125
|
+
|
|
126
|
+
parsed = JSON.parse(value)
|
|
127
|
+
parsed if parsed.is_a?(Array) || parsed.is_a?(Hash)
|
|
128
|
+
rescue JSON::ParserError
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "uri"
|
|
3
|
+
|
|
4
|
+
module Journeybook
|
|
5
|
+
class ComponentValidator
|
|
6
|
+
def initialize(parser: ComponentParser.new, registry: Journeybook.components)
|
|
7
|
+
@parser = parser
|
|
8
|
+
@registry = registry
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def errors_for(page)
|
|
12
|
+
components = @registry.for_site(page.site)
|
|
13
|
+
|
|
14
|
+
@parser.parse(page.markdown).flat_map.with_index do |component, index|
|
|
15
|
+
validate_component(component, index, components.registered(component.name))
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def validate_component(component, index, registration)
|
|
22
|
+
label = "Component #{index + 1} (#{component.name})"
|
|
23
|
+
return [ "#{label} is not registered for this site." ] unless registration
|
|
24
|
+
|
|
25
|
+
schema = registration.normalized_props
|
|
26
|
+
props = component.props.except(ComponentParser::HIDDEN_PROP)
|
|
27
|
+
errors = props.keys.filter_map do |name|
|
|
28
|
+
"#{label} has an unknown prop: #{name}." unless schema.key?(name) || target_prop?(name, schema)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
schema.each do |name, config|
|
|
32
|
+
value = props[name].to_s
|
|
33
|
+
errors << "#{label} is missing required prop: #{name}." if config[:required] && value.blank?
|
|
34
|
+
errors.concat(validate_collection(label, name, value, config)) if value.present? && config[:type] == :collection
|
|
35
|
+
errors << "#{label} has an invalid #{config[:type]} prop: #{name}." if value.present? && invalid_value?(value, config)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
errors
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def validate_collection(label, name, value, config)
|
|
42
|
+
items = JSON.parse(value)
|
|
43
|
+
return [ "#{label} has an invalid collection prop: #{name}." ] unless items.is_a?(Array)
|
|
44
|
+
|
|
45
|
+
errors = []
|
|
46
|
+
errors << "#{label} needs at least #{config[:min]} #{name} item(s)." if items.length < config[:min]
|
|
47
|
+
errors << "#{label} allows at most #{config[:max]} #{name} item(s)." if config[:max] && items.length > config[:max]
|
|
48
|
+
|
|
49
|
+
items.each_with_index do |item, index|
|
|
50
|
+
errors.concat(validate_collection_item(label, name, item, index, config[:item]))
|
|
51
|
+
end
|
|
52
|
+
errors
|
|
53
|
+
rescue JSON::ParserError
|
|
54
|
+
[ "#{label} has an invalid collection prop: #{name}." ]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def validate_collection_item(label, name, item, index, schema)
|
|
58
|
+
item_label = "#{label} #{name} item #{index + 1}"
|
|
59
|
+
return [ "#{item_label} must be an object." ] unless item.is_a?(Hash)
|
|
60
|
+
|
|
61
|
+
props = item.deep_symbolize_keys
|
|
62
|
+
errors = props.keys.filter_map do |key|
|
|
63
|
+
"#{item_label} has an unknown prop: #{key}." unless schema.key?(key) || target_prop?(key, schema)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
schema.each do |key, config|
|
|
67
|
+
value = props[key].to_s
|
|
68
|
+
errors << "#{item_label} is missing required prop: #{key}." if config[:required] && value.blank?
|
|
69
|
+
errors << "#{item_label} has an invalid #{config[:type]} prop: #{key}." if value.present? && invalid_value?(value, config)
|
|
70
|
+
end
|
|
71
|
+
errors
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def invalid_value?(value, config)
|
|
75
|
+
case config[:type]
|
|
76
|
+
when :select
|
|
77
|
+
!config[:options].include?(value)
|
|
78
|
+
when :image, :video
|
|
79
|
+
!http_url?(value) && !(config[:icons] && icon_reference?(value, config[:icons]))
|
|
80
|
+
when :url
|
|
81
|
+
!relative_or_http_url?(value) && !page_reference?(value)
|
|
82
|
+
else
|
|
83
|
+
false
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def http_url?(value)
|
|
88
|
+
uri = URI.parse(value)
|
|
89
|
+
uri.is_a?(URI::HTTP) && uri.host.present?
|
|
90
|
+
rescue URI::InvalidURIError
|
|
91
|
+
false
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def relative_or_http_url?(value)
|
|
95
|
+
value.start_with?("/", "#") || http_url?(value)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def page_reference?(value)
|
|
99
|
+
value.match?(%r{\Ajourneybook://page/\d+\z})
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def icon_reference?(value, icon_config)
|
|
103
|
+
style, color, identifier = value.to_s.match(%r{\Aicon:([a-z0-9-]+):([a-z0-9-]+):([a-z0-9-]+)\z})&.captures
|
|
104
|
+
style.in?(icon_config[:styles]) && color.in?(icon_config[:colors]) && identifier.present?
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def target_prop?(name, schema)
|
|
108
|
+
name.to_s.end_with?("_target") && schema.key?(name.to_s.delete_suffix("_target").to_sym)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
module Journeybook
|
|
2
|
+
class FormMailer
|
|
3
|
+
class ConfigurationError < StandardError; end
|
|
4
|
+
|
|
5
|
+
DEFAULT_SUBJECT = "New Journeybook form submission".freeze
|
|
6
|
+
|
|
7
|
+
def initialize(client: nil, configuration: Journeybook.configuration)
|
|
8
|
+
@client = client
|
|
9
|
+
@configuration = configuration
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def deliver(fields:)
|
|
13
|
+
validate_configuration!
|
|
14
|
+
|
|
15
|
+
client.send_email(
|
|
16
|
+
from: @configuration.form_from,
|
|
17
|
+
to: @configuration.form_to,
|
|
18
|
+
subject: fields.delete("subject").to_s.truncate(200).presence || DEFAULT_SUBJECT,
|
|
19
|
+
text_body: body_for(fields),
|
|
20
|
+
reply_to: reply_to_for(fields)
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def client
|
|
27
|
+
@client ||= delivery_client
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def delivery_client
|
|
31
|
+
case delivery_method
|
|
32
|
+
when "postmark"
|
|
33
|
+
PostmarkClient.new(api_key: @configuration.postmark_api_key)
|
|
34
|
+
when "letter_opener"
|
|
35
|
+
LetterOpenerClient.new
|
|
36
|
+
else
|
|
37
|
+
raise ConfigurationError, "Unsupported Journeybook form delivery method: #{delivery_method}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def delivery_method
|
|
42
|
+
@configuration.form_delivery_method.to_s
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def validate_configuration!
|
|
46
|
+
missing = []
|
|
47
|
+
missing << "postmark_api_key" if delivery_method == "postmark" && @configuration.postmark_api_key.blank?
|
|
48
|
+
missing << "form_from" if @configuration.form_from.blank?
|
|
49
|
+
missing << "form_to" if @configuration.form_to.blank?
|
|
50
|
+
return if missing.empty?
|
|
51
|
+
|
|
52
|
+
raise ConfigurationError, "Missing Journeybook email configuration: #{missing.join(", ")}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def body_for(fields)
|
|
56
|
+
fields.map do |name, value|
|
|
57
|
+
"#{name.to_s.humanize}: #{format_value(value)}"
|
|
58
|
+
end.join("\n\n")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def format_value(value)
|
|
62
|
+
Array(value).join(", ")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def reply_to_for(fields)
|
|
66
|
+
fields["email"].presence
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module Journeybook
|
|
2
|
+
class HtmlSanitizer
|
|
3
|
+
ALLOWED_TAGS = %w[
|
|
4
|
+
a abbr article blockquote br button code dd div dl dt em figcaption figure form h1 h2 h3 h4 h5 h6 i
|
|
5
|
+
hr iframe img input kbd label li ol p pre span strong table tbody td textarea th thead tr ul
|
|
6
|
+
section
|
|
7
|
+
].freeze
|
|
8
|
+
|
|
9
|
+
ALLOWED_ATTRIBUTES = %w[
|
|
10
|
+
action allow allowfullscreen alt autocomplete class height href id loading method name placeholder referrerpolicy rel required rows src target title type value width
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
ALLOWED_IFRAME_HOSTS = %w[
|
|
14
|
+
www.youtube.com youtube.com www.youtube-nocookie.com youtube-nocookie.com player.vimeo.com
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
# @param html [String]
|
|
18
|
+
# @return [String]
|
|
19
|
+
def sanitize(html)
|
|
20
|
+
remove_unsafe_iframes(sanitizer.sanitize(html.to_s, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES).to_s)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def remove_unsafe_iframes(html)
|
|
26
|
+
fragment = Nokogiri::HTML5.fragment(html)
|
|
27
|
+
|
|
28
|
+
fragment.css("iframe").each do |iframe|
|
|
29
|
+
iframe.remove unless safe_iframe_src?(iframe["src"])
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
fragment.to_html
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def safe_iframe_src?(src)
|
|
36
|
+
uri = URI.parse(src.to_s)
|
|
37
|
+
uri.is_a?(URI::HTTPS) && ALLOWED_IFRAME_HOSTS.include?(uri.host.to_s.downcase)
|
|
38
|
+
rescue URI::InvalidURIError
|
|
39
|
+
false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def sanitizer
|
|
43
|
+
@sanitizer ||= Rails::HTML5::SafeListSanitizer.new
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Journeybook
|
|
2
|
+
class LetterOpenerClient
|
|
3
|
+
def send_email(from:, to:, subject:, text_body:, reply_to: nil)
|
|
4
|
+
require "letter_opener"
|
|
5
|
+
|
|
6
|
+
message = Mail.new(from:, to:, subject:, body: text_body)
|
|
7
|
+
message.reply_to = reply_to if reply_to.present?
|
|
8
|
+
message.delivery_method(LetterOpener::DeliveryMethod, location: Rails.root.join("tmp/letter_opener"))
|
|
9
|
+
message.deliver!
|
|
10
|
+
rescue LoadError
|
|
11
|
+
raise FormMailer::ConfigurationError, "Install the letter_opener gem to use Journeybook form_delivery_method = :letter_opener"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
require "commonmarker"
|
|
2
|
+
|
|
3
|
+
module Journeybook
|
|
4
|
+
class MarkdownRenderer
|
|
5
|
+
# @param markdown [String]
|
|
6
|
+
# @return [String]
|
|
7
|
+
def render(markdown)
|
|
8
|
+
Commonmarker.to_html(markdown.to_s, options: commonmarker_options)
|
|
9
|
+
rescue ArgumentError
|
|
10
|
+
Commonmarker.to_html(markdown.to_s)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def commonmarker_options
|
|
16
|
+
{
|
|
17
|
+
extension: {
|
|
18
|
+
table: true,
|
|
19
|
+
strikethrough: true,
|
|
20
|
+
autolink: true,
|
|
21
|
+
tasklist: true
|
|
22
|
+
},
|
|
23
|
+
render: {
|
|
24
|
+
unsafe: true
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module Journeybook
|
|
2
|
+
class PageRenderer
|
|
3
|
+
def initialize(
|
|
4
|
+
parser: ComponentParser.new,
|
|
5
|
+
markdown_renderer: MarkdownRenderer.new,
|
|
6
|
+
component_renderer: ComponentRenderer.new,
|
|
7
|
+
sanitizer: HtmlSanitizer.new
|
|
8
|
+
)
|
|
9
|
+
@parser = parser
|
|
10
|
+
@markdown_renderer = markdown_renderer
|
|
11
|
+
@component_renderer = component_renderer
|
|
12
|
+
@sanitizer = sanitizer
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @param page_or_markdown [Journeybook::Page, String]
|
|
16
|
+
# @return [String]
|
|
17
|
+
def render(page_or_markdown, form_authenticity_token: nil, component_index: nil, component_anchors: false)
|
|
18
|
+
@component_anchors = component_anchors
|
|
19
|
+
markdown = page_or_markdown.respond_to?(:markdown) ? page_or_markdown.markdown : page_or_markdown
|
|
20
|
+
extracted_markdown, components = @parser.extract(markdown)
|
|
21
|
+
return render_component(components[component_index], form_authenticity_token, page_or_markdown) if component_index
|
|
22
|
+
|
|
23
|
+
page = page_or_markdown if page_or_markdown.respond_to?(:markdown)
|
|
24
|
+
html = @markdown_renderer.render(extracted_markdown)
|
|
25
|
+
|
|
26
|
+
components.each_with_index do |component, index|
|
|
27
|
+
html = html.gsub(%r{<p>\s*#{component.token}\s*</p>}, component.token)
|
|
28
|
+
html = html.gsub(component.token, "#{component_anchor(index)}#{@component_renderer.render(component, page:)}")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
@sanitizer.sanitize(inject_form_token(html, form_authenticity_token))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def render_component(component, form_authenticity_token, page)
|
|
37
|
+
return "" unless component
|
|
38
|
+
|
|
39
|
+
@sanitizer.sanitize(inject_form_token(@component_renderer.render(component, page:), form_authenticity_token))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def component_anchor(index)
|
|
43
|
+
return "" unless @component_anchors
|
|
44
|
+
|
|
45
|
+
%(<span id="journeybook-preview-component-#{index}" data-journeybook-preview-component="#{index}"></span>)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def inject_form_token(html, token)
|
|
49
|
+
return html if token.blank?
|
|
50
|
+
|
|
51
|
+
action = Regexp.escape(Journeybook.configuration.form_action.to_s)
|
|
52
|
+
form = %r{(<form\b(?=[^>]*\baction=(["'])#{action}\2)[^>]*>)}
|
|
53
|
+
token_input = %(<input type="hidden" name="authenticity_token" value="#{ERB::Util.html_escape(token)}">)
|
|
54
|
+
|
|
55
|
+
html.gsub(form) { "#{Regexp.last_match(1)}#{token_input}" }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module Journeybook
|
|
2
|
+
class PageResolver
|
|
3
|
+
def self.from_request(request, path: nil, locale: nil)
|
|
4
|
+
new(host: request.host, path: path, locale: locale)
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def initialize(host:, path:, locale: nil)
|
|
8
|
+
@host = host
|
|
9
|
+
@path = path
|
|
10
|
+
@locale = locale
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
matched_site = site
|
|
15
|
+
return unless matched_site
|
|
16
|
+
|
|
17
|
+
pages = matched_site.pages.published.by_path(matched_site.relative_path_for(normalized_path))
|
|
18
|
+
pages = pages.where(locale: @locale) if @locale.present?
|
|
19
|
+
pages.first
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def site
|
|
23
|
+
@site ||= find_site
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def find_site
|
|
29
|
+
sites = matching_host_sites.presence || local_sites
|
|
30
|
+
sites.by_longest_prefix.detect { |site| site.matches_path?(normalized_path) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def matching_host_sites
|
|
34
|
+
Site.for_host(@host)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def local_sites
|
|
38
|
+
return Site.none unless local_host?
|
|
39
|
+
|
|
40
|
+
Site.where(host: %w[localhost 127.0.0.1])
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def local_host?
|
|
44
|
+
@host.in?(%w[localhost 127.0.0.1 0.0.0.0])
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def normalized_path
|
|
48
|
+
path = @path.to_s.strip
|
|
49
|
+
path = "/#{path}" unless path.start_with?("/")
|
|
50
|
+
path = path.squeeze("/")
|
|
51
|
+
path = path.delete_suffix("/") unless path == "/"
|
|
52
|
+
path.presence || "/"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Journeybook
|
|
6
|
+
class PostmarkClient
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
|
|
9
|
+
ENDPOINT = URI("https://api.postmarkapp.com/email")
|
|
10
|
+
|
|
11
|
+
def initialize(api_key: Journeybook.configuration.postmark_api_key, endpoint: ENDPOINT)
|
|
12
|
+
@api_key = api_key
|
|
13
|
+
@endpoint = endpoint
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def send_email(from:, to:, subject:, text_body:, reply_to: nil)
|
|
17
|
+
response = Net::HTTP.start(@endpoint.host, @endpoint.port, use_ssl: @endpoint.scheme == "https") do |http|
|
|
18
|
+
http.request(request(from:, to:, subject:, text_body:, reply_to:))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
return response if response.is_a?(Net::HTTPSuccess)
|
|
22
|
+
|
|
23
|
+
raise Error, "Postmark delivery failed: #{response.code} #{response.body}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def request(from:, to:, subject:, text_body:, reply_to:)
|
|
29
|
+
request = Net::HTTP::Post.new(@endpoint)
|
|
30
|
+
request["Accept"] = "application/json"
|
|
31
|
+
request["Content-Type"] = "application/json"
|
|
32
|
+
request["X-Postmark-Server-Token"] = @api_key.to_s
|
|
33
|
+
request.body = payload(from:, to:, subject:, text_body:, reply_to:).to_json
|
|
34
|
+
request
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def payload(from:, to:, subject:, text_body:, reply_to:)
|
|
38
|
+
{
|
|
39
|
+
From: from,
|
|
40
|
+
To: to,
|
|
41
|
+
Subject: subject,
|
|
42
|
+
TextBody: text_body
|
|
43
|
+
}.tap do |attributes|
|
|
44
|
+
attributes[:ReplyTo] = reply_to if reply_to.present?
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
require "aws-sdk-s3"
|
|
2
|
+
require "digest"
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Journeybook
|
|
6
|
+
module Storage
|
|
7
|
+
class S3Client
|
|
8
|
+
class Error < Journeybook::Error; end
|
|
9
|
+
|
|
10
|
+
Upload = Data.define(:storage_key, :original_filename, :content_type, :byte_size, :checksum)
|
|
11
|
+
|
|
12
|
+
def initialize(configuration: Journeybook.configuration, client: nil)
|
|
13
|
+
@configuration = configuration
|
|
14
|
+
@client = client
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def upload(file)
|
|
18
|
+
raise Error, "Image file is required." unless file.respond_to?(:read)
|
|
19
|
+
|
|
20
|
+
content = file.read
|
|
21
|
+
file.rewind if file.respond_to?(:rewind)
|
|
22
|
+
original_filename = File.basename(file.original_filename.to_s)
|
|
23
|
+
content_type = file.content_type.to_s
|
|
24
|
+
storage_key = build_storage_key(original_filename)
|
|
25
|
+
|
|
26
|
+
s3_client.put_object(
|
|
27
|
+
bucket: bucket,
|
|
28
|
+
key: storage_key,
|
|
29
|
+
body: content,
|
|
30
|
+
content_type: content_type
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
Upload.new(
|
|
34
|
+
storage_key: storage_key,
|
|
35
|
+
original_filename: original_filename,
|
|
36
|
+
content_type: content_type,
|
|
37
|
+
byte_size: content.bytesize,
|
|
38
|
+
checksum: Digest::SHA256.hexdigest(content)
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def delete(storage_key)
|
|
43
|
+
return if storage_key.blank?
|
|
44
|
+
|
|
45
|
+
s3_client.delete_object(bucket: bucket, key: storage_key)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def public_url(storage_key)
|
|
49
|
+
base_url = @configuration.s3_public_base_url.to_s.delete_suffix("/")
|
|
50
|
+
return "#{base_url}/#{storage_key}" if base_url.present?
|
|
51
|
+
|
|
52
|
+
raise Error, "Journeybook S3 bucket and region are required." if bucket.blank? || region.blank?
|
|
53
|
+
|
|
54
|
+
"https://#{bucket}.s3.#{region}.amazonaws.com/#{storage_key}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def s3_client
|
|
60
|
+
@client ||= begin
|
|
61
|
+
raise Error, "Journeybook S3 bucket is missing. Set config.s3_bucket." if bucket.blank?
|
|
62
|
+
raise Error, "Journeybook S3 region is missing. Set config.s3_region." if region.blank?
|
|
63
|
+
raise Error, "Journeybook S3 access key is missing. Set config.s3_access_key_id." if @configuration.s3_access_key_id.blank?
|
|
64
|
+
raise Error, "Journeybook S3 secret key is missing. Set config.s3_secret_access_key." if @configuration.s3_secret_access_key.blank?
|
|
65
|
+
|
|
66
|
+
Aws::S3::Client.new(
|
|
67
|
+
region: region,
|
|
68
|
+
access_key_id: @configuration.s3_access_key_id,
|
|
69
|
+
secret_access_key: @configuration.s3_secret_access_key
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def build_storage_key(original_filename)
|
|
75
|
+
filename = sanitize_filename(original_filename.presence || "image")
|
|
76
|
+
[ prefix, "media", SecureRandom.uuid, filename ].reject(&:blank?).join("/")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def sanitize_filename(filename)
|
|
80
|
+
filename.to_s.downcase.gsub(/[^a-z0-9.\-_]+/, "-").delete_prefix("-").delete_suffix("-").presence || "image"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def prefix
|
|
84
|
+
@configuration.s3_prefix.to_s.strip.delete_prefix("/").delete_suffix("/")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def bucket
|
|
88
|
+
@configuration.s3_bucket
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def region
|
|
92
|
+
@configuration.s3_region
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|