├── .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 | 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 | 6 | 7 | 16 | -------------------------------------------------------------------------------- /client/src/views/HomeView.vue: -------------------------------------------------------------------------------- 1 | 3 | 4 | 9 | -------------------------------------------------------------------------------- /client/src/views/QuillView.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 38 | 83 | 84 | 85 | 304 | -------------------------------------------------------------------------------- /client/src/views/SettingsView.vue: -------------------------------------------------------------------------------- 1 | 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 | --------------------------------------------------------------------------------