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 | [](https://opencollective.com/immers-space)
211 |
212 |
213 | ## Immerser Members
214 |
215 | [](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 | <model-viewer> 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 |
20 | {!passEmailToHub && We don't save your e-mail, just an encrypted hash of it for password resets.}{' '}
21 | {emailOptInURL && (Click here to opt-in to our e-mail contact list.)}
22 |
40 | Hi {this.state.username}!
41 | You're headed to {
42 | this.state.clientName
43 | ? {this.state.clientName} ({this.state.redirectUri}).
44 | : {this.state.redirectUri}
45 | }
46 |
47 | How would you like to use your account while you're there?
48 |
159 | We found an existing account for your e-mail address
160 | and sent you an e-mail to confirm you want to allow login
161 | via {providerName}. Once you click the authorization link
162 | in the email, this login will proceed automatically
163 |