archsight 0.4.2 → 0.5.0

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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/Dockerfile +4 -0
  3. data/docs/configuration.md +5 -0
  4. data/docs/diagram.md +69 -0
  5. data/docs/index.md.erb +1 -0
  6. data/docs/modeling.md +3 -1
  7. data/docs/pdf-export.md +118 -0
  8. data/lib/archsight/analysis/typst_renderer.rb +25 -78
  9. data/lib/archsight/cli.rb +39 -1
  10. data/lib/archsight/database.rb +4 -3
  11. data/lib/archsight/diagram/cli.rb +11 -1
  12. data/lib/archsight/diagram/errors.rb +1 -0
  13. data/lib/archsight/diagram/graph/attributes.rb +3 -1
  14. data/lib/archsight/diagram/graph/node.rb +18 -1
  15. data/lib/archsight/diagram/includes.rb +168 -0
  16. data/lib/archsight/diagram/layout/expander.rb +75 -3
  17. data/lib/archsight/diagram/layout/rank_arranger.rb +3 -1
  18. data/lib/archsight/diagram/parser/ast.rb +5 -0
  19. data/lib/archsight/diagram/parser/table_layout.rb +2 -0
  20. data/lib/archsight/diagram/parser.rb +58 -16
  21. data/lib/archsight/diagram.rb +20 -4
  22. data/lib/archsight/export/confluence/storage.rb +6 -1
  23. data/lib/archsight/helpers/diagram_blocks.rb +34 -19
  24. data/lib/archsight/helpers/diagram_includes.rb +67 -0
  25. data/lib/archsight/helpers.rb +1 -0
  26. data/lib/archsight/linter.rb +10 -2
  27. data/lib/archsight/markdown.rb +17 -1
  28. data/lib/archsight/pdf/cache.rb +41 -0
  29. data/lib/archsight/pdf/compiler.rb +69 -0
  30. data/lib/archsight/pdf/dependency_graph.rb +127 -0
  31. data/lib/archsight/pdf/document.rb +227 -0
  32. data/lib/archsight/pdf/embeds.rb +134 -0
  33. data/lib/archsight/pdf/images.rb +94 -0
  34. data/lib/archsight/pdf/markdown.rb +217 -0
  35. data/lib/archsight/pdf/remote_image.rb +122 -0
  36. data/lib/archsight/pdf/resource_sheet.rb +129 -0
  37. data/lib/archsight/pdf/sample_document.rb +67 -0
  38. data/lib/archsight/pdf/tables.rb +72 -0
  39. data/lib/archsight/pdf/theme.rb +125 -0
  40. data/lib/archsight/pdf/typst/archsight.typ +152 -0
  41. data/lib/archsight/pdf/typst.rb +45 -0
  42. data/lib/archsight/pdf.rb +30 -0
  43. data/lib/archsight/references.rb +4 -3
  44. data/lib/archsight/resources/application_component.rb +10 -0
  45. data/lib/archsight/resources/application_service.rb +9 -0
  46. data/lib/archsight/resources/config.rb +147 -0
  47. data/lib/archsight/resources/technology_service.rb +9 -0
  48. data/lib/archsight/version.rb +1 -1
  49. data/lib/archsight/web/api/json_helpers.rb +3 -2
  50. data/lib/archsight/web/api/openapi/spec.yaml +239 -2
  51. data/lib/archsight/web/api/page_helpers.rb +1 -1
  52. data/lib/archsight/web/api/pdf_helpers.rb +46 -0
  53. data/lib/archsight/web/api/routes.rb +34 -3
  54. data/lib/archsight/web/application.rb +2 -1
  55. data/lib/archsight/web/public/vue/DocPage-D3QtnrmO.js +1 -0
  56. data/lib/archsight/web/public/vue/EditorPage-lZyG6dVt.js +35 -0
  57. data/lib/archsight/web/public/vue/ErrorPage-BhdYGZJk.js +2 -0
  58. data/lib/archsight/web/public/vue/GraphView-CoqAjFdR.js +1 -0
  59. data/lib/archsight/web/public/vue/HomePage-B7SaXVeb.js +2 -0
  60. data/lib/archsight/web/public/vue/{InstanceRouter-Ca_C8izS.css → InstanceRouter-BCKbW-Wp.css} +1 -1
  61. data/lib/archsight/web/public/vue/InstanceRouter-DJJn9fhP.js +1 -0
  62. data/lib/archsight/web/public/vue/KindList-UqMajqbf.js +1 -0
  63. data/lib/archsight/web/public/vue/PageView-CirIXjW6.js +1 -0
  64. data/lib/archsight/web/public/vue/QueryError-BIJH6gQC.js +1 -0
  65. data/lib/archsight/web/public/vue/ResourceList-761vG1Jw.js +2 -0
  66. data/lib/archsight/web/public/vue/SearchResults-BPHa6yhK.js +1 -0
  67. data/lib/archsight/web/public/vue/WikiPage-Bi7dzY2h.js +3 -0
  68. data/lib/archsight/web/public/vue/{WikiPage-TpmM1VAF.css → WikiPage-CuUi4C8v.css} +1 -1
  69. data/lib/archsight/web/public/vue/{index-yiZbbvAs.js → index-BF4zUlWZ.js} +2 -2
  70. data/lib/archsight/web/public/vue/index-BO59Fbpf.css +1 -0
  71. data/lib/archsight/web/public/vue.html +2 -2
  72. metadata +37 -17
  73. data/lib/archsight/web/public/vue/DocPage-C8Ysway-.js +0 -1
  74. data/lib/archsight/web/public/vue/EditorPage-pr2MR6_b.js +0 -35
  75. data/lib/archsight/web/public/vue/ErrorPage-FAkpW-hn.js +0 -2
  76. data/lib/archsight/web/public/vue/GraphView-liQJzFyo.js +0 -1
  77. data/lib/archsight/web/public/vue/HomePage-DYpzSXG8.js +0 -2
  78. data/lib/archsight/web/public/vue/InstanceRouter-BdkBTmGO.js +0 -1
  79. data/lib/archsight/web/public/vue/KindList-CrgI4qDb.js +0 -1
  80. data/lib/archsight/web/public/vue/PageView-BIxT_t49.js +0 -1
  81. data/lib/archsight/web/public/vue/QueryError-ChdyV8B1.js +0 -1
  82. data/lib/archsight/web/public/vue/ResourceList-DdtaHY4k.js +0 -2
  83. data/lib/archsight/web/public/vue/SearchResults-BYRAcK_o.js +0 -1
  84. data/lib/archsight/web/public/vue/WikiPage-CgvRN6R2.js +0 -3
  85. data/lib/archsight/web/public/vue/index-BLSE5DSv.css +0 -1
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require_relative "typst"
5
+ require_relative "markdown"
6
+ require_relative "tables"
7
+ require_relative "../view_table"
8
+ require_relative "../requirements"
9
+ require_relative "../user_config"
10
+ require_relative "../helpers/embeds"
11
+ require_relative "../helpers/view_blocks"
12
+ require_relative "../helpers/requirements_blocks"
13
+ require_relative "../helpers/resource_resolver"
14
+ require_relative "../query"
15
+
16
+ module Archsight
17
+ module Pdf
18
+ # The live content of a page, as the data of the moment of the export (as the Confluence export does it): a View
19
+ # (`![[View/Name]]` or a ```view block) and the requirements of some resources as tables, an Analysis as its result,
20
+ # any other resource as its description. Nothing here raises: what cannot be shown is a note in the document, so
21
+ # that nothing disappears silently.
22
+ class Embeds
23
+ MAX_ANALYSES = 5 # per document, an analysis may run for as long as its own timeout
24
+ ANALYSIS_TTL = 60 # seconds a result is reused (the PDF endpoint builds the document for every request)
25
+ ANALYSIS_ENTRIES = 32
26
+ ANALYSIS_HEADING_OFFSET = 2
27
+
28
+ @results = {}
29
+ @lock = Mutex.new
30
+
31
+ class << self
32
+ # The result of an analysis, from a run of the last minute if there is one
33
+ def result(database, analysis, now: Time.now)
34
+ key = [analysis.name, Digest::SHA256.hexdigest(analysis.annotations.to_a.sort.inspect)]
35
+ entry = @lock.synchronize { @results[key] }
36
+ return entry[:result] if entry && now - entry[:at] < ANALYSIS_TTL
37
+
38
+ require_relative "../analysis"
39
+ result = Archsight::Analysis::Executor.new(database).execute(analysis)
40
+ @lock.synchronize do
41
+ @results[key] = { result: result, at: now }
42
+ @results.shift while @results.size > ANALYSIS_ENTRIES
43
+ end
44
+ result
45
+ end
46
+ end
47
+
48
+ # @param markdown [#call] `(Boolean inline, String) -> Typst`: renders markdown the way the document does
49
+ def initialize(database, markdown:, max_analyses: nil)
50
+ @database = database
51
+ @markdown = markdown
52
+ @max_analyses = (max_analyses || UserConfig.setting("pdf", "max_analyses") || MAX_ANALYSES).to_i
53
+ @analyses = 0
54
+ @resolver = Helpers::ResourceResolver.new(database)
55
+ end
56
+
57
+ def view_block(source)
58
+ table(ViewTable.from_block(@database, source))
59
+ rescue Helpers::ViewBlocks::Error, Archsight::Query::QueryError => e
60
+ problem("view block: #{e.message}")
61
+ end
62
+
63
+ def requirements_block(source)
64
+ table(Requirements.table(@database, Helpers::RequirementsBlocks.parse(source)), empty: "No requirements")
65
+ rescue Helpers::RequirementsBlocks::Error, Archsight::Query::QueryError => e
66
+ problem("requirements block: #{e.message}")
67
+ end
68
+
69
+ # @param reference [String] `Kind/Name` of `![[Kind/Name]]`
70
+ def embed(reference)
71
+ found = @resolver.find(reference)
72
+ return problem("#{reference}: not found") if found == :missing
73
+ return problem("#{reference}: ambiguous, write Kind/Name") if found == :ambiguous
74
+
75
+ kind, name = found
76
+ instance = @database.instance_by_kind(kind, name)
77
+ case kind
78
+ when "View" then view(instance)
79
+ when "Analysis" then analysis(instance)
80
+ else description(kind, instance, reference)
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ def table(table, empty: "No results")
87
+ Tables.typst(table, markdown: ->(text) { @markdown.call(true, text) }, empty: empty)
88
+ end
89
+
90
+ def problem(message)
91
+ Typst.note(message, fill: "#fde8e8")
92
+ end
93
+
94
+ def view(instance)
95
+ table(ViewTable.from_view(@database, instance))
96
+ rescue Helpers::ViewBlocks::Error, Archsight::Query::QueryError => e
97
+ problem("view #{instance.name}: #{e.message}")
98
+ end
99
+
100
+ # A page or menu is not content, a resource without description has nothing to show: its name stands there
101
+ def description(kind, instance, reference)
102
+ text = instance.annotations["architecture/description"].to_s.strip
103
+ return Typst.str(reference) unless Helpers::Embeds.embeddable_kind?(kind) && !text.empty?
104
+
105
+ @markdown.call(false, text)
106
+ end
107
+
108
+ def analysis(instance)
109
+ return problem("Analysis #{instance.name} not run: at most #{@max_analyses} analyses per document") if @analyses >= @max_analyses
110
+
111
+ @analyses += 1
112
+ result = self.class.result(@database, instance)
113
+ renderer = Archsight::Analysis::TypstRenderer.new([result], full: true)
114
+ parts = ["#strong[#{Typst.str(instance.name)}]"]
115
+ parts << renderer.error(result) if result.failed?
116
+ parts.concat(result.sections.filter_map { |section| analysis_section(renderer, section) })
117
+ parts.join("\n\n")
118
+ end
119
+
120
+ def analysis_section(renderer, section)
121
+ case section[:type]
122
+ when :table then table(analysis_table(section))
123
+ when :text then @markdown.call(false, section[:content].to_s)
124
+ else renderer.section(section, heading_offset: ANALYSIS_HEADING_OFFSET)
125
+ end
126
+ end
127
+
128
+ def analysis_table(section)
129
+ rows = section[:rows].map { |row| section[:headers].each_index.map { |i| ViewTable::Cell.new(text: row[i].to_s, as: :markdown) } }
130
+ ViewTable::Table.new(title: "", columns: section[:headers], rows: rows, total: rows.size)
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "typst"
5
+ require_relative "remote_image"
6
+ require_relative "../assets"
7
+ require_relative "../diagram"
8
+ require_relative "../helpers/diagram_blocks"
9
+
10
+ module Archsight
11
+ module Pdf
12
+ # The pictures of a document: images next to the markdown and `asd` diagrams, copied (or converted) into the
13
+ # directory typst compiles in. Only what Archsight::Assets serves is ever read.
14
+ class Images
15
+ RASTER = %w[.png .jpg .jpeg .gif].freeze
16
+
17
+ # @return [Hash{String => String}] staged file name => content
18
+ attr_reader :files
19
+
20
+ # @param base_dir [String, nil] directory of the markdown file, relative to the resources directory
21
+ # @param pdf_images [Boolean] whether typst can place PDF files; else vector graphics stay SVG
22
+ def initialize(base_dir:, resources_dir:, resolver: nil, includes: nil, pdf_images: true, remote: RemoteImage.method(:fetch))
23
+ @base_dir = base_dir.to_s
24
+ @resources_dir = resources_dir
25
+ @resolver = resolver
26
+ @includes = includes
27
+ @pdf_images = pdf_images
28
+ @remote = remote && RemoteImage.enabled? ? remote : nil
29
+ @files = {}
30
+ end
31
+
32
+ # Hook for Markdown: Typst markup of the image a markdown reference names, nil if it cannot be shown
33
+ def image(src, _alt = nil)
34
+ return remote(src) if Assets.external?(src)
35
+
36
+ path = Assets.resolve_reference(src, @base_dir)
37
+ file = path && Assets.file_for(path, resources_dir: @resources_dir)
38
+ return nil unless file && File.size(file) <= Assets::MAX_BYTES
39
+
40
+ case File.extname(file).downcase
41
+ when *RASTER then place(File.binread(file), File.extname(file).downcase)
42
+ when ".svg" then vector(File.read(file, encoding: "UTF-8"))
43
+ when ".asd" then diagram(File.read(file, encoding: "UTF-8"))
44
+ when Assets::DRAWIO then Typst.note("draw.io diagram, shown in Archsight", fill: "#f2f2f2")
45
+ end
46
+ end
47
+
48
+ # Hook for Markdown: Typst markup of an `asd` diagram, or a note and the source if it does not render
49
+ def diagram(source)
50
+ vector(Helpers::DiagramBlocks.standalone_svg(source, resolver: @resolver, includes: @includes))
51
+ rescue Archsight::Diagram::Error => e
52
+ note = Typst.note("Diagram could not be rendered: #{e.message}", fill: "#fde8e8")
53
+ [note, Typst.raw(source, "asd")].join("\n\n")
54
+ end
55
+
56
+ # Vector graphics are placed as PDF (rsvg-convert paints text outlines correctly, typst's own SVG renderer
57
+ # does not honour `paint-order`), as SVG when that tool or PDF images are not available.
58
+ def vector(svg, max_height: nil)
59
+ pdf = @pdf_images ? Images.svg_to_pdf(svg) : nil
60
+ pdf ? place(pdf, ".pdf", max_height: max_height) : place(svg, ".svg", max_height: max_height)
61
+ end
62
+
63
+ # A PDF page that is already a picture (drawn by another tool), placed as it is
64
+ def pdf(bytes, max_height: nil)
65
+ place(bytes, ".pdf", max_height: max_height)
66
+ end
67
+
68
+ # @return [String, nil] PDF bytes of an SVG document, nil without rsvg-convert
69
+ def self.svg_to_pdf(svg)
70
+ out, status = Open3.capture2("rsvg-convert", "--format", "pdf", stdin_data: svg, binmode: true)
71
+ status.success? && !out.empty? ? out : nil
72
+ rescue Errno::ENOENT
73
+ nil
74
+ end
75
+
76
+ # An image by URL: fetched when allowed, else the caller shows the alt text
77
+ def remote(src)
78
+ return nil unless @remote && src.match?(%r{\Ahttps?://}i)
79
+
80
+ bytes, ext = @remote.call(src)
81
+ place(bytes, ext) if bytes
82
+ end
83
+
84
+ private
85
+
86
+ # @param max_height [Float, nil] share of the page height the picture may take (the layout's default if nil)
87
+ def place(bytes, ext, max_height: nil)
88
+ name = "img-#{@files.size + 1}#{ext}"
89
+ @files[name] = bytes
90
+ "#fit-image(#{Typst.lit(name)}#{", max-height: #{max_height}" if max_height})"
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../markdown"
4
+ require_relative "../helpers/macros"
5
+ require_relative "../jira_settings"
6
+ require_relative "typst"
7
+
8
+ module Archsight
9
+ module Pdf
10
+ # Markdown (the dialect of Archsight::Markdown) as Typst markup.
11
+ #
12
+ # What a document refers to is resolved by the caller through hooks, so this class knows neither the database
13
+ # nor the file system:
14
+ #
15
+ # - `image`: `(src, alt) -> String | nil`, Typst markup for an embedded image (nil: left out, the alt text is shown)
16
+ # - `diagram`: `(source) -> String`, Typst markup for an `asd` code block (default: the source as code)
17
+ # - `label`: `(target) -> String`, the text of a `[[Target]]` link without label (default: the target)
18
+ # - `embeds`: an object with `view_block(source)`, `requirements_block(source)` and `embed(reference)`, each
19
+ # returning Typst markup (never raising: what cannot be shown is a note). It makes the live content of a page, a
20
+ # ```view or ```requirements block and `![[Kind/Name]]` on a line of its own, part of the document. Without it
21
+ # those blocks are shown as written and an embed is its label.
22
+ class Markdown
23
+ include Typst
24
+
25
+ SYMBOLS = {
26
+ ndash: "–", mdash: "—", hellip: "…", laquo: "«", raquo: "»", laquo_space: "« ", raquo_space: " »",
27
+ lsquo: "‘", rsquo: "’", ldquo: "“", rdquo: "”"
28
+ }.freeze
29
+ STATUS_FILL = {
30
+ "green" => "#d9f2e0", "yellow" => "#fff3cc", "red" => "#fde8e8", "blue" => "#ddeeff", "grey" => "#e8e8e8",
31
+ "gray" => "#e8e8e8", "purple" => "#eadcf8"
32
+ }.freeze
33
+ EMBED = /\A!\[\[([^\]|]+)\]\]\z/
34
+ # `![[Embed]]`, `[[Target|label]]` and the `{macro:arguments}` of the wiki (see Helpers::Macros)
35
+ INLINE = /(!)?\[\[([^\]|]+)(?:\|([^\]]+))?\]\]|\{([a-z][a-z0-9-]*)(?::([^{}|\n]*))?\}/
36
+ ALIGNMENT = { left: "left", center: "center", right: "right" }.freeze
37
+
38
+ # @param heading_offset [Integer] added to the level of every heading
39
+ def initialize(heading_offset: 0, image: nil, diagram: nil, label: nil, embeds: nil)
40
+ @heading_offset = heading_offset
41
+ @image = image
42
+ @diagram = diagram
43
+ @label = label
44
+ @embeds = embeds
45
+ end
46
+
47
+ # @param inline [Boolean] for a table cell or list item: paragraphs are joined by a line break
48
+ # @return [String] Typst markup
49
+ def render(source, inline: false)
50
+ out = blocks(Archsight::Markdown.document(source).root.children)
51
+ inline ? out.gsub(/\n\n+/, " #linebreak() ") : out
52
+ end
53
+
54
+ private
55
+
56
+ def blocks(elements)
57
+ elements.filter_map { |node| block(node) }.join("\n\n")
58
+ end
59
+
60
+ def block(node)
61
+ case node.type
62
+ when :p then paragraph(node)
63
+ when :header then "#heading(level: #{[node.options[:level] + @heading_offset, 6].min})[#{inlines(node.children)}]"
64
+ when :codeblock then code(node.value, node.options[:lang])
65
+ when :blockquote then "#quote(block: true)[#{blocks(node.children)}]"
66
+ when :ul then "#list(#{items(node)})"
67
+ when :ol then "#enum(#{items(node)})"
68
+ when :hr then "#line(length: 100%, stroke: 0.5pt + luma(200))"
69
+ when :table then table(node)
70
+ when :blank, :html_element, :xml_comment then nil
71
+ else inlines(node.children) unless node.children.empty?
72
+ end
73
+ end
74
+
75
+ # A paragraph that is only an image is a figure, centred; one that is only embeds (one per line) is their content
76
+ def paragraph(node)
77
+ content = node.children.reject { |c| c.type == :text && c.value.strip.empty? }
78
+ return image(content.first) if content.size == 1 && content.first.type == :img
79
+
80
+ embeds = block_embeds(content)
81
+ return embeds if embeds
82
+
83
+ inlines(node.children)
84
+ end
85
+
86
+ def block_embeds(content)
87
+ return nil unless @embeds && content.size == 1 && content.first.type == :text
88
+
89
+ lines = content.first.value.lines.map(&:strip).reject(&:empty?)
90
+ return nil unless lines.any? && lines.all? { |line| line.match?(EMBED) }
91
+
92
+ lines.map { |line| @embeds.embed(line[EMBED, 1].strip) }.join("\n\n")
93
+ end
94
+
95
+ def items(list)
96
+ list.children.map { |li| "[#{blocks(li.children)}]" }.join(", ")
97
+ end
98
+
99
+ def code(source, lang)
100
+ case lang.to_s
101
+ when "asd" then @diagram ? @diagram.call(source) : raw(source, lang)
102
+ when "view" then @embeds ? @embeds.view_block(source) : raw(source, lang)
103
+ when "requirements" then @embeds ? @embeds.requirements_block(source) : raw(source, lang)
104
+ when "mermaid" then [note("Mermaid diagram, shown as a diagram in Archsight", fill: "#f2f2f2"), raw(source, lang)].join("\n\n")
105
+ else raw(source, lang)
106
+ end
107
+ end
108
+
109
+ def table(node)
110
+ head, body = node.children.partition { |section| section.type == :thead }
111
+ head_rows = head.flat_map(&:children).map { |tr| cells(tr, header: true) }
112
+ body_rows = body.flat_map(&:children).map { |tr| cells(tr) }
113
+ width = (head_rows + body_rows).map(&:size).max || 1
114
+ header = head_rows.empty? ? "" : "table.header(#{head_rows.flatten.join(", ")}), "
115
+ "#table(columns: #{width}, #{align(node, width)}#{header}#{body_rows.flatten.join(", ")})"
116
+ end
117
+
118
+ def align(node, width)
119
+ alignment = Array(node.options[:alignment])
120
+ return "" unless alignment.any? { |a| ALIGNMENT.key?(a) }
121
+
122
+ "align: (#{Array.new(width) { |i| ALIGNMENT.fetch(alignment[i], "auto") }.join(", ")},), "
123
+ end
124
+
125
+ # The header cells are semibold, set on the cells (a show rule on the table header does not reach their text)
126
+ def cells(row, header: false)
127
+ row.children.map do |td|
128
+ content = inlines(td.children)
129
+ header ? "[#text(weight: \"semibold\")[#{content}]]" : "[#{content}]"
130
+ end
131
+ end
132
+
133
+ def inlines(elements)
134
+ elements.map { |node| inline(node) }.join
135
+ end
136
+
137
+ def inline(node)
138
+ case node.type
139
+ when :text then text(node.value)
140
+ when :em then "#emph[#{inlines(node.children)}]"
141
+ when :strong then "#strong[#{inlines(node.children)}]"
142
+ when :codespan then "#raw(#{lit(node.value)})"
143
+ when :a then "#link(#{lit(node.attr["href"])})[#{inlines(node.children)}]"
144
+ when :br then "#linebreak()"
145
+ when :img then image(node)
146
+ when :entity then str(node.value.char)
147
+ when :smart_quote, :typographic_sym then str(SYMBOLS.fetch(node.value, node.value.to_s))
148
+ when :html_element then html(node)
149
+ else inlines(node.children)
150
+ end
151
+ end
152
+
153
+ def html(node)
154
+ return "#linebreak()" if node.value == "br"
155
+
156
+ checkbox(node) || inlines(node.children)
157
+ end
158
+
159
+ def checkbox(node)
160
+ return unless node.value == "input" && node.attr["type"] == "checkbox"
161
+
162
+ str(node.attr.key?("checked") ? "☑ " : "☐ ")
163
+ end
164
+
165
+ def image(node)
166
+ src = node.attr["src"].to_s
167
+ alt = node.attr["alt"].to_s
168
+ @image&.call(src, alt) || "#emph[#{str(alt.empty? ? src : alt)}]"
169
+ end
170
+
171
+ # Plain text, in which wiki links, embeds and the macros of the wiki are replaced
172
+ def text(value)
173
+ out = +""
174
+ pos = 0
175
+ value.scan(INLINE) do
176
+ match = Regexp.last_match
177
+ out << str(value[pos...match.begin(0)]) if match.begin(0) > pos
178
+ out << (replacement(match) || str(match[0]))
179
+ pos = match.end(0)
180
+ end
181
+ out << str(value[pos..]) if pos < value.length
182
+ out
183
+ end
184
+
185
+ def replacement(match)
186
+ match[2] ? link(match) : macro(match[4], match[5])
187
+ end
188
+
189
+ def link(match)
190
+ label = match[3]&.strip || @label&.call(match[2].strip) || match[2].strip
191
+ match[1] ? "#emph[#{str(label)}]" : str(label)
192
+ end
193
+
194
+ # A registered macro with valid arguments; nil leaves the text as written. What needs the running application
195
+ # (`children`, `pagetree`) has no meaning on paper.
196
+ def macro(name, arguments)
197
+ handler = Helpers::Macros.handler(name)
198
+ return nil unless handler
199
+
200
+ value = handler.parse(arguments)
201
+ return nil unless value
202
+
203
+ case name
204
+ when "status" then badge(value.text.upcase, STATUS_FILL.fetch(value.colour.to_s.downcase, "#e8e8e8"))
205
+ when "jira" then jira(value)
206
+ when "emoticon" then str(value.char)
207
+ when "children", "pagetree" then ""
208
+ end
209
+ end
210
+
211
+ def jira(key)
212
+ url = JiraSettings.link_for(key)
213
+ url ? "#link(#{lit(url)})[#{str(key)}]" : "#raw(#{lit(key)})"
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "resolv"
5
+ require "ipaddr"
6
+ require "uri"
7
+ require_relative "../user_config"
8
+
9
+ module Archsight
10
+ module Pdf
11
+ # Images that a page embeds by URL (`![](https://...)`), fetched by the server so they can go into the PDF.
12
+ #
13
+ # The URL comes from page content, so the server must not become a way into the network behind it: only http(s) on
14
+ # the default ports, only hosts that resolve to public addresses (checked for the address that is connected to, and
15
+ # again for every redirect), a size and time limit, and the answer must really be a png, jpeg, gif or webp image.
16
+ # Anything else is `nil` and the document shows the alt text. `pdf.remote_images: false`
17
+ # (`ARCHSIGHT_PDF_REMOTE_IMAGES`) turns fetching off.
18
+ module RemoteImage
19
+ MAX_BYTES = 10 * 1024 * 1024
20
+ TIMEOUT = 10
21
+ MAX_REDIRECTS = 3
22
+ CACHE_SECONDS = 3600
23
+ CACHE_ENTRIES = 64
24
+ PORTS = { "http" => 80, "https" => 443 }.freeze
25
+ NON_PUBLIC = %w[
26
+ 0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.0.0.0/24 192.168.0.0/16
27
+ 198.18.0.0/15 224.0.0.0/4 240.0.0.0/4 ::/128 ::1/128 fc00::/7 fe80::/10 ff00::/8
28
+ ].map { |range| IPAddr.new(range) }.freeze
29
+ TYPES = { png: ".png", jpeg: ".jpg", gif: ".gif", webp: ".webp" }.freeze
30
+
31
+ @cache = {}
32
+ @lock = Mutex.new
33
+
34
+ class << self
35
+ # @return [Boolean] whether fetching is on
36
+ def enabled?(env: ENV)
37
+ !%w[false no 0 off].include?(UserConfig.setting("pdf", "remote_images", env: env).to_s.strip.downcase)
38
+ end
39
+
40
+ # @return [Array(String, String), nil] image bytes and the file extension, nil if it cannot be had
41
+ def fetch(url, resolver: Resolv, now: Time.now)
42
+ cached(url, now) { download(url, resolver) }
43
+ rescue StandardError
44
+ nil
45
+ end
46
+
47
+ # Public addresses only: not loopback, private, link-local (cloud metadata), multicast, reserved or unspecified
48
+ def public_address?(address)
49
+ ip = IPAddr.new(address.to_s)
50
+ ip = ip.native if ip.ipv4_mapped?
51
+ NON_PUBLIC.none? { |range| range.include?(ip) }
52
+ rescue IPAddr::Error
53
+ false
54
+ end
55
+
56
+ # @return [Symbol, nil] :png, :jpeg, :gif or :webp from the first bytes
57
+ def image_type(bytes)
58
+ head = bytes.byteslice(0, 12).to_s.b
59
+ return :png if head.start_with?("\x89PNG\r\n\x1a\n".b)
60
+ return :jpeg if head.start_with?("\xFF\xD8\xFF".b)
61
+ return :gif if head.start_with?("GIF87a", "GIF89a")
62
+
63
+ :webp if head.start_with?("RIFF") && head.byteslice(8, 4) == "WEBP"
64
+ end
65
+
66
+ private
67
+
68
+ def cached(url, now)
69
+ entry = @lock.synchronize { @cache[url] }
70
+ return entry[:value] if entry && now - entry[:at] < CACHE_SECONDS
71
+
72
+ value = yield
73
+ @lock.synchronize do
74
+ @cache[url] = { value: value, at: now }
75
+ @cache.shift while @cache.size > CACHE_ENTRIES
76
+ end
77
+ value
78
+ end
79
+
80
+ def download(url, resolver, redirects = 0)
81
+ uri = URI.parse(url)
82
+ return nil unless PORTS.key?(uri.scheme) && uri.host && uri.port == PORTS[uri.scheme] && uri.userinfo.nil?
83
+
84
+ addresses = resolver.getaddresses(uri.host).map(&:to_s)
85
+ return nil if addresses.empty? || !addresses.all? { |a| public_address?(a) }
86
+
87
+ response, body = get(uri, addresses.first)
88
+ case response
89
+ when Net::HTTPSuccess then image(body)
90
+ when Net::HTTPRedirection
91
+ redirects < MAX_REDIRECTS ? download(URI.join(url, response["location"].to_s).to_s, resolver, redirects + 1) : nil
92
+ end
93
+ end
94
+
95
+ # Connects to the address that was checked (not to a second DNS answer); the certificate is checked for the name.
96
+ # @return [Array(Net::HTTPResponse, String), nil] the response and its body, nil if the body is too large
97
+ def get(uri, address)
98
+ http = Net::HTTP.new(uri.host, uri.port)
99
+ http.ipaddr = address
100
+ http.use_ssl = uri.scheme == "https"
101
+ http.open_timeout = http.read_timeout = http.write_timeout = TIMEOUT
102
+ http.request(Net::HTTP::Get.new(uri.request_uri, "Accept" => "image/*", "User-Agent" => "archsight-pdf")) do |res|
103
+ return [res, nil] unless res.is_a?(Net::HTTPSuccess)
104
+ return nil if res["Content-Length"].to_i > MAX_BYTES
105
+
106
+ body = +"".b
107
+ res.read_body do |chunk|
108
+ body << chunk
109
+ return nil if body.bytesize > MAX_BYTES
110
+ end
111
+ return [res, body]
112
+ end
113
+ end
114
+
115
+ def image(bytes)
116
+ type = image_type(bytes.to_s)
117
+ type ? [bytes, TYPES.fetch(type)] : nil
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end