diff --git a/desktop/src/main.js b/desktop/src/main.js index 1aa809ff3..16b8a7da0 100644 --- a/desktop/src/main.js +++ b/desktop/src/main.js @@ -1,327 +1,327 @@ // @flow import { app, BrowserWindow, shell, Menu, ipcMain, systemPreferences, autoUpdater, // eslint-disable-next-line import/extensions } from 'electron/main'; import contextMenu from 'electron-context-menu'; import fs from 'fs'; import path from 'path'; import { initAutoUpdate } from './auto-update.js'; import { handleSquirrelEvent } from './handle-squirrel-event.js'; import { listenForNotifications, registerForNotifications, } from './push-notifications.js'; const isDev = process.env.ENV === 'dev'; -const url = isDev ? 'http://localhost:3000/comm/' : 'https://web.comm.app'; +const url = isDev ? 'http://localhost:3000/webapp/' : 'https://web.comm.app'; const isMac = process.platform === 'darwin'; const scrollbarCSS = fs.promises.readFile( path.resolve(__dirname, '../scrollbar.css'), 'utf8', ); let mainWindow = null; const setApplicationMenu = () => { let mainMenu = []; if (isMac) { mainMenu = [ { label: app.name, submenu: [ { role: 'about' }, { type: 'separator' }, { role: 'services' }, { type: 'separator' }, { role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' }, { type: 'separator' }, { role: 'quit' }, ], }, ]; } const viewMenu = { label: 'View', submenu: [ { role: 'reload' }, { type: 'separator' }, { role: 'resetZoom' }, { role: 'zoomIn' }, { role: 'zoomOut' }, { type: 'separator' }, { role: 'togglefullscreen' }, { role: 'toggleDevTools' }, { label: 'Toggle Shared Worker Developer Tools', click: () => { if (mainWindow) { mainWindow.webContents.inspectSharedWorker(); } }, }, ], }; const windowMenu = { label: 'Window', submenu: [ { role: 'minimize' }, ...(isMac ? [ { type: 'separator' }, { role: 'front' }, { type: 'separator' }, { role: 'window' }, ] : [{ role: 'close' }]), ], }; const menu = Menu.buildFromTemplate([ ...mainMenu, { role: 'fileMenu' }, { role: 'editMenu' }, viewMenu, windowMenu, ]); Menu.setApplicationMenu(menu); }; const createMainWindow = (urlPath?: string) => { const win = new BrowserWindow({ show: false, width: 1300, height: 800, minWidth: 1100, minHeight: 600, titleBarStyle: 'hidden', trafficLightPosition: { x: 20, y: 24 }, titleBarOverlay: { color: '#0A0A0A', symbolColor: '#FFFFFF', height: 64, }, backgroundColor: '#0A0A0A', webPreferences: { preload: path.resolve(__dirname, 'preload.js'), }, }); const updateNavigationState = () => { win.webContents.send('on-navigate', { canGoBack: win.webContents.canGoBack(), canGoForward: win.webContents.canGoForward(), }); }; win.webContents.on('did-navigate-in-page', updateNavigationState); const clearHistory = () => { win.webContents.clearHistory(); updateNavigationState(); }; ipcMain.on('clear-history', clearHistory); const doubleClickTopBar = () => { if (isMac) { // Possible values for AppleActionOnDoubleClick are Maximize, // Minimize or None. We handle the last two inside this if. // Maximize (which is the only behaviour for other platforms) // is handled in the later block. const action = systemPreferences.getUserDefault( 'AppleActionOnDoubleClick', 'string', ); if (action === 'None') { return; } else if (action === 'Minimize') { win.minimize(); return; } } if (win.isMaximized()) { win.unmaximize(); } else { win.maximize(); } }; ipcMain.on('double-click-top-bar', doubleClickTopBar); const updateDownloaded = (event, releaseNotes, releaseName) => { win.webContents.send('on-new-version-available', releaseName); }; autoUpdater.on('update-downloaded', updateDownloaded); win.on('closed', () => { mainWindow = null; ipcMain.removeListener('clear-history', clearHistory); ipcMain.removeListener('double-click-top-bar', doubleClickTopBar); autoUpdater.removeListener('update-downloaded', updateDownloaded); }); win.webContents.setWindowOpenHandler(({ url: openURL }) => { shell.openExternal(openURL); // Returning 'deny' prevents a new electron window from being created return { action: 'deny' }; }); (async () => { const css = await scrollbarCSS; win.webContents.insertCSS(css); })(); win.loadURL(url + (urlPath ?? '')); mainWindow = win; return win; }; const createSplashWindow = () => { const win = new BrowserWindow({ width: 300, height: 300, resizable: false, frame: false, alwaysOnTop: true, center: true, backgroundColor: '#111827', }); win.loadFile(path.resolve(__dirname, '../pages/splash.html')); return win; }; const createErrorWindow = () => { const win = new BrowserWindow({ show: false, width: 400, height: 300, resizable: false, center: true, titleBarStyle: 'hidden', trafficLightPosition: { x: 20, y: 24 }, backgroundColor: '#111827', }); win.on('close', () => { app.quit(); }); win.loadFile(path.resolve(__dirname, '../pages/error.html')); return win; }; const show = (urlPath?: string) => { const splash = createSplashWindow(); const error = createErrorWindow(); const main = createMainWindow(urlPath); let loadedSuccessfully = true; main.webContents.on('did-fail-load', () => { loadedSuccessfully = false; if (!splash.isDestroyed()) { splash.destroy(); } if (!error.isDestroyed()) { error.show(); } setTimeout(() => { loadedSuccessfully = true; main.loadURL(url); }, 1000); }); main.webContents.on('did-finish-load', () => { if (loadedSuccessfully) { if (!splash.isDestroyed()) { splash.destroy(); } if (!error.isDestroyed()) { error.destroy(); } main.show(); if (app.isPackaged) { (async () => { const token = await registerForNotifications(); main.webContents.send('on-device-token-registered', token); })(); } } }); }; const run = () => { app.setName('Comm'); contextMenu({ showSaveImageAs: true, showSaveVideoAs: true, }); if (process.platform === 'win32') { app.setAppUserModelId('Comm'); } setApplicationMenu(); (async () => { await app.whenReady(); if (app.isPackaged) { try { initAutoUpdate(); } catch (error) { console.error(error); } listenForNotifications(threadID => { if (mainWindow) { mainWindow.webContents.send('on-notification-clicked', { threadID, }); } else { show(`chat/thread/${threadID}/`); } }); } ipcMain.on('set-badge', (event, value) => { if (isMac) { app.dock.setBadge(value?.toString() ?? ''); } }); ipcMain.on('get-version', event => { event.returnValue = app.getVersion().toString(); }); show(); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { show(); } }); })(); app.on('window-all-closed', () => { if (!isMac) { app.quit(); } }); }; if (app.isPackaged && process.platform === 'win32') { if (!handleSquirrelEvent()) { run(); } } else { run(); } diff --git a/docs/dev_environment.md b/docs/dev_environment.md index b3dba94f4..124b70a9a 100644 --- a/docs/dev_environment.md +++ b/docs/dev_environment.md @@ -1,772 +1,772 @@ # Requirements :warning: **This document is outdated. Click [here](./nix_dev_env.md) for the latest dev environment setup instructions.** Please note that our dev environment currently only works on macOS and Linux. For the Linux instructions [head to the Linux configuration steps](linux_dev_environment.md).
Why not Windows? (click to expand)

It’s primarily because Apple only supports iOS development using macOS. It’s true that we could support web, keyserver, and Android development on other operating systems, but because of the Apple requirement, all of our active developers currently run macOS. We’d very much welcome a PR to build out support on Windows!

Unfortunately the dev environment is overall pretty heavy. You’ll ideally want a machine with at least 32 GiB of RAM, although 16 GiB should suffice. # Prerequisites ## Xcode Go to the [Mac App Store](https://apps.apple.com/us/app/xcode/id497799835) to install Xcode, or if you already have it, to update it to the latest version. Once Xcode is installed, open it up. If you are prompted, follow the instructions to install any “Additional Required Components”. Finally, you need to make sure that the Xcode “Command Line Tools” are installed. Run this command: ``` xcode-select --install ``` ## Homebrew Install [Homebrew](https://brew.sh/), a package manager for macOS. ## Node Next, install [Node](https://nodejs.org/) using Homebrew. We’re going to use version 16 to avoid some possible issues that come up on Apple silicon when we install project dependencies. ``` brew install node@16 && brew upgrade node@16 ``` The reason we use both `install` and `upgrade` is that there’s no single Homebrew command equivalent to “install if not installed, and upgrade if already installed”. ## PHP [PHP](https://www.php.net) is needed for Arcanist. As of macOS 12 (Monterey), PHP is no longer bundled with the OS and needs to be installed via Homebrew. ``` brew install php@7.4 && brew upgrade php@7.4 ``` ## Rust We use a Rust [implementation](https://github.com/novifinancial/opaque-ke) of the OPAQUE password-authenticated key exchange protocol, so you will need to install Rust to compile the static library. The easiest way to do this is with `rustup`. ``` curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh ``` ## ShellCheck [ShellCheck](https://www.shellcheck.net/) is a static analysis tool that provides warnings and suggestions for shell scripts. We’ll install ShellCheck using Homebrew. ``` brew install shellcheck && brew upgrade shellcheck ``` ## Yarn We use the [Yarn](https://yarnpkg.com/) package manager for JavaScript in our repo. ``` brew install yarn && brew upgrade yarn ``` ## Watchman Watchman is a tool from Facebook used in the React Native dev environment to watch for changes to your filesystem. ``` brew install watchman && brew upgrade watchman ``` ## Node Version Manager Node Version Manager (nvm) is a tool that ensures we use the same version of Node on our keyserver between prod and dev environments. ``` brew install nvm && brew upgrade nvm ``` After installing, Homebrew will print out some instructions under the Caveats section of its output. It will ask you to do two things: `mkdir ~/.nvm`, and to add some lines to your `~/.bash_profile` (or desired shell configuration file). We recommend that you append `--no-use` to the line that loads nvm, so that you continue to use your Homebrew-sourced Node distribution by default. These lines are different depending on if you’re on Apple silicon or Intel x86-64. If you’re on Apple silicon, you should add the following to your shell configuration file (note the addition of the `--no-use` flag on the line that loads nvm): ``` export NVM_DIR="$HOME/.nvm" [ -s "/opt/homebrew/opt/nvm/nvm.sh" ] && \. "/opt/homebrew/opt/nvm/nvm.sh" --no-use # This loads nvm [ -s "/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm" ] && \. "/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm" # This loads nvm bash_completion ``` Otherwise, if you’re on Intel x86-64, add the following to your shell configuration file: ``` export NVM_DIR="$HOME/.nvm" [ -s "/usr/local/opt/nvm/nvm.sh" ] && \. "/usr/local/opt/nvm/nvm.sh" --no-use # This loads nvm [ -s "/usr/local/opt/nvm/etc/bash_completion.d/nvm" ] && \. "/usr/local/opt/nvm/etc/bash_completion.d/nvm" # This loads nvm bash_completion ``` Now either close and reopen your terminal window or re-source your shell configuration file in order to load nvm: ``` source ~/.bash_profile ``` ## MariaDB For the keyserver database we use MariaDB, which is a community-driven fork of MySQL. In this step we’ll install MariaDB using Homebrew. ``` brew install mariadb && brew upgrade mariadb ``` Next we’ll configure MariaDB to start when your computer boots using `brew services`: ``` brew tap homebrew/services brew services start mariadb ``` Finally, you should set up a root password for your local MariaDB instance. To do this we’ll use the `mysqladmin` command. Note that many of MariaDB’s commands still use the “mysql” name for compatibility. ``` sudo mysqladmin -u root password ``` ## Redis We use Redis on the keyserver side as a message broker. ``` brew install redis && brew upgrade redis ``` We’ll set it up to start on boot with `brew services`: ``` brew services start redis ``` ## CocoaPods CocoaPods is a dependency management system for iOS development. React Native uses it to manage native modules. ``` brew install cocoapods && brew upgrade cocoapods ``` ## Reactotron Reactotron is an event tracker and logger that can be used to aid in debugging on React Native. ``` brew install reactotron && brew upgrade reactotron ``` ## React Dev Tools Chrome extension The React Dev Tools Chrome extension lets you inspect the React component tree for web applications in Chrome. You can install it by navigating [here](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi) on Chrome. ## Redux Dev Tools Chrome extension The Redux Dev Tools Chrome extension lets you watch for Redux actions and inspect the Redux store state, both for web applications in Chrome, but also for our native applications using the “Remote DevTools” functionality. To install it, navigate [here](https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd) on Chrome. ## JDK We’ll need the Java Development Kit (JDK) for Android development. We’re using [SDKMAN!](https://sdkman.io/) to manage our JDK installation. Run the following to install SDKMAN!: ``` curl -s "https://get.sdkman.io" | bash ``` Now either close and reopen your terminal window or re-source your `~/.bash_profile` (or desired shell configuration file) in order to load SDKMAN!: ``` source ~/.bash_profile ``` You can run `sdk version` to see if SDKMAN! was installed properly. Run the following to install Azul Zulu 11 with SDKMAN!: ``` sdk install java 11.0.13-zulu ``` SDKMAN! takes care of setting up the `$JAVA_HOME` environment variable to point to the newly installed JDK. You can verify this by running `echo $JAVA_HOME`. ## Android Studio Start by downloading and installing [Android Studio](https://developer.android.com/studio/index.html) for your platform. When prompted to choose an installation type, select “Custom”. You’ll be prompted to select a JDK installation. If your SDKMAN!-sourced JDK doesn’t appear in the dropdown, you can find the absolute path to your installed JDK with the following command: ``` sdk home java 11.0.13-zulu ``` Make sure you check the boxes for the following: #### Intel x86-64: - `Android SDK` - `Android SDK Platform` - `Performance (Intel ® HAXM)` - `Android Virtual Device` #### Apple silicon: - `Android SDK` - `Android SDK Platform` - `Android Virtual Device` ### Android SDK Android Studio installs the latest Android SDK by default, but since React Native uses the Android 11 SDK specifically, we’ll need to install it using Android Studio’s SDK Manager. You can access the SDK Manager from the “Welcome to Android Studio” screen that pops up when you first open the application, from More Actions → SDK Manager. If you already have a project open, you can access it from Tools → SDK Manager. Once you have the SDK Manager open, select the “SDK Platforms” tab, and then check the box for “Show Package Details”. Now expand the “Android 11 (R)” section, and make sure the following subsections are checked: - `Android SDK Platform 30` #### Intel x86-64: - `Intel x86 Atom_64 System Image` or `Google APIs Intel x86 Atom System Image` #### Apple silicon: - `Google Play ARM 64 v8a System Image` Next, select the “SDK Tools” tab, and check the box for “Show Package Details”. Refer to `native/android/build.gradle` for specific tool versions and install the following: - Android SDK Build-Tools - NDK - CMake version 3.18.1 To finish the SDK Manager step, click “Apply” to download and install everything you’ve selected. ### Enable Android CLI commands You’ll need to append the following lines to your `~/.bash_profile` (or desired shell configuration file) in order for React Native to be able to build your Android project. ``` export ANDROID_HOME=$HOME/Library/Android/sdk export PATH=$PATH:$ANDROID_HOME/emulator export PATH=$PATH:$ANDROID_HOME/tools export PATH=$PATH:$ANDROID_HOME/tools/bin export PATH=$PATH:$ANDROID_HOME/platform-tools ``` Now either close and reopen your terminal window or re-source your shell configuration file in order to run the new commands: ``` source ~/.bash_profile ``` ## Arcanist We use Phabricator for code review. To upload a “diff” to Phabricator, you’ll need to use a tool called Arcanist. To install Arcanist, we’ll need to clone its Git repository. Pick a place in your filesystem to store it, and then run this command: ``` git clone https://github.com/phacility/arcanist.git ``` Next, you’ll need to add the path `./arcanist/bin` to your `$PATH` in your `~/.bash_profile` (or desired shell configuration file): ``` export PATH=$PATH:~/src/arcanist/bin ``` Make sure to replace the `~/src` portion of the above with the location of the directory you installed Arcanist in. # Configuration ## MariaDB Next we’ll set up a MariaDB user and a fresh database. We’ll start by opening up a console using the `mysql` command. ``` mysql -u root -p ``` Type in the MariaDB root password you set up previously when prompted. Then, we’ll go ahead and create an empty database. ``` CREATE DATABASE comm; ``` Now we need to create a user that can access this database. For the following command, replace “password” with a unique password. ``` CREATE USER comm@localhost IDENTIFIED BY 'password'; ``` Finally, we will give permissions to this user to access this database. ``` GRANT ALL ON comm.* TO comm@localhost; ``` You can now exit the MariaDB console using Ctrl+D. ## TablePlus Feel free to use any MariaDB administration platform that you’re comfortable with. PHP was deprecated in macOS 12 (Monterey), leading many of us to switch to [TablePlus](https://tableplus.com/). After installing TablePlus, you need to open a new connection. After opening TablePlus, click the “Create a new connection” text at the bottom of the window that appears. - Alternatively, you can navigate through Connection → New... in the menu at the top of the display. Choose MariaDB from the database options that appear. You’ll be prompted for: - Name (Comm) - Host (localhost) - Port (3306 by default) - User (comm) - Password (the one you made when initializing the MariaDB server in the previous step) ## Android Emulator In order to test the Android app on your computer you’ll need to set up an Android Emulator. To do this we’ll need to open up the Virtual Device Manager in Android Studio. You can access the Virtual Device Manager from the “Welcome to Android Studio” screen that pops up when you first open the application, from More Actions → Virtual Device Manager. If you already have a project open, you can access it from Tools → Virtual Device Manager. With the Virtual Device Manager open, select “Create device” on the top left. Feel free to select any “device definition” that includes Play Store support. On the next screen you’ll be asked to select a system image. Go for the latest version of Android that’s been released. From there you can just hit Next and then Finish. You should then be able to start your new Android Virtual Device from the Virtual Device Manager. # Git repo ## Clone from GitHub Finally! It’s time to clone the repo from GitHub. ``` git clone git@github.com:CommE2E/comm.git ``` Once you have the repo cloned, run this command to pull in dependencies. ``` cd comm yarn cleaninstall ``` ## URLs The keyserver needs to know some info about paths in order to properly construct URLs. ``` mkdir -p keyserver/facts -vim keyserver/facts/commapp_url.json +vim keyserver/facts/webapp_url.json ``` -Your `commapp_url.json` file should look like this: +Your `webapp_url.json` file should look like this: ```json { "baseDomain": "http://localhost:3000", - "basePath": "/comm/", - "baseRoutePath": "/comm/", + "basePath": "/webapp/", + "baseRoutePath": "/webapp/", "https": false, "proxy": "none" } ``` Additionally, we’ll create a file for the URLs in the landing page. ``` vim keyserver/facts/landing_url.json ``` Your `landing_url.json` file should look like this: ```json { "baseDomain": "http://localhost:3000", "basePath": "/commlanding/", "baseRoutePath": "/commlanding/", "https": false, "proxy": "none" } ``` ## MariaDB The keyserver side needs to see some config files before things can work. The first is a config file with MariaDB details. ``` cd keyserver mkdir secrets vim secrets/db_config.json ``` The DB config file should look like this: ```json { "host": "127.0.0.1", "user": "comm", "password": "password", "database": "comm", "dbType": "mariadb10.8" } ``` Make sure to replace the password with the one you set up for your `comm` MariaDB user earlier. ## Olm The second config file contains some details that the keyserver needs in order to launch Olm sessions to provide E2E encryption. We have a script that can generate this file for you. However, before we can run the script we’ll need to use Babel to transpile our source files into something Node can interpret. Babel will transpile the files in `src` into a new directory called `dist`. We’ll also use `rsync` to copy over files that don’t need transpilation. ``` cd keyserver yarn babel-build yarn rsync yarn script dist/scripts/generate-olm-config.js ``` This script will create the `keyserver/secrets/olm_config.json` config file. ## Phabricator The last configuration step is to set up an account on Phabricator, where we handle code review. Start by [logging in to Phabricator](https://phab.comm.dev) using your GitHub account. Next, make sure you’re inside the directory containing the Comm Git repository, and run the following command: ``` arc install-certificate ``` This command will help you connect your Phabricator account with the local Arcanist instance, allowing you to run `arc diff` and `arc land` commands. # Development ## Flow typechecker It’s good to run the `flow` typechecker frequently to make sure you’re not introducing any type errors. Flow treats each Yarn Workspace as a separate environment, and as such runs a separate type-checking server for each. This server is started when you first run `node_modules/.bin/flow` in each of the four Yarn Workspace folders. To make sure Flow runs from the command-line, you can edit your `$PATH` environmental variable in your `~/.bash_profile` file (or desired shell configuration file) to always include `./node_modules/.bin`. ``` export PATH=$PATH:./node_modules/.bin ``` As always, make sure you reload the `~/.bash_profile` after editing it: ``` source ~/.bash_profile ``` You should now be able to run `flow` in any of the Yarn workspaces: ``` cd lib flow ``` ## Running keyserver To run the web app, landing page, or the mobile app on the iOS Simulator or Android Emulator, you’ll need to run the keyserver. Open a new terminal and run: ``` cd keyserver yarn dev ``` This command runs three processes. The first two are to keep the `dist` folder updated whenever the `src` folder changes. They are “watch” versions of the same Babel and `rsync` commands we used to initially create the `dist` folder (before running the `generate-olm-config.js` script above). The final process is `nodemon`, which is similar to `node` except that it restarts whenever any of its source files (in the `dist` directory) changes. Note that if you run `yarn dev` in `keyserver` right after `yarn cleaninstall`, before Webpack is given a chance to build `app.build.cjs`/`landing.build.cjs` files, then Node will crash when it attempts to import those files. Just make sure to run `yarn dev` (or `yarn prod`) in `web` or `landing` before attempting to load the corresponding webpages. ## Running web app First, make sure that the keyserver is running. If you haven’t already, run: ``` cd keyserver yarn dev ``` Next, open a new terminal and run: ``` cd web yarn dev ``` -You should now be able to load the web app in your web browser at http://localhost/comm/. +You should now be able to load the web app in your web browser at http://localhost/webapp/. This command will start two processes. One is `webpack-dev-server`, which will serve the JS files. `webpack-dev-server` also makes sure the website automatically hot-reloads whenever any of the source files change. The other process is `webpack --watch`, which will build the `app.build.cjs` file, as well as rebuilding it whenever any of the source files change. The `app.build.cjs` file is consumed by the Node server in order to pre-render the initial HTML from the web source (“Server-Side Rendering”). ## Running desktop app First, make sure that the keyserver and the web app is running. If you haven’t already, run: ``` cd keyserver yarn dev ``` Next, open a new terminal and run: ``` cd web yarn dev ``` Then start the desktop app: ``` cd desktop yarn dev ``` This will run the desktop app in dev mode. Only code that is shared with the web app will be hot-reloaded, but you can easily reload the Electron app by typing `rs` into the terminal. ## Running landing page First, make sure that the keyserver is running. If you haven’t already, run: ``` cd keyserver yarn dev ``` Next, open a new terminal and run: ``` cd landing yarn dev ``` You should now be able to load the landing page in your web browser at http://localhost/commlanding/. This runs the same two processes as the web app, but for the landing page. Note that the `landing.build.cjs` file (similar to the web app’s `app.build.cjs` file) is consumed by the Node server. ## Running mobile app on iOS Simulator First, make sure that the keyserver is running. If you haven’t already, run: ``` cd keyserver yarn dev ``` Next, make sure that the Metro bundler is running. If you haven’t already, open a new terminal and run: ``` cd native yarn dev ``` This command runs the Metro bundler, which handles bundling our app’s JavaScript code and communicating with the debug build of the app running on either a physical or virtual device. Finally, open `native/ios/Comm.xcworkspace` in Xcode. Select a Simulator from the Scheme menu in the Workspace Toolbar. Then hit the Run button to build and run the project. ## Running mobile app on Android Emulator First, make sure that the keyserver is running. If you haven’t already, run: ``` cd keyserver yarn dev ``` Next, make sure that the Metro bundler is running. If you haven’t already, open a new terminal and run: ``` cd native yarn dev ``` Next, boot up an Android Emulator using Android Studio’s Virtual Device Manager. You should have a single Android Emulator (or plugged-in device) running at one time. Finally, use this command to build and run the Android app: ``` cd native yarn react-native run-android ``` ## Running mobile app on physical iOS devices There are a few things you’ll need to do before you can deploy the app to a physical iOS device. ### Xcode settings First, in Xcode, open the Comm workspace `native/ios/Comm.xcworkspace`. Make sure that you’re signed into Xcode with an Apple Developer account (either the Comm developer team’s or your own). You can see any accounts currently associated with Xcode by navigating to Xcode → Settings → Accounts. Next, you’ll want to ensure that the Comm project is configured with a valid Team, Bundle Identifier, and Provisioning Profile. To access these settings, navigate to View → Navigators → Project, and select the “Comm” project in the left sidebar. Then, select “Comm” from the “TARGETS” list, and navigate to the “Signing & Capabilities” tab. You should verify the following settings: #### Using a Comm development team Apple Developer account - Team - Comm Technologies, Inc. - Bundle Identifier - app.comm - Provisioning Profile - Make sure that the Provisioning Profile exists #### Using a Personal Apple Developer account - Team - Set this to a valid “Team”, which can just be your personal Apple Developer account. “Comm Technologies, Inc.” may be chosen by default, but it’s not valid if you’re using a personal Apple Developer account - Bundle Identifier - Pick a unique [Bundle Identifier](https://developer.apple.com/documentation/xcode/preparing-your-app-for-distribution) - Provisioning Profile - Make sure that the Provisioning Profile exists ### Building and deploying the app When you plug your iOS device into your machine for the first time, you’ll be prompted to enter your device passcode to enable debugging and deployment. Click the “Register” button in the dialog that Xcode displays if your device needs to be added to your Provisioning Profile. Make sure to pull the latest changes and clean the build folder before trying to deploy a build to your device. In Xcode, run Product → Clean Build Folder. If you’re running a debug build, you’ll need to check that the Metro bundler is running. If you haven’t already, open a new terminal and run: ``` cd native yarn dev ``` You should finally be ready to build and deploy the app in Xcode! Select your physical device from ”run destinations” in the Workspace Toolbar. Then hit the Run button to build and run the project. On the launcher screen choose your development server. If this is the first build you’re deploying to the target device, choose the “Enter URL manually” option, and type `http://w.x.y.z:8081`, where `w.x.y.z` is your machine’s local IP address. Your machine’s local IP address is displayed below the QR code, in the terminal window running the Metro bundler. Try visiting this IP address at port 80, using a browser on your device. It should display an “It works!” message if your iOS device can reach your machine. If you’re connecting to a local keyserver instance, you’ll want to “Allow Comm to Access” the “Local Network” in your device Settings. This toggle can be found from Settings → Comm. Note that this setting is not enabled by default, and you may have to re-enable it on subsequent build deployments. ### Connecting to local keyserver If you want your custom build of the app to connect to your local instance of the Node.js server (the `keyserver` subdirectory of the repo), you’ll need to do some additional work. First, confirm that your computer and physical iOS device are on the same network. If you’re running a local keyserver instance, you’ll need to be able to reach it with your device. Local keyservers run on the local IP address at port 3000. Finally, we need to direct the mobile app to use your local keyserver instance. There are a few different ways to do this, depending on your situation: - As long as you’re deploying a debug build, your local machine’s IP address should automatically be propagated to the native app when running the Metro bundler. This is the same IP that was discussed in the previous section. If for whatever reason this value is incorrect, you can override it by setting the `COMM_NAT_DEV_HOSTNAME` environment variable and restarting the Metro bundler: ```sh cd native COMM_NAT_DEV_HOSTNAME=w.x.y.z yarn dev ``` Where `w.x.y.z` is the hostname you want to use. You’ll need to delete and reinstall the app for changes to developer’s machine hostname to take effect, as the default keyserver URL is persisted in Redux. - If you’re deploying a release build, the above strategy won’t work. Your best bet to override the server URL is to get to the secret “Developer tools” menu option in the app. 1. You may need to use a real production account for this, since the server address will default to the production server if this is the first build you’ve deployed to the target device. 2. Next, in order for the “Developer tools” menu option to appear, you’ll need to add your user ID to [the list of user IDs in `staff.js`](https://github.com/CommE2E/comm/blob/master/lib/facts/staff.js). A good way to figure out your user ID is to use the Chrome Redux debugger to inspect the `currentUserInfo` property when logged into the web app. 3. Finally, you should be able to navigate to Profile → Developer tools in the app and set the address of the local server. It should look something like this: ``` - http://w.x.y.z/comm + http://w.x.y.z/webapp ``` Where `w.x.y.z` is the local IP address you found earlier. - Alternately, if you’re on a release build and option 2 above seems like too much of a hassle, you should be able to simply change the value of [`productionNodeServerURL` in the code](https://github.com/CommE2E/comm/blob/9e6a13f1569787b498a72c890b12ce0dd8323804/native/utils/url-utils.js#L12). Note that you’ll need to delete and reinstall the app for this change to take effect, as the default production URL is persisted in Redux. ## Running Node scripts To run one of the scripts in `keyserver/src/scripts`, you should start by making sure that the Node server is running. If you haven’t already, open a new terminal and run: ``` cd keyserver yarn dev ``` Then, from the `keyserver` directory, run `yarn script dist/scripts/name.js`, where `name.js` is the file containing the script. ## Creating a new user To create a new user, [run the keyserver](#running-keyserver) and the [mobile app on iOS Simulator](#running-mobile-app-on-ios-simulator), and then select “Sign Up” in the app. When you sign up, a new user will be created on your local MariaDB instance. You can then log in on any device connected to your local keyserver. ## Codegen We use a couple of tools that automatically generate code. There is always a source of truth – usually some file(s) with schemas. ### Codegen for JSI JSI is a framework in React Native that allows C++ and JS to communicate synchronously and directly. The codegen for JSI takes a Flow schema and generates C++ files that enable communication between JS and C++ in `react-native` apps. The script to generate this code is written in JavaScript and is included as a npm package so no additional software is needed to use it. The schema has to be defined in Flow as an interface, and that interface must inherit from react-native’s `TurboModule` interface. To run the JSI codegen, just run: ``` cd native yarn codegen-jsi ``` The input Flow schemas are located in `native/schema`. # Debugging ## React Developer Tools - For web, you can access the React Developer Tools through the Chrome extension by opening the Chrome Developer Tools and selecting the “Components” or “Profiler” tabs. This should work in both our development environment and in production. - For iOS and Android, you can access the React Developer Tools by running `cd native && yarn react-devtools`. If you want to use it along with the JS debugger (see [Debugging JavaScript](#debugging-javascript)), you should open the JS debugger first, then open React Developer Tools, and finally refresh the app via the dev menu. More details on using React Developer Tools with React Native can be found [here](https://github.com/facebook/react/tree/main/packages/react-devtools#usage-with-react-native). ## Redux Developer Tools - For web, you can access the Redux Developer Tools through the Chrome extension by opening the Chrome Developer Tools and selecting the “Redux” tab. This should work in both our development environment and in production, although in production you won’t be able to see Redux actions from before you opened up the Redux dev tools. - For iOS and Android, you can access the Redux Developer Tools by running `cd native && yarn redux-devtools`. On the first open, you’ll need to go to “Settings”, select “Use custom (local) server”, configure it to use port 8043, and then finally hit “Connect”. If you want to use it along with the JS debugger (see [Debugging JavaScript](#debugging-javascript)), you should open the JS debugger first, then open Redux Developer Tools, and finally refresh the app via the dev menu. ## Debugging JavaScript - For web, you can just use your browser of choice’s dev tools. - For iOS and Android, you can use the dev menu in dev builds to open up a JS debugger, where you can set breakpoints via the `debugger` expression. # Working with Phabricator ## Creating a new diff The biggest difference between GitHub PRs and Phabricator diffs is that a PR corresponds to a branch, whereas a diff corresponds to a commit. When you have a commit ready and want to submit it for code review, just run `arc diff` from within the Comm Git repo. `arc diff` will look at the most recent commit in `git log` and create a new diff for it. ## Updating a diff With GitHub PRs, updates are usually performed by adding on more commits. In contrast, in Phabricator a diff is updated by simply amending the existing commit and running `arc diff` again. When you run `arc diff`, it looks for a `Differential Revision: ` line in the commit text of the most recent commit. If Arcanist finds that line, it will assume you want to update the existing diff rather than create a new one. Other Arcanist commands such as `arc amend` (which amends commit text to match a diff on Phabricator) also look for the `Differential Revision: ` line. ## Working with a stack One of the advantages of Phabricator’s approach is that larger, multi-part changes can be split up into smaller pieces for review. These multi-part changes are usually referred to as a “stack” of diffs. When creating a diff that depends on another, you should make sure to create a dependency relationship between those two diffs, so that your reviewers can see the stack on Phabricator. The easiest way to do that is to include `Depends on D123` in the commit text of the child commit, but the dependency relationship can also be specified using the Phabricator web UI. You’ll find that mastering Git’s interactive rebase feature (`git rebase -i`) will help you a lot when working with stacks. Interactive rebases make it easy to “diff up” multiple commits at once, or to amend a specific commit in the middle of a stack in response to a review. ## Committing a diff After your diff has been accepted, you should be able to land it. To land a diff just run `arc land` from within the repository. If you have a stack of unlanded commits in your Git branch, `arc land` will attempt to land all of those diffs. If some of the diffs in your stack haven’t been accepted yet, you’ll need to create a new, separate branch that contains just the commits you want to land before running `arc land`. Note that you need commit rights to the repository in order to run `arc land`. If you don’t have commit rights, reach out to @ashoat for assistance. ## Final notes When developing, I usually just pop up three terminal windows, one for `yarn dev` in each of keyserver, web, and native. Note that it’s currently only possible to create a user account using the iOS or Android apps. The website supports logging in, but does not support account creation. Good luck, and let @ashoat know if you have any questions! diff --git a/docs/linux_dev_environment.md b/docs/linux_dev_environment.md index cac19924a..d90227129 100644 --- a/docs/linux_dev_environment.md +++ b/docs/linux_dev_environment.md @@ -1,144 +1,145 @@ # Linux Instructions The instructions to set up Comm development environment are only tested and available for Ubuntu 20.04. ## Reference main dev environment instructions This doc is very incomplete, so you should start by reading through the [main dev environment instructions](dev_environment.md). For anything that isn't mentioned here, you should either try to install it with the same approach described in the main docs, or (if that's not possible) you should try to figure out an alternate approach yourself. The following notes can help you for some of the more complex parts. ## React Native To set up React Native, follow the instructions in the [React Native docs](https://reactnative.dev/docs/environment-setup). ## nvm You can install nvm following the [nvm installation instructions](https://github.com/nvm-sh/nvm#installing-and-updating). ## Redis On Ubuntu, you can install the latest stable version of Redis from the `redislabs/redis` package repository via `apt`: ``` sudo add-apt-repository ppa:redislabs/redis sudo apt-get update sudo apt-get install redis sudo systemctl start redis sudo systemctl status redis ``` If you’re not on Ubuntu, you can look for distro-specific binaries using your preferred package manager, or compile from [source](https://redis.io/download). ## Reactotron Reactotron is an event tracker and logger that can be used to aid in debugging on React Native. Download and install the Reactotron package from their [release](https://github.com/infinitered/reactotron/blob/master/docs/installing.md) page. If Reactotron does not connect to the Android Emulator, run the following command and restart the Metro bundler: ``` adb reverse tcp:9090 tcp:9090 ``` You can see more information on the [Reactotron installation instructions](https://github.com/infinitered/reactotron/blob/master/docs/quick-start-react-native.md#configure-reactotron-with-your-project). ## phpMyAdmin On Ubuntu, you can install and run `phpmyadmin` using `apt`: ``` sudo apt install phpmyadmin php-mbstring php-zip php-gd php-json php-curl phpmyadmin ``` For configuration you should refer to the [main dev environment instructions](dev_environment.md#phpmyadmin). ## Apache On Ubuntu, you can install the Apache webserver using `apt`: ``` sudo apt install apache2 ``` The you can enable it to run on system startup using `systemd`: ``` sudo systemctl start apache2 sudo systemctl status apache2 ``` You'll also need to add a set of Apache modules: ``` sudo a2ensite mod_proxy mod_proxy_http mod_proxy_wstunnel mod_userdir ``` Finally, you'll need to configure the development site. Open `000-default.conf` with your text editor of choice: ``` sudo vim /etc/apache2/sites-enabled/000-default.conf ``` Add the content below, but make sure to replace “ashoat” with your username. ``` AllowOverride All Options Indexes FollowSymLinks Require all granted ProxyRequests on - ProxyPass /comm/ws ws://localhost:3000/ws - ProxyPass /comm/ http://localhost:3000/ + ProxyPass /keyserver/ http://localhost:3000/keyserver/ + ProxyPass /keyserver/ws ws://localhost:3000/keyserver/ws + ProxyPass /webapp/ http://localhost:3000/webapp/ ProxyPass /commlanding/ http://localhost:3000/commlanding/ RequestHeader set "X-Forwarded-Proto" expr=%{REQUEST_SCHEME} RequestHeader set "X-Forwarded-SSL" expr=%{HTTPS} ``` Finally, let’s restart Apache so it picks up the changes. ``` sudo systemctl restart apache2 sudo systemctl status apache2 ``` ## MySQL First, install Docker with the following commands: ``` sudo apt-get remove docker docker-engine docker.io sudo apt install docker.io sudo systemctl start docker sudo systemctl enable docker ``` Next you'll download the MySQL 5.7 Docker image: ``` docker pull mysql:5.7 ``` After setting up your MySQL install following the [main dev environment instructions](dev_environment.md#mysql-2), you can use the root password to run your Docker container: ``` docker run --name my-mysql -e MYSQL_ROOT_PASSWORD=password --expose 3306 -p 3306:3306 -v $HOME/mysql-data:/var/lib/mysql -d mysql:5.7 ``` You can log in to MySQL database in the Docker container using the following command: ``` docker exec -it my-mysql mysql -p ``` You may find yourself needing to configure your MySQL user using the IP corresponding to the inet entry from `ifconfig docker0` output, rather than the default (`127.0.0.1`): ``` CREATE USER comm@172.17.0.1 IDENTIFIED BY 'password'; GRANT ALL ON comm.* TO comm@172.17.0.1; ``` diff --git a/docs/nix_keyserver_deployment.md b/docs/nix_keyserver_deployment.md index c66e5dfe7..eeca5b063 100644 --- a/docs/nix_keyserver_deployment.md +++ b/docs/nix_keyserver_deployment.md @@ -1,71 +1,72 @@ # Keyserver Deployment Deploying the keyserver requires configuring it, building its Docker image, and deploying that image with Docker Compose. ## Configuration In order for the keyserver to interface with dependencies, host the landing page, and host the Comm web application, the following must be added to `keyserver/.env`: ``` # Mandatory COMM_DATABASE_DATABASE=comm COMM_DATABASE_USER= COMM_DATABASE_PASSWORD= COMM_JSONCONFIG_secrets_user_credentials='{"username":"","password":""}' COMM_JSONCONFIG_facts_landing_url='{"baseDomain":"http://localhost","basePath":"/commlanding/","baseRoutePath":"/commlanding/","https":false}' -COMM_JSONCONFIG_facts_commapp_url='{"baseDomain":"http://localhost:3000","basePath":"/comm/","https":false,"baseRoutePath":"/comm/","proxy":"none"}' +COMM_JSONCONFIG_facts_webapp_url='{"baseDomain":"http://localhost:3000","basePath":"/webapp/","https":false,"baseRoutePath":"/webapp/","proxy":"none"}' +COMM_JSONCONFIG_facts_keyserver_url='{"baseDomain":"http://localhost:3000","basePath":"/keyserver/","baseRoutePath":"/keyserver/","https":false,"proxy":"none"}' COMM_JSONCONFIG_facts_webapp_cors='{"domain": "http://localhost:3000"}' # Required to connect to production Identity service COMM_JSONCONFIG_secrets_identity_service_config="{\"identitySocketAddr\":\"https://identity.commtechnologies.org:50054\"}" # Required for ETH login COMM_JSONCONFIG_secrets_alchemy='{"key":""}' COMM_JSONCONFIG_secrets_walletconnect='{"key":""}' # Example backup configuration that stores up to 10 GiB of backups in /home/comm/backups COMM_JSONCONFIG_facts_backups='{"enabled":true,"directory":"/home/comm/backups","maxDirSizeMiB":10240}' ``` ### MariaDB configuration - `COMM_DATABASE_DATABASE`: Specifies the name of the database the keyserver will use. - `COMM_DATABASE_USER`: The username the keyserver uses to connect to MariaDB. Replace `` with your desired username. - `COMM_DATABASE_PASSWORD`: Corresponding password for the above user. Replace `` with your desired password. ### Identity service configuration - `COMM_JSONCONFIG_secrets_user_credentials`: Credentials for authenticating against the Identity service. Replace `` and `` with any values. In the future, they will need to be actual credentials registered with the Identity service. - `COMM_JSONCONFIG_secrets_identity_service_config`: Socket address for the Identity service. If omitted, the keyserver will try to connect to a local instance of the Identity service. ### ETH login configuration - `COMM_JSONCONFIG_secrets_alchemy`: Alchemy key used for Ethereum Name Service (ENS) resolution and retrieving ETH public keys. Replace `` with your actual key. - `COMM_JSONCONFIG_secrets_walletconnect`: WalletConnect key used to enable Sign-In with Ethereum (SIWE). Replace `` with your actual key. ### URL configuration -- `COMM_JSONCONFIG_facts_commapp_url`: Your keyserver needs to know what its externally-facing URL is in order to construct links. It also needs to know if it’s being proxied to that externally-facing URL, and what the internal route path is. +- `COMM_JSONCONFIG_facts_keyserver_url`: Your keyserver needs to know what its externally-facing URL is in order to construct links. It also needs to know if it’s being proxied to that externally-facing URL, and what the internal route path is. - `baseDomain`: Externally-facing domain. Used for constructing links. - `basePath`: Externally-facing path. Used for constructing links. - `baseRoutePath`: Internally-facing path. Same as basePath if no proxy. If there’s a proxy, this is the local path (e.g. http://localhost:3000/landing would correspond with /landing/) - `proxy`: `"none" | "apache"` Determines which request headers to use for HTTPS validation and IP address timezone detection. - `https`: If true, checks request headers to validate that HTTPS is in use. ### CORS configuration - `COMM_JSONCONFIG_facts_webapp_cors`: Your keyserver needs to be able to include CORS headers with the domain where the comm web application is hosted. - `domain`: Domain where the web application is hosted. ### Backup configuration - `COMM_JSONCONFIG_facts_backups`: Specifies whether to enable backups, where to store them, and the max size of the backups directory. ## Building & deploying Once configured, the keyserver can be built and deployed by simply running: ```bash cd keyserver ./bash/dc.sh up --build ``` diff --git a/docs/nix_web_workflows.md b/docs/nix_web_workflows.md index 805698643..0d3ae155b 100644 --- a/docs/nix_web_workflows.md +++ b/docs/nix_web_workflows.md @@ -1,81 +1,81 @@ # Development ## Flow typechecker It’s good to run the `flow` typechecker frequently to make sure you’re not introducing any type errors. Flow treats each Yarn Workspace as a separate environment, and as such runs a separate type-checking server for each. You should now be able to run `flow` in any of the Yarn workspaces: ``` cd lib flow ``` ## Running keyserver To run the web app or the landing page, you’ll first need to run the keyserver locally. Open a new terminal and run: ``` cd keyserver yarn dev ``` This command runs three processes. The first two are to keep the `dist` folder updated whenever the `src` folder changes. They are “watch” versions of the same Babel and `rsync` commands we used to initially create the `dist` folder (before running the `generate-olm-config.js` script above). The final process is `nodemon`, which is similar to `node` except that it restarts whenever any of its source files (in the `dist` directory) changes. Note that if you run `yarn dev` in `keyserver` right after `yarn cleaninstall`, before Webpack is given a chance to build `app.build.cjs`/`landing.build.cjs` files, then Node will crash when it attempts to import those files. Just make sure to run `yarn dev` (or `yarn prod`) in `web` or `landing` before attempting to load the corresponding webpages. ## Running web app First, make sure that the keyserver is running. If you haven’t already, run: ``` cd keyserver yarn dev ``` Next, open a new terminal and run: ``` cd web yarn dev ``` -You should now be able to load the web app in your web browser at http://localhost:3000/comm/. +You should now be able to load the web app in your web browser at http://localhost:3000/webapp/. This command will start two processes. One is `webpack-dev-server`, which will serve the JS files. `webpack-dev-server` also makes sure the website automatically hot-reloads whenever any of the source files change. The other process is `webpack --watch`, which will build the `app.build.cjs` file, as well as rebuilding it whenever any of the source files change. The `app.build.cjs` file is consumed by the Node server in order to pre-render the initial HTML from the web source (“Server-Side Rendering”). ## Running landing page First, make sure that the keyserver is running. If you haven’t already, run: ``` cd keyserver yarn dev ``` Next, open a new terminal and run: ``` cd landing yarn dev ``` You should now be able to load the landing page in your web browser at http://localhost:3000/commlanding/. This runs the same two processes as the web app, but for the landing page. Note that the `landing.build.cjs` file (similar to the web app’s `app.build.cjs` file) is consumed by the Node server. ## Debugging ### React Developer Tools You can access the React Developer Tools through the Chrome extension by opening the Chrome Developer Tools and selecting the “Components” or “Profiler” tabs. This should work in both our development environment and in production. ### Redux Developer Tools You can access the Redux Developer Tools through the Chrome extension by opening the Chrome Developer Tools and selecting the “Redux” tab. This should work in both our development environment and in production, although in production you won’t be able to see Redux actions from before you opened up the Redux dev tools. ### Debugging JavaScript You can just use your browser of choice’s dev tools. diff --git a/keyserver/icons/site.webmanifest b/keyserver/icons/site.webmanifest index 57505d48f..b00aef7e6 100644 --- a/keyserver/icons/site.webmanifest +++ b/keyserver/icons/site.webmanifest @@ -1,19 +1,19 @@ { "name": "SquadCal", "short_name": "SquadCal", "icons": [ { - "src": "/android-chrome-192x192.png", + "src": "android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, { - "src": "/android-chrome-512x512.png", + "src": "android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" } ], "theme_color": "#b91d47", "background_color": "#b91d47", "display": "standalone" } diff --git a/keyserver/src/creators/report-creator.js b/keyserver/src/creators/report-creator.js index 60ce12b8a..e6e7658db 100644 --- a/keyserver/src/creators/report-creator.js +++ b/keyserver/src/creators/report-creator.js @@ -1,238 +1,238 @@ // @flow import _isEqual from 'lodash/fp/isEqual.js'; import bots from 'lib/facts/bots.js'; import { filterRawEntryInfosByCalendarQuery, serverEntryInfosObject, } from 'lib/shared/entry-utils.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { type ReportCreationRequest, type ReportCreationResponse, type ThreadInconsistencyReportCreationRequest, type EntryInconsistencyReportCreationRequest, type UserInconsistencyReportCreationRequest, reportTypes, } from 'lib/types/report-types.js'; import { values } from 'lib/utils/objects.js'; import { sanitizeReduxReport, type ReduxCrashReport, } from 'lib/utils/sanitization.js'; import createIDs from './id-creator.js'; import createMessages from './message-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { fetchUsername } from '../fetchers/user-fetchers.js'; import { handleAsyncPromise } from '../responders/handlers.js'; import { createBotViewer } from '../session/bots.js'; import type { Viewer } from '../session/viewer.js'; -import { getAndAssertCommAppURLFacts } from '../utils/urls.js'; +import { getAndAssertKeyserverURLFacts } from '../utils/urls.js'; const { commbot } = bots; async function createReport( viewer: Viewer, request: ReportCreationRequest, ): Promise { const shouldIgnore = await ignoreReport(viewer, request); if (shouldIgnore) { return null; } const [id] = await createIDs('reports', 1); let type, report, time; if (request.type === reportTypes.THREAD_INCONSISTENCY) { ({ type, time, ...report } = request); time = time ? time : Date.now(); } else if (request.type === reportTypes.ENTRY_INCONSISTENCY) { ({ type, time, ...report } = request); } else if (request.type === reportTypes.MEDIA_MISSION) { ({ type, time, ...report } = request); } else if (request.type === reportTypes.USER_INCONSISTENCY) { ({ type, time, ...report } = request); } else { ({ type, ...report } = request); time = Date.now(); const redactedReduxReport: ReduxCrashReport = sanitizeReduxReport({ preloadedState: report.preloadedState, currentState: report.currentState, actions: report.actions, }); report = { ...report, ...redactedReduxReport, }; } const row = [ id, viewer.id, type, request.platformDetails.platform, JSON.stringify(report), time, ]; const query = SQL` INSERT INTO reports (id, user, type, platform, report, creation_time) VALUES ${[row]} `; await dbQuery(query); handleAsyncPromise(sendCommbotMessage(viewer, request, id)); return { id }; } async function sendCommbotMessage( viewer: Viewer, request: ReportCreationRequest, reportID: string, ): Promise { const canGenerateMessage = getCommbotMessage(request, reportID, null); if (!canGenerateMessage) { return; } const username = await fetchUsername(viewer.id); const message = getCommbotMessage(request, reportID, username); if (!message) { return; } const time = Date.now(); await createMessages(createBotViewer(commbot.userID), [ { type: messageTypes.TEXT, threadID: commbot.staffThreadID, creatorID: commbot.userID, time, text: message, }, ]); } async function ignoreReport( viewer: Viewer, request: ReportCreationRequest, ): Promise { // The below logic is to avoid duplicate inconsistency reports if ( request.type !== reportTypes.THREAD_INCONSISTENCY && request.type !== reportTypes.ENTRY_INCONSISTENCY ) { return false; } const { type, platformDetails, time } = request; if (!time) { return false; } const { platform } = platformDetails; const query = SQL` SELECT id FROM reports WHERE user = ${viewer.id} AND type = ${type} AND platform = ${platform} AND creation_time = ${time} `; const [result] = await dbQuery(query); return result.length !== 0; } function getCommbotMessage( request: ReportCreationRequest, reportID: string, username: ?string, ): ?string { const name = username ? username : '[null]'; const { platformDetails } = request; const { platform, codeVersion } = platformDetails; const platformString = codeVersion ? `${platform} v${codeVersion}` : platform; if (request.type === reportTypes.ERROR) { - const { baseDomain, basePath } = getAndAssertCommAppURLFacts(); + const { baseDomain, basePath } = getAndAssertKeyserverURLFacts(); return ( `${name} got an error :(\n` + `using ${platformString}\n` + `${baseDomain}${basePath}download_error_report/${reportID}` ); } else if (request.type === reportTypes.THREAD_INCONSISTENCY) { const nonMatchingThreadIDs = getInconsistentThreadIDsFromReport(request); const nonMatchingString = [...nonMatchingThreadIDs].join(', '); return ( `system detected inconsistency for ${name}!\n` + `using ${platformString}\n` + `occurred during ${request.action.type}\n` + `thread IDs that are inconsistent: ${nonMatchingString}` ); } else if (request.type === reportTypes.ENTRY_INCONSISTENCY) { const nonMatchingEntryIDs = getInconsistentEntryIDsFromReport(request); const nonMatchingString = [...nonMatchingEntryIDs].join(', '); return ( `system detected inconsistency for ${name}!\n` + `using ${platformString}\n` + `occurred during ${request.action.type}\n` + `entry IDs that are inconsistent: ${nonMatchingString}` ); } else if (request.type === reportTypes.USER_INCONSISTENCY) { const nonMatchingUserIDs = getInconsistentUserIDsFromReport(request); const nonMatchingString = [...nonMatchingUserIDs].join(', '); return ( `system detected inconsistency for ${name}!\n` + `using ${platformString}\n` + `occurred during ${request.action.type}\n` + `user IDs that are inconsistent: ${nonMatchingString}` ); } else if (request.type === reportTypes.MEDIA_MISSION) { const mediaMissionJSON = JSON.stringify(request.mediaMission); const success = request.mediaMission.result.success ? 'media mission success!' : 'media mission failed :('; return `${name} ${success}\n` + mediaMissionJSON; } else { return null; } } function findInconsistentObjectKeys( first: { +[id: string]: O }, second: { +[id: string]: O }, ): Set { const nonMatchingIDs = new Set(); for (const id in first) { if (!_isEqual(first[id])(second[id])) { nonMatchingIDs.add(id); } } for (const id in second) { if (!first[id]) { nonMatchingIDs.add(id); } } return nonMatchingIDs; } function getInconsistentThreadIDsFromReport( request: ThreadInconsistencyReportCreationRequest, ): Set { const { pushResult, beforeAction } = request; return findInconsistentObjectKeys(beforeAction, pushResult); } function getInconsistentEntryIDsFromReport( request: EntryInconsistencyReportCreationRequest, ): Set { const { pushResult, beforeAction, calendarQuery } = request; const filteredBeforeAction = filterRawEntryInfosByCalendarQuery( serverEntryInfosObject(values(beforeAction)), calendarQuery, ); const filteredAfterAction = filterRawEntryInfosByCalendarQuery( serverEntryInfosObject(values(pushResult)), calendarQuery, ); return findInconsistentObjectKeys(filteredBeforeAction, filteredAfterAction); } function getInconsistentUserIDsFromReport( request: UserInconsistencyReportCreationRequest, ): Set { const { beforeStateCheck, afterStateCheck } = request; return findInconsistentObjectKeys(beforeStateCheck, afterStateCheck); } export default createReport; diff --git a/keyserver/src/fetchers/upload-fetchers.js b/keyserver/src/fetchers/upload-fetchers.js index 2a1ff20a0..fd8e964ee 100644 --- a/keyserver/src/fetchers/upload-fetchers.js +++ b/keyserver/src/fetchers/upload-fetchers.js @@ -1,408 +1,408 @@ // @flow import ip from 'internal-ip'; import _keyBy from 'lodash/fp/keyBy.js'; import type { Media, Image, EncryptedImage } from 'lib/types/media-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import type { MediaMessageServerDBContent } from 'lib/types/messages/media.js'; import { getUploadIDsFromMediaMessageServerDBContents } from 'lib/types/messages/media.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import type { ThreadFetchMediaResult, ThreadFetchMediaRequest, } from 'lib/types/thread-types.js'; import { makeBlobServiceURI } from 'lib/utils/blob-service.js'; import { isDev } from 'lib/utils/dev-utils.js'; import { ServerError } from 'lib/utils/errors.js'; import { dbQuery, SQL } from '../database/database.js'; import type { Viewer } from '../session/viewer.js'; -import { getAndAssertCommAppURLFacts } from '../utils/urls.js'; +import { getAndAssertKeyserverURLFacts } from '../utils/urls.js'; type UploadInfo = { content: Buffer, mime: string, }; async function fetchUpload( viewer: Viewer, id: string, secret: string, ): Promise { const query = SQL` SELECT content, mime, extra FROM uploads WHERE id = ${id} AND secret = ${secret} `; const [result] = await dbQuery(query); if (result.length === 0) { throw new ServerError('invalid_parameters'); } const [row] = result; const { content, mime, extra } = row; const { blobHash } = JSON.parse(extra); if (blobHash) { throw new ServerError('resource_unavailable'); } return { content, mime }; } async function fetchUploadChunk( id: string, secret: string, pos: number, len: number, ): Promise { // We use pos + 1 because SQL is 1-indexed whereas js is 0-indexed const query = SQL` SELECT SUBSTRING(content, ${pos + 1}, ${len}) AS content, mime, extra FROM uploads WHERE id = ${id} AND secret = ${secret} `; const [result] = await dbQuery(query); if (result.length === 0) { throw new ServerError('invalid_parameters'); } const [row] = result; const { content, mime, extra } = row; if (extra) { const { blobHash } = JSON.parse(extra); if (blobHash) { throw new ServerError('resource_unavailable'); } } return { content, mime, }; } // Returns total size in bytes. async function getUploadSize(id: string, secret: string): Promise { const query = SQL` SELECT LENGTH(content) AS length, extra FROM uploads WHERE id = ${id} AND secret = ${secret} `; const [result] = await dbQuery(query); if (result.length === 0) { throw new ServerError('invalid_parameters'); } const [row] = result; const { length, extra } = row; if (extra) { const { blobHash } = JSON.parse(extra); if (blobHash) { throw new ServerError('resource_unavailable'); } } return length; } function getUploadURL(id: string, secret: string): string { - const { baseDomain, basePath } = getAndAssertCommAppURLFacts(); + const { baseDomain, basePath } = getAndAssertKeyserverURLFacts(); const uploadPath = `${basePath}upload/${id}/${secret}`; if (isDev) { const ipV4 = ip.v4.sync() || 'localhost'; const port = parseInt(process.env.PORT, 10) || 3000; return `http://${ipV4}:${port}${uploadPath}`; } return `${baseDomain}${uploadPath}`; } function makeUploadURI(blobHash: ?string, id: string, secret: string): string { if (blobHash) { return makeBlobServiceURI(blobHash); } return getUploadURL(id, secret); } function imagesFromRow(row: Object): Image | EncryptedImage { const uploadExtra = JSON.parse(row.uploadExtra); const { width, height, blobHash, thumbHash } = uploadExtra; const { uploadType: type, uploadSecret: secret } = row; const id = row.uploadID.toString(); const dimensions = { width, height }; const uri = makeUploadURI(blobHash, id, secret); const isEncrypted = !!uploadExtra.encryptionKey; if (type !== 'photo') { throw new ServerError('invalid_parameters'); } if (!isEncrypted) { return { id, type: 'photo', uri, dimensions, thumbHash }; } return { id, type: 'encrypted_photo', blobURI: uri, dimensions, thumbHash, encryptionKey: uploadExtra.encryptionKey, }; } async function fetchImages( viewer: Viewer, mediaIDs: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const query = SQL` SELECT id AS uploadID, secret AS uploadSecret, type AS uploadType, extra AS uploadExtra FROM uploads WHERE id IN (${mediaIDs}) AND uploader = ${viewer.id} AND container IS NULL `; const [result] = await dbQuery(query); return result.map(imagesFromRow); } async function fetchMediaForThread( viewer: Viewer, request: ThreadFetchMediaRequest, ): Promise { const visibleExtractString = `$.${threadPermissions.VISIBLE}.value`; const query = SQL` SELECT content.photo AS uploadID, u.secret AS uploadSecret, u.type AS uploadType, u.extra AS uploadExtra, u.container, u.creation_time, NULL AS thumbnailID, NULL AS thumbnailUploadSecret, NULL AS thumbnailUploadExtra FROM messages m LEFT JOIN JSON_TABLE( m.content, "$[*]" COLUMNS(photo INT PATH "$") ) content ON 1 LEFT JOIN uploads u ON u.id = content.photo LEFT JOIN memberships mm ON mm.thread = ${request.threadID} AND mm.user = ${viewer.id} WHERE m.thread = ${request.threadID} AND m.type = ${messageTypes.IMAGES} AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE UNION SELECT content.media AS uploadID, uv.secret AS uploadSecret, uv.type AS uploadType, uv.extra AS uploadExtra, uv.container, uv.creation_time, content.thumbnail AS thumbnailID, ut.secret AS thumbnailUploadSecret, ut.extra AS thumbnailUploadExtra FROM messages m LEFT JOIN JSON_TABLE( m.content, "$[*]" COLUMNS( media INT PATH "$.uploadID", thumbnail INT PATH "$.thumbnailUploadID" ) ) content ON 1 LEFT JOIN uploads uv ON uv.id = content.media LEFT JOIN uploads ut ON ut.id = content.thumbnail LEFT JOIN memberships mm ON mm.thread = ${request.threadID} AND mm.user = ${viewer.id} WHERE m.thread = ${request.threadID} AND m.type = ${messageTypes.MULTIMEDIA} AND JSON_EXTRACT(mm.permissions, ${visibleExtractString}) IS TRUE ORDER BY creation_time DESC LIMIT ${request.limit} OFFSET ${request.offset} `; const [uploads] = await dbQuery(query); const media = uploads.map(upload => { const { uploadID, uploadType, uploadSecret, uploadExtra } = upload; const { width, height, encryptionKey, blobHash, thumbHash } = JSON.parse(uploadExtra); const dimensions = { width, height }; const uri = makeUploadURI(blobHash, uploadID, uploadSecret); if (uploadType === 'photo') { if (encryptionKey) { return { type: 'encrypted_photo', id: uploadID.toString(), blobURI: uri, encryptionKey, dimensions, thumbHash, }; } return { type: 'photo', id: uploadID.toString(), uri, dimensions, thumbHash, }; } const { thumbnailUploadSecret, thumbnailUploadExtra } = upload; const thumbnailID = upload.thumbnailID?.toString(); const { encryptionKey: thumbnailEncryptionKey, blobHash: thumbnailBlobHash, thumbHash: thumbnailThumbHash, } = JSON.parse(thumbnailUploadExtra); const thumbnailURI = makeUploadURI( thumbnailBlobHash, thumbnailID, thumbnailUploadSecret, ); if (encryptionKey) { return { type: 'encrypted_video', id: uploadID.toString(), blobURI: uri, encryptionKey, dimensions, thumbnailID, thumbnailBlobURI: thumbnailURI, thumbnailEncryptionKey, thumbnailThumbHash, }; } return { type: 'video', id: uploadID.toString(), uri, dimensions, thumbnailID, thumbnailURI, thumbnailThumbHash, }; }); return { media }; } async function fetchUploadsForMessage( viewer: Viewer, mediaMessageContents: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const uploadIDs = getUploadIDsFromMediaMessageServerDBContents(mediaMessageContents); const query = SQL` SELECT id AS uploadID, secret AS uploadSecret, type AS uploadType, extra AS uploadExtra FROM uploads WHERE id IN (${uploadIDs}) AND uploader = ${viewer.id} AND container IS NULL `; const [uploads] = await dbQuery(query); return uploads; } async function fetchMediaFromMediaMessageContent( viewer: Viewer, mediaMessageContents: $ReadOnlyArray, ): Promise<$ReadOnlyArray> { const uploads = await fetchUploadsForMessage(viewer, mediaMessageContents); return constructMediaFromMediaMessageContentsAndUploadRows( mediaMessageContents, uploads, ); } function constructMediaFromMediaMessageContentsAndUploadRows( mediaMessageContents: $ReadOnlyArray, uploadRows: $ReadOnlyArray, ): $ReadOnlyArray { const uploadMap = _keyBy('uploadID')(uploadRows); const media: Media[] = []; for (const mediaMessageContent of mediaMessageContents) { const primaryUploadID = mediaMessageContent.uploadID; const primaryUpload = uploadMap[primaryUploadID]; const uploadExtra = JSON.parse(primaryUpload.uploadExtra); const { width, height, loop, blobHash, encryptionKey, thumbHash } = uploadExtra; const dimensions = { width, height }; const primaryUploadURI = makeUploadURI( blobHash, primaryUploadID, primaryUpload.uploadSecret, ); if (mediaMessageContent.type === 'photo') { if (encryptionKey) { media.push({ type: 'encrypted_photo', id: primaryUploadID, blobURI: primaryUploadURI, encryptionKey, dimensions, thumbHash, }); } else { media.push({ type: 'photo', id: primaryUploadID, uri: primaryUploadURI, dimensions, thumbHash, }); } continue; } const thumbnailUploadID = mediaMessageContent.thumbnailUploadID; const thumbnailUpload = uploadMap[thumbnailUploadID]; const thumbnailUploadExtra = JSON.parse(thumbnailUpload.uploadExtra); const { blobHash: thumbnailBlobHash, thumbHash: thumbnailThumbHash } = thumbnailUploadExtra; const thumbnailUploadURI = makeUploadURI( thumbnailBlobHash, thumbnailUploadID, thumbnailUpload.uploadSecret, ); if (encryptionKey) { const video = { type: 'encrypted_video', id: primaryUploadID, blobURI: primaryUploadURI, encryptionKey, dimensions, thumbnailID: thumbnailUploadID, thumbnailBlobURI: thumbnailUploadURI, thumbnailEncryptionKey: thumbnailUploadExtra.encryptionKey, thumbnailThumbHash, }; media.push(loop ? { ...video, loop } : video); } else { const video = { type: 'video', id: primaryUploadID, uri: primaryUploadURI, dimensions, thumbnailID: thumbnailUploadID, thumbnailURI: thumbnailUploadURI, thumbnailThumbHash, }; media.push(loop ? { ...video, loop } : video); } } return media; } export { fetchUpload, fetchUploadChunk, getUploadSize, getUploadURL, makeUploadURI, imagesFromRow, fetchImages, fetchMediaForThread, fetchMediaFromMediaMessageContent, constructMediaFromMediaMessageContentsAndUploadRows, }; diff --git a/keyserver/src/keyserver.js b/keyserver/src/keyserver.js index 0a71f552c..25bc7007a 100644 --- a/keyserver/src/keyserver.js +++ b/keyserver/src/keyserver.js @@ -1,263 +1,275 @@ // @flow import olm from '@commapp/olm'; import cluster from 'cluster'; import compression from 'compression'; import cookieParser from 'cookie-parser'; import cors from 'cors'; import crypto from 'crypto'; import express from 'express'; +import type { $Request, $Response } from 'express'; import expressWs from 'express-ws'; import os from 'os'; import qrcode from 'qrcode'; import './cron/cron.js'; import { qrCodeLinkURL } from 'lib/facts/links.js'; +import { isDev } from 'lib/utils/dev-utils.js'; import { migrate } from './database/migrations.js'; import { jsonEndpoints } from './endpoints.js'; import { logEndpointMetrics } from './middleware/endpoint-profiling.js'; import { emailSubscriptionResponder } from './responders/comm-landing-responders.js'; import { jsonHandler, downloadHandler, handleAsyncPromise, htmlHandler, uploadHandler, } from './responders/handlers.js'; import landingHandler from './responders/landing-handler.js'; import { errorReportDownloadResponder } from './responders/report-responders.js'; import { inviteResponder, websiteResponder, } from './responders/website-responders.js'; import { webWorkerResponder } from './responders/webworker-responders.js'; import { onConnection } from './socket/socket.js'; import { createAndMaintainTunnelbrokerWebsocket } from './socket/tunnelbroker.js'; import { multerProcessor, multimediaUploadResponder, uploadDownloadResponder, } from './uploads/uploads.js'; import { verifyUserLoggedIn } from './user/login.js'; import { initENSCache } from './utils/ens-cache.js'; import { getContentSigningKey } from './utils/olm-utils.js'; import { prefetchAllURLFacts, - getSquadCalURLFacts, + getKeyserverURLFacts, getLandingURLFacts, - getCommAppURLFacts, + getWebAppURLFacts, getWebAppCorsConfig, } from './utils/urls.js'; const shouldDisplayQRCodeInTerminal = false; (async () => { const [webAppCorsConfig] = await Promise.all([ getWebAppCorsConfig(), olm.init(), prefetchAllURLFacts(), initENSCache(), ]); - const squadCalBaseRoutePath = getSquadCalURLFacts()?.baseRoutePath; + const keyserverBaseRoutePath = getKeyserverURLFacts()?.baseRoutePath; const landingBaseRoutePath = getLandingURLFacts()?.baseRoutePath; - const commAppBaseRoutePath = getCommAppURLFacts()?.baseRoutePath; + const webAppURLFacts = getWebAppURLFacts(); + const webAppBaseRoutePath = webAppURLFacts?.baseRoutePath; const compiledFolderOptions = process.env.NODE_ENV === 'development' ? undefined : { maxAge: '1y', immutable: true }; let keyserverCorsOptions = null; if (webAppCorsConfig) { keyserverCorsOptions = { origin: webAppCorsConfig.domain, methods: ['GET', 'POST'], }; } const isCPUProfilingEnabled = process.env.KEYSERVER_CPU_PROFILING_ENABLED; const areEndpointMetricsEnabled = process.env.KEYSERVER_ENDPOINT_METRICS_ENABLED; if (cluster.isMaster) { const didMigrationsSucceed: boolean = await migrate(); if (!didMigrationsSucceed) { // The following line uses exit code 2 to ensure nodemon exits // in a dev environment, instead of restarting. Context provided // in https://github.com/remy/nodemon/issues/751 process.exit(2); } // Allow login to be optional until staging environment is available try { // We await here to ensure that the keyserver has been provisioned a // commServicesAccessToken. In the future, this will be necessary for // many keyserver operations. const identityInfo = await verifyUserLoggedIn(); // We don't await here, as Tunnelbroker communication is not needed for // normal keyserver behavior yet. In addition, this doesn't return // information useful for other keyserver functions. handleAsyncPromise(createAndMaintainTunnelbrokerWebsocket(identityInfo)); } catch (e) { console.warn( 'Failed identity login. Login optional until staging environment is available', ); } if (shouldDisplayQRCodeInTerminal) { try { const aes256Key = crypto.randomBytes(32).toString('hex'); const ed25519Key = await getContentSigningKey(); console.log( '\nOpen the Comm app on your phone and scan the QR code below\n', ); console.log('How to find the scanner:\n'); console.log('Go to \x1b[1mProfile\x1b[0m'); console.log('Select \x1b[1mLinked devices\x1b[0m'); console.log('Click \x1b[1mAdd\x1b[0m on the top right'); const url = qrCodeLinkURL(aes256Key, ed25519Key); qrcode.toString(url, (error, encodedURL) => console.log(encodedURL)); } catch (e) { console.log('Error generating QR code', e); } } if (!isCPUProfilingEnabled) { const cpuCount = os.cpus().length; for (let i = 0; i < cpuCount; i++) { cluster.fork(); } cluster.on('exit', () => cluster.fork()); } } if (!cluster.isMaster || isCPUProfilingEnabled) { const server = express(); server.use(compression()); expressWs(server); server.use(express.json({ limit: '250mb' })); server.use(cookieParser()); const setupAppRouter = router => { if (areEndpointMetricsEnabled) { router.use(logEndpointMetrics); } router.use('/images', express.static('images')); router.use('/fonts', express.static('fonts')); router.use('/misc', express.static('misc')); router.use( '/.well-known', express.static( '.well-known', // Necessary for apple-app-site-association file { setHeaders: res => res.setHeader('Content-Type', 'application/json'), }, ), ); router.use( '/compiled', express.static('app_compiled', compiledFolderOptions), ); router.use('/', express.static('icons')); for (const endpoint in jsonEndpoints) { // $FlowFixMe Flow thinks endpoint is string const responder = jsonEndpoints[endpoint]; const expectCookieInvalidation = endpoint === 'log_out'; router.post( `/${endpoint}`, jsonHandler(responder, expectCookieInvalidation), ); } router.get( '/download_error_report/:reportID', downloadHandler(errorReportDownloadResponder), ); router.get( '/upload/:uploadID/:secret', downloadHandler(uploadDownloadResponder), ); router.get('/invite/:secret', inviteResponder); // $FlowFixMe express-ws has side effects that can't be typed router.ws('/ws', onConnection); router.get('/worker/:worker', webWorkerResponder); router.get('*', htmlHandler(websiteResponder)); router.post( '/upload_multimedia', multerProcessor, uploadHandler(multimediaUploadResponder), ); }; // Note - the order of router declarations matters. On prod we have // squadCalBaseRoutePath configured to '/', which means it's a catch-all. If // we call server.use on squadCalRouter first, it will catch all requests // and prevent commAppRouter and landingRouter from working correctly. So we // make sure that squadCalRouter goes last server.get('/invite/:secret', inviteResponder); if (landingBaseRoutePath) { const landingRouter = express.Router(); landingRouter.get('/invite/:secret', inviteResponder); landingRouter.use( '/.well-known', express.static( '.well-known', // Necessary for apple-app-site-association file { setHeaders: res => res.setHeader('Content-Type', 'application/json'), }, ), ); landingRouter.use('/images', express.static('images')); landingRouter.use('/fonts', express.static('fonts')); landingRouter.use( '/compiled', express.static('landing_compiled', compiledFolderOptions), ); landingRouter.use('/', express.static('landing_icons')); landingRouter.post('/subscribe_email', emailSubscriptionResponder); landingRouter.get('*', landingHandler); server.use(landingBaseRoutePath, landingRouter); } - if (commAppBaseRoutePath) { + if (webAppBaseRoutePath) { const commAppRouter = express.Router(); setupAppRouter(commAppRouter); - server.use(commAppBaseRoutePath, commAppRouter); + server.use(webAppBaseRoutePath, commAppRouter); } - if (squadCalBaseRoutePath) { + if (keyserverBaseRoutePath) { const squadCalRouter = express.Router(); if (keyserverCorsOptions) { squadCalRouter.use(cors(keyserverCorsOptions)); } setupAppRouter(squadCalRouter); - server.use(squadCalBaseRoutePath, squadCalRouter); + server.use(keyserverBaseRoutePath, squadCalRouter); + } + + if (isDev && webAppURLFacts) { + const oldPath = '/comm/'; + server.all(`${oldPath}*`, (req: $Request, res: $Response) => { + const endpoint = req.url.slice(oldPath.length); + const newURL = `${webAppURLFacts.baseDomain}${webAppURLFacts.basePath}${endpoint}`; + res.redirect(newURL); + }); } const listenAddress = (() => { if (process.env.COMM_LISTEN_ADDR) { return process.env.COMM_LISTEN_ADDR; } else if (process.env.NODE_ENV === 'development') { return undefined; } else { return 'localhost'; } })(); server.listen(parseInt(process.env.PORT, 10) || 3000, listenAddress); } })(); diff --git a/keyserver/src/responders/website-responders.js b/keyserver/src/responders/website-responders.js index eb528b82e..21b6e753f 100644 --- a/keyserver/src/responders/website-responders.js +++ b/keyserver/src/responders/website-responders.js @@ -1,381 +1,392 @@ // @flow import html from 'common-tags/lib/html/index.js'; import { detect as detectBrowser } from 'detect-browser'; import type { $Response, $Request } from 'express'; import fs from 'fs'; import * as React from 'react'; // eslint-disable-next-line import/extensions import ReactDOMServer from 'react-dom/server'; import { promisify } from 'util'; import { inviteLinkURL } from 'lib/facts/links.js'; import stores from 'lib/facts/stores.js'; import getTitle from 'web/title/getTitle.js'; import { waitForStream } from '../utils/json-stream.js'; import { + getAndAssertKeyserverURLFacts, getAppURLFactsFromRequestURL, - getCommAppURLFacts, + getWebAppURLFacts, } from '../utils/urls.js'; const { renderToNodeStream } = ReactDOMServer; const access = promisify(fs.access); const readFile = promisify(fs.readFile); const googleFontsURL = 'https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=Inter:wght@400;500;600&display=swap'; const localFontsURL = 'fonts/local-fonts.css'; async function getFontsURL() { try { await access(localFontsURL); return localFontsURL; } catch { return googleFontsURL; } } type AssetInfo = { +jsURL: string, +fontsURL: string, +cssInclude: string, +olmFilename: string, +commQueryExecutorFilename: string, +opaqueURL: string, }; let assetInfo: ?AssetInfo = null; async function getAssetInfo() { if (assetInfo) { return assetInfo; } if (process.env.NODE_ENV === 'development') { const fontsURL = await getFontsURL(); assetInfo = { jsURL: 'http://localhost:8080/dev.build.js', fontsURL, cssInclude: '', olmFilename: '', commQueryExecutorFilename: '', opaqueURL: 'http://localhost:8080/opaque-ke.wasm', }; return assetInfo; } try { const manifestString = await readFile('../web/dist/manifest.json', 'utf8'); const manifest = JSON.parse(manifestString); const webworkersManifestString = await readFile( '../web/dist/webworkers/manifest.json', 'utf8', ); const webworkersManifest = JSON.parse(webworkersManifestString); assetInfo = { jsURL: `compiled/${manifest['browser.js']}`, fontsURL: googleFontsURL, cssInclude: html` `, olmFilename: manifest['olm.wasm'], commQueryExecutorFilename: webworkersManifest['comm_query_executor.wasm'], opaqueURL: `compiled/${manifest['comm_opaque2_wasm_bg.wasm']}`, }; return assetInfo; } catch { throw new Error( 'Could not load manifest.json for web build. ' + 'Did you forget to run `yarn dev` in the web folder?', ); } } let webpackCompiledRootComponent: ?React.ComponentType<{}> = null; async function getWebpackCompiledRootComponentForSSR() { if (webpackCompiledRootComponent) { return webpackCompiledRootComponent; } try { // $FlowFixMe web/dist doesn't always exist const webpackBuild = await import('web/dist/app.build.cjs'); webpackCompiledRootComponent = webpackBuild.app.default; return webpackCompiledRootComponent; } catch { throw new Error( 'Could not load app.build.cjs. ' + 'Did you forget to run `yarn dev` in the web folder?', ); } } +function stripLastSlash(input: string): string { + return input.replace(/\/$/, ''); +} + async function websiteResponder(req: $Request, res: $Response): Promise { const { basePath } = getAppURLFactsFromRequestURL(req.originalUrl); - const baseURL = basePath.replace(/\/$/, ''); + const baseURL = stripLastSlash(basePath); + + const keyserverURLFacts = getAndAssertKeyserverURLFacts(); + const keyserverURL = `${keyserverURLFacts.baseDomain}${stripLastSlash( + keyserverURLFacts.basePath, + )}`; const loadingPromise = getWebpackCompiledRootComponentForSSR(); const assetInfoPromise = getAssetInfo(); const { jsURL, fontsURL, cssInclude, olmFilename, opaqueURL, commQueryExecutorFilename, } = await assetInfoPromise; // prettier-ignore res.write(html` ${getTitle(0)} ${cssInclude}
`); const Loading = await loadingPromise; const reactStream = renderToNodeStream(); reactStream.pipe(res, { end: false }); await waitForStream(reactStream); res.end(html`
`); } const inviteSecretRegex = /^[a-z0-9]+$/i; // On native, if this responder is called, it means that the app isn't // installed. async function inviteResponder(req: $Request, res: $Response): Promise { const { secret } = req.params; const userAgent = req.get('User-Agent'); const detectionResult = detectBrowser(userAgent); if (detectionResult.os === 'Android OS') { const isSecretValid = inviteSecretRegex.test(secret); const referrer = isSecretValid ? `&referrer=${encodeURIComponent(`utm_source=invite/${secret}`)}` : ''; const redirectUrl = `${stores.googlePlayUrl}${referrer}`; res.writeHead(301, { Location: redirectUrl, }); res.end(); return; } else if (detectionResult.os !== 'iOS') { - const urlFacts = getCommAppURLFacts(); + const urlFacts = getWebAppURLFacts(); const baseDomain = urlFacts?.baseDomain ?? ''; const basePath = urlFacts?.basePath ?? '/'; const redirectUrl = `${baseDomain}${basePath}handle/invite/${secret}`; res.writeHead(301, { Location: redirectUrl, }); res.end(); return; } const fontsURL = await getFontsURL(); res.end(html` Comm

Comm

To join this community, download the Comm app and reopen this invite link

Download Comm Invite Link
Visit Comm’s website arrow up right `); } export { websiteResponder, inviteResponder }; diff --git a/keyserver/src/utils/urls.js b/keyserver/src/utils/urls.js index 07cdd3131..5ba924b77 100644 --- a/keyserver/src/utils/urls.js +++ b/keyserver/src/utils/urls.js @@ -1,105 +1,105 @@ // @flow import invariant from 'invariant'; import { getCommConfig } from 'lib/utils/comm-config.js'; import { values } from 'lib/utils/objects.js'; export type AppURLFacts = { +baseDomain: string, +basePath: string, +https: boolean, +baseRoutePath: string, +proxy?: 'apache' | 'none', // defaults to apache }; const validProxies = new Set(['apache', 'none']); const sitesObj = Object.freeze({ a: 'landing', - b: 'commapp', - c: 'squadcal', + b: 'webapp', + c: 'keyserver', }); export type Site = $Values; const sites: $ReadOnlyArray = values(sitesObj); const cachedURLFacts = new Map(); async function fetchURLFacts(site: Site): Promise { const existing = cachedURLFacts.get(site); if (existing !== undefined) { return existing; } let urlFacts: ?AppURLFacts = await getCommConfig({ folder: 'facts', name: `${site}_url`, }); if (urlFacts) { const { proxy } = urlFacts; urlFacts = { ...urlFacts, proxy: validProxies.has(proxy) ? proxy : 'apache', }; } cachedURLFacts.set(site, urlFacts); return urlFacts; } async function prefetchAllURLFacts() { await Promise.all(sites.map(fetchURLFacts)); } -function getSquadCalURLFacts(): ?AppURLFacts { - return cachedURLFacts.get('squadcal'); +function getKeyserverURLFacts(): ?AppURLFacts { + return cachedURLFacts.get('keyserver'); } -function getCommAppURLFacts(): ?AppURLFacts { - return cachedURLFacts.get('commapp'); +function getWebAppURLFacts(): ?AppURLFacts { + return cachedURLFacts.get('webapp'); } -function getAndAssertCommAppURLFacts(): AppURLFacts { - const urlFacts = getCommAppURLFacts(); - invariant(urlFacts, 'keyserver/facts/commapp_url.json missing'); +function getAndAssertKeyserverURLFacts(): AppURLFacts { + const urlFacts = getKeyserverURLFacts(); + invariant(urlFacts, 'keyserver/facts/keyserver_url.json missing'); return urlFacts; } // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return function getAppURLFactsFromRequestURL(url: string): AppURLFacts { - const commURLFacts = getCommAppURLFacts(); - if (commURLFacts && url.startsWith(commURLFacts.baseRoutePath)) { - return commURLFacts; + const webAppURLFacts = getWebAppURLFacts(); + if (webAppURLFacts && url.startsWith(webAppURLFacts.baseRoutePath)) { + return webAppURLFacts; } - const squadCalURLFacts = getSquadCalURLFacts(); - if (squadCalURLFacts) { - return squadCalURLFacts; + const keyserverURLFacts = getKeyserverURLFacts(); + if (keyserverURLFacts && url.startsWith(keyserverURLFacts.baseRoutePath)) { + return keyserverURLFacts; } invariant(false, 'request received but no URL facts are present'); } function getLandingURLFacts(): ?AppURLFacts { return cachedURLFacts.get('landing'); } function getAndAssertLandingURLFacts(): AppURLFacts { const urlFacts = getLandingURLFacts(); invariant(urlFacts, 'keyserver/facts/landing_url.json missing'); return urlFacts; } export type WebAppCorsConfig = { +domain: string }; async function getWebAppCorsConfig(): Promise { const config = await getCommConfig({ folder: 'facts', name: 'webapp_cors', }); return config; } export { prefetchAllURLFacts, - getSquadCalURLFacts, - getCommAppURLFacts, - getAndAssertCommAppURLFacts, + getKeyserverURLFacts, + getWebAppURLFacts, + getAndAssertKeyserverURLFacts, getLandingURLFacts, getAndAssertLandingURLFacts, getAppURLFactsFromRequestURL, getWebAppCorsConfig, }; diff --git a/native/redux/persist.js b/native/redux/persist.js index d08d83fe6..a980a9e84 100644 --- a/native/redux/persist.js +++ b/native/redux/persist.js @@ -1,992 +1,1009 @@ // @flow import AsyncStorage from '@react-native-async-storage/async-storage'; import invariant from 'invariant'; import { Platform } from 'react-native'; import Orientation from 'react-native-orientation-locker'; import { createTransform } from 'redux-persist'; import type { Transform } from 'redux-persist/es/types.js'; import { convertEntryStoreToNewIDSchema, convertInviteLinksStoreToNewIDSchema, convertMessageStoreToNewIDSchema, convertRawMessageInfoToNewIDSchema, convertCalendarFilterToNewIDSchema, convertConnectionInfoToNewIDSchema, } from 'lib/_generated/migration-utils.js'; import { type ClientDBMessageStoreOperation, messageStoreOpsHandlers, } from 'lib/ops/message-store-ops.js'; import { type ReportStoreOperation, type ClientDBReportStoreOperation, convertReportsToReplaceReportOps, reportStoreOpsHandlers, } from 'lib/ops/report-store-ops.js'; import type { ClientDBThreadStoreOperation } from 'lib/ops/thread-store-ops.js'; import { threadStoreOpsHandlers } from 'lib/ops/thread-store-ops.js'; import { highestLocalIDSelector } from 'lib/selectors/local-id-selectors.js'; import { createAsyncMigrate } from 'lib/shared/create-async-migrate.js'; import { inconsistencyResponsesToReports } from 'lib/shared/report-utils.js'; import { getContainingThreadID, getCommunity, } from 'lib/shared/thread-utils.js'; import { DEPRECATED_unshimMessageStore, unshimFunc, } from 'lib/shared/unshim-utils.js'; import { defaultEnabledApps } from 'lib/types/enabled-apps.js'; import { defaultCalendarQuery } from 'lib/types/entry-types.js'; import { defaultCalendarFilters } from 'lib/types/filter-types.js'; import type { KeyserverStore, KeyserverInfo, } from 'lib/types/keyserver-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { type LocalMessageInfo, type MessageStore, type MessageStoreThreads, } from 'lib/types/message-types.js'; import type { ReportStore, ClientReportCreationRequest, } from 'lib/types/report-types.js'; import { defaultConnectionInfo, type ConnectionInfo, } from 'lib/types/socket-types.js'; import { defaultGlobalThemeInfo } from 'lib/types/theme-types.js'; import type { ClientDBThreadInfo } from 'lib/types/thread-types.js'; import { translateClientDBMessageInfoToRawMessageInfo, translateRawMessageInfoToClientDBMessageInfo, } from 'lib/utils/message-ops-utils.js'; import { generateIDSchemaMigrationOpsForDrafts, convertMessageStoreThreadsToNewIDSchema, convertThreadStoreThreadInfosToNewIDSchema, } from 'lib/utils/migration-utils.js'; import { defaultNotifPermissionAlertInfo } from 'lib/utils/push-alerts.js'; import { convertClientDBThreadInfoToRawThreadInfo, convertRawThreadInfoToClientDBThreadInfo, } from 'lib/utils/thread-ops-utils.js'; import { getUUID } from 'lib/utils/uuid.js'; import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import { updateClientDBThreadStoreThreadInfos, createUpdateDBOpsForThreadStoreThreadInfos, createUpdateDBOpsForMessageStoreMessages, createUpdateDBOpsForMessageStoreThreads, } from './client-db-utils.js'; import { defaultState } from './default-state.js'; import { migrateThreadStoreForEditThreadPermissions } from './edit-thread-permission-migration.js'; import { persistMigrationForManagePinsThreadPermission } from './manage-pins-permission-migration.js'; import type { AppState } from './state-types.js'; import { unshimClientDB } from './unshim-utils.js'; import { updateRolesAndPermissions } from './update-roles-and-permissions.js'; import { commCoreModule } from '../native-modules.js'; import { defaultDeviceCameraInfo } from '../types/camera.js'; import { isTaskCancelledError } from '../utils/error-handling.js'; +import { defaultURLPrefix } from '../utils/url-utils.js'; const migrations = { [1]: (state: AppState) => ({ ...state, notifPermissionAlertInfo: defaultNotifPermissionAlertInfo, }), [2]: (state: AppState) => ({ ...state, messageSentFromRoute: [], }), [3]: state => ({ currentUserInfo: state.currentUserInfo, entryStore: state.entryStore, threadInfos: state.threadInfos, userInfos: state.userInfos, messageStore: { ...state.messageStore, currentAsOf: state.currentAsOf, }, updatesCurrentAsOf: state.currentAsOf, cookie: state.cookie, deviceToken: state.deviceToken, urlPrefix: state.urlPrefix, customServer: state.customServer, notifPermissionAlertInfo: state.notifPermissionAlertInfo, messageSentFromRoute: state.messageSentFromRoute, _persist: state._persist, }), [4]: (state: AppState) => ({ ...state, pingTimestamps: undefined, activeServerRequests: undefined, }), [5]: (state: AppState) => ({ ...state, calendarFilters: defaultCalendarFilters, }), [6]: state => ({ ...state, threadInfos: undefined, threadStore: { threadInfos: state.threadInfos, inconsistencyResponses: [], }, }), [7]: state => ({ ...state, lastUserInteraction: undefined, sessionID: undefined, entryStore: { ...state.entryStore, inconsistencyResponses: [], }, }), [8]: (state: AppState) => ({ ...state, pingTimestamps: undefined, activeServerRequests: undefined, connection: { ...defaultConnectionInfo, actualizedCalendarQuery: defaultCalendarQuery(Platform.OS), }, watchedThreadIDs: [], entryStore: { ...state.entryStore, actualizedCalendarQuery: undefined, }, }), [9]: state => ({ ...state, connection: { ...state.connection, lateResponses: [], }, }), [10]: state => ({ ...state, nextLocalID: highestLocalIDSelector(state) + 1, connection: { ...state.connection, showDisconnectedBar: false, }, messageStore: { ...state.messageStore, local: {}, }, }), [11]: (state: AppState) => ({ ...state, messageStore: DEPRECATED_unshimMessageStore(state.messageStore, [ messageTypes.IMAGES, ]), }), [12]: (state: AppState) => ({ ...state, globalThemeInfo: defaultGlobalThemeInfo, }), [13]: (state: AppState) => ({ ...state, deviceCameraInfo: defaultDeviceCameraInfo, deviceOrientation: Orientation.getInitialOrientation(), }), [14]: (state: AppState) => state, [15]: state => ({ ...state, threadStore: { ...state.threadStore, inconsistencyReports: inconsistencyResponsesToReports( state.threadStore.inconsistencyResponses, ), inconsistencyResponses: undefined, }, entryStore: { ...state.entryStore, inconsistencyReports: inconsistencyResponsesToReports( state.entryStore.inconsistencyResponses, ), inconsistencyResponses: undefined, }, queuedReports: [], }), [16]: state => { const result = { ...state, messageSentFromRoute: undefined, dataLoaded: !!state.currentUserInfo && !state.currentUserInfo.anonymous, }; if (state.navInfo) { result.navInfo = { ...state.navInfo, navigationState: undefined, }; } return result; }, [17]: state => ({ ...state, userInfos: undefined, userStore: { userInfos: state.userInfos, inconsistencyResponses: [], }, }), [18]: state => ({ ...state, userStore: { userInfos: state.userStore.userInfos, inconsistencyReports: [], }, }), [19]: state => { const threadInfos = {}; for (const threadID in state.threadStore.threadInfos) { const threadInfo = state.threadStore.threadInfos[threadID]; const { visibilityRules, ...rest } = threadInfo; threadInfos[threadID] = rest; } return { ...state, threadStore: { ...state.threadStore, threadInfos, }, }; }, [20]: (state: AppState) => ({ ...state, messageStore: DEPRECATED_unshimMessageStore(state.messageStore, [ messageTypes.UPDATE_RELATIONSHIP, ]), }), [21]: (state: AppState) => ({ ...state, messageStore: DEPRECATED_unshimMessageStore(state.messageStore, [ messageTypes.CREATE_SIDEBAR, messageTypes.SIDEBAR_SOURCE, ]), }), [22]: state => { for (const key in state.drafts) { const value = state.drafts[key]; try { commCoreModule.updateDraft(key, value); } catch (e) { if (!isTaskCancelledError(e)) { throw e; } } } return { ...state, drafts: undefined, }; }, [23]: state => ({ ...state, globalThemeInfo: defaultGlobalThemeInfo, }), [24]: state => ({ ...state, enabledApps: defaultEnabledApps, }), [25]: state => ({ ...state, crashReportsEnabled: __DEV__, }), [26]: state => { const { currentUserInfo } = state; if (currentUserInfo.anonymous) { return state; } return { ...state, crashReportsEnabled: undefined, currentUserInfo: { id: currentUserInfo.id, username: currentUserInfo.username, }, enabledReports: { crashReports: __DEV__, inconsistencyReports: __DEV__, mediaReports: __DEV__, }, }; }, [27]: state => ({ ...state, queuedReports: undefined, enabledReports: undefined, threadStore: { ...state.threadStore, inconsistencyReports: undefined, }, entryStore: { ...state.entryStore, inconsistencyReports: undefined, }, reportStore: { enabledReports: { crashReports: __DEV__, inconsistencyReports: __DEV__, mediaReports: __DEV__, }, queuedReports: [ ...state.entryStore.inconsistencyReports, ...state.threadStore.inconsistencyReports, ...state.queuedReports, ], }, }), [28]: state => { const threadParentToChildren = {}; for (const threadID in state.threadStore.threadInfos) { const threadInfo = state.threadStore.threadInfos[threadID]; const parentThreadInfo = threadInfo.parentThreadID ? state.threadStore.threadInfos[threadInfo.parentThreadID] : null; const parentIndex = parentThreadInfo ? parentThreadInfo.id : '-1'; if (!threadParentToChildren[parentIndex]) { threadParentToChildren[parentIndex] = []; } threadParentToChildren[parentIndex].push(threadID); } const rootIDs = threadParentToChildren['-1']; if (!rootIDs) { // This should never happen, but if it somehow does we'll let the state // check mechanism resolve it... return state; } const threadInfos = {}; const stack = [...rootIDs]; while (stack.length > 0) { const threadID = stack.shift(); const threadInfo = state.threadStore.threadInfos[threadID]; const parentThreadInfo = threadInfo.parentThreadID ? threadInfos[threadInfo.parentThreadID] : null; threadInfos[threadID] = { ...threadInfo, containingThreadID: getContainingThreadID( parentThreadInfo, threadInfo.type, ), community: getCommunity(parentThreadInfo), }; const children = threadParentToChildren[threadID]; if (children) { stack.push(...children); } } return { ...state, threadStore: { ...state.threadStore, threadInfos } }; }, [29]: (state: AppState) => { const updatedThreadInfos = migrateThreadStoreForEditThreadPermissions( state.threadStore.threadInfos, ); return { ...state, threadStore: { ...state.threadStore, threadInfos: updatedThreadInfos, }, }; }, [30]: (state: AppState) => { const threadInfos = state.threadStore.threadInfos; const operations = [ { type: 'remove_all', }, ...Object.keys(threadInfos).map((id: string) => ({ type: 'replace', payload: { id, threadInfo: threadInfos[id] }, })), ]; try { commCoreModule.processThreadStoreOperationsSync( threadStoreOpsHandlers.convertOpsToClientDBOps(operations), ); } catch (exception) { console.log(exception); if (isTaskCancelledError(exception)) { return state; } return { ...state, cookie: null }; } return state; }, [31]: (state: AppState) => { const messages = state.messageStore.messages; const operations: $ReadOnlyArray = [ { type: 'remove_all', }, ...Object.keys(messages).map((id: string) => ({ type: 'replace', payload: translateRawMessageInfoToClientDBMessageInfo(messages[id]), })), ]; try { commCoreModule.processMessageStoreOperationsSync(operations); } catch (exception) { console.log(exception); if (isTaskCancelledError(exception)) { return state; } return { ...state, cookie: null }; } return state; }, [32]: (state: AppState) => unshimClientDB(state, [messageTypes.MULTIMEDIA]), [33]: (state: AppState) => unshimClientDB(state, [messageTypes.REACTION]), [34]: state => { const { threadIDsToNotifIDs, ...stateSansThreadIDsToNotifIDs } = state; return stateSansThreadIDsToNotifIDs; }, [35]: (state: AppState) => unshimClientDB(state, [messageTypes.MULTIMEDIA]), [36]: (state: AppState) => { // 1. Get threads and messages from SQLite `threads` and `messages` tables. const clientDBThreadInfos = commCoreModule.getAllThreadsSync(); const clientDBMessageInfos = commCoreModule.getAllMessagesSync(); // 2. Translate `ClientDBThreadInfo`s to `RawThreadInfo`s and // `ClientDBMessageInfo`s to `RawMessageInfo`s. const rawThreadInfos = clientDBThreadInfos.map( convertClientDBThreadInfoToRawThreadInfo, ); const rawMessageInfos = clientDBMessageInfos.map( translateClientDBMessageInfoToRawMessageInfo, ); // 3. Unshim translated `RawMessageInfos` to get the TOGGLE_PIN messages const unshimmedRawMessageInfos = rawMessageInfos.map(messageInfo => unshimFunc(messageInfo, new Set([messageTypes.TOGGLE_PIN])), ); // 4. Filter out non-TOGGLE_PIN messages const filteredRawMessageInfos = unshimmedRawMessageInfos.filter( messageInfo => messageInfo.type === messageTypes.TOGGLE_PIN, ); // 5. We want only the last TOGGLE_PIN message for each message ID, // so 'pin', 'unpin', 'pin' don't count as 3 pins, but only 1. const lastMessageIDToRawMessageInfoMap = new Map(); for (const messageInfo of filteredRawMessageInfos) { const { targetMessageID } = messageInfo; lastMessageIDToRawMessageInfoMap.set(targetMessageID, messageInfo); } const lastMessageIDToRawMessageInfos = Array.from( lastMessageIDToRawMessageInfoMap.values(), ); // 6. Create a Map of threadIDs to pinnedCount const threadIDsToPinnedCount = new Map(); for (const messageInfo of lastMessageIDToRawMessageInfos) { const { threadID, type } = messageInfo; if (type === messageTypes.TOGGLE_PIN) { const pinnedCount = threadIDsToPinnedCount.get(threadID) || 0; threadIDsToPinnedCount.set(threadID, pinnedCount + 1); } } // 7. Include a pinnedCount for each rawThreadInfo const rawThreadInfosWithPinnedCount = rawThreadInfos.map(threadInfo => ({ ...threadInfo, pinnedCount: threadIDsToPinnedCount.get(threadInfo.id) || 0, })); // 8. Convert rawThreadInfos to a map of threadID to threadInfo const threadIDToThreadInfo = rawThreadInfosWithPinnedCount.reduce( (acc, threadInfo) => { acc[threadInfo.id] = threadInfo; return acc; }, {}, ); // 9. Add threadPermission to each threadInfo const rawThreadInfosWithThreadPermission = persistMigrationForManagePinsThreadPermission(threadIDToThreadInfo); // 10. Convert the new threadInfos back into an array const rawThreadInfosWithCountAndPermission = Object.keys( rawThreadInfosWithThreadPermission, ).map(id => rawThreadInfosWithThreadPermission[id]); // 11. Translate `RawThreadInfo`s to `ClientDBThreadInfo`s. const convertedClientDBThreadInfos = rawThreadInfosWithCountAndPermission.map( convertRawThreadInfoToClientDBThreadInfo, ); // 12. Construct `ClientDBThreadStoreOperation`s to clear SQLite `threads` // table and repopulate with `ClientDBThreadInfo`s. const operations: $ReadOnlyArray = [ { type: 'remove_all', }, ...convertedClientDBThreadInfos.map((thread: ClientDBThreadInfo) => ({ type: 'replace', payload: thread, })), ]; // 13. Try processing `ClientDBThreadStoreOperation`s and log out if // `processThreadStoreOperationsSync(...)` throws an exception. try { commCoreModule.processThreadStoreOperationsSync(operations); } catch (exception) { console.log(exception); return { ...state, cookie: null }; } return state; }, [37]: state => { const operations = messageStoreOpsHandlers.convertOpsToClientDBOps([ { type: 'remove_all_threads', }, { type: 'replace_threads', payload: { threads: state.messageStore.threads }, }, ]); try { commCoreModule.processMessageStoreOperationsSync(operations); } catch (exception) { console.error(exception); if (isTaskCancelledError(exception)) { return state; } return { ...state, cookie: null }; } return state; }, [38]: state => updateClientDBThreadStoreThreadInfos(state, updateRolesAndPermissions), [39]: (state: AppState) => unshimClientDB(state, [messageTypes.EDIT_MESSAGE]), [40]: state => updateClientDBThreadStoreThreadInfos(state, updateRolesAndPermissions), [41]: (state: AppState) => { const queuedReports = state.reportStore.queuedReports.map(report => ({ ...report, id: getUUID(), })); return { ...state, reportStore: { ...state.reportStore, queuedReports }, }; }, [42]: (state: AppState) => { const reportStoreOperations: $ReadOnlyArray = [ { type: 'remove_all_reports' }, ...convertReportsToReplaceReportOps(state.reportStore.queuedReports), ]; const dbOperations: $ReadOnlyArray = reportStoreOpsHandlers.convertOpsToClientDBOps(reportStoreOperations); try { commCoreModule.processReportStoreOperationsSync(dbOperations); } catch (exception) { if (isTaskCancelledError(exception)) { return state; } return { ...state, cookie: null }; } return state; }, [43]: async state => { const { messages, drafts, threads, messageStoreThreads } = await commCoreModule.getClientDBStore(); const messageStoreThreadsOperations = createUpdateDBOpsForMessageStoreThreads( messageStoreThreads, convertMessageStoreThreadsToNewIDSchema, ); const messageStoreMessagesOperations = createUpdateDBOpsForMessageStoreMessages(messages, messageInfos => messageInfos.map(convertRawMessageInfoToNewIDSchema), ); const threadOperations = createUpdateDBOpsForThreadStoreThreadInfos( threads, convertThreadStoreThreadInfosToNewIDSchema, ); const draftOperations = generateIDSchemaMigrationOpsForDrafts(drafts); try { await Promise.all([ commCoreModule.processMessageStoreOperations([ ...messageStoreMessagesOperations, ...messageStoreThreadsOperations, ]), commCoreModule.processThreadStoreOperations(threadOperations), commCoreModule.processDraftStoreOperations(draftOperations), ]); } catch (exception) { console.log(exception); return { ...state, cookie: null }; } const inviteLinksStore = state.inviteLinksStore ?? defaultState.inviteLinksStore; return { ...state, entryStore: convertEntryStoreToNewIDSchema(state.entryStore), messageStore: convertMessageStoreToNewIDSchema(state.messageStore), calendarFilters: state.calendarFilters.map( convertCalendarFilterToNewIDSchema, ), connection: convertConnectionInfoToNewIDSchema(state.connection), watchedThreadIDs: state.watchedThreadIDs.map( id => `${ashoatKeyserverID}|${id}`, ), inviteLinksStore: convertInviteLinksStoreToNewIDSchema(inviteLinksStore), }; }, [44]: async state => { const { cookie, ...rest } = state; return { ...rest, keyserverStore: { keyserverInfos: { [ashoatKeyserverID]: { cookie } } }, }; }, [45]: async state => { const { updatesCurrentAsOf, keyserverStore, ...rest } = state; return { ...rest, keyserverStore: { ...keyserverStore, keyserverInfos: { ...keyserverStore.keyserverInfos, [ashoatKeyserverID]: { ...keyserverStore.keyserverInfos[ashoatKeyserverID], updatesCurrentAsOf, }, }, }, }; }, [46]: async state => { const { currentAsOf } = state.messageStore; return { ...state, messageStore: { ...state.messageStore, currentAsOf: { [ashoatKeyserverID]: currentAsOf }, }, }; }, [47]: async state => { const { urlPrefix, keyserverStore, ...rest } = state; return { ...rest, keyserverStore: { ...keyserverStore, keyserverInfos: { ...keyserverStore.keyserverInfos, [ashoatKeyserverID]: { ...keyserverStore.keyserverInfos[ashoatKeyserverID], urlPrefix, }, }, }, }; }, [48]: async state => { const { connection, keyserverStore, ...rest } = state; return { ...rest, keyserverStore: { ...keyserverStore, keyserverInfos: { ...keyserverStore.keyserverInfos, [ashoatKeyserverID]: { ...keyserverStore.keyserverInfos[ashoatKeyserverID], connection, }, }, }, }; }, [49]: async state => { const { keyserverStore, ...rest } = state; const { connection, ...keyserverRest } = keyserverStore.keyserverInfos[ashoatKeyserverID]; return { ...rest, keyserverStore: { ...keyserverStore, keyserverInfos: { ...keyserverStore.keyserverInfos, [ashoatKeyserverID]: { ...keyserverRest, }, }, }, connection, }; }, [50]: async state => { const { connection, ...rest } = state; const { actualizedCalendarQuery, ...connectionRest } = connection; return { ...rest, connection: connectionRest, actualizedCalendarQuery, }; }, [51]: async state => { const { lastCommunicatedPlatformDetails, keyserverStore, ...rest } = state; return { ...rest, keyserverStore: { ...keyserverStore, keyserverInfos: { ...keyserverStore.keyserverInfos, [ashoatKeyserverID]: { ...keyserverStore.keyserverInfos[ashoatKeyserverID], lastCommunicatedPlatformDetails, }, }, }, }; }, [52]: async state => ({ ...state, integrityStore: { threadHashes: {}, threadHashingStatus: 'data_not_loaded', }, }), [53]: state => { if (!state.userStore.inconsistencyReports) { return state; } const reportStoreOperations = convertReportsToReplaceReportOps( state.userStore.inconsistencyReports, ); const dbOperations: $ReadOnlyArray = reportStoreOpsHandlers.convertOpsToClientDBOps(reportStoreOperations); try { commCoreModule.processReportStoreOperationsSync(dbOperations); } catch (exception) { if (isTaskCancelledError(exception)) { return state; } return { ...state, cookie: null }; } const { inconsistencyReports, ...newUserStore } = state.userStore; const queuedReports = reportStoreOpsHandlers.processStoreOperations( state.reportStore.queuedReports, reportStoreOperations, ); return { ...state, userStore: newUserStore, reportStore: { ...state.reportStore, queuedReports, }, }; }, [54]: state => { let updatedMessageStoreThreads: MessageStoreThreads = {}; for (const threadID: string in state.messageStore.threads) { const { lastNavigatedTo, lastPruned, ...rest } = state.messageStore.threads[threadID]; updatedMessageStoreThreads = { ...updatedMessageStoreThreads, [threadID]: rest, }; } return { ...state, messageStore: { ...state.messageStore, threads: updatedMessageStoreThreads, }, }; }, + [55]: async state => + __DEV__ + ? { + ...state, + keyserverStore: { + ...state.keyserverStore, + keyserverInfos: { + ...state.keyserverStore.keyserverInfos, + [ashoatKeyserverID]: { + ...state.keyserverStore.keyserverInfos[ashoatKeyserverID], + urlPrefix: defaultURLPrefix, + }, + }, + }, + } + : state, }; // After migration 31, we'll no longer want to persist `messageStore.messages` // via redux-persist. However, we DO want to continue persisting everything in // `messageStore` EXCEPT for `messages`. The `blacklist` property in // `persistConfig` allows us to specify top-level keys that shouldn't be // persisted. However, we aren't able to specify nested keys in `blacklist`. // As a result, if we want to prevent nested keys from being persisted we'll // need to use `createTransform(...)` to specify an `inbound` function that // allows us to modify the `state` object before it's passed through // `JSON.stringify(...)` and written to disk. We specify the keys for which // this transformation should be executed in the `whitelist` property of the // `config` object that's passed to `createTransform(...)`. // eslint-disable-next-line no-unused-vars type PersistedThreadMessageInfo = { +startReached: boolean, }; type PersistedMessageStore = { +local: { +[id: string]: LocalMessageInfo }, +currentAsOf: { +[keyserverID: string]: number }, +threads: { +[threadID: string]: PersistedThreadMessageInfo }, }; const messageStoreMessagesBlocklistTransform: Transform = createTransform( (state: MessageStore): PersistedMessageStore => { const { messages, threads, ...messageStoreSansMessages } = state; // We also do not want to persist `messageStore.threads[ID].messageIDs` // because they can be deterministically computed based on messages we have // from SQLite const threadsToPersist = {}; for (const threadID in threads) { const { messageIDs, ...threadsData } = threads[threadID]; threadsToPersist[threadID] = threadsData; } return { ...messageStoreSansMessages, threads: threadsToPersist }; }, (state: MessageStore): MessageStore => { const { threads: persistedThreads, ...messageStore } = state; const threads = {}; for (const threadID in persistedThreads) { threads[threadID] = { ...persistedThreads[threadID], messageIDs: [] }; } // We typically expect `messageStore.messages` to be `undefined` because // messages are persisted in the SQLite `messages` table rather than via // `redux-persist`. In this case we want to set `messageStore.messages` // to {} so we don't run into issues with `messageStore.messages` being // `undefined` (https://phab.comm.dev/D5545). // // However, in the case that a user is upgrading from a client where // `persistConfig.version` < 31, we expect `messageStore.messages` to // contain messages stored via `redux-persist` that we need in order // to correctly populate the SQLite `messages` table in migration 31 // (https://phab.comm.dev/D2600). // // However, because `messageStoreMessagesBlocklistTransform` modifies // `messageStore` before migrations are run, we need to make sure we aren't // inadvertently clearing `messageStore.messages` (by setting to {}) before // messages are stored in SQLite (https://linear.app/comm/issue/ENG-2377). return { ...messageStore, threads, messages: messageStore.messages ?? {} }; }, { whitelist: ['messageStore'] }, ); type PersistedReportStore = $Diff< ReportStore, { +queuedReports: $ReadOnlyArray }, >; const reportStoreTransform: Transform = createTransform( (state: ReportStore): PersistedReportStore => { return { enabledReports: state.enabledReports }; }, (state: PersistedReportStore): ReportStore => { return { ...state, queuedReports: [] }; }, { whitelist: ['reportStore'] }, ); type PersistedKeyserverInfo = $Diff< KeyserverInfo, { +connection: ConnectionInfo }, >; type PersistedKeyserverStore = { +keyserverInfos: { +[key: string]: PersistedKeyserverInfo }, }; const keyserverStoreTransform: Transform = createTransform( (state: KeyserverStore): PersistedKeyserverStore => { const keyserverInfos = {}; for (const key in state.keyserverInfos) { const { connection, ...rest } = state.keyserverInfos[key]; keyserverInfos[key] = rest; } return { ...state, keyserverInfos, }; }, (state: PersistedKeyserverStore): KeyserverStore => { const keyserverInfos = {}; const defaultConnection = defaultConnectionInfo; for (const key in state.keyserverInfos) { keyserverInfos[key] = { ...state.keyserverInfos[key], connection: { ...defaultConnection }, }; } return { ...state, keyserverInfos, }; }, { whitelist: ['keyserverStore'] }, ); const persistConfig = { key: 'root', storage: AsyncStorage, blacklist: [ 'loadingStatuses', 'lifecycleState', 'dimensions', 'draftStore', 'connectivity', 'deviceOrientation', 'frozen', 'threadStore', 'storeLoaded', 'connection', ], debug: __DEV__, - version: 54, + version: 55, transforms: [ messageStoreMessagesBlocklistTransform, reportStoreTransform, keyserverStoreTransform, ], migrate: (createAsyncMigrate(migrations, { debug: __DEV__ }): any), timeout: ((__DEV__ ? 0 : undefined): number | void), }; const codeVersion: number = commCoreModule.getCodeVersion(); // This local exists to avoid a circular dependency where redux-setup needs to // import all the navigation and screen stuff, but some of those screens want to // access the persistor to purge its state. let storedPersistor = null; function setPersistor(persistor: *) { storedPersistor = persistor; } function getPersistor(): empty { invariant(storedPersistor, 'should be set'); return storedPersistor; } export { persistConfig, codeVersion, setPersistor, getPersistor }; diff --git a/native/utils/url-utils.js b/native/utils/url-utils.js index 9c7218c33..316501bca 100644 --- a/native/utils/url-utils.js +++ b/native/utils/url-utils.js @@ -1,82 +1,82 @@ // @flow import invariant from 'invariant'; import { Platform } from 'react-native'; import DeviceInfo from 'react-native-device-info'; import urlParseLax from 'url-parse-lax'; import { natDevHostname, checkForMissingNatDevHostname, } from './dev-hostname.js'; const localhostHostname = 'localhost'; const localhostHostnameFromAndroidEmulator = '10.0.2.2'; const productionNodeServerURL = 'https://squadcal.org'; const productionLandingURL = 'https://comm.app'; const deviceIsEmulator: boolean = __DEV__ && DeviceInfo.isEmulatorSync(); // ESLint doesn't recognize that invariant always throws // eslint-disable-next-line consistent-return function getDevServerHostname(): string { if (!deviceIsEmulator) { checkForMissingNatDevHostname(); return natDevHostname; } else if (Platform.OS === 'android') { return localhostHostnameFromAndroidEmulator; } else if (Platform.OS === 'ios') { return localhostHostname; } invariant(false, `unsupported platform: ${Platform.OS}`); } function getDevNodeServerURLFromHostname(hostname: string): string { - return `http://${hostname}:3000/comm`; + return `http://${hostname}:3000/keyserver`; } function getDevLandingURLFromHostname(hostname: string): string { return `http://${hostname}:3000/commlanding`; } function getDevNodeServerURL(): string { const hostname = getDevServerHostname(); return getDevNodeServerURLFromHostname(hostname); } function getDevLandingURL(): string { const hostname = getDevServerHostname(); return getDevLandingURLFromHostname(hostname); } const nodeServerOptions: string[] = [ productionNodeServerURL, getDevNodeServerURL(), ]; const defaultURLPrefix: string = __DEV__ ? getDevNodeServerURL() : productionNodeServerURL; const defaultLandingURLPrefix: string = __DEV__ ? getDevLandingURL() : productionLandingURL; const natNodeServer: string = getDevNodeServerURLFromHostname(natDevHostname); const setCustomServer = 'SET_CUSTOM_SERVER'; function normalizeURL(url: string): string { return urlParseLax(url).href; } export { defaultURLPrefix, defaultLandingURLPrefix, getDevServerHostname, nodeServerOptions, natNodeServer, setCustomServer, normalizeURL, deviceIsEmulator, }; diff --git a/web/push-notif/service-worker.js b/web/push-notif/service-worker.js index e8ec18497..612f6bcbb 100644 --- a/web/push-notif/service-worker.js +++ b/web/push-notif/service-worker.js @@ -1,80 +1,80 @@ // @flow import type { WebNotification } from 'lib/types/notif-types.js'; import { convertNonPendingIDToNewSchema } from 'lib/utils/migration-utils.js'; import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; declare class PushMessageData { json(): Object; } declare class PushEvent extends ExtendableEvent { +data: PushMessageData; } declare var clients: Clients; declare function skipWaiting(): Promise; self.addEventListener('install', () => { skipWaiting(); }); self.addEventListener('activate', (event: ExtendableEvent) => { event.waitUntil(clients.claim()); }); self.addEventListener('push', (event: PushEvent) => { const data: WebNotification = event.data.json(); event.waitUntil( (async () => { let body = data.body; if (data.prefix) { body = `${data.prefix} ${body}`; } await self.registration.showNotification(data.title, { body, badge: 'https://web.comm.app/favicon.ico', icon: 'https://web.comm.app/favicon.ico', tag: data.id, data: { unreadCount: data.unreadCount, threadID: data.threadID, }, }); })(), ); }); self.addEventListener('notificationclick', (event: NotificationEvent) => { event.notification.close(); event.waitUntil( (async () => { const clientList: Array = (await clients.matchAll({ type: 'window', }): any); const selectedClient = clientList.find(client => client.focused) ?? clientList[0]; const threadID = convertNonPendingIDToNewSchema( event.notification.data.threadID, ashoatKeyserverID, ); if (selectedClient) { if (!selectedClient.focused) { await selectedClient.focus(); } selectedClient.postMessage({ targetThreadID: threadID, }); } else { const url = (process.env.NODE_ENV === 'production' ? 'https://web.comm.app' - : 'http://localhost:3000/comm') + `/chat/thread/${threadID}/`; + : 'http://localhost:3000/webapp') + `/chat/thread/${threadID}/`; clients.openWindow(url); } })(), ); }); diff --git a/web/redux/default-state.js b/web/redux/default-state.js index 35173bc05..91c8c1d52 100644 --- a/web/redux/default-state.js +++ b/web/redux/default-state.js @@ -1,96 +1,95 @@ // @flow import { defaultEnabledApps } from 'lib/types/enabled-apps.js'; import { defaultCalendarFilters } from 'lib/types/filter-types.js'; import { defaultConnectionInfo } from 'lib/types/socket-types.js'; import { defaultGlobalThemeInfo } from 'lib/types/theme-types.js'; -import { isDev } from 'lib/utils/dev-utils.js'; import { defaultNotifPermissionAlertInfo } from 'lib/utils/push-alerts.js'; import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import type { AppState } from './redux-setup.js'; +declare var keyserverURL: string; + const defaultWebState: AppState = Object.freeze({ navInfo: { activeChatThreadID: null, startDate: '', endDate: '', tab: 'chat', }, currentUserInfo: null, draftStore: { drafts: {} }, entryStore: { entryInfos: {}, daysToEntries: {}, lastUserInteractionCalendar: 0, }, threadStore: { threadInfos: {}, }, userStore: { userInfos: {}, }, messageStore: { messages: {}, threads: {}, local: {}, currentAsOf: { [ashoatKeyserverID]: 0 }, }, windowActive: true, pushApiPublicKey: null, cryptoStore: { primaryAccount: null, primaryIdentityKeys: null, notificationAccount: null, notificationIdentityKeys: null, }, windowDimensions: { width: window.width, height: window.height }, loadingStatuses: {}, calendarFilters: defaultCalendarFilters, deviceToken: null, dataLoaded: false, notifPermissionAlertInfo: defaultNotifPermissionAlertInfo, watchedThreadIDs: [], lifecycleState: 'active', enabledApps: defaultEnabledApps, reportStore: { enabledReports: { crashReports: false, inconsistencyReports: false, mediaReports: false, }, queuedReports: [], }, nextLocalID: 0, _persist: null, userPolicies: {}, commServicesAccessToken: null, inviteLinksStore: { links: {}, }, actualizedCalendarQuery: { startDate: '', endDate: '', filters: defaultCalendarFilters, }, communityPickerStore: { chat: null, calendar: null }, keyserverStore: { keyserverInfos: { [ashoatKeyserverID]: { cookie: null, updatesCurrentAsOf: 0, - urlPrefix: isDev - ? 'http://localhost:3000/comm' - : 'https://web.comm.app', + urlPrefix: keyserverURL, connection: { ...defaultConnectionInfo }, lastCommunicatedPlatformDetails: null, }, }, }, threadActivityStore: {}, initialStateLoaded: false, integrityStore: { threadHashes: {}, threadHashingStatus: 'starting' }, globalThemeInfo: defaultGlobalThemeInfo, }); export { defaultWebState }; diff --git a/web/redux/persist.js b/web/redux/persist.js index d54e82c0e..8136c1890 100644 --- a/web/redux/persist.js +++ b/web/redux/persist.js @@ -1,287 +1,302 @@ // @flow import invariant from 'invariant'; import { getStoredState, purgeStoredState, createTransform, } from 'redux-persist'; import storage from 'redux-persist/es/storage/index.js'; import type { Transform } from 'redux-persist/es/types.js'; import type { PersistConfig } from 'redux-persist/src/types.js'; import { createAsyncMigrate, type StorageMigrationFunction, } from 'lib/shared/create-async-migrate.js'; import type { KeyserverInfo, KeyserverStore, } from 'lib/types/keyserver-types.js'; import { cookieTypes } from 'lib/types/session-types.js'; import { defaultConnectionInfo, type ConnectionInfo, } from 'lib/types/socket-types.js'; import { defaultGlobalThemeInfo } from 'lib/types/theme-types.js'; import { parseCookies } from 'lib/utils/cookie-utils.js'; import { isDev } from 'lib/utils/dev-utils.js'; import { generateIDSchemaMigrationOpsForDrafts, convertDraftStoreToNewIDSchema, } from 'lib/utils/migration-utils.js'; import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import commReduxStorageEngine from './comm-redux-storage-engine.js'; import type { AppState } from './redux-setup.js'; import { getDatabaseModule } from '../database/database-module-provider.js'; import { isSQLiteSupported } from '../database/utils/db-utils.js'; import { workerRequestMessageTypes } from '../types/worker-types.js'; +declare var keyserverURL: string; + const migrations = { [1]: async state => { const { primaryIdentityPublicKey, ...stateWithoutPrimaryIdentityPublicKey } = state; return { ...stateWithoutPrimaryIdentityPublicKey, cryptoStore: { primaryAccount: null, primaryIdentityKeys: null, notificationAccount: null, notificationIdentityKeys: null, }, }; }, [2]: async state => { return state; }, [3]: async (state: AppState) => { let newState = state; if (state.draftStore) { newState = { ...newState, draftStore: convertDraftStoreToNewIDSchema(state.draftStore), }; } const databaseModule = await getDatabaseModule(); const isDatabaseSupported = await databaseModule.isDatabaseSupported(); if (!isDatabaseSupported) { return newState; } const stores = await databaseModule.schedule({ type: workerRequestMessageTypes.GET_CLIENT_STORE, }); invariant(stores?.store, 'Stores should exist'); await databaseModule.schedule({ type: workerRequestMessageTypes.PROCESS_STORE_OPERATIONS, storeOperations: { draftStoreOperations: generateIDSchemaMigrationOpsForDrafts( stores.store.drafts, ), }, }); return newState; }, [4]: async state => { const { lastCommunicatedPlatformDetails, keyserverStore, ...rest } = state; return { ...rest, keyserverStore: { ...keyserverStore, keyserverInfos: { ...keyserverStore.keyserverInfos, [ashoatKeyserverID]: { ...keyserverStore.keyserverInfos[ashoatKeyserverID], lastCommunicatedPlatformDetails, }, }, }, }; }, [5]: async state => { const databaseModule = await getDatabaseModule(); const isDatabaseSupported = await databaseModule.isDatabaseSupported(); if (!isDatabaseSupported) { return state; } if (!state.draftStore) { return state; } const { drafts } = state.draftStore; const draftStoreOperations = []; for (const key in drafts) { const text = drafts[key]; draftStoreOperations.push({ type: 'update', payload: { key, text }, }); } await databaseModule.schedule({ type: workerRequestMessageTypes.PROCESS_STORE_OPERATIONS, storeOperations: { draftStoreOperations }, }); return state; }, [6]: async state => ({ ...state, integrityStore: { threadHashes: {}, threadHashingStatus: 'starting' }, }), [7]: async (state: AppState): Promise => { if (!document.cookie) { return state; } const params = parseCookies(document.cookie); let cookie = null; if (params[cookieTypes.USER]) { cookie = `${cookieTypes.USER}=${params[cookieTypes.USER]}`; } else if (params[cookieTypes.ANONYMOUS]) { cookie = `${cookieTypes.ANONYMOUS}=${params[cookieTypes.ANONYMOUS]}`; } return { ...state, keyserverStore: { ...state.keyserverStore, keyserverInfos: { ...state.keyserverStore.keyserverInfos, [ashoatKeyserverID]: { ...state.keyserverStore.keyserverInfos[ashoatKeyserverID], cookie, }, }, }, }; }, [8]: async state => ({ ...state, globalThemeInfo: defaultGlobalThemeInfo, }), + [9]: async state => ({ + ...state, + keyserverStore: { + ...state.keyserverStore, + keyserverInfos: { + ...state.keyserverStore.keyserverInfos, + [ashoatKeyserverID]: { + ...state.keyserverStore.keyserverInfos[ashoatKeyserverID], + urlPrefix: keyserverURL, + }, + }, + }, + }), }; const persistWhitelist = [ 'enabledApps', 'cryptoStore', 'notifPermissionAlertInfo', 'commServicesAccessToken', 'keyserverStore', 'globalThemeInfo', ]; const rootKey = 'root'; const migrateStorageToSQLite: StorageMigrationFunction = async debug => { const databaseModule = await getDatabaseModule(); const isSupported = await databaseModule.isDatabaseSupported(); if (!isSupported) { return undefined; } const oldStorage = await getStoredState({ storage, key: rootKey }); if (!oldStorage) { return undefined; } purgeStoredState({ storage, key: rootKey }); if (debug) { console.log('redux-persist: migrating state to SQLite storage'); } // We need to simulate the keyserverStoreTransform for data stored in the // old local storage (because redux persist will only run it for the // sqlite storage which is empty in this case). // We don't just use keyserverStoreTransform.out(oldStorage) because // the transform might change in the future, but we need to treat // this code like migration code (it shouldn't change). if (oldStorage?._persist?.version === 4) { const defaultConnection = defaultConnectionInfo; return { ...oldStorage, keyserverStore: { ...oldStorage.keyserverStore, keyserverInfos: { ...oldStorage.keyserverStore.keyserverInfos, [ashoatKeyserverID]: { ...oldStorage.keyserverStore.keyserverInfos[ashoatKeyserverID], connection: { ...defaultConnection }, updatesCurrentAsOf: 0, sessionID: null, }, }, }, }; } return oldStorage; }; type PersistedKeyserverInfo = $Diff< KeyserverInfo, { +connection: ConnectionInfo, +updatesCurrentAsOf: number, +sessionID?: ?string, }, >; type PersistedKeyserverStore = { +keyserverInfos: { +[key: string]: PersistedKeyserverInfo }, }; const keyserverStoreTransform: Transform = createTransform( (state: KeyserverStore): PersistedKeyserverStore => { const keyserverInfos = {}; for (const key in state.keyserverInfos) { const { connection, updatesCurrentAsOf, sessionID, ...rest } = state.keyserverInfos[key]; keyserverInfos[key] = rest; } return { ...state, keyserverInfos, }; }, (state: PersistedKeyserverStore): KeyserverStore => { const keyserverInfos = {}; const defaultConnection = defaultConnectionInfo; for (const key in state.keyserverInfos) { keyserverInfos[key] = { ...state.keyserverInfos[key], connection: { ...defaultConnection }, updatesCurrentAsOf: 0, sessionID: null, }; } return { ...state, keyserverInfos, }; }, { whitelist: ['keyserverStore'] }, ); const persistConfig: PersistConfig = { key: rootKey, storage: commReduxStorageEngine, whitelist: isSQLiteSupported() ? persistWhitelist : [...persistWhitelist, 'draftStore'], migrate: (createAsyncMigrate( migrations, { debug: isDev }, migrateStorageToSQLite, ): any), - version: 8, + version: 9, transforms: [keyserverStoreTransform], }; export { persistConfig };