Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ gem "rouge", "~> 5.0"
# Converts a paste's HTML into Markdown for the /p/<token>/markdown endpoint.
gem "reverse_markdown", "~> 3.0"

# GitHub-flavored Markdown -> HTML for markdown uploads (cmark-gfm/comrak).
gem "commonmarker", "~> 2.0"

gem "meta-tags", "~> 2.23"

# Trust Cloudflare's IP ranges so remote_ip (and the per-IP rate limits) see
Expand Down
13 changes: 13 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ GEM
activesupport (>= 7.2.0, < 8.2.0)
railties (>= 7.2.0, < 8.2.0)
zeitwerk (>= 2.5.0)
commonmarker (2.8.2-aarch64-linux)
commonmarker (2.8.2-aarch64-linux-musl)
commonmarker (2.8.2-arm-linux)
commonmarker (2.8.2-arm64-darwin)
commonmarker (2.8.2-x86_64-linux)
commonmarker (2.8.2-x86_64-linux-musl)
concurrent-ruby (1.3.7)
connection_pool (3.0.2)
crass (1.0.7)
Expand Down Expand Up @@ -383,6 +389,7 @@ DEPENDENCIES
bundler-audit
capybara
cloudflare-rails (~> 7.0)
commonmarker (~> 2.0)
cssbundling-rails
debug
jsbundling-rails
Expand Down Expand Up @@ -429,6 +436,12 @@ CHECKSUMS
bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9
capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef
cloudflare-rails (7.0.0) sha256=9049b8305eab120e2d4ed9d04b42ce7030da5297fb62b0fa718b0a9e2d9af56c
commonmarker (2.8.2-aarch64-linux) sha256=715a5df28e5c1ec7b520e6441466062e9717b82193d2a5bd11a9c94f2f1e15f0
commonmarker (2.8.2-aarch64-linux-musl) sha256=c7d587bd7978416978e84d0de7488906b8f29b3f67ce8424942f0ca9aa6899db
commonmarker (2.8.2-arm-linux) sha256=da9da5c08d5133ad429ece13f2e93d3a30e1b23621ffac9040e185d7ea29bde6
commonmarker (2.8.2-arm64-darwin) sha256=cd7364f1fb9735d60218e4af0a4afbbeedbc465eb81cccca56f29a4efe2b5013
commonmarker (2.8.2-x86_64-linux) sha256=42cfb6532b15dd5821ce99e47397923255f32dcfc81024ef4c17e01e66ac7d75
commonmarker (2.8.2-x86_64-linux-musl) sha256=66568dce1c6f265e85d57ea7f749a148446527c7dd599c6ded4cdc819997c43e
concurrent-ruby (1.3.7) sha256=4412caec3a5ea2e5fdc52076724c071a81f2c0593d83b2ac8cbb8ca63b3151b0
connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295
Expand Down
13 changes: 12 additions & 1 deletion app/controllers/api/pastes_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ class PastesController < ActionController::API
end

def create
paste = Paste.new(content: submitted_content, original_filename: submitted_filename || "untitled.html")
filename = submitted_filename || default_filename
paste = Paste.new(content: Paste.render_content(submitted_content, filename), original_filename: filename)

if paste.save
render json: payload(paste).merge(update_token: paste.update_token), status: :created
Expand Down Expand Up @@ -55,6 +56,16 @@ def submitted_filename
upload ? upload.original_filename : params[:filename].presence
end

# A raw body sent as text/markdown gets a .md default so it's rendered
# (multipart uploads carry their own filename via `submitted_filename`).
def default_filename
markdown_request? ? "untitled.md" : "untitled.html"
end

def markdown_request?
request.media_type.to_s.match?(%r{\Atext/(x-)?markdown\z})
end

def raw_content
request.raw_post.to_s.byteslice(0, Paste::MAX_CONTENT_BYTES + 1).force_encoding(Encoding::UTF_8).scrub
end
Expand Down
2 changes: 1 addition & 1 deletion app/javascript/controllers/dropzone_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export default class extends Controller {
}

problemWith(file) {
if (!/\.html?$/i.test(file.name)) return this.notHtmlFileValue
if (!/\.(html?|md|markdown)$/i.test(file.name)) return this.notHtmlFileValue
if (file.size === 0) return this.emptyValue
if (file.size > MAX_BYTES) return this.tooLargeValue
return null
Expand Down
149 changes: 149 additions & 0 deletions app/models/markdown_document.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# Renders GitHub-flavored Markdown into a self-contained HTML page dressed in
# the pastehtml.dev brand -- the server-side counterpart to the client-side
# mark-it-down guide. Because we render here (not in the browser), the stored
# paste is real HTML: /raw, /render, the live origin, /markdown read-back, the
# source view, and <title> extraction all keep working unchanged.
#
# Code fences are highlighted with Rouge (the same highlighter the source view
# uses) so nothing is fetched at view time. Mermaid is the exception: its
# library is ~2.8 MB -- far past the 2 MB paste limit -- so it can never be
# inlined. Mermaid fences are left as <pre class="mermaid"> and upgraded in the
# browser by a pinned CDN module, injected ONLY when the document actually
# contains a diagram.
class MarkdownDocument
# GitHub's flavor: tables, ~~strikethrough~~, the raw-HTML tag filter,
# autolinks, task lists, and footnotes.
EXTENSIONS = {
table: true, strikethrough: true, tagfilter: true,
autolink: true, tasklist: true, footnotes: true
}.freeze

# Pinned so a stored paste renders identically forever. The ESM build boots
# itself via startOnLoad and upgrades every <pre class="mermaid">.
MERMAID_MODULE = "https://cdn.jsdelivr.net/npm/mermaid@11.4.1/dist/mermaid.esm.min.mjs".freeze

# The brand fonts the .md-body styles reference (Bangers for headings, Inter
# for body, IBM Plex Mono for code, Lalezar/IBM Plex Sans Arabic for RTL).
FONTS_HREF = "https://fonts.googleapis.com/css2?family=Bangers&family=Inter:wght@400;600;700&family=IBM+Plex+Mono:wght@400;500&family=Lalezar&family=IBM+Plex+Sans+Arabic:wght@400;600;700&display=swap".freeze

FORMATTER = Rouge::Formatters::HTML.new

# github.dark tokens read cleanly on the brand's ink code background; the
# classed spans Rouge emits pair with this stylesheet, rendered once at boot.
HIGHLIGHT_CSS = Rouge::Theme.find("github.dark").render(scope: ".md-body .highlight").freeze

# The brand chrome, lifted verbatim from the mark-it-down template so a
# server-rendered page is visually identical to one built by the guide.
BRAND_CSS = <<~CSS.freeze
:root { --ink:#18120e; --paper:#fff8ec; --red:#e62429; --yellow:#ffd400; --blue:#0072ce; }
* { margin:0; box-sizing:border-box; }
body { background-color:var(--paper);
background-image:radial-gradient(circle, rgb(24 18 14 / 0.07) 1px, transparent 1.4px);
background-size:12px 12px; color:var(--ink);
font-family:Inter,"IBM Plex Sans Arabic",system-ui,sans-serif; -webkit-font-smoothing:antialiased; }
[dir="rtl"] * { letter-spacing:normal; }
.md-body { max-width: 50rem; margin: 0 auto; padding: 2.5rem 1.25rem 4rem; color: var(--ink); line-height: 1.65; }
.md-body > :first-child { margin-top: 0; }
.md-body h1, .md-body h2, .md-body h3, .md-body h4 { font-family: Bangers, Lalezar, Impact, sans-serif; letter-spacing: 0.02em; line-height: 1.2; margin: 1.8rem 0 0.7rem; }
.md-body h1 { font-size: clamp(2.2rem,6vw,3rem); text-shadow: 2px 2px 0 var(--red); }
.md-body h2 { font-size: 1.8rem; border-bottom: 3px solid var(--ink); padding-bottom: 0.2rem; }
.md-body h3 { font-size: 1.4rem; } .md-body h4 { font-size: 1.15rem; }
.md-body p { margin: 0.85rem 0; }
.md-body a { color: var(--blue); text-decoration: underline; text-underline-offset: 2px; }
.md-body ul, .md-body ol { margin: 0.85rem 0; padding-inline-start: 1.7rem; }
.md-body li { margin: 0.3rem 0; }
.md-body code { font-family: "IBM Plex Mono", "IBM Plex Sans Arabic", monospace; font-size: 0.85em; background: rgb(24 18 14 / 0.08); border-radius: 4px; padding: 0.08rem 0.34rem; unicode-bidi: isolate; }
.md-body pre { direction: ltr; text-align: left; background: var(--ink); border: 3px solid var(--ink); border-radius: 12px; box-shadow: 5px 5px 0 0 var(--red); padding: 1rem 1.1rem; overflow-x: auto; margin: 1.1rem 0; }
.md-body pre code { background: none; padding: 0; font-size: 0.85rem; line-height: 1.55; }
.md-body blockquote { border-inline-start: 6px solid var(--yellow); background: #fff7d6; margin: 1.1rem 0; padding: 0.6rem 1rem; border-radius: 8px; border-start-start-radius: 0; border-end-start-radius: 0; }
.md-body blockquote > :first-child { margin-top: 0; } .md-body blockquote > :last-child { margin-bottom: 0; }
.md-body table { border-collapse: collapse; margin: 1.1rem 0; width: 100%; display: block; overflow-x: auto; }
.md-body th, .md-body td { border: 2px solid var(--ink); padding: 0.45rem 0.7rem; text-align: start; }
.md-body th { background: var(--yellow); font-family: "IBM Plex Mono", "IBM Plex Sans Arabic", monospace; font-size: 0.9rem; }
.md-body img { max-width: 100%; height: auto; border: 3px solid var(--ink); border-radius: 8px; }
.md-body hr { border: none; border-top: 3px dashed rgb(24 18 14 / 0.3); margin: 1.8rem 0; }
.md-body input[type="checkbox"] { width: 1rem; height: 1rem; vertical-align: middle; margin-inline-end: 0.3rem; }
.md-body pre.mermaid { background: none; border: none; box-shadow: none; padding: 0; text-align: center; }
CSS

def initialize(markdown, filename: nil)
@markdown = markdown.to_s
@filename = filename.to_s
end

# The full HTML document. Safe to store and serve verbatim as a paste.
def to_html
body = render_body
<<~HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>#{CGI.escapeHTML(document_title)}</title>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="#{FONTS_HREF}">
<style>
#{BRAND_CSS}#{HIGHLIGHT_CSS}
.md-body pre code.highlight { background: transparent; padding: 0; }
</style>
</head>
<body>
<main class="md-body" dir="auto">
#{body}
</main>
#{mermaid_script}
</body>
</html>
HTML
end

private
# Convert the Markdown, then post-process every code fence: Mermaid fences
# become diagram containers, all others are highlighted with Rouge. Raw HTML
# in the source is dropped (unsafe: false) -- the plain HTML upload path
# already exists for anyone who wants arbitrary markup.
def render_body
html = Commonmarker.to_html(@markdown,
options: { extension: EXTENSIONS, render: { unsafe: false } },
plugins: { syntax_highlighter: nil })

fragment = Nokogiri::HTML5.fragment(html)
fragment.css("pre").each { |pre| transform_code_block(pre) }
@heading = fragment.at_css("h1")&.text&.strip
fragment.to_html
end

def transform_code_block(pre)
code = pre.at_css("code")
return unless code

language = pre["lang"].to_s
source = code.text
pre.remove_attribute("lang")

if language == "mermaid"
@has_mermaid = true
pre["class"] = "mermaid"
pre.content = source # replaces <code>; Mermaid reads the element's text
else
lexer = Rouge::Lexer.find(language) || Rouge::Lexers::PlainText
code["class"] = "highlight"
code.inner_html = FORMATTER.format(lexer.lex(source))
end
end

def mermaid_script
return "" unless @has_mermaid

%(<script type="module">) +
%(import mermaid from "#{MERMAID_MODULE}";mermaid.initialize({ startOnLoad: true });) +
%(</script>)
end

# The document's own H1 names it; fall back to the filename (sans extension)
# so the paste still gets a meaningful <title> -- and title.
def document_title
@heading.presence || File.basename(@filename, ".*").presence || "Untitled"
end
end
28 changes: 23 additions & 5 deletions app/models/paste.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ class Paste < ApplicationRecord
TOKEN_ALPHABET = [ *"a".."z", *"0".."9" ].freeze
MAX_CONTENT_BYTES = 2.megabytes
HTML_EXTENSION = /\A\.html?\z/i
# Markdown uploads are rendered to a branded HTML page at ingest, so the
# stored paste is still HTML -- only the accepted filename widens.
MARKDOWN_EXTENSION = /\A\.(md|markdown)\z/i
TITLE_TAG = %r{<title[^>]*>(.*?)</title>}im
MAX_TITLE_LENGTH = 120

Expand All @@ -15,7 +18,7 @@ class Paste < ApplicationRecord

validates :content, presence: true
validates :original_filename, presence: true
validate :original_filename_must_be_html
validate :original_filename_must_be_supported
validate :content_must_fit_size_limit

before_create :assign_token, :assign_update_token
Expand All @@ -27,13 +30,26 @@ class Paste < ApplicationRecord

class << self
def from_upload(upload)
new(content: read_upload(upload), original_filename: upload.original_filename)
filename = upload.original_filename
new(content: render_content(read_upload(upload), filename), original_filename: filename)
end

def read_upload(upload)
upload.read(MAX_CONTENT_BYTES + 1).to_s.force_encoding(Encoding::UTF_8).scrub
end

# Markdown ingests are rendered to a branded HTML page; every other upload
# is stored as-is. Keyed on the filename so both create and republish agree.
def render_content(content, original_filename)
return content unless markdown_filename?(original_filename)

MarkdownDocument.new(content, filename: original_filename).to_html
end

def markdown_filename?(filename)
File.extname(filename.to_s).match?(MARKDOWN_EXTENSION)
end

def digest_update_token(token)
OpenSSL::Digest::SHA256.hexdigest(token)
end
Expand All @@ -53,8 +69,8 @@ def updatable_with?(candidate)
end

def republish(content:, original_filename: nil)
self.content = content
self.original_filename = original_filename if original_filename.present?
self.content = self.class.render_content(content, self.original_filename)
save
end

Expand Down Expand Up @@ -94,9 +110,11 @@ def extract_title
end
end

def original_filename_must_be_html
def original_filename_must_be_supported
return if original_filename.blank?
return if File.extname(original_filename).match?(HTML_EXTENSION)

extension = File.extname(original_filename)
return if extension.match?(HTML_EXTENSION) || extension.match?(MARKDOWN_EXTENSION)

errors.add(:original_filename, :not_html)
end
Expand Down
4 changes: 2 additions & 2 deletions app/views/pastes/new.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
browse: tag.span(t("home.dropzone.browse"), class: "font-semibold text-hero-blue underline decoration-2 underline-offset-4"),
paste: tag.kbd("⌘V", class: "rounded border border-ink/30 bg-paper px-1 font-mono text-xs", dir: "ltr")) %></span>
<span class="font-mono text-xs text-ink/50"><%= t("home.dropzone.constraints_html",
html: tag.bdi(".html", dir: "ltr"), htm: tag.bdi(".htm", dir: "ltr"), size: tag.bdi("2 MB", dir: "ltr")) %></span>
html: tag.bdi(".html", dir: "ltr"), htm: tag.bdi(".htm", dir: "ltr"), md: tag.bdi(".md", dir: "ltr"), size: tag.bdi("2 MB", dir: "ltr")) %></span>
</span>

<span class="flex flex-col items-center gap-4 not-group-data-busy:hidden">
Expand All @@ -77,7 +77,7 @@
</button>
<p class="t-error-msg mt-4 text-center text-base font-semibold text-hero-red sm:text-sm" data-dropzone-target="error" aria-live="polite"><%= flash[:alert] %></p>
</div>
<%= form.file_field :file, accept: ".html,.htm,text/html", class: "sr-only", tabindex: -1,
<%= form.file_field :file, accept: ".html,.htm,.md,.markdown,text/html,text/markdown", class: "sr-only", tabindex: -1,
aria: { label: t("home.dropzone.choose_file") },
data: { dropzone_target: "input", action: "change->dropzone#fileSelected" } %>
<% end %>
Expand Down
6 changes: 3 additions & 3 deletions config/locales/ar.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ ar:
heading: أفلِت ملف HTML هنا!
browse: تصفّح ملفاتك
browse_html: أو %{browse} · أو الصق HTML مباشرةً (%{paste})
constraints_html: "%{html} أو %{htm} · حتى %{size}"
constraints_html: "%{html} أو %{htm} أو %{md} · حتى %{size}"
publishing: جارٍ نشر صفحتك…
publishing_hint: لن يستغرق الأمر سوى لحظة.
choose_file: اختر ملف HTML
Expand Down Expand Up @@ -76,7 +76,7 @@ ar:
iframe_title: معاينة %{filename}
isolation_html: 'تعمل كل صفحة في بيئة معزولة خاصة بها: تعمل بداخلها النصوص البرمجية والتخزين المحلي بشكل كامل، لكنها لا تستطيع الوصول إلى هذا الموقع أو إلى الصفحات الأخرى مطلقًا. اضغط %{fullscreen_key} لملء الشاشة، أو %{exit_key} للخروج.'
dropzone_errors:
not_html_file: لا يبدو هذا ملف HTML. اختر ملف .html أو .htm.
not_html_file: لا يبدو هذا ملف HTML أو Markdown. اختر ملف .html أو .htm أو .md.
empty: هذا الملف فارغ.
too_large: حجم هذا الملف أكبر من 2 MB.
not_html_clipboard: لا يبدو محتوى الحافظة بصيغة HTML. يجب أن يبدأ بوسم.
Expand Down Expand Up @@ -123,7 +123,7 @@ ar:
paste:
attributes:
original_filename:
not_html: يجب أن يكون ملف .html أو .htm
not_html: يجب أن يكون ملف .html أو .htm أو .md أو .markdown
content:
blank: لا يمكن أن يكون فارغًا
too_large: كبير جدًا (الحد الأقصى 2 MB)
6 changes: 3 additions & 3 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ en:
heading: "Drop your HTML file here!"
browse: "browse your files"
browse_html: "or %{browse} · or just paste HTML (%{paste})"
constraints_html: "%{html} or %{htm} · up to %{size}"
constraints_html: "%{html}, %{htm} or %{md} · up to %{size}"
publishing: "Publishing your page…"
publishing_hint: "This only takes a moment."
choose_file: "Choose an HTML file"
Expand Down Expand Up @@ -79,7 +79,7 @@ en:
isolation_html: "Every page runs on its own isolated origin: scripts and local storage work, but they can never touch this site or other pastes. Press %{fullscreen_key} for fullscreen, %{exit_key} to leave."

dropzone_errors:
not_html_file: "That doesn't look like an HTML file. Choose a .html or .htm file."
not_html_file: "That doesn't look like an HTML or Markdown file. Choose a .html, .htm, or .md file."
empty: "That file is empty."
too_large: "That file is larger than 2 MB."
not_html_clipboard: "The clipboard doesn't look like HTML. It should start with a tag."
Expand Down Expand Up @@ -130,7 +130,7 @@ en:
paste:
attributes:
original_filename:
not_html: "must be an .html or .htm file"
not_html: "must be an .html, .htm, .md, or .markdown file"
content:
blank: "can't be blank"
too_large: "is too large (maximum is 2 MB)"
Loading
Loading