├── .env.sample
├── .gitignore
├── .husky
├── .gitignore
└── pre-commit
├── .prettierrc
├── LICENSE.md
├── README.md
├── client
├── .eslintrc.cjs
├── .gitignore
├── .prettierrc.json
├── .vscode
│ └── extensions.json
├── README.md
├── cypress.config.js
├── cypress
│ ├── e2e
│ │ ├── example.cy.js
│ │ └── jsconfig.json
│ ├── fixtures
│ │ └── example.json
│ └── support
│ │ ├── commands.js
│ │ ├── component-index.html
│ │ ├── component.js
│ │ └── e2e.js
├── index.html
├── package-lock.json
├── package.json
├── public
│ └── favicon.ico
├── src
│ ├── App.vue
│ ├── assets
│ │ ├── base.css
│ │ └── main.css
│ ├── main.js
│ ├── router
│ │ └── index.js
│ ├── stores
│ │ ├── counter.js
│ │ └── meeting.js
│ └── views
│ │ ├── AboutView.vue
│ │ ├── HomeView.vue
│ │ ├── QuillView.vue
│ │ └── SettingsView.vue
└── vite.config.js
├── package-lock.json
├── package.json
├── scripts
├── build.js
├── common.js
├── dev.js
└── gen-secrets.cjs
├── server
├── .eslintrc.cjs
├── package.json
├── src
│ ├── config.ts
│ ├── helpers
│ │ ├── cipher.ts
│ │ ├── routing.ts
│ │ └── zoom-api.ts
│ ├── http.ts
│ ├── index.ts
│ ├── middleware
│ │ ├── error-handler.ts
│ │ ├── log-axios.ts
│ │ └── zoom-context.ts
│ ├── models
│ │ └── exception.ts
│ ├── routes
│ │ ├── auth.ts
│ │ └── install.ts
│ ├── session.ts
│ ├── signal.ts
│ └── views
│ │ ├── error.pug
│ │ ├── install.pug
│ │ └── layout.pug
└── tsconfig.json
└── tsconfig.base.json
/.env.sample:
--------------------------------------------------------------------------------
1 | # Client ID for your Zoom App
2 | ZM_CLIENT_ID=
3 |
4 | # Client Secret for your Zoom app
5 | ZM_CLIENT_SECRET=
6 |
7 | # Redirect URI set for your app in the Zoom Marketplace
8 | ZM_REDIRECT_URL=
9 |
10 | # App Name used for isolating logs
11 | APP_NAME=${_APP_NAME}
12 |
13 | # Key used Sign Session Cookies
14 | SESSION_SECRET=${_SESSION_SECRET}
15 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # dotenv environment variables file
2 | .env
3 | .env.*
4 | !.env.sample
5 |
6 | server/public
7 |
8 | ### JetBrains template
9 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
10 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
11 |
12 | .idea
13 |
14 | ### NotepadPP template
15 | # Notepad++ backups #
16 | *.bak
17 |
18 | ### C++ template
19 | # Prerequisites
20 | *.d
21 |
22 | # Compiled Object files
23 | *.slo
24 | *.lo
25 | *.o
26 | *.obj
27 |
28 | # Precompiled Headers
29 | *.gch
30 | *.pch
31 |
32 | # Compiled Dynamic libraries
33 | *.so
34 | *.dylib
35 | *.dll
36 |
37 | # Compiled Static libraries
38 | *.lai
39 | *.la
40 | *.a
41 | *.lib
42 |
43 |
44 |
45 | ### VisualStudioCode template
46 | .vscode/*
47 | !.vscode/settings.json
48 | !.vscode/tasks.json
49 | !.vscode/launch.json
50 | !.vscode/extensions.json
51 | *.code-workspace
52 |
53 | # Local History for Visual Studio Code
54 | .history/
55 |
56 | ### Xcode template
57 | # Xcode
58 | #
59 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
60 |
61 | ## User settings
62 | xcuserdata/
63 |
64 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
65 | *.xcscmblueprint
66 | *.xccheckout
67 |
68 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
69 | build/
70 | DerivedData/
71 | *.moved-aside
72 | *.pbxuser
73 | !default.pbxuser
74 | *.mode1v3
75 | !default.mode1v3
76 | *.mode2v3
77 | !default.mode2v3
78 | *.perspectivev3
79 | !default.perspectivev3
80 |
81 | ## Gcc Patch
82 | /*.gcno
83 |
84 | ### CMake template
85 | CMakeLists.txt.user
86 | CMakeCache.txt
87 | CMakeFiles
88 | CMakeScripts
89 | Testing
90 | Makefile
91 | cmake_install.cmake
92 | install_manifest.txt
93 | compile_commands.json
94 | CTestTestfile.cmake
95 | _deps
96 |
97 | ### Node template
98 | # Logs
99 | logs
100 | *.log
101 | npm-debug.log*
102 | yarn-debug.log*
103 | yarn-error.log*
104 | lerna-debug.log*
105 |
106 | # Diagnostic reports (https://nodejs.org/api/report.html)
107 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
108 |
109 | # Runtime data
110 | pids
111 | *.pid
112 | *.seed
113 | *.pid.lock
114 |
115 | # Directory for instrumented libs generated by jscoverage/JSCover
116 | lib-cov
117 |
118 | # Coverage directory used by tools like istanbul
119 | coverage
120 | *.lcov
121 |
122 | # nyc test coverage
123 | .nyc_output
124 |
125 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
126 | .grunt
127 |
128 | # Bower dependency directory (https://bower.io/)
129 | bower_components
130 |
131 | # node-waf configuration
132 | .lock-wscript
133 |
134 | # Compiled binary addons (https://nodejs.org/api/addons.html)
135 | build/Release
136 |
137 | # Dependency directories
138 | node_modules/
139 | jspm_packages/
140 |
141 | # Snowpack dependency directory (https://snowpack.dev/)
142 | web_modules/
143 |
144 | # TypeScript cache
145 | *.tsbuildinfo
146 |
147 | # Optional npm cache directory
148 | .npm
149 |
150 | # Optional eslint cache
151 | .eslintcache
152 |
153 | # Microbundle cache
154 | .rpt2_cache/
155 | .rts2_cache_cjs/
156 | .rts2_cache_es/
157 | .rts2_cache_umd/
158 |
159 | # Optional REPL history
160 | .node_repl_history
161 |
162 | # Output of 'npm pack'
163 | *.tgz
164 |
165 | # Yarn Integrity file
166 | .yarn-integrity
167 |
168 | # parcel-bundler cache (https://parceljs.org/)
169 | .cache
170 | .parcel-cache
171 |
172 | # Next.js build output
173 | .next
174 | out
175 |
176 | # Nuxt.js build / generate output
177 | .nuxt
178 | dist
179 |
180 | # Gatsby files
181 | .cache/
182 | # Comment in the public line in if your project uses Gatsby and not Next.js
183 | # https://nextjs.org/blog/next-9-1#public-directory-support
184 | # public
185 |
186 | # vuepress build output
187 | .vuepress/dist
188 |
189 | # Serverless directories
190 | .serverless/
191 |
192 | # FuseBox cache
193 | .fusebox/
194 |
195 | # DynamoDB Local files
196 | .dynamodb/
197 |
198 | # TernJS port file
199 | .tern-port
200 |
201 | # Stores VSCode versions used for testing VSCode extensions
202 | .vscode-test
203 |
204 | # yarn v2
205 | .yarn/cache
206 | .yarn/unplugged
207 | .yarn/build-state.yml
208 | .yarn/install-state.gz
209 | .pnp.*
210 |
211 | ### Go template
212 | # Binaries for programs and plugins
213 | *.exe~
214 |
215 | # Test binary, built with `go test -c`
216 | *.test
217 |
218 | # Output of the go coverage tool, specifically when used with LiteIDE
219 |
220 | # Dependency directories (remove the comment below to include it)
221 | # vendor/
222 |
223 | ### Redis template
224 | # Ignore redis binary dump (dump.rdb) files
225 |
226 | *.rdb
227 |
228 | ### Windows template
229 | # Windows thumbnail cache files
230 | Thumbs.db
231 | Thumbs.db:encryptable
232 | ehthumbs.db
233 | ehthumbs_vista.db
234 |
235 | # Dump file
236 | *.stackdump
237 |
238 | # Folder config file
239 | [Dd]esktop.ini
240 |
241 | # Recycle Bin used on file shares
242 | $RECYCLE.BIN/
243 |
244 | # Windows Installer files
245 | *.cab
246 | *.msi
247 | *.msix
248 | *.msm
249 | *.msp
250 |
251 | # Windows shortcuts
252 | *.lnk
253 |
254 | ### OpenSSL template
255 | # OpenSSL-related files best not committed
256 |
257 | ## Certificate Authority
258 | *.ca
259 |
260 | ## Certificate
261 | *.crt
262 |
263 | ## Certificate Sign Request
264 | *.csr
265 |
266 | ## Certificate
267 | *.der
268 |
269 | ## Key database file
270 | *.kdb
271 |
272 | ## OSCP request data
273 | *.org
274 |
275 | ## PKCS #12
276 | *.p12
277 |
278 | ## PEM-encoded certificate data
279 | *.pem
280 |
281 | ## Random number seed
282 | *.rnd
283 |
284 | ## SSLeay data
285 | *.ssleay
286 |
287 | ## S/MIME message
288 | *.smime
289 |
290 | ### SublimeText template
291 | # Cache files for Sublime Text
292 | *.tmlanguage.cache
293 | *.tmPreferences.cache
294 | *.stTheme.cache
295 |
296 | # Workspace files are user-specific
297 | *.sublime-workspace
298 |
299 | # Project files should be checked into the repository, unless a significant
300 | # proportion of contributors will probably not be using Sublime Text
301 | # *.sublime-project
302 |
303 | # SFTP configuration file
304 | sftp-config.json
305 | sftp-config-alt*.json
306 |
307 | # Package control specific files
308 | Package Control.last-run
309 | Package Control.ca-list
310 | Package Control.ca-bundle
311 | Package Control.system-ca-bundle
312 | Package Control.cache/
313 | Package Control.ca-certs/
314 | Package Control.merged-ca-bundle
315 | Package Control.user-ca-bundle
316 | oscrypto-ca-bundle.crt
317 | bh_unicode_properties.cache
318 |
319 | # Sublime-github package stores a github token in this file
320 | # https://packagecontrol.io/packages/sublime-github
321 | GitHub.sublime-settings
322 |
323 | ### Vagrant template
324 | # General
325 | .vagrant/
326 |
327 | # Log files (if you are creating logs in debug mode, uncomment this)
328 | # *.log
329 |
330 | # CMake
331 | cmake-build-*/
332 |
333 | # Mongo Explorer plugin
334 | .idea/**/mongoSettings.xml
335 |
336 | # File-based project format
337 | *.iws
338 |
339 | # IntelliJ
340 | out/
341 |
342 | # mpeltonen/sbt-idea plugin
343 | .idea_modules/
344 |
345 | # JIRA plugin
346 | atlassian-ide-plugin.xml
347 |
348 | # Cursive Clojure plugin
349 | .idea/replstate.xml
350 |
351 | # Crashlytics plugin (for Android Studio and IntelliJ)
352 | com_crashlytics_export_strings.xml
353 | crashlytics.properties
354 | crashlytics-build.properties
355 | fabric.properties
356 |
357 | # Editor-based Rest Client
358 | .idea/httpRequests
359 |
360 | # Android studio 3.1+ serialized cache file
361 | .idea/caches/build_file_checksums.ser
362 |
363 | ### Emacs template
364 | # -*- mode: gitignore; -*-
365 | *~
366 | \#*\#
367 | /.emacs.desktop
368 | /.emacs.desktop.lock
369 | *.elc
370 | auto-save-list
371 | tramp
372 | .\#*
373 |
374 | # Org-mode
375 | .org-id-locations
376 | *_archive
377 |
378 | # flymake-mode
379 | *_flymake.*
380 |
381 | # eshell files
382 | /eshell/history
383 | /eshell/lastdir
384 |
385 | # elpa packages
386 | /elpa/
387 |
388 | # reftex files
389 | *.rel
390 |
391 | # AUCTeX auto folder
392 | /auto/
393 |
394 | # cask packages
395 | .cask/
396 | dist/
397 |
398 | # Flycheck
399 | flycheck_*.el
400 |
401 | # server auth directory
402 | # /server/
403 |
404 | # projectiles files
405 | .projectile
406 |
407 | # directory configuration
408 | .dir-locals.el
409 |
410 | # network security
411 | /network-security.data
412 |
413 |
414 | ### VisualStudio template
415 | ## Ignore Visual Studio temporary files, build results, and
416 | ## files generated by popular Visual Studio add-ons.
417 | ##
418 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
419 |
420 | # User-specific files
421 | *.rsuser
422 | *.suo
423 | *.user
424 | *.userosscache
425 | *.sln.docstates
426 |
427 | # User-specific files (MonoDevelop/Xamarin Studio)
428 | *.userprefs
429 |
430 | # Mono auto generated files
431 | mono_crash.*
432 |
433 | # Build results
434 | [Dd]ebug/
435 | [Dd]ebugPublic/
436 | [Rr]elease/
437 | [Rr]eleases/
438 | x64/
439 | x86/
440 | [Ww][Ii][Nn]32/
441 | [Aa][Rr][Mm]/
442 | [Aa][Rr][Mm]64/
443 | bld/
444 | [Bb]in/
445 | [Oo]bj/
446 | [Ll]og/
447 | [Ll]ogs/
448 |
449 | # Visual Studio 2015/2017 cache/options directory
450 | .vs/
451 | # Uncomment if you have tasks that create the project's static files in wwwroot
452 | #wwwroot/
453 |
454 | # Visual Studio 2017 auto generated files
455 | Generated\ Files/
456 |
457 | # MSTest test Results
458 | [Tt]est[Rr]esult*/
459 | [Bb]uild[Ll]og.*
460 |
461 | # NUnit
462 | *.VisualState.xml
463 | TestResult.xml
464 | nunit-*.xml
465 |
466 | # Build Results of an ATL Project
467 | [Dd]ebugPS/
468 | [Rr]eleasePS/
469 | dlldata.c
470 |
471 | # Benchmark Results
472 | BenchmarkDotNet.Artifacts/
473 |
474 | # .NET Core
475 | project.lock.json
476 | project.fragment.lock.json
477 | artifacts/
478 |
479 | # ASP.NET Scaffolding
480 | ScaffoldingReadMe.txt
481 |
482 | # StyleCop
483 | StyleCopReport.xml
484 |
485 | # Files built by Visual Studio
486 | *_i.c
487 | *_p.c
488 | *_h.h
489 | *.meta
490 | *.iobj
491 | *.ipdb
492 | *.pgc
493 | *.pgd
494 | *.rsp
495 | *.sbr
496 | *.tlb
497 | *.tli
498 | *.tlh
499 | *.tmp
500 | *.tmp_proj
501 | *_wpftmp.csproj
502 | *.vspscc
503 | *.vssscc
504 | .builds
505 | *.pidb
506 | *.svclog
507 | *.scc
508 |
509 | # Chutzpah Test files
510 | _Chutzpah*
511 |
512 | # Visual C++ cache files
513 | ipch/
514 | *.aps
515 | *.ncb
516 | *.opendb
517 | *.opensdf
518 | *.sdf
519 | *.cachefile
520 | *.VC.db
521 | *.VC.VC.opendb
522 |
523 | # Visual Studio profiler
524 | *.psess
525 | *.vsp
526 | *.vspx
527 | *.sap
528 |
529 | # Visual Studio Trace Files
530 | *.e2e
531 |
532 | # TFS 2012 Local Workspace
533 | $tf/
534 |
535 | # Guidance Automation Toolkit
536 | *.gpState
537 |
538 | # ReSharper is a .NET coding add-in
539 | _ReSharper*/
540 | *.[Rr]e[Ss]harper
541 | *.DotSettings.user
542 |
543 | # TeamCity is a build add-in
544 | _TeamCity*
545 |
546 | # DotCover is a Code Coverage Tool
547 | *.dotCover
548 |
549 | # AxoCover is a Code Coverage Tool
550 | .axoCover/*
551 | !.axoCover/settings.json
552 |
553 | # Coverlet is a free, cross platform Code Coverage Tool
554 | coverage*.json
555 | coverage*.xml
556 | coverage*.info
557 |
558 | # Visual Studio code coverage results
559 | *.coverage
560 | *.coveragexml
561 |
562 | # NCrunch
563 | _NCrunch_*
564 | .*crunch*.local.xml
565 | nCrunchTemp_*
566 |
567 | # MightyMoose
568 | *.mm.*
569 | AutoTest.Net/
570 |
571 | # Web workbench (sass)
572 | .sass-cache/
573 |
574 | # Installshield output folder
575 | [Ee]xpress/
576 |
577 | # DocProject is a documentation generator add-in
578 | DocProject/buildhelp/
579 | DocProject/Help/*.HxT
580 | DocProject/Help/*.HxC
581 | DocProject/Help/*.hhc
582 | DocProject/Help/*.hhk
583 | DocProject/Help/*.hhp
584 | DocProject/Help/Html2
585 | DocProject/Help/html
586 |
587 | # Click-Once directory
588 | publish/
589 |
590 | # Publish Web Output
591 | *.[Pp]ublish.xml
592 | *.azurePubxml
593 | # Note: Comment the next line if you want to checkin your web deploy settings,
594 | # but database connection strings (with potential passwords) will be unencrypted
595 | *.pubxml
596 | *.publishproj
597 |
598 | # Microsoft Azure Web App publish settings. Comment the next line if you want to
599 | # checkin your Azure Web App publish settings, but sensitive information contained
600 | # in these scripts will be unencrypted
601 | PublishScripts/
602 |
603 | # NuGet Packages
604 | *.nupkg
605 | # NuGet Symbol Packages
606 | *.snupkg
607 | # The packages folder can be ignored because of Package Restore
608 | **/[Pp]ackages/*
609 | # except build/, which is used as an MSBuild target.
610 | !**/[Pp]ackages/build/
611 | # Uncomment if necessary however generally it will be regenerated when needed
612 | #!**/[Pp]ackages/repositories.config
613 | # NuGet v3's project.json files produces more ignorable files
614 | *.nuget.props
615 | *.nuget.targets
616 |
617 | # Microsoft Azure Build Output
618 | csx/
619 | *.build.csdef
620 |
621 | # Microsoft Azure Emulator
622 | ecf/
623 | rcf/
624 |
625 | # Windows Store app package directories and files
626 | AppPackages/
627 | BundleArtifacts/
628 | Package.StoreAssociation.xml
629 | _pkginfo.txt
630 | *.appx
631 | *.appxbundle
632 | *.appxupload
633 |
634 | # Visual Studio cache files
635 | # files ending in .cache can be ignored
636 | *.[Cc]ache
637 | # but keep track of directories ending in .cache
638 | !?*.[Cc]ache/
639 |
640 | # Others
641 | ClientBin/
642 | ~$*
643 | *.dbmdl
644 | *.dbproj.schemaview
645 | *.jfm
646 | *.pfx
647 | *.publishsettings
648 | orleans.codegen.cs
649 |
650 | # Including strong name files can present a security risk
651 | # (https://github.com/github/gitignore/pull/2483#issue-259490424)
652 | #*.snk
653 |
654 | # Since there are multiple workflows, uncomment next line to ignore bower_components
655 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
656 | #bower_components/
657 |
658 | # RIA/Silverlight projects
659 | Generated_Code/
660 |
661 | # Backup & report files from converting an old project file
662 | # to a newer Visual Studio version. Backup files are not needed,
663 | # because we have git ;-)
664 | _UpgradeReport_Files/
665 | Backup*/
666 | UpgradeLog*.XML
667 | UpgradeLog*.htm
668 | ServiceFabricBackup/
669 | *.rptproj.bak
670 |
671 | # SQL Server files
672 | *.mdf
673 | *.ldf
674 | *.ndf
675 |
676 | # Business Intelligence projects
677 | *.rdl.data
678 | *.bim.layout
679 | *.bim_*.settings
680 | *.rptproj.rsuser
681 | *- [Bb]ackup.rdl
682 | *- [Bb]ackup ([0-9]).rdl
683 | *- [Bb]ackup ([0-9][0-9]).rdl
684 |
685 | # Microsoft Fakes
686 | FakesAssemblies/
687 |
688 | # GhostDoc plugin setting file
689 | *.GhostDoc.xml
690 |
691 | # Node.js Tools for Visual Studio
692 | .ntvs_analysis.dat
693 |
694 | # Visual Studio 6 build log
695 | *.plg
696 |
697 | # Visual Studio 6 workspace options file
698 | *.opt
699 |
700 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
701 | *.vbw
702 |
703 | # Visual Studio LightSwitch build output
704 | **/*.HTMLClient/GeneratedArtifacts
705 | **/*.DesktopClient/GeneratedArtifacts
706 | **/*.DesktopClient/ModelManifest.xml
707 | **/*.Server/GeneratedArtifacts
708 | **/*.Server/ModelManifest.xml
709 | _Pvt_Extensions
710 |
711 |
712 | ### AppEngine template
713 | # Google App Engine generated folder
714 | appengine-generated/
715 |
716 | ### Vim template
717 | # Swap
718 | [._]*.s[a-v][a-z]
719 | !*.svg # comment out if you don't need vector files
720 | [._]*.sw[a-p]
721 | [._]s[a-rt-v][a-z]
722 | [._]ss[a-gi-z]
723 | [._]sw[a-p]
724 |
725 | # Session
726 | Session.vim
727 | Sessionx.vim
728 |
729 | # Temporary
730 | .netrwhist
731 | # Auto-generated tag files
732 | tags
733 | # Persistent undo
734 | [._]*.un~
735 |
736 | ### macOS template
737 | # General
738 | .DS_Store
739 | .AppleDouble
740 | .LSOverride
741 |
742 | # Icon must end with two \r
743 | Icon
744 |
745 | # Thumbnails
746 | ._*
747 |
748 | # Files that might appear in the root of a volume
749 | .DocumentRevisions-V100
750 | .fseventsd
751 | .Spotlight-V100
752 | .TemporaryItems
753 | .Trashes
754 | .VolumeIcon.icns
755 | .com.apple.timemachine.donotpresent
756 |
757 | # Directories potentially created on remote AFP share
758 | .AppleDB
759 | .AppleDesktop
760 | Network Trash Folder
761 | Temporary Items
762 | .apdisk
763 |
--------------------------------------------------------------------------------
/.husky/.gitignore:
--------------------------------------------------------------------------------
1 | _
2 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | show_ignored() {
5 | files=$(git ls-files -v | grep '^[[:lower:]]')
6 |
7 | if [ -n "${files}" ]; then
8 | echo '➜ The following files are ignored by git:'
9 | echo "${files}"
10 | fi
11 |
12 | # always return success for husky
13 | return 0
14 | }
15 |
16 | # shellcheck disable=SC2015
17 | cd server && npx lint-staged && (show_ignored || true)
18 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "es5",
3 | "tabWidth": 4,
4 | "semi": true,
5 | "singleQuote": true
6 | }
7 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Zoom Video Communications, Inc.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Zoom Apps Collaborative Text Editor Sample
2 |
3 | This Zoom App sample uses Typescript + Vue.js to build a collaborative text editor that lives right in your meeting!
4 |
5 | ## Prerequisites
6 |
7 | 1. [Node JS](https://nodejs.org/en/)
8 | 2. [Ngrok](https://ngrok.com/docs/getting-started)
9 | 3. [Zoom Account](https://support.zoom.us/hc/en-us/articles/207278726-Plan-Types-)
10 | 4. [Zoom App Credentials](#config:-app-credentials) (Instructions below)
11 | 1. Client ID
12 | 2. Client Secret
13 | 3. Redirect URI
14 |
15 | ## Getting started
16 |
17 | Open your terminal:
18 |
19 | ```bash
20 | # Clone down this repository
21 | git clone git@github.com:zoom/zoomapps-texteditor-vuejs.git
22 |
23 | # navigate into the cloned project directory
24 | cd zoomapps-texteditor-vuejs
25 |
26 | # run NPM to install the app dependencies
27 | npm install
28 |
29 | # initialize your ngrok session
30 | ngrok http 3000
31 | ```
32 |
33 | ### Create your Zoom App
34 |
35 | In your web browser, navigate to [Zoom Developer Portal](https://developers.zoom.us/) and register/log into your
36 | developer account.
37 |
38 | Click the "Build App" button at the top and choose to "Zoom Apps" application.
39 |
40 | 1. Name your app
41 | 2. Choose whether to list your app on the marketplace or not
42 | 3. Click "Create"
43 |
44 | For more information, you can follow [this guide](https://marketplace.zoom.us/docs/beta-docs/zoom-apps/createazoomapp)
45 | check out [this video series]() on how to create and configure these sample Zoom Apps.
46 |
47 | ### Config: App Credentials
48 |
49 | In your terminal where you launched `ngrok`, find the `Forwarding` value and copy/paste that into the "Home URL" and "
50 | Redirect URL for OAuth" fields.
51 |
52 | ```
53 | Home URL: https://xxxxx.ngrok.io
54 | Redirect URL for OAuth: https://xxxxx.ngrok.io/auth
55 | ```
56 |
57 | > NOTE: ngrok URLs under ngrok's Free plan are ephemeral, meaning they will only live for up to a couple hours at most, and will change every time you reinitialize the application. This will require you to update these fields every time you restart your ngrok service.
58 |
59 | #### OAuth allow list
60 |
61 | - `https://example.ngrok.io`
62 |
63 | #### Domain allow list
64 |
65 | - `appssdk.zoom.us`
66 | - `ngrok.io`
67 | - `signaling.yjs.dev`
68 | - `y-webrtc-signaling-eu.herokuapp.com`
69 | - `y-webrtc-signaling-us.herokuapp.com`
70 |
71 | > NOTE: This sample application uses the public heroku signaling servers provided by the Y.js organization, as such any information your app synchronizes through their services is considered non-private. However you can create and host your own signaling server using the information for the y-webrtc library -- https://github.com/yjs/y-webrtc#signaling
72 |
73 |
74 | ### Config: Information
75 |
76 | The following information is required to activate your application:
77 |
78 | - Basic Information
79 | - App name
80 | - Short description
81 | - Long description (entering a short message here is fine for now)
82 | - Developer Contact Information
83 | - Name
84 | - Email address
85 |
86 | > NOTE: if you intend to publish your application on the Zoom Apps Marketplace, more information will be required in this section before submitting.
87 |
88 | ### Config: App Features
89 |
90 | Under the Zoom App SDK section, click the `+ Add APIs` button and enable the following options from their respective
91 | sections:
92 |
93 | #### APIs
94 |
95 | - `getRunningContext`
96 | - `getUserContext`
97 | - `getMeetingUUID`
98 | - `connect`
99 | - `postMessage`
100 |
101 | #### Events:
102 |
103 | - `onParticipantChange`
104 | - `onConnect`
105 | - `onMeeting`
106 | - `onMessage`
107 |
108 | ### Zoom App Features
109 |
110 | Enable `Collaborate mode`
111 |
112 | ### Scopes
113 |
114 | Select the following OAuth scopes from the Scopes tab:
115 |
116 | - `meeting:read`
117 | - `meeting:write`
118 | - `user:read`
119 | - `zoomapp:inmeeting`
120 |
121 | ### Config `.env`
122 |
123 | Open the `.env` file in your text editor and enter the following information from the App Credentials section you just
124 | configured:
125 |
126 | ```ini
127 | # Client ID for your Zoom App
128 | ZOOM_CLIENT_ID=[app_client_id]
129 |
130 | # Client Secret for your Zoom app
131 | ZOOM_CLIENT_SECRET=[app_client_id]
132 |
133 | # Redirect URI set for your app in the Zoom Marketplace
134 | ZOOM_REDIRECT_URL=https://[xxxx-xx-xx-xxx-x].ngrok.io/auth
135 | ```
136 |
137 | #### Zoom for Government
138 |
139 | If you are a [Zoom for Government (ZfG)](https://www.zoomgov.com/) customer you can use the `ZOOM_HOST` variable to change
140 | the base URL used for Zoom. This will allow you to adjust to the different Marketplace and API Base URLs used by ZfG
141 | customers.
142 |
143 | **Marketplace URL:** marketplace.*zoomgov.com*
144 |
145 | **API Base URL:** api.*zoomgov.com*
146 |
147 | ## Start the App
148 |
149 | ### Development
150 |
151 | Run the `dev` npm script to start in development mode using a Docker container.
152 |
153 | ```shell
154 | npm run dev
155 | ```
156 |
157 | The `dev` script will:
158 |
159 | 1. Watch Vue.js files and built to the `server/public/` folder
160 | 1. Watch Server files and build to the `dist/` folder
161 | 1. Start the application
162 |
163 | ### Production
164 |
165 | When running your application in production no logs are sent to the console by default and the server is not restarted
166 | on file changes.
167 |
168 | We use the `NODE_ENV` environment variable here to tell the application to start in prodcution mode.
169 |
170 | ```shell
171 | # Mac/Linux
172 | NODE_ENV=production npm start
173 |
174 | # Windows
175 | set NODE_ENV=production && npm start
176 | ````
177 |
178 | ## Usage
179 |
180 | To install the Zoom App, Navigate to the **Home Page URL** that you set in your browser and click the link to install.
181 |
182 | After you authorize the app, Zoom will automatically open the app within the client.
183 |
184 | ## Contribution
185 |
186 | Please send pull requests and issues to this project for any problems or suggestions that you have!
187 |
188 | Make sure that you install packages locally to pass pre-commit git hooks.
189 |
190 | ### Keeping secrets secret
191 |
192 | This application makes use of your Zoom App Client ID and Client Secret as well as a custom secret for signing session
193 | cookies. During development, the application will read from the .env file. ;
194 |
195 | In order to align with security best practices, this application does not read from the .env file in production mode.
196 |
197 | This means you'll want to set environment variables on the hosting platform that you'
198 | re using instead of within the .env file. This might include using a secret manager or a CI/CD pipeline.
199 |
200 | > :warning: **Never commit your .env file to version control:** The file likely contains Zoom App Credentials and Session Secrets
201 |
202 | ### Code Style
203 |
204 | This project uses [prettier](https://prettier.io/) and [eslint](https://eslint.org/) to enforce style and protect
205 | against coding errors along with a pre-commit git hook(s) via [husky](https://typicode.github.io/husky/#/) to ensure
206 | files pass checks prior to commit.
207 |
208 | ### Testing
209 |
210 | At this time there are no e2e or unit tests.
211 |
212 | ## Need help?
213 |
214 | If you're looking for help, try [Developer Support](https://devsupport.zoom.us) or
215 | our [Developer Forum](https://devforum.zoom.us). Priority support is also available
216 | with [Premier Developer Support](https://zoom.us/docs/en-us/developer-support-plans.html) plans.
217 |
--------------------------------------------------------------------------------
/client/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | /* eslint-env node */
2 | require('@rushstack/eslint-patch/modern-module-resolution')
3 |
4 | module.exports = {
5 | root: true,
6 | 'extends': [
7 | 'plugin:vue/vue3-essential',
8 | 'eslint:recommended',
9 | '@vue/eslint-config-prettier'
10 | ],
11 | overrides: [
12 | {
13 | files: [
14 | '**/__tests__/*.{cy,spec}.{js,ts,jsx,tsx}',
15 | 'cypress/e2e/**.{cy,spec}.{js,ts,jsx,tsx}'
16 | ],
17 | 'extends': [
18 | 'plugin:cypress/recommended'
19 |
20 | ] }
21 | ],
22 | rules: {
23 | semi: "never",
24 | },
25 | parserOptions: {
26 | ecmaVersion: 'latest'
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/client/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | .DS_Store
12 | dist
13 | dist-ssr
14 | coverage
15 | *.local
16 |
17 | /cypress/videos/
18 | /cypress/screenshots/
19 |
20 | # Editor directories and files
21 | .vscode/*
22 | !.vscode/extensions.json
23 | .idea
24 | *.suo
25 | *.ntvs*
26 | *.njsproj
27 | *.sln
28 | *.sw?
29 |
--------------------------------------------------------------------------------
/client/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {}
--------------------------------------------------------------------------------
/client/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"]
3 | }
4 |
--------------------------------------------------------------------------------
/client/README.md:
--------------------------------------------------------------------------------
1 | # vue-project
2 |
3 | This template should help get you started developing with Vue 3 in Vite.
4 |
5 | ## Recommended IDE Setup
6 |
7 | [VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
8 |
9 | ## Customize configuration
10 |
11 | See [Vite Configuration Reference](https://vitejs.dev/config/).
12 |
13 | ## Project Setup
14 |
15 | ```sh
16 | npm install
17 | ```
18 |
19 | ### Compile and Hot-Reload for Development
20 |
21 | ```sh
22 | npm run dev
23 | ```
24 |
25 | ### Compile and Minify for Production
26 |
27 | ```sh
28 | npm run build
29 | ```
30 |
31 | ### Run Headed Component Tests with [Cypress Component Testing](https://on.cypress.io/component)
32 |
33 | ```sh
34 | npm run test:unit # or `npm run test:unit:ci` for headless testing
35 | ```
36 |
37 | ### Run End-to-End Tests with [Cypress](https://www.cypress.io/)
38 |
39 | ```sh
40 | npm run build
41 | npm run test:e2e # or `npm run test:e2e:ci` for headless testing
42 | ```
43 |
44 | ### Lint with [ESLint](https://eslint.org/)
45 |
46 | ```sh
47 | npm run lint
48 | ```
49 |
--------------------------------------------------------------------------------
/client/cypress.config.js:
--------------------------------------------------------------------------------
1 | const { defineConfig } = require('cypress')
2 |
3 | module.exports = defineConfig({
4 | e2e: {
5 | specPattern: 'cypress/e2e/**/*.{cy,spec}.{js,jsx,ts,tsx}',
6 | baseUrl: 'http://localhost:4173'
7 | },
8 | component: {
9 | specPattern: 'src/**/__tests__/*.{cy,spec}.{js,ts,jsx,tsx}',
10 | devServer: {
11 | framework: 'vue',
12 | bundler: 'vite'
13 | }
14 | }
15 | })
16 |
--------------------------------------------------------------------------------
/client/cypress/e2e/example.cy.js:
--------------------------------------------------------------------------------
1 | // https://docs.cypress.io/api/introduction/api.html
2 |
3 | describe('My First Test', () => {
4 | it('visits the app root url', () => {
5 | cy.visit('/')
6 | cy.contains('h1', 'You did it!')
7 | })
8 | })
9 |
--------------------------------------------------------------------------------
/client/cypress/e2e/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["es5", "dom"],
5 | "types": ["cypress"]
6 | },
7 | "include": ["./**/*", "../support/**/*"]
8 | }
9 |
--------------------------------------------------------------------------------
/client/cypress/fixtures/example.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Using fixtures to represent data",
3 | "email": "hello@cypress.io",
4 | "body": "Fixtures are a great way to mock data for responses to routes"
5 | }
6 |
--------------------------------------------------------------------------------
/client/cypress/support/commands.js:
--------------------------------------------------------------------------------
1 | // ***********************************************
2 | // This example commands.js shows you how to
3 | // create various custom commands and overwrite
4 | // existing commands.
5 | //
6 | // For more comprehensive examples of custom
7 | // commands please read more here:
8 | // https://on.cypress.io/custom-commands
9 | // ***********************************************
10 | //
11 | //
12 | // -- This is a parent command --
13 | // Cypress.Commands.add('login', (email, password) => { ... })
14 | //
15 | //
16 | // -- This is a child command --
17 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
18 | //
19 | //
20 | // -- This is a dual command --
21 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
22 | //
23 | //
24 | // -- This will overwrite an existing command --
25 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
26 |
--------------------------------------------------------------------------------
/client/cypress/support/component-index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Components App
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/client/cypress/support/component.js:
--------------------------------------------------------------------------------
1 | // ***********************************************************
2 | // This example support/component.js is processed and
3 | // loaded automatically before your test files.
4 | //
5 | // This is a great place to put global configuration and
6 | // behavior that modifies Cypress.
7 | //
8 | // You can change the location of this file or turn off
9 | // automatically serving support files with the
10 | // 'supportFile' configuration option.
11 | //
12 | // You can read more here:
13 | // https://on.cypress.io/configuration
14 | // ***********************************************************
15 |
16 | // Import commands.js using ES2015 syntax:
17 | import './commands'
18 |
19 | // Alternatively you can use CommonJS syntax:
20 | // require('./commands')
21 |
22 | // Import global styles
23 | import '@/assets/main.css'
24 |
25 | import { mount } from 'cypress/vue'
26 |
27 | Cypress.Commands.add('mount', mount)
28 |
29 | // Example use:
30 | // cy.mount(MyComponent)
31 |
--------------------------------------------------------------------------------
/client/cypress/support/e2e.js:
--------------------------------------------------------------------------------
1 | // ***********************************************************
2 | // This example support/index.js is processed and
3 | // loaded automatically before your test files.
4 | //
5 | // This is a great place to put global configuration and
6 | // behavior that modifies Cypress.
7 | //
8 | // You can change the location of this file or turn off
9 | // automatically serving support files with the
10 | // 'supportFile' configuration option.
11 | //
12 | // You can read more here:
13 | // https://on.cypress.io/configuration
14 | // ***********************************************************
15 |
16 | // Import commands.js using ES2015 syntax:
17 | import './commands'
18 |
19 | // Alternatively you can use CommonJS syntax:
20 | // require('./commands')
21 |
--------------------------------------------------------------------------------
/client/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite App
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vue-project",
3 | "version": "0.0.0",
4 | "scripts": {
5 | "dev": "vite build --watch --mode=dev --emptyOutDir --outDir=../server/public",
6 | "build": "vite build --outDir=../server/public",
7 | "preview": "vite preview --port 4173",
8 | "test:e2e": "start-server-and-test preview http://localhost:4173/ 'cypress open --e2e'",
9 | "test:e2e:ci": "start-server-and-test preview http://localhost:4173/ 'cypress run --e2e'",
10 | "test:unit": "cypress open --component",
11 | "test:unit:ci": "cypress run --component --quiet --reporter spec",
12 | "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
13 | },
14 | "dependencies": {
15 | "pinia": "2.0.21",
16 | "quill-cursors": "^4.0.0",
17 | "vue": "3.2.38",
18 | "vue-router": "4.1.5",
19 | "vue3-quill-editor-vite": "^0.0.4",
20 | "vuex": "^4.0.2",
21 | "y-quill": "^0.1.5",
22 | "y-webrtc": "10.2.3",
23 | "y-websocket": "^1.4.5",
24 | "yjs": "13.5.35"
25 | },
26 | "devDependencies": {
27 | "@rushstack/eslint-patch": "1.1.4",
28 | "@vitejs/plugin-vue": "3.0.3",
29 | "@vue/eslint-config-prettier": "7.0.0",
30 | "cypress": "10.7.0",
31 | "eslint": "8.22.0",
32 | "eslint-plugin-cypress": "2.12.1",
33 | "eslint-plugin-vue": "9.3.0",
34 | "prettier": "2.7.1",
35 | "start-server-and-test": "1.14.0",
36 | "vite": "3.0.9"
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/client/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zoom/zoomapps-texteditor-vuejs/078bbd24a924f271b67833668fadf9342e3df8c7/client/public/favicon.ico
--------------------------------------------------------------------------------
/client/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
13 |
14 |
15 |
16 |
17 |
20 |
21 |
24 |
--------------------------------------------------------------------------------
/client/src/assets/base.css:
--------------------------------------------------------------------------------
1 | /* color palette from */
2 | :root {
3 | --vt-c-white: #ffffff;
4 | --vt-c-white-soft: #f8f8f8;
5 | --vt-c-white-mute: #f2f2f2;
6 |
7 | --vt-c-black: #181818;
8 | --vt-c-black-soft: #222222;
9 | --vt-c-black-mute: #282828;
10 |
11 | --vt-c-indigo: #2c3e50;
12 |
13 | --vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
14 | --vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
15 | --vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
16 | --vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
17 |
18 | --vt-c-text-light-1: var(--vt-c-indigo);
19 | --vt-c-text-light-2: rgba(60, 60, 60, 0.66);
20 | --vt-c-text-dark-1: var(--vt-c-white);
21 | --vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
22 | }
23 |
24 | /* semantic color variables for this project */
25 | :root {
26 | --color-background: var(--vt-c-white);
27 | --color-background-soft: var(--vt-c-white-soft);
28 | --color-background-mute: var(--vt-c-white-mute);
29 |
30 | --color-border: var(--vt-c-divider-light-2);
31 | --color-border-hover: var(--vt-c-divider-light-1);
32 |
33 | --color-heading: var(--vt-c-text-light-1);
34 | --color-text: var(--vt-c-text-light-1);
35 |
36 | --section-gap: 160px;
37 | }
38 |
39 | @media (prefers-color-scheme: dark) {
40 | :root {
41 | --color-background: var(--vt-c-black);
42 | --color-background-soft: var(--vt-c-black-soft);
43 | --color-background-mute: var(--vt-c-black-mute);
44 |
45 | --color-border: var(--vt-c-divider-dark-2);
46 | --color-border-hover: var(--vt-c-divider-dark-1);
47 |
48 | --color-heading: var(--vt-c-text-dark-1);
49 | --color-text: var(--vt-c-text-dark-2);
50 | }
51 | }
52 |
53 | *,
54 | *::before,
55 | *::after {
56 | box-sizing: border-box;
57 | margin: 0;
58 | position: relative;
59 | font-weight: normal;
60 | }
61 |
62 | body {
63 | min-height: 100vh;
64 | color: var(--color-text);
65 | background: var(--color-background);
66 | transition: color 0.5s, background-color 0.5s;
67 | line-height: 1.6;
68 | font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
69 | Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
70 | font-size: 15px;
71 | text-rendering: optimizeLegibility;
72 | -webkit-font-smoothing: antialiased;
73 | -moz-osx-font-smoothing: grayscale;
74 | }
75 |
--------------------------------------------------------------------------------
/client/src/assets/main.css:
--------------------------------------------------------------------------------
1 |
2 | #app {
3 | max-width: 1280px;
4 | margin: 0 auto;
5 | padding: 2rem;
6 |
7 | font-weight: normal;
8 | }
9 |
10 | a,
11 | .green {
12 | text-decoration: none;
13 | color: hsla(160, 100%, 37%, 1);
14 | transition: 0.4s;
15 | }
16 |
17 | @media (hover: hover) {
18 | a:hover {
19 | background-color: hsla(160, 100%, 37%, 0.2);
20 | }
21 | }
22 |
23 | @media (min-width: 1024px) {
24 | body {
25 | display: flex;
26 | place-items: center;
27 | }
28 |
29 | #app {
30 | display: grid;
31 | grid-template-columns: 1fr 1fr;
32 | padding: 0 2rem;
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/client/src/main.js:
--------------------------------------------------------------------------------
1 | import { createApp } from 'vue'
2 | import { createPinia } from 'pinia'
3 |
4 | import App from './App.vue'
5 | import router from './router'
6 |
7 | // import './assets/base.css'
8 | // import './assets/main.css'
9 |
10 | const app = createApp(App)
11 |
12 | app.use(createPinia())
13 | app.use(router)
14 |
15 |
16 | // import QuillEditor from 'vue3-quill-editor-vite'
17 | // import 'vue3-quill-editor-vite/dist/style.css'
18 |
19 | // // console.log({VueQuill})
20 |
21 | // app.use(QuillEditor)
22 |
23 | app.mount('#app')
24 |
--------------------------------------------------------------------------------
/client/src/router/index.js:
--------------------------------------------------------------------------------
1 | import { createRouter, createWebHistory } from 'vue-router'
2 | import HomeView from '@/views/HomeView.vue'
3 |
4 | const router = createRouter({
5 | history: createWebHistory(import.meta.env.BASE_URL),
6 | routes: [
7 | {
8 | path: '/',
9 | name: 'home',
10 | component: HomeView
11 | },
12 |
13 | {
14 | path: '/about',
15 | name: 'about',
16 | // route level code-splitting
17 | // this generates a separate chunk (About.[hash].js) for this route
18 | // which is lazy-loaded when the route is visited.
19 | component: () => import('@/views/AboutView.vue')
20 | },
21 |
22 | {
23 | path: '/quill/:meetingId?',
24 | name: 'quill',
25 | component: () => import('@/views/QuillView.vue')
26 | },
27 |
28 | {
29 | path: '/settings',
30 | name: 'settings',
31 | component: () => import('@/views/SettingsView.vue')
32 | },
33 | ]
34 | })
35 |
36 | export default router
37 |
--------------------------------------------------------------------------------
/client/src/stores/counter.js:
--------------------------------------------------------------------------------
1 | import { ref, computed } from 'vue'
2 | import { defineStore } from 'pinia'
3 |
4 | export const useCounterStore = defineStore('counter', () => {
5 | const count = ref(0)
6 | const doubleCount = computed(() => count.value * 2)
7 | function increment() {
8 | count.value++
9 | }
10 |
11 | return { count, doubleCount, increment }
12 | })
13 |
--------------------------------------------------------------------------------
/client/src/stores/meeting.js:
--------------------------------------------------------------------------------
1 | import { ref, computed } from 'vue'
2 | import { defineStore } from 'pinia'
3 |
4 | export const useMeetingStore = defineStore('meeting', () => {
5 | const meetingId = ref('')
6 |
7 | // const doubleCount = computed(() => count.value * 2)
8 |
9 | function update(state) {
10 | meetingId.value = state
11 | }
12 |
13 | return { meetingId, update }
14 | })
15 |
--------------------------------------------------------------------------------
/client/src/views/AboutView.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
This is an about page
4 |
5 |
6 |
7 |
16 |
--------------------------------------------------------------------------------
/client/src/views/HomeView.vue:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
6 | App home page
7 |
8 |
9 |
--------------------------------------------------------------------------------
/client/src/views/QuillView.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | -
10 | {{ user.initials }}
11 |
12 |
13 |
14 |
23 |
24 |
25 | Debug
26 |
27 | users {{ users }}
28 | meetingId: {{meetingId}}
29 |
30 |
31 |
32 |
33 | Click "Start Meeting" to get startd!
34 |
35 |
36 |
37 |
38 |
83 |
84 |
85 |
304 |
--------------------------------------------------------------------------------
/client/src/views/SettingsView.vue:
--------------------------------------------------------------------------------
1 |
2 |
17 |
18 |
19 |
20 |
21 |
22 |
29 |
30 |
31 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
88 |
--------------------------------------------------------------------------------
/client/vite.config.js:
--------------------------------------------------------------------------------
1 | import { fileURLToPath, URL } from 'node:url'
2 |
3 | import { watcherOptions } from 'rollup'
4 | import { defineConfig } from 'vite'
5 | import vue from '@vitejs/plugin-vue'
6 |
7 | // https://vitejs.dev/config/
8 | export default defineConfig({
9 | plugins: [vue()],
10 | resolve: {
11 | alias: {
12 | '@': fileURLToPath(new URL('./src', import.meta.url))
13 | }
14 | },
15 | })
16 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "zoomapps-texteditor-vuejs",
3 | "version": "1.0.2",
4 | "description": "A Zoom App Template",
5 | "private": true,
6 | "license": "MIT",
7 | "main": "dist/index.js",
8 | "type": "module",
9 | "workspaces": [
10 | "server",
11 | "client"
12 | ],
13 | "scripts": {
14 | "dev": "concurrently -c \"bgCyan.bold,bgYellow.bold\" \"npm:client\" \"npm:server\"",
15 | "build": "npm run build --prefix client",
16 | "start": "npm --prefix dist run start",
17 | "client": "npm run dev --prefix client",
18 | "server": "npm run dev --prefix server",
19 | "old-build": "node scripts/build.js",
20 | "old-dev": "node scripts/dev.js",
21 | "prepare": "husky install",
22 | "postinstall": "node scripts/gen-secrets.cjs",
23 | "proxy": "lt --port 3000"
24 | },
25 | "devDependencies": {
26 | "await-spawn": "^4.0.2",
27 | "concurrently": "^7.1.0",
28 | "dotenv-cli": "^5.1.0",
29 | "envsub": "^4.0.7",
30 | "fs-extra": "^10.1.0",
31 | "husky": "^7.0.4",
32 | "server": "^0.1.0"
33 | },
34 | "lint-staged": {
35 | "*.ts": [
36 | "eslint --cache --fix",
37 | "prettier --write"
38 | ]
39 | },
40 | "dependencies": {
41 | "@zoom/appssdk": "^0.16.7",
42 | "ts-node": "^10.8.1",
43 | "upath": "^2.0.1"
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/scripts/build.js:
--------------------------------------------------------------------------------
1 | import fse from 'fs-extra'
2 | import {init, shell} from './common.js';
3 |
4 | const out = './dist';
5 |
6 | try {
7 | fse.emptydir(out);
8 |
9 | await shell('npm', ['run', 'build', '-ws']);
10 |
11 | await init(out);
12 | fse.copy('./app/dist', './dist/public');
13 | } catch (e) {
14 | console.error(e);
15 | }
16 |
--------------------------------------------------------------------------------
/scripts/common.js:
--------------------------------------------------------------------------------
1 | import fse from 'fs-extra';
2 |
3 | import spawn from 'await-spawn';
4 |
5 | export const shell = async (cmd, opts) =>
6 | spawn(cmd, opts, { stdio: 'inherit' });
7 |
8 | export const init = async (outDir) => {
9 | fse.ensureDir(outDir);
10 |
11 | try {
12 | fse.copy('package-lock.json', `${outDir}/package-lock.json`);
13 | fse.copy('server/package.json', `${outDir}/package.json`);
14 | fse.copy('server/src/views', `${outDir}/views`);
15 | } catch (e) {
16 | console.error(e);
17 | }
18 | };
19 |
--------------------------------------------------------------------------------
/scripts/dev.js:
--------------------------------------------------------------------------------
1 | import concurrently from 'concurrently';
2 | import {init, shell} from './common.js';
3 | import fs from 'fs';
4 |
5 | const outDir = './dist'
6 |
7 | // Configure our server environment variables for darwin/linux and win32
8 | let command = `npm run dev -w server`;
9 |
10 | if (process.platform === 'win32')
11 | command = `set "DEBUG=zoomapps*" & ${command}`;
12 | else command = `DEBUG="zoomapps*" ${command}`;
13 |
14 |
15 |
16 |
17 | const { result } = concurrently([
18 | {
19 | command,
20 | name: 'dev-server',
21 | prefixColor: 'inverse.cyan',
22 | },
23 | {
24 | command: `npm:dev -w client`,
25 | name: 'dev-app',
26 | prefixColor: 'inverse.yellow',
27 | },
28 | ]);
29 |
30 | result.catch((e) => console.error(e));
31 |
--------------------------------------------------------------------------------
/scripts/gen-secrets.cjs:
--------------------------------------------------------------------------------
1 | const envsub = require('envsub')
2 | const crypto = require('crypto')
3 | const fs = require('fs')
4 |
5 | const {name} = require('../package.json')
6 |
7 | const outputFile = '.env';
8 | const templateFile = `${outputFile}.sample`;
9 |
10 | const options = {
11 | protect: true,
12 | envs: [
13 | {
14 | name: '_SESSION_SECRET',
15 | value: crypto.randomBytes(32).toString('hex'),
16 | },
17 | {
18 | name: '_APP_NAME',
19 | value: name,
20 | },
21 | ],
22 | };
23 |
24 | if (!fs.existsSync(outputFile))
25 | envsub({ templateFile, outputFile, options }).catch((e) =>
26 | console.error(e)
27 | );
28 |
--------------------------------------------------------------------------------
/server/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: {
4 | node: true,
5 | },
6 | parser: '@typescript-eslint/parser',
7 | plugins: ['@typescript-eslint'],
8 | extends: [
9 | 'eslint:recommended',
10 | 'plugin:@typescript-eslint/eslint-recommended',
11 | 'plugin:@typescript-eslint/recommended',
12 | ],
13 | ignorePatterns: ['node_modules', 'dist'],
14 | };
15 |
--------------------------------------------------------------------------------
/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "server",
3 | "version": "0.1.0",
4 | "private": true,
5 | "license": "MIT",
6 | "type": "module",
7 | "description": "A template express server for Zoom Apps",
8 | "main": "index.js",
9 | "scripts": {
10 | "start": "node --experimental-specifier-resolution=node --loader ts-node/esm ./src/index.ts",
11 | "start-dev": "node --experimental-specifier-resolution=node --loader ts-node/esm ./src/index.ts",
12 | "build": "tsc -b",
13 | "dev": "tsc-watch --outDir ../dist --noClear --onSuccess \"npm run start-dev\""
14 | },
15 | "dependencies": {
16 | "axios": "^0.26.0",
17 | "compression": "^1.7.4",
18 | "cookie-parser": "~1.4.6",
19 | "cookie-session": "^2.0.0",
20 | "debug": "~4.3.3",
21 | "dotenv": "^16.0.0",
22 | "express": "~4.17.3",
23 | "express-validator": "^6.14.0",
24 | "helmet": "^5.0.2",
25 | "http-errors": "^2.0.0",
26 | "morgan": "~1.10.0",
27 | "pug": "^3.0.2",
28 | "ws": "^8.5.0"
29 | },
30 | "devDependencies": {
31 | "@types/compression": "^1.7.2",
32 | "@types/cookie-parser": "^1.4.2",
33 | "@types/cookie-session": "^2.0.44",
34 | "@types/debug": "^4.1.7",
35 | "@types/express": "^4.17.13",
36 | "@types/http-errors": "^1.8.2",
37 | "@types/morgan": "^1.9.3",
38 | "@types/node": "^17.0.23",
39 | "@types/websocket": "^1.0.5",
40 | "@types/ws": "^8.5.4",
41 | "@typescript-eslint/eslint-plugin": "^5.17.0",
42 | "@typescript-eslint/parser": "^5.17.0",
43 | "eslint": "^8.12.0",
44 | "eslint-config-prettier": "^8.4.0",
45 | "lint-staged": "^12.3.4",
46 | "prettier": "^2.5.1",
47 | "tsc-watch": "^5.0.2",
48 | "typescript": "^4.6.3"
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/server/src/config.ts:
--------------------------------------------------------------------------------
1 | import { URL } from 'url';
2 | import debug from 'debug';
3 | import dotenv from 'dotenv';
4 | import path from 'upath';
5 |
6 | if (process.env.NODE_ENV !== 'production') {
7 | const result = dotenv.config({ path: path.resolve('../.env') });
8 |
9 | if (result.error) {
10 | throw result.error;
11 | }
12 | }
13 |
14 | const config = process.env;
15 |
16 | const deps = [
17 | 'ZM_CLIENT_ID',
18 | 'ZM_CLIENT_SECRET',
19 | 'ZM_REDIRECT_URL',
20 | 'SESSION_SECRET',
21 | ];
22 |
23 | // Check that we have all our config dependencies
24 | let hasMissing = !config;
25 | for (const dep in deps) {
26 | const conf = deps[dep];
27 | const str = config[conf];
28 |
29 | if (!str) {
30 | console.error(`${conf} is required`);
31 | hasMissing = true;
32 | }
33 | }
34 |
35 | if (hasMissing) throw new Error('Missing required .env values...exiting');
36 |
37 | export const zoomApp = {
38 | host: config.ZOOM_HOST || 'https://zoom.us',
39 | clientId: config.ZM_CLIENT_ID as string,
40 | clientSecret: config.ZM_CLIENT_SECRET as string,
41 | redirectUrl: config.ZM_REDIRECT_URL as string,
42 | sessionSecret: config.SESSION_SECRET as string,
43 | };
44 |
45 | export const appName = config.APP_NAME || 'zoom-app';
46 | export const redirectUrl = zoomApp.redirectUrl as string;
47 | export const port = config.PORT || 3000;
48 |
49 | const dbg = debug(`${config.APP_NAME}:config`);
50 |
51 | try {
52 | new URL(config.ZM_REDIRECT_URL as string);
53 | } catch (e) {
54 | if (!(e instanceof Error)) dbg(e);
55 | else throw e;
56 | }
57 |
58 | // require secrets are explicitly imported
59 | export default {
60 | appName,
61 | redirectUrl,
62 | port,
63 | };
64 |
--------------------------------------------------------------------------------
/server/src/helpers/cipher.ts:
--------------------------------------------------------------------------------
1 | import crypto from 'crypto';
2 | import createError from 'http-errors';
3 | import { zoomApp } from '../config.js';
4 |
5 | /**
6 | * Decode and parse a base64 encoded Zoom App Context
7 | * @param {String} ctx - Encoded Zoom App Context
8 | * @return {Object} Decoded Zoom App Context object
9 | */
10 | function unpack(ctx: string) {
11 | // Decode base64
12 | let buf = Buffer.from(ctx, 'base64');
13 |
14 | // Get iv length (1 byte)
15 | const ivLength = buf.readUInt8();
16 | buf = buf.slice(1);
17 |
18 | // Get iv
19 | const iv = buf.slice(0, ivLength);
20 | buf = buf.slice(ivLength);
21 |
22 | // Get aad length (2 bytes)
23 | const aadLength = buf.readUInt16LE();
24 | buf = buf.slice(2);
25 |
26 | // Get aad
27 | const aad = buf.slice(0, aadLength);
28 | buf = buf.slice(aadLength);
29 |
30 | // Get cipher length (4 bytes)
31 | const cipherLength = buf.readInt32LE();
32 | buf = buf.slice(4);
33 |
34 | // Get cipherText
35 | const cipherText = buf.slice(0, cipherLength);
36 |
37 | // Get tag
38 | const tag = buf.slice(cipherLength);
39 |
40 | return {
41 | iv,
42 | aad,
43 | cipherText,
44 | tag,
45 | };
46 | }
47 |
48 | /**
49 | * Decrypts cipherText from a decoded Zoom App Context object
50 | * @param {Buffer} cipherText - Data to decrypt
51 | * @param {Buffer} hash - sha256 hash of the Client Secret
52 | * @param {Buffer} iv - Initialization Vector for cipherText
53 | * @param {Buffer} aad - Additional Auth Data for cipher
54 | * @param {Buffer} tag - cipherText auth tag
55 | * @return {JSON|Error} Decrypted JSON obj from cipherText or Error
56 | */
57 | function decrypt(
58 | cipherText: Buffer,
59 | hash: Buffer,
60 | iv: Buffer,
61 | aad: Buffer,
62 | tag: Buffer
63 | ) {
64 | // AES/GCM decryption
65 | const decipher = crypto
66 | .createDecipheriv('aes-256-gcm', hash, iv)
67 | .setAAD(aad)
68 | .setAuthTag(tag)
69 | .setAutoPadding(false);
70 |
71 | const enc = 'hex';
72 | const update = decipher.update(cipherText.toString(enc), enc, 'utf-8');
73 | const final = decipher.final('utf-8');
74 |
75 | const decrypted = update + final;
76 |
77 | return JSON.parse(decrypted);
78 | }
79 |
80 | /**
81 | * Decodes, parses and decrypts the x-zoom-server-context header
82 | * @see https://marketplace.zoom.us/docs/beta-docs/zoom-apps/zoomappcontext#decrypting-the-header-value
83 | * @param {String} header - Encoded Zoom App Context header
84 | * @param {String} [secret=''] - Client Secret for the Zoom App
85 | * @return {JSON|Error} Decrypted Zoom App Context or Error
86 | */
87 | export function getAppContext(header: string, secret = '') {
88 | if (!header)
89 | throw createError(500, 'context header must be a valid string');
90 |
91 | const key = secret || zoomApp.clientSecret;
92 |
93 | // Decode and parse context
94 | const { iv, aad, cipherText, tag } = unpack(header);
95 |
96 | // Create sha256 hash from Client Secret (key)
97 | const hash = crypto.createHash('sha256').update(key).digest();
98 |
99 | // return decrypted context
100 | return decrypt(cipherText, hash, iv, aad, tag);
101 | }
102 |
103 | export const contextHeader = 'x-zoom-app-context';
104 |
--------------------------------------------------------------------------------
/server/src/helpers/routing.ts:
--------------------------------------------------------------------------------
1 | import { validationResult } from 'express-validator';
2 | import createError from 'http-errors';
3 | import { Exception } from '../models/exception.js';
4 |
5 | /**
6 | * sanitize - throw an error if the request did not pass validation
7 | */
8 | export function sanitize(req: Express.Request) {
9 | return new Promise((resolve, reject) => {
10 | const errors = validationResult(req);
11 |
12 | if (errors.isEmpty()) resolve();
13 |
14 | const { msg } = errors.array({ onlyFirstError: true })[0];
15 | const e = new Exception(msg, 400);
16 | reject(e);
17 | });
18 | }
19 |
20 | /**
21 | * Passes errors to the error handler route
22 | */
23 | export function handleError(e: Exception): Error {
24 | let status = e.code;
25 | let data = e.message;
26 |
27 | if (e.response) {
28 | status = e.response.status.toString();
29 | data = e.response.data;
30 | } else if (e.request) {
31 | data = e.request.data;
32 | }
33 |
34 | return createError(status || 500, data);
35 | }
36 |
--------------------------------------------------------------------------------
/server/src/helpers/zoom-api.ts:
--------------------------------------------------------------------------------
1 | import axios, { Method } from 'axios';
2 | import { URL } from 'url';
3 | import createError from 'http-errors';
4 | import { zoomApp } from '../config.js';
5 |
6 | import crypto from 'crypto';
7 |
8 | // returns a base64 encoded url
9 | const base64URL = (s: string) =>
10 | s.toString().replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
11 |
12 | // returns a random string of format fmt
13 | const rand = (fmt: string, depth = 32) =>
14 | crypto.randomBytes(depth).toString(fmt as BufferEncoding);
15 |
16 | // Get Zoom API URL from Zoom Host value
17 | const host = new URL(zoomApp.host);
18 | host.hostname = host.hostname.replace(/^/, 'api.');
19 |
20 | const baseURL = host.href;
21 |
22 | // returns a random string of format fmt
23 |
24 | /**
25 | * Generic function for retrieving access or refresh tokens
26 | * @param params - Request parameters (form-urlencoded)
27 | * @param [id=''] - Username for Basic Auth
28 | * @param [secret=''] - Password for Basic Auth
29 | */
30 | function tokenRequest(params: URLSearchParams, id?: string, secret?: string) {
31 | const username = id || zoomApp.clientId;
32 | const password = secret || zoomApp.clientSecret;
33 |
34 | return axios({
35 | data: new URLSearchParams(params).toString(),
36 | baseURL: zoomApp.host,
37 | url: '/oauth/token',
38 | method: 'POST',
39 | headers: {
40 | 'Content-Type': 'application/x-www-form-urlencoded',
41 | },
42 | auth: {
43 | username,
44 | password,
45 | },
46 | }).then(({ data }) => Promise.resolve(data));
47 | }
48 |
49 | /**
50 | * Generic function to make a request to the Zoom API
51 | */
52 | function apiRequest(
53 | method: Method,
54 | endpoint: string,
55 | token: string,
56 | data?: unknown
57 | ) {
58 | return axios({
59 | data,
60 | method,
61 | baseURL,
62 | url: `/v2${endpoint}`,
63 | headers: {
64 | Authorization: `Bearer ${token}`,
65 | },
66 | }).then(({ data }) => Promise.resolve(data));
67 | }
68 |
69 | export function getInstallURL() {
70 | const state = rand('base64');
71 | const verifier = rand('ascii');
72 |
73 | const digest = crypto
74 | .createHash('sha256')
75 | .update(verifier)
76 | .digest('base64')
77 | .toString();
78 |
79 | const challenge = base64URL(digest);
80 |
81 | const url = new URL('/oauth/authorize', zoomApp.host);
82 |
83 | url.searchParams.set('response_type', 'code');
84 | url.searchParams.set('client_id', zoomApp.clientId);
85 | url.searchParams.set('redirect_uri', zoomApp.redirectUrl);
86 | url.searchParams.set('code_challenge', challenge);
87 | url.searchParams.set('code_challenge_method', 'S256');
88 | url.searchParams.set('state', state);
89 |
90 | return { url, state, verifier };
91 | }
92 |
93 | /**
94 | * Obtains an OAuth access token from Zoom
95 | * @param code - Authorization code from user authorization
96 | * @param verifier - code verifier for PKCE
97 | */
98 | export async function getToken(code: string, verifier: string) {
99 | if (!code)
100 | throw createError(500, 'authorization code must be a valid string');
101 |
102 | if (!verifier)
103 | throw createError(500, 'code verifier code must be a valid string');
104 |
105 | const params = new URLSearchParams({
106 | code,
107 | code_verifier: verifier,
108 | redirect_uri: zoomApp.redirectUrl,
109 | grant_type: 'authorization_code',
110 | });
111 | return tokenRequest(params);
112 | }
113 |
114 | /**
115 | * Obtain a new Access Token from a Zoom Refresh Token
116 | * @param token - Refresh token to use
117 | */
118 | export async function refreshToken(token: string) {
119 | if (!token) throw createError(500, 'refresh token must be a valid string');
120 |
121 | const params = new URLSearchParams({
122 | refresh_token: token,
123 | grant_type: 'refresh_token',
124 | });
125 |
126 | return tokenRequest(params);
127 | }
128 |
129 | /**
130 | * Use the Zoom API to get a Zoom User
131 | * @param {string} uid - User ID to query on
132 | * @param {string} token Zoom App Access Token
133 | */
134 | export function getZoomUser(uid: string, token: string) {
135 | return apiRequest('GET', `/users/${uid}`, token);
136 | }
137 |
138 | /**
139 | * Return the DeepLink for opening Zoom
140 | * @param {string} token - Zoom App Access Token
141 | */
142 | export function getDeeplink(token: string) {
143 | return apiRequest('POST', '/zoomapp/deeplink', token, {
144 | action: JSON.stringify({
145 | url: '/',
146 | role_name: 'Owner',
147 | verified: 1,
148 | role_id: 0,
149 | }),
150 | }).then((data) => Promise.resolve(data.deeplink));
151 | }
152 |
--------------------------------------------------------------------------------
/server/src/http.ts:
--------------------------------------------------------------------------------
1 | import { Application } from 'express';
2 | import http from 'http';
3 | import debug from 'debug';
4 | import { appName } from './config.js';
5 |
6 | import { Exception } from './models/exception.js';
7 |
8 | const dbg = debug(`${appName}:http`);
9 |
10 | function getPort(server: http.Server): string {
11 | const addr = server.address();
12 |
13 | if (!addr) return '';
14 |
15 | if (typeof addr === 'string') return addr;
16 | else return addr.port.toString();
17 | }
18 |
19 | /**
20 | * Start the HTTP server
21 | * @param app - Express server to attach to
22 | * @param onRequest - Event listener for the server
23 | */
24 | export function createHTTP(app: Application) {
25 | // Create HTTP server
26 | const server = http.createServer(app);
27 |
28 | // let the user know when we're serving
29 | server.on('listening', (p = getPort(server)) =>
30 | dbg(`Listening on http://localhost:${p}`)
31 | );
32 |
33 | server.on('error', (e: Exception) => {
34 | if (e?.syscall !== 'listen') throw e;
35 |
36 | const p = `Port ${getPort(server)}`;
37 | let msg = '';
38 |
39 | // handle specific listen errors with friendly messages
40 | switch (e?.code) {
41 | case 'EACCES':
42 | msg = `${p} requires elevated privileges`;
43 | break;
44 | case 'EADDRINUSE':
45 | msg = `${p} is already in use`;
46 | break;
47 | default:
48 | throw e;
49 | }
50 |
51 | if (msg) throw new Exception(msg);
52 | });
53 |
54 | return server;
55 | }
56 |
--------------------------------------------------------------------------------
/server/src/index.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import compression from 'compression';
3 | import cookieParser from 'cookie-parser';
4 | import debug from 'debug';
5 | import express from 'express';
6 | import helmet from 'helmet';
7 | import logger from 'morgan';
8 | import path from 'upath';
9 | import { URL } from 'url';
10 |
11 | import { createHTTP } from './http.js';
12 | import signal from './signal.js';
13 |
14 | import zoomContext from './middleware/zoom-context.js';
15 | import errorHandler from './middleware/error-handler.js';
16 | import logAxios from './middleware/log-axios.js';
17 |
18 | import authRoutes from './routes/auth.js';
19 | import installRoutes from './routes/install.js';
20 |
21 | import { appName, port, zoomApp } from './config.js';
22 |
23 | const dbg = debug(`${appName}:app`);
24 |
25 | /* App Config */
26 | const app = express();
27 | app.set('port', port);
28 |
29 | const redirectHost = new URL(zoomApp.redirectUrl).host;
30 |
31 | const publicDir = path.resolve('public');
32 | const viewsDir = path.resolve('src/views');
33 |
34 | // we use server views to show server errors and prompt installs
35 | app.set('view engine', 'pug');
36 | app.set('views', viewsDir);
37 |
38 | /* Middleware */
39 | axios.interceptors.request.use(logAxios.request);
40 | axios.interceptors.response.use(logAxios.response);
41 |
42 | const origins = ["'self'", "'unsafe-inline'", "'unsafe-eval'"];
43 |
44 | app.use(
45 | helmet({
46 | frameguard: {
47 | action: 'sameorigin',
48 | },
49 | hsts: {
50 | maxAge: 31536000,
51 | },
52 | referrerPolicy: {
53 | policy: 'same-origin',
54 | },
55 | crossOriginEmbedderPolicy: false,
56 | contentSecurityPolicy: {
57 | directives: {
58 | 'default-src': origins,
59 | styleSrc: origins,
60 | scriptSrc: ['https://appssdk.zoom.us/sdk.min.js', ...origins],
61 | imgSrc: ["'self'", 'data:', `https://${redirectHost}`],
62 | 'connect-src': [
63 | "'self'",
64 | `wss://${redirectHost}`,
65 | 'wss://signaling.yjs.dev/',
66 | 'wss://y-webrtc-signaling-eu.herokuapp.com/',
67 | 'wss://y-webrtc-signaling-us.herokuapp.com/',
68 | ],
69 | 'base-uri': 'self',
70 | 'form-action': 'self',
71 | },
72 | },
73 | })
74 | );
75 |
76 | app.use(express.json());
77 | app.use(compression());
78 | app.use(cookieParser());
79 | app.use(express.urlencoded({ extended: false }));
80 | app.use(logger('dev', { stream: { write: (msg: string) => dbg(msg) } }));
81 |
82 | // Check each page for a Zoom Context Header
83 | app.use(/\/|\*.html/g, zoomContext());
84 |
85 | // set up our server routes
86 | app.use('/', installRoutes);
87 | app.use('/auth', authRoutes);
88 |
89 | // handle server errors
90 | app.use(errorHandler());
91 |
92 | // serve our vue app
93 | app.use(express.static(publicDir));
94 |
95 | // redirect 404s back to index.html
96 | app.get('*', (req, res) => {
97 | res.sendFile('index.html', { root: publicDir });
98 | });
99 |
100 | //start http server
101 | const srvHttp = createHTTP(app);
102 |
103 | // start signaling websocket server for webrtc
104 | signal.config(srvHttp);
105 |
106 | (async () => {
107 | try {
108 | await srvHttp.listen(port);
109 | } catch (e: unknown) {
110 | dbg(e);
111 | process.exit(1);
112 | }
113 | })();
114 |
115 | export default app;
116 |
--------------------------------------------------------------------------------
/server/src/middleware/error-handler.ts:
--------------------------------------------------------------------------------
1 | import { NextFunction, Request, Response } from 'express';
2 | import debug from 'debug';
3 |
4 | import { Exception } from '../models/exception.js';
5 | import { appName } from '../config.js';
6 |
7 | const dbg = debug(`${appName}:error`);
8 |
9 | export default () =>
10 | (
11 | err: Exception,
12 | req: Request,
13 | res: Response,
14 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
15 | next: NextFunction
16 | ) => {
17 | const status = err.status || 500;
18 | const title = `Error ${err.status}`;
19 |
20 | // set locals, only providing error in development
21 | res.locals.message = err.message;
22 | res.locals.error = req.app.get('env') === 'development' ? err : {};
23 |
24 | if (res.locals.error) dbg(`${title} %s`, err.stack);
25 |
26 | // render the error page
27 | res.status(status).render('error');
28 | };
29 |
--------------------------------------------------------------------------------
/server/src/middleware/log-axios.ts:
--------------------------------------------------------------------------------
1 | import { AxiosRequestConfig, AxiosResponse } from 'axios';
2 | import debug from 'debug';
3 | import { URL } from 'url';
4 | import { appName } from '../config.js';
5 |
6 | const dbg = debug(`${appName}:axios`);
7 | const isProd = process.env.NODE_ENV === 'production';
8 |
9 | type AxiosInterceptor = (r: T) => T;
10 | type AxiosLogger = {
11 | response?: AxiosInterceptor;
12 | request?: AxiosInterceptor;
13 | };
14 |
15 | const printLog = (
16 | method: string | undefined,
17 | path: string | undefined,
18 | baseURL: string | undefined,
19 | status?: number
20 | ) => {
21 | let msg = method ? `${method.toUpperCase()} ` : '';
22 |
23 | if (status) msg = `${status.toString()} ${msg} `;
24 |
25 | if (path && baseURL) msg += new URL(path, baseURL).href;
26 | else if (baseURL) msg += baseURL;
27 |
28 | dbg(msg);
29 | };
30 |
31 | const logger: AxiosLogger = {
32 | response: (r) => {
33 | if (isProd) return r;
34 |
35 | const {
36 | status,
37 | config: { method, url, baseURL },
38 | } = r;
39 |
40 | printLog(method, url, baseURL, status);
41 |
42 | return r;
43 | },
44 | request: (r) => {
45 | if (isProd) return r;
46 |
47 | const { method, url, baseURL } = r;
48 |
49 | printLog(method, url, baseURL);
50 |
51 | return r;
52 | },
53 | };
54 |
55 | export default logger;
56 |
--------------------------------------------------------------------------------
/server/src/middleware/zoom-context.ts:
--------------------------------------------------------------------------------
1 | import { NextFunction, Request, Response } from 'express';
2 | import { Exception } from '../models/exception.js';
3 |
4 | import { handleError } from '../helpers/routing.js';
5 | import { contextHeader, getAppContext } from '../helpers/cipher.js';
6 |
7 | const maxLen = 512;
8 |
9 | /**
10 | * Decrypt the Zoom App Context or prompt the user to open Zoom
11 | */
12 | export default () => (req: Request, res: Response, next: NextFunction) => {
13 | const header = req.header(contextHeader);
14 |
15 | if (!header) return res.render('install');
16 |
17 | if (header.length > maxLen) {
18 | const e = new Exception(
19 | `Zoom App Context Header must be < ${maxLen} characters`,
20 | 400
21 | );
22 | return next(handleError(e));
23 | }
24 |
25 | const { uid, mid } = getAppContext(header);
26 |
27 | if (req.session) {
28 | req.session['userId'] = uid;
29 | req.session['meetingUUID'] = mid;
30 | }
31 |
32 | next();
33 | };
34 |
--------------------------------------------------------------------------------
/server/src/models/exception.ts:
--------------------------------------------------------------------------------
1 | import { AxiosRequestConfig, AxiosResponse } from 'axios';
2 |
3 | export class Exception extends Error {
4 | name: string;
5 | message: string;
6 | status?: number;
7 | code?: string;
8 | syscall?: string;
9 | stack?: string;
10 | request?: AxiosRequestConfig;
11 | response?: AxiosResponse;
12 |
13 | constructor(message: string, status = 500) {
14 | super();
15 |
16 | this.name = 'Express Exception';
17 | this.message = message;
18 | this.status = status;
19 | this.code = status.toString();
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/server/src/routes/auth.ts:
--------------------------------------------------------------------------------
1 | import express, { NextFunction, Request, Response } from 'express';
2 | import { query } from 'express-validator';
3 | import debug from 'debug';
4 |
5 | import { Exception } from '../models/exception.js';
6 | import { handleError, sanitize } from '../helpers/routing.js';
7 | import { getDeeplink, getToken } from '../helpers/zoom-api.js';
8 |
9 | import session from '../session.js';
10 |
11 | import { appName } from '../config.js';
12 | import createError from 'http-errors';
13 |
14 | const dbg = debug(`${appName}:auth`);
15 |
16 | const router = express.Router();
17 |
18 | const codeMin = 32;
19 | const codeMax = 64;
20 | const stateMax = 1024;
21 |
22 | // Validate the Authorization Code sent from Zoom
23 | const validateQuery = [
24 | query('code')
25 | .isString()
26 | .withMessage('code must be a valid string')
27 | .isLength({ min: codeMin, max: codeMax })
28 | .withMessage(`code must be > ${codeMin} and < ${codeMax} chars`)
29 | .escape(),
30 | query('state')
31 | .isString()
32 | .withMessage('state must be a string')
33 | .custom((value, { req }) => value === req.session.state)
34 | .withMessage('invalid state parameter')
35 | .escape(),
36 | ];
37 |
38 | /*
39 | * Redirect URI - Zoom App Launch handler
40 | * The user is redirected to this route when they authorize your server
41 | */
42 | const authHandler = async (req: Request, res: Response, next: NextFunction) => {
43 | if (!req.session) return createError(500, 'Cannot read session data');
44 | req.session['state'] = null;
45 |
46 | try {
47 | // sanitize code and state query parameters
48 | await sanitize(req);
49 |
50 | const code = req.query.code;
51 | const verifier = req.session['verifier'];
52 | // we have to check the type for TS so let's add an error too
53 | if (typeof code !== 'string') {
54 | const e = new Exception('invalid code parameter received', 400);
55 | return next(handleError(e));
56 | }
57 |
58 | // get Access Token from Zoom
59 | const { access_token: accessToken } = await getToken(code, verifier);
60 |
61 | // fetch deeplink from Zoom API
62 | const deeplink = await getDeeplink(accessToken);
63 |
64 | // redirect the user to the Zoom Client
65 | res.redirect(deeplink);
66 | } catch (e: unknown) {
67 | if (!(e instanceof Exception)) return dbg(e);
68 | return next(handleError(e));
69 | }
70 | };
71 |
72 | router.get('/', session, validateQuery, authHandler);
73 |
74 | export default router;
75 |
--------------------------------------------------------------------------------
/server/src/routes/install.ts:
--------------------------------------------------------------------------------
1 | import express, { Request, Response } from 'express';
2 | import createError from 'http-errors';
3 | import { getInstallURL } from '../helpers/zoom-api.js';
4 | import session from '../session.js';
5 |
6 | const router = express.Router();
7 |
8 | /*
9 | * Install Route - Install the Zoom App from the Zoom Marketplace
10 | * this route is used when a user installs the app from the Zoom Client
11 | */
12 | const installHandler = async (req: Request, res: Response) => {
13 | if (!req.session) return createError(500, 'Cannot read session data');
14 |
15 | const { url, state, verifier } = getInstallURL();
16 | req.session['state'] = state;
17 | req.session['verifier'] = verifier;
18 |
19 | res.redirect(url.href);
20 | };
21 | router.get('/install', session, installHandler);
22 |
23 | export default router;
24 |
--------------------------------------------------------------------------------
/server/src/session.ts:
--------------------------------------------------------------------------------
1 | import cookieSession from 'cookie-session';
2 | import { zoomApp } from './config.js';
3 |
4 | export default cookieSession({
5 | name: 'session',
6 | httpOnly: true,
7 | keys: [zoomApp.sessionSecret],
8 | maxAge: 24 * 60 * 60 * 1000,
9 | secure: process.env.NODE_ENV === 'production',
10 | });
11 |
--------------------------------------------------------------------------------
/server/src/signal.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This is a slight modification of the default pubsub/signaling server from the y-webrtc package
3 | * @see https://github.com/yjs/y-webrtc/blob/master/bin/server.js
4 | */
5 |
6 | import { WebSocket, WebSocketServer } from 'ws';
7 | import * as map from 'lib0/map';
8 | import { Server } from 'http';
9 |
10 | type message = {
11 | type?: string;
12 | topic?: string;
13 | topics?: string[];
14 | };
15 |
16 | const wsReadyStateConnecting = 0;
17 | const wsReadyStateOpen = 1;
18 | const timeout = 30000;
19 | const meetings = new Map();
20 |
21 | function send(conn: WebSocket, msg: message) {
22 | const isConnecting = conn.readyState !== wsReadyStateConnecting;
23 | const isOpen = conn.readyState !== wsReadyStateOpen;
24 |
25 | if (isConnecting && isOpen) conn.close();
26 |
27 | try {
28 | conn.send(JSON.stringify(msg));
29 | } catch (e) {
30 | conn.close();
31 | }
32 | }
33 |
34 | function onConnect(wss: WebSocket) {
35 | let closed = false;
36 | let pongReceived = true;
37 |
38 | const subscribedTopics = new Set();
39 |
40 | const pingInterval = setInterval(() => {
41 | if (!pongReceived) {
42 | wss.close();
43 | clearInterval(pingInterval);
44 | } else {
45 | pongReceived = false;
46 | try {
47 | wss.ping();
48 | } catch (e) {
49 | wss.close();
50 | }
51 | }
52 | }, timeout);
53 |
54 | wss.on('pong', () => (pongReceived = true));
55 |
56 | wss.on('close', () => {
57 | subscribedTopics.forEach((topicName) => {
58 | const subs = meetings.get(topicName) || new Set();
59 | subs.delete(WebSocket);
60 | if (subs.size === 0) {
61 | meetings.delete(topicName);
62 | }
63 | });
64 | subscribedTopics.clear();
65 | closed = true;
66 | });
67 |
68 | wss.on('message', (msg: message & string) => {
69 | if (typeof msg === 'string') msg = JSON.parse(msg);
70 | if (closed || !msg?.type) return;
71 |
72 | if (msg.type === 'subscribe') {
73 | msg.topics?.forEach((topicName: string) => {
74 | // add ws to topic
75 | const topic = map.setIfUndefined(
76 | meetings,
77 | topicName,
78 | () => new Set()
79 | );
80 | topic.add(wss);
81 | // add topic to ws
82 | subscribedTopics.add(topicName);
83 | });
84 | } else if (msg.type === 'unsubscribe') {
85 | msg.topics?.forEach((topicName: string) => {
86 | const subs = meetings.get(topicName);
87 | if (subs) {
88 | subs.delete(wss);
89 | }
90 | });
91 | } else if (msg.topic && msg.type === 'publish') {
92 | meetings
93 | .get(msg.topic)
94 | ?.forEach((receiver: WebSocket) => send(receiver, msg));
95 | } else if (msg.type === 'ping') send(wss, { type: 'pong' });
96 | });
97 | }
98 |
99 | const config = (server: Server) => {
100 | const wss = new WebSocketServer({ server });
101 |
102 | wss.on('connection', onConnect);
103 | };
104 |
105 | export default {
106 | config,
107 | };
108 |
--------------------------------------------------------------------------------
/server/src/views/error.pug:
--------------------------------------------------------------------------------
1 | extends layout
2 |
3 | block content
4 | h1= `Error ${error.status}`
5 | h2= message
6 | pre #{error.stack}
7 |
--------------------------------------------------------------------------------
/server/src/views/install.pug:
--------------------------------------------------------------------------------
1 | extends layout
2 |
3 | block content
4 | h1 Hello Browser
5 | p You're viewing your Zoom App through the browser.
6 | a(href=`/install`) Click Here
7 | | to install your app in Zoom.
8 |
--------------------------------------------------------------------------------
/server/src/views/layout.pug:
--------------------------------------------------------------------------------
1 | doctype html
2 | html(lang="en")
3 | head
4 | block head
5 | meta(charset='utf-8')
6 | meta(http-equiv='X-UA-Compatible', content='IE=edge')
7 | meta(name='description', content='Some description')
8 | meta(name='viewport', content='width=device-width, initial-scale=1')
9 | style.
10 | body {
11 | padding: 50px;
12 | font: 14px "Lucida Grande", Helvetica, Arial, sans-serif;
13 | }
14 |
15 | a {
16 | color: #00B7FF;
17 | }
18 | body
19 | block content
20 |
--------------------------------------------------------------------------------
/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.base.json",
3 | "compilerOptions": {
4 | "outDir": "../dist"
5 | },
6 | "include": [
7 | "src/**/*.ts",
8 | "tests/**/*.ts",
9 | ],
10 | "exclude": [
11 | "node_modules"
12 | ],
13 | "ts-node": {
14 | "esm": true
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/tsconfig.base.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2020",
4 | "module": "es2020",
5 | "strict": true,
6 | "skipLibCheck": true,
7 | "moduleResolution": "node",
8 | "esModuleInterop": true,
9 | "forceConsistentCasingInFileNames": true,
10 | "sourceMap": true,
11 | "baseUrl": ".",
12 | },
13 | }
14 |
--------------------------------------------------------------------------------