summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-07-09 15:29:40 +0200
committererdgeist <erdgeist@erdgeist.org>2026-07-09 15:29:40 +0200
commit9c3217df50d462d6be4399e3e654d591bf704df7 (patch)
tree627e474b273789667bca9eadffa0e7f626d002bb
parent8310751d4db2854597d6379b24e346c65622972d (diff)
Fix malformed-HTML fallout across RSS, link rewriting, and flash messaging
Editors' TinyMCE output can contain unclosed void elements like <br>, which is valid HTML5 but invalid XML -- three different places assumed the stricter rule and broke or silently misbehaved on the looser one. The Atom feed's <content type="xhtml"> block required real, well-formed XML structure but was handed a raw, unescaped body string; switched to type="html" with CGI.escapeHTML, matching how title/summary already handle the same content. rewrite_links_in_body used libxml's strict XML parser to rewrite internal links to be locale-prefixed, which raised on exactly this class of malformed markup -- silently, since the whole method was wrapped in rescue; nil, meaning the link rewrite (not the save) quietly failed with no error anywhere. Replaced with Nokogiri's lenient HTML parser, which repairs malformed void elements rather than rejecting them; also drops the bare rescue now that the actual failure mode it was guarding against shouldn't occur, and fixes two adjacent bugs found while in this method: a typo'd /sytem/uploads/ regex that could never match, and a missing https:// exclusion alongside the existing http:// one. Also addresses stale flash messaging surfaced while testing the above: update's save confirmation was being clobbered by edit's own "locked and ready" notice on the very next request, since nothing distinguished a fresh lock acquisition from a redirect back after saving. The save confirmation now names the next step (publish from Status) and flags a stale translation if one exists, using Page#outdated_translations?, already present but previously unused by any controller.
-rw-r--r--app/controllers/nodes_controller.rb18
-rw-r--r--app/models/page.rb33
-rw-r--r--app/views/layouts/admin.html.erb7
-rw-r--r--app/views/rss/updates.xml.builder4
-rw-r--r--public/stylesheets/admin.css9
5 files changed, 44 insertions, 27 deletions
diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb
index 72d4a3e..38d42d9 100644
--- a/app/controllers/nodes_controller.rb
+++ b/app/controllers/nodes_controller.rb
@@ -65,15 +65,16 @@ class NodesController < ApplicationController
65 end 65 end
66 66
67 def edit 67 def edit
68 freshly_locked = @node.lock_owner != current_user
68 @node.lock_for_editing!( current_user ) 69 @node.lock_for_editing!( current_user )
69 @page = @node.autosave || @node.draft || @node.head 70 @page = @node.autosave || @node.draft || @node.head
70 71
71 flash.now[:notice] = "Node locked and ready to edit" unless @node.autosave
72
73 if @node.autosave 72 if @node.autosave
74 flash.now[:notice] = 73 flash.now[:notice] =
75 "This page has unsaved changes from a previous session, shown below. " \ 74 "This page has unsaved changes from a previous session, shown below. " \
76 "Save to keep them, or use \"Discard changes\" below to go back to the last saved version." 75 "Save to keep them, or use \"Discard changes\" below to go back to the last saved version."
76 elsif freshly_locked
77 flash.now[:notice] = "Node locked and ready to edit"
77 end 78 end
78 rescue LockedByAnotherUser => e 79 rescue LockedByAnotherUser => e
79 flash[:error] = e.message 80 flash[:error] = e.message
@@ -85,7 +86,18 @@ class NodesController < ApplicationController
85 @node.autosave!( page_params.merge(:tag_list => params[:tag_list]), current_user ) 86 @node.autosave!( page_params.merge(:tag_list => params[:tag_list]), current_user )
86 @node.save_draft!(current_user) 87 @node.save_draft!(current_user)
87 88
88 flash[:notice] = "Draft has been saved: #{Time.now}" 89 flash[:notice] = "Draft saved. Publish your changes in the Status section once you're done."
90 flash[:status_path] = node_path(@node)
91
92 if @node.draft.translated_locales.size > 1
93 stale_locale = @node.draft.translated_locales.find do |locale|
94 @node.draft.outdated_translations?(locale: locale)
95 end
96 if stale_locale
97 flash[:stale_locale] = stale_locale
98 flash[:stale_locale_path] = edit_node_path(@node, locale: stale_locale)
99 end
100 end
89 101
90 if params[:commit] == "Save + Unlock + Exit" 102 if params[:commit] == "Save + Unlock + Exit"
91 @node.unlock! 103 @node.unlock!
diff --git a/app/models/page.rb b/app/models/page.rb
index fff044e..1a98e08 100644
--- a/app/models/page.rb
+++ b/app/models/page.rb
@@ -243,31 +243,22 @@ class Page < ApplicationRecord
243 end 243 end
244 244
245 def rewrite_links_in_body 245 def rewrite_links_in_body
246 begin 246 return unless self.body
247 if self.body
248 tmp_body = "<div>#{self.body}</div>"
249 xml_string = XML::Parser.string( tmp_body )
250 xml_doc = xml_string.parse
251 links = xml_doc.find("//a[not(starts-with(@href, 'http://'))]")
252 links = links.reject { |l| l[:href] =~ /system\/uploads/ }
253 locales = I18n.available_locales.reject {|l| l == :root}
254 247
255 links.each do |link| 248 doc = Nokogiri::HTML::DocumentFragment.parse(self.body)
256 unless locales.include? link[:href].slice(1,2).to_sym 249 locales = I18n.available_locales.reject { |l| l == :root }
257 unless link[:href] =~ /sytem\/uploads/
258 link[:href] = link[:href].sub(/^\//, "/#{I18n.locale}/")
259 end
260 end
261 end
262 250
263 tmp_body = xml_doc.to_s.gsub(/(\n\<div\>|\<\/div\>\n)/, "") 251 doc.css('a').each do |link|
264 tmp_body.gsub!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>", "") 252 href = link['href']
253 next unless href
254 next if href.start_with?('http://', 'https://')
255 next if href =~ /system\/uploads/
265 256
266 self.body = tmp_body 257 unless locales.include?(href.slice(1, 2)&.to_sym)
258 link['href'] = href.sub(/^\//, "/#{I18n.locale}/")
267 end 259 end
268 rescue
269 nil
270 end 260 end
271 end
272 261
262 self.body = doc.to_html
263 end
273end 264end
diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb
index 340eaf2..79b1380 100644
--- a/app/views/layouts/admin.html.erb
+++ b/app/views/layouts/admin.html.erb
@@ -33,6 +33,13 @@
33 <% if flash[:notice].present? || flash[:error].present? %> 33 <% if flash[:notice].present? || flash[:error].present? %>
34 <div id="flash"> 34 <div id="flash">
35 <%= flash[:notice] %> 35 <%= flash[:notice] %>
36 <% if flash[:status_path] %>
37 <%= link_to 'Go to Status', flash[:status_path] %>
38 <% end %>
39 <% if flash[:stale_locale_path] %>
40 The <%= flash[:stale_locale] %> translation may be out of date —
41 <%= link_to 'review it', flash[:stale_locale_path] %> too.
42 <% end %>
36 <% if flash[:error] %> 43 <% if flash[:error] %>
37 <span id="flash_error"><%= flash[:error] %></span> 44 <span id="flash_error"><%= flash[:error] %></span>
38 <% end %> 45 <% end %>
diff --git a/app/views/rss/updates.xml.builder b/app/views/rss/updates.xml.builder
index 4b2e2f7..27845c4 100644
--- a/app/views/rss/updates.xml.builder
+++ b/app/views/rss/updates.xml.builder
@@ -22,9 +22,7 @@ xml.feed(:xmlns => "http://www.w3.org/2005/Atom", "xml:base" => @host) do
22 xml.updated(item.updated_at.xmlschema) 22 xml.updated(item.updated_at.xmlschema)
23 xml.published(item.published_at.xmlschema) 23 xml.published(item.published_at.xmlschema)
24 xml.summary(CGI.escapeHTML(item.abstract.to_s)) 24 xml.summary(CGI.escapeHTML(item.abstract.to_s))
25 xml.content(:type => "xhtml") do 25 xml.content(CGI.escapeHTML(item.body.to_s), :type => "html")
26 xml.div(item.body, :xmlns => "http://www.w3.org/1999/xhtml")
27 end
28 end 26 end
29 27
30 end 28 end
diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css
index f00a658..3f95b0c 100644
--- a/public/stylesheets/admin.css
+++ b/public/stylesheets/admin.css
@@ -193,6 +193,15 @@ input[type=radio] {
193 margin-left: 5px; 193 margin-left: 5px;
194} 194}
195 195
196#flash a {
197 text-decoration: underline;
198 -webkit-text-decoration-style: wavy;
199 text-decoration-style: wavy;
200 text-decoration-color: #b0b0b0;
201 text-decoration-thickness: 1px;
202 text-underline-offset: 2px;
203}
204
196#flash span { 205#flash span {
197 letter-spacing: 1px; 206 letter-spacing: 1px;
198 margin-right: 10px; 207 margin-right: 10px;