├── .dockerignore ├── .env ├── .env.defaults ├── .github └── workflows │ └── lint.yml ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── babel.config.json ├── bin └── install-admin-key.mjs ├── common └── scopes.js ├── dev-utils ├── common.mjs ├── controlled-account-token.mjs └── create-controlled-account.mjs ├── doc ├── ControlledAccounts.md └── OpenIdConnectProviders.md ├── index.js ├── migrate-mongo-config.js ├── migrations ├── .gitkeep ├── 20210430215117-hash_emails.js ├── 20220809181207-add_user_destinations_collections.js └── 20230104232611-consolidate-remote-clients.js ├── package-lock.json ├── package.json ├── secrets-template.json ├── src ├── adminApi.js ├── apex.js ├── auth │ ├── authdb.js │ ├── consts.js │ ├── index.js │ ├── oauthClient.js │ ├── oauthServer.js │ ├── openIdServer.js │ ├── openIdServerDb.js │ └── resourceServer.js ├── clientApi.js ├── cryptoUtils.js ├── media.js ├── migrate.js ├── openGraph.js ├── settings.js ├── streaming │ └── SocketManager.js └── utils.js ├── static ├── immers-context.json ├── immers_logo.png ├── reference-theme.css ├── twitter-player.html ├── vapor.png ├── vaporwave-icon.png └── welcome.html ├── views ├── admin │ ├── AddEditOauthClient.js │ ├── Admin.css │ ├── Admin.js │ ├── AdminNavigation.js │ ├── EditTheme.js │ ├── OauthAdmin.js │ ├── OauthClients.css │ └── OauthClients.js ├── ap.html ├── ap │ ├── Feed.js │ ├── Friends.css │ ├── Friends.js │ ├── ObjectView.js │ ├── Post.css │ ├── Post.js │ ├── Profile.css │ ├── Profile.js │ ├── ServerDataContext.js │ ├── Thread.js │ ├── ap.js │ └── utils │ │ ├── immersClient.js │ │ ├── useAsyncEffect.js │ │ └── useCheckAdmin.js ├── assets │ ├── immers.scss │ ├── immers_logo.png │ └── web95.css ├── auth.html ├── components │ ├── AvatarPreview.css │ ├── AvatarPreview.js │ ├── DialogModal.js │ ├── EmailInput.js │ ├── EmailOptIn.js │ ├── EmojiButton.css │ ├── EmojiButton.js │ ├── EmojiLink.js │ ├── Emojis.js │ ├── FormError.js │ ├── HandleInput.js │ ├── ImmerLink.css │ ├── ImmerLink.js │ ├── ImmersHandle.css │ ├── ImmersHandle.js │ ├── Layout.js │ ├── LayoutLoader.js │ ├── Loader.js │ ├── ModelPostBody.css │ ├── ModelPostBody.js │ ├── PasswordInput.js │ ├── PlacePostBody.js │ ├── ProfileIcon.css │ ├── ProfileIcon.js │ ├── ProviderLogin.js │ ├── SanitizedHTML.js │ ├── Tab.js │ └── TabContainer.js ├── dialog │ ├── dialog.css │ └── dialog.js ├── layout.njk ├── login │ └── login.js ├── oidc-interstitial │ └── oidc-interstitial.js └── reset │ └── reset.js └── webpack.config.js /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | config.json 4 | secrets.json 5 | certs 6 | .vscode 7 | .cache 8 | dist 9 | .parcel-cache 10 | .env 11 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | port=8081 2 | domain=localhost:8081 3 | hub=localhost:8080 4 | name=My Immer 5 | dbString=mongodb://localhost:27017/immers 6 | smtpHost=smtp.example.com 7 | smtpPort=587 8 | smtpUser=apikey 9 | keyPath=certs/server.key 10 | certPath=certs/server.cert 11 | googleFont=Monoton 12 | backgroundColor=#a6549d 13 | backgroundImage=vapor.png 14 | icon=vaporwave-icon.png 15 | imageAttributionText=Vectors by Vecteezy 16 | imageAttributionUrl=https://www.vecteezy.com/free-vector/vector 17 | sessionSecret=secret 18 | easySecret=supersecret 19 | systemUserName=immerser 20 | systemDisplayName=Immerser One 21 | welcome=welcome.html 22 | -------------------------------------------------------------------------------- /.env.defaults: -------------------------------------------------------------------------------- 1 | homepage= 2 | googleFont=Monoton 3 | backgroundColor=#a6549d 4 | backgroundImage=vapor.png 5 | baseTheme= 6 | customCSS= 7 | icon=vaporwave-icon.png 8 | imageAttributionText=Vectors by Vecteezy 9 | imageAttributionUrl=https://www.vecteezy.com/free-vector/vector 10 | maxUploadSize=20 11 | monetizationPointer= 12 | port=8081 13 | smtpFrom=noreply@mail.localhost 14 | passEmailToHub=false 15 | emailOptInURL= 16 | emailOptInParam= 17 | emailOptInNameParam= 18 | systemUserName= 19 | systemDisplayName= 20 | welcome= 21 | proxyMode= 22 | enablePublicRegistration=true 23 | enableClientRegistration=true 24 | cookieName=connect.sid 25 | loginRedirect= 26 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | run-linters: 13 | name: Run linters 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Check out Git repository 18 | uses: actions/checkout@v2 19 | 20 | - name: Set up Node.js 21 | uses: actions/setup-node@v1 22 | with: 23 | node-version: 18 24 | - name: Install Node.js dependencies 25 | run: npm ci 26 | 27 | - name: Run linters 28 | run: npm run lint 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | certs 3 | .vscode 4 | secrets.json 5 | config.json 6 | .cache 7 | dist 8 | .parcel-cache 9 | .env 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18 2 | WORKDIR /usr/src/immers 3 | 4 | COPY package*.json ./ 5 | RUN npm ci 6 | 7 | COPY . . 8 | RUN npm run build:client 9 | 10 | EXPOSE 443 80 11 | 12 | CMD [ "node", "index.js" ] 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The Immers Server 2 | 3 | [![Docker Image Version (latest semver)](https://img.shields.io/docker/v/immersspace/immers?label=Docker%20version)](https://hub.docker.com/r/immersspace/immers) 4 | [![Docker Pulls](https://img.shields.io/docker/pulls/immersspace/immers?label=Docker%20pulls)](https://hub.docker.com/r/immersspace/immers) 5 | [![Matrix](https://img.shields.io/matrix/immers-space:matrix.org?label=Matrix%20chat)](https://matrix.to/#/#immers-space:matrix.org) 6 | [![Open Collective](https://opencollective.com/immers-space/tiers/badge.svg)](https://opencollective.com/immers-space) 7 | 8 | Connect your WebXR project to the metaverse. A microsvervice that adds federated social features to any Immersive Web Project. 9 | 10 |
11 |
🔐 Secure user account registration
12 |
Provides sign up, password resets, and profile pages. Password saved with bcrypt, emails saved only as SHA-256 hashes
13 |
♻️ Reciproal OAuth2 authorization
14 |
Users can login to your site with an account from any immer, and your users can use their accounts to login at any other immer
15 |
🧑‍🤝‍🧑 Standards-based federated social features
16 |
Friends lists, messaging, and blocklists - not just between users of your site but across all immers and even other sites using ActivityPub like Mastodon
17 |
🥬 Organic discovery
18 |
Spread your project without any centralized indexer or algorithms. When an immerser visits your site, they share the link with all of their friends
19 |
20 | 21 | ## See a Demo 22 | 23 | Check out [Virtual Reign Immersive Chess](https://vreign.space/auth/login) - this Immers Server is connected to a chess 24 | game built on top of Mozilla Hubs. 25 | 26 | 27 | ## Get Started 28 | 29 | 1. Spin up your Immers Server using an [immers-app template](https://github.com/immers-space/immers-app) 30 | 2. Add the [immers-client](https://github.com/immers-space/immers-client) to your WebXR project to connect to it 31 | 3. [Join our community on Matrix](https://matrix.to/#/#immers-space:matrix.org) for help & discussion 32 | 4. [Join our Platform Cooperative](https://opencollective.com/immers-space/contribute/creator-member-33683) to guide the future of the project 33 | 34 | We provide a [Docker Hub image](https://hub.docker.com/repository/docker/immersspace/immers) for immers, 35 | and the [immers-app repo](https://github.com/immers-space/immers-app) contains 36 | docker-compose configuration, configuration script, and deploy instructions for various setups. 37 | If you prefer to run immers without docker, it can be deployed just like 38 | any other NodeJS & MongoDB app. 39 | 40 | If using with Hubs Cloud, but not using our docker config & Hubs deployer, 41 | [see Manual Hubs Cloud Config](#manual-hubs-cloud-config) section below. 42 | 43 | ## Configuration 44 | 45 | Immers looks for the following configuration values as environment variables 46 | or in a `.env` file in the project root. 47 | 48 | ## Required configuration 49 | 50 | Variable | Value | Example 51 | --- | --- | --- 52 | adminEmail | e-mail of user with administrative privileges | none 53 | name | Name of your immer | Immers Space 54 | domain | Domain name for your immers server | immers.space 55 | hub | Domain name for your Mozilla Hubs Cloud or other connected immersive experience. Can either be a single domain or comma separated list. Each domain listed will be enabled for CORS & trusted OAuth client requests. Users will be redirected to the first domain listed. | hub.immers.space 56 | dbString | Full MongoDB connection string, with credentials and database name, e.g. `mongodb://localhost:27017/immers` 57 | smtpHost | Mail service domain (for password resets) | smtp.sendgrid.net 58 | smtpPort | Mail service port | 587 59 | smtpUser | Mail service username | apikey 60 | smtpPassword | Mail service password | 61 | smtpClient | Mail OAuth service account id (instead of password) | 62 | smtpKey | Mail OAuth service account private key (instead of password) | 63 | sessionSecret | Secret key for session cookie encryption | *Automatically generated when [using setup script](https://github.com/immers-space/immers-app#step-1---setup)* 64 | easySecret | Secret key for email token encryption | *Automatically generated when [using setup script](https://github.com/immers-space/immers-app#step-1---setup)* 65 | userFiles | Path to storage location for user-uploaded files | `uploads/` 66 | 67 | 68 | ## Optional configuration 69 | 70 | Variable | Value | Default 71 | --- | --- | --- 72 | homepage | Redirect root html requests to this url | Use `hub` url 73 | googleFont | Font family name from to fetch from Google Fonts for immer name header | Monoton 74 | backgroundColor | CSS color | #a6549d 75 | backgroundImage | Image file | vapor.png 76 | baseTheme | `'light'` or `'dark'` | Follow user `prefers-color-scheme` setting, default to light 77 | customCSS | Additional CSS file to load | None 78 | icon | Image file | vaporwave-icon.png 79 | imageAttributionText | Attribution for backgroundImage, if needed | Vectors by Vecteezy 80 | imageAttributionUrl | Attribution for backgroundImage, if needed | https://www.vecteezy.com/free-vector/vector 81 | maxUploadSize | Limit on media upload file size in Mb | 20 82 | monetizationPointer | [Payment pointer](https://webmonetization.org/docs/ilp-wallets/#payment-pointers) for Web Monetization on login & profile pages | Immers Space organization wallet 83 | port | Port number for immers sever | 8081 84 | smtpFrom | From address for emails | noreply@mail.`domain` 85 | passEmailToHub | For apps that depend on user emails, this option will include the cleartext e-mail address in the initial token response (as an additional hash parameter named `email`) to the hub on registration so it can be saved and associated with the profile | `false` 86 | emailOptInURL | Link to an opt-in form for email updates to show on registration page | None 87 | emailOptInParam | Query parameter for `emailOptInURL` for the e-mail address | Use opt-in url without inserting e-mail 88 | emailOptInNameParam | Query parameter for `emailOptInURL` for the name | Use opt-in url without inserting name 89 | systemUserName | Username for a "Service" type actor representing the Immer, enables welcome messages and [Mastodon secure mode](https://docs.joinmastodon.org/spec/activitypub/#secure-mode) compatibility | none (does not create service actor) 90 | systemDisplayName | Sets the display name for the service actor | none 91 | welcome | HTML file for a message that will be delivered from the system user to new user's inboxes (requires `systemUserName`) | none (does not send message) 92 | proxyMode | Enable use behind an SSL-terminating proxy or load balancer, serves over http instead of https and sets Express `trust proxy` setting to the value of `proxyMode` (e.g. `1`, [other options](https://expressjs.com/en/guide/behind-proxies.html)) | none (serves over https with AutoEncrypt) 93 | enablePublicRegistration | Allow new user self-registration | true 94 | enableClientRegistration | Allow new remote immers servers to register - if this is `false`, users will not be able to login with their accounts from other servers unless that server is already registered | true 95 | cookieName | Changes the key associated with session cookies, useful to differentiate sessions if you have multiple immers servers on the same apex domain | `connect.sid` 96 | loginRedirect | Replace the immers login experience with your custom page ([details](./doc/ControlledAccounts.md#custom-login-redirect)) | none 97 | additionalContext | Filename to a json document within `static-ext` folder defining JSON-LD context extensions for custom activity types and properties | None 98 | 99 | **Notes on use with a reverse proxy**: When setting `proxyMode`, you must ensure your reverse proxy sets the following headers: X-Forwarded-For, X-Forwarded-Host, and X-Forwarded-Proto (example for nginx below). If you are load balancing multiple immers server instances, you will also need to setup sticky sessions in order for streaming updates to work. 100 | 101 | ``` 102 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 103 | proxy_set_header X-Forwarded-Host $host; 104 | proxy_set_header X-Forwarded-Proto $scheme; 105 | ``` 106 | 107 | ## API access 108 | 109 | Most API access will be done with the [immers-client](https://github.com/immers-space/immers-client) 110 | library on your immersive website, but the immers server also attempts to 111 | parse your `domain` option to set the login 112 | session cookie on the apex domain so that it can be used in CORS requests. 113 | As long as your immers server and immersive website are on the same apex domain, 114 | e.g. immers.space and hub.immers.space, then you can make authenticated requests 115 | with the `credentials: 'include'` fetch option. 116 | 117 | Restore session for previously logged in user: 118 | 119 | ```js 120 | let user 121 | const token = await fetch('https://your.immer/auth/token', { method: 'POST', credentials: 'include' }) 122 | .then(res => { 123 | if (!res.ok) { 124 | // 401 if not logged in 125 | return undefined 126 | } 127 | return res.text() 128 | }) 129 | if (token) { 130 | user = await window.fetch(`https://your.immer/auth/me`, { 131 | headers: { 132 | Accept: 'application/activity+json', 133 | Authorization: `Bearer ${token}` 134 | } 135 | }).then(res => res.json()); 136 | } 137 | ``` 138 | 139 | Log out of session without having to navigate to immers profile page: 140 | 141 | ```js 142 | fetch('https://your.immer/auth/logout', { method: 'POST', credentials: 'include' }) 143 | ``` 144 | 145 | ## Controlled Accounts 146 | 147 | If you have an existing user account system, you may not want to bother users with having 148 | another account for immers features. In this case, you can setup a service account 149 | with total authority to create users, login as them, and act on their behalf. 150 | 151 | [Controlled accounts docs](./doc/ControlledAccounts.md) 152 | 153 | 154 | ## Manual Hubs Cloud Config 155 | 156 | These steps are not necessary if you're using our docker Hubs deployer. 157 | If you aren't, you'll need to add the following in Hubs Cloud admin -> setup -> sever settings -> advanced 158 | 159 | 160 | * Extra room Header HTML: `` 161 | (replace value in content with your immers server url) 162 | * Extra Content Security Policy connect-src Rules: `https: wss:` 163 | (allows API and streaming connections to remote users home instances) 164 | * Allowed CORS origins: `*` 165 | (temporary measure cross-hub for avatar sharing) 166 | 167 | ## Local dev 168 | 169 | immers 170 | 171 | * Clone and install immers 172 | ``` 173 | git clone https://github.com/immers-space/immers.git 174 | cd immers 175 | npm ci 176 | ``` 177 | * Install [mongodb](https://docs.mongodb.com/manual/installation/) 178 | * Run immer with `npm run dev:server` 179 | 180 | hubs 181 | 182 | * Clone and install our fork - **Recommend Node 16 / NPM >=8** 183 | ``` 184 | git clone https://github.com/immers-space/hubs.git 185 | cd hubs 186 | git checkout immers-integration 187 | npm ci 188 | ``` 189 | * Run hub with either `npm run dev` (use Hubs dev networking servers) or `npm run start` (to connect to your hubs cloud networking server). 190 | * Visit you immer at `https://localhost:8081`, approve the certificate exception, get automatically forwarded to your hub at `https://localhost:8080`, approve another certificate exception, create a room, and you will be redirected to login or register with your immer. 191 | 192 | Default immers server is `https://localhost:8081`, override with entry `IMMERS_SERVER` in hubs repo root folder `.env` file. 193 | 194 | If running local hubs add a `.env` file with `HOST_IP=localhost`. 195 | 196 | If working on immers server web client, run both `npm run dev:client` and `npm run dev:server` at the same time. 197 | 198 | ### Creating a new release 199 | 200 | 1. Update `CHANGELOG.md` - Update top section header from "Unreleased" to "vx.x.x (yyyy-mm-dd)" with the version and date of the new release 201 | 2. Update package version: `npm version [patch|minor|major]` 202 | 3. Build new docker image: `npm run build:image` 203 | 4. Login to docker hub: `docker login -u your_user_name` (if needed) 204 | 5. Publish new docker image: `npm run publish:image` 205 | 6. Sync tag to github: `git push --follow-tags` 206 | 7. Cut a github release and autogenerate the notes 207 | 208 | ## Creator Members 209 | 210 | [![Creator members](https://opencollective.com/immers-space/tiers/creator-member.svg?avatarHeight=36&width=600)](https://opencollective.com/immers-space) 211 | 212 | 213 | ## Immerser Members 214 | 215 | [![Immerser members](https://opencollective.com/immers-space/tiers/immerser-member.svg?avatarHeight=36&width=600)](https://opencollective.com/immers-space) 216 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": ["defaults", "not ie 11"], 7 | "bugfixes": true, 8 | "useBuiltIns": "usage", 9 | "corejs": "3.21", 10 | "shippedProposals": true 11 | } 12 | ], 13 | "@babel/preset-react" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /bin/install-admin-key.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 'use strict' 3 | /** 4 | * This script can be used to generate and install 5 | * a service account keypair to enable Controlled Accounts feature. 6 | * See ../doc/ControlledAccounts.md 7 | */ 8 | import { appSettings } from '../src/settings' 9 | import crypto from 'crypto' 10 | import { MongoClient } from 'mongodb' 11 | const { mongoURI, name, domain, hubs } = appSettings 12 | 13 | const client = new MongoClient(mongoURI) 14 | let exitCode = 0 15 | try { 16 | await client.connect() 17 | } catch (err) { 18 | console.error('Unable to connect to database', err) 19 | process.exit(1) 20 | } 21 | try { 22 | const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { 23 | modulusLength: 4096, 24 | publicKeyEncoding: { 25 | type: 'pkcs1', 26 | format: 'pem' 27 | }, 28 | privateKeyEncoding: { 29 | type: 'pkcs1', 30 | format: 'pem' 31 | } 32 | }) 33 | await client.db().collection('clients').findOneAndUpdate({ 34 | clientId: `https://${domain}/o/immer` 35 | }, { 36 | $set: { 37 | name, 38 | clientId: `https://${domain}/o/immer`, 39 | redirectUri: `https://${hubs[0]}`, 40 | isTrusted: true, 41 | canControlUserAccounts: true, 42 | jwtPublicKeyPem: publicKey 43 | } 44 | }, { upsert: true }) 45 | console.log(privateKey) 46 | } catch (err) { 47 | exitCode = 1 48 | console.error('Error installing key', err) 49 | } finally { 50 | await client.close() 51 | } 52 | process.exit(exitCode) 53 | -------------------------------------------------------------------------------- /common/scopes.js: -------------------------------------------------------------------------------- 1 | // actual permissions scopes 2 | module.exports.scopes = { 3 | // level 0 scopes don't grant any more access than unauthorized requests on public routes 4 | viewProfile: { level: 0, description: ['Use your name and avatar'], name: 'viewProfile' }, 5 | viewPublic: { level: 0, description: ['View public posts in Immers Chat'], name: 'viewPublic' }, 6 | 7 | viewFriends: { level: 1, description: ['View your friends list'], name: 'viewFriends' }, 8 | postLocation: { level: 1, description: ['Share your presence here with friends'], name: 'postLocation' }, 9 | 10 | viewPrivate: { level: 2, description: ['View private posts in Immers chat'], name: 'viewPrivate' }, 11 | creative: { 12 | level: 2, 13 | description: [ 14 | 'Save changes to your name and avatar', 15 | 'Make posts in Immers chat and share selfies', 16 | 'Save new avatars and inventory items' 17 | ], 18 | name: 'creative' 19 | }, 20 | addFriends: { level: 2, description: ['Send and accept friend requests'], name: 'addFriends' }, 21 | addBlocks: { level: 2, description: ['Add to your blocklist'], name: 'addBlocks' }, 22 | 23 | destructive: { 24 | level: 3, 25 | description: [ 26 | 'Remove existing friends and blocks', 27 | 'Delete Immers chats, avatars, and inventory items' 28 | ], 29 | name: 'destructive' 30 | } 31 | } 32 | 33 | // common bundles of scopes 34 | module.exports.roles = [ 35 | { label: 'Just identity', name: 'public', level: 0 }, 36 | { label: 'Share location', name: 'friends', level: 1 }, 37 | { label: 'Social features', name: 'modAdditive', level: 2 }, 38 | { label: 'Account maintenance', name: 'modFull', level: 3 } 39 | ] 40 | -------------------------------------------------------------------------------- /dev-utils/common.mjs: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | import fs from 'fs' 3 | import jsonwebtoken from 'jsonwebtoken' 4 | import axios from 'axios' 5 | import https from 'https' 6 | import yargs from 'yargs' 7 | import { hideBin } from 'yargs/helpers' 8 | 9 | export function getPrivateKey (keyPath) { 10 | try { 11 | return fs.readFileSync(keyPath) 12 | } catch { 13 | return false 14 | } 15 | } 16 | 17 | export function getJwt (immerDomain, privateKey, payload = {}, options = {}) { 18 | const defaultOptions = { 19 | algorithm: 'RS256', 20 | expiresIn: '1h', 21 | issuer: `https://${immerDomain}/o/immer`, 22 | audience: `https://${immerDomain}/o/immer` 23 | } 24 | const jwt = jsonwebtoken.sign(payload, privateKey, { 25 | ...defaultOptions, 26 | ...options 27 | }) 28 | return jwt 29 | } 30 | 31 | export function getHttpClient (sslRequired) { 32 | return axios.create({ 33 | httpsAgent: new https.Agent({ 34 | rejectUnauthorized: sslRequired 35 | }) 36 | }) 37 | } 38 | 39 | export function logErrors (e) { 40 | console.error('Error Code: ', e.code) 41 | if (e.response) { 42 | console.error(`Server response: ${e.response.status}: ${e.response.statusText}`) 43 | console.error(`Error message: ${e.response.data?.error}`) 44 | if (e.response.data?.error_description) { 45 | console.error(`Error description: ${e.response.data.error_description}`) 46 | } 47 | } 48 | if (e.code === 'ECONNREFUSED') { 49 | console.error('Is the Immers server running?') 50 | } 51 | } 52 | 53 | export function getYargs (args) { 54 | return yargs(hideBin(args)) 55 | } 56 | -------------------------------------------------------------------------------- /dev-utils/controlled-account-token.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 'use strict' 3 | import { getPrivateKey, getJwt, getHttpClient, logErrors, getYargs } from './common.mjs' 4 | import { appSettings } from '../src/settings.js' 5 | 6 | const { domain, hubs } = appSettings 7 | const yargs = getYargs(process.argv) 8 | const argv = await yargs 9 | .default('ssl-check', true) 10 | .default('key', './immersAdminPrivateKey.pem') 11 | .option('immer-handle', { type: 'string', require: true }) 12 | .alias('i', 'immer-handle') 13 | .example('$0 --i "test[localhost:8081]" --ssl-check false') 14 | .argv 15 | const immersAdminPrivateKey = getPrivateKey(argv.key) 16 | if (!immersAdminPrivateKey) { 17 | console.error('Error reading key file.') 18 | process.exit(1) 19 | } 20 | const payload = { scope: '*', origin: hubs[0] } 21 | const options = { audience: `https://${domain}/o/immer`, subject: argv.i } 22 | const oAuthJwt = getJwt(domain, immersAdminPrivateKey, payload, options) 23 | 24 | const sslCheck = argv.sslCheck === true || argv.sslCheck.toLowerCase() === 'true' 25 | const httpClient = getHttpClient(sslCheck) 26 | await httpClient.post( 27 | `https://${domain}/auth/exchange`, 28 | new URLSearchParams({ 29 | grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', 30 | assertion: oAuthJwt 31 | }).toString() 32 | ).then(response => { 33 | const { access_token: accessToken, scope } = response.data 34 | console.log(`access_token: ${accessToken}`) 35 | console.log(`scope: ${scope}`) 36 | }).catch((e) => { 37 | logErrors(e) 38 | process.exit(1) 39 | }) 40 | -------------------------------------------------------------------------------- /dev-utils/create-controlled-account.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 'use strict' 3 | import { getPrivateKey, getJwt, getHttpClient, logErrors, getYargs } from './common.mjs' 4 | import { appSettings } from '../src/settings.js' 5 | 6 | const { domain } = appSettings 7 | const yargs = getYargs(process.argv) 8 | const argv = await yargs 9 | .default('ssl-check', true) 10 | .default('key', './immersAdminPrivateKey.pem') 11 | .option('username', { type: 'string', require: true }) 12 | .option('email', { type: 'string', require: true }) 13 | .alias('u', 'username') 14 | .alias('e', 'email') 15 | .argv 16 | const immersAdminPrivateKey = getPrivateKey(argv.key) 17 | if (!immersAdminPrivateKey) { 18 | console.error('Error reading key file.') 19 | process.exit(1) 20 | } 21 | const auth = getJwt(domain, immersAdminPrivateKey) 22 | const sslCheck = argv.sslCheck === true || argv.sslCheck.toLowerCase() === 'true' 23 | const httpClient = getHttpClient(sslCheck) 24 | await httpClient.post( 25 | `https://${domain}/auth/user`, 26 | { 27 | username: argv.username, 28 | email: argv.email 29 | }, 30 | { headers: { Authorization: `Bearer ${auth}` } } 31 | ).then(res => { 32 | console.log(`User successfully created: https://${domain}/u/${argv.username}`) 33 | }).catch((e) => { 34 | logErrors(e) 35 | process.exit(1) 36 | }) 37 | -------------------------------------------------------------------------------- /doc/ControlledAccounts.md: -------------------------------------------------------------------------------- 1 | # Controlled Accounts 2 | 3 | If you have an existing user account system, you may not want to bother users with having 4 | another account for immers features. In this case, you can setup a service account 5 | with total authority to create users, login as them, and act on their behalf. 6 | This way, the immers accounts can be totally transparent to users who will interact 7 | only with your account system. This is accomplished with a "2-legged OAuth" approach, 8 | using a service account that can exchange a JWT for an access token for any user. 9 | 10 | ## Activate feature 11 | 12 | First you must activate the feature by setting `canControlUserAccounts: true` and installing 13 | a `jwtPublicKeyPem` on a `client` record. We provide a script that does this for the 14 | system client and outputs the private key that you need to save. 15 | 16 | ```bash 17 | ./bin/install-admin-key.mjs > immersAdminPrivateKey.pem 18 | # or, if using an immers-app docker config 19 | docker-compose exec immer /usr/src/immers/bin/install-admin-key.mjs > immersAdminPrivateKey.pem 20 | ``` 21 | 22 | ## (server-side only) Create user accounts 23 | 24 | User accounts can be created programatically without passwords (disabling direct login), and you'll probably want to disable user 25 | self-registration on your Immers server so that all sign-ups go 26 | through your primary account system. 27 | 28 | In your `.env` add: 29 | 30 | ``` 31 | enablePublicRegistration=false 32 | ``` 33 | 34 | From your application server, make an authenticated POST 35 | to the user endpoint to register accounts. 36 | Your admin private key must be kept private on your server and never sent to the client. 37 | 38 | ```js 39 | const { readFileSync } = require('fs') 40 | const jwt = require('jsonwebtoken') 41 | const axios = require('axios') // any request library will do 42 | const immersAdminPrivateKey = readFileSync('immersAdminPrivateKey.pem') 43 | const auth = jwt.sign({}, immersAdminPrivateKey, { 44 | algorithm: "RS256", 45 | issuer: `https://yourDomain.com/o/immer`, 46 | audience: `https://yourDomain.com/o/immer`, 47 | expiresIn: "1h" 48 | }) 49 | axios.post( 50 | `https://${immerDomain}/auth/user`, 51 | { 52 | username, 53 | email 54 | }, 55 | { headers: { Authorization: `Bearer ${auth}` } } 56 | ) 57 | ``` 58 | 59 | ## (server-side only) Exchange service JWT for user access token 60 | 61 | Your admin private key must be kept private on your server and never sent to the client. 62 | Sign a JWT and send it as a OAuth2 token exchange to retrieve a user access token. 63 | 64 | ```js 65 | const { readFileSync } = require('fs') 66 | const jwt = require('jsonwebtoken') 67 | const axios = require('axios') // any request library will do 68 | const immersAdminPrivateKey = readFileSync('immersAdminPrivateKey.pem') 69 | const oAuthJwt = jwt.sign( 70 | { 71 | scope: "*", 72 | origin: "https://hub.yourDomain.com" // make this match the origin where the tokens will be used 73 | }, 74 | immersAdminPrivateKey, 75 | { 76 | algorithm: "RS256", 77 | issuer: `https://yourDomain.com/o/immer`, 78 | audience: `https://yourDomain.com/o/immer`, 79 | expiresIn: "1h", 80 | subject: "user[yourdomain.dom]" // the user that you will login as 81 | } 82 | ); 83 | axios.post( 84 | `https://yourDomain.com/auth/exchange`, 85 | new url.URLSearchParams({ 86 | grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", 87 | assertion: oAuthJwt 88 | }).toString() 89 | ).then(response => { 90 | const { access_token, scope } = response.data 91 | // send token to front-end 92 | }); 93 | ``` 94 | 95 | The response from the exchange request will be 96 | 97 | ``` 98 | application/json { access_token, scope } 99 | ``` 100 | 101 | Send the token and scope back your client, and then use it to login to the Immers Client 102 | 103 | ```js 104 | const success = await immersClient.loginWithToken(access_token, "yourDomain.com", scope) 105 | ``` 106 | 107 | ## Logging in a controlled account 108 | 109 | For controlled accounts to be able to use their identities on other federated immers servers, 110 | users will need to be able to create a logged-in session. A simple option is to allow 111 | users to set a password after controlled account creation using the existing forgot password 112 | flow - this will work automatically as long as your config inluduces SMTP mail settings. 113 | For a more seamless experience, you can also log users in by using your service account credentials. 114 | 115 | **Requirements:** 116 | * Your application API server and your immers server must be on the same apex domain 117 | * If your application API server is not on the same origin as your web application, it must 118 | set CORS headers `Access-Control-Allow-Origin` (specific origin required, not just `*`), and 119 | `Access-Control-Allow-Credentials: true` 120 | * If your application API server is not on the same origin as your web application, 121 | the client side fetch must specify `credentials: include` 122 | 123 | ### Server-side 124 | 125 | ```js 126 | const { readFileSync } = require('fs') 127 | const jwt = require('jsonwebtoken') 128 | const cors = require("cors") 129 | const axios = require('axios') // any request library will do 130 | const immersAdminPrivateKey = readFileSync('immersAdminPrivateKey.pem') 131 | 132 | const proxyLogin = [ 133 | cors({ 134 | origin: 'https://hub.yourdomain.com', // make this match the origin of your web application 135 | credentials: true 136 | }), 137 | yourUserAuthenticationMiddleware, 138 | (req, res, next) => { 139 | const oAuthJwt = jwt.sign( 140 | { 141 | scope: "*", 142 | origin: "https://hub.yourDomain.com" // make this match the origin where the tokens will be used 143 | }, 144 | immersAdminPrivateKey, 145 | { 146 | algorithm: "RS256", 147 | issuer: `https://yourDomain.com/o/immer`, 148 | audience: `https://yourDomain.com/o/immer`, 149 | expiresIn: "1h", 150 | subject: "user[yourdomain.dom]" // the authenticated user that you will login as 151 | } 152 | ) 153 | axios({ 154 | method: "post", 155 | url: `https://yourDomain.com/auth/login`, 156 | headers: { Authorization: `Bearer ${oAuthJwt}` } 157 | }).then(response => { 158 | // forward the login session cookie 159 | res.set("Set-Cookie", response.headers["set-cookie"]); 160 | res.sendStatus(200); 161 | }).catch(next) 162 | } 163 | ] 164 | ``` 165 | 166 | ### Client-side 167 | ```js 168 | fetch('https://application-api-server.com/proxy-login', { 169 | method: 'POST', 170 | credentials: 'include', 171 | // also include credentials necessary to authenticate this user 172 | }) 173 | ``` 174 | 175 | ### Custom login redirect 176 | 177 | While proxy-login above will allow users to seamlessly use 178 | their controlled account identities on other immers-enabled sites, 179 | in the off chance they attempt to use that identity in a browser 180 | where they haven't arleady completed a proxy login, you can 181 | customize the login experience they will see in that flow 182 | by redirecting to you app with the `loginRedirect` config setting. 183 | 184 | Set it to the url of your login experience, 185 | e.g. `loginRedirect=https://yourdomain.com/login`, 186 | and users will see this page instead of the Immers Server login page 187 | when a login is required to complete an authorization request. 188 | 189 | In your app's login flow, you must perform a proxy login as described 190 | above and check for a `redirectAfterLogin` 191 | query parameter on the login page and redirect there after a 192 | successful login. 193 | 194 | ``` 195 | // after successful login & proxy login 196 | const searchParams = new URLSearchParams(window.location.search); 197 | const redirect = searchParams.get("redirectAfterLogin"); 198 | if (searchParams.has("redirectAfterLogin")) { 199 | // return to interrupted OAuth authorization flow 200 | window.location.href = searchParams.get("redirectAfterLogin"); 201 | } 202 | ``` 203 | -------------------------------------------------------------------------------- /doc/OpenIdConnectProviders.md: -------------------------------------------------------------------------------- 1 | # Setting up OpenID Connect Providers 2 | 3 | This describes setup for OpenID identity providers - services that 4 | can verify user identity to provide a means of logging in other than 5 | entering a password on your immer. These users will still get accounts 6 | on your immer and their immers handle will be on your domain. 7 | 8 | For providers that support 9 | [Dyanmic Client Registration](https://openid.net/specs/openid-connect-registration-1_0.html), 10 | no action is needed. 11 | This providers will be configured automatically the first time a user enters 12 | their domain in the login screen. For providers requiring manual client 13 | registration, follow these steps. 14 | 15 | ### 1. Manual sign up with the provider 16 | 17 | Follow providers instructions to create a client. 18 | 19 | **For your redirect_uri**, register `https://yourdomain.com/auth/return` 20 | 21 | Record the following for later entry: `client_id`, `client_secret`, 22 | and the provider domain (or discovery document url) 23 | 24 | 25 | 26 | ### 2. Create admin user in Immers 27 | 28 | If you haven't already made an admin user, update your .env file to include 29 | `adminEmail=youremail@domain.com` and restart the server (`docker-compose up -d`). 30 | The user account registered with that email will be update to administrator if it exists 31 | or will be an administrator upon registration if it does not yet exist. 32 | 33 | ### 3. Enter client info in Immers admin interface 34 | 35 | 1. Login to your Immers Server as the admin user. 36 | 2. From your profile, click the 👸 icon in the system tray or naviate to 37 | `/admin` on your domain to view the admin interface. 38 | 3. Click "Add OpenID Connect Client" 39 | 4. Enter a name to recognize this client 40 | 5. Enter provider domain, client id, and client secret from above 41 | 6. (optional) Fill out "Optional Login Button" section to add button to login screen 42 | 43 | For providers that support 44 | [OpenID Provider Issuer Discovery](https://openid.net/specs/openid-connect-discovery-1_0.html#IssuerDiscovery), 45 | the login button is optional as the provider can be discovered from the users handle. 46 | For other providers, complete this section to give them their own login button. 47 | Icons uploaded to your static folder 48 | (`~/immers` when using [Immers App](https://github.com/immers-space/immers-app)) 49 | can be referenced as `/static/filename.png`. 50 | 51 | ### Finished 52 | 53 | User can now use their OpenID Connect provider to login to your immer! 54 | After their first login, they'll be prompted to choose a username and 55 | an immerser account will be created for them on your immer. 56 | -------------------------------------------------------------------------------- /migrate-mongo-config.js: -------------------------------------------------------------------------------- 1 | const { mongoURI } = require('./src/settings').appSettings 2 | 3 | const config = { 4 | mongodb: { 5 | // the default below is overriden with config when migrate is called from index.js 6 | url: mongoURI, 7 | 8 | options: { 9 | useNewUrlParser: true, // removes a deprecation warning when connecting 10 | useUnifiedTopology: true // removes a deprecating warning when connecting 11 | // connectTimeoutMS: 3600000, // increase connection timeout to 1 hour 12 | // socketTimeoutMS: 3600000, // increase socket timeout to 1 hour 13 | } 14 | }, 15 | 16 | // The migrations dir, can be an relative or absolute path. Only edit this when really necessary. 17 | migrationsDir: 'migrations', 18 | 19 | // The mongodb collection where the applied changes are stored. Only edit this when really necessary. 20 | changelogCollectionName: 'changelog', 21 | 22 | // The file extension to create migrations and search for in migration dir 23 | migrationFileExtension: '.js' 24 | } 25 | 26 | // Return the config as a promise 27 | module.exports = config 28 | -------------------------------------------------------------------------------- /migrations/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/immers-space/immers/54881f9fec74e9427f1b60898e2e0949ea3b4215/migrations/.gitkeep -------------------------------------------------------------------------------- /migrations/20210430215117-hash_emails.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto') 2 | 3 | module.exports = { 4 | async up (db, client) { 5 | const users = await db.collection('users') 6 | .find({}) 7 | .project({ _id: 1, email: 1 }) 8 | .toArray() 9 | const session = client.startSession() 10 | try { 11 | await session.withTransaction(() => { 12 | return Promise.all(users.map(async user => { 13 | if (!user.email) { 14 | return 15 | } 16 | return db.collection('users').updateOne({ _id: user._id }, { 17 | $set: { 18 | email: crypto.createHash('sha256').update(user.email.toLowerCase()).digest('base64') 19 | } 20 | }) 21 | })) 22 | }) 23 | } finally { 24 | await session.endSession() 25 | } 26 | }, 27 | 28 | async down () { 29 | // can't undo this; that's the point :) 30 | return Promise.resolve('ok') 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /migrations/20220809181207-add_user_destinations_collections.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | require('dotenv-defaults').config() 3 | const { apex, refreshAndUpdateActorObject } = require('../src/apex') 4 | 5 | const { domain } = process.env 6 | const destinationsPrefix = `https://${domain}/u/` 7 | 8 | module.exports = { 9 | async up (db, client) { 10 | apex.store.db = db 11 | apex.offlineMode = true // outbox items are queued but not delivered 12 | 13 | const session = client.startSession() 14 | try { 15 | await session.withTransaction(async () => { 16 | // add destinations & friendsDestinations to actor.streams for existing users 17 | const usersCursor = db.collection('users') 18 | .find({}) 19 | .project({ _id: 0, username: 1 }) 20 | for await (const user of usersCursor) { 21 | await refreshAndUpdateActorObject(user) 22 | } 23 | // add existing Arrive activitities to destinations/friendsDestinations collections 24 | const arrivesCursor = db.collection('streams').find({ type: 'Arrive' }) 25 | for await (const arrive of arrivesCursor) { 26 | const newCols = [] 27 | arrive._meta.collection?.forEach(col => { 28 | const colInfo = apex.utils.iriToCollectionInfo(col) 29 | if (colInfo?.name === 'inbox') { 30 | // for every inbox collection, add a friendsDestinations collection 31 | newCols.push(`https://${domain}/u/${colInfo.actor}/friends-destinations`) 32 | } else if (colInfo?.name === 'outbox') { 33 | // for every outbox collection, add a destinations collection 34 | newCols.push(`https://${domain}/u/${colInfo.actor}/destinations`) 35 | } 36 | }) 37 | if (newCols.length) { 38 | await apex.store.updateActivityMeta(arrive, 'collection', { $each: newCols }) 39 | } 40 | } 41 | }) 42 | } finally { 43 | await session.endSession() 44 | } 45 | }, 46 | 47 | async down (db, client) { 48 | apex.store.db = db 49 | apex.offlineMode = true // outbox items are queued but not delivered 50 | 51 | const session = client.startSession() 52 | try { 53 | await session.withTransaction(async () => { 54 | const usersCursor = db.collection('users') 55 | .find({}) 56 | .project({ _id: 0, username: 1 }) 57 | for await (const user of usersCursor) { 58 | await db.collection('objects') 59 | .updateOne({ id: apex.utils.usernameToIRI(user.username) }, { $unset: { destinations: '', friendsDestinations: '' } }) 60 | } 61 | // remove Arrive activitities from destinations/friendsDestinations collections 62 | const arrivesCursor = db.collection('streams').find({ type: 'Arrive' }) 63 | for await (const arrive of arrivesCursor) { 64 | const oldCols = [] 65 | arrive._meta.collection?.forEach(col => { 66 | if ( 67 | col.startsWith(destinationsPrefix) && 68 | (col.endsWith('destinations') || col.endsWith('friends-destinations')) 69 | ) { 70 | oldCols.push(col) 71 | } 72 | }) 73 | if (oldCols.length) { 74 | await apex.store.updateActivityMeta(arrive, 'collection', { $in: oldCols }, true) 75 | } 76 | } 77 | }) 78 | } finally { 79 | await session.endSession() 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /migrations/20230104232611-consolidate-remote-clients.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { CLIENT_TYPES } = require('../src/auth/consts') 4 | const REMOTES = 'remotes' 5 | const OIDC_REMOTES = 'oidcRemoteClients' 6 | const NEW_REMOTES = 'remoteClients' 7 | 8 | module.exports = { 9 | /** 10 | * @param {import('mongodb').Db} db 11 | * @param {import('mongodb').MongoClient} client 12 | */ 13 | async up (db, client) { 14 | const immerClients = await db.collection(REMOTES).find({}).project({ _id: 0 }).toArray() 15 | const oidcClients = await db.collection(OIDC_REMOTES).find({}).project({ _id: 0 }).toArray() 16 | const remoteClients = [] 17 | let anyDomainConflicts = false 18 | immerClients.forEach(immerClient => { 19 | const { domain, ...client } = immerClient 20 | remoteClients.push({ 21 | type: CLIENT_TYPES.IMMERS, 22 | domain, 23 | client 24 | }) 25 | }) 26 | oidcClients.forEach(oidcClient => { 27 | oidcClient.type = CLIENT_TYPES.OIDC 28 | if (remoteClients.find(client => client.domain === oidcClient.domain)) { 29 | anyDomainConflicts = true 30 | console.warn('Could not migrate OIDC client due to duplicate domain', JSON.stringify(oidcClient)) 31 | return 32 | } 33 | remoteClients.push(oidcClient) 34 | }) 35 | if (!remoteClients.length) { 36 | // no work to do 37 | return 38 | } 39 | const session = client.startSession() 40 | try { 41 | await session.withTransaction(async () => { 42 | await db.collection(NEW_REMOTES).createIndex({ 43 | domain: 1 44 | }, { 45 | unique: true 46 | }) 47 | await db.collection(NEW_REMOTES).insertMany(remoteClients) 48 | 49 | await dropIfExists(db.collection(REMOTES)) 50 | if (anyDomainConflicts) { 51 | console.warn(`Not dropping ${OIDC_REMOTES} collection as some clients could not be migrated.`) 52 | } else { 53 | await dropIfExists(db.collection(OIDC_REMOTES)) 54 | } 55 | }) 56 | } finally { 57 | await session.endSession() 58 | } 59 | }, 60 | 61 | async down (db, client) { 62 | const remoteClients = await db.collection(NEW_REMOTES).find({}).project({ _id: 0 }).toArray() 63 | const remotes = [] 64 | const oidcRemoteClients = [] 65 | let anyNewClientTypes = false 66 | remoteClients.forEach(remoteClient => { 67 | switch (remoteClient.type) { 68 | case CLIENT_TYPES.IMMERS: { 69 | const { domain, client } = remoteClient 70 | remotes.push({ 71 | domain, 72 | ...client 73 | }) 74 | break 75 | } 76 | case CLIENT_TYPES.OIDC: { 77 | delete remoteClient.type 78 | oidcRemoteClients.push(remoteClient) 79 | break 80 | } 81 | default: 82 | anyNewClientTypes = true 83 | console.warn('cannot migrate unsupported client type', JSON.stringify(remoteClient)) 84 | } 85 | }) 86 | const session = client.startSession() 87 | try { 88 | await session.withTransaction(async () => { 89 | await db.collection(REMOTES).createIndex({ 90 | domain: 1 91 | }, { 92 | unique: true 93 | }) 94 | await db.collection(OIDC_REMOTES).createIndex({ 95 | domain: 1 96 | }, { 97 | unique: true 98 | }) 99 | if (remotes.length) { 100 | await db.collection(REMOTES).insertMany(remotes) 101 | } 102 | if (oidcRemoteClients.length) { 103 | await db.collection(OIDC_REMOTES).insertMany(oidcRemoteClients) 104 | } 105 | if (anyNewClientTypes) { 106 | console.warn(`Not dropping ${NEW_REMOTES} as some clients could not be migrated`) 107 | } else { 108 | await dropIfExists(db.collection(NEW_REMOTES)) 109 | } 110 | }) 111 | } finally { 112 | await session.endSession() 113 | } 114 | } 115 | } 116 | 117 | /** 118 | * @param {import('mongodb').Collection} col 119 | */ 120 | function dropIfExists (col) { 121 | return col.drop().catch(err => { 122 | if (!err.message.match(/ns not found/)) { 123 | throw err 124 | } 125 | }) 126 | } 127 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "immers", 3 | "version": "6.0.3", 4 | "description": "ActivityPub server for the metaverse", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1", 7 | "lint": "standard", 8 | "dev:server": "webpack build --mode development && cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 node index.js", 9 | "dev:client": "webpack watch --mode development", 10 | "build:client": "webpack build --mode production --devtool source-map", 11 | "start": "cross-env NODE_ENV=production node index.js", 12 | "build:image": "docker build -t immersspace/immers .", 13 | "publish:image": "docker tag immersspace/immers:latest immersspace/immers:v$npm_package_version && docker push immersspace/immers:latest && docker push immersspace/immers:v$npm_package_version" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/wmurphyrd/immers.git" 18 | }, 19 | "keywords": [ 20 | "ActivityPub", 21 | "activitypub-express", 22 | "decentralized", 23 | "virtual", 24 | "reality", 25 | "WebXR" 26 | ], 27 | "author": "Will Murphy", 28 | "license": "AGPL-3.0-or-later", 29 | "bugs": { 30 | "url": "https://github.com/wmurphyrd/immers/issues" 31 | }, 32 | "homepage": "https://github.com/wmurphyrd/immers#readme", 33 | "dependencies": { 34 | "@authenio/samlify-node-xmllint": "^2.0.0", 35 | "@peculiar/x509": "^1.9.2", 36 | "@small-tech/auto-encrypt": "^3.1.0", 37 | "activitypub-express": "^4.4.2", 38 | "aesthetic-css": "^1.0.1", 39 | "bcrypt": "^5.0.1", 40 | "connect-ensure-login": "^0.1.1", 41 | "connect-history-api-fallback": "^2.0.0", 42 | "connect-mongodb-session": "^3.1.1", 43 | "cookie-parser": "^1.4.6", 44 | "cors": "^2.8.5", 45 | "cross-env": "^7.0.3", 46 | "dotenv-defaults": "^5.0.0", 47 | "easy-no-password": "^1.2.2", 48 | "email-validator": "^2.0.4", 49 | "express": "^4.17.3", 50 | "express-session": "^1.17.2", 51 | "jsonwebtoken": "^9.0.0", 52 | "migrate-mongo": "^8.2.3", 53 | "mongodb": "^4.4.1", 54 | "morgan": "^1.10.0", 55 | "multer": "^1.4.5-lts.1", 56 | "multer-gridfs-storage": "^5.0.2", 57 | "node-graceful-shutdown": "^1.1.0", 58 | "nodemailer": "^6.7.2", 59 | "nunjucks": "^3.2.3", 60 | "oauth2orize": "^1.11.1", 61 | "oauth2orize-jwt-bearer": "^0.2.0", 62 | "oidc-provider": "^7.11.3", 63 | "openid-client": "^5.1.7", 64 | "overlaps": "^1.0.0", 65 | "parse-domain": "^5.0.0", 66 | "passport": "^0.6.0", 67 | "passport-anonymous": "^1.0.1", 68 | "passport-custom": "^1.1.1", 69 | "passport-http-bearer": "^1.0.1", 70 | "passport-local": "^1.0.0", 71 | "request": "^2.88.2", 72 | "request-promise-native": "^1.0.9", 73 | "rimraf": "^5.0.1", 74 | "samlify": "^2.8.7", 75 | "socket.io": "^4.4.1", 76 | "textversionjs": "^1.1.3", 77 | "uid-safe": "^2.1.5" 78 | }, 79 | "devDependencies": { 80 | "@babel/core": "^7.17.9", 81 | "@babel/preset-env": "^7.16.11", 82 | "@babel/preset-react": "^7.16.7", 83 | "@emoji-mart/data": "^1.0.6", 84 | "@emoji-mart/react": "^1.0.1", 85 | "@picocss/pico": "^1.5.7", 86 | "axios": "^1.4.0", 87 | "babel-loader": "^9.1.2", 88 | "classnames": "^2.3.1", 89 | "core-js": "^3.21.1", 90 | "css-loader": "^6.7.1", 91 | "dompurify": "^3.0.3", 92 | "emoji-mart": "^5.2.2", 93 | "file-loader": "^6.2.0", 94 | "html-webpack-plugin": "^5.5.0", 95 | "immers-client": "^2.13.2", 96 | "mini-css-extract-plugin": "^2.6.0", 97 | "react": "^18.2.0", 98 | "react-dom": "^18.2.0", 99 | "react-intl": "^6.4.2", 100 | "react-router": "^6.4.2", 101 | "react-router-dom": "^6.4.2", 102 | "sass": "^1.50.0", 103 | "sass-loader": "^13.2.2", 104 | "standard": "^17.0.0-2", 105 | "style-loader": "^3.3.1", 106 | "webpack": "^5.72.0", 107 | "webpack-cli": "^5.1.1", 108 | "yargs": "^17.5.1" 109 | }, 110 | "overrides": { 111 | "multer": "^1.4.5-lts.1", 112 | "debug": "4.3.4" 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /secrets-template.json: -------------------------------------------------------------------------------- 1 | { 2 | "sessionSecret": "", 3 | "easySecret": "", 4 | "smtpUser": "", 5 | "smtpPassword": "" 6 | } 7 | -------------------------------------------------------------------------------- /src/adminApi.js: -------------------------------------------------------------------------------- 1 | const { Router } = require('express') 2 | const { Issuer } = require('openid-client') 3 | const saml = require('samlify') 4 | const request = require('request-promise-native') 5 | const { apex } = require('./apex') 6 | const { authdb } = require('./auth') 7 | const auth = require('./auth') 8 | const { appSettings, updateThemeSettings } = require('./settings') 9 | const { CLIENT_TYPES } = require('./auth/consts') 10 | const router = new Router() 11 | const ObjectId = require('mongodb').ObjectId 12 | 13 | const { domain } = appSettings 14 | 15 | module.exports = { 16 | router 17 | } 18 | 19 | router.get('/a/is-admin', [ 20 | auth.admn, 21 | (req, res) => { 22 | res.json({ isAdmin: true }) 23 | } 24 | ]) 25 | 26 | router.get('/a/oauth-clients', [ 27 | auth.admn, 28 | getOauthClients 29 | ]) 30 | 31 | router.post('/a/oauth-clients', [ 32 | auth.admn, 33 | postOauthClient 34 | ]) 35 | 36 | router.delete('/a/oauth-clients', [ 37 | auth.admn, 38 | deleteOauthClient 39 | ]) 40 | 41 | router.get('/a/oauth-client/:id', [ 42 | auth.admn, 43 | getOauthClient 44 | ]) 45 | 46 | router.put('/a/oauth-client/:id', [ 47 | auth.admn, 48 | updateOauthClient 49 | ]) 50 | 51 | router.put('/a/settings/theme', [ 52 | auth.admn, 53 | putThemeSettings 54 | ]) 55 | 56 | const clientProjection = { 57 | type: 1, 58 | name: 1, 59 | domain: 1, 60 | 'client.client_id': 1, 61 | 'issuer.isAssertionEncrypted': 1, 62 | 'issuer.wantLogoutRequestSigned': 1, 63 | 'issuer.messageSigningOrder': 1, 64 | showButton: 1, 65 | buttonIcon: 1, 66 | buttonLabel: 1, 67 | usernameTemplate: 1 68 | } 69 | 70 | async function getOauthClients (req, res) { 71 | const remoteClients = await apex.store.db.collection('remoteClients') 72 | .find({}, { projection: clientProjection }) 73 | .toArray() 74 | res.json(remoteClients.map(toFrontEndClientFormat)) 75 | } 76 | 77 | async function postOauthClient (req, res) { 78 | let clientData 79 | try { 80 | clientData = await processClientFromFrontEnd(req.body) 81 | } catch (err) { 82 | return res.json({ success: false, step: 'discovery', error: err.toString() }) 83 | } 84 | try { 85 | const { domain: providerDomain, type, issuer, client, metadata } = clientData 86 | await authdb.saveRemoteClient(providerDomain, type, issuer, client, metadata) 87 | return res.json({ success: true }) 88 | } catch (err) { 89 | if (err.name === 'MongoServerError' && err.code === 11000) { 90 | return res.json({ success: false, step: 'domain', error: `Provider domain, ${clientData.domain}, already registered` }) 91 | } 92 | return res.sendStatus(500) 93 | } 94 | } 95 | 96 | async function deleteOauthClient (req, res) { 97 | try { 98 | await apex.store.db.collection('remoteClients').deleteOne({ 99 | _id: ObjectId(req.body.id) 100 | }) 101 | return res.json({ success: true }) 102 | } catch (err) { return res.sendStatus(500) } 103 | } 104 | 105 | async function getOauthClient (req, res) { 106 | const remoteClients = await apex.store.db.collection('remoteClients') 107 | .findOne({ _id: ObjectId(req.params.id) }, { projection: clientProjection }) 108 | res.json(toFrontEndClientFormat(remoteClients)) 109 | } 110 | 111 | async function updateOauthClient (req, res) { 112 | try { 113 | const update = { 114 | name: req.body.name, 115 | showButton: req.body.showButton, 116 | buttonIcon: req.body.buttonIcon, 117 | buttonLabel: req.body.buttonLabel, 118 | usernameTemplate: req.body.usernameTemplate 119 | } 120 | // OIDC updates 121 | if (req.body.clientId) { 122 | update['client.client_id'] = req.body.clientId 123 | } 124 | if (req.body.clientSecret) { 125 | update['client.client_secret'] = req.body.clientSecret 126 | } 127 | // SAML udpates 128 | if (req.body.isAssertionEncrypted != null) { 129 | update['issuer.isAssertionEncrypted'] = req.body.isAssertionEncrypted 130 | } 131 | if (req.body.wantLogoutRequestSigned != null) { 132 | update['issuer.wantLogoutRequestSigned'] = req.body.wantLogoutRequestSigned 133 | } 134 | if (req.body.messageSigningOrder) { 135 | update['issuer.messageSigningOrder'] = req.body.messageSigningOrder 136 | } 137 | await apex.store.db.collection('remoteClients').updateOne( 138 | { _id: ObjectId(req.params.id) }, 139 | { $set: update } 140 | ) 141 | return res.json({ success: true }) 142 | } catch (err) { return res.sendStatus(500) } 143 | } 144 | 145 | function putThemeSettings (req, res, next) { 146 | const { 147 | baseTheme, 148 | customTheme 149 | } = req.body 150 | updateThemeSettings(apex.store.db, { baseTheme, customTheme }) 151 | .then(() => res.sendStatus(200)) 152 | .catch(next) 153 | } 154 | 155 | /// utils /// 156 | function toFrontEndClientFormat (dbClient) { 157 | const { _id, type, name, domain: providerDomain, showButton, buttonIcon, buttonLabel, client, issuer, usernameTemplate } = dbClient 158 | return { 159 | _id, 160 | type, 161 | name, 162 | domain: providerDomain, 163 | showButton, 164 | buttonIcon, 165 | buttonLabel, 166 | usernameTemplate, 167 | clientId: client?.client_id, 168 | isAssertionEncrypted: issuer?.isAssertionEncrypted, 169 | wantLogoutRequestSigned: issuer?.wantLogoutRequestSigned, 170 | messageSigningOrder: issuer?.messageSigningOrder 171 | } 172 | } 173 | 174 | async function processClientFromFrontEnd (data) { 175 | const { type, name, domain: providerDomain, showButton, buttonIcon, buttonLabel, usernameTemplate } = data 176 | const metadata = { name, showButton, buttonIcon, buttonLabel, usernameTemplate } 177 | let cleanProviderDomain 178 | let issuer 179 | let client 180 | if (type === CLIENT_TYPES.OIDC) { 181 | const { clientId, clientSecret } = data 182 | const providerOriginOrDisdoveryUrl = providerDomain.includes('://') ? providerDomain : `https://${providerDomain}` 183 | const oidcIssuer = await Issuer.discover(providerOriginOrDisdoveryUrl) 184 | const oidcClient = new oidcIssuer.Client({ 185 | client_id: clientId, 186 | client_secret: clientSecret, 187 | redirect_uris: [`https://${domain}/auth/return`], 188 | response_types: ['code'] 189 | }) 190 | issuer = oidcIssuer.metadata 191 | client = oidcClient.metadata 192 | cleanProviderDomain = new URL(providerOriginOrDisdoveryUrl).host 193 | } else if (type === CLIENT_TYPES.SAML) { 194 | const { isAssertionEncrypted, wantLogoutRequestSigned, messageSigningOrder } = data 195 | const metadata = data.metadata || await request(data.domain) 196 | issuer = { metadata, isAssertionEncrypted, wantLogoutRequestSigned, messageSigningOrder } 197 | const testIdP = new saml.IdentityProvider(issuer) 198 | cleanProviderDomain = new URL(testIdP.entityMeta.getEntityID()).host 199 | } 200 | return { type, domain: cleanProviderDomain, issuer, client, metadata } 201 | } 202 | -------------------------------------------------------------------------------- /src/apex.js: -------------------------------------------------------------------------------- 1 | const { domain, additionalContext } = require('./settings').appSettings 2 | const ActivitypubExpress = require('activitypub-express') 3 | const overlaps = require('overlaps') 4 | const immersContext = require('../static/immers-context.json') 5 | const { scopes } = require('../common/scopes') 6 | const { version } = require('../package.json') 7 | const { readStaticFileSync } = require('./utils') 8 | 9 | let parsedAddlContext = [] 10 | if (additionalContext) { 11 | try { 12 | const addlCtx = JSON.parse(readStaticFileSync(additionalContext)) 13 | // normalize array or object into array 14 | parsedAddlContext = parsedAddlContext.concat(addlCtx) 15 | } catch (err) { 16 | console.warn('Error adding activity additionalContext', err) 17 | } 18 | } 19 | 20 | const routes = { 21 | actor: '/u/:actor', 22 | object: '/o/:id', 23 | activity: '/s/:id', 24 | inbox: '/inbox/:actor', 25 | outbox: '/outbox/:actor', 26 | followers: '/followers/:actor', 27 | following: '/following/:actor', 28 | liked: '/liked/:actor', 29 | collections: '/collection/:actor/:id', 30 | blocked: '/blocked/:actor', 31 | rejections: '/rejections/:actor/', 32 | rejected: '/rejected/:actor/', 33 | shares: '/shares/:id/', 34 | likes: '/likes/:id/' 35 | } 36 | 37 | const apex = ActivitypubExpress({ 38 | name: 'Immers Space', 39 | version, 40 | domain, 41 | actorParam: 'actor', 42 | objectParam: 'id', 43 | routes, 44 | context: [immersContext, ...parsedAddlContext], 45 | endpoints: { 46 | oauthAuthorizationEndpoint: `https://${domain}/auth/authorize`, 47 | proxyUrl: `https://${domain}/proxy`, 48 | uploadMedia: `https://${domain}/media` 49 | }, 50 | openRegistrations: true, 51 | nodeInfoMetadata: { 52 | WebCollectibles: '1.0' 53 | } 54 | }) 55 | 56 | /* 57 | Similar to apex default with addition of scope-by-activty-type auth. 58 | Moved outboxCreate validation earlier to before the auth also 59 | */ 60 | const outboxPost = [ 61 | apex.net.validators.jsonld, 62 | apex.net.validators.targetActorWithMeta, 63 | apex.net.validators.outboxCreate, 64 | outboxScoping, 65 | apex.net.security.verifyAuthorization, 66 | apex.net.security.requireAuthorized, 67 | apex.net.validators.outboxActivityObject, 68 | apex.net.validators.outboxActivity, 69 | apex.net.activity.save, 70 | apex.net.activity.outboxSideEffects, 71 | apex.net.responders.status 72 | ] 73 | 74 | module.exports = { 75 | apex, 76 | createImmersActor, 77 | // createSystemActor, 78 | deliverWelcomeMessage, 79 | refreshAndUpdateActorObject, 80 | onOutbox, 81 | onInbox, 82 | routes, 83 | outboxPost 84 | } 85 | 86 | async function createImmersActor (preferredUsername, name, summary = 'Immerser profile', icon, type) { 87 | const actor = await apex.createActor(preferredUsername, name, summary, icon, type) 88 | const { blocked } = apex.utils.nameToActorStreams(preferredUsername) 89 | actor.streams = [{ 90 | id: `${actor.id}#streams`, 91 | // personal avatar collection 92 | avatars: apex.utils.userCollectionIdToIRI(preferredUsername, 'avatars'), 93 | // friends list and statuses 94 | friends: `https://${domain}/u/${preferredUsername}/friends`, 95 | // your recent destinations 96 | destinations: `https://${domain}/u/${preferredUsername}/destinations`, 97 | // friends recent destinations 98 | friendsDestinations: `https://${domain}/u/${preferredUsername}/friends-destinations`, 99 | // blocklist (requires auth) 100 | blocked 101 | }] 102 | return actor 103 | } 104 | 105 | async function deliverWelcomeMessage (actor, welcomeContent) { 106 | if (!(apex.systemUser && welcomeContent)) { 107 | return 108 | } 109 | const object = { 110 | id: apex.utils.objectIdToIRI(), 111 | type: 'Note', 112 | attributedTo: apex.systemUser.id, 113 | to: actor.id, 114 | content: welcomeContent 115 | } 116 | await apex.store.saveObject(object) 117 | const message = await apex.buildActivity('Create', apex.systemUser.id, actor.id, { 118 | object 119 | }) 120 | return apex.addToOutbox(apex.systemUser, message) 121 | } 122 | 123 | // apex event handlers for custom side-effects 124 | 125 | const collectionTypes = ['Add', 'Remove'] 126 | async function onOutbox ({ actor, activity, object }) { 127 | // publish avatars collection updates 128 | const isColChange = collectionTypes.includes(activity.type) 129 | const isAvatarCollection = activity.target?.[0] === actor.streams?.[0].avatars 130 | if (isColChange && isAvatarCollection) { 131 | return apex.publishUpdate(actor, await apex.getAdded(actor, 'avatars')) 132 | } 133 | // Friend behavior - follows are made reciprocal with automated followback 134 | if (activity.type === 'Accept' && object.type === 'Follow') { 135 | const followback = await apex.buildActivity('Follow', actor.id, object.actor, { 136 | object: object.actor, 137 | inReplyTo: object.id 138 | }) 139 | return apex.addToOutbox(actor, followback) 140 | } 141 | // tag visited destinations for later query 142 | const destCollection = actor.streams[0].destinations 143 | if (activity.type === 'Arrive' && destCollection) { 144 | return apex.store 145 | .updateActivityMeta(activity, 'collection', destCollection) 146 | } 147 | } 148 | 149 | async function onInbox ({ actor, activity, recipient, object }) { 150 | // Friend behavior - follows are made reciprocal by auto-accepting followbacks 151 | // validate by checking it is a reply to an outgoing follow for the same actor 152 | // (use this over checking actor is in my following list to avoid race condition with Accept processing) 153 | let inReplyTo 154 | if ( 155 | // is a follow for me and 156 | activity.type === 'Follow' && object.id === recipient.id && 157 | // is a reply 158 | (inReplyTo = activity.inReplyTo && await apex.store.getActivity(activity.inReplyTo[0])) && 159 | // to a follow 160 | inReplyTo.type === 'Follow' && 161 | // sent by me 162 | apex.actorIdFromActivity(inReplyTo) === recipient.id && 163 | // to this actor 164 | apex.objectIdFromActivity(inReplyTo) === actor.id 165 | ) { 166 | const accept = await apex.buildActivity('Accept', recipient.id, actor.id, { 167 | object: activity.id 168 | }) 169 | const { postTask: publishUpdatedFollowers } = await apex.acceptFollow(recipient, activity) 170 | await apex.addToOutbox(recipient, accept) 171 | return publishUpdatedFollowers() 172 | } 173 | // auto unfollowback 174 | if (activity.type === 'Reject' && object.type === 'Follow' && object.actor[0] === recipient.id) { 175 | const rejectedIRI = apex.utils.nameToRejectedIRI(recipient.preferredUsername) 176 | const follow = await apex.store.findActivityByCollectionAndActorId(recipient.followers[0], actor.id, true) 177 | if (!follow || follow._meta?.collection?.includes(rejectedIRI)) { 178 | return 179 | } 180 | // perform reject side effects and publish 181 | await apex.store.updateActivityMeta(follow, 'collection', rejectedIRI) 182 | await apex.store.updateActivityMeta(follow, 'collection', recipient.followers[0], true) 183 | const reject = await apex.buildActivity('Reject', recipient.id, actor.id, { 184 | object: follow.id 185 | }) 186 | await apex.addToOutbox(recipient, reject) 187 | return apex.publishUpdate(recipient, await apex.getFollowers(recipient)) 188 | } 189 | // tag friends' visited destinations for later query 190 | const friendsDestCollection = recipient.streams[0].friendsDestinations 191 | if (activity.type === 'Arrive' && friendsDestCollection) { 192 | return apex.store 193 | .updateActivityMeta(activity, 'collection', friendsDestCollection) 194 | } 195 | } 196 | 197 | // complex scoping by activity type for outbox post 198 | const profileUpdateProps = ['id', 'name', 'icon', 'avatar', 'summary'] 199 | function outboxScoping (req, res, next) { 200 | const authorizedScope = req.authInfo?.scope || [] 201 | let postType = req.body?.type?.toLowerCase?.() 202 | const object = req.body?.object?.[0] 203 | if ( 204 | postType === 'update' && 205 | // update target is the actor itself 206 | object?.id === res.locals.apex.target?.id && 207 | Object.keys(object).every(prop => profileUpdateProps.includes(prop)) 208 | ) { 209 | // profile udpates are lower permission than general update 210 | postType = 'update-profile' 211 | } 212 | // default requires unlimited access 213 | const requiredScope = ['*'] 214 | switch (postType) { 215 | case 'arrive': 216 | case 'leave': 217 | requiredScope.push(scopes.postLocation.name) 218 | break 219 | case 'follow': 220 | case 'accept': 221 | requiredScope.push(scopes.addFriends.name) 222 | break 223 | case 'block': 224 | requiredScope.push(scopes.addBlocks.name) 225 | break 226 | case 'add': 227 | case 'create': 228 | case 'like': 229 | case 'announce': 230 | case 'update-profile': 231 | requiredScope.push(scopes.creative.name) 232 | break 233 | case 'update': 234 | case 'reject': 235 | case 'undo': 236 | case 'delete': 237 | case 'remove': 238 | requiredScope.push(scopes.destructive.name) 239 | break 240 | } 241 | if (!overlaps(requiredScope, authorizedScope)) { 242 | res.locals.apex.authorized = false 243 | } 244 | next() 245 | } 246 | 247 | /** frequently used in migrations to sync actor objects with latest capability updates */ 248 | async function refreshAndUpdateActorObject (user) { 249 | const actor = await apex.store 250 | .getObject(apex.utils.usernameToIRI(user.username), false) 251 | if (!actor) { 252 | // avoid errors in case of database error where user has no actor object 253 | return 254 | } 255 | const tempActor = await createImmersActor(actor.preferredUsername[0], actor.name[0]) 256 | const endpoints = [Object.assign(actor.endpoints?.[0] || {}, tempActor.endpoints[0])] 257 | const streams = [Object.assign(actor.streams?.[0] || {}, tempActor.streams[0])] 258 | const newActorUpdate = await apex.store.updateObject({ id: actor.id, endpoints, streams }, actor.id, false) 259 | const newActorWithMeta = await apex.store.getObject(actor.id, true) 260 | return apex.publishUpdate(newActorWithMeta, newActorUpdate) 261 | } 262 | -------------------------------------------------------------------------------- /src/auth/consts.js: -------------------------------------------------------------------------------- 1 | module.exports.OICD_ISSUER_REL = 'http://openid.net/specs/connect/1.0/issuer' 2 | module.exports.USER_ROLES = { 3 | ADMIN: 'admin', 4 | USER: 'user' 5 | } 6 | module.exports.CLIENT_TYPES = { 7 | IMMERS: 'immers', 8 | OIDC: 'oidc', 9 | SAML: 'saml' 10 | } 11 | -------------------------------------------------------------------------------- /src/auth/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /** 3 | * Authorization index 4 | */ 5 | const authdb = require('./authdb') 6 | const resourceServer = require('./resourceServer') 7 | const authorizationServer = require('./oauthServer') 8 | const oauthClient = require('./oauthClient') 9 | // const openIdServer = require('./openIdServer') 10 | 11 | module.exports = { 12 | authdb, 13 | // resource server 14 | clnt: resourceServer.clnt, 15 | open: resourceServer.open, 16 | priv: resourceServer.priv, 17 | publ: resourceServer.publ, 18 | admn: resourceServer.admn, 19 | scope: resourceServer.scope, 20 | viewScope: resourceServer.viewScope, 21 | friendsScope: resourceServer.friendsScope, 22 | localToken: resourceServer.localToken, 23 | logout: resourceServer.logout, 24 | authorizeServiceAccount: resourceServer.authorizeServiceAccount, 25 | controlledAccountLogin: resourceServer.controlledAccountLogin, 26 | oidcLoginProviders: resourceServer.oidcLoginProviders, 27 | oidcSendProviderApprovalEmail: resourceServer.oidcSendProviderApprovalEmail, 28 | oidcProcessProviderApproved: resourceServer.oidcProcessProviderApproved, 29 | passIfNotAuthorized: resourceServer.passIfNotAuthorized, 30 | requirePrivilege: resourceServer.requirePrivilege, 31 | userToActor: resourceServer.userToActor, 32 | registerUser: resourceServer.registerUser, 33 | changePassword: resourceServer.changePassword, 34 | changePasswordAndReturn: resourceServer.changePasswordAndReturn, 35 | validateNewUser: resourceServer.validateNewUser, 36 | returnTo: resourceServer.returnTo, 37 | respondRedirect: resourceServer.respondRedirect, 38 | // oauth2 authorization server 39 | registerClient: authorizationServer.registerClient, 40 | authorization: authorizationServer.authorization, 41 | decision: authorizationServer.decision, 42 | tokenExchange: authorizationServer.tokenExchange, 43 | // oauth2 client 44 | checkImmer: oauthClient.checkImmer, 45 | checkImmerAndRedirect: oauthClient.checkImmerAndRedirect, 46 | handleOAuthReturn: [oauthClient.handleOAuthReturn, resourceServer.returnTo], 47 | handleSamlReturn: [oauthClient.handleSamlReturn, resourceServer.returnTo], 48 | oidcPreRegister: oauthClient.oidcPreRegister, 49 | oidcPostRegister: oauthClient.oidcPostRegister, 50 | oidcPostMerge: oauthClient.oidcPostMerge, 51 | samlProviderMetadata: oauthClient.samlServiceProviderMetadata 52 | // openId Connect server (WIP) 53 | /* 54 | oidcServerRouter: openIdServer.router, 55 | oidcWebfingerPassIfNotIssuer: openIdServer.webfingerPassIfNotIssuer, 56 | oidcWebfingerRespond: openIdServer.webfingerRespond 57 | */ 58 | } 59 | -------------------------------------------------------------------------------- /src/auth/oauthServer.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | /** 3 | * OAuth2 authorization server 4 | * Processes requests to act on behalf of users from this immer, 5 | * granting access tokens if authorized 6 | */ 7 | const { appSettings, renderConfig } = require('../settings') 8 | const oauth2orize = require('oauth2orize') 9 | const passport = require('passport') 10 | const login = require('connect-ensure-login') 11 | // strategies for OAuth client authentication 12 | const CustomStrategy = require('passport-custom').Strategy 13 | // additional OAuth exchange protocols 14 | const jwtBearer = require('oauth2orize-jwt-bearer').Exchange 15 | 16 | const authdb = require('./authdb') 17 | const { clnt } = require('./resourceServer') 18 | const { domain } = appSettings 19 | 20 | const oauthJwtExchangeType = 'urn:ietf:params:oauth:grant-type:jwt-bearer' 21 | const server = oauth2orize.createServer() 22 | 23 | /// exports /// 24 | module.exports = { 25 | registerClient, 26 | // OAuth2 client authorization & token request 27 | authorization: [ 28 | stashHandle, 29 | login.ensureLoggedIn('/auth/login'), 30 | sessionToLocals, 31 | server.authorization(authdb.validateClient, checkIfAuthorizationDialogNeeded), 32 | renderAuthorizationDialog 33 | ], 34 | // process result of auth dialog form 35 | decision: [ 36 | login.ensureLoggedIn('/auth/login'), 37 | server.decision(determineTokenParams) 38 | ], 39 | tokenExchange: [ 40 | clnt, // authorize the client 41 | server.token(), 42 | server.errorHandler() 43 | ] 44 | } 45 | 46 | /// side effects /// 47 | 48 | // Configure OAuth2 authorization server (grant access to remote clients) 49 | server.serializeClient(authdb.serializeClient) 50 | server.deserializeClient(authdb.deserializeClient) 51 | // OAuth2 client login 52 | passport.use('oauth2-client-jwt', new CustomStrategy(async (req, done) => { 53 | const rawToken = req.body?.assertion ?? req.get('authorization')?.split('Bearer ')[1] 54 | if (!rawToken) { 55 | return done(null, false, 'missing assertion body field or Bearer authorization header') 56 | } 57 | authdb.authenticateClientJwt(rawToken, done) 58 | })) 59 | // Implicit grant 60 | server.grant(oauth2orize.grant.token(authdb.createAccessToken)) 61 | // jwt bearer exchange for admin service accounts (2-Legged OAuth) 62 | server.exchange(oauthJwtExchangeType, jwtBearer( 63 | // Authorize client token exchange request 64 | function authorizeClientJwt (client, jwtBearer, done) { 65 | authdb.authorizeAccountControl(client, jwtBearer).then(({ validatedPayload, user }) => { 66 | const params = {} 67 | if (validatedPayload.origin) { 68 | params.origin = validatedPayload.origin 69 | } else { 70 | const origin = new URL(client.redirectUri[0]) 71 | params.origin = `${origin.protocol}//${origin.host}` 72 | } 73 | params.issuer = `https://${domain}` 74 | params.scope = validatedPayload.scope.split(' ') 75 | authdb.createAccessToken(client, user, params, done) 76 | }).catch(err => { 77 | done(null, false, err.message) 78 | }) 79 | }) 80 | ) 81 | 82 | /// utilities /// 83 | async function registerClient (req, res, next) { 84 | let client 85 | if (!req.body.clientId || !req.body.redirectUri || !req.body.name) { 86 | return res.status(400).send('Invalid clientId or redirectUri') 87 | } 88 | try { 89 | client = await authdb.createClient(req.body.clientId, req.body.redirectUri, req.body.name) 90 | } catch (err) { 91 | if (err.name === 'MongoServerError' && err.code === 11000) { 92 | return res.status(409).send('Client already registered') 93 | } 94 | next(err) 95 | } 96 | return res.json(client) 97 | } 98 | 99 | function checkIfAuthorizationDialogNeeded (client, user, scope, type, authRequest, locals, done) { 100 | // Auto-approve for home immer 101 | if (client.isTrusted) { 102 | const params = {} 103 | const origin = new URL(authRequest.redirectURI) 104 | params.origin = `${origin.protocol}//${origin.host}` 105 | params.issuer = `https://${domain}` 106 | params.scope = ['*'] 107 | /** 108 | * Can pass additional info to its own hub on user registration, 109 | * added as params in auth response, parsed to client.sessionInfo in ImmersClient 110 | */ 111 | if (client.clientId === `https://${domain}/o/immer`) { 112 | // registrationInfo set in resourceServer->registerUser 113 | params.registrationInfo = locals.registrationInfo 114 | } 115 | return done(null, true, params) 116 | } 117 | // Otherwise ask user 118 | return done(null, false) 119 | } 120 | 121 | function renderAuthorizationDialog (request, response) { 122 | const data = Object.assign({ 123 | transactionId: request.oauth2.transactionID, 124 | username: request.user.username, 125 | clientName: request.oauth2.client.name, 126 | redirectUri: request.oauth2.redirectURI, 127 | preferredScope: request.oauth2.req.scope.join(' ') 128 | }, renderConfig) 129 | response.render('dist/dialog/dialog.html', data) 130 | } 131 | 132 | function determineTokenParams (req, done) { 133 | const params = {} 134 | const origin = new URL(req.oauth2.redirectURI) 135 | params.origin = `${origin.protocol}//${origin.host}` 136 | params.issuer = `https://${domain}` 137 | params.scope = req.body.scope?.split(' ') || [] 138 | done(null, params) 139 | } 140 | 141 | function stashHandle (req, res, next) { 142 | /* To save repeated handle entry, an immer can pass along handle when 143 | * redirecting for auth. Store it in session for access during login 144 | */ 145 | if (req.query.me && req.session) { 146 | req.session.handle = req.query.me 147 | } 148 | if (req.query.tab && req.session) { 149 | req.session.loginTab = req.query.tab 150 | } 151 | next() 152 | } 153 | 154 | /** 155 | * oauth2orize doesn't allow access to raw request or session in callbacks, 156 | * but it does allow access to request.locals 157 | */ 158 | function sessionToLocals (req, res, next) { 159 | req.locals ??= {} 160 | req.locals.registrationInfo = req.session.registrationInfo 161 | delete req.session.registrationInfo 162 | next() 163 | } 164 | -------------------------------------------------------------------------------- /src/auth/openIdServer.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { appSettings } = require('../settings') 3 | const { Provider } = require('oidc-provider') 4 | const { ObjectId } = require('mongodb') 5 | const uid = require('uid-safe') 6 | const { MongoAdapter } = require('./openIdServerDb') 7 | const authdb = require('./authdb') 8 | const { apex } = require('../apex') 9 | const { OICD_ISSUER_REL } = require('./consts') 10 | const { domain, proxyMode, sessionSecret, enableClientRegistration } = appSettings 11 | const configuration = { 12 | routes: { 13 | authorization: '/authorize', 14 | backchannel_authentication: '/backchannel', 15 | code_verification: '/device', 16 | device_authorization: '/device/auth', 17 | end_session: '/session/end', 18 | introspection: '/token/introspection', 19 | jwks: '/jwks', 20 | pushed_authorization_request: '/request', 21 | registration: '/client', 22 | revocation: '/token/revocation', 23 | token: '/token', 24 | userinfo: '/me' 25 | }, 26 | adapter: MongoAdapter, 27 | clients: [], 28 | cookies: { 29 | keys: [sessionSecret] 30 | }, 31 | features: { 32 | registration: { 33 | enabled: enableClientRegistration, 34 | idFactory: () => new ObjectId().toString(), 35 | initialAccessToken: false, 36 | issueRegistrationAccessToken: true, 37 | policies: undefined, 38 | secretFactory: () => uid(128) 39 | } 40 | }, 41 | findAccount 42 | } 43 | 44 | const oidc = new Provider(`https://${domain}`, configuration) 45 | // match app proxy config 46 | oidc.proxy = !!proxyMode 47 | 48 | /// exports /// 49 | module.exports = { 50 | router: oidc.callback(), 51 | webfingerPassIfNotIssuer, 52 | webfingerRespond 53 | } 54 | 55 | function webfingerPassIfNotIssuer (req, res, next) { 56 | if (req.query.rel !== OICD_ISSUER_REL) { 57 | return next('route') 58 | } 59 | next() 60 | } 61 | function webfingerRespond (req, res, next) { 62 | const resource = req.query.resource 63 | const actorObj = res.locals.apex.target 64 | if (!actorObj) { 65 | return res.status(404).send(`${resource} not found`) 66 | } 67 | const sendFinger = () => res.send({ 68 | subject: resource, 69 | links: 70 | [ 71 | { 72 | rel: OICD_ISSUER_REL, 73 | href: `https://${domain}` 74 | } 75 | ] 76 | }) 77 | res.format({ 78 | 'application/json': sendFinger, 79 | 'application/jrd+json': sendFinger 80 | }) 81 | } 82 | 83 | /// utils /// 84 | async function findAccount (ctx, sub, token) { 85 | // @param ctx - koa request context 86 | // @param sub {string} - account identifier (subject) 87 | // @param token - is a reference to the token used for which a given account is being loaded, 88 | // is undefined in scenarios where claims are returned from authorization endpoint 89 | const user = await authdb.getUserByName(sub.replace(`https://${domain}/u/`, '')) 90 | if (!user) { 91 | return null 92 | } 93 | // we don't save user emails; fill in immers handle instead 94 | const email = `${user.username}@${domain}` 95 | return { 96 | accountId: sub, 97 | // @param use {string} - can either be "id_token" or "userinfo", depending on 98 | // where the specific claims are intended to be put in 99 | // @param scope {string} - the intended scope, while oidc-provider will mask 100 | // claims depending on the scope automatically you might want to skip 101 | // loading some claims from external resources or through db projection etc. based on this 102 | // detail or not return them in ID Tokens but only UserInfo and so on 103 | // @param claims {object} - the part of the claims authorization parameter for either 104 | // "id_token" or "userinfo" (depends on the "use" param) 105 | // @param rejected {Array[String]} - claim names that were rejected by the end-user, you might 106 | // want to skip loading some claims from external resources or through db projection 107 | async claims (use, scope, claims, rejected) { 108 | const result = { sub, email } 109 | if (use === 'userinfo') { 110 | result.profile = await apex.toJSONLD(await apex.store.getObject(sub, false)) 111 | } 112 | console.log('OIDC CLAIMS:', result) 113 | return result 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/auth/openIdServerDb.js: -------------------------------------------------------------------------------- 1 | const { escape, unescape } = require('mongo-escape') 2 | const merge = require('deepmerge') 3 | 4 | function escapeClone (obj) { 5 | return escape(merge({}, obj)) 6 | } 7 | 8 | const collections = [ 9 | 'Grant', 'Session', 'AccessToken', 10 | 'AuthorizationCode', 'RefreshToken', 'ClientCredentials', 'Client', 'InitialAccessToken', 11 | 'RegistrationAccessToken', 'DeviceCode', 'Interaction', 'ReplayDetection', 12 | 'BackchannelAuthenticationRequest', 'PushedAuthorizationRequest' 13 | ] 14 | const grantIdCollections = new Set([ 15 | 'AccessToken', 'AuthorizationCode', 'RefreshToken', 'DeviceCode', 'BackchannelAuthenticationRequest' 16 | ]) 17 | const userCodeCollections = new Set(['DeviceCode']) 18 | const uidCollections = new Set(['Session']) 19 | 20 | class MongoAdapter { 21 | constructor (name) { 22 | this.name = `oidcServer_${name}` 23 | /** 24 | * Workaround oidc-provider requirement that dabatase be connected 25 | * before you can initailize Provider and get its router 26 | * by waiting for initialization in all methods that access DB 27 | */ 28 | this.coll = new Promise(resolve => { 29 | const ready = () => resolve(MongoAdapter.DB.collection(this.name)) 30 | if (MongoAdapter.DB) { 31 | ready() 32 | } else { 33 | MongoAdapter.InitializedResolvers.add(ready) 34 | } 35 | }) 36 | } 37 | 38 | async upsert (_id, payload, expiresIn) { 39 | const payloadEsc = escapeClone(payload) 40 | 41 | if (expiresIn) { 42 | payloadEsc.expiresAt = new Date(Date.now() + (expiresIn * 1000)) 43 | } 44 | 45 | await (await this.coll).updateOne( 46 | { _id }, 47 | { $set: { payload: payloadEsc } }, 48 | { upsert: true } 49 | ) 50 | } 51 | 52 | async find (_id) { 53 | const result = await (await this.coll).find( 54 | { _id }, 55 | { payload: 1 } 56 | ).limit(1).next() 57 | 58 | if (!result) return undefined 59 | return unescape(result.payload) 60 | } 61 | 62 | async findByUserCode (userCode) { 63 | const result = await (await this.coll).find( 64 | { 'payload.userCode': userCode }, 65 | { payload: 1 } 66 | ).limit(1).next() 67 | 68 | if (!result) return undefined 69 | return unescape(result.payload) 70 | } 71 | 72 | async findByUid (uid) { 73 | const result = await (await this.coll).find( 74 | { 'payload.uid': uid }, 75 | { payload: 1 } 76 | ).limit(1).next() 77 | 78 | if (!result) return undefined 79 | return unescape(result.payload) 80 | } 81 | 82 | async destroy (_id) { 83 | await (await this.coll).deleteOne({ _id }) 84 | } 85 | 86 | async revokeByGrantId (grantId) { 87 | await (await this.coll).deleteMany({ 'payload.grantId': grantId }) 88 | } 89 | 90 | async consume (_id) { 91 | await (await this.coll).findOneAndUpdate( 92 | { _id }, 93 | { $set: { 'payload.consumed': Math.floor(Date.now() / 1000) } } 94 | ) 95 | } 96 | 97 | static DB 98 | static InitializedResolvers = new Set() 99 | static Initialize (db) { 100 | const indexPromises = collections.map(name => { 101 | return db.collection(name).createIndexes([ 102 | ...(grantIdCollections.has(name) 103 | ? [{ 104 | key: { 'payload.grantId': 1 } 105 | }] 106 | : []), 107 | ...(userCodeCollections.has(name) 108 | ? [{ 109 | key: { 'payload.userCode': 1 }, 110 | unique: true 111 | }] 112 | : []), 113 | ...(uidCollections.has(name) 114 | ? [{ 115 | key: { 'payload.uid': 1 }, 116 | unique: true 117 | }] 118 | : []), 119 | { 120 | key: { expiresAt: 1 }, 121 | expireAfterSeconds: 0 122 | } 123 | ]) 124 | }) 125 | return Promise.all(indexPromises).then(() => { 126 | MongoAdapter.DB = db 127 | MongoAdapter.InitializedResolvers.forEach(resolver => resolver()) 128 | }) 129 | } 130 | } 131 | 132 | module.exports = { MongoAdapter } 133 | -------------------------------------------------------------------------------- /src/clientApi.js: -------------------------------------------------------------------------------- 1 | // custom c2s apis 2 | const { Router } = require('express') 3 | const { apex } = require('./apex') 4 | const auth = require('./auth') 5 | const { domain } = require('./settings').appSettings 6 | const router = new Router() 7 | // note we have to include reject here or else the query will find the previous accept and 8 | // the user will show up as a current friend. Clients probably want to filter the rejects before display 9 | const friendStatusTypes = ['Arrive', 'Leave', 'Accept', 'Follow', 'Reject'] 10 | 11 | module.exports = { 12 | router 13 | } 14 | 15 | router.get('/u/:actor/friends', [ 16 | // check content type first in case this is HTML request 17 | apex.net.validators.jsonld, 18 | auth.priv, 19 | auth.friendsScope, 20 | apex.net.validators.targetActorWithMeta, 21 | apex.net.security.verifyAuthorization, 22 | apex.net.security.requireAuthorized, 23 | friendsLocations, 24 | apex.net.responders.result 25 | ]) 26 | 27 | router.get('/u/:actor/destinations', [ 28 | // check content type first in case this is HTML request 29 | apex.net.validators.jsonld, 30 | auth.priv, 31 | auth.friendsScope, 32 | apex.net.validators.targetActorWithMeta, 33 | apex.net.security.verifyAuthorization, 34 | apex.net.security.requireAuthorized, 35 | outboxDestinations, 36 | apex.net.responders.result 37 | ]) 38 | 39 | router.get('/u/:actor/friends-destinations', [ 40 | // check content type first in case this is HTML request 41 | apex.net.validators.jsonld, 42 | auth.priv, 43 | auth.friendsScope, 44 | apex.net.validators.targetActorWithMeta, 45 | apex.net.security.verifyAuthorization, 46 | apex.net.security.requireAuthorized, 47 | inboxDestinations, 48 | apex.net.responders.result 49 | ]) 50 | 51 | async function friendsLocations (req, res, next) { 52 | const apex = req.app.locals.apex 53 | const locals = res.locals.apex 54 | const actor = locals.target 55 | const inbox = actor.inbox[0] 56 | const followers = actor.followers[0] 57 | const rejected = apex.utils.nameToRejectedIRI(actor.preferredUsername) 58 | const outbox = actor.outbox[0] 59 | const following = actor.following[0] 60 | const rejections = apex.utils.nameToRejectionsIRI(actor.preferredUsername) 61 | const friends = await apex.store.db.collection('streams').aggregate([ 62 | { 63 | $match: { 64 | $and: [ 65 | { '_meta.collection': inbox }, 66 | // filter only pending follow requests 67 | { '_meta.collection': { $nin: [followers, rejected] } } 68 | ], 69 | type: { $in: friendStatusTypes }, 70 | actor: { $nin: actor._local.blockList } 71 | } 72 | }, 73 | // most recent activity per actor 74 | { $sort: { _id: -1 } }, 75 | { $group: { _id: '$actor', loc: { $first: '$$ROOT' } } }, 76 | // sort actors by most recent activity 77 | { $sort: { _id: -1 } }, 78 | { $replaceRoot: { newRoot: '$loc' } }, 79 | { $sort: { _id: -1 } }, 80 | { $lookup: { from: 'objects', localField: 'actor', foreignField: 'id', as: 'actor' } }, 81 | { $project: { _id: 0, _meta: 0, 'actor.publicKey': 0, 'actor._meta': 0 } }, 82 | { 83 | // include outgoing follow requests that are pending 84 | $unionWith: { 85 | coll: 'streams', 86 | pipeline: [ 87 | { 88 | $match: { 89 | $and: [ 90 | { '_meta.collection': outbox }, 91 | // filter only pending follow requests 92 | { '_meta.collection': { $nin: [following, rejections] } } 93 | ], 94 | type: { $eq: 'Follow' } 95 | } 96 | }, 97 | { $sort: { _id: -1 } }, 98 | { $lookup: { from: 'objects', localField: 'object', foreignField: 'id', as: 'object' } }, 99 | { $project: { _id: 0, _meta: 0, 'object.publicKey': 0, 'object._meta': 0 } } 100 | ] 101 | } 102 | } 103 | ]).toArray() 104 | locals.result = { 105 | id: `https://${domain}${req.originalUrl}`, 106 | type: 'OrderedCollection', 107 | totalItems: friends.length, 108 | orderedItems: friends 109 | } 110 | next() 111 | } 112 | 113 | async function outboxDestinations (req, res, next) { 114 | const locals = res.locals.apex 115 | if (!locals.target) return next() 116 | const actor = locals.target 117 | try { 118 | const collection = await destinationHistory(actor.streams[0].destinations, req.query.page, locals.authorized, actor._local.blockList) 119 | locals.result = collection 120 | next() 121 | } catch (err) { 122 | console.error('Error querying outbox destination history', err) 123 | next(err) 124 | } 125 | } 126 | 127 | async function inboxDestinations (req, res, next) { 128 | const locals = res.locals.apex 129 | if (!locals.target) return next() 130 | const actor = locals.target 131 | try { 132 | const collection = await destinationHistory(actor.streams[0].friendsDestinations, req.query.page, locals.authorized, actor._local.blockList) 133 | locals.result = collection 134 | next() 135 | } catch (err) { 136 | console.error('Error querying inbox destination history', err) 137 | next(err) 138 | } 139 | } 140 | 141 | /** List unique visited locations, most recent first. Your destinations if collectionId is outbox or else friends' if inbox */ 142 | function destinationHistory (collectionId, page, authorized, blockList) { 143 | const recentUniqueUrls = [{ 144 | $sort: { 145 | _id: -1 146 | } 147 | }, { 148 | $group: { 149 | _id: '$target.url', 150 | recent: { 151 | $first: '$$ROOT' 152 | } 153 | } 154 | }, { 155 | $replaceRoot: { 156 | newRoot: '$recent' 157 | } 158 | }] 159 | return apex.getCollection(collectionId, page, null, authorized, blockList, recentUniqueUrls) 160 | } 161 | -------------------------------------------------------------------------------- /src/cryptoUtils.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto') 2 | const x509 = require('@peculiar/x509') 3 | const { mkdir, readFile, writeFile } = require('fs/promises') 4 | const path = require('path') 5 | 6 | module.exports = { 7 | createSelfSignedCertificate, 8 | getLocalDevCertificate, 9 | hashEmail 10 | } 11 | 12 | async function getLocalDevCertificate () { 13 | try { 14 | const certificate = await readFile(path.join(process.cwd(), 'certs', 'certificate.pem')) 15 | const privateKey = await readFile(path.join(process.cwd(), 'certs', 'pivateKey.pem')) 16 | return { certificate, privateKey } 17 | } catch { 18 | const { certificate, privateKey } = await createSelfSignedCertificate() 19 | await mkdir(path.join(process.cwd(), 'certs'), { recursive: true }) 20 | await writeFile(path.join(process.cwd(), 'certs', 'certificate.pem'), certificate) 21 | await writeFile(path.join(process.cwd(), 'certs', 'pivateKey.pem'), privateKey) 22 | return { certificate, privateKey } 23 | } 24 | } 25 | 26 | function hashEmail (email) { 27 | return crypto.createHash('sha256').update(email.toLowerCase()).digest('base64') 28 | } 29 | 30 | async function createSelfSignedCertificate (name = 'localhost', number = '01') { 31 | x509.cryptoProvider.set(crypto.webcrypto) 32 | const alg = { 33 | name: 'RSASSA-PKCS1-v1_5', 34 | hash: 'SHA-256', 35 | publicExponent: new Uint8Array([1, 0, 1]), 36 | modulusLength: 2048 37 | } 38 | const notAfter = new Date() 39 | notAfter.setUTCFullYear(notAfter.getUTCFullYear() + 10) 40 | const usages = x509.KeyUsageFlags.nonRepudiation | 41 | x509.KeyUsageFlags.digitalSignature | 42 | x509.KeyUsageFlags.keyEncipherment | 43 | x509.KeyUsageFlags.keyAgreement 44 | const keys = await crypto.webcrypto.subtle.generateKey(alg, true, ['sign', 'verify']) 45 | const cert = await x509.X509CertificateGenerator.createSelfSigned({ 46 | serialNumber: number, 47 | name: `CN=${name}`, 48 | notBefore: new Date(), 49 | notAfter, 50 | signingAlgorithm: alg, 51 | keys, 52 | extensions: [ 53 | new x509.BasicConstraintsExtension(false, undefined, true), 54 | new x509.KeyUsagesExtension(usages, true), 55 | await x509.SubjectKeyIdentifierExtension.create(keys.publicKey) 56 | ] 57 | }) 58 | const publicKey = webKeyToPem(keys.publicKey, false) 59 | const privateKey = webKeyToPem(keys.privateKey, true) 60 | return { certificate: cert.toString('pem'), publicKey, privateKey } 61 | } 62 | 63 | function webKeyToPem (cryptoKey, isPrivate) { 64 | return crypto.KeyObject 65 | .from(cryptoKey) 66 | .export({ type: isPrivate ? 'pkcs8' : 'spki', format: 'pem' }) 67 | .toString() 68 | } 69 | -------------------------------------------------------------------------------- /src/media.js: -------------------------------------------------------------------------------- 1 | const { appSettings } = require('./settings') 2 | const path = require('path') 3 | const crypto = require('crypto') 4 | const express = require('express') 5 | const multer = require('multer') 6 | const cors = require('cors') 7 | const overlaps = require('overlaps') 8 | const { GridFSBucket, ObjectId } = require('mongodb') 9 | const { GridFsStorage } = require('multer-gridfs-storage') 10 | 11 | const auth = require('./auth') 12 | const { scopes } = require('../common/scopes') 13 | const { apex, outboxPost } = require('./apex') 14 | 15 | const { mongoURI, domain, maxUploadSize } = appSettings 16 | const router = express.Router() 17 | const bucketName = 'uploads' 18 | let bucket 19 | 20 | const upload = multer({ 21 | storage: new GridFsStorage({ 22 | url: mongoURI, 23 | file: (req, file) => { 24 | // set random filename with original extension 25 | return new Promise((resolve, reject) => { 26 | crypto.randomBytes(16, (err, buf) => { 27 | if (err) { 28 | return reject(err) 29 | } 30 | const filename = buf.toString('hex') + path.extname(file.originalname) 31 | const fileInfo = { 32 | filename, 33 | bucketName 34 | } 35 | resolve(fileInfo) 36 | }) 37 | }) 38 | } 39 | }), 40 | limits: { 41 | fileSize: maxUploadSize * 1024 * 1024 42 | } 43 | }) 44 | 45 | router.post( 46 | '/', 47 | auth.priv, 48 | // check scope 49 | (req, res, next) => { 50 | if (!overlaps(['*', scopes.creative.name], req.authInfo?.scope ?? [])) { 51 | return res.status(403).send(`Uploading media requires ${scopes.creative.name} scope`) 52 | } 53 | next() 54 | }, 55 | upload.fields([{ name: 'file', maxCount: 1 }, { name: 'icon', maxCount: 1 }]), 56 | (req, res, next) => { 57 | const file = req.files.file[0] 58 | const icon = req.files.icon?.[0] 59 | const fileIds = [file.id.toString()] 60 | let object 61 | try { 62 | object = JSON.parse(req.body.object) 63 | } catch (err) { 64 | console.error('Error parsing media upload AP object', err) 65 | return next(err) 66 | } 67 | object.url = [{ 68 | type: 'Link', 69 | href: `https://${domain}/media/${file.filename}`, 70 | mediaType: file.mimetype 71 | }] 72 | if (icon) { 73 | object.icon = { 74 | type: 'Image', 75 | mediaType: icon.mimetype, 76 | url: `https://${domain}/media/${icon.filename}` 77 | } 78 | fileIds.push(icon.id.toString()) 79 | } 80 | // attach file ids to object metadata after creation 81 | res.locals.apex.postWork.push(sentResponse => { 82 | const objId = sentResponse.locals.apex.object?.id 83 | if (!objId) { 84 | // post must have errored, cleanup will be handled by error handler 85 | return 86 | } 87 | return apex.store.db.collection('objects').updateOne( 88 | { id: objId }, 89 | { $addToSet: { '_meta.files': { $each: fileIds } } } 90 | ).catch(err => console.error('Unable to save file metadata', fileIds, err)) 91 | }) 92 | // forward to outbox route, alter request to look like object post 93 | req.body = object 94 | req.headers['content-type'] = apex.consts.jsonldOutgoingType 95 | req.params.actor = req.user.username 96 | next() 97 | }, 98 | // finish publishing create activity 99 | outboxPost, 100 | (err, req, res, next) => { 101 | // delete unused files from failed request 102 | if (req.files) { 103 | if (!bucket) { 104 | bucket = new GridFSBucket(apex.store.db, { bucketName }) 105 | } 106 | for (const fileArray in req.files) { 107 | req.files[fileArray].forEach(file => deleteFileIfUnused(file.id)) 108 | } 109 | } 110 | next(err) 111 | } 112 | ) 113 | 114 | router.get( 115 | '/:filename', 116 | // leave cors open for now since the proxy features were so recently added to client/server. 117 | // After some time for these to be adopted, change to auth.publ, restricting cors to logged in users only 118 | cors(), 119 | // auth.publ, // adds dynamic cors when logged-in 120 | async (req, res) => { 121 | if (!bucket) { 122 | bucket = new GridFSBucket(apex.store.db, { bucketName }) 123 | } 124 | const file = await bucket 125 | .find({ filename: req.params.filename }) 126 | .next() 127 | if (!file) { 128 | return res.sendStatus(404) 129 | } 130 | res.set('Content-Type', file.contentType) 131 | bucket.openDownloadStream(file._id).pipe(res) 132 | } 133 | ) 134 | 135 | function fileCleanupOnDelete ({ activity, object }) { 136 | if (activity.type !== 'Delete' || !object) { 137 | return 138 | } 139 | object._meta?.files?.forEach?.(fileId => deleteFileIfUnused(fileId)) 140 | } 141 | 142 | async function deleteFileIfUnused (fileId) { 143 | if (!bucket) { 144 | bucket = new GridFSBucket(apex.store.db, { bucketName }) 145 | } 146 | try { 147 | const count = await apex.store.db 148 | .collection('objects') 149 | .countDocuments({ '_meta.files': fileId }) 150 | if (!count) { 151 | console.log(`Deleting file no longer in use: ${fileId}`) 152 | await bucket.delete(ObjectId(fileId)) 153 | } else { 154 | console.log(`Retaining file still used by ${count} objects`) 155 | } 156 | } catch (err) { 157 | console.warn(`Unable to perform file cleanup for ${fileId}: ${err}`) 158 | } 159 | } 160 | 161 | module.exports = { 162 | router, 163 | fileCleanupOnDelete 164 | } 165 | -------------------------------------------------------------------------------- /src/migrate.js: -------------------------------------------------------------------------------- 1 | const { 2 | database, 3 | config, 4 | up 5 | } = require('migrate-mongo') 6 | 7 | module.exports.migrate = async function migrate (mongoURI) { 8 | let db 9 | let client 10 | const conf = await config.read() 11 | conf.mongodb.url = mongoURI 12 | config.set(conf) 13 | try { 14 | ;({ db, client } = await database.connect()) 15 | const migrated = await up(db, client) 16 | if (migrated.length) { 17 | migrated.forEach(fileName => console.log('Migrated:', fileName)) 18 | } else { 19 | console.log('No pending migrations') 20 | } 21 | } catch (err) { 22 | await client.close() 23 | throw err 24 | } 25 | return client.close() 26 | } 27 | -------------------------------------------------------------------------------- /src/openGraph.js: -------------------------------------------------------------------------------- 1 | const { URL, URLSearchParams } = require('url') 2 | const textVersion = require('textversionjs') 3 | 4 | // AP summary allows HTML, but og:description does not 5 | const htmlToText = html => textVersion(html, { 6 | linkProcess: (href, text) => text, 7 | imgProcess: (src, alt) => alt, 8 | headingStyle: 'linebreak', 9 | listStyle: 'linebreak' 10 | }) 11 | 12 | module.exports = { 13 | generateMetaTags 14 | } 15 | 16 | const objectReg = /^\/(\w)\/([A-Za-z0-9-]+)/ 17 | 18 | async function generateMetaTags (req, res, next) { 19 | const path = req.originalUrl 20 | const apex = req.app.locals.apex 21 | const openGraph = res.locals.openGraph = {} 22 | 23 | const pathMatch = objectReg.exec(path.toLowerCase()) 24 | if (!pathMatch) { 25 | return next() 26 | } 27 | const [, type, id] = pathMatch 28 | try { 29 | if (type === 'u') { 30 | const profile = await apex.store.getObject(`https://${apex.domain}/u/${id}`) 31 | if (!profile) { 32 | return next() 33 | } 34 | const { name, summary, icon, avatar } = await apex.toJSONLD(profile) 35 | openGraph.ogTitle = `${name}'s Profile` 36 | openGraph.ogDescription = htmlToText(summary ?? 'Immerser profile') 37 | if (icon) { 38 | openGraph.ogImage = typeof icon === 'string' ? icon : icon.url 39 | } 40 | if (avatar) { 41 | openGraph.twitterEmbed = modelPlayer(avatar, apex.domain) 42 | } 43 | } else if (type === 's') { 44 | const activity = await apex.store.getActivity(`https://${apex.domain}/s/${id}`) 45 | if (!activity) { 46 | return next() 47 | } 48 | const { type: activityType, summary: activitySummary, object } = await apex.toJSONLD(activity) 49 | const summary = activitySummary || object?.summary 50 | if (summary) { 51 | openGraph.ogTitle = `${activityType} ${object?.name || 'Activity'}` 52 | openGraph.ogDescription = htmlToText(summary) 53 | } else { 54 | openGraph.ogTitle = `${activityType} Activity` 55 | openGraph.ogDescription = htmlToText(object?.name || object?.type) 56 | } 57 | if (object?.icon) { 58 | openGraph.ogImage = hrefFromIcon(object.icon) 59 | } 60 | if (object?.type === 'Model') { 61 | openGraph.twitterEmbed = modelPlayer(object, apex.domain) 62 | } else if (object?.type === 'Image') { 63 | openGraph.ogImage = hrefFromIcon(object) 64 | } 65 | } else if (type === 'o') { 66 | let object = await apex.store.getObject(`https://${apex.domain}/o/${id}`) 67 | if (!object) { 68 | return next() 69 | } 70 | object = await apex.toJSONLD(object) 71 | if (object.name && object.summary) { 72 | openGraph.ogTitle = object.name 73 | openGraph.ogDescription = htmlToText(object.summary) 74 | } else { 75 | openGraph.ogTitle = `${object.type}` 76 | openGraph.ogDescription = htmlToText(object.summary ?? object.name ?? '') 77 | } 78 | if (object.icon) { 79 | openGraph.ogImage = hrefFromIcon(object.icon) 80 | } 81 | if (object.type === 'Model') { 82 | openGraph.twitterEmbed = modelPlayer(object, apex.domain) 83 | } else if (object.type === 'Image') { 84 | openGraph.ogImage = hrefFromIcon(object) 85 | } 86 | } 87 | } catch (err) { 88 | console.warn('Error generating open graph tags: ', err) 89 | // continue to serve page anyway without og tags 90 | } 91 | next() 92 | } 93 | 94 | function modelPlayer (model, domain) { 95 | const cardUrl = new URL(`https://${domain}/static/twitter-player.html`) 96 | const poster = model.icon ? hrefFromIcon(model.icon) : '' 97 | cardUrl.search = new URLSearchParams({ 98 | src: typeof model.url === 'string' 99 | ? model.url 100 | : model.url?.href, 101 | poster, 102 | alt: model.name 103 | }).toString() 104 | return { 105 | width: 480, 106 | height: 480, 107 | url: cardUrl.href 108 | } 109 | } 110 | 111 | function hrefFromIcon (icon) { 112 | if (typeof icon === 'string') { 113 | return icon 114 | } else { 115 | return typeof icon.url === 'string' 116 | ? icon.url 117 | : icon.url?.href 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/settings.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | require('dotenv-defaults').config() 3 | const { readStaticFileSync, parseProxyMode } = require('./utils') 4 | const SETTINGS_COL = 'appSettings' 5 | const THEME_SETTING = 'theme' 6 | 7 | const { 8 | additionalContext, 9 | adminEmail, 10 | backgroundColor, 11 | backgroundImage, 12 | baseTheme, 13 | cookieName, 14 | customCSS, 15 | dbHost, 16 | dbName, 17 | dbPort, 18 | dbString, 19 | domain, 20 | easySecret, 21 | emailOptInNameParam, 22 | emailOptInParam, 23 | emailOptInURL, 24 | enableClientRegistration, 25 | enablePublicRegistration, 26 | googleFont, 27 | homepage, 28 | hub, 29 | icon, 30 | imageAttributionText, 31 | imageAttributionUrl, 32 | loginRedirect, 33 | maxUploadSize, 34 | monetizationPointer, 35 | name, 36 | passEmailToHub, 37 | port, 38 | proxyMode, 39 | sessionSecret, 40 | smtpFrom, 41 | smtpHost, 42 | smtpPassword, 43 | smtpPort, 44 | smtpUser, 45 | smtpClient, 46 | smtpKey, 47 | systemDisplayName, 48 | systemUserName, 49 | welcome 50 | } = process.env 51 | 52 | const welcomeContent = readStaticFileSync(welcome) 53 | const hubs = hub.split(',') 54 | // fallback to building string from parts for backwards compat 55 | const mongoURI = dbString || `mongodb://${dbHost}:${dbPort}/${dbName}` 56 | 57 | const appSettings = { 58 | additionalContext, 59 | adminEmail, 60 | backgroundColor, 61 | backgroundImage, 62 | baseTheme, 63 | cookieName, 64 | customCSS, 65 | domain, 66 | easySecret, 67 | emailOptInNameParam, 68 | emailOptInParam, 69 | emailOptInURL, 70 | enableClientRegistration, 71 | enablePublicRegistration, 72 | googleFont, 73 | homepage, 74 | hubs, 75 | icon, 76 | imageAttributionText, 77 | imageAttributionUrl, 78 | loginRedirect, 79 | maxUploadSize, 80 | monetizationPointer, 81 | mongoURI, 82 | name, 83 | passEmailToHub: passEmailToHub === 'true', 84 | port, 85 | proxyMode: parseProxyMode(proxyMode), 86 | sessionSecret, 87 | smtpFrom, 88 | smtpHost, 89 | smtpPassword, 90 | smtpPort, 91 | smtpUser, 92 | smtpClient, 93 | smtpKey, 94 | systemDisplayName, 95 | systemUserName, 96 | welcomeContent 97 | } 98 | 99 | const renderConfig = { 100 | name, 101 | domain, 102 | hub: hubs, 103 | homepage, 104 | monetizationPointer, 105 | googleFont, 106 | backgroundColor, 107 | backgroundImage, 108 | baseTheme, 109 | customCSS, 110 | customTheme: '', // loaded from DB 111 | icon, 112 | imageAttributionText, 113 | imageAttributionUrl, 114 | emailOptInURL, 115 | enablePublicRegistration, 116 | passEmailToHub: appSettings.passEmailToHub 117 | } 118 | 119 | const isTrue = (settingName) => { 120 | return isEqualTo(settingName, 'true') 121 | } 122 | 123 | const isFalse = (settingName) => { 124 | return isEqualTo(settingName, 'false') 125 | } 126 | 127 | const isEqualTo = (settingName, value) => { 128 | return (req, res, next) => { 129 | if (appSettings[settingName] !== value) { 130 | const validMessage = 'Method unavailable due to Immers configuration.' 131 | return res.status(405).format({ 132 | text: () => res.send(validMessage), 133 | json: () => res.json({ error: validMessage }) 134 | }) 135 | } 136 | next() 137 | } 138 | } 139 | 140 | module.exports = { 141 | appSettings, 142 | renderConfig, 143 | isTrue, 144 | isFalse, 145 | isEqualTo, 146 | updateRenderConfigFromDb, 147 | updateThemeSettings 148 | } 149 | 150 | async function updateRenderConfigFromDb (db) { 151 | const dbTheme = await db.collection(SETTINGS_COL) 152 | .findOne({ setting: THEME_SETTING }) 153 | if (dbTheme) { 154 | Object.assign(renderConfig, dbTheme) 155 | } 156 | } 157 | 158 | async function updateThemeSettings (db, data) { 159 | const result = await db.collection(SETTINGS_COL).findOneAndUpdate( 160 | { setting: THEME_SETTING }, 161 | { $set: data }, 162 | { upsert: true, returnDocument: 'after' } 163 | ) 164 | if (!result.ok) { 165 | throw new Error('Error saving settings') 166 | } 167 | // also update the settings in memory to affect renders immediately 168 | Object.assign(renderConfig, result.value) 169 | } 170 | -------------------------------------------------------------------------------- /src/streaming/SocketManager.js: -------------------------------------------------------------------------------- 1 | 2 | class SocketList extends Set { 3 | add (value) { 4 | if (typeof value.emit !== 'function') { 5 | console.warn('Ignoring invalid socket') 6 | return 7 | } 8 | return super.add(value) 9 | } 10 | 11 | /** 12 | * Call the emit method for every socket in the set with the provided args 13 | */ 14 | emitAll (...args) { 15 | for (const liveSocket of this.values()) { 16 | liveSocket.emit(...args) 17 | } 18 | } 19 | } 20 | /** 21 | * Map where every value is a SocketList set, 22 | * created on demand if not existing 23 | */ 24 | class SocketManager extends Map { 25 | /** 26 | * @param {string} key actor IRI 27 | * @returns {SocketList} set of live sockets for this user 28 | */ 29 | get (key) { 30 | if (!this.has(key)) { 31 | this.set(key, new SocketList()) 32 | } 33 | return super.get(key) 34 | } 35 | } 36 | 37 | module.exports = SocketManager 38 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const { parseDomain, ParseResultType } = require('parse-domain') 4 | 5 | module.exports = { 6 | parseHandle, 7 | parseProxyMode, 8 | debugOutput, 9 | apexDomain, 10 | readStaticFileSync 11 | } 12 | 13 | function debugOutput (app) { 14 | const l = (activity) => { 15 | const obj = activity.object?.[0]?.id ?? activity.object?.[0] 16 | const target = activity.target?.[0]?.id ?? activity.target?.[0] 17 | return `${activity.type}: from ${activity.actor[0].id ?? activity.actor[0]} to ${activity.to} obj ${obj} target ${target}` 18 | } 19 | app.on('apex-inbox', msg => console.log('---inbox----------\n', l(msg.activity))) 20 | app.on('apex-outbox', msg => console.log('---outbox---------\n', l(msg.activity))) 21 | } 22 | 23 | const handleReg = /([^@[]+)[@[]([^\]]+)/ 24 | 25 | function parseHandle (handle) { 26 | const match = handleReg.exec(handle) 27 | if (match && match.length === 3) { 28 | return { 29 | username: match[1], 30 | immer: match[2] 31 | } 32 | } 33 | } 34 | 35 | function parseProxyMode (proxyMode) { 36 | try { 37 | // boolean or number 38 | return JSON.parse(proxyMode) 39 | } catch (ignore) {} 40 | // string 41 | return proxyMode 42 | } 43 | 44 | function apexDomain (domain) { 45 | try { 46 | const url = new URL(`https://${domain}`) 47 | // use url.hostname to strip port from domain 48 | const parsedDomain = parseDomain(url.hostname) 49 | return parsedDomain.type === ParseResultType.Listed 50 | ? [parsedDomain.domain, ...parsedDomain.topLevelDomains].join('.') 51 | : url.hostname 52 | } catch (err) { 53 | console.warn(`Unable to parse apex domain: ${err.message}`) 54 | return domain 55 | } 56 | } 57 | 58 | /** Find a file in either local or docker static folder and readFileSync it */ 59 | function readStaticFileSync (file) { 60 | if (!file) { 61 | return 62 | } 63 | if (fs.existsSync(path.join(__dirname, '..', 'static-ext', file))) { 64 | // docker volume location 65 | return fs.readFileSync(path.join(__dirname, '..', 'static-ext', file), 'utf8') 66 | } else if (fs.existsSync(path.join(__dirname, '..', 'static', file))) { 67 | // internal default 68 | return fs.readFileSync(path.join(__dirname, '..', 'static', file), 'utf8') 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /static/immers-context.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": { 3 | "is": "https://immers.space/ns/v1#", 4 | "Model": "is:Model", 5 | "avatar": { 6 | "@id": "is:avatar", 7 | "@type": "@id" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /static/immers_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/immers-space/immers/54881f9fec74e9427f1b60898e2e0949ea3b4215/static/immers_logo.png -------------------------------------------------------------------------------- /static/reference-theme.css: -------------------------------------------------------------------------------- 1 | /* using :is to spike the specificity of :root */ 2 | :is(:root, #root) { 3 | /* picoCSS vars */ 4 | --font-family: system-ui, -apple-system, "Segoe UI", "Roboto", "Ubuntu", 5 | "Cantarell", "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", 6 | "Segoe UI Symbol", "Noto Color Emoji"; 7 | --line-height: 1.5; 8 | --font-weight: 400; 9 | --font-size: 16px; 10 | --border-radius: 0.25rem; 11 | --border-width: 1px; 12 | --outline-width: 3px; 13 | --spacing: 1rem; 14 | --typography-spacing-vertical: 1.5rem; 15 | --block-spacing-vertical: calc(var(--spacing) * 2); 16 | --block-spacing-horizontal: var(--spacing); 17 | --grid-spacing-vertical: 0; 18 | --grid-spacing-horizontal: var(--spacing); 19 | --form-element-spacing-vertical: 0.75rem; 20 | --form-element-spacing-horizontal: 1rem; 21 | --nav-element-spacing-vertical: 1rem; 22 | --nav-element-spacing-horizontal: 0.5rem; 23 | --nav-link-spacing-vertical: 0.5rem; 24 | --nav-link-spacing-horizontal: 0.5rem; 25 | --form-label-font-weight: var(--font-weight); 26 | --transition: 0.2s ease-in-out; 27 | --modal-overlay-backdrop-filter: blur(0.25rem); 28 | --background-color: #fff; 29 | --color: hsl(205deg, 20%, 32%); 30 | --h1-color: hsl(205deg, 30%, 15%); 31 | --h2-color: #24333e; 32 | --h3-color: hsl(205deg, 25%, 23%); 33 | --h4-color: #374956; 34 | --h5-color: hsl(205deg, 20%, 32%); 35 | --h6-color: #4d606d; 36 | --muted-color: hsl(205deg, 10%, 50%); 37 | --muted-border-color: hsl(205deg, 20%, 94%); 38 | --primary: hsl(195deg, 85%, 41%); 39 | --primary-hover: hsl(195deg, 90%, 32%); 40 | --primary-focus: rgba(16, 149, 193, 0.125); 41 | --primary-inverse: #fff; 42 | --secondary: hsl(205deg, 15%, 41%); 43 | --secondary-hover: hsl(205deg, 20%, 32%); 44 | --secondary-focus: rgba(89, 107, 120, 0.125); 45 | --secondary-inverse: #fff; 46 | --contrast: hsl(205deg, 30%, 15%); 47 | --contrast-hover: #000; 48 | --contrast-focus: rgba(89, 107, 120, 0.125); 49 | --contrast-inverse: #fff; 50 | --mark-background-color: #fff2ca; 51 | --mark-color: #543a26; 52 | --ins-color: #388e3c; 53 | --del-color: #c62828; 54 | --blockquote-border-color: var(--muted-border-color); 55 | --blockquote-footer-color: var(--muted-color); 56 | --button-box-shadow: 0 0 0 rgba(0, 0, 0, 0); 57 | --button-hover-box-shadow: 0 0 0 rgba(0, 0, 0, 0); 58 | --form-element-background-color: transparent; 59 | --form-element-border-color: hsl(205deg, 14%, 68%); 60 | --form-element-color: var(--color); 61 | --form-element-placeholder-color: var(--muted-color); 62 | --form-element-active-background-color: transparent; 63 | --form-element-active-border-color: var(--primary); 64 | --form-element-focus-color: var(--primary-focus); 65 | --form-element-disabled-background-color: hsl(205deg, 18%, 86%); 66 | --form-element-disabled-border-color: hsl(205deg, 14%, 68%); 67 | --form-element-disabled-opacity: 0.5; 68 | --form-element-invalid-border-color: #c62828; 69 | --form-element-invalid-active-border-color: #d32f2f; 70 | --form-element-invalid-focus-color: rgba(211, 47, 47, 0.125); 71 | --form-element-valid-border-color: #388e3c; 72 | --form-element-valid-active-border-color: #43a047; 73 | --form-element-valid-focus-color: rgba(67, 160, 71, 0.125); 74 | --switch-background-color: hsl(205deg, 16%, 77%); 75 | --switch-color: var(--primary-inverse); 76 | --switch-checked-background-color: var(--primary); 77 | --range-border-color: hsl(205deg, 18%, 86%); 78 | --range-active-border-color: hsl(205deg, 16%, 77%); 79 | --range-thumb-border-color: var(--background-color); 80 | --range-thumb-color: var(--secondary); 81 | --range-thumb-hover-color: var(--secondary-hover); 82 | --range-thumb-active-color: var(--primary); 83 | --table-border-color: var(--muted-border-color); 84 | --table-row-stripped-background-color: #f6f8f9; 85 | --code-background-color: hsl(205deg, 20%, 94%); 86 | --code-color: var(--muted-color); 87 | --code-kbd-background-color: var(--contrast); 88 | --code-kbd-color: var(--contrast-inverse); 89 | --code-tag-color: hsl(330deg, 40%, 50%); 90 | --code-property-color: hsl(185deg, 40%, 40%); 91 | --code-value-color: hsl(40deg, 20%, 50%); 92 | --code-comment-color: hsl(205deg, 14%, 68%); 93 | --accordion-border-color: var(--muted-border-color); 94 | --accordion-close-summary-color: var(--color); 95 | --accordion-open-summary-color: var(--muted-color); 96 | --card-background-color: var(--background-color); 97 | --card-border-color: var(--muted-border-color); 98 | --card-box-shadow: 99 | 0.0145rem 0.029rem 0.174rem rgba(27, 40, 50, 0.01698), 100 | 0.0335rem 0.067rem 0.402rem rgba(27, 40, 50, 0.024), 101 | 0.0625rem 0.125rem 0.75rem rgba(27, 40, 50, 0.03), 102 | 0.1125rem 0.225rem 1.35rem rgba(27, 40, 50, 0.036), 103 | 0.2085rem 0.417rem 2.502rem rgba(27, 40, 50, 0.04302), 104 | 0.5rem 1rem 6rem rgba(27, 40, 50, 0.06), 105 | 0 0 0 0.0625rem rgba(27, 40, 50, 0.015); 106 | --card-sectionning-background-color: #fbfbfc; 107 | --dropdown-background-color: #fbfbfc; 108 | --dropdown-border-color: #e1e6eb; 109 | --dropdown-box-shadow: var(--card-box-shadow); 110 | --dropdown-color: var(--color); 111 | --dropdown-hover-background-color: hsl(205deg, 20%, 94%); 112 | --modal-overlay-background-color: rgba(213, 220, 226, 0.7); 113 | --progress-background-color: hsl(205deg, 18%, 86%); 114 | --progress-color: var(--primary); 115 | --loading-spinner-opacity: 0.5; 116 | --tooltip-background-color: var(--contrast); 117 | --tooltip-color: var(--contrast-inverse); 118 | --icon-checkbox: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E"); 119 | --icon-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(65, 84, 98)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); 120 | --icon-chevron-button: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); 121 | --icon-chevron-button-inverse: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); 122 | --icon-close: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(115, 130, 140)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E"); 123 | --icon-date: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(65, 84, 98)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E"); 124 | --icon-invalid: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(198, 40, 40)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E"); 125 | --icon-minus: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E"); 126 | --icon-search: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(65, 84, 98)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E"); 127 | --icon-time: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(65, 84, 98)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E"); 128 | --icon-valid: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(56, 142, 60)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E"); 129 | 130 | /* additional immers vars */ 131 | --nav-background-color: var(--background-color); 132 | --nav-box-shadow: var(--card-box-shadow); 133 | --tab-border-radius: var(--border-radius); 134 | --tab-box-shadow: none; 135 | --emoji-button-spacing: var(--spacing); 136 | --emoji-button-background-color: transparent; 137 | --emoji-button-color: var(--secondary); 138 | --emoji-button-border-color: var(--secondary); 139 | --attribution-link-color: var(--secondary); 140 | --offset-for-navbar-height: calc(2rem + var(--nav-element-spacing-vertical) + 5px); 141 | --enter-button-backround-color: var(--primary); 142 | --enter-button-border-color: var(--primary); 143 | --enter-button-color: var(--primary-inverse); 144 | --main-title-color: var(--h1-color); 145 | --backdrop-filter: none; 146 | --navbar-actions-border: var(--border-width) solid var(--secondary); 147 | --card-sectioning-footer-color: var(--card-sectionning-background-color); 148 | } -------------------------------------------------------------------------------- /static/twitter-player.html: -------------------------------------------------------------------------------- 1 | 17 | 18 | 19 | 20 | <code><model-viewer></code> Player 21 | 22 | 23 | 24 | 25 | 26 | 27 | 57 | 58 | 59 | 60 | 71 | 72 | 89 | -------------------------------------------------------------------------------- /static/vapor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/immers-space/immers/54881f9fec74e9427f1b60898e2e0949ea3b4215/static/vapor.png -------------------------------------------------------------------------------- /static/vaporwave-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/immers-space/immers/54881f9fec74e9427f1b60898e2e0949ea3b4215/static/vaporwave-icon.png -------------------------------------------------------------------------------- /static/welcome.html: -------------------------------------------------------------------------------- 1 |
2 |

Welcome to Immers Space!

3 |

Make friends and explore the metaverse.

4 |
5 | -------------------------------------------------------------------------------- /views/admin/Admin.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/immers-space/immers/54881f9fec74e9427f1b60898e2e0949ea3b4215/views/admin/Admin.css -------------------------------------------------------------------------------- /views/admin/Admin.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react' 2 | import { Routes, Route } from 'react-router-dom' 3 | import ServerDataContext from '../ap/ServerDataContext' 4 | import { useCheckAdmin } from '../ap/utils/useCheckAdmin' 5 | import LayoutLoader from '../components/LayoutLoader' 6 | import AdminNavigation from './AdminNavigation' 7 | import EditTheme from './EditTheme' 8 | import OauthAdmin from './OauthAdmin' 9 | 10 | export default function Admin ({ taskbarButtons }) { 11 | const { token } = useContext(ServerDataContext) 12 | const loading = !useCheckAdmin(token, true) 13 | return loading 14 | ? 15 | : ( 16 | 17 | } /> 18 | } /> 19 | } /> 20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /views/admin/AdminNavigation.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react' 2 | import { Link } from 'react-router-dom' 3 | import EmojiButton from '../components/EmojiButton' 4 | import ServerDataContext from '../ap/ServerDataContext' 5 | import Layout from '../components/Layout' 6 | 7 | export default function AdminNavigation ({ taskbarButtons }) { 8 | const { loggedInUser } = useContext(ServerDataContext) 9 | const buttons = 10 | return ( 11 | 12 | 20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /views/admin/EditTheme.js: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect, useState } from 'react' 2 | import { useNavigate } from 'react-router-dom' 3 | import ServerDataContext from '../ap/ServerDataContext' 4 | import EmojiButton from '../components/EmojiButton' 5 | import Layout from '../components/Layout' 6 | 7 | export default function EditTheme ({ taskbarButtons }) { 8 | const ctx = useContext(ServerDataContext) 9 | const navigate = useNavigate() 10 | const [baseTheme, setBaseTheme] = useState(ctx.baseTheme || 'auto') 11 | const [customTheme, setCustomTheme] = useState(ctx.customTheme || '') 12 | const [saving, setSaving] = useState(false) 13 | const baseThemeOpts = [ 14 | { label: 'Auto light/dark per user preference', value: 'auto' }, 15 | { label: 'Light', value: 'light' }, 16 | { label: 'Dark', value: 'dark' }, 17 | { label: 'Web95', value: 'web95' } 18 | ] 19 | const buttons = 20 | /* apply settings to page for insant preview */ 21 | useEffect(() => { 22 | const setTheme = (theme) => { 23 | if (theme === 'auto') { 24 | document.documentElement.removeAttribute('data-theme') 25 | } else { 26 | document.documentElement.setAttribute('data-theme', theme) 27 | } 28 | } 29 | setTheme(baseTheme) 30 | // restore style on cancel/back 31 | return () => setTheme(ctx.baseTheme) 32 | }, [baseTheme, ctx]) 33 | useEffect(() => { 34 | const themeEl = document.querySelector('style#custom-theme') 35 | themeEl.innerHTML = `:is(:root, #root) {\n${customTheme}\n}` 36 | // restore style on cancel/back 37 | return () => (themeEl.innerHTML = `:is(:root, #root) {\n${ctx.customTheme}\n}`) 38 | }, [customTheme, ctx]) 39 | const handleCancel = () => { 40 | navigate('..') 41 | } 42 | const handleSave = async () => { 43 | setSaving(true) 44 | await window.fetch('/a/settings/theme', { 45 | method: 'PUT', 46 | body: JSON.stringify({ 47 | baseTheme: baseTheme === 'auto' ? undefined : baseTheme, 48 | customTheme 49 | }), 50 | headers: { 51 | 'Content-Type': 'application/json', 52 | Authorization: `Bearer ${ctx.token}` 53 | } 54 | }) 55 | setSaving(false) 56 | ctx.baseTheme = baseTheme 57 | ctx.customTheme = customTheme 58 | navigate('..') 59 | } 60 | const handleBaseChange = evt => setBaseTheme(evt.target.value) 61 | const handleCustomChange = evt => setCustomTheme(evt.target.value) 62 | return ( 63 | 64 |

Theme Editor

65 |
66 | Base theme 67 | {baseThemeOpts.map(({ label, value }) => ( 68 | 78 | ))} 79 |
80 |