xolo-server 1.0.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 (42) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +177 -0
  3. data/README.md +7 -0
  4. data/bin/xoloserver +106 -0
  5. data/data/com.pixar.xoloserver.plist +29 -0
  6. data/data/uninstall-pkgs-by-id.zsh +103 -0
  7. data/lib/xolo/server/app.rb +133 -0
  8. data/lib/xolo/server/command_line.rb +216 -0
  9. data/lib/xolo/server/configuration.rb +739 -0
  10. data/lib/xolo/server/constants.rb +70 -0
  11. data/lib/xolo/server/helpers/auth.rb +257 -0
  12. data/lib/xolo/server/helpers/client_data.rb +415 -0
  13. data/lib/xolo/server/helpers/file_transfers.rb +265 -0
  14. data/lib/xolo/server/helpers/jamf_pro.rb +156 -0
  15. data/lib/xolo/server/helpers/log.rb +97 -0
  16. data/lib/xolo/server/helpers/maintenance.rb +401 -0
  17. data/lib/xolo/server/helpers/notification.rb +145 -0
  18. data/lib/xolo/server/helpers/pkg_signing.rb +141 -0
  19. data/lib/xolo/server/helpers/progress_streaming.rb +252 -0
  20. data/lib/xolo/server/helpers/title_editor.rb +92 -0
  21. data/lib/xolo/server/helpers/titles.rb +145 -0
  22. data/lib/xolo/server/helpers/versions.rb +160 -0
  23. data/lib/xolo/server/log.rb +286 -0
  24. data/lib/xolo/server/mixins/changelog.rb +315 -0
  25. data/lib/xolo/server/mixins/title_jamf_access.rb +1668 -0
  26. data/lib/xolo/server/mixins/title_ted_access.rb +519 -0
  27. data/lib/xolo/server/mixins/version_jamf_access.rb +1541 -0
  28. data/lib/xolo/server/mixins/version_ted_access.rb +373 -0
  29. data/lib/xolo/server/object_locks.rb +102 -0
  30. data/lib/xolo/server/routes/auth.rb +89 -0
  31. data/lib/xolo/server/routes/jamf_pro.rb +89 -0
  32. data/lib/xolo/server/routes/maint.rb +174 -0
  33. data/lib/xolo/server/routes/title_editor.rb +71 -0
  34. data/lib/xolo/server/routes/titles.rb +285 -0
  35. data/lib/xolo/server/routes/uploads.rb +93 -0
  36. data/lib/xolo/server/routes/versions.rb +261 -0
  37. data/lib/xolo/server/routes.rb +168 -0
  38. data/lib/xolo/server/title.rb +1143 -0
  39. data/lib/xolo/server/version.rb +902 -0
  40. data/lib/xolo/server.rb +205 -0
  41. data/lib/xolo-server.rb +8 -0
  42. metadata +243 -0
@@ -0,0 +1,145 @@
1
+ # Copyright 2025 Pixar
2
+ #
3
+ # Licensed under the terms set forth in the LICENSE.txt file available at
4
+ # at the root of this project.
5
+ #
6
+ #
7
+
8
+ # frozen_string_literal: true
9
+
10
+ # main module
11
+ module Xolo
12
+
13
+ module Server
14
+
15
+ module Helpers
16
+
17
+ # This is mixed in to Xolo::Server::App (as a helper, available in route processing)
18
+ # and in Xolo::Server::Title and Xolo::Server::Version.
19
+ #
20
+ # This holds methods and constants for sending alerts and emails.
21
+ #
22
+ module Notification
23
+
24
+ # Constants
25
+ #####################
26
+ #####################
27
+
28
+ DFT_EMAIL_FROM = 'xolo-server-do-not-reply'
29
+
30
+ ALERT_TOOL_EMAIL_PREFIX = 'email:'
31
+
32
+ # Module Methods
33
+ #######################
34
+ #######################
35
+
36
+ # when this module is included
37
+ def self.included(includer)
38
+ Xolo.verbose_include includer, self
39
+ end
40
+
41
+ # Instance Methods
42
+ #######################
43
+ ######################
44
+
45
+ # Send a message thru the alert_tool, if one is defined in the config.
46
+ #
47
+ # Messages are prepended with "#{level} ALERT: "
48
+ # This should be called by passing alert: true to one of the
49
+ # logging wrapper methods
50
+ #
51
+ # @param msg [String] the message to send
52
+ # @param level [Symbol] the log level of the message
53
+ #
54
+ # @return [void]
55
+ ###############################
56
+ def send_alert(msg, level)
57
+ return unless Xolo::Server.config.alert_tool
58
+ return if send_email_alert(msg, level)
59
+
60
+ alerter = nil # just in case we need the ensure clause below.
61
+ alerter = IO.popen(Xolo::Server.config.alert_tool, 'w')
62
+ alerter.puts "#{level} ALERT: #{msg}"
63
+
64
+ # this catches the quitting of the alerter before expected
65
+ rescue Errno::EPIPE
66
+ true
67
+ ensure
68
+ # this flushes the pipe and makes the msg go
69
+ alerter&.close
70
+ end
71
+
72
+ # Send an alert via email
73
+ # @param msg [String] the message to send
74
+ # @param level [Symbol] the log level of the message
75
+ #
76
+ # @return [Boolean] true if the email was sent, false otherwise
77
+ ################################
78
+ def send_email_alert(msg, level)
79
+ return false unless Xolo::Server.config.smtp_server
80
+ return false unless Xolo::Server.config.alert_tool.start_with? ALERT_TOOL_EMAIL_PREFIX
81
+
82
+ send_email(
83
+ to: Xolo::Server.config.alert_tool.delete_prefix(ALERT_TOOL_EMAIL_PREFIX).strip,
84
+ subject: "#{level} ALERT from Xolo Server",
85
+ msg: msg
86
+ )
87
+ true
88
+ end
89
+
90
+ # Send an email, if the smtp_server is defined in the config.
91
+ #
92
+ # @param to [String] the email address to send to
93
+ # @param subject [String] the subject of the email
94
+ # @param msg [String] the body of the email
95
+ # @param html [Boolean] should the email be sent as HTML?
96
+ #
97
+ # @return [void]
98
+ ###############################
99
+ def send_email(to:, subject:, msg:, html: false)
100
+ return unless Xolo::Server.config.smtp_server
101
+
102
+ headers = [
103
+ "From: #{server_name} <#{email_from}>",
104
+ "Date: #{Time.now.rfc2822}",
105
+ "To: #{to} <#{to}>",
106
+ "Subject: #{subject}"
107
+ ]
108
+
109
+ if html
110
+ headers << 'MIME-Version: 1.0'
111
+ headers << 'Content-type: text/html'
112
+ end
113
+
114
+ msg = "#{headers.join "\n"}\n\n#{msg}"
115
+
116
+ Net::SMTP.start(Xolo::Server.config.smtp_server) do |smtp|
117
+ smtp.send_message msg, email_from, to
118
+ end
119
+ end
120
+
121
+ # @return [String] the from address for emails
122
+ ###############################
123
+ def email_from
124
+ @email_from ||= Xolo::Server.config.email_from || "#{DFT_EMAIL_FROM}@#{server_fqdn}"
125
+ end
126
+
127
+ # @return [String] the human-readable name of the server for sending emails
128
+ ###############################
129
+ def server_name
130
+ @server_name ||= "Xolo Server on #{server_fqdn}"
131
+ end
132
+
133
+ # @return [String] the server's fully qualified domain name
134
+ ###############################
135
+ def server_fqdn
136
+ @server_fqdn ||= Addrinfo.getaddrinfo(Socket.gethostname, nil).first.getnameinfo.first
137
+ end
138
+
139
+ end # Log
140
+
141
+ end # Helpers
142
+
143
+ end # Server
144
+
145
+ end # module Xolo
@@ -0,0 +1,141 @@
1
+ # Copyright 2025 Pixar
2
+ #
3
+ # Licensed under the terms set forth in the LICENSE.txt file available at
4
+ # at the root of this project.
5
+ #
6
+ #
7
+
8
+ # frozen_string_literal: true
9
+
10
+ # main module
11
+ module Xolo
12
+
13
+ module Server
14
+
15
+ module Helpers
16
+
17
+ # constants and methods for signing packages
18
+ module PkgSigning
19
+
20
+ # Module methods
21
+ #
22
+ # These are available as module methods but not as 'helper'
23
+ # methods in sinatra routes & views.
24
+ #
25
+ ##############################
26
+ ##############################
27
+
28
+ # when this module is included
29
+ ##############################
30
+ def self.included(includer)
31
+ Xolo.verbose_include includer, self
32
+ end
33
+
34
+ # when this module is extended
35
+ def self.extended(extender)
36
+ Xolo.verbose_extend extender, self
37
+ end
38
+
39
+ # Instance methods
40
+ #
41
+ # These are available directly in sinatra routes and views
42
+ #
43
+ ##############################
44
+ ##############################
45
+
46
+ # do we need to sign a pkg?
47
+ # TODO: sign zipped bundle installers? prob not, they shouldn't be used anymore
48
+ # (I'm looking at YOU Adobe)
49
+ # @param pkg [Pathname] Path to a .pkg to see if it's signed.
50
+ # @return [Boolean] should we sign it?
51
+ def need_to_sign?(pkg)
52
+ log_debug "Checking need to sign uploaded pkg '#{pkg}'"
53
+ unless Xolo::Server.config.sign_pkgs
54
+ log_debug "No need to sign '#{pkg.basename}': xolo server is not configured to sign pkgs."
55
+ return false
56
+ end
57
+ if pkg.extname == Xolo::DOT_ZIP
58
+ log_debug "No need to sign '#{pkg.basename}': It is a compressed .pkg bundle. TODO: maybe support signing these?"
59
+ return false
60
+ end
61
+
62
+ !pkg_signed?(pkg)
63
+ end
64
+
65
+ # @param pkg [Pathname] Path to a .pkg to see if it's already signed.
66
+ # @return [Boolean] is then pkg at the given pathname signed?
67
+ #########################
68
+ def pkg_signed?(pkg)
69
+ `/usr/sbin/pkgutil --check-signature #{Shellwords.escape pkg.to_s}`
70
+ already_signed = $CHILD_STATUS.success?
71
+ if already_signed
72
+ log_debug "No need to sign '#{pkg.basename}': It is already signed."
73
+ else
74
+ log_debug "About to sign '#{pkg.basename}'"
75
+ end
76
+ already_signed
77
+ end
78
+
79
+ # Sign a package
80
+ #
81
+ # @param unsigned_pkg [Pathname] the unsigned pkg to sign
82
+ # @param signed_pkg [Pathname] the destination file to write the signed version of the pkg.
83
+ #
84
+ # @return [void]
85
+ #######################################################
86
+ def sign_uploaded_pkg(unsigned_pkg, signed_pkg)
87
+ unlock_signing_keychain
88
+
89
+ sh_unsigned = Shellwords.escape unsigned_pkg.to_s
90
+ sh_signed = Shellwords.escape signed_pkg.to_s
91
+ sh_kch = Shellwords.escape Xolo::Server::Configuration::PKG_SIGNING_KEYCHAIN.to_s
92
+ sh_ident = Shellwords.escape Xolo::Server.config.pkg_signing_identity
93
+
94
+ cmd = "/usr/bin/productsign --sign #{sh_ident} --keychain #{sh_kch} #{sh_unsigned} #{sh_signed}"
95
+ log_debug "Signing #{signed_pkg.basename} using this command: #{cmd}"
96
+
97
+ stdouterr, exit_status = Open3.capture2e(cmd)
98
+ return if exit_status.success?
99
+
100
+ msg = "Failed to sign uploaded pkg: #{stdouterr}"
101
+ log_error msg
102
+ halt 400, { status: 400, error: msg }
103
+ end
104
+
105
+ # unlock the pkg signing keychain
106
+ # TODO: Be DRY with the keychain stuff in Xolo::Admin::Credentials
107
+ #############################
108
+ def unlock_signing_keychain
109
+ log_debug 'Unlocking the signing keychain'
110
+
111
+ pw = Xolo::Server.config.pkg_signing_keychain_pw
112
+ # first escape backslashes
113
+ pw = pw.to_s.gsub '\\', '\\\\\\'
114
+ # then single quotes
115
+ pw.gsub! "'", "\\\\'"
116
+ # then warp in sgl quotes
117
+ pw = "'#{pw}'"
118
+
119
+ outerrs = Xolo::BLANK
120
+ exit_status = nil
121
+
122
+ Open3.popen2e('/usr/bin/security -i') do |stdin, stdout_err, wait_thr|
123
+ stdin.puts "unlock-keychain -p #{pw} '#{Xolo::Server::Configuration::PKG_SIGNING_KEYCHAIN}'"
124
+ stdin.close
125
+ outerrs = stdout_err.read
126
+ exit_status = wait_thr.value # Process::Status object returned.
127
+ end # Open3.popen2e
128
+ return if exit_status.success?
129
+
130
+ msg = "Error unlocking signing keychain: #{outerrs}"
131
+ log_error msg
132
+ halt 400, { status: 400, error: msg }
133
+ end
134
+
135
+ end # JamfPro
136
+
137
+ end # Helpers
138
+
139
+ end # Server
140
+
141
+ end # module Xolo
@@ -0,0 +1,252 @@
1
+ # Copyright 2025 Pixar
2
+ #
3
+ # Licensed under the terms set forth in the LICENSE.txt file available at
4
+ # at the root of this project.
5
+ #
6
+ #
7
+
8
+ # frozen_string_literal: true
9
+
10
+ # main module
11
+ module Xolo
12
+
13
+ module Server
14
+
15
+ module Helpers
16
+
17
+ # This is used both as a 'helper' in the Sinatra server,
18
+ # and an included mixin for the Xolo::Server::Title and
19
+ # Xolo::Server::Version classes
20
+ # to provide common methods for long-running routes that deliver
21
+ # realtime progress updates via http streaming.
22
+ module ProgressStreaming
23
+
24
+ # Constants
25
+ #######################
26
+ #######################
27
+
28
+ PROGRESS_THREAD_NAME_PREFIX = 'xolo-progress-stream-'
29
+
30
+ # Module Methods
31
+ #######################
32
+ #######################
33
+
34
+ # when this module is included
35
+ def self.included(includer)
36
+ Xolo.verbose_include includer, self
37
+ end
38
+
39
+ # Instance Methods
40
+ #######################
41
+ #######################
42
+
43
+ # Call this from long-running routes.
44
+ #
45
+ # It runs a block in a thread with streaming
46
+ #
47
+ # The block should call #progress as needed to write
48
+ # to the progress file, and optionally the log
49
+ #
50
+ # Always sends back a JSON response body with
51
+ # {
52
+ # status: :running,
53
+ # progress_stream_url_path: progress_stream_url_path
54
+ # }
55
+ # Any errors should be written to the stream file,
56
+ # as will unhandled exceptions.
57
+ #
58
+ # @yield The block to run in the thread with streaming
59
+ ##########################
60
+ def with_streaming
61
+ raise 'No block given to run in streaming thread' unless block_given?
62
+
63
+ @streaming_now = true
64
+
65
+ # always call this first in a
66
+ # long-running route that will use progress streaming
67
+ setup_progress_streaming
68
+
69
+ log_debug 'Starting with_streaming block in thread'
70
+
71
+ @streaming_thread = Thread.new do
72
+ log_debug 'Thread with_streaming is starting and yielding to block'
73
+ yield
74
+ log_debug 'Thread with_streaming is finished'
75
+ rescue StandardError => e
76
+ progress "ERROR: #{e.class}: #{e}", log: :error
77
+ e.backtrace.each { |l| log_debug "..#{l}" }
78
+ ensure
79
+ stop_progress_streaming
80
+ end
81
+
82
+ @streaming_thread.name = "#{PROGRESS_THREAD_NAME_PREFIX}#{session[:xolo_id]}"
83
+
84
+ resp_body = {
85
+ status: :running,
86
+ progress_stream_url_path: progress_stream_url_path
87
+ }
88
+ body resp_body
89
+ end
90
+
91
+ # Setup for streaming:
92
+ # create the tmp file so that any threads will see it
93
+ # and do any other pre-streaming stuff
94
+ #
95
+ # @param xadm_command [String] the xadm command that is causing this stream.
96
+ # may be used for finding a returning the progress file after the fact.
97
+ #
98
+ ##########################
99
+ def setup_progress_streaming
100
+ log_debug "Setting up for progress streaming. progress_stream_file is: #{progress_stream_file}"
101
+ end
102
+
103
+ # End che current progress stream
104
+ ###############################
105
+ def stop_progress_streaming
106
+ log_debug "Stopping progress streaming to file: #{progress_stream_file}"
107
+ progress Xolo::Server::PROGRESS_COMPLETE
108
+ end
109
+
110
+ # The file to which we write progess messages
111
+ # for long-running processes, which might in turn
112
+ # be streamed to xadm now or in the future
113
+ #
114
+ #############################
115
+ def progress_stream_file
116
+ return @progress_stream_file if @progress_stream_file
117
+
118
+ tempf = Tempfile.create "#{PROGRESS_THREAD_NAME_PREFIX}#{session[:xolo_id]}-"
119
+ tempf.close # we'll write to it later
120
+ log_debug "Created progress_stream_file: #{tempf.path}"
121
+ @progress_stream_file = Pathname.new(tempf.path)
122
+ @progress_stream_file.pix_touch
123
+ @progress_stream_file
124
+ end
125
+
126
+ # The file to which we write progess messages
127
+ # for long-running processes, which might in turn
128
+ # be streamed to xadm.
129
+ #############################
130
+ def progress_stream_url_path
131
+ "/streamed_progress/?stream_file=#{CGI.escape progress_stream_file.to_s}"
132
+ end
133
+
134
+ # Append a message to the progress stream file,
135
+ # optionally sending it also to the log
136
+ #
137
+ # @param message [String] the message to append
138
+ # @param log [Symbol] the level at which to log the message
139
+ # one of :debug, :info, :warn, :error, :fatal, or :unknown.
140
+ # Default is nil, which doesn't log the message at all.
141
+ #
142
+ # @return [void]
143
+ ###################
144
+ def progress(msg, log: :nil)
145
+ # log_debug "Progress method called from #{caller_locations.first}"
146
+
147
+ progress_stream_file.pix_append "#{msg.chomp}\n"
148
+
149
+ unless log
150
+ # log_debug 'Processed unlogged progress message'
151
+ return
152
+ end
153
+
154
+ case log
155
+ when :debug
156
+ log_debug msg
157
+ when :info
158
+ log_info msg
159
+ when :warn
160
+ log_warn msg
161
+ when :error
162
+ log_error msg
163
+ when :fatal
164
+ log_fatal msg
165
+ when :unknown
166
+ log_unknown msg
167
+ end
168
+ end
169
+
170
+ # Stream lines from the given file to the given stream
171
+ #
172
+ # @param stream_file: [Pathname] the file to stream from
173
+ # @param stream: [Sinatra::Helpers::Stream] the stream to send to
174
+ #
175
+ # @return [void]
176
+ #############################
177
+ def stream_progress(stream_file:, stream:)
178
+ log_debug "About to tail: usr/bin/tail -f -c +1 #{Shellwords.escape stream_file.to_s}"
179
+
180
+ stdin, stdouterr, wait_thr = Open3.popen2e("/usr/bin/tail -f -c +1 #{Shellwords.escape stream_file.to_s}")
181
+ stdin.close
182
+
183
+ while line = stdouterr.gets
184
+ break if line.chomp == Xolo::Server::PROGRESS_COMPLETE
185
+
186
+ stream << line
187
+ end
188
+ stdouterr.close
189
+ wait_thr.exit
190
+ # TODO: deal with wait_thr.value.exitstatus > 0 ?
191
+ end
192
+
193
+ # TEMP TESTING - Thisis happening in a thread and
194
+ # should send updates via #progress
195
+ #
196
+ ######################
197
+ def a_long_thing_with_streamed_feedback
198
+ log_debug 'Starting a_long_thing_with_streamed_feedback'
199
+
200
+ progress 'Doing a quick thing...'
201
+ sleep 3
202
+ progress 'Quick thing done.'
203
+
204
+ progress "Doing a slow thing at #{Time.now} ..."
205
+ log_debug 'Starting thread in a_long_thing_with_streamed_feedback'
206
+
207
+ # even tho we are in a thread, if we want to
208
+ # send updates while some long sub-task is running
209
+ # we do it in another thread like this
210
+ long_thr = Thread.new { sleep 30 }
211
+ sleep 3
212
+
213
+ while long_thr.alive?
214
+ log_debug 'Thread still alive...'
215
+ progress "Slow thing still happening at #{Time.now} ..."
216
+ sleep 3
217
+ end
218
+
219
+ log_debug 'Thread in a_long_thing_with_streamed_feedback is done'
220
+
221
+ progress 'Slow thing done.'
222
+
223
+ progress "Doing a medium thing at #{Time.now} ..."
224
+ log_debug 'Starting another thread in a_long_thing_with_streamed_feedback - sends output from the thread'
225
+
226
+ # Doing this in a thead is just for academics...
227
+ # as is doing anything in a thread and immediately doing thr.join
228
+ med_thr = Thread.new do
229
+ 3.times do |x|
230
+ progress "the medium thing has done #{x + 1} things", log: :debug
231
+ sleep 5
232
+ end
233
+ end
234
+
235
+ progress 'Now waiting for medium thing to finish...'
236
+ med_thr.join
237
+
238
+ progress 'Medium thing is done.'
239
+ end
240
+
241
+ ########## TMP
242
+ def jsonify_stream_msg(msg)
243
+ @out_to_stream << { msg: msg }.to_json
244
+ end
245
+
246
+ end # Streaming
247
+
248
+ end # Helpers
249
+
250
+ end # Server
251
+
252
+ end # module Xolo
@@ -0,0 +1,92 @@
1
+ # Copyright 2025 Pixar
2
+ #
3
+ # Licensed under the terms set forth in the LICENSE.txt file available at
4
+ # at the root of this project.
5
+ #
6
+ #
7
+
8
+ # frozen_string_literal: true
9
+
10
+ # main module
11
+ module Xolo
12
+
13
+ module Server
14
+
15
+ module Helpers
16
+
17
+ # constants and methods for accessing the Title Editor server
18
+ #
19
+ # This is both uses as a 'helper' in the Sinatra server,
20
+ # and an included mixin for the Xolo::Server::Title and
21
+ # Xolo::Server::Version classes.
22
+ #
23
+ # This means methods here are available in instances of
24
+ # those classes, and in all routes, views, and helpers in
25
+ # Sinatra.
26
+ #
27
+ # NOTE: The names of various attributes of Title Editor SoftwareTitles
28
+ # and Xolo Titles are not always in sync.
29
+ # For example:
30
+ # - the :display_name of the Xolo Title is the :name of a SoftwareTitle
31
+ # - the :title of a Xolo Title id the :id of a SoftwareTitle
32
+ # - the numeric :softwareTitleId of the SoftwareTitle doesn't exist in a Xolo Title
33
+ #
34
+ # See Windoo::SoftwareTitle::JSON_ATTRIBUTES for more details about them.
35
+ # The Xolo server code will deal with all the translations.
36
+ #
37
+ module TitleEditor
38
+
39
+ # Module methods
40
+ #
41
+ # These are available as module methods but not as 'helper'
42
+ # methods in sinatra routes & views.
43
+ #
44
+ ##############################
45
+ ##############################
46
+
47
+ # when this module is included
48
+ ##############################
49
+ def self.included(includer)
50
+ Xolo.verbose_include includer, self
51
+ end
52
+
53
+ # Instance methods
54
+ #
55
+ # These are available directly in sinatra routes and views
56
+ #
57
+ ##############################
58
+ ##############################
59
+
60
+ # A connection to the Title Editor via Windoo
61
+ # We don't use the Windoo default connection but
62
+ # use this method to create standalone ones as needed
63
+ # and ensure they are disconnected, (or will timeout)
64
+ # when we are done.
65
+ #
66
+ # @return [Windoo::Connection] A connection object
67
+ ##############################
68
+ def ted_cnx
69
+ return @ted_cnx if @ted_cnx
70
+
71
+ @ted_cnx = Windoo::Connection.new(
72
+ name: "title-editor-cnx-#{Time.now.strftime('%F-%T')}",
73
+ host: Xolo::Server.config.ted_hostname,
74
+ user: Xolo::Server.config.ted_api_user,
75
+ pw: Xolo::Server.config.ted_api_pw,
76
+ open_timeout: Xolo::Server.config.ted_open_timeout,
77
+ timeout: Xolo::Server.config.ted_timeout,
78
+ keep_alive: false
79
+ )
80
+
81
+ log_debug "Title Editor: Connected at #{@ted_cnx.base_url}, user '#{Xolo::Server.config.ted_api_user}'. KeepAlive: #{@ted_cnx.keep_alive?}, Expires: #{@ted_cnx.token.expires}. cnxID: #{@ted_cnx.object_id}"
82
+
83
+ @ted_cnx
84
+ end
85
+
86
+ end # TitleEditor
87
+
88
+ end # Helpers
89
+
90
+ end # Server
91
+
92
+ end # module Xolo