studio-engine 0.98.0.rc1 → 0.99.0.rc1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -0
- data/README.md +10 -0
- data/app/controllers/studio/knowledge_docs_controller.rb +24 -1
- data/app/javascript/studio/controllers/modal_host_controller.js +6 -16
- data/app/javascript/studio/modal_host.js +25 -5
- data/app/models/studio/knowledge_doc.rb +37 -2
- data/app/views/studio/knowledge_docs/_preview.html.erb +66 -0
- data/app/views/studio/knowledge_docs/_preview_fallback.html.erb +9 -0
- data/app/views/studio/knowledge_docs/_preview_sheet.html.erb +43 -0
- data/app/views/studio/knowledge_docs/_preview_styles.html.erb +24 -0
- data/app/views/studio/knowledge_docs/preview.html.erb +12 -0
- data/app/views/studio/knowledge_docs/show.html.erb +26 -2
- data/lib/studio/knowledge_preview/number_format.rb +342 -0
- data/lib/studio/knowledge_preview/xlsx.rb +646 -0
- data/lib/studio/knowledge_preview/xml_guard.rb +344 -0
- data/lib/studio/knowledge_preview/zip.rb +242 -0
- data/lib/studio/knowledge_preview.rb +322 -0
- data/lib/studio/s3.rb +39 -4
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +5 -2
- data/studio-engine.gemspec +4 -0
- metadata +26 -2
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# What /admin/knowledge/:id shows of a document without a download: a
|
|
5
|
+
# workbook or CSV as tables, a PDF or image inline, text as text.
|
|
6
|
+
#
|
|
7
|
+
# NOT required by lib/studio.rb. Studio::KnowledgeDoc requires it on first
|
|
8
|
+
# use, and the spreadsheet reader (nokogiri, zlib) and `csv` load later
|
|
9
|
+
# still, on the first file of that kind, so an app that never draws the
|
|
10
|
+
# knowledge routes pays nothing for it.
|
|
11
|
+
#
|
|
12
|
+
# Everything here is read-only and answers with a Result, never an
|
|
13
|
+
# exception: a file this cannot show, for any reason, is a Result of kind
|
|
14
|
+
# :fallback carrying one plain sentence, and the page offers the download
|
|
15
|
+
# link beside it.
|
|
16
|
+
#
|
|
17
|
+
# THE CAPS, all in one place:
|
|
18
|
+
#
|
|
19
|
+
# SPREADSHEET_MAX_BYTES an .xlsx larger than this is not opened at all.
|
|
20
|
+
# INFLATE_BUDGET what a whole workbook may inflate to, every part
|
|
21
|
+
# read counted, a part read twice counted twice.
|
|
22
|
+
# SPREADSHEET_DEADLINE_SECONDS
|
|
23
|
+
# wall-clock time one workbook read may take.
|
|
24
|
+
# MAX_PARSED_TEXT_BYTES text the XML parser may hand the reader across a
|
|
25
|
+
# workbook, kept or not.
|
|
26
|
+
# XmlGuard::MAX_TOKEN_BYTES, MAX_OPEN_TAG_BYTES, MAX_ASIDE_BYTES,
|
|
27
|
+
# MAX_NAMESPACE_DECLARATIONS, MAX_DEPTH
|
|
28
|
+
# what the XML parser is ever GIVEN, enforced on the
|
|
29
|
+
# byte stream below it: no DTD, UTF-8 only, and no
|
|
30
|
+
# text node, set of open start tags, or pile of
|
|
31
|
+
# comments large enough for the parser to spend a
|
|
32
|
+
# gigabyte on before a single node comes out.
|
|
33
|
+
# MAX_ROWS, MAX_COLUMNS what one sheet (or CSV) shows; the rest is named
|
|
34
|
+
# in a notice, never silently dropped.
|
|
35
|
+
# MAX_SHEETS tabs rendered; further sheets are counted.
|
|
36
|
+
# MAX_CELLS filled cells across one workbook.
|
|
37
|
+
# MAX_CELL_CHARS text kept for one cell (Excel's own limit); a
|
|
38
|
+
# longer value is cut and marked.
|
|
39
|
+
# MAX_TEXT_BYTES cell text across one workbook, counted once per
|
|
40
|
+
# cell that shows it.
|
|
41
|
+
# MAX_GRID_CELLS cells rendered across one workbook, empty ones
|
|
42
|
+
# included.
|
|
43
|
+
# The sheet that spends the last of any of these
|
|
44
|
+
# three is cut there and says so; the sheets after
|
|
45
|
+
# it are counted, not rendered.
|
|
46
|
+
# NumberFormat::MAX_CODE_LENGTH, MAX_DECIMALS
|
|
47
|
+
# a number format longer than Excel allows is
|
|
48
|
+
# ignored, and no number renders past thirty
|
|
49
|
+
# decimals.
|
|
50
|
+
# DELIMITED_HEAD_BYTES a CSV is read only this far, so one of any size
|
|
51
|
+
# previews its first rows.
|
|
52
|
+
# TEXT_HEAD_BYTES likewise for text.
|
|
53
|
+
# INLINE_MAX_BYTES a PDF or image past this is not embedded.
|
|
54
|
+
module KnowledgePreview
|
|
55
|
+
class Error < StandardError; end
|
|
56
|
+
# The file is not what its name says, or is damaged. The message completes
|
|
57
|
+
# "This file could not be previewed: ...".
|
|
58
|
+
class Unreadable < Error; end
|
|
59
|
+
class TooLarge < Error; end
|
|
60
|
+
|
|
61
|
+
MEGABYTE = 1_048_576
|
|
62
|
+
SPREADSHEET_MAX_BYTES = 20 * MEGABYTE
|
|
63
|
+
INFLATE_BUDGET = 64 * MEGABYTE
|
|
64
|
+
SPREADSHEET_DEADLINE_SECONDS = 10
|
|
65
|
+
MAX_PARSED_TEXT_BYTES = 16 * MEGABYTE
|
|
66
|
+
MAX_ROWS = 500
|
|
67
|
+
MAX_COLUMNS = 50
|
|
68
|
+
MAX_SHEETS = 20
|
|
69
|
+
MAX_CELLS = 50_000
|
|
70
|
+
MAX_CELL_CHARS = 32_767
|
|
71
|
+
MAX_TEXT_BYTES = 2 * MEGABYTE
|
|
72
|
+
MAX_GRID_CELLS = 100_000
|
|
73
|
+
DELIMITED_HEAD_BYTES = 1 * MEGABYTE
|
|
74
|
+
TEXT_HEAD_BYTES = 256 * 1024
|
|
75
|
+
INLINE_MAX_BYTES = 100 * MEGABYTE
|
|
76
|
+
|
|
77
|
+
XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
78
|
+
|
|
79
|
+
KIND_BY_EXTENSION = {
|
|
80
|
+
".xlsx" => :spreadsheet, ".xlsm" => :spreadsheet,
|
|
81
|
+
".csv" => :delimited, ".tsv" => :delimited,
|
|
82
|
+
".pdf" => :pdf,
|
|
83
|
+
".png" => :image, ".jpg" => :image, ".jpeg" => :image, ".gif" => :image, ".webp" => :image,
|
|
84
|
+
".txt" => :text, ".md" => :text, ".markdown" => :text, ".log" => :text,
|
|
85
|
+
".json" => :text, ".vtt" => :text, ".srt" => :text
|
|
86
|
+
}.freeze
|
|
87
|
+
|
|
88
|
+
KIND_BY_MIME = {
|
|
89
|
+
XLSX_MIME => :spreadsheet,
|
|
90
|
+
"text/csv" => :delimited, "text/tab-separated-values" => :delimited,
|
|
91
|
+
"application/pdf" => :pdf,
|
|
92
|
+
"image/png" => :image, "image/jpeg" => :image, "image/gif" => :image, "image/webp" => :image,
|
|
93
|
+
"application/json" => :text
|
|
94
|
+
}.freeze
|
|
95
|
+
|
|
96
|
+
# The content type an inline object is SERVED as. Chosen here, from the
|
|
97
|
+
# kind, and never copied from the stored mime type: a file uploaded as
|
|
98
|
+
# text/html under a .pdf name must reach the browser as a (broken) PDF,
|
|
99
|
+
# not as a page. SVG is absent on purpose; it is a document that can
|
|
100
|
+
# carry script, and it takes the download link.
|
|
101
|
+
INLINE_TYPE_BY_EXTENSION = {
|
|
102
|
+
".pdf" => "application/pdf",
|
|
103
|
+
".png" => "image/png", ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg",
|
|
104
|
+
".gif" => "image/gif", ".webp" => "image/webp"
|
|
105
|
+
}.freeze
|
|
106
|
+
|
|
107
|
+
Cell = Struct.new(:text, :numeric)
|
|
108
|
+
# rows is a rectangle of Cell-or-nil. row_limit is the row count the
|
|
109
|
+
# truncation notice names.
|
|
110
|
+
Sheet = Struct.new(:name, :rows, :truncated_rows, :truncated_columns, :hidden, :row_limit,
|
|
111
|
+
keyword_init: true)
|
|
112
|
+
|
|
113
|
+
# kind: :table, :pdf, :image, :text or :fallback.
|
|
114
|
+
# error: the unexpected exception behind a fallback, for the caller to
|
|
115
|
+
# log; nil when the fallback is an ordinary refusal.
|
|
116
|
+
Result = Struct.new(:kind, :sheets, :omitted_sheets, :text, :truncated, :url, :reason, :error,
|
|
117
|
+
keyword_init: true) do
|
|
118
|
+
def fallback? = kind == :fallback
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
NUMERIC_TEXT = /\A\(?[-+$€£]?\s?\d[\d,]*(\.\d+)?%?\)?\z/
|
|
122
|
+
|
|
123
|
+
class << self
|
|
124
|
+
# :spreadsheet, :delimited, :pdf, :image, :text, or nil for a file with
|
|
125
|
+
# no inline preview. The extension decides when it is one this knows;
|
|
126
|
+
# the stored mime type is the browser's guess at upload and is consulted
|
|
127
|
+
# only for a name that says nothing.
|
|
128
|
+
def kind_for(filename:, mime_type: nil)
|
|
129
|
+
extension = File.extname(filename.to_s).downcase
|
|
130
|
+
return KIND_BY_EXTENSION[extension] if KIND_BY_EXTENSION.key?(extension)
|
|
131
|
+
|
|
132
|
+
mime = mime_type.to_s.split(";").first.to_s.strip.downcase
|
|
133
|
+
KIND_BY_MIME[mime] || (mime.start_with?("text/") ? :text : nil)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def inline_content_type(filename:, mime_type: nil)
|
|
137
|
+
by_name = INLINE_TYPE_BY_EXTENSION[File.extname(filename.to_s).downcase]
|
|
138
|
+
return by_name if by_name
|
|
139
|
+
|
|
140
|
+
mime = mime_type.to_s.split(";").first.to_s.strip.downcase
|
|
141
|
+
INLINE_TYPE_BY_EXTENSION.value?(mime) ? mime : nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# 0 -> "A", 25 -> "Z", 26 -> "AA": the heading a spreadsheet gives a column.
|
|
145
|
+
def column_letter(index)
|
|
146
|
+
letters = +""
|
|
147
|
+
number = index + 1
|
|
148
|
+
while number.positive?
|
|
149
|
+
number, remainder = (number - 1).divmod(26)
|
|
150
|
+
letters.prepend((65 + remainder).chr)
|
|
151
|
+
end
|
|
152
|
+
letters
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# The preview for a Studio::KnowledgeDoc. Reads the bucket (or, for a
|
|
156
|
+
# PDF or image, only signs a URL), so call it off the page's own render
|
|
157
|
+
# path. `storage` answers download(key:, max_bytes:) and defaults to
|
|
158
|
+
# Studio::S3, named only when a read is about to happen.
|
|
159
|
+
def for(doc, storage: nil)
|
|
160
|
+
return fallback("No file is attached.") unless doc.file?
|
|
161
|
+
|
|
162
|
+
kind = kind_for(filename: doc.filename, mime_type: doc.mime_type)
|
|
163
|
+
case kind
|
|
164
|
+
when :spreadsheet then spreadsheet(doc, storage)
|
|
165
|
+
when :delimited then delimited(doc, storage)
|
|
166
|
+
when :text then text(doc, storage)
|
|
167
|
+
when :pdf, :image then inline(doc, kind)
|
|
168
|
+
else fallback(no_preview_reason(doc))
|
|
169
|
+
end
|
|
170
|
+
rescue TooLarge => e
|
|
171
|
+
fallback("This file is too large to preview: #{e.message}.")
|
|
172
|
+
rescue Unreadable => e
|
|
173
|
+
fallback("This file could not be previewed: #{e.message}.")
|
|
174
|
+
rescue StandardError, LoadError => e
|
|
175
|
+
# Storage down, an object missing, a defect in a reader, or a library
|
|
176
|
+
# the host's bundle lacks. The page must still render; the caller logs
|
|
177
|
+
# what happened.
|
|
178
|
+
fallback("The preview could not be built.", error: e)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# --- readers, usable without a document ---------------------------------
|
|
182
|
+
|
|
183
|
+
def read_spreadsheet(bytes, **overrides)
|
|
184
|
+
require_relative "knowledge_preview/xlsx"
|
|
185
|
+
Xlsx.read(bytes, max_rows: MAX_ROWS, max_columns: MAX_COLUMNS, max_sheets: MAX_SHEETS,
|
|
186
|
+
max_cells: MAX_CELLS, max_cell_chars: MAX_CELL_CHARS, max_text_bytes: MAX_TEXT_BYTES,
|
|
187
|
+
max_grid_cells: MAX_GRID_CELLS, max_parsed_text_bytes: MAX_PARSED_TEXT_BYTES,
|
|
188
|
+
inflate_budget: INFLATE_BUDGET, deadline_seconds: SPREADSHEET_DEADLINE_SECONDS,
|
|
189
|
+
**overrides)
|
|
190
|
+
rescue Error
|
|
191
|
+
raise
|
|
192
|
+
rescue StandardError => e
|
|
193
|
+
# A hostile or half-written workbook can trip the reader anywhere;
|
|
194
|
+
# every such trip is the file's fault until shown otherwise.
|
|
195
|
+
raise Unreadable, "its contents are damaged (#{e.class})"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# `partial` says the bytes are only the head of a longer file, so the
|
|
199
|
+
# last line may be cut and is dropped.
|
|
200
|
+
def read_delimited(bytes, separator: ",", partial: false)
|
|
201
|
+
require "csv"
|
|
202
|
+
text = decode(bytes, partial: partial)
|
|
203
|
+
raise Unreadable, "it is empty" if text.strip.empty?
|
|
204
|
+
|
|
205
|
+
text = text[0..text.rindex("\n")] if partial && text.include?("\n")
|
|
206
|
+
rows = []
|
|
207
|
+
truncated_rows = partial
|
|
208
|
+
truncated_columns = false
|
|
209
|
+
begin
|
|
210
|
+
CSV.new(text, col_sep: separator, liberal_parsing: true).each do |row|
|
|
211
|
+
if rows.size >= MAX_ROWS
|
|
212
|
+
truncated_rows = true
|
|
213
|
+
break
|
|
214
|
+
end
|
|
215
|
+
truncated_columns = true if row.size > MAX_COLUMNS
|
|
216
|
+
rows << row.first(MAX_COLUMNS)
|
|
217
|
+
end
|
|
218
|
+
rescue CSV::MalformedCSVError
|
|
219
|
+
# Rows read before the bad line still show; with none, it is not CSV.
|
|
220
|
+
raise Unreadable, "it is not valid CSV" if rows.empty?
|
|
221
|
+
|
|
222
|
+
truncated_rows = true
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
width = rows.map(&:size).max.to_i
|
|
226
|
+
cells = rows.map do |row|
|
|
227
|
+
Array.new(width) do |index|
|
|
228
|
+
value = row[index].to_s
|
|
229
|
+
value.empty? ? nil : Cell.new(value, value.match?(NUMERIC_TEXT))
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
Sheet.new(name: nil, rows: cells, truncated_rows: truncated_rows,
|
|
233
|
+
truncated_columns: truncated_columns, hidden: false, row_limit: cells.size)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Bytes to UTF-8 text. UTF-8 first; a UTF-16 byte-order mark is honoured;
|
|
237
|
+
# anything else is read as Windows-1252, which is what a CSV exported by
|
|
238
|
+
# desktop accounting software usually is. NUL bytes mean it is not text.
|
|
239
|
+
def decode(bytes, partial: false)
|
|
240
|
+
raw = bytes.to_s.b
|
|
241
|
+
if raw.start_with?("\xFF\xFE".b, "\xFE\xFF".b)
|
|
242
|
+
encoding = raw.start_with?("\xFF\xFE".b) ? Encoding::UTF_16LE : Encoding::UTF_16BE
|
|
243
|
+
body = raw.byteslice(2..)
|
|
244
|
+
body = body.byteslice(0, body.bytesize - 1) if body.bytesize.odd?
|
|
245
|
+
return body.force_encoding(encoding).encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
246
|
+
end
|
|
247
|
+
raise Unreadable, "it does not look like text" if raw.include?("\x00".b)
|
|
248
|
+
|
|
249
|
+
raw = raw.byteslice(3..) if raw.start_with?("\xEF\xBB\xBF".b)
|
|
250
|
+
utf8 = raw.dup.force_encoding(Encoding::UTF_8)
|
|
251
|
+
return utf8 if utf8.valid_encoding?
|
|
252
|
+
|
|
253
|
+
if partial
|
|
254
|
+
# A head read can end inside a multi-byte character.
|
|
255
|
+
1.upto(3) do |trim|
|
|
256
|
+
shorter = raw.byteslice(0, [raw.bytesize - trim, 0].max).force_encoding(Encoding::UTF_8)
|
|
257
|
+
return shorter if shorter.valid_encoding?
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
raw.force_encoding(Encoding::Windows_1252).encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
private
|
|
264
|
+
|
|
265
|
+
def fallback(reason, error: nil)
|
|
266
|
+
Result.new(kind: :fallback, reason: reason, error: error)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def no_preview_reason(doc)
|
|
270
|
+
extension = File.extname(doc.filename.to_s).downcase
|
|
271
|
+
extension.empty? ? "There is no inline preview for this kind of file." : "There is no inline preview for #{extension} files."
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def megabytes(bytes)
|
|
275
|
+
"#{(bytes.to_f / MEGABYTE).round(1).to_s.delete_suffix('.0')} MB"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def spreadsheet(doc, storage)
|
|
279
|
+
refuse_over!(doc, SPREADSHEET_MAX_BYTES)
|
|
280
|
+
# One byte past the cap: the recorded byte_size can be absent or
|
|
281
|
+
# stale, and this is the read that cannot be.
|
|
282
|
+
bytes = (storage || Studio::S3).download(key: doc.s3_key, max_bytes: SPREADSHEET_MAX_BYTES + 1)
|
|
283
|
+
raise TooLarge, "spreadsheet previews stop at #{megabytes(SPREADSHEET_MAX_BYTES)}" if bytes.bytesize > SPREADSHEET_MAX_BYTES
|
|
284
|
+
|
|
285
|
+
workbook = read_spreadsheet(bytes)
|
|
286
|
+
Result.new(kind: :table, sheets: workbook.sheets, omitted_sheets: workbook.omitted_sheets)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def delimited(doc, storage)
|
|
290
|
+
bytes = (storage || Studio::S3).download(key: doc.s3_key, max_bytes: DELIMITED_HEAD_BYTES + 1)
|
|
291
|
+
partial = bytes.bytesize > DELIMITED_HEAD_BYTES
|
|
292
|
+
tabbed = File.extname(doc.filename.to_s).casecmp?(".tsv") ||
|
|
293
|
+
doc.mime_type.to_s.start_with?("text/tab-separated-values")
|
|
294
|
+
sheet = read_delimited(bytes.byteslice(0, DELIMITED_HEAD_BYTES), separator: tabbed ? "\t" : ",", partial: partial)
|
|
295
|
+
Result.new(kind: :table, sheets: [sheet], omitted_sheets: 0)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def text(doc, storage)
|
|
299
|
+
bytes = (storage || Studio::S3).download(key: doc.s3_key, max_bytes: TEXT_HEAD_BYTES + 1)
|
|
300
|
+
partial = bytes.bytesize > TEXT_HEAD_BYTES
|
|
301
|
+
Result.new(kind: :text, text: decode(bytes.byteslice(0, TEXT_HEAD_BYTES), partial: partial), truncated: partial)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def inline(doc, kind)
|
|
305
|
+
refuse_over!(doc, INLINE_MAX_BYTES)
|
|
306
|
+
content_type = inline_content_type(filename: doc.filename, mime_type: doc.mime_type)
|
|
307
|
+
# Without a type of our choosing the URL would be signed to serve the
|
|
308
|
+
# STORED type, which is the one thing this path exists to prevent.
|
|
309
|
+
raise Unreadable, "its type cannot be shown inline" if content_type.nil?
|
|
310
|
+
|
|
311
|
+
Result.new(kind: kind, url: doc.signed_url(inline_as: content_type))
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def refuse_over!(doc, cap)
|
|
315
|
+
size = doc.byte_size.to_i
|
|
316
|
+
return unless size > cap
|
|
317
|
+
|
|
318
|
+
raise TooLarge, "it is #{megabytes(size)} and previews of this kind stop at #{megabytes(cap)}"
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
data/lib/studio/s3.rb
CHANGED
|
@@ -18,8 +18,32 @@ module Studio
|
|
|
18
18
|
public_url? ? url(key: key) : nil
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
# max_bytes reads only the head of the object (a ranged GET), which is
|
|
22
|
+
# how a preview looks at the first megabyte of a file of any size and
|
|
23
|
+
# how a caller enforces a size cap without trusting a recorded length.
|
|
24
|
+
# A ranged read of an EMPTY object is a 416 (InvalidRange) on S3; that
|
|
25
|
+
# is answered as the empty string it means. Tested against a stubbed
|
|
26
|
+
# client only: no live store, R2 included, has been asked.
|
|
27
|
+
def download(key:, max_bytes: nil)
|
|
28
|
+
# 0 or a negative would build "bytes=0--1", which a store may read as
|
|
29
|
+
# no range at all and answer with the whole object.
|
|
30
|
+
unless max_bytes.nil? || (max_bytes.is_a?(Integer) && max_bytes.positive?)
|
|
31
|
+
raise ArgumentError, "max_bytes must be a positive Integer, got #{max_bytes.inspect}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
opts = { bucket: bucket, key: full_key(key) }
|
|
35
|
+
opts[:range] = "bytes=0-#{max_bytes - 1}" if max_bytes
|
|
36
|
+
s3 = client
|
|
37
|
+
# The rescue names an SDK constant, so it wraps only what runs after
|
|
38
|
+
# the client (which loads the SDK) exists: an unconfigured app must
|
|
39
|
+
# raise NotConfigured from `bucket` above, not a NameError from here.
|
|
40
|
+
begin
|
|
41
|
+
s3.get_object(**opts).body.read
|
|
42
|
+
rescue Aws::S3::Errors::InvalidRange
|
|
43
|
+
raise unless max_bytes
|
|
44
|
+
|
|
45
|
+
"".b
|
|
46
|
+
end
|
|
23
47
|
end
|
|
24
48
|
|
|
25
49
|
# The PUBLIC URL of an object. On AWS (no endpoint) it is the bucket's
|
|
@@ -43,9 +67,20 @@ module Studio
|
|
|
43
67
|
endpoint.nil? || !Studio.s3_public_url.to_s.empty?
|
|
44
68
|
end
|
|
45
69
|
|
|
46
|
-
|
|
70
|
+
# response_content_disposition and response_content_type are SIGNED into
|
|
71
|
+
# the URL and override the headers the object is served with. That is
|
|
72
|
+
# S3's documented behaviour and part of the S3 compatibility Cloudflare
|
|
73
|
+
# documents for R2. What the tests here prove is the SIGNING, against an
|
|
74
|
+
# R2-style endpoint; that R2 serves the overridden headers has not been
|
|
75
|
+
# verified against a live bucket. "inline" plus a content type the
|
|
76
|
+
# caller chose is how a private PDF or image is shown in the page
|
|
77
|
+
# without the stored content type deciding how the browser treats it.
|
|
78
|
+
def signed_url(key:, expires_in: 3600, response_content_disposition: nil, response_content_type: nil)
|
|
47
79
|
require "aws-sdk-s3"
|
|
48
|
-
|
|
80
|
+
params = { bucket: bucket, key: full_key(key), expires_in: expires_in }
|
|
81
|
+
params[:response_content_disposition] = response_content_disposition if response_content_disposition
|
|
82
|
+
params[:response_content_type] = response_content_type if response_content_type
|
|
83
|
+
Aws::S3::Presigner.new(client: client).presigned_url(:get_object, **params)
|
|
49
84
|
end
|
|
50
85
|
|
|
51
86
|
def exists?(key:)
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -1453,8 +1453,8 @@ module Studio
|
|
|
1453
1453
|
end
|
|
1454
1454
|
|
|
1455
1455
|
# Knowledge layer — /admin/knowledge (Studio::KnowledgeDoc): folder/flat
|
|
1456
|
-
# document browser, upload-to-inbox intake, per-agent access map,
|
|
1457
|
-
# 15-minute presigned downloads. Opt-in (default off) like every route
|
|
1456
|
+
# document browser, upload-to-inbox intake, per-agent access map,
|
|
1457
|
+
# 15-minute presigned downloads, and the inline document preview. Opt-in (default off) like every route
|
|
1458
1458
|
# surface, so no app grows an admin page it never asked for.
|
|
1459
1459
|
if Studio.draw_knowledge_routes
|
|
1460
1460
|
get "admin/knowledge", to: "studio/knowledge_docs#index", as: :admin_knowledge
|
|
@@ -1471,6 +1471,9 @@ module Studio
|
|
|
1471
1471
|
patch "admin/knowledge/:id", to: "studio/knowledge_docs#update"
|
|
1472
1472
|
get "admin/knowledge/:id/download", to: "studio/knowledge_docs#download",
|
|
1473
1473
|
as: :admin_knowledge_doc_download
|
|
1474
|
+
# The inline preview the show page lazy-loads (Studio::KnowledgePreview).
|
|
1475
|
+
get "admin/knowledge/:id/preview", to: "studio/knowledge_docs#preview",
|
|
1476
|
+
as: :admin_knowledge_doc_preview
|
|
1474
1477
|
end
|
|
1475
1478
|
|
|
1476
1479
|
# Solana / Phantom wallet sign-in (nonce challenge + signature verify),
|
data/studio-engine.gemspec
CHANGED
|
@@ -40,6 +40,10 @@ Gem::Specification.new do |spec|
|
|
|
40
40
|
spec.add_dependency "solid_queue", ">= 1.0", "< 2.0"
|
|
41
41
|
spec.add_dependency "aws-sdk-s3", "~> 1.218"
|
|
42
42
|
spec.add_dependency "mini_magick", "~> 5.0"
|
|
43
|
+
# The knowledge layer's CSV preview (Studio::KnowledgePreview) requires it,
|
|
44
|
+
# lazily. csv left Ruby's default gems in 3.4, and a bundled app can then
|
|
45
|
+
# load it only if its lockfile names it; not every consumer's does.
|
|
46
|
+
spec.add_dependency "csv", ">= 3.0"
|
|
43
47
|
spec.add_dependency "resend", "~> 1.1"
|
|
44
48
|
# Realtime: the redis cable/cache/Sidekiq adapter (Studio::Redis) + Turbo Streams
|
|
45
49
|
# broadcasting (Studio::Cable). `redis` is the dependency whose ABSENCE
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.99.0.rc1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-10 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -112,6 +112,20 @@ dependencies:
|
|
|
112
112
|
- - "~>"
|
|
113
113
|
- !ruby/object:Gem::Version
|
|
114
114
|
version: '5.0'
|
|
115
|
+
- !ruby/object:Gem::Dependency
|
|
116
|
+
name: csv
|
|
117
|
+
requirement: !ruby/object:Gem::Requirement
|
|
118
|
+
requirements:
|
|
119
|
+
- - ">="
|
|
120
|
+
- !ruby/object:Gem::Version
|
|
121
|
+
version: '3.0'
|
|
122
|
+
type: :runtime
|
|
123
|
+
prerelease: false
|
|
124
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
125
|
+
requirements:
|
|
126
|
+
- - ">="
|
|
127
|
+
- !ruby/object:Gem::Version
|
|
128
|
+
version: '3.0'
|
|
115
129
|
- !ruby/object:Gem::Dependency
|
|
116
130
|
name: resend
|
|
117
131
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -564,9 +578,14 @@ files:
|
|
|
564
578
|
- app/views/studio/knowledge_docs/_chip_styles.html.erb
|
|
565
579
|
- app/views/studio/knowledge_docs/_coverage_table.html.erb
|
|
566
580
|
- app/views/studio/knowledge_docs/_doc_row.html.erb
|
|
581
|
+
- app/views/studio/knowledge_docs/_preview.html.erb
|
|
582
|
+
- app/views/studio/knowledge_docs/_preview_fallback.html.erb
|
|
583
|
+
- app/views/studio/knowledge_docs/_preview_sheet.html.erb
|
|
584
|
+
- app/views/studio/knowledge_docs/_preview_styles.html.erb
|
|
567
585
|
- app/views/studio/knowledge_docs/_upload_form.html.erb
|
|
568
586
|
- app/views/studio/knowledge_docs/coverage.html.erb
|
|
569
587
|
- app/views/studio/knowledge_docs/index.html.erb
|
|
588
|
+
- app/views/studio/knowledge_docs/preview.html.erb
|
|
570
589
|
- app/views/studio/knowledge_docs/show.html.erb
|
|
571
590
|
- app/views/studio/links/confirm.html.erb
|
|
572
591
|
- app/views/studio/local_emails/index.html.erb
|
|
@@ -715,6 +734,11 @@ files:
|
|
|
715
734
|
- lib/studio/ip_locations.rb
|
|
716
735
|
- lib/studio/js_identifier.rb
|
|
717
736
|
- lib/studio/js_literal.rb
|
|
737
|
+
- lib/studio/knowledge_preview.rb
|
|
738
|
+
- lib/studio/knowledge_preview/number_format.rb
|
|
739
|
+
- lib/studio/knowledge_preview/xlsx.rb
|
|
740
|
+
- lib/studio/knowledge_preview/xml_guard.rb
|
|
741
|
+
- lib/studio/knowledge_preview/zip.rb
|
|
718
742
|
- lib/studio/knowledge_recording.rb
|
|
719
743
|
- lib/studio/knowledge_transcript.rb
|
|
720
744
|
- lib/studio/link_preview.rb
|