webuntis-api 0.1.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 +7 -0
- data/CHANGELOG.md +26 -0
- data/LICENSE.txt +21 -0
- data/README.md +241 -0
- data/examples/class_teachers.rb +66 -0
- data/lib/webuntis/client.rb +540 -0
- data/lib/webuntis/cookie_jar.rb +55 -0
- data/lib/webuntis/element_type.rb +49 -0
- data/lib/webuntis/errors.rb +64 -0
- data/lib/webuntis/http/fake.rb +45 -0
- data/lib/webuntis/http/net_http.rb +93 -0
- data/lib/webuntis/http.rb +81 -0
- data/lib/webuntis/rest.rb +319 -0
- data/lib/webuntis/rpc.rb +182 -0
- data/lib/webuntis/school.rb +146 -0
- data/lib/webuntis/session.rb +33 -0
- data/lib/webuntis/totp.rb +29 -0
- data/lib/webuntis/util.rb +85 -0
- data/lib/webuntis/version.rb +5 -0
- data/lib/webuntis.rb +29 -0
- metadata +63 -0
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module WebUntis
|
|
9
|
+
# A WebUntis session: logs in, keeps cookies and the JWT fresh, and exposes
|
|
10
|
+
# the {#rpc} and {#rest} namespaces.
|
|
11
|
+
class Client
|
|
12
|
+
DEFAULT_CLIENT_NAME = "webuntis-api"
|
|
13
|
+
TOKEN_SKEW = 30
|
|
14
|
+
TOKEN_FALLBACK_TTL = 600
|
|
15
|
+
ANONYMOUS_USER = "#anonymous#"
|
|
16
|
+
ANONYMOUS_OTP = "100170"
|
|
17
|
+
APP_DATA_PATH = "api/rest/view/v1/app/data"
|
|
18
|
+
LOGIN_MODES = %i[rpc form secret anonymous].freeze
|
|
19
|
+
|
|
20
|
+
# The current {Session}, the cookie jar, the logger, the `client` name sent to `authenticate`
|
|
21
|
+
# and the login flow in use (`:rpc`, `:form`, `:secret` or `:anonymous`).
|
|
22
|
+
attr_reader :session, :cookies, :logger, :client_name, :login_mode
|
|
23
|
+
|
|
24
|
+
def initialize(school:, username: nil, password: nil, secret: nil, anonymous: false,
|
|
25
|
+
server: nil, tenant_id: nil, login: nil, school_year_id: nil,
|
|
26
|
+
client_name: DEFAULT_CLIENT_NAME, http: nil, logger: nil,
|
|
27
|
+
open_timeout: 10, read_timeout: 30, now: nil)
|
|
28
|
+
@school_query = school
|
|
29
|
+
@username = username
|
|
30
|
+
@password = password
|
|
31
|
+
@secret = secret
|
|
32
|
+
@anonymous = anonymous
|
|
33
|
+
@server = server
|
|
34
|
+
@configured_tenant_id = tenant_id
|
|
35
|
+
@login_mode = validate_login_mode(login || derive_login_mode)
|
|
36
|
+
@school_year_id = school_year_id
|
|
37
|
+
@client_name = client_name
|
|
38
|
+
@http = http || HTTP::NetHttp.new(open_timeout: open_timeout, read_timeout: read_timeout)
|
|
39
|
+
@logger = logger
|
|
40
|
+
@now = now || -> { Time.now }
|
|
41
|
+
@session = Session.new
|
|
42
|
+
@cookies = CookieJar.new
|
|
43
|
+
@logged_in = false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The resolved tenant; discovered via {School.find} when no `server:` was given.
|
|
47
|
+
def school
|
|
48
|
+
@school ||= @server ? explicit_school : School.find(@school_query, http: @http)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The `https://<host>/WebUntis` prefix every request is built from.
|
|
52
|
+
def base_url
|
|
53
|
+
school.base_url
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The JSON-RPC namespace (`jsonrpc.do`).
|
|
57
|
+
def rpc
|
|
58
|
+
@rpc ||= RPC.new(self)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The modern REST namespace (`api/rest/view/...` plus the legacy `api/...` endpoints).
|
|
62
|
+
def rest
|
|
63
|
+
@rest ||= REST.new(self)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# True once a login succeeded and the session has not been invalidated since.
|
|
67
|
+
def logged_in?
|
|
68
|
+
@logged_in
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Logs in with the configured flow, replacing any existing session.
|
|
72
|
+
def login!
|
|
73
|
+
reset_state!
|
|
74
|
+
case @login_mode
|
|
75
|
+
when :rpc then login_rpc!
|
|
76
|
+
when :form then login_form!
|
|
77
|
+
when :secret then login_otp!(TOTP.code(@secret, at: now), @username)
|
|
78
|
+
when :anonymous then login_otp!(ANONYMOUS_OTP, ANONYMOUS_USER)
|
|
79
|
+
end
|
|
80
|
+
@logged_in = true
|
|
81
|
+
@session.tenant_id ||= tenant_id_without_app_data
|
|
82
|
+
self
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Ends the session via the `logout` JSON-RPC method and drops all local state.
|
|
86
|
+
def logout!
|
|
87
|
+
return self unless @logged_in
|
|
88
|
+
|
|
89
|
+
begin
|
|
90
|
+
rpc_post("logout", {})
|
|
91
|
+
rescue Error
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
reset_state!
|
|
95
|
+
self
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Logs in unless a session is already open.
|
|
99
|
+
def ensure_login!
|
|
100
|
+
login! unless @logged_in
|
|
101
|
+
self
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The tenant id, from the constructor, the `Tenant-Id` cookie, discovery, or `app/data`.
|
|
105
|
+
def tenant_id
|
|
106
|
+
@tenant_id ||= tenant_id_without_app_data || app_data_metadata["tenant_id"]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The school year id sent as `X-Webuntis-Api-School-Year-Id`, if any.
|
|
110
|
+
def school_year_id
|
|
111
|
+
Thread.current[school_year_key] || @school_year_id
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Runs the block with every REST call scoped to one school year (thread-locally).
|
|
115
|
+
def with_school_year(id)
|
|
116
|
+
previous = Thread.current[school_year_key]
|
|
117
|
+
Thread.current[school_year_key] = id
|
|
118
|
+
yield self
|
|
119
|
+
ensure
|
|
120
|
+
Thread.current[school_year_key] = previous
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The current JWT, minted lazily via `api/token/new` and refreshed before it expires.
|
|
124
|
+
def token
|
|
125
|
+
ensure_login!
|
|
126
|
+
return @session.token unless @session.token_stale?(now)
|
|
127
|
+
|
|
128
|
+
mint_token!(strict: false)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Teachers from `getTeachers`, indexed by id and memoised for this session.
|
|
132
|
+
def teachers_by_id
|
|
133
|
+
@teachers_by_id ||= rpc.teachers.to_h { |teacher| [teacher["id"], teacher] }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Class teachers per class: `{"class" => {id, name, long_name}, "teachers" => [{id, name, long_name, fore_name}]}`.
|
|
137
|
+
# `source: :rest` reads `timetable/filter?resourceType=CLASS` (`classTeacher1`/`classTeacher2`) for `date`;
|
|
138
|
+
# `source: :rpc` joins `getKlassen`'s `teacher1`/`teacher2` against `getTeachers` and also yields forenames.
|
|
139
|
+
def class_teachers(source: :rest, date: nil, school_year_id: nil, include_inactive: false)
|
|
140
|
+
case source
|
|
141
|
+
when :rest then rest_class_teachers(date || Date.today)
|
|
142
|
+
when :rpc then rpc_class_teachers(school_year_id, include_inactive)
|
|
143
|
+
else raise ArgumentError, "source must be :rest or :rpc, got #{source.inspect}"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Performs one JSON-RPC call, re-authenticating once when the session expired.
|
|
148
|
+
def rpc_call(method, params = {})
|
|
149
|
+
ensure_login!
|
|
150
|
+
payload = rpc_post(method, params)
|
|
151
|
+
if not_authenticated?(payload)
|
|
152
|
+
login!
|
|
153
|
+
payload = rpc_post(method, params)
|
|
154
|
+
end
|
|
155
|
+
raise rpc_error(method, payload["error"]) if payload["error"]
|
|
156
|
+
|
|
157
|
+
payload["result"]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Performs one REST call, re-authenticating once when the session is rejected: a 401/403, a redirect
|
|
161
|
+
# to `index.do`, or a failed call for which no JWT could be minted.
|
|
162
|
+
def rest_request(method, path, query: {}, body: nil)
|
|
163
|
+
ensure_login!
|
|
164
|
+
response = perform_rest(method, path, query, body)
|
|
165
|
+
if auth_rejected?(response) || sent_without_token?(response)
|
|
166
|
+
login!
|
|
167
|
+
response = perform_rest(method, path, query, body)
|
|
168
|
+
end
|
|
169
|
+
handle_rest(method, path, response)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def inspect
|
|
173
|
+
"#<WebUntis::Client school=#{@school_query.inspect} login=#{@login_mode.inspect} logged_in=#{@logged_in}>"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
private
|
|
177
|
+
|
|
178
|
+
def now
|
|
179
|
+
@now.call
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def school_year_key
|
|
183
|
+
@school_year_key ||= :"webuntis_school_year_#{object_id}"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def derive_login_mode
|
|
187
|
+
return :secret if @secret
|
|
188
|
+
return :anonymous if @anonymous
|
|
189
|
+
|
|
190
|
+
:rpc
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def validate_login_mode(mode)
|
|
194
|
+
unless LOGIN_MODES.include?(mode)
|
|
195
|
+
raise ArgumentError, "login must be one of #{LOGIN_MODES.inspect}, got #{mode.inspect}"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
missing = case mode
|
|
199
|
+
when :rpc, :form then { username: @username, password: @password }
|
|
200
|
+
when :secret then { username: @username, secret: @secret }
|
|
201
|
+
else {}
|
|
202
|
+
end.filter_map { |name, value| name if value.nil? || value.to_s.empty? }
|
|
203
|
+
raise ArgumentError, "login mode #{mode.inspect} requires #{missing.join(" and ")}" unless missing.empty?
|
|
204
|
+
|
|
205
|
+
mode
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def explicit_school
|
|
209
|
+
School.new(
|
|
210
|
+
server: @server,
|
|
211
|
+
login_name: @school_query,
|
|
212
|
+
display_name: @school_query,
|
|
213
|
+
tenant_id: @configured_tenant_id
|
|
214
|
+
)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def reset_state!
|
|
218
|
+
@session = Session.new
|
|
219
|
+
@cookies = CookieJar.new
|
|
220
|
+
# The server expects the school name base64-encoded *and* prefixed with an underscore.
|
|
221
|
+
@cookies["schoolname"] = "_#{Util.base64(school.name)}"
|
|
222
|
+
@teachers_by_id = nil
|
|
223
|
+
@app_data = nil
|
|
224
|
+
@tenant_id = nil
|
|
225
|
+
@logged_in = false
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def tenant_id_without_app_data
|
|
229
|
+
@configured_tenant_id || @cookies["Tenant-Id"] || school.tenant_id
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def app_data_metadata
|
|
233
|
+
return @app_data if @app_data
|
|
234
|
+
|
|
235
|
+
# Set before the request: fetch_app_data builds its headers via tenant_id, which lands here again.
|
|
236
|
+
@app_data = {}
|
|
237
|
+
payload = fetch_app_data
|
|
238
|
+
@app_data = {
|
|
239
|
+
"tenant_id" => payload.dig("tenant", "id"),
|
|
240
|
+
"school_year_id" => payload.dig("currentSchoolYear", "id")
|
|
241
|
+
}
|
|
242
|
+
@session.tenant_id ||= @app_data["tenant_id"]
|
|
243
|
+
@session.school_year_id ||= @app_data["school_year_id"]
|
|
244
|
+
@app_data
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def fetch_app_data
|
|
248
|
+
response = http_call(:get, "#{base_url}/#{APP_DATA_PATH}", headers: rest_headers)
|
|
249
|
+
@cookies.absorb(response)
|
|
250
|
+
payload = response.success? ? response.json : nil
|
|
251
|
+
payload.is_a?(Hash) ? payload : {}
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def login_rpc!
|
|
255
|
+
payload = rpc_post("authenticate",
|
|
256
|
+
{ "user" => @username, "password" => @password, "client" => @client_name })
|
|
257
|
+
raise auth_error_from_rpc(payload["error"]) if payload["error"]
|
|
258
|
+
|
|
259
|
+
result = payload["result"]
|
|
260
|
+
unless result.is_a?(Hash) && result["sessionId"]
|
|
261
|
+
code = result.is_a?(Hash) ? result["code"] : nil
|
|
262
|
+
raise AuthError.new("login failed#{" with code #{code}" if code}", stage: :login)
|
|
263
|
+
end
|
|
264
|
+
store_rpc_session(result)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def store_rpc_session(result)
|
|
268
|
+
@session.session_id = result["sessionId"]
|
|
269
|
+
@session.person_id = result["personId"]
|
|
270
|
+
@session.person_type = result["personType"]
|
|
271
|
+
@session.klasse_id = result["klasseId"]
|
|
272
|
+
@cookies["JSESSIONID"] = result["sessionId"]
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def login_form!
|
|
276
|
+
seed = http_call(:get, "#{base_url}/index.do", headers: { "Accept" => "text/html, */*" })
|
|
277
|
+
@cookies.absorb(seed)
|
|
278
|
+
response = http_call(
|
|
279
|
+
:post, "#{base_url}/j_spring_security_check",
|
|
280
|
+
headers: { "Content-Type" => "application/x-www-form-urlencoded", "Accept" => "text/html, */*" },
|
|
281
|
+
body: URI.encode_www_form(school: school.name, j_username: @username, j_password: @password)
|
|
282
|
+
)
|
|
283
|
+
@cookies.absorb(response)
|
|
284
|
+
# A form login answers 302 whether or not it worked; only a mintable JWT proves it did.
|
|
285
|
+
mint_token!
|
|
286
|
+
@session.session_id = @cookies["JSESSIONID"]
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def login_otp!(otp, user)
|
|
290
|
+
url = "#{base_url}/jsonrpc_intern.do?" \
|
|
291
|
+
"#{URI.encode_www_form(m: "getUserData2017", school: school.name, v: "i2.2")}"
|
|
292
|
+
response = http_call(
|
|
293
|
+
:post, url,
|
|
294
|
+
headers: { "Content-Type" => "application/json", "Accept" => "application/json" },
|
|
295
|
+
body: JSON.generate(
|
|
296
|
+
"id" => SecureRandom.hex(8),
|
|
297
|
+
"method" => "getUserData2017",
|
|
298
|
+
"params" => [{ "auth" => { "clientTime" => (now.to_f * 1000).round, "user" => user, "otp" => otp } }],
|
|
299
|
+
"jsonrpc" => "2.0"
|
|
300
|
+
)
|
|
301
|
+
)
|
|
302
|
+
finish_otp_login(response, user)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def finish_otp_login(response, user)
|
|
306
|
+
unless response.success?
|
|
307
|
+
raise AuthError.new("OTP login failed (HTTP #{response.status})", stage: :login, status: response.status)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
payload = response.json
|
|
311
|
+
raise auth_error_from_rpc(payload["error"]) if payload.is_a?(Hash) && payload["error"]
|
|
312
|
+
|
|
313
|
+
@cookies.absorb(response)
|
|
314
|
+
session_id = @cookies["JSESSIONID"]
|
|
315
|
+
raise AuthError.new("login returned no JSESSIONID cookie", stage: :login) if session_id.nil?
|
|
316
|
+
|
|
317
|
+
@session.session_id = session_id
|
|
318
|
+
load_person_from_app_config unless user == ANONYMOUS_USER
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def load_person_from_app_config
|
|
322
|
+
response = http_call(:get, "#{base_url}/api/app/config", headers: { "Accept" => "application/json",
|
|
323
|
+
"Cookie" => cookie_header })
|
|
324
|
+
@cookies.absorb(response)
|
|
325
|
+
payload = response.json
|
|
326
|
+
user = payload.is_a?(Hash) ? payload.dig("data", "loginServiceConfig", "user") : nil
|
|
327
|
+
raise AuthError.new("app config did not contain a login service user", stage: :login) unless user.is_a?(Hash)
|
|
328
|
+
|
|
329
|
+
@session.person_id = user["personId"]
|
|
330
|
+
person = Array(user["persons"]).find { |entry| entry["id"] == user["personId"] }
|
|
331
|
+
@session.person_type = person && person["type"]
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def mint_token!(strict: true)
|
|
335
|
+
response = http_call(:get, "#{base_url}/api/token/new",
|
|
336
|
+
headers: { "Accept" => "text/plain, */*", "Cookie" => cookie_header }.compact)
|
|
337
|
+
@cookies.absorb(response)
|
|
338
|
+
value = response.body.to_s.strip
|
|
339
|
+
return store_token(value) if response.success? && !value.empty? && !html?(value)
|
|
340
|
+
if strict
|
|
341
|
+
raise AuthError.new("could not mint a JWT (HTTP #{response.status})", stage: :token,
|
|
342
|
+
status: response.status)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
@session.forget_token
|
|
346
|
+
nil
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def store_token(value)
|
|
350
|
+
expiry = jwt_expiry(value)
|
|
351
|
+
@session.token = value
|
|
352
|
+
if expiry
|
|
353
|
+
@session.token_expires_at = Time.at(expiry)
|
|
354
|
+
@session.stale_at = Time.at(expiry - TOKEN_SKEW)
|
|
355
|
+
else
|
|
356
|
+
@session.token_expires_at = now + TOKEN_FALLBACK_TTL
|
|
357
|
+
@session.stale_at = @session.token_expires_at
|
|
358
|
+
end
|
|
359
|
+
value
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def jwt_expiry(token)
|
|
363
|
+
segment = token.split(".")[1]
|
|
364
|
+
return nil if segment.nil?
|
|
365
|
+
|
|
366
|
+
claims = JSON.parse(Util.base64url_decode(segment))
|
|
367
|
+
claims.is_a?(Hash) && claims["exp"].is_a?(Numeric) ? claims["exp"] : nil
|
|
368
|
+
rescue JSON::ParserError, ArgumentError
|
|
369
|
+
nil
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def html?(value)
|
|
373
|
+
value.match?(/\A\s*<(!doctype|html)/i)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def cookie_header
|
|
377
|
+
@cookies.header
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Headers every modern REST call carries.
|
|
381
|
+
def rest_headers(json_body: false)
|
|
382
|
+
jwt = token
|
|
383
|
+
{
|
|
384
|
+
"Accept" => "application/json, text/plain, */*",
|
|
385
|
+
"Authorization" => jwt && "Bearer #{jwt}",
|
|
386
|
+
"Tenant-Id" => tenant_id&.to_s,
|
|
387
|
+
"X-Webuntis-Api-School-Year-Id" => school_year_id&.to_s,
|
|
388
|
+
"Cookie" => cookie_header,
|
|
389
|
+
"Content-Type" => json_body ? "application/json" : nil
|
|
390
|
+
}.compact
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def perform_rest(method, path, query, body)
|
|
394
|
+
payload = body.nil? || body.is_a?(String) ? body : JSON.generate(body)
|
|
395
|
+
http_call(method, rest_url(path, query),
|
|
396
|
+
headers: rest_headers(json_body: !payload.nil?), body: payload)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def rest_url(path, query)
|
|
400
|
+
url = "#{base_url}/#{path.to_s.sub(%r{\A/+}, "")}"
|
|
401
|
+
encoded = REST.encode_query(query)
|
|
402
|
+
encoded.empty? ? url : "#{url}?#{encoded}"
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def handle_rest(method, path, response)
|
|
406
|
+
@cookies.absorb(response)
|
|
407
|
+
unless response.success?
|
|
408
|
+
raise HttpError.new("HTTP #{response.status} for #{method.to_s.upcase} #{path}",
|
|
409
|
+
status: response.status, body: response.body, method: method, path: path)
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
payload = response.json
|
|
413
|
+
check_api_error(payload, path)
|
|
414
|
+
payload
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def check_api_error(payload, path)
|
|
418
|
+
return unless payload.is_a?(Hash)
|
|
419
|
+
|
|
420
|
+
key = payload.dig("data", "error", "data", "messageKey")
|
|
421
|
+
raise ApiError.new(key.to_s, message_key: key, body: payload, path: path) if key
|
|
422
|
+
|
|
423
|
+
message = payload["errorMessage"]
|
|
424
|
+
raise ApiError.new(message.to_s, body: payload, path: path) if message
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def auth_rejected?(response)
|
|
428
|
+
return true if [401, 403].include?(response.status)
|
|
429
|
+
|
|
430
|
+
response.redirect? && response.location.to_s.include?("index.do")
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# `api/token/new` redirects instead of minting once the session is dead, and the view API answers
|
|
434
|
+
# a request without a bearer token with 404 rather than 401.
|
|
435
|
+
def sent_without_token?(response)
|
|
436
|
+
!response.success? && @session.token.nil?
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def rpc_url
|
|
440
|
+
"#{base_url}/jsonrpc.do?#{URI.encode_www_form(school: school.name)}"
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def rpc_post(method, params)
|
|
444
|
+
body = JSON.generate(
|
|
445
|
+
"id" => SecureRandom.hex(8),
|
|
446
|
+
"method" => method,
|
|
447
|
+
"params" => Util.compact_params(params),
|
|
448
|
+
"jsonrpc" => "2.0"
|
|
449
|
+
)
|
|
450
|
+
response = http_call(:post, rpc_url, headers: rpc_headers, body: body)
|
|
451
|
+
@cookies.absorb(response)
|
|
452
|
+
unless response.success?
|
|
453
|
+
raise HttpError.new("HTTP #{response.status} for JSON-RPC #{method}", status: response.status,
|
|
454
|
+
body: response.body, method: :post,
|
|
455
|
+
path: "jsonrpc.do")
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
parse_rpc_body(response, method)
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def parse_rpc_body(response, method)
|
|
462
|
+
payload = response.json
|
|
463
|
+
raise Error, "JSON-RPC #{method} returned a non-JSON body" unless payload.is_a?(Hash)
|
|
464
|
+
|
|
465
|
+
payload
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def rpc_headers
|
|
469
|
+
{ "Content-Type" => "application/json", "Accept" => "application/json", "Cookie" => cookie_header }.compact
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def not_authenticated?(payload)
|
|
473
|
+
error = payload["error"]
|
|
474
|
+
return false unless error.is_a?(Hash)
|
|
475
|
+
|
|
476
|
+
error["code"] == -8520 || error["message"].to_s.downcase.include?("not authenticated")
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def rpc_error(method, error)
|
|
480
|
+
error = {} unless error.is_a?(Hash)
|
|
481
|
+
RpcError.new("JSON-RPC #{method} failed: #{error["message"]}",
|
|
482
|
+
code: error["code"], data: error["data"], method: method)
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def auth_error_from_rpc(error)
|
|
486
|
+
error = {} unless error.is_a?(Hash)
|
|
487
|
+
AuthError.new("login failed: #{error["message"]}#{" (code #{error["code"]})" if error["code"]}",
|
|
488
|
+
stage: :login)
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def rest_class_teachers(date)
|
|
492
|
+
filter = rest.timetable_filter(resource_type: :class, start: date, end: date)
|
|
493
|
+
Array(filter["classes"]).map do |entry|
|
|
494
|
+
teachers = [entry["classTeacher1"], entry["classTeacher2"]].compact
|
|
495
|
+
{
|
|
496
|
+
"class" => resource_row(entry["class"]),
|
|
497
|
+
"teachers" => teachers.map { |teacher| resource_row(teacher).merge("fore_name" => nil) }
|
|
498
|
+
}
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def resource_row(resource)
|
|
503
|
+
{ "id" => resource["id"], "name" => resource["shortName"], "long_name" => resource["longName"] }
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def rpc_class_teachers(school_year_id, include_inactive)
|
|
507
|
+
klassen = Array(rpc.classes(school_year_id: school_year_id))
|
|
508
|
+
unless klassen.any? { |klasse| klasse.key?("teacher1") || klasse.key?("teacher2") }
|
|
509
|
+
raise Error, "this account cannot see class teachers via getKlassen: no teacher1/teacher2 fields were returned"
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
klassen.filter_map do |klasse|
|
|
513
|
+
next if !include_inactive && klasse["active"] == false
|
|
514
|
+
|
|
515
|
+
{
|
|
516
|
+
"class" => { "id" => klasse["id"], "name" => klasse["name"], "long_name" => klasse["longName"] },
|
|
517
|
+
"teachers" => rpc_class_teachers_for(klasse)
|
|
518
|
+
}
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def rpc_class_teachers_for(klasse)
|
|
523
|
+
[klasse["teacher1"], klasse["teacher2"]].filter_map do |id|
|
|
524
|
+
next if id.to_i.zero?
|
|
525
|
+
|
|
526
|
+
teacher = teachers_by_id[id]
|
|
527
|
+
next unless teacher
|
|
528
|
+
|
|
529
|
+
{ "id" => teacher["id"], "name" => teacher["name"], "long_name" => teacher["longName"],
|
|
530
|
+
"fore_name" => teacher["foreName"] }
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
def http_call(method, url, headers: {}, body: nil)
|
|
535
|
+
response = @http.call(method, url, headers: headers, body: body)
|
|
536
|
+
@logger&.debug { "WebUntis #{method.to_s.upcase} #{URI.parse(url).path} -> #{response.status}" }
|
|
537
|
+
response
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WebUntis
|
|
4
|
+
# A name-keyed cookie store; WebUntis only ever needs the newest value per name.
|
|
5
|
+
class CookieJar
|
|
6
|
+
def initialize
|
|
7
|
+
@cookies = {}
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Records the `Set-Cookie` values of one response.
|
|
11
|
+
def absorb(response)
|
|
12
|
+
response.set_cookies.each do |raw|
|
|
13
|
+
pair = raw.to_s.split(";", 2).first.to_s
|
|
14
|
+
name, value = pair.split("=", 2)
|
|
15
|
+
next if value.nil? || name.strip.empty?
|
|
16
|
+
|
|
17
|
+
self[name.strip] = value.strip
|
|
18
|
+
end
|
|
19
|
+
self
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The value of one cookie, without the double quotes a server may wrap it in (RFC 6265).
|
|
23
|
+
def [](name)
|
|
24
|
+
value = @cookies[name.to_s]
|
|
25
|
+
value&.match?(/\A".*"\z/) ? value[1..-2] : value
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Sets one cookie value; a nil value removes it.
|
|
29
|
+
def []=(name, value)
|
|
30
|
+
if value.nil?
|
|
31
|
+
@cookies.delete(name.to_s)
|
|
32
|
+
else
|
|
33
|
+
@cookies[name.to_s] = value.to_s
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The `Cookie` request header value, or nil when the jar is empty.
|
|
38
|
+
def header
|
|
39
|
+
return nil if @cookies.empty?
|
|
40
|
+
|
|
41
|
+
@cookies.map { |name, value| "#{name}=#{value}" }.join("; ")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Drops every cookie.
|
|
45
|
+
def clear
|
|
46
|
+
@cookies.clear
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# True when the jar holds no cookies.
|
|
51
|
+
def empty?
|
|
52
|
+
@cookies.empty?
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WebUntis
|
|
4
|
+
# The numeric element types WebUntis uses for classes, people, subjects and rooms.
|
|
5
|
+
module ElementType
|
|
6
|
+
CLASS = 1
|
|
7
|
+
TEACHER = 2
|
|
8
|
+
SUBJECT = 3
|
|
9
|
+
ROOM = 4
|
|
10
|
+
STUDENT = 5
|
|
11
|
+
|
|
12
|
+
NAMES = {
|
|
13
|
+
"class" => CLASS,
|
|
14
|
+
"klasse" => CLASS,
|
|
15
|
+
"teacher" => TEACHER,
|
|
16
|
+
"subject" => SUBJECT,
|
|
17
|
+
"room" => ROOM,
|
|
18
|
+
"student" => STUDENT
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
LABELS = {
|
|
22
|
+
CLASS => "CLASS",
|
|
23
|
+
TEACHER => "TEACHER",
|
|
24
|
+
SUBJECT => "SUBJECT",
|
|
25
|
+
ROOM => "ROOM",
|
|
26
|
+
STUDENT => "STUDENT"
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
# Coerces an Integer, Symbol or String element type to its numeric form.
|
|
30
|
+
def self.resolve(value)
|
|
31
|
+
return value if value.is_a?(Integer)
|
|
32
|
+
return nil if value.nil?
|
|
33
|
+
|
|
34
|
+
key = value.to_s.downcase
|
|
35
|
+
NAMES[key] || (raise Error, "unknown element type #{value.inspect}")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Coerces an element type to the uppercase name the modern REST API expects.
|
|
39
|
+
def self.label(value)
|
|
40
|
+
return nil if value.nil?
|
|
41
|
+
return LABELS.fetch(value) { raise Error, "unknown element type #{value.inspect}" } if value.is_a?(Integer)
|
|
42
|
+
|
|
43
|
+
name = value.to_s.upcase
|
|
44
|
+
return name if LABELS.value?(name)
|
|
45
|
+
|
|
46
|
+
LABELS.fetch(resolve(value))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WebUntis
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Raised when a school cannot be resolved to exactly one WebUntis tenant.
|
|
7
|
+
class DiscoveryError < Error
|
|
8
|
+
attr_reader :query, :matches
|
|
9
|
+
|
|
10
|
+
def initialize(message, query: nil, matches: [])
|
|
11
|
+
super(message)
|
|
12
|
+
@query = query
|
|
13
|
+
@matches = matches
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Raised when logging in, minting a JWT, or re-authenticating fails.
|
|
18
|
+
class AuthError < Error
|
|
19
|
+
attr_reader :stage, :status
|
|
20
|
+
|
|
21
|
+
def initialize(message, stage: nil, status: nil)
|
|
22
|
+
super(message)
|
|
23
|
+
@stage = stage
|
|
24
|
+
@status = status
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Raised when a JSON-RPC response carries an `error` member.
|
|
29
|
+
class RpcError < Error
|
|
30
|
+
attr_reader :code, :data, :method
|
|
31
|
+
|
|
32
|
+
def initialize(message, code: nil, data: nil, method: nil)
|
|
33
|
+
super(message)
|
|
34
|
+
@code = code
|
|
35
|
+
@data = data
|
|
36
|
+
@method = method
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Raised for any non-success HTTP status the client did not handle itself.
|
|
41
|
+
class HttpError < Error
|
|
42
|
+
attr_reader :status, :body, :method, :path
|
|
43
|
+
|
|
44
|
+
def initialize(message, status: nil, body: nil, method: nil, path: nil)
|
|
45
|
+
super(message)
|
|
46
|
+
@status = status
|
|
47
|
+
@body = body
|
|
48
|
+
@method = method
|
|
49
|
+
@path = path
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Raised when a 2xx REST body carries an application-level error envelope.
|
|
54
|
+
class ApiError < Error
|
|
55
|
+
attr_reader :message_key, :body, :path
|
|
56
|
+
|
|
57
|
+
def initialize(message, message_key: nil, body: nil, path: nil)
|
|
58
|
+
super(message)
|
|
59
|
+
@message_key = message_key
|
|
60
|
+
@body = body
|
|
61
|
+
@path = path
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|