mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-04-05 07:14:02 -04:00
Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b30af128c7 | ||
|
|
72c31ae097 | ||
|
|
d2c608021d | ||
|
|
1f36fb2413 | ||
|
|
16a0cbcecf | ||
|
|
e068e246aa | ||
|
|
ec7c77fcf9 | ||
|
|
46a338b874 | ||
|
|
bfee7ff09d | ||
|
|
ce1305d8ae | ||
|
|
aaebf88438 | ||
|
|
dde2c99e36 | ||
|
|
4dc2f3b9b9 | ||
|
|
f30cfffb86 | ||
|
|
ca3eb62ba7 | ||
|
|
c8e55ca4ce | ||
|
|
e4acb25a40 | ||
|
|
c741e10139 | ||
|
|
28d0b35f8e | ||
|
|
f7f09cd9e5 | ||
|
|
501c92c350 | ||
|
|
f021101322 | ||
|
|
369265bc2c | ||
|
|
b1f1e5db1f | ||
|
|
51d32e5afb | ||
|
|
f396e8e482 | ||
|
|
077321731e | ||
|
|
60eb0c6978 | ||
|
|
475f0af78a | ||
|
|
206fa07035 | ||
|
|
aff949714c | ||
|
|
7e834b9ff6 | ||
|
|
19bad26a98 | ||
|
|
7cc7c8d27b | ||
|
|
ae5a8c7cfa | ||
|
|
5004b73210 | ||
|
|
02f613d269 | ||
|
|
439ac0310b | ||
|
|
3e95467819 | ||
|
|
90522cb88b | ||
|
|
af39b01d4a | ||
|
|
73a0a5ff0b | ||
|
|
e157f500bc | ||
|
|
274ee5ed5f | ||
|
|
4cb11ba8c0 | ||
|
|
7b8e775139 | ||
|
|
86a7d26bfd | ||
|
|
84a437772d | ||
|
|
d7c95e2ae0 | ||
|
|
b4f0ef8b43 | ||
|
|
6d30cd7ae4 | ||
|
|
f631236ee7 | ||
|
|
1a58ff5c4c | ||
|
|
73aca913a1 | ||
|
|
24dee0cad6 | ||
|
|
2d2de75372 | ||
|
|
d98982e6fd | ||
|
|
14c12ffb08 | ||
|
|
f260afca11 | ||
|
|
5bcbe25d97 | ||
|
|
2eee366fbd | ||
|
|
85d57ec5e6 | ||
|
|
502c878f82 |
23
.env.example
23
.env.example
@@ -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
15
.github/FUNDING.yml
vendored
Normal 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
6
.gitignore
vendored
@@ -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/
|
||||
|
||||
13
README.md
13
README.md
@@ -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!
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import {aColor, supportEmail} from "@/lib/client/constants";
|
||||
import Image from 'next/image';
|
||||
|
||||
export default function PrivacyPage() {
|
||||
2
_old/lib/client/constants.tsx
Normal file
2
_old/lib/client/constants.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
'use client';
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
3
backend/shared/src/constants.ts
Normal file
3
backend/shared/src/constants.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const getLocalEnv = () => {
|
||||
return (process.env.ENVIRONMENT?.toUpperCase() ?? 'DEV') as 'PROD' | 'DEV'
|
||||
}
|
||||
51
backend/shared/src/firebase-utils.ts
Normal file
51
backend/shared/src/firebase-utils.ts
Normal 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}`);
|
||||
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
18
backups/firebase/storage/backup.sh
Executable file
18
backups/firebase/storage/backup.sh
Executable 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
170
backups/supabase/main.tf
Normal 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
18
backups/supabase/ssh.sh
Executable 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
|
||||
81
backups/supabase/supabase_backup.sh
Executable file
81
backups/supabase/supabase_backup.sh
Executable 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)."
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
23
common/src/discord/core.ts
Normal file
23
common/src/discord/core.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user