Zoom Auditor

Script that lets you find the final instances of recurring Zoom meetings

  1. // ==UserScript==
  2. // @name Zoom Auditor
  3. // @description Script that lets you find the final instances of recurring Zoom meetings
  4. // @version 3
  5. // @grant none
  6. // @include https://zoom.us/*
  7. // @include https://*.zoom.us/*
  8. // @namespace https://greasyfork.org/users/22981
  9. // @license https://anticapitalist.software/
  10. // ==/UserScript==
  11.  
  12. /*
  13. ANTI-CAPITALIST SOFTWARE LICENSE (v 1.4)
  14.  
  15. Copyright © 2022 Adam Novak
  16.  
  17. This is anti-capitalist software, released for free use by individuals and
  18. organizations that do not operate by capitalist principles.
  19.  
  20. Permission is hereby granted, free of charge, to any person or organization
  21. (the "User") obtaining a copy of this software and associated documentation
  22. files (the "Software"), to use, copy, modify, merge, distribute, and/or sell
  23. copies of the Software, subject to the following conditions:
  24.  
  25. 1. The above copyright notice and this permission notice shall be included in
  26. all copies or modified versions of the Software.
  27.  
  28. 2. The User is one of the following:
  29. a. An individual person, laboring for themselves
  30. b. A non-profit organization
  31. c. An educational institution
  32. d. An organization that seeks shared profit for all of its members, and
  33. allows non-members to set the cost of their labor
  34.  
  35. 3. If the User is an organization with owners, then all owners are workers and
  36. all workers are owners with equal equity and/or equal vote.
  37.  
  38. 4. If the User is an organization, then the User is not law enforcement or
  39. military, or working for or under either.
  40.  
  41. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT EXPRESS OR IMPLIED WARRANTY OF ANY
  42. KIND, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
  43. FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE
  44. LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
  45. CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
  46. SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  47. */
  48.  
  49. /// Globally cache CSRF token
  50. let _csrf_token
  51.  
  52. /// Get the Zoom CSRF token to make requests.
  53. async function get_token() {
  54. if (!_csrf_token) {
  55. // See https://github.com/pozhiloy-enotik/zoom-gta/blob/1982234a066b2ed06277d68765ed2670f042fae6/gif.py#L15
  56. _csrf_token = await fetch('https://zoom.us/csrf_js', {
  57. method: 'POST',
  58. headers: {
  59. 'FETCH-CSRF-TOKEN': '1'
  60. }
  61. }).then((response) => {
  62. if (!response.ok) {
  63. throw new Error(`HTTP error! Status: ${response.status}`)
  64. }
  65. return response.text()
  66. }).then((text) => {
  67. let parts = text.split(':')
  68. console.log("Token got: " + parts[1])
  69. return parts[1]
  70. })
  71. }
  72. return _csrf_token
  73. }
  74.  
  75. /// Run the given async function on each page of upcoming Zoom meetings
  76. async function for_each_page(csrf_token, callback) {
  77. let page = 1
  78. let items_seen = 0
  79. let total_items = undefined
  80. while (total_items === undefined || total_items > items_seen) {
  81. let query = new URLSearchParams({
  82. 'listType': 'upcoming',
  83. 'page': page
  84. })
  85.  
  86. let page_data = await fetch('https://zoom.us/rest/meeting/list', {
  87. method: 'POST',
  88. headers: {
  89. 'Accept': 'application/json, text/plain, */*',
  90. 'Accept-Language': 'en-US,en;q=0.5',
  91. 'Content-Type': 'application/x-www-form-urlencoded',
  92. 'X-Requested-With': 'XMLHttpRequest, OWASP CSRFGuard Project',
  93. 'ZOOM-CSRFTOKEN': csrf_token
  94. },
  95. body: query
  96. }).then((response) => {
  97. if (!response.ok) {
  98. throw new Error(`HTTP error! Status: ${response.status}`)
  99. }
  100. return response.json()
  101. })
  102. if (!page_data.status) {
  103. console.log('Status is ', page_data.status)
  104. console.log(page_data)
  105. throw new Error(`API error! API says: ${JSON.stringify(page_data)}`)
  106. }
  107. page += 1
  108. total_items = page_data.result.totalRecords
  109. items_seen += (page_data.result.meetings || []).length
  110. await callback(page_data)
  111. if ((page_data.result.meetings || []).length == 0) {
  112. // Got an empty page for some reason. Probably done?
  113. break
  114. }
  115. }
  116. }
  117.  
  118. async function get_all_events() {
  119. console.log("Getting all events")
  120. csrf_token = await get_token()
  121. all_events = []
  122. await for_each_page(csrf_token, (page_data) => {
  123. console.log("Got event page:", page_data)
  124. for (let m of page_data.result.meetings) {
  125. for (let o of m.list) {
  126. all_events.push(o)
  127. }
  128. }
  129. })
  130. return all_events
  131. }
  132.  
  133. /// Given events, get a sorted list of objects for final events, with 'name', 'date', and 'link' fields
  134. function audit(events) {
  135. last_items = []
  136. for (let event of events) {
  137. // Find the last ones
  138. if (!event.occurrenceTip) {
  139. // Not a repeating event
  140. continue
  141. }
  142. let parsed_tip = event.occurrenceTip.match(/([0-9]+) of ([0-9]+)$/)
  143. if (parsed_tip[1] != parsed_tip[2]) {
  144. // Not a last one
  145. continue
  146. }
  147. last_items.push(event)
  148. }
  149. // Sort by ending soonest
  150. last_items.sort((a, b) => {return a.occurrence > b.occurrence})
  151. let results = []
  152. for (let e of last_items) {
  153. results.push({'name': e.topic, 'date': new Date(e.occurrence), 'link': `https://zoom.us/meeting/${e.number}/edit`})
  154. }
  155. return results
  156. }
  157.  
  158. /// Make an HTML element describing the given final meetings
  159. function make_table(final_meetings) {
  160. // Set up so we can know how far in advance things are.
  161. let now = new Date()
  162. const TWO_MONTHS_MS = 60 * 24 * 60 * 60 * 1000
  163. let root = document.createElement('div')
  164. root.innerHTML=`
  165. <style content-type="text/css">
  166. table.audit {
  167. margin: 0.5em;
  168. }
  169. table.audit td, table.audit th {
  170. border: 1px solid black;
  171. padding: 2px;
  172. }
  173. table.audit th {
  174. background-color: black;
  175. color: white;
  176. }
  177. table.audit tr.soon {
  178. background-color: lemonchiffon;
  179. }
  180. table.audit tr.soon td.name::after {
  181. content: " ⚠️ Expiring Soon!";
  182. color: red;
  183. font-size: 10pt;
  184. text-align: right;
  185. }
  186. table.audit td.link {
  187. text-align: center;
  188. }
  189. </style>
  190. <p>The following Zoom meetings will run out of occurrences soon.</p>
  191. `
  192. let table = document.createElement('table')
  193. table.classList.add('audit')
  194. let header = document.createElement('tr')
  195. header.innerHTML="<th>Name</th><th>Final Occurrence</th><th>Edit</th>"
  196. table.appendChild(header)
  197. for (let m of final_meetings) {
  198. // Make a row for each final meeting
  199. let row = document.createElement('tr')
  200. let name_cell = document.createElement('td')
  201. name_cell.classList.add('name')
  202. name_cell.innerText = m.name
  203. row.appendChild(name_cell)
  204. let date_cell = document.createElement('td')
  205. date_cell.innerText = m.date.toLocaleString('en-us')
  206. row.appendChild(date_cell)
  207. let ms_in_future = m.date - now
  208. if (ms_in_future < TWO_MONTHS_MS) {
  209. // Mark this as ending soon!
  210. row.classList.add('soon')
  211. }
  212. // Make sure we have a new-tab edit link
  213. let link_cell = document.createElement('td')
  214. link_cell.classList.add('link')
  215. let link = document.createElement('a')
  216. link.innerText = '📝'
  217. link.setAttribute('href', m.link)
  218. link.setAttribute('target', '_blank')
  219. link_cell.appendChild(link)
  220. row.appendChild(link_cell)
  221. table.appendChild(row)
  222. }
  223. root.appendChild(table)
  224. return root
  225. }
  226.  
  227. /// Show the given content in a closeable modal dialog
  228. function show_dialog(element) {
  229. const DIALOG_ID = "zoom_audit_dialog"
  230. // Get rid of any old dialogs from the page.
  231. let old_dialog = document.getElementById(DIALOG_ID)
  232. if (old_dialog) {
  233. old_dialog.remove()
  234. }
  235. let dialog = document.createElement('dialog')
  236. dialog.setAttribute('id', DIALOG_ID)
  237. dialog.appendChild(element)
  238. // Style the dialog
  239. dialog.style.padding = '1em'
  240. // Center the dialog
  241. dialog.style.position = 'fixed'
  242. dialog.style.left = '50%'
  243. dialog.style.overflow = 'scroll'
  244. dialog.style.transform = 'translateX(-50%)'
  245. let form = document.createElement('form')
  246. form.setAttribute('method', 'dialog')
  247. form.innerHTML="<button>Close</button>"
  248. dialog.appendChild(form)
  249. let body = (document.getElementsByTagName('body') || [])[0]
  250. if (body) {
  251. body.appendChild(dialog)
  252. dialog.showModal()
  253. }
  254. }
  255.  
  256. /// Make an HTML element which when pressed launches an audit, for the Zoom navbar
  257. function make_audit_button() {
  258. let item = document.createElement('li')
  259. item.setAttribute('role', 'none')
  260. let link = document.createElement('a')
  261. link.classList.add('light')
  262. link.setAttribute('role', 'menuitem')
  263. link.setAttribute('href', 'javascript:;')
  264. link.innerText = "🌹 AUDIT"
  265. // Put a throbber before the link text
  266. let throbber = document.createElement('span')
  267. throbber.innerText = '⌛'
  268. throbber.style.display = 'none'
  269. link.prepend(throbber)
  270. let auditing = false
  271. link.addEventListener('click', async () => {
  272. if (!auditing) {
  273. // Only run one flow at a time
  274. try {
  275. auditing = true
  276. throbber.style.display = 'inline'
  277. await do_audit()
  278. } finally {
  279. auditing = false
  280. throbber.style.display = 'none'
  281. }
  282. }
  283. })
  284. item.appendChild(link)
  285. return item
  286. }
  287.  
  288. /// Add an element to the right-side Zoom navbar, from a factory callback
  289. function add_to_right_navbar(make_element) {
  290. let navbar = document.getElementById('navbar')
  291. console.log("Navbar is:", navbar)
  292. if (!navbar) {
  293. throw new Error('Could not find navbar')
  294. }
  295. // Zoom now has mobile and non-mobile right navbars
  296. let right_navbars = (navbar.getElementsByClassName('navbar-right') || [])
  297. console.log("Right navbar(s):", right_navbars)
  298. if (!right_navbars) {
  299. throw new Error('Could not find right navbar')
  300. }
  301. for (let right_navbar of right_navbars) {
  302. let button = make_element()
  303. console.log("Made a button", button)
  304. right_navbar.appendChild(button)
  305. }
  306. }
  307.  
  308. /// Hook our UI into the page
  309. function setup() {
  310. console.log("Hooking in auditing")
  311. add_to_right_navbar(make_audit_button)
  312. }
  313.  
  314. /// Do an audit and show the dialog
  315. async function do_audit() {
  316. // Pre-declare variables so we can paste the rest in the console
  317. let all_events
  318. let final_meetings
  319. let report
  320. all_events = await get_all_events()
  321. final_meetings = audit(all_events)
  322. report = make_table(final_meetings)
  323. show_dialog(report)
  324. }
  325.  
  326. // On page load, hook in
  327. setup()
  328.  
  329.  
  330.