63 Commits
1.1.2 ... 1.2.0

Author SHA1 Message Date
MartinBraquet
b30af128c7 Release 2025-10-07 22:11:34 +02:00
MartinBraquet
72c31ae097 Add compat score math video link 2025-10-07 21:25:08 +02:00
Martin Braquet
d2c608021d Improve home (#10) 2025-10-05 10:38:04 +02:00
Martin Braquet
1f36fb2413 Update faq.md 2025-10-05 10:14:09 +02:00
Martin Braquet
16a0cbcecf Update about.tsx 2025-10-05 10:00:30 +02:00
Martin Braquet
e068e246aa Update faq.md 2025-10-03 13:58:31 +02:00
MartinBraquet
ec7c77fcf9 Log 2025-10-02 15:14:13 +02:00
MartinBraquet
46a338b874 Clean 2025-10-02 14:54:47 +02:00
MartinBraquet
bfee7ff09d Fix age rendering 2025-10-02 14:14:24 +02:00
MartinBraquet
ce1305d8ae Host logos 2025-10-02 13:17:22 +02:00
MartinBraquet
aaebf88438 Log profile count 2025-10-01 09:02:50 +02:00
MartinBraquet
dde2c99e36 Fix 2025-10-01 08:58:11 +02:00
MartinBraquet
4dc2f3b9b9 Add Community Growth over Time 2025-10-01 08:56:03 +02:00
MartinBraquet
f30cfffb86 Fix bio parsing grid 2025-09-30 22:17:28 +02:00
MartinBraquet
ca3eb62ba7 Fix 2 2025-09-30 21:48:15 +02:00
MartinBraquet
c8e55ca4ce Fix 2025-09-30 21:46:31 +02:00
MartinBraquet
e4acb25a40 Better render headings and lists in profile grid 2025-09-30 21:45:09 +02:00
MartinBraquet
c741e10139 Stop spamming prod discord channels 2025-09-30 21:30:38 +02:00
MartinBraquet
28d0b35f8e Move discord move to create profile 2025-09-30 20:53:26 +02:00
MartinBraquet
f7f09cd9e5 Send message to Discord when reaching 50, 100, ..., users 2025-09-28 21:09:11 +02:00
MartinBraquet
501c92c350 Send discord message at every profile creation 2025-09-28 20:47:31 +02:00
MartinBraquet
f021101322 Remove unused React 2025-09-26 22:17:24 +02:00
MartinBraquet
369265bc2c Add firebase storage backup script 2025-09-26 22:12:08 +02:00
Martin Braquet
b1f1e5db1f Fully delete profile in database and Firebase auth + storage (#7) 2025-09-26 22:10:38 +02:00
MartinBraquet
51d32e5afb Link donations in FAQ 2025-09-26 13:52:40 +02:00
MartinBraquet
f396e8e482 Your filters 2025-09-26 13:51:08 +02:00
MartinBraquet
077321731e Add bio warning message 2025-09-25 23:02:04 +02:00
MartinBraquet
60eb0c6978 Add 'datingdoc', 'friendshipdoc', 'connectiondoc', 'workdoc' 2025-09-25 22:49:54 +02:00
MartinBraquet
475f0af78a Add supabase backup VM to Firebase storage and discord error hook 2025-09-24 15:59:46 +02:00
MartinBraquet
206fa07035 Ignore tf 2025-09-24 15:13:33 +02:00
MartinBraquet
aff949714c Add charts example 2025-09-24 13:28:46 +02:00
Martin Braquet
7e834b9ff6 Update FUNDING.yml 2025-09-24 12:46:20 +02:00
Martin Braquet
19bad26a98 Create FUNDING.yml 2025-09-24 12:44:37 +02:00
MartinBraquet
7cc7c8d27b Hide age, city and gender if null 2025-09-22 11:06:52 +02:00
MartinBraquet
ae5a8c7cfa Add page loading warning 2025-09-22 11:02:53 +02:00
MartinBraquet
5004b73210 Fix calendly link 2025-09-22 10:52:46 +02:00
MartinBraquet
02f613d269 Add Ko-fi donation link 2025-09-22 10:42:23 +02:00
MartinBraquet
439ac0310b Add option to delete an answered compatibility prompt 2025-09-22 10:09:13 +02:00
MartinBraquet
3e95467819 Add okcupid and calendly links 2025-09-22 00:06:05 +02:00
MartinBraquet
90522cb88b Comment log 2025-09-22 00:05:15 +02:00
MartinBraquet
af39b01d4a Reload env config after setting env vars 2025-09-21 16:56:42 +02:00
MartinBraquet
73a0a5ff0b Fix vercel env 2025-09-21 16:08:29 +02:00
MartinBraquet
e157f500bc Fix dev firebase admin key 2025-09-21 16:00:29 +02:00
MartinBraquet
274ee5ed5f Remove json 2025-09-21 15:44:33 +02:00
MartinBraquet
4cb11ba8c0 Remove log 2025-09-21 15:00:26 +02:00
MartinBraquet
7b8e775139 Fix google cloud env 2025-09-21 14:55:32 +02:00
MartinBraquet
86a7d26bfd Clean readme 2025-09-20 23:54:56 +02:00
MartinBraquet
84a437772d Make local DEV work out of the box 2025-09-20 23:51:28 +02:00
MartinBraquet
d7c95e2ae0 Clean ENV 2025-09-20 18:26:03 +02:00
MartinBraquet
b4f0ef8b43 Move supabase dev pwd inside code 2025-09-20 18:12:46 +02:00
MartinBraquet
6d30cd7ae4 Add open source to FAQ 2025-09-19 14:47:58 +02:00
MartinBraquet
f631236ee7 Add platform to FAQ 2025-09-19 14:42:36 +02:00
MartinBraquet
1a58ff5c4c Shuffle prompts 2025-09-18 15:33:23 +02:00
MartinBraquet
73aca913a1 Fix 2025-09-18 14:14:58 +02:00
MartinBraquet
24dee0cad6 Add bio char template 2025-09-18 13:34:29 +02:00
MartinBraquet
2d2de75372 Add bio tips 2025-09-18 13:12:48 +02:00
MartinBraquet
d98982e6fd Factor out links 2025-09-18 11:30:59 +02:00
MartinBraquet
14c12ffb08 Rename 2025-09-18 11:19:09 +02:00
MartinBraquet
f260afca11 Ignore 2025-09-18 11:18:22 +02:00
MartinBraquet
5bcbe25d97 Rm 2025-09-18 11:18:04 +02:00
MartinBraquet
2eee366fbd Update financials 2025-09-18 11:12:33 +02:00
MartinBraquet
85d57ec5e6 Fix resend email limit 2/sec 2025-09-17 18:35:29 +02:00
MartinBraquet
502c878f82 Fix 2025-09-17 18:15:02 +02:00
158 changed files with 1833 additions and 626 deletions

View File

@@ -1,20 +1,7 @@
# Rename this file to `.env` and fill in the values.
# You already have access to basic local functionality (UI, authentication, database read access).
# Optional variables for the backend server functionality (modifying user data, etc.)
# For database write access (dev).
# A 16-character password with digits and letters.
SUPABASE_DB_PASSWORD=09wATRREfAzyL5pc
# For Firebase access.
# Open a GitHub issue with your contribution ideas and an admin will give you the key.
# TODO: find a way to give anyone moderate access to dev firebase.
GOOGLE_APPLICATION_CREDENTIALS_DEV="[...].json"
# The URL where your local backend server is running.
# You can change the port if needed.
NEXT_PUBLIC_API_URL=localhost:8088
# openssl enc -aes-256-cbc -salt -pbkdf2 -iter 100000 -in backend/shared/src/googleApplicationCredentials-dev.json -out secrets/googleApplicationCredentials-dev.json.enc
GOOGLE_CREDENTIALS_ENC_PWD=nP7s3274uzOG4c2t
# Optional variables for full local functionality
@@ -23,10 +10,6 @@ NEXT_PUBLIC_API_URL=localhost:8088
# Create a free account at https://rapidapi.com/wirefreethought/api/geodb-cities and get an API key.
GEODB_API_KEY=
# For analytics like page views, user actions, feature usage, etc.
# Create a free account at https://posthog.com and get a project API key. Should start with "phc_".
POSTHOG_KEY=
# For sending emails (e.g. for user sign up, password reset, notifications, etc.).
# Create a free account at https://resend.com and get an API key. Should start with "re_".
RESEND_API_KEY=
RESEND_KEY=

15
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: CompassMeet # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: compassconnections # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

6
.gitignore vendored
View File

@@ -79,3 +79,9 @@ email-preview
*.zip
*.tar.gz
*.rar
/favicon_color.ico
/backend/shared/src/googleApplicationCredentials-dev.json
*.tfstate
*.tfstate.backup
*.terraform
/backups/firebase/storage/data/

View File

@@ -100,20 +100,17 @@ yarn install
### Environment Variables
Almost all the features will work out of the box, so you can skip this step and come back later if you need to test the following services: email, geolocation.
We can't make the following information public, for security and privacy reasons:
- Database, otherwise anyone could access all the user data (including private messages)
- Firebase, otherwise anyone could remove users or modify the media files
- Email, analytics, and location services, otherwise anyone could use our paid plan
We separate all those services between production and local development, so that you can code freely without impacting the functioning of the platform.
That's why we separate all those services between production and development environments, so that you can code freely without impacting the functioning of the deployed platform.
Contributors should use the default keys for local development. Production uses a separate environment with stricter rules and private keys that are not shared.
Most of the code will work out of the box. All you need to do is creating an `.env` file as a copy of `.env.example`:
```bash
cp .env.example .env
```
If you do need one of the few remaining services, you need to store your own secrets as environment variables. To do so, simply open `.env` and fill in the variables according to the instructions in the file.
If you do need one of the few remaining services, you need to set them up and store your own secrets as environment variables. To do so, simply open `.env` and fill in the variables according to the instructions in the file.
### Tests
@@ -132,6 +129,8 @@ yarn dev
Once the server is running, visit http://localhost:3000 to start using the app. You can sign up and visit the profiles; you should see a few synthetic profiles.
Note: it's normal if page loading locally is much slower than the deployed version. It can take up to 10 seconds, it would be great to improve that though!
### Contributing
Now you can start contributing by making changes and submitting pull requests!

View File

View File

View File

View File

@@ -1,6 +1,5 @@
'use client';
import {aColor, supportEmail} from "@/lib/client/constants";
import Image from 'next/image';
export default function PrivacyPage() {

View File

View File

@@ -0,0 +1,2 @@
'use client';

View File

View File

View File

View File

View File

View File

View File

View File

@@ -7,6 +7,7 @@ import { track } from 'shared/analytics'
import { updateUser } from 'shared/supabase/users'
import { tryCatch } from 'common/util/try-catch'
import { insert } from 'shared/supabase/utils'
import {sendDiscordMessage} from "common/discord/core";
export const createProfile: APIHandler<'create-profile'> = async (body, auth) => {
const pg = createSupabaseDirectClient()
@@ -40,7 +41,49 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
}
log('Created user', data)
await track(user.id, 'create profile', { username: user.username })
return data
const continuation = async () => {
try {
await track(auth.uid, 'create profile', {username: user.username})
} catch (e) {
console.log('Failed to track create profile', e)
}
try {
await sendDiscordMessage(
`**${user.name}** just created a profile at https://www.compassmeet.com/${user.username}`,
'members',
)
} catch (e) {
console.log('Failed to send discord new profile', e)
}
try {
const nProfiles = await pg.one<number>(
`SELECT count(*) FROM profiles`,
[],
(r) => Number(r.count)
)
const isMilestone = (n: number) => {
return (
[15, 20, 30, 40].includes(n) || // early milestones
n % 50 === 0
)
}
console.log(nProfiles, isMilestone(nProfiles))
if (isMilestone(nProfiles)) {
await sendDiscordMessage(
`We just reached **${nProfiles}** total profiles! 🎉`,
'general',
)
}
} catch (e) {
console.log('Failed to send discord user milestone', e)
}
}
return {
result: data,
continue: continuation,
}
}

View File

@@ -1,27 +1,25 @@
import * as admin from 'firebase-admin'
import { PrivateUser } from 'common/user'
import { randomString } from 'common/util/random'
import { cleanDisplayName, cleanUsername } from 'common/util/clean-username'
import { getIp, track } from 'shared/analytics'
import { APIError, APIHandler } from './helpers/endpoint'
import { getDefaultNotificationPreferences } from 'common/user-notification-preferences'
import { removeUndefinedProps } from 'common/util/object'
import { generateAvatarUrl } from 'shared/helpers/generate-and-update-avatar-urls'
import { getStorage } from 'firebase-admin/storage'
import { DEV_CONFIG } from 'common/envs/dev'
import { PROD_CONFIG } from 'common/envs/prod'
import { RESERVED_PATHS } from 'common/envs/constants'
import { log, isProd, getUser, getUserByUsername } from 'shared/utils'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { insert } from 'shared/supabase/utils'
import { convertPrivateUser, convertUser } from 'common/supabase/users'
import {PrivateUser} from 'common/user'
import {randomString} from 'common/util/random'
import {cleanDisplayName, cleanUsername} from 'common/util/clean-username'
import {getIp, track} from 'shared/analytics'
import {APIError, APIHandler} from './helpers/endpoint'
import {getDefaultNotificationPreferences} from 'common/user-notification-preferences'
import {removeUndefinedProps} from 'common/util/object'
import {generateAvatarUrl} from 'shared/helpers/generate-and-update-avatar-urls'
import {RESERVED_PATHS} from 'common/envs/constants'
import {getUser, getUserByUsername, log} from 'shared/utils'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {insert} from 'shared/supabase/utils'
import {convertPrivateUser, convertUser} from 'common/supabase/users'
import {getBucket} from "shared/firebase-utils";
export const createUser: APIHandler<'create-user'> = async (
props,
auth,
req
) => {
const { deviceToken: preDeviceToken } = props
const {deviceToken: preDeviceToken} = props
const firebaseUser = await admin.auth().getUser(auth.uid)
const testUserAKAEmailPasswordUser =
@@ -52,7 +50,7 @@ export const createUser: APIHandler<'create-user'> = async (
const rawName = fbUser.displayName || emailName || 'User' + randomString(4)
const name = cleanDisplayName(rawName)
const bucket = getStorage().bucket(getStorageBucketId())
const bucket = getBucket()
const avatarUrl = fbUser.photoURL
? fbUser.photoURL
: await generateAvatarUrl(auth.uid, name, bucket)
@@ -63,7 +61,9 @@ export const createUser: APIHandler<'create-user'> = async (
// Check username case-insensitive
const dupes = await pg.one<number>(
`select count(*) from users where username ilike $1`,
`select count(*)
from users
where username ilike $1`,
[username],
(r) => r.count
)
@@ -71,7 +71,7 @@ export const createUser: APIHandler<'create-user'> = async (
const isReservedName = RESERVED_PATHS.includes(username)
if (usernameExists || isReservedName) username += randomString(4)
const { user, privateUser } = await pg.tx(async (tx) => {
const {user, privateUser} = await pg.tx(async (tx) => {
const preexistingUser = await getUser(auth.uid, tx)
if (preexistingUser)
throw new APIError(403, 'User already exists', {
@@ -81,13 +81,13 @@ export const createUser: APIHandler<'create-user'> = async (
// Check exact username to avoid problems with duplicate requests
const sameNameUser = await getUserByUsername(username, tx)
if (sameNameUser)
throw new APIError(403, 'Username already taken', { username })
throw new APIError(403, 'Username already taken', {username})
const user = removeUndefinedProps({
avatarUrl,
isBannedFromPosting: Boolean(
(deviceToken && bannedDeviceTokens.includes(deviceToken)) ||
(ip && bannedIpAddresses.includes(ip))
(ip && bannedIpAddresses.includes(ip))
),
link: {},
})
@@ -120,10 +120,14 @@ export const createUser: APIHandler<'create-user'> = async (
}
})
log('created user ', { username: user.username, firebaseId: auth.uid })
log('created user ', {username: user.username, firebaseId: auth.uid})
const continuation = async () => {
await track(auth.uid, 'create profile', { username: user.username })
try {
await track(auth.uid, 'create profile', {username: user.username})
} catch (e) {
console.log('Failed to track create profile', e)
}
}
return {
@@ -135,12 +139,6 @@ export const createUser: APIHandler<'create-user'> = async (
}
}
function getStorageBucketId() {
return isProd()
? PROD_CONFIG.firebaseConfig.storageBucket
: DEV_CONFIG.firebaseConfig.storageBucket
}
// Automatically ban users with these device tokens or ip addresses.
const bannedDeviceTokens = [
'fa807d664415',

View File

@@ -1,11 +1,11 @@
import { getUser } from 'shared/utils'
import { APIError, APIHandler } from './helpers/endpoint'
import { updatePrivateUser, updateUser } from 'shared/supabase/users'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { FieldVal } from 'shared/supabase/utils'
import {getUser} from 'shared/utils'
import {APIError, APIHandler} from './helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import * as admin from "firebase-admin";
import {deleteUserFiles} from "shared/firebase-utils";
export const deleteMe: APIHandler<'me/delete'> = async (body, auth) => {
const { username } = body
const {username} = body
const user = await getUser(auth.uid)
if (!user) {
throw new APIError(401, 'Your account was not found')
@@ -16,13 +16,27 @@ export const deleteMe: APIHandler<'me/delete'> = async (body, auth) => {
`Incorrect username. You are logged in as ${user.username}. Are you sure you want to delete this account?`
)
}
const userId = user.id
if (!userId) {
throw new APIError(400, 'Invalid user ID')
}
// Remove user data from Supabase
const pg = createSupabaseDirectClient()
await updateUser(pg, auth.uid, {
userDeleted: true,
isBannedFromPosting: true,
})
await updatePrivateUser(pg, auth.uid, {
email: FieldVal.delete(),
})
await pg.none('DELETE FROM users WHERE id = $1', [userId])
await pg.none('DELETE FROM private_users WHERE id = $1', [userId])
await pg.none('DELETE FROM profiles WHERE user_id = $1', [userId])
// May need to also delete from other tables in the future (such as messages, compatibility responses, etc.)
// Delete user files from Firebase Storage
await deleteUserFiles(user.username)
// Remove user from Firebase Auth
try {
const auth = admin.auth()
await auth.deleteUser(userId)
console.log(`Deleted user ${userId} from Firebase Auth and Supabase`)
} catch (e) {
console.error('Error deleting user from Firebase Auth:', e)
}
}

View File

@@ -2,12 +2,21 @@ import { type APIHandler } from 'api/helpers/endpoint'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { Row } from 'common/supabase/utils'
export function shuffle<T>(array: T[]): T[] {
const arr = [...array]; // copy to avoid mutating the original
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
export const getCompatibilityQuestions: APIHandler<
'get-compatibility-questions'
> = async (_props, _auth) => {
const pg = createSupabaseDirectClient()
const questions = await pg.manyOrNone<
const dbQuestions = await pg.manyOrNone<
Row<'love_questions'> & { answer_count: number; score: number }
>(
`SELECT
@@ -28,11 +37,12 @@ export const getCompatibilityQuestions: APIHandler<
[]
)
if (false)
console.log(
'got questions',
questions.map((q) => q.question + ' ' + q.score)
)
const questions = shuffle(dbQuestions)
// console.log(
// 'got questions',
// questions.map((q) => q.question + ' ' + q.score)
// )
return {
status: 'success',

View File

@@ -1,8 +1,6 @@
import { sign } from 'jsonwebtoken'
import { APIError, APIHandler } from './helpers/endpoint'
import { DEV_CONFIG } from 'common/envs/dev'
import { PROD_CONFIG } from 'common/envs/prod'
import { isProd } from 'shared/utils'
import {sign} from 'jsonwebtoken'
import {APIError, APIHandler} from './helpers/endpoint'
import {ENV_CONFIG} from "common/envs/constants";
export const getSupabaseToken: APIHandler<'get-supabase-token'> = async (
_,
@@ -12,21 +10,17 @@ export const getSupabaseToken: APIHandler<'get-supabase-token'> = async (
if (jwtSecret == null) {
throw new APIError(500, "No SUPABASE_JWT_SECRET; couldn't sign token.")
}
const instanceId = isProd()
? PROD_CONFIG.supabaseInstanceId
: DEV_CONFIG.supabaseInstanceId
const instanceId = ENV_CONFIG.supabaseInstanceId
if (!instanceId) {
throw new APIError(500, 'No Supabase instance ID in config.')
}
const payload = { role: 'anon' } // postgres role
const payload = {role: 'anon'} // postgres role
return {
jwt: sign(payload, jwtSecret, {
algorithm: 'HS256', // same as what supabase uses for its auth tokens
expiresIn: '1d',
audience: instanceId,
issuer: isProd()
? PROD_CONFIG.firebaseConfig.projectId
: DEV_CONFIG.firebaseConfig.projectId,
issuer: ENV_CONFIG.firebaseConfig.projectId,
subject: auth.uid,
}),
}

View File

@@ -1,35 +1,29 @@
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { Request, Response, NextFunction } from 'express'
import {z} from 'zod'
import {NextFunction, Request, Response} from 'express'
import { PrivateUser } from 'common/user'
import { APIError } from 'common/api/utils'
export { APIError } from 'common/api/utils'
import {
API,
APIPath,
APIResponseOptionalContinue,
APISchema,
ValidatedAPIParams,
} from 'common/api/schema'
import { log } from 'shared/utils'
import { getPrivateUserByKey } from 'shared/utils'
import {PrivateUser} from 'common/user'
import {APIError} from 'common/api/utils'
import {API, APIPath, APIResponseOptionalContinue, APISchema, ValidatedAPIParams,} from 'common/api/schema'
import {getPrivateUserByKey, log} from 'shared/utils'
export type Json = Record<string, unknown> | Json[]
export type JsonHandler<T extends Json> = (
req: Request,
res: Response
) => Promise<T>
export type AuthedHandler<T extends Json> = (
req: Request,
user: AuthedUser,
res: Response
) => Promise<T>
export type MaybeAuthedHandler<T extends Json> = (
req: Request,
user: AuthedUser | undefined,
res: Response
) => Promise<T>
export {APIError} from 'common/api/utils'
// export type Json = Record<string, unknown> | Json[]
// export type JsonHandler<T extends Json> = (
// req: Request,
// res: Response
// ) => Promise<T>
// export type AuthedHandler<T extends Json> = (
// req: Request,
// user: AuthedUser,
// res: Response
// ) => Promise<T>
// export type MaybeAuthedHandler<T extends Json> = (
// req: Request,
// user: AuthedUser | undefined,
// res: Response
// ) => Promise<T>
export type AuthedUser = {
uid: string
@@ -39,6 +33,29 @@ type JwtCredentials = { kind: 'jwt'; data: admin.auth.DecodedIdToken }
type KeyCredentials = { kind: 'key'; data: string }
type Credentials = JwtCredentials | KeyCredentials
// export async function verifyIdToken(payload: string): Promise<DecodedIdToken> {
// TODO: make local dev work without firebase admin SDK setup.
// if (IS_LOCAL) {
// // Skip real verification locally (to avoid needing to set up admin service account).
// return {
// aud: "",
// auth_time: 0,
// email_verified: false,
// exp: 0,
// firebase: {identities: {}, sign_in_provider: ""},
// iat: 0,
// iss: "",
// phone_number: "",
// picture: "",
// sub: "",
// uid: 'dev-user',
// user_id: 'dev-user',
// email: 'dev-user@example.com'
// };
// }
// return await admin.auth().verifyIdToken(payload);
// }
export const parseCredentials = async (req: Request): Promise<Credentials> => {
const auth = admin.auth()
const authHeader = req.get('Authorization')
@@ -57,14 +74,14 @@ export const parseCredentials = async (req: Request): Promise<Credentials> => {
throw new APIError(401, 'Firebase JWT payload undefined.')
}
try {
return { kind: 'jwt', data: await auth.verifyIdToken(payload) }
return {kind: 'jwt', data: await auth.verifyIdToken(payload)}
} catch (err) {
// This is somewhat suspicious, so get it into the firebase console
console.error('Error verifying Firebase JWT: ', err, scheme, payload)
throw new APIError(500, 'Error validating token.')
}
case 'Key':
return { kind: 'key', data: payload }
return {kind: 'key', data: payload}
default:
throw new APIError(401, 'Invalid auth scheme; must be "Key" or "Bearer".')
}
@@ -76,7 +93,7 @@ export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => {
if (typeof creds.data.user_id !== 'string') {
throw new APIError(401, 'JWT must contain user ID.')
}
return { uid: creds.data.user_id, creds }
return {uid: creds.data.user_id, creds}
}
case 'key': {
const key = creds.data
@@ -84,7 +101,7 @@ export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => {
if (!privateUser) {
throw new APIError(401, `No private user exists with API key ${key}.`)
}
return { uid: privateUser.id, creds: { privateUser, ...creds } }
return {uid: privateUser.id, creds: {privateUser, ...creds}}
}
default:
throw new APIError(401, 'Invalid credential type.')
@@ -109,45 +126,45 @@ export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => {
}
}
export const jsonEndpoint = <T extends Json>(fn: JsonHandler<T>) => {
return async (req: Request, res: Response, next: NextFunction) => {
try {
res.status(200).json(await fn(req, res))
} catch (e) {
next(e)
}
}
}
export const authEndpoint = <T extends Json>(fn: AuthedHandler<T>) => {
return async (req: Request, res: Response, next: NextFunction) => {
try {
const authedUser = await lookupUser(await parseCredentials(req))
res.status(200).json(await fn(req, authedUser, res))
} catch (e) {
next(e)
}
}
}
export const MaybeAuthedEndpoint = <T extends Json>(
fn: MaybeAuthedHandler<T>
) => {
return async (req: Request, res: Response, next: NextFunction) => {
let authUser: AuthedUser | undefined = undefined
try {
authUser = await lookupUser(await parseCredentials(req))
} catch {
// it's treated as an anon request
}
try {
res.status(200).json(await fn(req, authUser, res))
} catch (e) {
next(e)
}
}
}
// export const jsonEndpoint = <T extends Json>(fn: JsonHandler<T>) => {
// return async (req: Request, res: Response, next: NextFunction) => {
// try {
// res.status(200).json(await fn(req, res))
// } catch (e) {
// next(e)
// }
// }
// }
//
// export const authEndpoint = <T extends Json>(fn: AuthedHandler<T>) => {
// return async (req: Request, res: Response, next: NextFunction) => {
// try {
// const authedUser = await lookupUser(await parseCredentials(req))
// res.status(200).json(await fn(req, authedUser, res))
// } catch (e) {
// next(e)
// }
// }
// }
//
// export const MaybeAuthedEndpoint = <T extends Json>(
// fn: MaybeAuthedHandler<T>
// ) => {
// return async (req: Request, res: Response, next: NextFunction) => {
// let authUser: AuthedUser | undefined = undefined
// try {
// authUser = await lookupUser(await parseCredentials(req))
// } catch {
// // it's treated as an anon request
// }
//
// try {
// res.status(200).json(await fn(req, authUser, res))
// } catch (e) {
// next(e)
// }
// }
// }
export type APIHandler<N extends APIPath> = (
props: ValidatedAPIParams<N>,
@@ -161,7 +178,7 @@ export const typedEndpoint = <N extends APIPath>(
name: N,
handler: APIHandler<N>
) => {
const { props: propSchema, authed: authRequired, method } = API[name]
const {props: propSchema, authed: authRequired, method} = API[name]
return async (req: Request, res: Response, next: NextFunction) => {
let authUser: AuthedUser | undefined = undefined
@@ -195,7 +212,7 @@ export const typedEndpoint = <N extends APIPath>(
// Convert bigint to number, b/c JSON doesn't support bigint.
const convertedResult = deepConvertBigIntToNumber(result)
res.status(200).json(convertedResult ?? { success: true })
res.status(200).json(convertedResult ?? {success: true})
}
if (hasContinue) {

View File

@@ -1,14 +1,14 @@
import * as admin from 'firebase-admin'
import {getLocalEnv, initAdmin} from 'shared/init-admin'
import {loadSecretsToEnv, getServiceAccountCredentials} from 'common/secrets'
import {initAdmin} from 'shared/init-admin'
import {loadSecretsToEnv} from 'common/secrets'
import {log} from 'shared/utils'
import {LOCAL_DEV} from "common/envs/constants";
import {IS_LOCAL} from "common/envs/constants";
import {METRIC_WRITER} from 'shared/monitoring/metric-writer'
import {listen as webSocketListen} from 'shared/websockets/server'
log('Api server starting up....')
if (LOCAL_DEV) {
if (IS_LOCAL) {
initAdmin()
} else {
const projectId = process.env.GOOGLE_CLOUD_PROJECT
@@ -21,9 +21,10 @@ if (LOCAL_DEV) {
METRIC_WRITER.start()
import {app} from './app'
import {getServiceAccountCredentials} from "shared/firebase-utils";
const credentials = LOCAL_DEV
? getServiceAccountCredentials(getLocalEnv())
const credentials = IS_LOCAL
? getServiceAccountCredentials()
: // No explicit credentials needed for deployed service.
undefined
@@ -37,6 +38,5 @@ const startupProcess = async () => {
})
webSocketListen(httpServer, '/ws')
log('Server started successfully')
}
startupProcess()
startupProcess().then(r => log('Server started successfully'))

View File

@@ -5,6 +5,10 @@ import {
} from 'resend'
import { log } from 'shared/utils'
import pLimit from 'p-limit'
const limit = pLimit(1) // 1 concurrent per second
/*
* typically: { subject: string, to: string | string[] } & ({ text: string } | { react: ReactNode })
*/
@@ -14,7 +18,13 @@ export const sendEmail = async (
) => {
const resend = getResend()
console.log(resend, payload, options)
const { data, error } = await resend.emails.send(
async function sendEmailThrottle(data: any, options: any) {
if (!resend) return { data: null, error: 'No Resend client' }
return limit(() => resend.emails.send(data, options))
}
const { data, error } = await sendEmailThrottle(
{ replyTo: 'Compass <no-reply@compassmeet.com>', ...payload },
options
)
@@ -36,6 +46,11 @@ let resend: Resend | null = null
const getResend = () => {
if (resend) return resend
if (!process.env.RESEND_KEY) {
console.log('No RESEND_KEY, skipping email send')
return
}
const apiKey = process.env.RESEND_KEY as string
// console.log(`RESEND_KEY: ${apiKey}`)
resend = new Resend(apiKey)

View File

@@ -1,4 +1,6 @@
import {Column, Img, Link, Row, Section, Text} from "@react-email/components";
import {discordLink, githubRepo, patreonLink, paypalLink} from "common/constants";
import {DOMAIN} from "common/envs/constants";
interface Props {
email?: string
@@ -13,36 +15,36 @@ export const Footer = ({
<hr style={{border: 'none', borderTop: '1px solid #e0e0e0', margin: '10px 0'}}/>
<Row>
<Column align="center">
<Link href="https://github.com/CompassConnections/Compass" target="_blank">
<Link href={githubRepo} target="_blank">
<Img
src="https://cdn-icons-png.flaticon.com/512/733/733553.png"
src={`https://${DOMAIN}/images/github-logo.png`}
width="24"
height="24"
alt="GitHub"
style={{ display: "inline-block", margin: "0 4px" }}
/>
</Link>
<Link href="https://discord.gg/8Vd7jzqjun" target="_blank">
<Link href={discordLink} target="_blank">
<Img
src="https://cdn-icons-png.flaticon.com/512/2111/2111370.png"
src={`https://${DOMAIN}/images/discord-logo.png`}
width="24"
height="24"
alt="Discord"
style={{ display: "inline-block", margin: "0 4px" }}
/>
</Link>
<Link href="https://patreon.com/CompassMeet" target="_blank">
<Link href={patreonLink} target="_blank">
<Img
src="https://static.vecteezy.com/system/resources/previews/027/127/454/non_2x/patreon-logo-patreon-icon-transparent-free-png.png"
src={`https://${DOMAIN}/images/patreon-logo.png`}
width="24"
height="24"
alt="Patreon"
style={{ display: "inline-block", margin: "0 4px" }}
/>
</Link>
<Link href="https://www.paypal.com/paypalme/CompassConnections" target="_blank">
<Link href={paypalLink} target="_blank">
<Img
src="https://cdn-icons-png.flaticon.com/512/174/174861.png"
src={`https://${DOMAIN}/images/paypal-logo.png`}
width="24"
height="24"
alt="PayPal"

View File

@@ -1,22 +1,19 @@
import { getLocalEnv, initAdmin } from 'shared/init-admin'
import { getServiceAccountCredentials, loadSecretsToEnv } from 'common/secrets'
import {
createSupabaseDirectClient,
type SupabaseDirectClient,
} from 'shared/supabase/init'
import {initAdmin} from 'shared/init-admin'
import {loadSecretsToEnv} from 'common/secrets'
import {createSupabaseDirectClient, type SupabaseDirectClient,} from 'shared/supabase/init'
import {getServiceAccountCredentials} from "shared/firebase-utils";
initAdmin()
export const runScript = async (
main: (services: { pg: SupabaseDirectClient }) => Promise<any> | any
) => {
const env = getLocalEnv()
const credentials = getServiceAccountCredentials(env)
const credentials = getServiceAccountCredentials()
await loadSecretsToEnv(credentials)
const pg = createSupabaseDirectClient()
await main({ pg })
await main({pg})
process.exit()
}

View File

@@ -1,11 +1,10 @@
import { Request } from 'express'
import { trackAuditEvent } from 'shared/audit-events'
import { PostHog } from 'posthog-node'
import { isProd, log } from 'shared/utils'
import { PROD_CONFIG } from 'common/envs/prod'
import { DEV_CONFIG } from 'common/envs/dev'
import {Request} from 'express'
import {trackAuditEvent} from 'shared/audit-events'
import {PostHog} from 'posthog-node'
import {log} from 'shared/utils'
import {ENV_CONFIG} from "common/envs/constants";
const key = isProd() ? PROD_CONFIG.posthogKey : DEV_CONFIG.posthogKey
const key = ENV_CONFIG.posthogKey
const client = new PostHog(key, {
host: 'https://us.i.posthog.com',
@@ -35,7 +34,7 @@ export const trackPublicEvent = async (
properties?: any
) => {
const allProperties = Object.assign(properties ?? {}, {})
const { commentId, ...data } = allProperties
const {commentId, ...data} = allProperties
try {
client.capture({
distinctId: userId,

View File

@@ -0,0 +1,3 @@
export const getLocalEnv = () => {
return (process.env.ENVIRONMENT?.toUpperCase() ?? 'DEV') as 'PROD' | 'DEV'
}

View File

@@ -0,0 +1,51 @@
import {readFileSync} from "fs";
import {getStorage, Storage} from 'firebase-admin/storage'
import {ENV_CONFIG, getStorageBucketId} from "common/envs/constants";
export const getServiceAccountCredentials = () => {
let keyPath = ENV_CONFIG.googleApplicationCredentials
// console.log('Using GOOGLE_APPLICATION_CREDENTIALS:', keyPath)
if (!keyPath) {
// throw new Error(
// `Please set the GOOGLE_APPLICATION_CREDENTIALS environment variable to contain the path to your key file.`
// )
return {}
}
if (!keyPath.startsWith('/')) {
// Make relative paths relative to the current file
keyPath = __dirname + '/' + keyPath
// console.log(keyPath)
}
try {
return JSON.parse(readFileSync(keyPath, {encoding: 'utf8'}))
} catch (e) {
throw new Error(`Failed to load service account key from ${keyPath}: ${e}`)
}
}
export function getBucket() {
return getStorage().bucket(getStorageBucketId())
}
export type Bucket = ReturnType<InstanceType<typeof Storage>['bucket']>
export async function deleteUserFiles(username: string) {
const path = `user-images/${username}`
// Delete all files in the directory
const bucket = getBucket()
const [files] = await bucket.getFiles({prefix: path});
if (files.length === 0) {
console.log(`No files found in bucket for user ${username}`);
return;
}
await Promise.all(files.map(file => file.delete()));
console.log(`Deleted ${files.length} files for user ${username}`);
}

View File

@@ -1,7 +1,5 @@
import { Storage } from 'firebase-admin/storage'
import { DOMAIN } from 'common/envs/constants'
type Bucket = ReturnType<InstanceType<typeof Storage>['bucket']>
import {DOMAIN} from 'common/envs/constants'
import {Bucket} from "shared/firebase-utils";
export const generateAvatarUrl = async (
userId: string,
@@ -44,7 +42,7 @@ async function upload(userId: string, buffer: Buffer, bucket: Bucket) {
await file.save(buffer, {
private: false,
public: true,
metadata: { contentType: 'image/png' },
metadata: {contentType: 'image/png'},
})
return `https://storage.googleapis.com/${bucket.cloudStorageURI.hostname}/${filename}`
}

View File

@@ -1,27 +1,30 @@
import * as admin from 'firebase-admin'
import { getServiceAccountCredentials } from 'common/secrets'
export const getLocalEnv = () => {
return (process.env.ENV?.toUpperCase() ?? 'STAGING') as 'PROD' | 'DEV'
}
import {getServiceAccountCredentials} from "shared/firebase-utils";
import {IS_LOCAL} from "common/envs/constants";
// Locally initialize Firebase Admin.
export const initAdmin = () => {
try {
const env = getLocalEnv()
const serviceAccount = getServiceAccountCredentials(env)
console.log(
`Initializing connection to ${serviceAccount.project_id} Firebase...`
)
return admin.initializeApp({
projectId: serviceAccount.project_id,
credential: admin.credential.cert(serviceAccount),
storageBucket: `${serviceAccount.project_id}.appspot.com`,
})
} catch (err) {
console.error(err)
console.log(`Initializing connection to default Firebase...`)
return admin.initializeApp()
if (IS_LOCAL) {
try {
const serviceAccount = getServiceAccountCredentials()
// console.log(serviceAccount)
if (!serviceAccount.project_id) {
console.log(`GOOGLE_APPLICATION_CREDENTIALS not set, skipping admin firebase init.`)
return
}
console.log(`Initializing connection to ${serviceAccount.project_id} Firebase...`)
return admin.initializeApp({
projectId: serviceAccount.project_id,
credential: admin.credential.cert(serviceAccount),
storageBucket: `${serviceAccount.project_id}.appspot.com`,
})
} catch (err) {
console.error(err)
}
}
console.log(`Initializing connection to default Firebase...`)
return admin.initializeApp()
}

View File

@@ -2,6 +2,7 @@ import { format } from 'node:util'
import { isError, pick, omit } from 'lodash'
import { dim, red, yellow } from 'colors/safe'
import { getMonitoringContext } from './context'
import {IS_GOOGLE_CLOUD} from "common/envs/constants";
// mapping JS log levels (e.g. functions on console object) to GCP log levels
const JS_TO_GCP_LEVELS = {
@@ -13,7 +14,6 @@ const JS_TO_GCP_LEVELS = {
const JS_LEVELS = Object.keys(JS_TO_GCP_LEVELS) as LogLevel[]
const DEFAULT_LEVEL = 'info'
const IS_GCP = process.env.GOOGLE_CLOUD_PROJECT != null
// keys to put in front to categorize a log line in the console
const DISPLAY_CATEGORY_KEYS = ['endpoint', 'job'] as const
@@ -72,7 +72,7 @@ function writeLog(
const contextData = getMonitoringContext()
const message = format(toString(msg), ...(rest ?? []))
const data = { ...(contextData ?? {}), ...(props ?? {}) }
if (IS_GCP) {
if (IS_GOOGLE_CLOUD) {
const severity = JS_TO_GCP_LEVELS[level]
const output: LogDetails = { severity, message, ...data }
if (msg instanceof Error) {

View File

@@ -1,25 +1,19 @@
import { MetricServiceClient } from '@google-cloud/monitoring'
import { average, sumOfSquaredError } from 'common/util/math'
import { log } from './log'
import { InstanceInfo, getInstanceInfo } from './instance-info'
import { chunk } from 'lodash'
import {
CUSTOM_METRICS,
MetricStore,
MetricStoreEntry,
metrics,
} from './metrics'
import {MetricServiceClient} from '@google-cloud/monitoring'
import {average, sumOfSquaredError} from 'common/util/math'
import {log} from './log'
import {getInstanceInfo, InstanceInfo} from './instance-info'
import {chunk} from 'lodash'
import {CUSTOM_METRICS, metrics, MetricStore, MetricStoreEntry,} from './metrics'
import {IS_GOOGLE_CLOUD} from "common/envs/constants";
// how often metrics are written. GCP says don't write for a single time series
// more than once per 5 seconds.
export const METRICS_INTERVAL_MS = 60_000
const LOCAL_DEV = process.env.GOOGLE_CLOUD_PROJECT == null
function serializeTimestamp(ts: number) {
const seconds = ts / 1000
const nanos = (ts % 1000) * 1000
return { seconds, nanos } as const
return {seconds, nanos} as const
}
// see https://cloud.google.com/monitoring/api/ref_v3/rest/v3/projects.snoozes#timeinterval
@@ -31,7 +25,7 @@ function serializeInterval(entry: MetricStoreEntry, ts: number) {
endTime: serializeTimestamp(ts),
}
case 'GAUGE': {
return { endTime: serializeTimestamp(ts) }
return {endTime: serializeTimestamp(ts)}
}
}
}
@@ -43,7 +37,7 @@ function serializeDistribution(points: number[]) {
mean: average(points),
sumOfSquaredDeviation: sumOfSquaredError(points),
// not interested in handling histograms right now
bucketOptions: { explicitBuckets: { bounds: [0] } },
bucketOptions: {explicitBuckets: {bounds: [0]}},
bucketCounts: [0, points.length],
}
}
@@ -52,9 +46,9 @@ function serializeDistribution(points: number[]) {
function serializeValue(entry: MetricStoreEntry) {
switch (CUSTOM_METRICS[entry.type].valueKind) {
case 'int64Value':
return { int64Value: entry.value }
return {int64Value: entry.value}
case 'distributionValue': {
return { distributionValue: serializeDistribution(entry.points ?? []) }
return {distributionValue: serializeDistribution(entry.points ?? [])}
}
default:
throw new Error('Other value kinds not yet implemented.')
@@ -110,8 +104,8 @@ export class MetricWriter {
for (const entry of freshEntries) {
entry.fresh = false
}
if (!LOCAL_DEV) {
log.debug('Writing GCP metrics.', { entries: freshEntries })
if (!IS_GOOGLE_CLOUD) {
log.debug('Writing GCP metrics.', {entries: freshEntries})
if (this.instance == null) {
this.instance = await getInstanceInfo()
log.debug('Retrieved instance metadata.', {
@@ -126,7 +120,7 @@ export class MetricWriter {
for (const batch of chunk(timeSeries, 200)) {
this.store.clearDistributionGauges()
// see https://cloud.google.com/monitoring/custom-metrics/creating-metrics
await this.client.createTimeSeries({ timeSeries: batch, name })
await this.client.createTimeSeries({timeSeries: batch, name})
}
}
}
@@ -138,7 +132,7 @@ export class MetricWriter {
try {
await this.write()
} catch (error) {
log.error('Failed to write metrics.', { error })
log.error('Failed to write metrics.', {error})
}
}, this.intervalMs)
}

View File

@@ -1,14 +1,12 @@
import pgPromise from 'pg-promise'
export { SupabaseClient } from 'common/supabase/utils'
import { DEV_CONFIG } from 'common/envs/dev'
import { PROD_CONFIG } from 'common/envs/prod'
import { metrics, log, isProd } from '../utils'
import { IDatabase, ITask } from 'pg-promise'
import { IClient } from 'pg-promise/typescript/pg-subset'
import { HOUR_MS } from 'common/util/time'
import { METRICS_INTERVAL_MS } from 'shared/monitoring/metric-writer'
import { getMonitoringContext } from 'shared/monitoring/context'
import { type IConnectionParameters } from 'pg-promise/typescript/pg-subset'
import pgPromise, {IDatabase, ITask} from 'pg-promise'
import {log, metrics} from '../utils'
import {IClient, type IConnectionParameters} from 'pg-promise/typescript/pg-subset'
import {HOUR_MS} from 'common/util/time'
import {METRICS_INTERVAL_MS} from 'shared/monitoring/metric-writer'
import {getMonitoringContext} from 'shared/monitoring/context'
import {ENV_CONFIG} from "common/envs/constants";
export {SupabaseClient} from 'common/supabase/utils'
export const pgp = pgPromise({
error(err: any, e: pgPromise.IEventContext) {
@@ -21,9 +19,9 @@ export const pgp = pgPromise({
query() {
const ctx = getMonitoringContext()
if (ctx?.endpoint) {
metrics.inc('pg/query_count', { endpoint: ctx.endpoint })
metrics.inc('pg/query_count', {endpoint: ctx.endpoint})
} else if (ctx?.job) {
metrics.inc('pg/query_count', { job: ctx.job })
metrics.inc('pg/query_count', {job: ctx.job})
} else {
metrics.inc('pg/query_count')
}
@@ -38,10 +36,11 @@ export type SupabaseTransaction = ITask<{}>
export type SupabaseDirectClient = IDatabase<{}, IClient> | SupabaseTransaction
export function getInstanceId() {
return (
process.env.SUPABASE_INSTANCE_ID ??
(isProd() ? PROD_CONFIG.supabaseInstanceId : DEV_CONFIG.supabaseInstanceId)
)
return ENV_CONFIG.supabaseInstanceId
}
export function getSupabasePwd() {
return ENV_CONFIG.supabasePwd
}
const newClient = (
@@ -50,17 +49,17 @@ const newClient = (
password?: string
} & IConnectionParameters
) => {
const { instanceId, password, ...settings } = props
const {instanceId, password, ...settings} = props
const config = {
// This host is IPV4 compatible, for the google cloud VM
host: 'aws-1-us-west-1.pooler.supabase.com',
port: 5432,
user: `postgres.ltzepxnhhnrnvovqblfr`,
user: `postgres.${instanceId}`,
password: password,
database: 'postgres',
pool_mode: 'session',
ssl: { rejectUnauthorized: false },
ssl: {rejectUnauthorized: false},
family: 4, // <- forces IPv4
...settings,
}
@@ -72,6 +71,7 @@ const newClient = (
// Use one connection to avoid WARNING: Creating a duplicate database object for the same connection.
let pgpDirect: IDatabase<{}, IClient> | null = null
export function createSupabaseDirectClient(
instanceId?: string,
password?: string
@@ -83,7 +83,7 @@ export function createSupabaseDirectClient(
"Can't connect to Supabase; no process.env.SUPABASE_INSTANCE_ID and no instance ID in config."
)
}
password = password ?? process.env.SUPABASE_DB_PASSWORD
password = password ?? getSupabasePwd()
if (!password) {
throw new Error(
"Can't connect to Supabase; no process.env.SUPABASE_DB_PASSWORD."
@@ -101,10 +101,10 @@ export function createSupabaseDirectClient(
pool.on('acquire', () => metrics.inc('pg/connections_acquired'))
pool.on('release', () => metrics.inc('pg/connections_released'))
setInterval(() => {
metrics.set('pg/pool_connections', pool.waitingCount, { state: 'waiting' })
metrics.set('pg/pool_connections', pool.idleCount, { state: 'idle' })
metrics.set('pg/pool_connections', pool.expiredCount, { state: 'expired' })
metrics.set('pg/pool_connections', pool.totalCount, { state: 'total' })
metrics.set('pg/pool_connections', pool.waitingCount, {state: 'waiting'})
metrics.set('pg/pool_connections', pool.idleCount, {state: 'idle'})
metrics.set('pg/pool_connections', pool.expiredCount, {state: 'expired'})
metrics.set('pg/pool_connections', pool.totalCount, {state: 'total'})
}, METRICS_INTERVAL_MS)
return (pgpDirect = client)
}
@@ -114,7 +114,7 @@ export const createShortTimeoutDirectClient = () => {
if (shortTimeoutPgpClient) return shortTimeoutPgpClient
shortTimeoutPgpClient = newClient({
instanceId: getInstanceId(),
password: process.env.SUPABASE_DB_PASSWORD,
password: getSupabasePwd(),
query_timeout: 1000 * 30,
max: 20,
})

View File

@@ -1,18 +1,20 @@
import {createSupabaseDirectClient, SupabaseDirectClient,} from 'shared/supabase/init'
import * as admin from 'firebase-admin'
import {convertPrivateUser, convertUser} from 'common/supabase/users'
import {log, type Logger} from 'shared/monitoring/log'
import {metrics} from 'shared/monitoring/metrics'
export { metrics }
export { log, type Logger }
export {metrics}
export {log, type Logger}
export const getUser = async (
userId: string,
pg: SupabaseDirectClient = createSupabaseDirectClient()
) => {
return await pg.oneOrNone(
`select * from users where id = $1 limit 1`,
`select *
from users
where id = $1
limit 1`,
[userId],
convertUser
)
@@ -23,7 +25,10 @@ export const getPrivateUser = async (
pg: SupabaseDirectClient = createSupabaseDirectClient()
) => {
return await pg.oneOrNone(
`select * from private_users where id = $1 limit 1`,
`select *
from private_users
where id = $1
limit 1`,
[userId],
convertPrivateUser
)
@@ -34,7 +39,9 @@ export const getUserByUsername = async (
pg: SupabaseDirectClient = createSupabaseDirectClient()
) => {
const res = await pg.oneOrNone(
`select * from users where username = $1`,
`select *
from users
where username = $1`,
username
)
@@ -46,20 +53,11 @@ export const getPrivateUserByKey = async (
pg: SupabaseDirectClient = createSupabaseDirectClient()
) => {
return await pg.oneOrNone(
`select * from private_users where data->>'apiKey' = $1 limit 1`,
`select *
from private_users
where data ->> 'apiKey' = $1
limit 1`,
[apiKey],
convertPrivateUser
)
}
// TODO: deprecate in favor of common/src/envs/is-prod.ts
export const isProd = () => {
// mqp: kind of hacky rn. the first clause is for cloud run API service,
// second clause is for local scripts and cloud functions
if (process.env.ENVIRONMENT) {
return process.env.ENVIRONMENT == 'PROD'
} else {
return admin.app().options.projectId === 'compass-130ba'
}
}

View File

@@ -9,7 +9,8 @@ import {
ServerMessage,
CLIENT_MESSAGE_SCHEMA,
} from 'common/api/websockets'
import {LOCAL_DEV} from "common/envs/constants";
import {IS_LOCAL} from "common/envs/constants";
import {getWebsocketUrl} from "common/api/utils";
const SWITCHBOARD = new Switchboard()
@@ -107,7 +108,7 @@ export function broadcastMulti(topics: string[], data: BroadcastPayload) {
// it isn't secure to do this in prod for auth reasons (maybe?)
// but it's super convenient for testing
if (LOCAL_DEV) {
if (IS_LOCAL) {
const msg = { type: 'broadcast', topic: '*', topics, data }
sendToSubscribers('*', msg)
}
@@ -127,7 +128,7 @@ export function listen(server: HttpServer, path: string) {
const wss = new WebSocketServer({ server, path })
let deadConnectionCleaner: NodeJS.Timeout | undefined
wss.on('listening', () => {
log.info(`Web socket server listening on ${path}.`)
log.info(`Web socket server listening on ${path}. ${getWebsocketUrl()}`)
deadConnectionCleaner = setInterval(function ping() {
const now = Date.now()
for (const ws of wss.clients) {

View File

@@ -9,7 +9,7 @@ regen-types-prod:
cd ../../common && npx prettier --write ./src/supabase/schema.ts
regen-types-dev:
npx supabase gen types typescript --project-id ltzepxnhhnrnvovqblfr --schema public > ../../common/src/supabase/schema.ts
npx supabase gen types typescript --project-id zbspxezubpzxmuxciurg --schema public > ../../common/src/supabase/schema.ts
cd ../../common && npx prettier --write ./src/supabase/schema.ts
regen-schema:

View File

@@ -0,0 +1,18 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
PROJECT=compass-130ba
TIMESTAMP=$(date +"%F_%H-%M-%S")
DESTINATION=./data/$TIMESTAMP
mkdir -p $DESTINATION
gsutil -m cp -r gs://$PROJECT.firebasestorage.app $DESTINATION
echo Backup of Firebase Storage done

170
backups/supabase/main.tf Normal file
View File

@@ -0,0 +1,170 @@
locals {
project = "compass-130ba"
region = "us-west1"
zone = "us-west1-b"
service_name = "backup"
machine_type = "e2-micro"
}
variable "env" {
description = "Environment (env or prod)"
type = string
default = "prod"
}
provider "google" {
project = local.project
region = local.region
zone = local.zone
}
# Service account for the VM (needs Secret Manager + Storage access)
resource "google_service_account" "backup_vm_sa" {
account_id = "backup-vm-sa"
display_name = "Backup VM Service Account"
}
# IAM roles
resource "google_project_iam_member" "backup_sa_secret_manager" {
project = "compass-130ba"
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.backup_vm_sa.email}"
}
resource "google_project_iam_member" "backup_sa_storage_admin" {
project = "compass-130ba"
role = "roles/storage.objectAdmin"
member = "serviceAccount:${google_service_account.backup_vm_sa.email}"
}
# Minimal VM
resource "google_compute_instance" "backup_vm" {
name = "supabase-backup-vm"
machine_type = local.machine_type
zone = local.zone
boot_disk {
initialize_params {
image = "debian-11-bullseye-v20250915"
size = 20
}
}
network_interface {
network = "default"
access_config {}
}
service_account {
email = google_service_account.backup_vm_sa.email
scopes = ["https://www.googleapis.com/auth/cloud-platform"]
}
metadata_startup_script = <<-EOT
#!/bin/bash
apt-get update
apt-get install -y postgresql-client cron wget curl unzip
# Add PostgreSQL repo
sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
wget -qO - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
sudo apt-get update
sudo apt-get install -y postgresql-client-17
sudo apt-get install -y mailutils
# Create backup directory
mkdir -p /home/martin/supabase_backups
chown -R martin:martin /home/martin
# Example backup script
cat <<'EOF' > /home/martin/backup.sh
#!/bin/bash
# Backup Supabase database and upload to Google Cloud Storage daily, retaining backups for 30 days.
set -e
cd $(dirname "$0")
export ENV=prod
if [ "$ENV" = "prod" ]; then
export PGHOST="aws-1-us-west-1.pooler.supabase.com"
elif [ "$ENV" = "dev" ]; then
export PGHOST="db.zbspxezubpzxmuxciurg.supabase.co"
else
echo "Error: ENV must be 'prod' or 'dev'" >&2
exit 1
fi
# Config
PGPORT="5432"
PGUSER="postgres.ltzepxnhhnrnvovqblfr"
PGDATABASE="postgres"
# Retrieve password from Secret Manager
PGPASSWORD=$(gcloud secrets versions access latest --secret="SUPABASE_DB_PASSWORD")
BUCKET_NAME="gs://compass-130ba.firebasestorage.app/backups/supabase"
BACKUP_DIR="/tmp/supabase_backups"
RETENTION_DAYS=30
mkdir -p "$BACKUP_DIR"
TIMESTAMP=$(date +"%F_%H-%M-%S")
BACKUP_FILE="$BACKUP_DIR/$TIMESTAMP.sql"
export PGPASSWORD
pg_dump -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -F c -b -v -f "$BACKUP_FILE"
if [ $? -ne 0 ]; then
echo "Backup failed!"
exit 1
fi
echo "Backup successful: $BACKUP_FILE"
# UPLOAD TO GCS
echo "Uploading backup to GCS..."
gsutil cp "$BACKUP_FILE" "$BUCKET_NAME/"
# LOCAL RETENTION
LOCAL_RETENTION_DAYS=7
echo "Removing local backups older than $LOCAL_RETENTION_DAYS days..."
find "$BACKUP_DIR" -type f -mtime +$LOCAL_RETENTION_DAYS -delete
# GCS RETENTION
echo "Cleaning old backups from GCS..."
gsutil ls "$BUCKET_NAME/" | while read file; do
filename=$(basename "$file")
# Extract timestamp from filename
file_date=$(echo "$filename" | sed -E 's/(.*)\.sql/\1/')
# Convert to seconds since epoch
file_date="2025-09-24_13-00-54"
date_part=${file_date%_*} # "2025-09-24"
time_part=${file_date#*_} # "13-00-54"
time_part=${time_part//-/:} # "13:00:54"
file_ts=$(date -d "$date_part $time_part" +%s)
# echo "$file, $filename, $file_date, $file_ts"
if [ -z "$file_ts" ]; then
continue
fi
now=$(date +%s)
diff_days=$(( (now - file_ts) / 86400 ))
echo "File: $filename is $diff_days days old."
if [ "$diff_days" -gt "$RETENTION_DAYS" ]; then
echo "Deleting $file from GCS..."
gsutil rm "$file"
fi
done
echo "Backup and retention process completed at $(date)."
EOF
chmod +x /home/martin/backup.sh
# Add cron job: daily at 2AM
( crontab -l 2>/dev/null; echo '0 2 * * * /home/martin/backup.sh >> /home/martin/backup.log 2>&1 || curl -H "Content-Type: application/json" -X POST -d "{\"content\": \" Backup FAILED on $(hostname) at $(date)\"}" https://discord.com/api/webhooks/1420405275340574873/XgF5pgHABvvWT2fyWASBs3VhAF7Zy11rCH2BkI_RBxH1Xd5duWxGtukrc1cPy1ZucNwx' ) | crontab -
# tail -f /home/martin/backup.log
}

18
backups/supabase/ssh.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/bin/bash
set -e
cd $(dirname "$0")
#gcloud compute firewall-rules create allow-iap-ssh \
# --direction=INGRESS \
# --action=ALLOW \
# --rules=tcp:22 \
# --source-ranges=35.235.240.0/20 \
# --target-tags=iap-ssh
# gcloud compute instances add-tags "supabase-backup-vm" --tags=iap-ssh --zone="us-west1-b"
gcloud compute ssh --zone "us-west1-b" "supabase-backup-vm" --project "compass-130ba" --tunnel-through-iap
# sudo crontab -u backup -l

View File

@@ -0,0 +1,81 @@
#!/bin/bash
# Backup Supabase database and upload to Google Cloud Storage daily, retaining backups for 30 days.
set -e
cd $(dirname "$0")
export ENV=prod
if [ "$ENV" = "prod" ]; then
export PGHOST="aws-1-us-west-1.pooler.supabase.com"
elif [ "$ENV" = "dev" ]; then
export PGHOST="db.zbspxezubpzxmuxciurg.supabase.co"
else
echo "Error: ENV must be 'prod' or 'dev'" >&2
exit 1
fi
# Config
PGPORT="5432"
PGUSER="postgres.ltzepxnhhnrnvovqblfr"
PGDATABASE="postgres"
# Retrieve password from Secret Manager
PGPASSWORD=$(gcloud secrets versions access latest --secret="SUPABASE_DB_PASSWORD")
BUCKET_NAME="gs://compass-130ba.firebasestorage.app/backups/supabase"
BACKUP_DIR="/tmp/supabase_backups"
RETENTION_DAYS=30
mkdir -p "$BACKUP_DIR"
TIMESTAMP=$(date +"%F_%H-%M-%S")
BACKUP_FILE="$BACKUP_DIR/$TIMESTAMP.sql"
export PGPASSWORD
pg_dump -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -F c -b -v -f "$BACKUP_FILE"
if [ $? -ne 0 ]; then
echo "Backup failed!"
exit 1
fi
echo "Backup successful: $BACKUP_FILE"
# UPLOAD TO GCS
echo "Uploading backup to GCS..."
gsutil cp "$BACKUP_FILE" "$BUCKET_NAME/"
# LOCAL RETENTION
LOCAL_RETENTION_DAYS=7
echo "Removing local backups older than $LOCAL_RETENTION_DAYS days..."
find "$BACKUP_DIR" -type f -mtime +$LOCAL_RETENTION_DAYS -delete
# GCS RETENTION
echo "Cleaning old backups from GCS..."
gsutil ls "$BUCKET_NAME/" | while read file; do
filename=$(basename "$file")
# Extract timestamp from filename
file_date=$(echo "$filename" | sed -E 's/(.*)\.sql/\1/')
# Convert to seconds since epoch
file_date="2025-09-24_13-00-54"
date_part=${file_date%_*} # "2025-09-24"
time_part=${file_date#*_} # "13-00-54"
time_part=${time_part//-/:} # "13:00:54"
file_ts=$(date -d "$date_part $time_part" +%s)
# echo "$file, $filename, $file_date, $file_ts"
if [ -z "$file_ts" ]; then
continue
fi
now=$(date +%s)
diff_days=$(( (now - file_ts) / 86400 ))
echo "File: $filename is $diff_days days old."
if [ "$diff_days" -gt "$RETENTION_DAYS" ]; then
echo "Deleting $file from GCS..."
gsutil rm "$file"
fi
done
echo "Backup and retention process completed at $(date)."

View File

@@ -1,4 +1,4 @@
import { ENV_CONFIG } from 'common/envs/constants'
import {BACKEND_DOMAIN, IS_LOCAL} from 'common/envs/constants'
type ErrorCode =
| 400 // your input is bad (like zod is mad)
@@ -11,6 +11,7 @@ type ErrorCode =
export class APIError extends Error {
code: ErrorCode
details?: unknown
constructor(code: ErrorCode, message: string, details?: unknown) {
super(message)
this.code = code
@@ -19,20 +20,18 @@ export class APIError extends Error {
}
}
const prefix = 'v0'
export function pathWithPrefix(path: string) {
return `/v0${path}`
return `/${prefix}${path}`
}
export function getWebsocketUrl() {
const endpoint = process.env.NEXT_PUBLIC_API_URL ?? ENV_CONFIG.apiEndpoint
const protocol = endpoint.startsWith('localhost') ? 'ws' : 'wss'
return `${protocol}://${endpoint}/ws`
const protocol = IS_LOCAL ? 'ws' : 'wss'
return `${protocol}://${BACKEND_DOMAIN}/ws`
}
export function getApiUrl(path: string) {
const endpoint = process.env.NEXT_PUBLIC_API_URL ?? ENV_CONFIG.apiEndpoint
const protocol = endpoint.startsWith('localhost') ? 'http' : 'https'
const prefix = 'v0'
return `${protocol}://${endpoint}/${prefix}/${path}`
const protocol = IS_LOCAL ? 'http' : 'https'
return `${protocol}://${BACKEND_DOMAIN}/${prefix}/${path}`
}

View File

@@ -1,2 +1,15 @@
export const MAX_INT = 99999
export const MIN_INT = -MAX_INT
export const supportEmail = 'compass.meet.info@gmail.com';
export const marketingEmail = 'compass.meet.marketing@gmail.com';
export const githubRepo = "https://github.com/CompassConnections/Compass";
export const githubIssues = `${githubRepo}/issues`
export const paypalLink = "https://www.paypal.com/paypalme/CompassConnections"
export const patreonLink = "https://patreon.com/CompassMeet"
export const discordLink = "https://discord.gg/8Vd7jzqjun"
export const formLink = "https://forms.gle/tKnXUMAbEreMK6FC6"
export const pStyle = "mt-1 text-gray-800 dark:text-white whitespace-pre-line";

View File

@@ -0,0 +1,23 @@
import {IS_DEV} from "common/envs/constants";
export const sendDiscordMessage = async (content: string, channel: string) => {
let webhookUrl = {
members: process.env.DISCORD_WEBHOOK_MEMBERS,
general: process.env.DISCORD_WEBHOOK_GENERAL,
}[channel]
if (IS_DEV) webhookUrl = process.env.DISCORD_WEBHOOK_DEV
if (!webhookUrl) return
const response = await fetch(webhookUrl!, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({content}),
})
if (!response.ok) {
const text = await response.text()
console.error(text)
}
}

View File

@@ -1,20 +1,11 @@
import {DEV_CONFIG} from './dev'
import {EnvConfig, PROD_CONFIG} from './prod'
// Valid in web client & Vercel deployments only.
export const ENV = (process.env.NEXT_PUBLIC_FIREBASE_ENV ?? 'PROD') as
| 'PROD'
| 'DEV'
export const CONFIGS: { [env: string]: EnvConfig } = {
PROD: PROD_CONFIG,
DEV: DEV_CONFIG,
}
import {PROD_CONFIG} from './prod'
import {isProd} from "common/envs/is-prod";
export const MAX_DESCRIPTION_LENGTH = 16000
export const MAX_ANSWER_LENGTH = 240
export const ENV_CONFIG = CONFIGS[ENV]
export const ENV_CONFIG = isProd() ? PROD_CONFIG : DEV_CONFIG
export function isAdminId(id: string) {
return ENV_CONFIG.adminIds.includes(id)
@@ -23,7 +14,41 @@ export function isAdminId(id: string) {
export function isModId(id: string) {
return MOD_IDS.includes(id)
}
export const DOMAIN = ENV_CONFIG.domain
export const ENV = isProd() ? 'prod' : 'dev'
export const IS_PROD = ENV === 'prod'
export const IS_DEV = ENV === 'dev'
export const LOCAL_WEB_DOMAIN = 'localhost:3000';
export const LOCAL_BACKEND_DOMAIN = 'localhost:8088';
export const IS_GOOGLE_CLOUD = !!process.env.GOOGLE_CLOUD_PROJECT
export const IS_VERCEL = !!process.env.NEXT_PUBLIC_VERCEL
export const IS_LOCAL = !IS_GOOGLE_CLOUD && !IS_VERCEL
export const HOSTING_ENV = IS_GOOGLE_CLOUD ? 'Google Cloud' : IS_VERCEL ? 'Vercel' : IS_LOCAL ? 'local' : 'unknown'
console.log(`Running in ${HOSTING_ENV} (${ENV})`,);
// class MissingKeyError implements Error {
// constructor(key: string) {
// this.message = `Missing ENV_CONFIG.${key} in ${ENV}. If you're running locally, you most likely want to run in dev mode: yarn dev.`
// this.name = 'MissingKeyError'
// }
//
// message: string;
// name: string;
// }
// for (const key of ['supabaseAnonKey', 'supabasePwd', 'googleApplicationCredentials'] as const) {
// if (!(key in ENV_CONFIG) || ENV_CONFIG[key as keyof typeof ENV_CONFIG] == null) {
// throw new MissingKeyError(key)
// }
// }
// if (!ENV_CONFIG.firebaseConfig.apiKey) {
// throw new MissingKeyError('firebaseConfig.apiKey')
// }
export const DOMAIN = IS_LOCAL ? LOCAL_WEB_DOMAIN : ENV_CONFIG.domain
export const BACKEND_DOMAIN = IS_LOCAL ? LOCAL_BACKEND_DOMAIN : ENV_CONFIG.backendDomain
export const FIREBASE_CONFIG = ENV_CONFIG.firebaseConfig
export const PROJECT_ID = ENV_CONFIG.firebaseConfig.projectId
@@ -108,5 +133,6 @@ export const RESERVED_PATHS = [
'welcome',
]
export const LOCAL_WEB_URL = 'http://localhost:3000';
export const LOCAL_DEV = process.env.GOOGLE_CLOUD_PROJECT == null
export function getStorageBucketId() {
return ENV_CONFIG.firebaseConfig.storageBucket
}

View File

@@ -2,8 +2,12 @@ import { EnvConfig, PROD_CONFIG } from './prod'
export const DEV_CONFIG: EnvConfig = {
...PROD_CONFIG,
domain: 'dev.compassmeet.com',
backendDomain: 'api.dev.compassmeet.com',
supabaseInstanceId: 'zbspxezubpzxmuxciurg',
supabasePwd: 'FO3y0G7chzdq6aE7', // For database write access (dev). A 16-character password with digits and letters.
supabaseAnonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inpic3B4ZXp1YnB6eG11eGNpdXJnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTc2ODM0MTMsImV4cCI6MjA3MzI1OTQxM30.ZkM7zlawP8Nke0T3KJrqpOQ4DzqPaXTaJXLC2WU8Y7c',
googleApplicationCredentials: 'googleApplicationCredentials-dev.json',
firebaseConfig: {
apiKey: "AIzaSyBspL9glBXWbMsjmtt36dgb2yU0YGGhzKo",
authDomain: "compass-57c3c.firebaseapp.com",
@@ -14,5 +18,5 @@ export const DEV_CONFIG: EnvConfig = {
appId: "1:297460199314:web:c45678c54285910e255b4b",
measurementId: "G-N6LZ64EMJ2",
region: 'us-west1',
}
},
}

Some files were not shown because too many files have changed in this diff Show More