fixes for plain text bbcode rendering
This commit is contained in:
@@ -55,7 +55,9 @@ RUN \
|
||||
watchman \
|
||||
ffmpeg \
|
||||
ffmpegthumbnailer \
|
||||
abiword
|
||||
abiword \
|
||||
pdftohtml \
|
||||
libreoffice
|
||||
|
||||
# Install postgres 15 client
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
|
||||
@@ -60,7 +60,9 @@ RUN \
|
||||
libpq-dev \
|
||||
ffmpeg \
|
||||
ffmpegthumbnailer \
|
||||
abiword
|
||||
abiword \
|
||||
pdftohtml \
|
||||
libreoffice
|
||||
|
||||
COPY --from=native-gems /usr/src/app/gems/xdiff-rb /gems/xdiff-rb
|
||||
COPY --from=native-gems /usr/src/app/gems/rb-bsdiff /gems/rb-bsdiff
|
||||
|
||||
@@ -51,3 +51,7 @@
|
||||
.log-entry-table-row-cell {
|
||||
@apply flex items-center border-b border-slate-200 px-2 py-1 text-sm group-hover:bg-slate-50;
|
||||
}
|
||||
|
||||
#rich-text-content blockquote {
|
||||
@apply my-4 border-s-4 border-gray-300 bg-slate-200 p-4 italic leading-relaxed;
|
||||
}
|
||||
|
||||
@@ -142,6 +142,7 @@ class Domain::UsersController < DomainController
|
||||
error: "user '#{exclude_url_name}' not found",
|
||||
error_type: "exclude_user_not_found",
|
||||
}
|
||||
return
|
||||
elsif exclude_followed_by.scanned_follows_at.nil?
|
||||
render status: 500,
|
||||
json: {
|
||||
@@ -149,6 +150,7 @@ class Domain::UsersController < DomainController
|
||||
"user '#{exclude_url_name}' followers list hasn't been scanned",
|
||||
error_type: "exclude_user_not_scanned",
|
||||
}
|
||||
return
|
||||
else
|
||||
not_followed_similar_users =
|
||||
users_similar_to_by_followers(
|
||||
|
||||
@@ -85,37 +85,48 @@ module Domain::PostsHelper
|
||||
return nil unless file.state_ok?
|
||||
return nil unless file.log_entry_id.present?
|
||||
content_type = file.log_entry&.content_type || ""
|
||||
pretty_content_type(content_type)
|
||||
end
|
||||
|
||||
ct =
|
||||
case content_type
|
||||
when %r{text/plain}
|
||||
"Text Document"
|
||||
when %r{application/pdf}
|
||||
"PDF Document"
|
||||
when %r{image/jpeg}
|
||||
"JPEG Image"
|
||||
when %r{image/png}
|
||||
"PNG Image"
|
||||
when %r{image/gif}
|
||||
"GIF Image"
|
||||
when %r{video/webm}
|
||||
"Webm Video"
|
||||
when %r{audio/mpeg}
|
||||
"MP3 Audio"
|
||||
when %r{audio/mp3}
|
||||
"MP3 Audio"
|
||||
when %r{audio/wav}
|
||||
"WAV Audio"
|
||||
when %r{application/msword}
|
||||
"Microsoft Word Document"
|
||||
else
|
||||
content_type.split(";").first&.split("/")&.last&.titleize
|
||||
end
|
||||
sig { params(content_type: String).returns(String) }
|
||||
def pretty_content_type(content_type)
|
||||
case content_type
|
||||
when %r{text/plain}
|
||||
"Plain Text Document"
|
||||
when %r{application/pdf}
|
||||
"PDF Document"
|
||||
when %r{application/msword}
|
||||
"Microsoft Word Document"
|
||||
when %r{application/vnd\.openxmlformats-officedocument\.wordprocessingml\.document}
|
||||
"Microsoft Word Document (OpenXML)"
|
||||
when %r{application/rtf}
|
||||
"Rich Text Document"
|
||||
when %r{image/jpeg}
|
||||
"JPEG Image"
|
||||
when %r{image/png}
|
||||
"PNG Image"
|
||||
when %r{image/gif}
|
||||
"GIF Image"
|
||||
when %r{video/webm}
|
||||
"Webm Video"
|
||||
when %r{audio/mpeg}
|
||||
"MP3 Audio"
|
||||
when %r{audio/mp3}
|
||||
"MP3 Audio"
|
||||
when %r{audio/wav}
|
||||
"WAV Audio"
|
||||
else
|
||||
content_type.split(";").first&.split("/")&.last&.titleize || "Unknown"
|
||||
end
|
||||
end
|
||||
|
||||
file_size =
|
||||
file.log_entry&.response_size&.then { |size| number_to_human_size(size) }
|
||||
|
||||
[ct, file_size].compact.join(", ")
|
||||
sig { params(post: Domain::Post).returns(T.nilable(String)) }
|
||||
def gallery_file_size_for_post(post)
|
||||
file = post.primary_file_for_view
|
||||
return nil unless file.present?
|
||||
return nil unless file.state_ok?
|
||||
return nil unless file.log_entry_id.present?
|
||||
file.log_entry&.response_size&.then { |size| number_to_human_size(size) }
|
||||
end
|
||||
|
||||
sig { params(url: String).returns(T.nilable(String)) }
|
||||
@@ -318,7 +329,11 @@ module Domain::PostsHelper
|
||||
# normalize the source to a lowercase string with a protocol
|
||||
source.downcase!
|
||||
source = "https://" + source unless source.include?("://")
|
||||
uri = URI.parse(source)
|
||||
begin
|
||||
uri = URI.parse(source)
|
||||
rescue StandardError
|
||||
return nil
|
||||
end
|
||||
uri_host = uri.host
|
||||
return nil if uri_host.blank?
|
||||
|
||||
|
||||
@@ -12,17 +12,29 @@ module Domain::UsersHelper
|
||||
).returns(String)
|
||||
end
|
||||
def domain_user_avatar_img_src_path(avatar, thumb: nil)
|
||||
if (sha256 = avatar&.log_entry&.response_sha256)
|
||||
Rails.application.routes.url_helpers.blob_path(
|
||||
HexUtil.bin2hex(sha256),
|
||||
format: "jpg",
|
||||
thumb: thumb,
|
||||
)
|
||||
else
|
||||
# default / 'not found' avatar image
|
||||
# "/blobs/9080fd4e7e23920eb2dccfe2d86903fc3e748eebb2e5aa8c657bbf6f3d941cdc/contents.jpg"
|
||||
asset_path("user-circle.svg")
|
||||
end
|
||||
cache_key = ["domain_user_avatar_img_src_path", avatar&.id, thumb]
|
||||
Rails
|
||||
.cache
|
||||
.fetch(cache_key, expires_in: 1.day) do
|
||||
if (sha256 = avatar&.log_entry&.response_sha256)
|
||||
Rails.application.routes.url_helpers.blob_path(
|
||||
HexUtil.bin2hex(sha256),
|
||||
format: "jpg",
|
||||
thumb: thumb,
|
||||
)
|
||||
elsif avatar && avatar.state_file_404? &&
|
||||
(sha256 = avatar.last_log_entry&.response_sha256)
|
||||
Rails.application.routes.url_helpers.blob_path(
|
||||
HexUtil.bin2hex(sha256),
|
||||
format: "jpg",
|
||||
thumb: thumb,
|
||||
)
|
||||
else
|
||||
# default / 'not found' avatar image
|
||||
# "/blobs/9080fd4e7e23920eb2dccfe2d86903fc3e748eebb2e5aa8c657bbf6f3d941cdc/contents.jpg"
|
||||
asset_path("user-circle.svg")
|
||||
end
|
||||
end
|
||||
end
|
||||
sig do
|
||||
params(
|
||||
|
||||
@@ -62,6 +62,17 @@ module LogEntriesHelper
|
||||
content_type.starts_with?("application/json")
|
||||
end
|
||||
|
||||
sig { params(content_type: String).returns(T::Boolean) }
|
||||
def is_rich_text_content_type?(content_type)
|
||||
%w[
|
||||
application/pdf
|
||||
application/rtf
|
||||
application/msword
|
||||
text/plain
|
||||
application/vnd.openxmlformats-officedocument.wordprocessingml.document
|
||||
].any? { |ct| content_type.starts_with?(ct) }
|
||||
end
|
||||
|
||||
sig { params(content_type: String).returns(T::Boolean) }
|
||||
def is_renderable_video_type?(content_type)
|
||||
%w[video/mp4 video/webm].any? { |ct| content_type.starts_with?(ct) }
|
||||
@@ -85,23 +96,55 @@ module LogEntriesHelper
|
||||
is_renderable_image_type?(content_type)
|
||||
end
|
||||
|
||||
sig { params(log_entry: HttpLogEntry).returns(T.nilable(String)) }
|
||||
def render_msword_content(log_entry)
|
||||
docx_body = log_entry.response_bytes
|
||||
return nil if docx_body.blank?
|
||||
# Invoke abiword to convert doc / docx to html
|
||||
# Run abiword conversion with pipes
|
||||
stdin, stdout, wait_thr =
|
||||
Open3.popen2(
|
||||
sig { params(rich_text_body: String).returns(T.nilable(String)) }
|
||||
def convert_with_pdftohtml(rich_text_body)
|
||||
stdin, stdout, stderr, wait_thr =
|
||||
Open3.popen3(
|
||||
"pdftohtml",
|
||||
"-i", # ignore images
|
||||
"-s", # generate single HTML page
|
||||
"-nodrm", # ignore drm
|
||||
"-enc",
|
||||
"UTF-8",
|
||||
"-stdout",
|
||||
"-", # read from stdin
|
||||
"-", # write to stdout (???)
|
||||
)
|
||||
stdin.binmode
|
||||
stdin.write(rich_text_body)
|
||||
stdin.close
|
||||
|
||||
stdout_str = stdout.read
|
||||
exit_status = T.cast(wait_thr.value, Process::Status)
|
||||
return nil unless exit_status.success?
|
||||
|
||||
# For PDFs, handle both HTML entities and Unicode NBSPs
|
||||
# First replace the actual unicode NBSP character (U+00A0)
|
||||
# stdout_str.gsub!(/[[:space:]]+/, " ")
|
||||
stdout_str.gsub!(/\u00A0/, " ")
|
||||
stdout_str.gsub!(/ /i, " ")
|
||||
stdout_str.gsub!(/ /, " ")
|
||||
stdout_str.gsub!(/ /i, " ")
|
||||
|
||||
stdout_str
|
||||
ensure
|
||||
stdin&.close
|
||||
stdout&.close
|
||||
stderr&.close
|
||||
end
|
||||
|
||||
sig { params(rich_text_body: String).returns(T.nilable(String)) }
|
||||
def convert_with_abiword(rich_text_body)
|
||||
stdin, stdout, stderr, wait_thr =
|
||||
Open3.popen3(
|
||||
"abiword",
|
||||
"--display=0",
|
||||
"--to=html",
|
||||
"--to-name=fd://1",
|
||||
"fd://0",
|
||||
)
|
||||
|
||||
stdin.binmode
|
||||
stdin.write(docx_body)
|
||||
stdin.write(rich_text_body)
|
||||
stdin.close
|
||||
|
||||
stdout_str = stdout.read
|
||||
@@ -109,29 +152,164 @@ module LogEntriesHelper
|
||||
return nil unless exit_status.success?
|
||||
|
||||
stdout_str.gsub!(/Abiword HTML Document/, "")
|
||||
stdout_str = T.cast(T.unsafe(stdout_str).bbcode_to_html(false), String)
|
||||
stdout_str = try_convert_bbcode_to_html(stdout_str)
|
||||
stdout_str.gsub!(%r{<br\s*/>}, "")
|
||||
|
||||
sanitizer =
|
||||
Sanitize.new(
|
||||
elements: %w[span div p i b strong em],
|
||||
attributes: {
|
||||
"span" => %w[style],
|
||||
"div" => %w[style],
|
||||
"p" => %w[style],
|
||||
"b" => %w[style],
|
||||
"i" => %w[style],
|
||||
"strong" => %w[style],
|
||||
"em" => %w[style],
|
||||
},
|
||||
css: {
|
||||
properties: %w[font-size color text-align margin-bottom],
|
||||
},
|
||||
)
|
||||
|
||||
raw sanitizer.fragment(stdout_str)
|
||||
stdout_str
|
||||
ensure
|
||||
stdin&.close
|
||||
stdout&.close
|
||||
stderr&.close
|
||||
end
|
||||
|
||||
sig { params(rich_text_body: String).returns(T.nilable(String)) }
|
||||
def convert_with_libreoffice(rich_text_body)
|
||||
tempfile = Tempfile.new(%w[test .doc], binmode: true)
|
||||
tempfile.write(rich_text_body)
|
||||
tempfile.flush
|
||||
|
||||
stdin, stdout, stderr, wait_thr =
|
||||
Open3.popen3(
|
||||
"libreoffice",
|
||||
"--display",
|
||||
"0",
|
||||
"--headless",
|
||||
"--convert-to",
|
||||
"html",
|
||||
T.must(tempfile.path),
|
||||
"--cat",
|
||||
)
|
||||
stdin.binmode
|
||||
stdin.write(rich_text_body)
|
||||
stdin.close
|
||||
|
||||
stdout_str = stdout.read
|
||||
exit_status = T.cast(wait_thr.value, Process::Status)
|
||||
return nil unless exit_status.success?
|
||||
|
||||
stdout_str
|
||||
ensure
|
||||
stdin&.close
|
||||
stdout&.close
|
||||
stderr&.close
|
||||
tempfile&.close
|
||||
end
|
||||
|
||||
sig { params(rich_text_body: String).returns(String) }
|
||||
def try_convert_bbcode_to_html(rich_text_body)
|
||||
rich_text_body.bbcode_to_html(false)
|
||||
rescue StandardError
|
||||
rich_text_body
|
||||
end
|
||||
|
||||
sig { params(log_entry: HttpLogEntry).returns(T.nilable(String)) }
|
||||
def render_rich_text_content(log_entry)
|
||||
content_type = log_entry.content_type
|
||||
rich_text_body = log_entry.response_bytes
|
||||
return nil if rich_text_body.blank? || content_type.blank?
|
||||
|
||||
is_plain_text = content_type.starts_with?("text/plain")
|
||||
|
||||
if is_plain_text
|
||||
# rich_text_body.gsub!(/(\r\n|\n|\r)+/, "<br />")
|
||||
rich_text_body = rich_text_body.force_encoding("UTF-8")
|
||||
document_html = try_convert_bbcode_to_html(rich_text_body)
|
||||
elsif content_type.starts_with?("application/pdf")
|
||||
document_html = convert_with_pdftohtml(rich_text_body)
|
||||
else
|
||||
document_html =
|
||||
convert_with_abiword(rich_text_body) ||
|
||||
convert_with_libreoffice(rich_text_body)
|
||||
end
|
||||
|
||||
return nil if document_html.blank?
|
||||
|
||||
quote_transformer =
|
||||
Kernel.lambda do |env|
|
||||
node = env[:node]
|
||||
if node["class"]
|
||||
classes = node["class"].split(" ").map(&:strip).compact
|
||||
node.remove_attribute("class")
|
||||
if classes.include?("quote")
|
||||
# write div to be a blockquote
|
||||
node.name = "blockquote"
|
||||
end
|
||||
end
|
||||
node
|
||||
end
|
||||
|
||||
clean_plain_text_node =
|
||||
Kernel.lambda do |node|
|
||||
if node.text?
|
||||
node_text = node.text.strip
|
||||
if node_text.empty?
|
||||
node.unlink
|
||||
else
|
||||
# collect all the subsequent nodes that are not a block element
|
||||
# and replace the current node with a <p> containing the text
|
||||
# and the collected nodes
|
||||
current_node = node
|
||||
inline_elements = []
|
||||
while (next_sibling = current_node&.next_sibling) &&
|
||||
(next_sibling.name != "br") && (next_sibling.name != "p")
|
||||
inline_elements << next_sibling
|
||||
current_node = next_sibling
|
||||
end
|
||||
|
||||
node_html = [node_text]
|
||||
inline_elements.each do |inline_element|
|
||||
inline_element.unlink
|
||||
node_html << inline_element.to_html
|
||||
end
|
||||
node.replace("<p>#{node_html.join(" ")}</p>")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
plain_text_transformer =
|
||||
Kernel.lambda do |env|
|
||||
# within a div, wrap bare text nodes in a <p>
|
||||
node = env[:node]
|
||||
node_name = env[:node_name]
|
||||
if node_name == "div"
|
||||
node.children.each { |child| clean_plain_text_node.call(child) }
|
||||
elsif node.text? && node.parent&.name == "#document-fragment"
|
||||
clean_plain_text_node.call(node)
|
||||
end
|
||||
{ node_allowlist: [node] }
|
||||
end
|
||||
|
||||
remove_empty_newline_transformer =
|
||||
Kernel.lambda do |env|
|
||||
node = env[:node]
|
||||
node.unlink if node.text? && node.text.strip.chomp.blank?
|
||||
end
|
||||
|
||||
remove_multiple_br_transformer =
|
||||
Kernel.lambda do |env|
|
||||
node = env[:node]
|
||||
if node.name == "br"
|
||||
node.unlink if node.previous_sibling&.name == "br"
|
||||
end
|
||||
end
|
||||
|
||||
sanitizer =
|
||||
Sanitize.new(
|
||||
elements: %w[span div p i b strong em blockquote br],
|
||||
attributes: {
|
||||
all: %w[style class],
|
||||
},
|
||||
css: {
|
||||
properties: %w[color text-align margin-bottom],
|
||||
},
|
||||
transformers: [
|
||||
quote_transformer,
|
||||
is_plain_text ? remove_empty_newline_transformer : nil,
|
||||
is_plain_text ? plain_text_transformer : nil,
|
||||
is_plain_text ? remove_multiple_br_transformer : nil,
|
||||
].compact,
|
||||
)
|
||||
|
||||
raw sanitizer.fragment(document_html).strip
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,8 +8,8 @@ module Domain::NeighborFinder
|
||||
.returns(ActiveRecord::Relation)
|
||||
end
|
||||
def self.find_neighbors(factors)
|
||||
Domain::Factors.connection.execute("SET ivfflat.max_probes = 10")
|
||||
Domain::Factors.connection.execute("SET ivfflat.probes = 10")
|
||||
factors.nearest_neighbors(:embedding, distance: "cosine")
|
||||
Domain::Factors.connection.execute("SET ivfflat.max_probes = 32")
|
||||
Domain::Factors.connection.execute("SET ivfflat.probes = 32")
|
||||
factors.nearest_neighbors(:embedding, distance: "euclidean")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -31,7 +31,7 @@ class Scraper::FaHttpClientConfig < Scraper::HttpClientConfig
|
||||
|
||||
def ratelimit
|
||||
# number represents minimum delay in seconds between requests to the same domain
|
||||
[["d.furaffinity.net", :none], ["*.facdn.net", :none], ["*", 1]]
|
||||
[["d.furaffinity.net", :none], ["*.facdn.net", :none], ["*", 1.5]]
|
||||
end
|
||||
|
||||
def allowed_domains
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
alt: post.title_for_view %>
|
||||
<% end %>
|
||||
<% elsif file_info = gallery_file_info_for_post(post) %>
|
||||
<span class="text-sm text-slate-500 italic"><%= file_info %></span>
|
||||
<span class="text-sm text-slate-500 italic"><%= file_info %>, <%= gallery_file_size_for_post(post) %></span>
|
||||
<% else %>
|
||||
<span class="text-sm text-slate-500 italic">No file available</span>
|
||||
<% end %>
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
<%= render partial: "log_entries/renderers/embed", locals: { log_entry:, path: } %>
|
||||
<% elsif is_json_content_type?(log_entry.content_type) %>
|
||||
<%= render partial: "log_entries/renderers/json", locals: { log_entry:, path: } %>
|
||||
<% elsif log_entry.content_type =~ %r{application/msword} %>
|
||||
<%= render partial: "log_entries/renderers/msword", locals: { log_entry:, path: } %>
|
||||
<% elsif is_rich_text_content_type?(log_entry.content_type) %>
|
||||
<%= render partial: "log_entries/renderers/rich_text", locals: { log_entry:, path: } %>
|
||||
<% else %>
|
||||
<%= render partial: "log_entries/renderers/iframe_fallback", locals: { log_entry:, path: } %>
|
||||
<% end %>
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<section class="sky-section w-full">
|
||||
<div class="section-header flex justify-between items-center sticky z-10 py-2 border-b">
|
||||
<span>Word Document</span>
|
||||
<button onclick="toggleMsWordContent()" id="msword-toggle" class="text-sm text-blue-600 hover:text-blue-800 flex items-center gap-1">
|
||||
<span>Show More</span>
|
||||
<i id="msword-toggle-icon" class="fa-solid fa-chevron-down w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-slate-100 p-4 indent-4 font-medium font-serif">
|
||||
<div id="msword-content" class="line-clamp-6">
|
||||
<%= render_msword_content(log_entry) %>
|
||||
</div>
|
||||
<script>
|
||||
function toggleMsWordContent() {
|
||||
const content = document.getElementById('msword-content');
|
||||
const button = document.getElementById('msword-toggle');
|
||||
const icon = document.getElementById('msword-toggle-icon');
|
||||
if (content.classList.contains('line-clamp-6')) {
|
||||
content.classList.remove('line-clamp-6');
|
||||
button.firstElementChild.textContent = 'Show Less';
|
||||
icon.style.transform = 'rotate(180deg)';
|
||||
} else {
|
||||
content.classList.add('line-clamp-6');
|
||||
button.firstElementChild.textContent = 'Show More';
|
||||
icon.style.transform = 'rotate(0deg)';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
</section>
|
||||
86
app/views/log_entries/renderers/_rich_text.html.erb
Normal file
86
app/views/log_entries/renderers/_rich_text.html.erb
Normal file
@@ -0,0 +1,86 @@
|
||||
<section class="sky-section w-full">
|
||||
<% rich_text_content = render_rich_text_content(log_entry) %>
|
||||
<div class="section-header flex justify-between items-center sticky z-10 py-2 border-b">
|
||||
<span><%= pretty_content_type(log_entry.content_type) %></span>
|
||||
<div class="flex items-center gap-3">
|
||||
<% if rich_text_content.present? %>
|
||||
<div class="flex items-center gap-2">
|
||||
<span id="font-size-percentage" class="text-sm text-gray-600 font-medium">100%</span>
|
||||
<button onclick="decreaseFontSize()" class="text-sm text-blue-600 hover:text-blue-800 flex items-center justify-center bg-gray-100 border border-gray-300 rounded-md h-6 w-6 shadow-sm hover:bg-gray-200">
|
||||
<i class="fa-solid fa-minus"></i>
|
||||
</button>
|
||||
<button onclick="increaseFontSize()" class="text-sm text-blue-600 hover:text-blue-800 flex items-center justify-center bg-gray-100 border border-gray-300 rounded-md h-6 w-6 shadow-sm hover:bg-gray-200">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button onclick="toggleRichTextContent()" id="rich-text-toggle" class="text-sm text-blue-600 hover:text-blue-800 flex items-center gap-1 bg-gray-100 border border-gray-300 rounded-md px-2 py-1 shadow-sm hover:bg-gray-200">
|
||||
<span>Show More</span>
|
||||
<i id="rich-text-toggle-icon" class="fa-solid fa-chevron-down w-4 h-4"></i>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% if rich_text_content.present? %>
|
||||
<div class="bg-slate-100 p-4 indent-4 font-medium font-serif">
|
||||
<div id="rich-text-content" class="line-clamp-6 leading-loose">
|
||||
<%= rich_text_content %>
|
||||
</div>
|
||||
<script>
|
||||
// Store the base font size when page loads
|
||||
let baseFontSize;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const content = document.getElementById('rich-text-content');
|
||||
baseFontSize = parseFloat(window.getComputedStyle(content).fontSize);
|
||||
updateFontSizePercentage(baseFontSize);
|
||||
});
|
||||
|
||||
function toggleRichTextContent() {
|
||||
const content = document.getElementById('rich-text-content');
|
||||
const button = document.getElementById('rich-text-toggle');
|
||||
const icon = document.getElementById('rich-text-toggle-icon');
|
||||
if (content.classList.contains('line-clamp-6')) {
|
||||
content.classList.remove('line-clamp-6');
|
||||
button.firstElementChild.textContent = 'Show Less';
|
||||
icon.style.transform = 'rotate(180deg)';
|
||||
} else {
|
||||
content.classList.add('line-clamp-6');
|
||||
button.firstElementChild.textContent = 'Show More';
|
||||
icon.style.transform = 'rotate(0deg)';
|
||||
}
|
||||
}
|
||||
|
||||
function increaseFontSize() {
|
||||
const content = document.getElementById('rich-text-content');
|
||||
const currentSize = window.getComputedStyle(content).fontSize;
|
||||
const newSize = Math.min(parseFloat(currentSize) + 2, 22); // Don't allow larger than 22px
|
||||
content.style.fontSize = `${newSize}px`;
|
||||
updateFontSizePercentage(newSize);
|
||||
}
|
||||
|
||||
function decreaseFontSize() {
|
||||
const content = document.getElementById('rich-text-content');
|
||||
const currentSize = window.getComputedStyle(content).fontSize;
|
||||
const newSize = Math.max(parseFloat(currentSize) - 2, 12); // Don't allow smaller than 12px
|
||||
content.style.fontSize = `${newSize}px`;
|
||||
updateFontSizePercentage(newSize);
|
||||
}
|
||||
|
||||
function updateFontSizePercentage(currentSize) {
|
||||
// If baseFontSize isn't set yet, set it
|
||||
if (!baseFontSize) {
|
||||
baseFontSize = currentSize;
|
||||
}
|
||||
|
||||
const percentageElement = document.getElementById('font-size-percentage');
|
||||
const percentage = Math.round((currentSize / baseFontSize) * 100);
|
||||
percentageElement.textContent = `${percentage}%`;
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-slate-100 p-4 text-slate-500 italic text-center">
|
||||
<span>Error rendering document</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</section>
|
||||
@@ -98,6 +98,10 @@
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right text-sm text-slate-500">SHA256</div>
|
||||
<div class="text-sm">
|
||||
<span class="font-mono text-slate-500"><%= HexUtil.bin2hex(@log_entry.response_sha256) %></span>
|
||||
</div>
|
||||
<div class="text-right text-sm text-slate-500">Content</div>
|
||||
<div class="text-sm">
|
||||
<span class="font-mono"><%= @log_entry.content_type %></span>
|
||||
|
||||
@@ -67,12 +67,11 @@ Rails.application.configure do
|
||||
config.log_tags = [:request_id]
|
||||
|
||||
# Use a different cache store in production.
|
||||
config.cache_store = :memory_store, { size: 64.megabytes }
|
||||
config.cache_store = :file_store, "/mnt/rails-cache"
|
||||
|
||||
# Use a real queuing backend for Active Job (and separate queues per environment).
|
||||
# config.active_job.queue_adapter = :resque
|
||||
# config.active_job.queue_name_prefix = "legacy_explorer_production"
|
||||
|
||||
config.action_mailer.perform_caching = false
|
||||
|
||||
# Ignore bad email addresses and do not raise email delivery errors.
|
||||
@@ -99,22 +98,6 @@ Rails.application.configure do
|
||||
config.logger = ActiveSupport::TaggedLogging.new(logger)
|
||||
end
|
||||
|
||||
config.log_tags = {
|
||||
ip: ->(request) do
|
||||
request.headers["HTTP_CF_CONNECTING_IP"] || request.remote_ip
|
||||
end,
|
||||
api_token: ->(request) { request.params[:api_token] || "(nil api token)" },
|
||||
user_name: ->(request) do
|
||||
api_token = request.params[:api_token]
|
||||
if api_token
|
||||
user = Domain::Fa::ApiController::API_TOKENS[api_token]
|
||||
user || "(nil user)"
|
||||
else
|
||||
"(nil api_token)"
|
||||
end
|
||||
end,
|
||||
}
|
||||
|
||||
# Do not dump schema after migrations.
|
||||
config.active_record.dump_schema_after_migration = false
|
||||
end
|
||||
|
||||
18
sorbet/rbi/shims/bbcode_to_html.rbi
Normal file
18
sorbet/rbi/shims/bbcode_to_html.rbi
Normal file
@@ -0,0 +1,18 @@
|
||||
# typed: strict
|
||||
class String
|
||||
sig do
|
||||
params(
|
||||
escape_html: T::Boolean,
|
||||
additional_tags: T::Hash[Symbol, T.untyped],
|
||||
method: Symbol,
|
||||
tags: T.untyped,
|
||||
).returns(String)
|
||||
end
|
||||
def bbcode_to_html(
|
||||
escape_html = true,
|
||||
additional_tags = {},
|
||||
method = :disable,
|
||||
*tags
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -1,26 +1,35 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe LogEntriesHelper, type: :helper do
|
||||
describe "#render_msword_content" do
|
||||
let(:log_entry) do
|
||||
describe "#render_rich_text_content" do
|
||||
def build_log_entry(content_type, fixture_path, binary: true)
|
||||
build(
|
||||
:http_log_entry,
|
||||
content_type: content_type,
|
||||
response:
|
||||
build(
|
||||
:blob_file,
|
||||
content_type: "application/msword",
|
||||
content_type: content_type,
|
||||
contents:
|
||||
File.binread(
|
||||
Rails.root.join(
|
||||
"test/fixtures/files/5447897_Thakur_trials_13.doc",
|
||||
),
|
||||
(
|
||||
if binary
|
||||
File.binread(Rails.root.join(fixture_path))
|
||||
else
|
||||
File.read(Rails.root.join(fixture_path))
|
||||
end
|
||||
),
|
||||
),
|
||||
)
|
||||
end
|
||||
|
||||
it "returns the converted HTML content" do
|
||||
rendered = helper.render_msword_content(log_entry)
|
||||
it "works with msword files" do
|
||||
log_entry =
|
||||
build_log_entry(
|
||||
"application/msword",
|
||||
"test/fixtures/files/5447897_Thakur_trials_13.doc",
|
||||
)
|
||||
|
||||
rendered = helper.render_rich_text_content(log_entry)
|
||||
expect(rendered).not_to be_nil
|
||||
# no abiword header
|
||||
expect(rendered).not_to include("Abiword HTML Document")
|
||||
@@ -35,5 +44,40 @@ RSpec.describe LogEntriesHelper, type: :helper do
|
||||
|
||||
expect(rendered).to include("The next morning")
|
||||
end
|
||||
|
||||
it "works with other doc files" do
|
||||
log_entry =
|
||||
build_log_entry(
|
||||
"application/msword",
|
||||
"test/fixtures/files/1665721884.wolfsnack.writer_doc.doc",
|
||||
)
|
||||
rendered = helper.render_rich_text_content(log_entry)
|
||||
expect(rendered).not_to be_nil
|
||||
expect(rendered).to include("Now, America")
|
||||
expect(rendered).to include("Years passed.")
|
||||
end
|
||||
|
||||
it "works with pdf files" do
|
||||
log_entry =
|
||||
build_log_entry(
|
||||
"application/pdf",
|
||||
"test/fixtures/files/1677472939.wolfsnack.writer_maze_of_the_wolf.pdf",
|
||||
)
|
||||
rendered = helper.render_rich_text_content(log_entry)
|
||||
expect(rendered).not_to be_nil
|
||||
expect(rendered).to include("The Garden")
|
||||
expect(rendered).to include("Will promised -- as best he could")
|
||||
end
|
||||
|
||||
it "parses bbcode in plain text" do
|
||||
log_entry =
|
||||
build_log_entry(
|
||||
"text/plain",
|
||||
"test/fixtures/files/1674254968.darkviolet_firedancer_text.txt",
|
||||
binary: false,
|
||||
)
|
||||
rendered = helper.render_rich_text_content(log_entry)
|
||||
expect(rendered).not_to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
BIN
test/fixtures/files/1665721884.wolfsnack.writer_doc.doc
vendored
Normal file
BIN
test/fixtures/files/1665721884.wolfsnack.writer_doc.doc
vendored
Normal file
Binary file not shown.
287
test/fixtures/files/1674254968.darkviolet_firedancer_text.txt
vendored
Normal file
287
test/fixtures/files/1674254968.darkviolet_firedancer_text.txt
vendored
Normal file
@@ -0,0 +1,287 @@
|
||||
[quote][center]Hello, and welcome to Weststate News at 7.
|
||||
|
||||
The Prairietown Arsonist has struck again. The Towerbridge Mall was discovered ablaze at a quarter past four this morning; local police were immediately called to the scene, but no trace of the arsonist were found. The local fire department has been fighting the blaze; the latest reports are that they have managed to stop the spread, but the building - the first mall of its kind in the town - could not be saved.
|
||||
|
||||
Local sheriff departments say they that the case for the Prairietown Arsonist is ongoing, but little progress has been made, and has once again asked for appeals for any information from the public...[/center][/quote]
|
||||
|
||||
|
||||
[center][b]Firedancer[/b]
|
||||
[i]A Someone’s PC Master Tier tale[/i]
|
||||
[i]By Dark Violet[/i]
|
||||
|
||||
[i]Requested by Luster Unicorn[/i][/center]
|
||||
|
||||
|
||||
The weak morning sunlight peeked through the blackened timbers, twisted and stark against the pale spring sky. Their shadows stretched like fingers across what remained of the old Weskers’ inn, dull, frozen facsimiles of the flames that had torn through it just hours earlier.
|
||||
|
||||
Brendan wiped the soot from his visor as he peered through the misshapen first-floor windows at the broken rubble inside, encrusted with snow-white ash. White smoke and steam drifted in the still air, and humid warmth radiated even through his suit.
|
||||
|
||||
“Brendan!”
|
||||
|
||||
He started from his reverie, turning around. Connie was picking her way through the dry, cracked remains of the front lawn.
|
||||
|
||||
“Brendan, hunny, you deaf? I asked if y’were okay.”
|
||||
|
||||
Brendan stepped back towards her. “Yeah, I’m fine,” he lied. He tried to cross his arms; they ached like they had leaden weights on the wrists.
|
||||
|
||||
“Sure y’are. Just like anyone after five hours fightin’ a blaze.”
|
||||
|
||||
He pulled off a glove slowly. “Five hours…?” he muttered, reaching under his visor to rub at his eyes. “Fuckin’... thought it was less than that.”
|
||||
|
||||
Connie patted his arm. “Sun don’t lie. C’mon. You need water.”
|
||||
|
||||
They picked their way over the debris of the front lawn. Their firetruck was parked askew, halfway onto the lawn, its faded red coat barely visible under decades of caked-on mud. The young Declan was already by it with Wavesurfer, the Greninja, winding the hoses back up. As Connie and Brendan walked past them, Declan was nudging one of the Squirtle, ash-smeared and half-retreated into its shell, laying on the ragged hoseline. It regarded him with an exhausted stare before rolling off.
|
||||
|
||||
Brendan and Connie reached the truck, walking to the wide-open door on the passenger side. Connie heaved a dusty plastic bottle out from under the seat, and pulled off the lid.
|
||||
|
||||
“Drink,” she ordered.
|
||||
|
||||
Brendan hesitated only a moment, before taking the bottle and swigging. The water was warm, but was still a blessing on his parched tongue.
|
||||
|
||||
“Attaboy.”
|
||||
|
||||
Brendan passed the bottle back to her, and gasped for air, dribbles of water running down his chin. He slumped back onto the ledge of the firetruck. “You know it was nineteenth century, that one?” he said, staring at the skeleton of the old building.
|
||||
|
||||
“I’d believe it. Would be concrete otherwise, and then it mighta stood a chance.” Connie sat down next to him, the water bottle perched on her lap.
|
||||
|
||||
“Y’know, that’s what really gets me?” he said, taking off his other glove and dropping the pair onto the ledge next to them. “It’s always old buildings. Antiques. He ain’t burnin’ down that local eyesore people’re calling a Mart, is he? It’s always some treasure or old family home.”
|
||||
|
||||
“‘Least no-one’s in ‘em, right?”
|
||||
|
||||
Brendan grunted. “Still. Can’t replace those things. I just wish he’d go and burn somethin’... less… irreplaceable.” He realised he was fingering his wedding ring, twisting it around his finger.
|
||||
|
||||
“Look at it this way, hunny,” Connie said, slapping his shoulder. “He keeps this up, we won’t be out of a job anytime soon.”
|
||||
|
||||
Brendan sighed and closed his eyes. “Connie, we’re fuckin’ volunteers.”
|
||||
|
||||
Connie snickered to herself, and then silence fell, save for the whispering of the morning breeze in the trees and rustling and clunking as Declan and Wavesurfer continued packing up the rest of their kit.
|
||||
|
||||
“I hear ya though, Brendan,” Connie said at last, leaning back with him. “I mean, whoever this arsonist is, he could at least give us a break. Bad enough this being the… what’re we up to now since January? Thirty seven?”
|
||||
|
||||
“Thirty eight,” Brendan said automatically. “The old bird tower over Prairietown-ways burned down on Monday.”
|
||||
|
||||
“Arceus, you’re kiddin’. Used to climb that thing when I was a kid. Those Pidgeotto get real nasty when you try and steal their eggs. Still got the scars to prove it!” She rolled up her sleeve, gleefully flashing the raised white line down her forearm.
|
||||
|
||||
Brendan glanced at her, his throat tightening.
|
||||
|
||||
Connie must have seen his expression, and rolled her sleeve back down. “Sorry. That ain’t appropriate no more, I know. I wouldn’t do that today, you know that. And I don’t mean no offence to your missus, now.”
|
||||
|
||||
Brendan nodded, but didn’t say anything. They returned to silence, looking across at the remains of the inn.
|
||||
|
||||
“Well…” Brendan muttered, looking down. “I should get back to the station, start on the paperwork…”
|
||||
|
||||
Connie waved her hand in front of him. “Brendan, you were up last night with the O’Riley’s old place, and now you’ve been called out of your bed tonight too. Now I know ya got a day off today. If anyone deserves rest, it’s you.”
|
||||
|
||||
Brendan bit his lip. “Connie, it’s okay. I-”
|
||||
|
||||
“Nah, not hearing it. Look, I’ll do the paperwork. Anyway, I gotta be at the station today. Gonna be showing Comet the ropes. Literally!” She cackled, elbowing him. “Geddit? ‘Cause ‘e’s a Sawsbuck, and he’s gonna be… no? Nothin’?”
|
||||
|
||||
Brendan waved a hand.
|
||||
|
||||
“Hey,” she said, her voice suddenly a lot lower than it had been. “Look, we’re gonna catch this sonuvabitch. PD’s got their growlithe out sniffing every road from here to the Blue Mountains and back. Psychics tryin’ ta pin down the next hit. It’s only a matter of time, y’know that.”
|
||||
|
||||
Brendan stared at the ground, his eyes unfocused. He was fiddling with his wedding ring again, and forced his hands back on his knees.
|
||||
|
||||
“Yeah, yeah I know,” he said eventually.
|
||||
|
||||
“Good on ya. Go on, get on back to the missus. I know she’ll be missin’ ya.”
|
||||
|
||||
She patted him again, and got up. She made her way over towards Declan and Wavedancer, and set about helping them with the rest of the tear-down.
|
||||
|
||||
Meanwhile, Brendan’s heart burned.
|
||||
|
||||
After a short while, he got up too, and made his way over to his truck. He packed up his suit, gloves, and helmet into a case in the truckbed, tightened the straps around it, and heaved himself into the cab. The engine grumbled into life.
|
||||
|
||||
He didn’t remember the drive home.
|
||||
|
||||
It was almost mid-morning by the time he pulled into his driveway. He cut the engine early in order to coast in, and made sure not to unpack anything just yet. He eased the front door closed behind him, and crept up the stairs, making sure to avoid the squeaky floorboards. He let out a slow breath when he got to the bedroom door.
|
||||
|
||||
He pushed open the door, and there she was.
|
||||
|
||||
The curtains were drawn, only the barest slivers of light illuminating their edges, but the hallway was sunlit from the nearby window; those rays were all that was needed to shine through the now-open doorway and land on the shining orange mane that spilled out over the covers. The comforter was tight around her, showing off the smooth curve of chest and haunches, dipping to her thin waist; the blankets rose and fell with each rolling, deep purr that was her version of snoring. Her scent, the heady, musty feline aura that seemed to permeate her, tickled his nose.
|
||||
|
||||
Others said it was just like any Pyroar’s scent; but no other Pyroar sent Brendan’s heart fluttering quite like she did.
|
||||
|
||||
For a moment, everything else faded away - the fires, the worries, all of it. And, like he’d done every morning for seven years, he wondered how he’d ever managed to get such a wonderful woman.
|
||||
|
||||
Languorously, Firedancer rolled over, and her snub muzzle poked out from behind the flowing locks of her mane. She opened a lazy, topaz eye, and let out a purring [i]rrrrroowwwlll[/i][i] [/i]of greeting.
|
||||
|
||||
Her nose twitched; the smell of ash and smoke clung to him, permeating the bedroom air. She looked him up and down, and a smile twinkled at the corner of her lips.
|
||||
|
||||
At that sight, any weight still in his chest lifted completely. He’d already started to forget how it felt as he shut the door, and climbed into bed behind his wife, sliding his arms around her and holding her tightly in his arms. His tired heart leapt as she nuzzled close to him.
|
||||
|
||||
|
||||
[center]* * *[/center]
|
||||
|
||||
|
||||
When he woke up later, Firedancer was still half-curled around him, head twisted awkwardly and long legs everywhere, with her mane tumbling over his chest. It was like all the tension had drained from Brendan’s body, like water running off his fingertips, and the only reason he didn’t chuckle in relief was tiredness. Through a sliver between the curtains, he could see an orange sky, while a sliver of golden sunset ran across the far wall.
|
||||
|
||||
Eventually, he got up, and set about making them both dinner - meatloaf with whole hunks of meat, just slightly overdone, just like she liked it. They’d relaxed on the couch while channel flipping, the light of the TV gleaming off their wedding rings. It was calm and quiet and effortless, his fingers lazily rubbing the back of her neck, her nose gently nuzzling his chest.
|
||||
|
||||
But as the hours went on, Brendan realised he was ignoring how Firedancer’s paws twitched beneath him, how even now her smile had faded since the morning. As the clock lazily ticked on into the dark hours of the evening, he noticed that her tail was starting to lazily swing back and forth. He pushed it to the back of his mind - instead, he got up, gave her a kiss on the snout, and went to wash up.
|
||||
|
||||
He was scrubbing the tray from the meatloaf when she showed up at the door, mane rolling down the side of her head.
|
||||
|
||||
Brendan continued scrubbing - or, at least, tried to, tried to keep his mind off her. But he could never keep his eyes off her for long. He took a breath, attempting to quell the rising anxiousness in his chest.
|
||||
|
||||
“Hey honey,” he said, glancing at her. “You okay?”
|
||||
|
||||
Firedancer raised her head slowly, and looked at him from down her snout.
|
||||
|
||||
The anxiety flared up fully, like a lump in his throat. He scrubbed the tray a few more times. “Hey now, hon, I’ve barely got up, and…”
|
||||
|
||||
A gentle rolling [i]rrrrrowwwwllll[/i] simmered beneath his voice. Her eyes narrowed.
|
||||
|
||||
Brendan put down the tray. “Firedancer…”
|
||||
|
||||
The purring growl faded, but left room for it to return. Her gaze never wandered from him.
|
||||
|
||||
He turned around. “Come on, hun. It’s been twice this week already. This’d be the third night in a row.”
|
||||
|
||||
She stepped into the room, giving a short grunt of acknowledgement.
|
||||
|
||||
“Honey, you gotta understand. The PD’s on the lookout now. The radio’s talkin’ of nothin’ else. This arsonist is a household name from here to Castelia.”
|
||||
|
||||
A smile pulled at her lips. She raised her eyebrows.
|
||||
|
||||
“That’s not… [i]fuck…” [/i]Brendan turned back around, putting his hands on the edge of the sink and hanging his head. After a while, he realised he could see her paws from the corner of his eye - she was watching him still. Staring at him.
|
||||
|
||||
Fuck.
|
||||
|
||||
“Firedancer, this can’t be a secret forever. It’s only a matter of time. You know that, right?”
|
||||
|
||||
Firedancer gave a short, sharp [i]rrowp[/i], followed by a low growl again.
|
||||
|
||||
How could she be so Arceus-damn cavalier about this…? Brendan sighed, rubbing his face. There were soft pawsteps behind him, and warmth rolled over his hips.
|
||||
|
||||
He turned to face her, and there she was - right in front of him, eyes half-lidded and hopeful smile on her muzzle, mane tossed along her back where it lay like an image of a flame.
|
||||
|
||||
“Fire, honey…”
|
||||
|
||||
[i]Rrrrroooowwwwll[/i][i]…[/i]
|
||||
|
||||
And then she jumped up, and her paws were on his chest, and she looked down at him from above eye-height. He leaned back, almost pressed into the sink, and her scent surrounded him like a warm embrace.
|
||||
|
||||
He rested his hands on her neck. She purred, angling her head a touch closer to him, and pleasant trembles ran through his body. He leaned in, raising his head to her lips, only for her to pull back coyly.
|
||||
|
||||
[i]Rrowp[/i][i]! [/i]A paw batting gently at his chest. A questioning flick of the ear orchestrated his cue to answer.
|
||||
|
||||
“Okay… okay.” His heart danced in flames of love. “...Anythin’ for you.”
|
||||
|
||||
|
||||
[center]* * *[/center]
|
||||
|
||||
|
||||
It was a cooler night than the rest of the week had been, and the waxing moon was little more than a thin crescent hanging just over the trees. The last of its silver light barely illuminated the backroad through the woods a half-hour south of Prairietown. Fuzzy flickers of shadowed branches trailed across the bonnet of their truck as they bumped down the gravelled path - a rarely used route, and importantly, not being watched by the PD. At least, as far as Brendan knew. He couldn’t stop his gaze constantly sweeping the bushes at the edge of the road for any gleams of eyes, or dark, unmarked cars.
|
||||
|
||||
Firedancer half-lay on the passenger side, lounging her head out of the window. Locks of orange mane rippled and tumbled in the wind, and every time Brendan looked at her, it was a fight to tear his gaze away. It was just as hard to ignore how her tail flicked lazily against his leg. But he didn’t want to ignore everything.
|
||||
|
||||
How many more times would he be able to look over at her like this?
|
||||
|
||||
He stole another glance. For a moment, the sight of her like that made him remember their first drive, all those years ago. It had been summer, and she’d been so fiery and vibrant against the green trees. She’d been introduced to him as a model, and working for herself too, rather than through some trainer - all stuff he had to be reminded of later, because at first, he saw nothing but her beauty, radiating from her like a star. He could barely stammer an introduction. She’d given a trilling chuckle as she sniffed his hand in greeting.
|
||||
|
||||
How he’d worked up the courage to ask her out on a date, he still didn’t know. A drive around the hills to the east, with the wind through her mane, a dazzling smile on her delicate muzzle, and a delirious feeling in his chest - that if he hadn’t found [i]the one[/i], then everyone else would be spoiled forever in comparison.
|
||||
|
||||
They’d been married within the year. And it had been… glorious. Everything he had done, it had been for the want of but another smile. She even seemed to enjoy his nervousness, teasing him with her particular, pleasant, familiar trills and purrs. They lived together, slept together, and she even moved into his shop so they could spend every day together.
|
||||
|
||||
But over time, the fire in that smile had become harder to light. The nuzzles every time he woke up had become softer. The passion at night had begun to slip away, lost to a deep knowledge of inadequacy, like a pit of ice in his stomach…
|
||||
|
||||
Until he’d asked what she wanted, back this past New Year’s Eve. A special place, a date just for her, anything she wanted. Please - he really would do anything, just to see that smile again, as bright and as vibrant as he remembered. Anything for his flame.
|
||||
|
||||
And her eyes, oh, how they’d gleamed…
|
||||
|
||||
[i]Rrowpp.[/i]
|
||||
|
||||
He eased off the pedals, and they began to coast to a stop. The thin moonlight caught the edge of a low wall, overgrown with bushes, while the hulking shape of a building complete with a terraced tower was black against a deep, navy sky.
|
||||
|
||||
As they pulled in, the headlights briefly illuminated a makeshift sign hastily hung from one of the gates:
|
||||
|
||||
[center][i]To the [/i][i]Prairietown Arsonist![/i]
|
||||
[i][u]PLEASE[/u][/i]
|
||||
[i]DON’T BURN[/i]
|
||||
[i]THIS DOWN![/i][/center]
|
||||
|
||||
Brendan turned the key in the ignition, plunging them into darkness. He could just make out her silhouette against the window. She was staring at the building.
|
||||
|
||||
“This is the old Victoria House,” he muttered. Already, the words were half-hearted. “You know that this was a family home at the turn of the last century? Same family that founded Prairietown.”
|
||||
|
||||
Firedancer didn’t react. Her tail tip still swayed lazily across his leg.
|
||||
|
||||
“They say the architect Samantha Victoria’s final work was on this place…”
|
||||
|
||||
Firedancer turned her head to face him. Her eyes were dark in the shadows, but they looked straight at him.
|
||||
|
||||
Brendan took in a deep breath, trying to use it to push down the anxiety rolling around his chest.
|
||||
|
||||
“Alright.”
|
||||
|
||||
He got out of the truck, and went around to the bed, opening the case strapped there. The faint moonlight caught the reflective coating of his firefighting gear. He hesitated for just a moment, then pushed it aside, and pulled out the bolt cutters, the small can of gasoline, and the pack of lighters. That moon allowed him just enough light as he broke the chain around the gate, pushed past the sign, and set off down the cracked paving stones towards the abandoned building.
|
||||
|
||||
It didn’t even take five minutes.
|
||||
|
||||
He stared at the fire crackling beneath the decking. He watched, motionless, as it caught the dribbles of gasoline, and began to curl its way up the outside beams, before spreading out under the eaves. The smell of smoke was already heavy in his nose.
|
||||
|
||||
What the night had previously cloaked, the fire now revealed in dancing light - the expertly carved timbers, the shuttered windows, the architect’s mark just above the front door. He turned away from it, not wanting to read what it said.
|
||||
|
||||
He looked back towards the truck - and in the light of the fire, he saw her.
|
||||
|
||||
Firedancer was leaning out of the driver-side window, paws over the edge, kneading at the scratched paintwork. Her wedding ring shone.
|
||||
|
||||
He walked back, and climbed back into the truck, unable to take his eyes off her for more than a second.
|
||||
|
||||
The Pyroar was lit up in brilliant orange. On her matte coat, shadows traced the slight curves of muscle beneath her fur, while her mane sparkled. She turned to face him, and there was that smile again - broad, knowing, gleeful. And there they went - his doubts, his anxieties, they all melted away beneath that smile. She lowered her head, and from beneath a quiff of that shining yellow mane that shaded half her face, she looked him over with lidded, twinkling eyes,
|
||||
|
||||
He took her head in his hands, and they nuzzled. Her purr rolled down his body, making his fingers quiver as he cupped her cheek, and they kissed. Their tongues danced, his broad and thick, hers flat and rough, each rolling around and tasting and enjoying the other.
|
||||
|
||||
The fire was roaring now, the sound of rushing air and crackling timbers flowing across them as the second storey caught alight. Their paws, their hands, roamed each other. Firedancer’s claws pressed through the fabric of his shirt, and he grabbed at her back, running his fingers over her spine, down to her waist, while her tail curled around his legs. And all the while, that purr, that vibrating purr, rolled through her body like waves of pleasure.
|
||||
|
||||
He unhooked his jeans with one hand, pushing his boxers out of the way. He broke the kiss, pressing his forehead against hers, catching brief glimpses of her smoky eyes and broad, ecstatic grin. She gave another [i]rrrraawl[/i], a happy and delighted sound, as she leaned back and flashed fangs behind her black lips. He kissed those lips, pressing his tongue between those sharp, pointed teeth, and she let him. She squirmed indulgently, even as he grabbed his shaft, rigid, ready, and pressed the tip to her small, wet, teardrop of a slit.
|
||||
|
||||
The low roar she made when he slid into her made him gasp in delight. Her lips pulled back, her whiskers dancing, fangs bared. He kissed her again, awkwardly, his mouth small compared to her wide maw, and she kissed back, letting those same fangs drag over the skin of his cheeks. He grasped behind her head with one hand, while the other held her waist, as he thrusted with wild, indulgent abandon.
|
||||
|
||||
There was a distant crunch as something fell inside the house, and light danced across the truck’s cabin. Something shattered the tinkling sound lost in the dull roar and crackles of the flames. The heat of the fire blazed through the open window, but the smell of smoke played second fiddle to Firedancer’s rolling, intoxicating scent.
|
||||
|
||||
Firedancer’s paws moved to his neck now, claws still teasing the skin, sending sharp shivers of pain through him as she tugged at the barely healed scratches from the previous night. But he didn’t care, because those paws were holding him there, and she was hungrily dragging her fangs across his face, tongue delving deep into his mouth. Her eyelashes batted his thumb as he caressed her cheek, and he angled his hips as he thrust deeper, faster.
|
||||
|
||||
Brendan leaned back, breaking the hungry kiss, and stared down at Firedancer, at his wife, at this beauty he could never live up to deserving, illuminated in the firelight. She held his neck in her front paws, fixing him with a sultry gaze, as if to hold him there with that look alone. She rolled her hips, hind legs waving on either side of him, rolling her body indulgently around his shaft. Her belly, that normal cream colour lost in the flickering orange, had every contour and muscle highlighted. It showed the supple curves, the nipples peeking through the thin fur, and the way the lines of fur and muscle and contours of her thighs all seemed to point right down to her crotch - where wet, coarse fur didn’t quite hide his shaft stretching pink, gleaming lips, or the gentle bulge just above them.
|
||||
|
||||
She was no delicate flower - no petite human woman, or some coquettish Braixen - there was a firmness, a ripple of muscles beneath her lithe form, a domineering power to her that he had tried to resist quailing before, and had not yet managed. She was incredible, incandescent, and the light of the growing inferno danced and pirouetted across her body in beautiful, bewitching patterns. Before he knew it, she was pulling him in for another passionate, feral kiss, her legs spreading further and her tail curling tightly around his waist to hold him in, begging for more with each shuddering, delighting stroke.
|
||||
|
||||
Pleasure mounted, stoked by her grabbing paws, her writhing body. Another crash from the burning house, a rush of heat over them that was nothing but a delightful reminder. Brendan ran his fingers over her face as they kissed again. He thrust into her, and she pressed back, wrapping her hind legs around his hips to pull him in with each stroke. The moment had taken him over; they gasped, muttering incomprehensible words in their own tongues, half-heard above their twin panting. He rubbed his hands up along neck, over her jaw, fingers running through her thin, velvet-soft fur. He traced the corners of her lips with his fingertips, found them curled upwards - and that’s when the pleasure finally grew to its roaring crescendo, and he gasped into her maw, meeting a sudden rolling, vibrating [i]rrrrrroooooowwwwwwllll[/i].
|
||||
|
||||
Paws and hands still roamed each other, clutched, as they both trembled in dancing, explosive ecstasy. His shaft jerked, pulsed, spurt after spurt of seed splashing deep inside her, and each one sent tingling pins and needles down his limbs that made him grab and hold onto the beauty that was his wife, and revel in how she did the same.
|
||||
|
||||
The kiss broke, and they gasped together, grabbing trembling breaths in the fiery whirlwind that surrounded them.
|
||||
|
||||
As they rolled down from the peak, it was like landing in a bed of hot ash, surrounding them in unparalleled warmth as they panted with foreheads pressed together and broad smiles on their faces.
|
||||
|
||||
In the distance, faded, almost forgotten, the fire crackled away.
|
||||
|
||||
When Brendan eventually started the engine again, the old Victoria house was already in full conflagration, with the previously dark tower now coated in a dancing layer of flames. Brendan took one final look up at it; near the top of the tower, the intricate wooden carvings of a coat of arms were half-lost behind the inferno, before a window exploded outwards, and it disappeared behind a gout of flame. Firedancer pressed up against his side, staring at it with wide, dark eyes that reflected every last burning timber.
|
||||
|
||||
Brendan eased the accelerator down and pulled back into the road. He shifted gears, and the light of the fire gleamed in his rear-view mirror as he drove away. The old Victoria house was pretty remote; the flames might not even be seen for another hour or so. Enough time to get back home, before the department called him onto the scene. Or at least, he hoped so.
|
||||
|
||||
Firedancer rested her head against his as the last flickering rays of the fire danced across them through the trees. He sighed happily at the very feel of her against him.
|
||||
|
||||
This couldn’t last forever. He knew that.
|
||||
|
||||
Eventually, every fire burns out.
|
||||
|
||||
But for now…
|
||||
|
||||
…For now.
|
||||
|
||||
|
||||
|
||||
|
||||
[center][i]The End[/i][/center]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[i]This story was inspired by the reporting of Phoebe Judge and Lauren Spohrer in the Podcast ‘Criminal’, Episode #166 (‘On Fire’)[/i]
|
||||
BIN
test/fixtures/files/1677472939.wolfsnack.writer_maze_of_the_wolf.pdf
vendored
Normal file
BIN
test/fixtures/files/1677472939.wolfsnack.writer_maze_of_the_wolf.pdf
vendored
Normal file
Binary file not shown.
Reference in New Issue
Block a user