294 lines
11 KiB
Ruby
294 lines
11 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
#
|
|
# Copyright (C) 2016 - present Instructure, Inc.
|
|
#
|
|
# This file is part of Canvas.
|
|
#
|
|
# Canvas is free software: you can redistribute it and/or modify it under
|
|
# the terms of the GNU Affero General Public License as published by the Free
|
|
# Software Foundation, version 3 of the License.
|
|
#
|
|
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
|
|
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
|
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
|
# details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License along
|
|
# with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
module Services
|
|
class AddressBook
|
|
# regarding these methods' parameters or options, generally:
|
|
#
|
|
# * `sender` is a User or a user ID
|
|
# * `context` is an asset string ('course_123', or optionally subscoped
|
|
# such as 'course_123_teachers') or a Course, CourseSection, or Group
|
|
# * `users` is a list of Users or user IDs
|
|
# * `search` is a string
|
|
# * `exclude` is a list of Users or of user IDs
|
|
# * `weak_checks` is a truthy/falsey value
|
|
#
|
|
|
|
# which of the users does the sender know, and what contexts do they and
|
|
# the sender have in common?
|
|
def self.common_contexts(sender, users, ignore_result = false)
|
|
recipients(sender:, user_ids: users, ignore_result:).common_contexts
|
|
end
|
|
|
|
# which of the users have roles in the context and what are those roles?
|
|
def self.roles_in_context(context, users, ignore_result = false)
|
|
context = context.course if context.is_a?(CourseSection)
|
|
recipients(context:, user_ids: users, ignore_result:).common_contexts
|
|
end
|
|
|
|
# which users:
|
|
#
|
|
# - does the sender know in the context and what are their roles in that
|
|
# context? (sender present)
|
|
#
|
|
# --OR--
|
|
#
|
|
# - have roles in the context and what are those roles? (sender absent;
|
|
# admin view)
|
|
#
|
|
def self.known_in_context(sender, context, user_ids = nil, ignore_result = false)
|
|
params = { sender:, context:, ignore_result: }
|
|
params[:user_ids] = user_ids if user_ids
|
|
response = recipients(params)
|
|
[response.user_ids, response.common_contexts]
|
|
end
|
|
|
|
# how many users does the sender know in each of the contexts?
|
|
def self.count_in_contexts(sender, contexts, ignore_result = false)
|
|
counts = count_recipients(sender:, contexts:, ignore_result:)
|
|
# map back from normalized to argument
|
|
contexts.each do |ctx|
|
|
serialized = serialize_context(ctx)
|
|
if serialized != ctx
|
|
counts[ctx] = counts.delete(serialized)
|
|
end
|
|
end
|
|
counts
|
|
end
|
|
|
|
# of the users who are not in `exclude_ids` and whose name matches the
|
|
# `search` term, if any, which:
|
|
#
|
|
# - does the sender know, and what are their common contexts with the
|
|
# sender? (no context provided, sender must be)
|
|
#
|
|
# - does the sender know in the context and what are their roles in that
|
|
# context? (context provided with sender)
|
|
#
|
|
# --OR--
|
|
#
|
|
# - have roles in the context and what are those roles? (context provided
|
|
# without sender; admin view)
|
|
#
|
|
def self.search_users(sender, options, service_options, ignore_result = false)
|
|
params = options.slice(:search, :context, :exclude_ids, :weak_checks)
|
|
params[:ignore_result] = ignore_result
|
|
params[:sender] = sender
|
|
|
|
# interpret pagination as specified in service_options
|
|
params[:per_page] = service_options[:per_page] if service_options[:per_page]
|
|
params[:cursor] = service_options[:cursor] if service_options[:cursor]
|
|
|
|
# call out to service
|
|
response = recipients(params)
|
|
|
|
# interpret response
|
|
[response.user_ids, response.common_contexts, response.cursors]
|
|
end
|
|
|
|
def self.recipients(params)
|
|
Response.new(fetch("/recipients", query_params(params)))
|
|
end
|
|
|
|
def self.count_recipients(params)
|
|
return {} if params[:contexts].blank?
|
|
|
|
fetch("/recipients/counts", query_params(params))["counts"] || {}
|
|
end
|
|
|
|
def self.jwt # public only for testing, should not be used directly
|
|
Canvas::Security.create_jwt({ iat: Time.now.to_i }, nil, jwt_secret, :HS512)
|
|
rescue => e
|
|
Canvas::Errors.capture_exception(:address_book, e)
|
|
nil
|
|
end
|
|
|
|
class << self
|
|
private
|
|
|
|
def setting(key)
|
|
DynamicSettings.find("address-book", default_ttl: 5.minutes)[key, failsafe: nil]
|
|
end
|
|
|
|
def app_host
|
|
setting("app-host")
|
|
end
|
|
|
|
def jwt_secret
|
|
Canvas::Security.base64_decode(setting("secret"))
|
|
end
|
|
|
|
# generic retrieve, parse
|
|
def fetch(path, params = {})
|
|
url = app_host + path
|
|
url += "?" + params.to_query unless params.empty?
|
|
fallback = { "records" => [] }
|
|
timeout_service_name = if params[:ignore_result] == 1
|
|
"address_book_performance_tap"
|
|
else
|
|
"address_book"
|
|
end
|
|
Canvas.timeout_protection(timeout_service_name) do
|
|
response = CanvasHttp.get(url, { "Authorization" => "Bearer #{jwt}" })
|
|
if ![200, 202].include?(response.code.to_i)
|
|
err = CanvasHttp::InvalidResponseCodeError.new(response.code.to_i)
|
|
data = {
|
|
extra: { url:, response: response.body },
|
|
tags: { type: "address_book_fault" }
|
|
}
|
|
Canvas::Errors.capture(err, data, :warn)
|
|
return fallback
|
|
elsif params[:ignore_result] == 1
|
|
return fallback
|
|
else
|
|
return JSON.parse(response.body)
|
|
end
|
|
end || fallback
|
|
end
|
|
|
|
# serialize logical params into query string values
|
|
def query_params(params = {})
|
|
query_params = {}
|
|
query_params[:cursor] = params[:cursor] if params[:cursor]
|
|
query_params[:per_page] = params[:per_page] if params[:per_page]
|
|
query_params[:search] = params[:search] if params[:search]
|
|
if params[:sender]
|
|
sender = params[:sender]
|
|
sender = User.find(sender) unless sender.is_a?(User)
|
|
visible_accounts = sender.associated_accounts.select { |account| account.grants_right?(sender, :read_roster) }
|
|
restricted_courses = sender.all_courses.reject { |course| course.grants_right?(sender, :send_messages) }
|
|
query_params[:for_sender] = serialize_item(sender)
|
|
query_params[:visible_account_ids] = serialize_list(visible_accounts) unless visible_accounts.empty?
|
|
query_params[:restricted_course_ids] = serialize_list(restricted_courses) unless restricted_courses.empty?
|
|
end
|
|
query_params[:in_context] = serialize_context(params[:context]) if params[:context]
|
|
if params[:contexts]
|
|
contexts = params[:contexts].map { |ctx| serialize_context(ctx) }
|
|
query_params[:in_contexts] = contexts.join(",")
|
|
end
|
|
query_params[:user_ids] = serialize_list(params[:user_ids]) if params[:user_ids]
|
|
query_params[:exclude_ids] = serialize_list(params[:exclude_ids]) if params[:exclude_ids]
|
|
query_params[:weak_checks] = 1 if params[:weak_checks]
|
|
query_params[:ignore_result] = 1 if params[:ignore_result]
|
|
query_params
|
|
end
|
|
|
|
def serialize_item(item)
|
|
Shard.global_id_for(item)
|
|
end
|
|
|
|
def serialize_list(list) # can be either IDs or objects (e.g. User)
|
|
list.map { |item| serialize_item(item) }.join(",")
|
|
end
|
|
|
|
def serialize_context(context)
|
|
if context.respond_to?(:global_asset_string)
|
|
context.global_asset_string
|
|
else
|
|
context_type, context_id, scope = context.split("_", 3)
|
|
global_context_id = serialize_item(context_id)
|
|
asset_string = "#{context_type}_#{global_context_id}"
|
|
asset_string += "_#{scope}" if scope
|
|
asset_string
|
|
end
|
|
end
|
|
end
|
|
|
|
# /recipients returns data in the (JSON) shape:
|
|
#
|
|
# {
|
|
# records: [
|
|
# {
|
|
# 'user_id': '10000000000002',
|
|
# 'contexts': [
|
|
# { 'context_type': 'course', 'context_id': '10000000000001', 'roles': ['TeacherEnrollment'] }
|
|
# ],
|
|
# cursor: ...
|
|
# },
|
|
# {
|
|
# 'user_id': '10000000000005',
|
|
# 'contexts': [
|
|
# { 'context_type': 'course', 'context_id': '10000000000002', 'roles': ['StudentEnrollment'] },
|
|
# { 'context_type': 'group', 'context_id': '10000000000001', 'roles': ['Member'] }
|
|
# ],
|
|
# cursor: ...
|
|
# }
|
|
# ],
|
|
# ...
|
|
# }
|
|
#
|
|
# where `user_id` is a string representation of the recipient's global
|
|
# user ID, `contexts` is a list of contexts they have in common with
|
|
# the sender, and `cursor` is the cursor to pass to start at the next
|
|
# record. each context states the type, id (again as a string
|
|
# representation of the global ID), and roles the recipient has in that
|
|
# context (to the knowledge of the sender).
|
|
#
|
|
# this class facilitates separating those pieces
|
|
class Response
|
|
def initialize(response)
|
|
@response = response
|
|
end
|
|
|
|
# extract just the user IDs from the response, as an ordered list
|
|
def user_ids
|
|
@response["records"].map { |record| record["user_id"].to_i }
|
|
end
|
|
|
|
# reshape the records into a ruby hash with integers instead of strings
|
|
# for IDs (but still global), user_ids promoted to keys, and context
|
|
# types collated. e.g. for the above example, the transformed data would
|
|
# have the (ruby) shape:
|
|
#
|
|
# {
|
|
# 10000000000002 => {
|
|
# courses: { 10000000000001 => ['TeacherEnrollment'] },
|
|
# groups: {}
|
|
# },
|
|
# 10000000000005 => {
|
|
# courses: { 10000000000002 => ['StudentEnrollment'] },
|
|
# groups: { 10000000000001 => ['Member'] }
|
|
# }
|
|
# }
|
|
#
|
|
def common_contexts
|
|
common_contexts = {}
|
|
@response["records"].each do |recipient|
|
|
global_user_id = recipient["user_id"].to_i
|
|
contexts = recipient["contexts"]
|
|
common_contexts[global_user_id] ||= { courses: {}, groups: {} }
|
|
contexts.each do |context|
|
|
context_type = context["context_type"].pluralize.to_sym
|
|
next unless common_contexts[global_user_id].key?(context_type)
|
|
|
|
global_context_id = context["context_id"].to_i
|
|
common_contexts[global_user_id][context_type][global_context_id] = context["roles"]
|
|
end
|
|
end
|
|
common_contexts
|
|
end
|
|
|
|
# extract the next page cursor from the response
|
|
def cursors
|
|
@response["records"].pluck("cursor")
|
|
end
|
|
end
|
|
end
|
|
end
|