InstitutionsAPI.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. import { callbackify } from 'node:util'
  2. import OError from '@overleaf/o-error'
  3. import logger from '@overleaf/logger'
  4. import settings from '@overleaf/settings'
  5. import request from 'requestretry'
  6. import { promisifyAll, promiseMapWithLimit } from '@overleaf/promise-utils'
  7. import NotificationsBuilder from '../Notifications/NotificationsBuilder.mjs'
  8. import {
  9. V1ConnectionError,
  10. InvalidInstitutionalEmailError,
  11. } from '../Errors/Errors.js'
  12. import { fetchJson, fetchNothing } from '@overleaf/fetch-utils'
  13. import Modules from '../../infrastructure/Modules.mjs'
  14. function _makeRequestOptions(options) {
  15. const requestOptions = {
  16. method: options.method,
  17. basicAuth: { user: settings.apis.v1.user, password: settings.apis.v1.pass },
  18. signal: AbortSignal.timeout(settings.apis.v1.timeout),
  19. }
  20. if (options.body) {
  21. requestOptions.json = options.body
  22. }
  23. return requestOptions
  24. }
  25. function _responseErrorHandling(options, error) {
  26. const status = error.response.status
  27. if (status >= 500) {
  28. throw new V1ConnectionError({
  29. message: 'error getting affiliations from v1',
  30. info: {
  31. status,
  32. body: error.body,
  33. },
  34. })
  35. }
  36. let errorBody
  37. try {
  38. if (error.body) {
  39. errorBody = JSON.parse(error.body)
  40. }
  41. } catch (e) {}
  42. let errorMessage
  43. if (errorBody?.errors) {
  44. errorMessage = `${status}: ${errorBody.errors}`
  45. } else {
  46. errorMessage = `${options.defaultErrorMessage}: ${status}`
  47. }
  48. throw new OError(errorMessage, { status })
  49. }
  50. async function _affiliationRequestFetchJson(options) {
  51. if (!settings.apis.v1.url) {
  52. return
  53. } // service is not configured
  54. const url = `${settings.apis.v1.url}${options.path}`
  55. const requestOptions = _makeRequestOptions(options)
  56. try {
  57. return await fetchJson(url, requestOptions)
  58. } catch (error) {
  59. _responseErrorHandling(options, error)
  60. }
  61. }
  62. async function _affiliationRequestFetchNothing(options) {
  63. if (!settings.apis.v1.url) {
  64. return
  65. } // service is not configured
  66. const url = `${settings.apis.v1.url}${options.path}`
  67. const requestOptions = _makeRequestOptions(options)
  68. try {
  69. await fetchNothing(url, requestOptions)
  70. } catch (error) {
  71. _responseErrorHandling(options, error)
  72. }
  73. }
  74. async function _affiliationRequestFetchNothing404Ok(options) {
  75. try {
  76. await _affiliationRequestFetchNothing(options)
  77. } catch (error) {
  78. const status = error.info?.status
  79. if (status !== 404) {
  80. throw error
  81. }
  82. }
  83. }
  84. function getInstitutionAffiliations(institutionId, callback) {
  85. makeAffiliationRequest(
  86. {
  87. method: 'GET',
  88. path: `/api/v2/institutions/${institutionId.toString()}/affiliations`,
  89. defaultErrorMessage: "Couldn't get institution affiliations",
  90. },
  91. (error, body) => callback(error, body || [])
  92. )
  93. }
  94. function getConfirmedInstitutionAffiliations(institutionId, callback) {
  95. makeAffiliationRequest(
  96. {
  97. method: 'GET',
  98. path: `/api/v2/institutions/${institutionId.toString()}/confirmed_affiliations`,
  99. defaultErrorMessage: "Couldn't get institution affiliations",
  100. },
  101. (error, body) => callback(error, body || [])
  102. )
  103. }
  104. function getInstitutionAffiliationsCounts(institutionId, callback) {
  105. makeAffiliationRequest(
  106. {
  107. method: 'GET',
  108. path: `/api/v2/institutions/${institutionId.toString()}/affiliations_counts`,
  109. defaultErrorMessage: "Couldn't get institution counts",
  110. },
  111. (error, body) => callback(error, body || [])
  112. )
  113. }
  114. function getLicencesForAnalytics(lag, queryDate, callback) {
  115. makeAffiliationRequest(
  116. {
  117. method: 'GET',
  118. path: `/api/v2/institutions/institutions_licences`,
  119. body: { query_date: queryDate, lag },
  120. defaultErrorMessage: 'Could not get institutions licences',
  121. },
  122. callback
  123. )
  124. }
  125. function getUserAffiliations(userId, callback) {
  126. makeAffiliationRequest(
  127. {
  128. method: 'GET',
  129. path: `/api/v2/users/${userId.toString()}/affiliations`,
  130. defaultErrorMessage: "Couldn't get user affiliations",
  131. },
  132. async (error, body) => {
  133. if (error) {
  134. return callback(error, [])
  135. }
  136. const affiliations = []
  137. if (body?.length > 0) {
  138. const concurrencyLimit = 10
  139. await promiseMapWithLimit(concurrencyLimit, body, async affiliation => {
  140. const group = (
  141. await Modules.promises.hooks.fire(
  142. 'getGroupWithDomainCaptureByV1Id',
  143. affiliation.institution.id
  144. )
  145. )?.[0]
  146. if (group) {
  147. affiliation.group = {
  148. _id: group._id,
  149. managedUsersEnabled: Boolean(group.managedUsersEnabled),
  150. domainCaptureEnabled: Boolean(group.domainCaptureEnabled),
  151. }
  152. }
  153. affiliations.push(affiliation)
  154. })
  155. }
  156. callback(null, affiliations)
  157. }
  158. )
  159. }
  160. async function getUsersNeedingReconfirmationsLapsedProcessed() {
  161. return await _affiliationRequestFetchJson({
  162. method: 'GET',
  163. path: '/api/v2/institutions/need_reconfirmation_lapsed_processed',
  164. defaultErrorMessage:
  165. 'Could not get users that need reconfirmations lapsed processed',
  166. })
  167. }
  168. async function addAffiliation(userId, email, affiliationOptions) {
  169. const {
  170. university,
  171. department,
  172. role,
  173. confirmedAt,
  174. entitlement,
  175. rejectIfBlocklisted,
  176. } = affiliationOptions
  177. try {
  178. await _affiliationRequestFetchNothing({
  179. method: 'POST',
  180. path: `/api/v2/users/${userId.toString()}/affiliations`,
  181. body: {
  182. email,
  183. university,
  184. department,
  185. role,
  186. confirmedAt,
  187. entitlement,
  188. rejectIfBlocklisted,
  189. },
  190. defaultErrorMessage: "Couldn't create affiliation",
  191. })
  192. } catch (error) {
  193. if (error.info?.status === 422) {
  194. throw new InvalidInstitutionalEmailError(error.message).withCause(error)
  195. }
  196. throw error
  197. }
  198. if (!university) {
  199. return
  200. }
  201. // have notifications delete any ip matcher notifications for this university
  202. try {
  203. await NotificationsBuilder.promises
  204. .ipMatcherAffiliation(userId.toString())
  205. .read(university.id)
  206. } catch (err) {
  207. // log and ignore error
  208. logger.err({ err }, 'Something went wrong marking ip notifications read')
  209. }
  210. }
  211. async function removeAffiliation(userId, email) {
  212. await _affiliationRequestFetchNothing404Ok({
  213. method: 'POST',
  214. path: `/api/v2/users/${userId.toString()}/affiliations/remove`,
  215. body: { email },
  216. defaultErrorMessage: "Couldn't remove affiliation",
  217. })
  218. }
  219. function endorseAffiliation(userId, email, role, department, callback) {
  220. makeAffiliationRequest(
  221. {
  222. method: 'POST',
  223. path: `/api/v2/users/${userId.toString()}/affiliations/endorse`,
  224. body: { email, role, department },
  225. defaultErrorMessage: "Couldn't endorse affiliation",
  226. },
  227. callback
  228. )
  229. }
  230. function deleteAffiliations(userId, callback) {
  231. makeAffiliationRequest(
  232. {
  233. method: 'DELETE',
  234. path: `/api/v2/users/${userId.toString()}/affiliations`,
  235. defaultErrorMessage: "Couldn't delete affiliations",
  236. },
  237. callback
  238. )
  239. }
  240. function addEntitlement(userId, email, callback) {
  241. makeAffiliationRequest(
  242. {
  243. method: 'POST',
  244. path: `/api/v2/users/${userId}/affiliations/add_entitlement`,
  245. body: { email },
  246. defaultErrorMessage: "Couldn't add entitlement",
  247. },
  248. callback
  249. )
  250. }
  251. function removeEntitlement(userId, email, callback) {
  252. makeAffiliationRequest(
  253. {
  254. method: 'POST',
  255. path: `/api/v2/users/${userId}/affiliations/remove_entitlement`,
  256. body: { email },
  257. defaultErrorMessage: "Couldn't remove entitlement",
  258. extraSuccessStatusCodes: [404],
  259. },
  260. callback
  261. )
  262. }
  263. function sendUsersWithReconfirmationsLapsedProcessed(users, callback) {
  264. makeAffiliationRequest(
  265. {
  266. method: 'POST',
  267. path: '/api/v2/institutions/reconfirmation_lapsed_processed',
  268. body: { users },
  269. defaultErrorMessage:
  270. 'Could not update reconfirmation_lapsed_processed_at',
  271. },
  272. (error, body) => callback(error, body || [])
  273. )
  274. }
  275. const InstitutionsAPI = {
  276. getInstitutionAffiliations,
  277. getConfirmedInstitutionAffiliations,
  278. getInstitutionAffiliationsCounts,
  279. getLicencesForAnalytics,
  280. getUserAffiliations,
  281. getUsersNeedingReconfirmationsLapsedProcessed: callbackify(
  282. getUsersNeedingReconfirmationsLapsedProcessed
  283. ),
  284. addAffiliation: callbackify(addAffiliation),
  285. removeAffiliation: callbackify(removeAffiliation),
  286. endorseAffiliation,
  287. deleteAffiliations,
  288. addEntitlement,
  289. removeEntitlement,
  290. sendUsersWithReconfirmationsLapsedProcessed,
  291. }
  292. function makeAffiliationRequest(options, callback) {
  293. if (!settings.apis.v1.url) {
  294. return callback(null)
  295. } // service is not configured
  296. if (!options.extraSuccessStatusCodes) {
  297. options.extraSuccessStatusCodes = []
  298. }
  299. const requestOptions = {
  300. method: options.method,
  301. url: `${settings.apis.v1.url}${options.path}`,
  302. body: options.body,
  303. auth: { user: settings.apis.v1.user, pass: settings.apis.v1.pass },
  304. json: true,
  305. timeout: settings.apis.v1.timeout,
  306. }
  307. if (options.method === 'GET') {
  308. requestOptions.maxAttempts = 3
  309. requestOptions.retryDelay = 500
  310. } else {
  311. requestOptions.maxAttempts = 0
  312. }
  313. request(requestOptions, function (error, response, body) {
  314. if (error) {
  315. return callback(
  316. new V1ConnectionError('error getting affiliations from v1').withCause(
  317. error
  318. )
  319. )
  320. }
  321. if (response && response.statusCode >= 500) {
  322. return callback(
  323. new V1ConnectionError({
  324. message: 'error getting affiliations from v1',
  325. info: {
  326. status: response.statusCode,
  327. body,
  328. },
  329. })
  330. )
  331. }
  332. let isSuccess = response.statusCode >= 200 && response.statusCode < 300
  333. if (!isSuccess) {
  334. isSuccess = options.extraSuccessStatusCodes.includes(response.statusCode)
  335. }
  336. if (!isSuccess) {
  337. let errorMessage
  338. if (body && body.errors) {
  339. errorMessage = `${response.statusCode}: ${body.errors}`
  340. } else {
  341. errorMessage = `${options.defaultErrorMessage}: ${response.statusCode}`
  342. }
  343. logger.warn({ path: options.path, body: options.body }, errorMessage)
  344. return callback(
  345. new OError(errorMessage, { statusCode: response.statusCode })
  346. )
  347. }
  348. callback(null, body)
  349. })
  350. }
  351. InstitutionsAPI.promises = promisifyAll(InstitutionsAPI, {
  352. without: [
  353. 'addAffiliation',
  354. 'removeAffiliation',
  355. 'getUsersNeedingReconfirmationsLapsedProcessed',
  356. ],
  357. })
  358. InstitutionsAPI.promises.addAffiliation = addAffiliation
  359. InstitutionsAPI.promises.removeAffiliation = removeAffiliation
  360. InstitutionsAPI.promises.getUsersNeedingReconfirmationsLapsedProcessed =
  361. getUsersNeedingReconfirmationsLapsedProcessed
  362. export default InstitutionsAPI