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.
@@ -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()") and studio/alpine_stores has registered the
182
- `alpine:init` listener that installs $store.theme and $store.devMode. Moved
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. %>