Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ android {
applicationId "com.compassconnections.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 187
versionCode 188
versionName "1.47.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
Expand Down
2 changes: 1 addition & 1 deletion backend/api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@compass/api",
"version": "1.87.0",
"version": "1.88.0",
"private": true,
"description": "Backend API endpoints",
"main": "src/serve.ts",
Expand Down
6 changes: 6 additions & 0 deletions backend/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {createVoteComment} from 'api/create-vote-comment'
import {deleteCompatibilityPrompt} from 'api/delete-compatibility-prompt'
import {deleteMessage} from 'api/delete-message'
import {deleteOption} from 'api/delete-option'
import {deleteSubscription} from 'api/delete-subscription'
import {editMessage} from 'api/edit-message'
import {editVoteComment} from 'api/edit-vote-comment'
import {getChannelMemberships} from 'api/get-channel-memberships'
Expand Down Expand Up @@ -102,6 +103,8 @@ import {getEvents} from './get-events'
import {getMe} from './get-me'
import {getMyReferralCount} from './get-my-referral-count'
import {getMyReferrals} from './get-my-referrals'
import {getMyStars} from './get-my-stars'
import {getMyVoteChoices} from './get-my-vote-choices'
import {getNotifications} from './get-notifications'
import {getOutreachQueue} from './get-outreach-queue'
import {getOutreachStats} from './get-outreach-stats'
Expand Down Expand Up @@ -689,6 +692,8 @@ const handlers: {[k in APIPath]: APIHandler<k>} = {
'create-vote-comment': createVoteComment,
'delete-bookmarked-search': deleteBookmarkedSearch,
'get-bookmarked-searches': getBookmarkedSearches,
'get-my-stars': getMyStars,
'get-my-vote-choices': getMyVoteChoices,
'delete-compatibility-answer': deleteCompatibilityAnswer,
'delete-message': deleteMessage,
'edit-message': editMessage,
Expand Down Expand Up @@ -765,6 +770,7 @@ const handlers: {[k in APIPath]: APIHandler<k>} = {
'remove-pinned-photo': removePinnedPhoto,
'save-subscription': saveSubscription,
'save-subscription-mobile': saveSubscriptionMobile,
'delete-subscription': deleteSubscription,
'search-location': searchLocationEndpoint,
'search-near-city': searchNearCity,
'search-users': searchUsers,
Expand Down
18 changes: 14 additions & 4 deletions backend/api/src/create-comment.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {type JSONContent} from '@tiptap/core'
import {assertNotBlocked} from 'api/helpers/blocks'
import {MAX_COMMENT_JSON_LENGTH, validateCommentAuthor} from 'api/helpers/comment'
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
import {Notification} from 'common/notifications'
Expand Down Expand Up @@ -57,10 +58,19 @@ export const createComment: APIHandler<'create-comment'> = async (
const validateComment = async (userId: string, creatorId: string, content: JSONContent) => {
const {creator} = await validateCommentAuthor(creatorId, content)

const otherUser = await getPrivateUser(userId)
if (!otherUser) throw APIErrors.notFound('Other user not found')
if (otherUser.blockedUserIds.includes(creatorId)) {
throw APIErrors.notFound('User has blocked you')
const pg = createSupabaseDirectClient()
const profile = await pg.oneOrNone<{comments_enabled: boolean}>(
`select comments_enabled from profiles where user_id = $1`,
[userId],
)
if (!profile) throw APIErrors.notFound('Other user not found')

// Blocks run both ways (see `helpers/blocks`): the author can't endorse someone they blocked either.
await assertNotBlocked(creatorId, [userId])

// The switch reads "endorsements from others", so the owner can still post on their own profile.
if (creatorId !== userId && !profile.comments_enabled) {
throw APIErrors.forbidden('This person has turned off endorsements')
}

return {content, creator}
Expand Down
17 changes: 4 additions & 13 deletions backend/api/src/delete-message.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {getChangeableOwnMessage} from 'api/helpers/own-message'
import {broadcastPrivateMessages} from 'api/helpers/private-messages'
import {createSupabaseDirectClient} from 'shared/supabase/init'

import {APIErrors, APIHandler} from './helpers/endpoint'
import {APIHandler} from './helpers/endpoint'

// const DELETED_MESSAGE_CONTENT: JSONContent = {
// type: 'doc',
Expand All @@ -21,18 +22,8 @@ import {APIErrors, APIHandler} from './helpers/endpoint'
export const deleteMessage: APIHandler<'delete-message'> = async ({messageId}, auth) => {
const pg = createSupabaseDirectClient()

// Verify user is the message author and message is not too old
const message = await pg.oneOrNone(
`SELECT *
FROM private_user_messages
WHERE id = $1
AND user_id = $2`,
[messageId, auth.uid],
)

if (!message) {
throw APIErrors.notFound('Message not found')
}
// Verify user is the message author, is still in the chat and is not blocked by anyone in it
const message = await getChangeableOwnMessage(pg, messageId, auth.uid)

// Soft delete the message
// await pg.none(
Expand Down
37 changes: 37 additions & 0 deletions backend/api/src/delete-subscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {createSupabaseDirectClient} from 'shared/supabase/init'

import {APIErrors, APIHandler} from './helpers/endpoint'

/**
* Called on sign-out, while the session is still valid, so the device stops receiving the
* signed-out member's notifications and is free for the next account to claim.
*
* `save-subscription*` no longer hands a token that belongs to someone else over to whoever sends
* it (that let anyone holding another member's token redirect their notifications), so this is how
* a shared device changes hands. Only the caller's own rows are removed; a token owned by anyone
* else is left alone, and the response does not say which happened.
*/
export const deleteSubscription: APIHandler<'delete-subscription'> = async (
{endpoint, token},
auth,
) => {
if (!endpoint && !token) {
throw APIErrors.badRequest('Missing endpoint or token')
}

const pg = createSupabaseDirectClient()
if (endpoint) {
await pg.none('delete from push_subscriptions where endpoint = $1 and user_id = $2', [
endpoint,
auth.uid,
])
}
if (token) {
await pg.none('delete from push_subscriptions_mobile where token = $1 and user_id = $2', [
token,
auth.uid,
])
}

return {success: true}
}
19 changes: 4 additions & 15 deletions backend/api/src/edit-message.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,15 @@
import {getChangeableOwnMessage} from 'api/helpers/own-message'
import {broadcastPrivateMessages} from 'api/helpers/private-messages'
import {encryptMessage} from 'shared/encryption'
import {createSupabaseDirectClient} from 'shared/supabase/init'

import {APIErrors, APIHandler} from './helpers/endpoint'
import {APIHandler} from './helpers/endpoint'

export const editMessage: APIHandler<'edit-message'> = async ({messageId, content}, auth) => {
const pg = createSupabaseDirectClient()

// Verify user is the message author and message is not too old
const message = await pg.oneOrNone(
`SELECT *
FROM private_user_messages
WHERE id = $1
AND user_id = $2
-- AND created_time > NOW() - INTERVAL '1 day'
AND deleted = FALSE`,
[messageId, auth.uid],
)

if (!message) {
throw APIErrors.notFound('Message not found or cannot be edited')
}
// Verify user is the message author, is still in the chat and is not blocked by anyone in it
const message = await getChangeableOwnMessage(pg, messageId, auth.uid, {includeDeleted: false})

const plaintext = JSON.stringify(content)
const {ciphertext, iv, tag} = encryptMessage(plaintext)
Expand Down
22 changes: 22 additions & 0 deletions backend/api/src/get-my-stars.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import {type APIHandler} from 'api/helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'

/**
* The ids of the profiles the caller has starred, newest first.
*
* Takes no member id on purpose: who someone has saved is private to them. The web used to read this
* through `get_profile_stars(creator)`, which anyone could call with any member's id on the anon key.
*/
export const getMyStars: APIHandler<'get-my-stars'> = async (_props, auth) => {
const pg = createSupabaseDirectClient()

const targetIds = await pg.map<string>(
`select target_id from profile_stars
where creator_id = $1
order by created_time desc`,
[auth.uid],
(r) => r.target_id,
)

return {targetIds}
}
20 changes: 20 additions & 0 deletions backend/api/src/get-my-vote-choices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import {type APIHandler} from 'api/helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'

/**
* The caller's own ballot on every proposal, keyed by proposal id, so each card can highlight the
* button they picked.
*
* Takes no member id on purpose: ballots are private. The web used to read `vote_results` straight
* from PostgREST, where anyone with the public key could see how every member voted.
*/
export const getMyVoteChoices: APIHandler<'get-my-vote-choices'> = async (_props, auth) => {
const pg = createSupabaseDirectClient()

const rows = await pg.manyOrNone<{vote_id: number; choice: number}>(
`select vote_id, choice from vote_results where user_id = $1`,
[auth.uid],
)

return {choices: Object.fromEntries(rows.map((r) => [r.vote_id, r.choice]))}
}
13 changes: 12 additions & 1 deletion backend/api/src/get-profiles.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as Sentry from '@sentry/node'
import {type JSONContent} from '@tiptap/core'
import {type APIHandler} from 'api/helpers/endpoint'
import {APIErrors, type APIHandler} from 'api/helpers/endpoint'
import {
DIET_CHOICES,
EDUCATION_CHOICES,
Expand All @@ -18,6 +18,7 @@ import {
ROMANTIC_CHOICES,
} from 'common/choices'
import {MIN_BIO_LENGTH} from 'common/constants'
import {isAdminId} from 'common/envs/constants'
import {OptionTableKey} from 'common/profiles/constants'
import {parseJsonContentToText} from 'common/util/parse'
import {getWantsKidsRange} from 'common/wants-kids'
Expand Down Expand Up @@ -1173,6 +1174,16 @@ export const loadProfiles = async (props: profileQueryType, db?: SupabaseDirectC
}

export const getProfiles: APIHandler<'get-profiles'> = async (props, auth) => {
// Ordering by someone else's compatibility scores is an admin tool. For anyone else it would page
// through another member's scores, skipping the block and visibility checks that
// `compatible-profiles` applies. Checked outside the try so it answers 403 rather than an empty page.
if (
props.compatibleWithUserId &&
props.compatibleWithUserId !== auth.uid &&
!isAdminId(auth.uid)
) {
throw APIErrors.forbidden('Only admins can view compatibility scores for another member')
}
try {
if (!props.skipId) props.skipId = auth.uid
const {profiles, count} = await loadProfiles({...props, userId: auth.uid})
Expand Down
52 changes: 52 additions & 0 deletions backend/api/src/helpers/own-message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import {assertNotBlocked} from 'api/helpers/blocks'
import {APIErrors} from 'api/helpers/endpoint'
import {SupabaseDirectClient} from 'shared/supabase/init'

/**
* The message `userId` wants to edit or delete, provided they still may.
*
* Authorship alone is not enough. Once a member has left the chat, or a block stands between them
* and anyone else in it, the thread is frozen for them: they cannot post (see
* `createPrivateUserMessageMain` and `create-private-user-message.ts`), and they cannot rewrite or
* remove what they already said either. Otherwise a harasser who had just been blocked could go back
* and edit or delete the very messages the other person is about to report.
*
* The other members are taken whether or not they have left, the same set `create-private-user-message`
* checks: a block against someone who already walked away still counts.
*/
export const getChangeableOwnMessage = async (
pg: SupabaseDirectClient,
messageId: number,
userId: string,
{includeDeleted = true}: {includeDeleted?: boolean} = {},
) => {
const message = await pg.oneOrNone<{
channel_id: number
member_status: string | null
other_member_ids: string[]
}>(
`select msg.*,
m.status as member_status,
coalesce((select array_agg(o.user_id)
from private_user_message_channel_members o
where o.channel_id = msg.channel_id
and o.user_id != $2), '{}') as other_member_ids
from private_user_messages msg
left join private_user_message_channel_members m
on m.channel_id = msg.channel_id and m.user_id = $2
where msg.id = $1
and msg.user_id = $2
and ($3 or msg.deleted = false)`,
[messageId, userId, includeDeleted],
)

if (!message) throw APIErrors.notFound('Message not found')

if (!message.member_status || message.member_status === 'left') {
throw APIErrors.forbidden('You are no longer in this chat')
}

await assertNotBlocked(userId, message.other_member_ids)

return message
}
5 changes: 5 additions & 0 deletions backend/api/src/llm-extract-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {OPTION_TABLES} from 'common/profiles/constants'
import {ProfileWithoutUser} from 'common/profiles/profile'
import {SITE_ORDER} from 'common/socials'
import {cleanUsername} from 'common/util/clean-username'
import {isOwnImageUrl} from 'common/util/image-url'
import {removeNullOrUndefinedProps} from 'common/util/object'
import {parseJsonContentToText, textToJSONContent} from 'common/util/parse'
import {HOUR_MS, MINUTE_MS, sleep} from 'common/util/time'
Expand Down Expand Up @@ -428,6 +429,10 @@ async function processAndCache(
callLLM(content, locale, source),
bio ? rehostExternalImages(bio, imageFolderName) : undefined,
])
// The model can answer with any URL it saw on the page. Only a photo we host may become the
// avatar: anything else would be loaded from its owner's server by everyone who views the
// profile, and the profile schema refuses it anyway (see `isOwnImageUrl`).
if (!isOwnImageUrl(profile.pinned_url)) profile.pinned_url = undefined
if (rehostedBio) {
console.log('rehosted bio')
profile.bio = rehostedBio
Expand Down
8 changes: 6 additions & 2 deletions backend/api/src/save-subscription-mobile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,16 @@ export const saveSubscriptionMobile: APIHandler<'save-subscription-mobile'> = as

try {
const pg = createSupabaseDirectClient()
// A token that already belongs to another member is left alone. It used to be reassigned to
// whoever sent it, so anyone holding someone else's token could redirect their notifications to
// an account of their own choosing. The response is the same either way, so it does not say whose
// token it was.
await pg.none(
`
insert into push_subscriptions_mobile(token, platform, user_id)
values ($1, $2, $3)
on conflict(token) do update set platform = excluded.platform,
user_id = excluded.user_id
on conflict(token) do update set platform = excluded.platform
where push_subscriptions_mobile.user_id = excluded.user_id
`,
[token, platform, userId],
)
Expand Down
32 changes: 12 additions & 20 deletions backend/api/src/save-subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,26 +13,18 @@ export const saveSubscription: APIHandler<'save-subscription'> = async (body, au

try {
const pg = createSupabaseDirectClient()
// Check if a subscription already exists
const exists = await pg.oneOrNone('select id from push_subscriptions where endpoint = $1', [
subscription.endpoint,
])

if (exists) {
// Already exists, optionally update keys and userId
await pg.none('update push_subscriptions set keys = $1, user_id = $2 where id = $3', [
subscription.keys,
userId,
exists.id,
])
} else {
await pg.none(
`insert into push_subscriptions(endpoint, keys, user_id) values($1, $2, $3)
on conflict(endpoint) do update set keys = excluded.keys
`,
[subscription.endpoint, subscription.keys, userId],
)
}
// An endpoint that already belongs to another member is left alone, keys included. It used to be
// reassigned to whoever sent it, so anyone holding someone else's endpoint could redirect their
// notifications — or overwrite the keys so the browser could no longer decrypt them. A row with no
// owner yet can still be claimed. The response is the same either way, so it does not say whose
// endpoint it was.
await pg.none(
`insert into push_subscriptions(endpoint, keys, user_id) values($1, $2, $3)
on conflict(endpoint) do update set keys = excluded.keys, user_id = excluded.user_id
where push_subscriptions.user_id is null or push_subscriptions.user_id = excluded.user_id
`,
[subscription.endpoint, subscription.keys, userId],
)

return {success: true}
} catch (err) {
Expand Down
Loading
Loading