studio-engine 0.95.0 → 0.96.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +131 -16
- data/README.md +193 -12
- data/app/javascript/studio/application.js +2 -0
- data/app/javascript/studio/controllers/modal_host_controller.js +40 -0
- data/app/javascript/studio/modal_host.js +574 -0
- data/app/models/concerns/sluggable.rb +11 -1
- data/app/models/studio/knowledge_doc/recording.rb +214 -0
- data/app/models/studio/knowledge_doc.rb +3 -0
- data/app/views/layouts/studio/_head.html.erb +10 -2
- data/app/views/studio/emails/index.html.erb +13 -0
- data/app/views/studio/modals/_host.html.erb +33 -597
- data/app/views/studio/modals/_load_convention.html.erb +6 -27
- data/app/views/studio/modals/_scoped_host.html.erb +14 -257
- data/app/views/studio/modals/auth/_resend_footer.html.erb +2 -2
- data/app/views/studio/modals/blocks/_processing_card.html.erb +1 -1
- data/app/views/style/_modals.html.erb +12 -15
- data/db/migrate/20261008120000_add_recording_to_studio_knowledge_docs.rb +19 -0
- data/lib/studio/component_gallery.rb +13 -3
- data/lib/studio/email_image_generator.rb +125 -0
- data/lib/studio/engine.rb +2 -5
- data/lib/studio/knowledge_recording.rb +579 -0
- data/lib/studio/knowledge_transcript.rb +185 -0
- data/lib/studio/s3/multipart.rb +193 -0
- data/lib/studio/theme_resolver.rb +11 -22
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +33 -1
- data/lib/tasks/studio_knowledge.rake +89 -0
- metadata +10 -1
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
module Studio
|
|
2
|
+
class KnowledgeDoc
|
|
3
|
+
# A document's RECORDING: the audio or video of the meeting whose transcript
|
|
4
|
+
# the document is. The transcript stays the document (s3_key); the recording
|
|
5
|
+
# is one more object in the same private bucket, pointed at by four columns
|
|
6
|
+
# (recording_key, recording_mime_type, recording_byte_size,
|
|
7
|
+
# recording_source_url) that the AddRecordingToStudioKnowledgeDocs migration
|
|
8
|
+
# adds.
|
|
9
|
+
#
|
|
10
|
+
# A CONSUMER THAT HAS NOT INSTALLED THAT MIGRATION KEEPS WORKING. Every
|
|
11
|
+
# reader here asks `has_attribute?` first, so on the old schema `recording?`
|
|
12
|
+
# is false and `recording_link` is nil; only the two attach methods raise
|
|
13
|
+
# (MissingRecordingColumns, naming the fix) and they raise before any byte
|
|
14
|
+
# moves.
|
|
15
|
+
#
|
|
16
|
+
# What counts as a recording, the size cap and the URL fetch live in
|
|
17
|
+
# Studio::KnowledgeRecording; the upload is Studio::S3::Multipart. Both load
|
|
18
|
+
# on first use.
|
|
19
|
+
module Recording
|
|
20
|
+
extend ActiveSupport::Concern
|
|
21
|
+
|
|
22
|
+
# The app has the documents table but not the recording columns.
|
|
23
|
+
class MissingRecordingColumns < StandardError; end
|
|
24
|
+
|
|
25
|
+
# How long a playback URL is good for: six hours. A presigned URL is
|
|
26
|
+
# checked on EVERY request the player makes, and a player makes a new
|
|
27
|
+
# ranged request each time the viewer seeks, so the URL has to outlive
|
|
28
|
+
# the whole sitting, not just the page load: the 15-minute document link
|
|
29
|
+
# would stop a recording at its first seek past minute fifteen. Six hours
|
|
30
|
+
# is twice the longest meeting the layer is expected to hold, which covers
|
|
31
|
+
# one viewing with pauses. It is far under SigV4's seven-day ceiling
|
|
32
|
+
# (MAX_RECORDING_URL_TTL), and the URL is only ever handed to a signed-in
|
|
33
|
+
# admin's page.
|
|
34
|
+
RECORDING_URL_TTL = 6 * 60 * 60
|
|
35
|
+
MAX_RECORDING_URL_TTL = 7 * 24 * 60 * 60
|
|
36
|
+
|
|
37
|
+
RECORDING_COLUMNS = %w[recording_key recording_mime_type recording_byte_size recording_source_url].freeze
|
|
38
|
+
|
|
39
|
+
included do
|
|
40
|
+
validate :recording_source_url_is_a_web_link
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class_methods do
|
|
44
|
+
# Whether this app's table carries the recording columns.
|
|
45
|
+
def recording_columns?
|
|
46
|
+
table_exists? && (RECORDING_COLUMNS - column_names).empty?
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A recording is stored for this document. False, never an error, on a
|
|
51
|
+
# table without the columns.
|
|
52
|
+
def recording?
|
|
53
|
+
has_attribute?(:recording_key) && self[:recording_key].present?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# :video or :audio, by the stored content type; nil without a recording.
|
|
57
|
+
def recording_kind
|
|
58
|
+
return nil unless recording?
|
|
59
|
+
|
|
60
|
+
self[:recording_mime_type].to_s.start_with?("audio/") ? :audio : :video
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The external page the recording came from, when it is a link a page can
|
|
64
|
+
# safely render (http or https); nil otherwise, and nil without the column.
|
|
65
|
+
def recording_link
|
|
66
|
+
return nil unless has_attribute?(:recording_source_url)
|
|
67
|
+
|
|
68
|
+
require "studio/knowledge_recording"
|
|
69
|
+
Studio::KnowledgeRecording.web_link(self[:recording_source_url])
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# A presigned GET for the player. See RECORDING_URL_TTL for the default.
|
|
73
|
+
def recording_url(expires_in: RECORDING_URL_TTL)
|
|
74
|
+
raise Studio::S3::Error, "no recording attached to #{title.inspect}" unless recording?
|
|
75
|
+
|
|
76
|
+
seconds = expires_in.to_i
|
|
77
|
+
unless seconds.between?(1, MAX_RECORDING_URL_TTL)
|
|
78
|
+
raise ArgumentError, "expires_in must be between 1 and #{MAX_RECORDING_URL_TTL} seconds, got #{expires_in.inspect}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
Studio::S3.signed_url(key: self[:recording_key], expires_in: seconds)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Stores the file at `path` as this document's recording and SAVES the
|
|
85
|
+
# row. The file is streamed in parts, never read whole.
|
|
86
|
+
#
|
|
87
|
+
# filename: the name whose extension is judged and kept in the key
|
|
88
|
+
# (default: the path's own)
|
|
89
|
+
# source_url: the external page the recording came from; nil leaves
|
|
90
|
+
# the stored link as it is
|
|
91
|
+
#
|
|
92
|
+
# Order, and what each failure leaves behind:
|
|
93
|
+
# 1. columns, link, file type and size, and the row's own validity are
|
|
94
|
+
# checked. Nothing has been written.
|
|
95
|
+
# 2. the new object is uploaded under a new key. Any failure, a signal
|
|
96
|
+
# included, aborts the multipart upload; the row is untouched.
|
|
97
|
+
# 3. the row is saved, under a row lock that also reads which recording
|
|
98
|
+
# it replaces. Any failure, a signal included, trashes the new
|
|
99
|
+
# object and re-raises.
|
|
100
|
+
# 4. the recording this one replaced goes to trash (Studio::S3.delete,
|
|
101
|
+
# recoverable for three days). A failure here raises with the row
|
|
102
|
+
# already pointing at the new recording; the old object is the only
|
|
103
|
+
# thing left behind.
|
|
104
|
+
#
|
|
105
|
+
# A process KILLED between 2 and 3 leaves a complete object no row
|
|
106
|
+
# references, under `knowledge/<entity>/<path>/…-recording-…`.
|
|
107
|
+
#
|
|
108
|
+
# Two attaches to one document at once are safe for the row and the
|
|
109
|
+
# bucket: step 3 reads the key it replaces from the database, not from
|
|
110
|
+
# this instance, so whichever saves second trashes the first one's
|
|
111
|
+
# object. One recording wins; neither is orphaned.
|
|
112
|
+
def attach_recording!(path, filename: nil, source_url: nil)
|
|
113
|
+
ensure_recording_columns!
|
|
114
|
+
require "studio/knowledge_recording"
|
|
115
|
+
require "studio/s3/multipart"
|
|
116
|
+
|
|
117
|
+
link = checked_recording_link(source_url)
|
|
118
|
+
name = filename || File.basename(path.to_s)
|
|
119
|
+
identified = Studio::KnowledgeRecording.identify!(path, filename: name)
|
|
120
|
+
validate!
|
|
121
|
+
|
|
122
|
+
key = new_recording_key(name, identified.extension)
|
|
123
|
+
uploaded = Studio::S3::Multipart.upload_file(key: key, path: path, content_type: identified.content_type,
|
|
124
|
+
max_bytes: Studio::KnowledgeRecording::MAX_BYTES)
|
|
125
|
+
attributes = { recording_key: key, recording_mime_type: identified.content_type,
|
|
126
|
+
recording_byte_size: uploaded.byte_size }
|
|
127
|
+
attributes[:recording_source_url] = link if link
|
|
128
|
+
replaced = nil
|
|
129
|
+
saved = false
|
|
130
|
+
begin
|
|
131
|
+
self.class.transaction do
|
|
132
|
+
replaced = self.class.lock.where(id: id).pick(:recording_key)
|
|
133
|
+
update!(attributes)
|
|
134
|
+
end
|
|
135
|
+
saved = true
|
|
136
|
+
ensure
|
|
137
|
+
trash_unreferenced_recording(key) unless saved
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
Studio::S3.delete(key: replaced) if replaced.present? && replaced != key
|
|
141
|
+
self
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Fetches `url` to a temporary file and attaches it. The fetch is https
|
|
145
|
+
# only and goes through the engine's SSRF guard on every hop; a download
|
|
146
|
+
# that ends short of its declared length raises before anything is
|
|
147
|
+
# uploaded, so the recording already stored is untouched. See
|
|
148
|
+
# Studio::KnowledgeRecording.fetch.
|
|
149
|
+
#
|
|
150
|
+
# NOTHING OF THE DOWNLOAD URL IS STORED OR USED AS A NAME: its query and
|
|
151
|
+
# its path can both carry a credential. Pass `filename:` to name the
|
|
152
|
+
# object (default "recording") and `source_url:` for the page to link.
|
|
153
|
+
def attach_recording_from_url!(url, filename: nil, source_url: nil, resolver: nil)
|
|
154
|
+
ensure_recording_columns!
|
|
155
|
+
require "studio/knowledge_recording"
|
|
156
|
+
checked_recording_link(source_url)
|
|
157
|
+
|
|
158
|
+
options = resolver ? { resolver: resolver } : {}
|
|
159
|
+
Studio::KnowledgeRecording.fetch(url, **options) do |path|
|
|
160
|
+
attach_recording!(path, filename: filename || "recording", source_url: source_url)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
def ensure_recording_columns!
|
|
167
|
+
return if self.class.recording_columns?
|
|
168
|
+
|
|
169
|
+
raise MissingRecordingColumns,
|
|
170
|
+
"Attaching a recording needs the recording columns on studio_knowledge_docs, and " \
|
|
171
|
+
"#{Studio.app_name} does not have them. Run `bin/rails studio_engine:install:migrations && " \
|
|
172
|
+
"bin/rails db:migrate`."
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def checked_recording_link(source_url)
|
|
176
|
+
return nil if source_url.nil? || source_url.to_s.strip.empty?
|
|
177
|
+
|
|
178
|
+
Studio::KnowledgeRecording.web_link(source_url) ||
|
|
179
|
+
raise(ArgumentError, "source_url must be an http(s) link of at most " \
|
|
180
|
+
"#{Studio::KnowledgeRecording::MAX_URL_BYTES} bytes")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Beside the document's own key, with the same collision-proofing
|
|
184
|
+
# attach! uses (a second-granular timestamp plus a random suffix).
|
|
185
|
+
def new_recording_key(name, extension)
|
|
186
|
+
file = self.class.sanitize_filename(Studio::KnowledgeRecording.filename_for(name, extension))
|
|
187
|
+
[
|
|
188
|
+
"knowledge", entity, path.presence,
|
|
189
|
+
"#{Time.current.strftime('%Y%m%d%H%M%S')}-#{SecureRandom.hex(4)}-recording-#{file}"
|
|
190
|
+
].compact.join("/")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# The row never took the new object, so nothing points at it. Trashing
|
|
194
|
+
# it must not hide the error that got us here.
|
|
195
|
+
def trash_unreferenced_recording(key)
|
|
196
|
+
Studio::S3.delete(key: key)
|
|
197
|
+
rescue StandardError => error
|
|
198
|
+
Rails.logger&.error("[Studio::KnowledgeDoc] could not trash unreferenced recording #{key}: #{error.class}")
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def recording_source_url_is_a_web_link
|
|
202
|
+
return unless has_attribute?(:recording_source_url)
|
|
203
|
+
|
|
204
|
+
value = self[:recording_source_url]
|
|
205
|
+
return if value.blank?
|
|
206
|
+
|
|
207
|
+
require "studio/knowledge_recording"
|
|
208
|
+
return if Studio::KnowledgeRecording.web_link(value)
|
|
209
|
+
|
|
210
|
+
errors.add(:recording_source_url, "must be an http(s) link")
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -35,6 +35,9 @@ module Studio
|
|
|
35
35
|
ACCESS_LEVELS = %w[full aware none].freeze
|
|
36
36
|
STATUSES = %w[inbox filed superseded].freeze
|
|
37
37
|
|
|
38
|
+
# The meeting recording stored beside a transcript (knowledge_doc/recording.rb).
|
|
39
|
+
include Recording
|
|
40
|
+
|
|
38
41
|
# The expectation this document fulfills (coverage view) — optional, set at
|
|
39
42
|
# triage. Guarded: consumers that installed the docs table before the
|
|
40
43
|
# expectations migration existed simply have no column yet.
|
|
@@ -175,11 +175,19 @@
|
|
|
175
175
|
registers nothing twice. A module tag, so it carries the nonce: never a bare
|
|
176
176
|
inline fallback (e2e/stores_survive_boot_failure.spec.js). %>
|
|
177
177
|
<%= javascript_import_module_tag "studio/alpine_stores" %>
|
|
178
|
+
<%# THE MODAL STACK, BY ITS OWN DOOR, for the same reason. studio/modal_host
|
|
179
|
+
registers $store.modals (and each page-scoped host's store) on alpine:init and
|
|
180
|
+
publishes window.ModalAnimations and window.StudioModals; a host's template
|
|
181
|
+
binds $store.modals.current(), so a failed boot must not take the store with
|
|
182
|
+
it. It imports nothing, and the modal-host controller imports it, so this
|
|
183
|
+
resolves to the same preloaded module. %>
|
|
184
|
+
<%= javascript_import_module_tag "studio/modal_host" %>
|
|
178
185
|
<%# ALPINE LOADS AFTER THE MODULE TAGS, and the order is the contract. Deferred
|
|
179
186
|
classic scripts and module scripts execute in document order, so by the time
|
|
180
187
|
Alpine starts, studio/application has defined the shims it evaluates
|
|
181
|
-
(x-data="navCollapse()")
|
|
182
|
-
`alpine:init` listener that installs $store.theme and $store.devMode
|
|
188
|
+
(x-data="navCollapse()"), studio/alpine_stores has registered the
|
|
189
|
+
`alpine:init` listener that installs $store.theme and $store.devMode, and
|
|
190
|
+
studio/modal_host the one that installs each modal host's store. Moved
|
|
183
191
|
above the module tags, Alpine would evaluate those before they exist (e2e/stimulus_boot.spec.js). %>
|
|
184
192
|
<%# Alpine is VENDORED into the engine (studio/alpine.js) and shipped through the
|
|
185
193
|
asset pipeline — no CDN, CSP-safe (same-origin :self), mirroring canvas_confetti
|
|
@@ -66,6 +66,19 @@
|
|
|
66
66
|
</p>
|
|
67
67
|
</header>
|
|
68
68
|
|
|
69
|
+
<%# The app's own banner generator, when it configured one
|
|
70
|
+
(config.email_manager_generator_url; see Studio::EmailImageGenerator). Unset
|
|
71
|
+
draws nothing. The URL is an http(s) URL by construction, and every value
|
|
72
|
+
below is escaped by ERB output tags. %>
|
|
73
|
+
<% generator = Studio.email_manager_generator_link(controller&.request) %>
|
|
74
|
+
<% if generator %>
|
|
75
|
+
<div class="mb-6 flex flex-col sm:flex-row sm:items-center gap-3 rounded-xl border border-subtle bg-inset px-4 py-3"
|
|
76
|
+
data-email-image-generator>
|
|
77
|
+
<p class="text-sm text-body flex-1 min-w-0"><%= generator.description %></p>
|
|
78
|
+
<a href="<%= generator.url %>" target="_blank" rel="noopener noreferrer"
|
|
79
|
+
class="btn btn-primary btn-sm shrink-0 self-start sm:self-auto"><%= generator.label %></a>
|
|
80
|
+
</div>
|
|
81
|
+
<% end %>
|
|
69
82
|
<%# No flash block here on purpose — every host layout already renders the
|
|
70
83
|
shared layouts/studio/flash partial, so the controller's notice/alert
|
|
71
84
|
surfaces once rather than twice. %>
|