diff --git a/android/app/build.gradle b/android/app/build.gradle index f1f606d1a..090ea984e 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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 { diff --git a/backend/api/package.json b/backend/api/package.json index 6d2eed52b..3401a59eb 100644 --- a/backend/api/package.json +++ b/backend/api/package.json @@ -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", diff --git a/backend/api/src/app.ts b/backend/api/src/app.ts index 82d9cc242..9c84ad59b 100644 --- a/backend/api/src/app.ts +++ b/backend/api/src/app.ts @@ -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' @@ -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' @@ -689,6 +692,8 @@ const handlers: {[k in APIPath]: APIHandler} = { '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, @@ -765,6 +770,7 @@ const handlers: {[k in APIPath]: APIHandler} = { 'remove-pinned-photo': removePinnedPhoto, 'save-subscription': saveSubscription, 'save-subscription-mobile': saveSubscriptionMobile, + 'delete-subscription': deleteSubscription, 'search-location': searchLocationEndpoint, 'search-near-city': searchNearCity, 'search-users': searchUsers, diff --git a/backend/api/src/create-comment.ts b/backend/api/src/create-comment.ts index d1396e429..bd2ff1937 100644 --- a/backend/api/src/create-comment.ts +++ b/backend/api/src/create-comment.ts @@ -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' @@ -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} diff --git a/backend/api/src/delete-message.ts b/backend/api/src/delete-message.ts index 785ea3712..426b386fa 100644 --- a/backend/api/src/delete-message.ts +++ b/backend/api/src/delete-message.ts @@ -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', @@ -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( diff --git a/backend/api/src/delete-subscription.ts b/backend/api/src/delete-subscription.ts new file mode 100644 index 000000000..ab7158d94 --- /dev/null +++ b/backend/api/src/delete-subscription.ts @@ -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} +} diff --git a/backend/api/src/edit-message.ts b/backend/api/src/edit-message.ts index 571242ad9..5a8fa5c29 100644 --- a/backend/api/src/edit-message.ts +++ b/backend/api/src/edit-message.ts @@ -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) diff --git a/backend/api/src/get-my-stars.ts b/backend/api/src/get-my-stars.ts new file mode 100644 index 000000000..e4aafca01 --- /dev/null +++ b/backend/api/src/get-my-stars.ts @@ -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( + `select target_id from profile_stars + where creator_id = $1 + order by created_time desc`, + [auth.uid], + (r) => r.target_id, + ) + + return {targetIds} +} diff --git a/backend/api/src/get-my-vote-choices.ts b/backend/api/src/get-my-vote-choices.ts new file mode 100644 index 000000000..301e49a68 --- /dev/null +++ b/backend/api/src/get-my-vote-choices.ts @@ -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]))} +} diff --git a/backend/api/src/get-profiles.ts b/backend/api/src/get-profiles.ts index af0b428a7..2390df2e1 100644 --- a/backend/api/src/get-profiles.ts +++ b/backend/api/src/get-profiles.ts @@ -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, @@ -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' @@ -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}) diff --git a/backend/api/src/helpers/own-message.ts b/backend/api/src/helpers/own-message.ts new file mode 100644 index 000000000..e662baf68 --- /dev/null +++ b/backend/api/src/helpers/own-message.ts @@ -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 +} diff --git a/backend/api/src/llm-extract-profile.ts b/backend/api/src/llm-extract-profile.ts index a072a9228..7fdcd950e 100644 --- a/backend/api/src/llm-extract-profile.ts +++ b/backend/api/src/llm-extract-profile.ts @@ -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' @@ -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 diff --git a/backend/api/src/save-subscription-mobile.ts b/backend/api/src/save-subscription-mobile.ts index 5cdb64ec9..4787e6ae9 100644 --- a/backend/api/src/save-subscription-mobile.ts +++ b/backend/api/src/save-subscription-mobile.ts @@ -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], ) diff --git a/backend/api/src/save-subscription.ts b/backend/api/src/save-subscription.ts index f583c2d8b..16e1aa040 100644 --- a/backend/api/src/save-subscription.ts +++ b/backend/api/src/save-subscription.ts @@ -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) { diff --git a/backend/api/src/stats.ts b/backend/api/src/stats.ts index 86cf19c8d..442fc130a 100644 --- a/backend/api/src/stats.ts +++ b/backend/api/src/stats.ts @@ -149,6 +149,7 @@ export const stats: APIHandler<'stats'> = async (_, _auth) => { countryStats, memberGrowthRows, activeMembersCount, + voteCounts, demographicResults, groupShareResults, ] = await Promise.all([ @@ -193,6 +194,12 @@ export const stats: APIHandler<'stats'> = async (_, _auth) => { pg.one( `select count(*)::int as count from user_activity where last_online_time > now() - interval '30 days'`, ), + // Proposal and ballot counts for the democracy panel. Counted here because neither table is + // readable by the client roles any more: individual ballots and anonymous authors are private. + pg.one( + `select (select count(*)::int from votes) as proposals, + (select count(*)::int from vote_results) as ballots`, + ), Promise.all( demographicFields.map(([field, {multi}]) => field === 'age' ? ageDistribution(pg) : fieldDistribution(pg, field, multi), @@ -246,6 +253,8 @@ export const stats: APIHandler<'stats'> = async (_, _auth) => { upcomingEvents: eventsCount.count, messages: messagesCount.count, conversations: conversationCount.count, + proposals: voteCounts.proposals, + ballots: voteCounts.ballots, genderRatio: genderPercentage, genderCounts: genderRatio, // The full ranked list, not a top-N: the /stats world map colours every country with members, and diff --git a/backend/api/src/unsubscribe.ts b/backend/api/src/unsubscribe.ts index 3d58b3586..533f77c38 100644 --- a/backend/api/src/unsubscribe.ts +++ b/backend/api/src/unsubscribe.ts @@ -56,11 +56,11 @@ export const unsubscribe: APIHandler<'unsubscribe/:token'> = async ({token}, _au `UPDATE private_users SET data = jsonb_set( data, - '{notificationPreferences,${notification_type}}', - (COALESCE(data->'notificationPreferences'->'${notification_type}', '["email", "browser", "mobile"]'::jsonb) - 'email') + array['notificationPreferences', $2::text], + (COALESCE(data->'notificationPreferences'->$2::text, '["email", "browser", "mobile"]'::jsonb) - 'email') ) WHERE id = $1`, - [user_id], + [user_id, notification_type], ) } else { // Unsubscribe from all emails by adding email to opt_out_all diff --git a/backend/api/tests/unit/create-comment.unit.test.ts b/backend/api/tests/unit/create-comment.unit.test.ts index b5eaf884b..b29d1ab42 100644 --- a/backend/api/tests/unit/create-comment.unit.test.ts +++ b/backend/api/tests/unit/create-comment.unit.test.ts @@ -24,6 +24,7 @@ describe('createComment', () => { jest.resetAllMocks() mockPg = { one: jest.fn(), + oneOrNone: jest.fn(), } ;(supabaseInit.createSupabaseDirectClient as jest.Mock).mockReturnValue(mockPg) }) @@ -82,9 +83,10 @@ describe('createComment', () => { ;(sharedUtils.getUser as jest.Mock) .mockResolvedValueOnce(mockCreator) .mockResolvedValueOnce(mockOnUser) - ;(sharedUtils.getPrivateUser as jest.Mock) - .mockResolvedValueOnce(mockUserId) - .mockResolvedValueOnce(mockOnUser) + ;(sharedUtils.getPrivateUser as jest.Mock).mockImplementation(async (id: string) => + id === mockOnUser.id ? mockOnUser : {id, blockedUserIds: []}, + ) + ;(mockPg.oneOrNone as jest.Mock).mockResolvedValue({comments_enabled: true}) ;(mockPg.one as jest.Mock).mockResolvedValue(mockComment) ;(notificationPrefs.getNotificationDestinationsForUser as jest.Mock).mockReturnValue( mockNotificationDestination, @@ -97,9 +99,11 @@ describe('createComment', () => { expect(sharedUtils.getUser).toBeCalledTimes(2) expect(sharedUtils.getUser).toHaveBeenNthCalledWith(1, mockAuth.uid) expect(sharedUtils.getUser).toHaveBeenNthCalledWith(2, mockUserId.userId) - expect(sharedUtils.getPrivateUser).toBeCalledTimes(2) - expect(sharedUtils.getPrivateUser).toHaveBeenNthCalledWith(1, mockProps.userId) - expect(sharedUtils.getPrivateUser).toHaveBeenNthCalledWith(2, mockOnUser.id) + expect(mockPg.oneOrNone).toBeCalledWith(sqlMatch('select comments_enabled from profiles'), [ + mockProps.userId, + ]) + expect(sharedUtils.getPrivateUser).toHaveBeenCalledWith(mockAuth.uid) + expect(sharedUtils.getPrivateUser).toHaveBeenCalledWith(mockOnUser.id) expect(mockPg.one).toBeCalledTimes(1) expect(mockPg.one).toBeCalledWith(sqlMatch('insert into profile_comments'), [ mockCreator.id, @@ -172,8 +176,11 @@ describe('createComment', () => { .mockResolvedValueOnce(mockCreator) .mockResolvedValueOnce(false) ;(sharedUtils.getPrivateUser as jest.Mock).mockResolvedValue(mockUserId) + ;(mockPg.oneOrNone as jest.Mock).mockResolvedValue({comments_enabled: true}) - expect(createComment(mockProps, mockAuth, mockReq)).rejects.toThrowError('User not found') + await expect(createComment(mockProps, mockAuth, mockReq)).rejects.toThrowError( + 'User not found', + ) }) it('throw if there is no account associated with the authId', async () => { @@ -298,9 +305,9 @@ describe('createComment', () => { } ;(sharedUtils.getUser as jest.Mock).mockResolvedValueOnce(mockCreator) - ;(sharedUtils.getPrivateUser as jest.Mock).mockResolvedValue(null) + ;(mockPg.oneOrNone as jest.Mock).mockResolvedValue(null) - expect(createComment(mockProps, mockAuth, mockReq)).rejects.toThrowError( + await expect(createComment(mockProps, mockAuth, mockReq)).rejects.toThrowError( 'Other user not found', ) }) @@ -344,11 +351,64 @@ describe('createComment', () => { } ;(sharedUtils.getUser as jest.Mock).mockResolvedValueOnce(mockCreator) - ;(sharedUtils.getPrivateUser as jest.Mock).mockResolvedValue(mockUserId) + ;(sharedUtils.getPrivateUser as jest.Mock).mockImplementation(async (id: string) => + id === mockUserId.userId ? mockUserId : {id, blockedUserIds: []}, + ) + ;(mockPg.oneOrNone as jest.Mock).mockResolvedValue({comments_enabled: true}) - expect(createComment(mockProps, mockAuth, mockReq)).rejects.toThrowError( - 'User has blocked you', + await expect(createComment(mockProps, mockAuth, mockReq)).rejects.toThrowError( + 'You can no longer interact with this person', + ) + expect(mockPg.one).not.toHaveBeenCalled() + }) + + it('throw if you have blocked the user', async () => { + const mockAuth = {uid: '321'} as AuthedUser + const mockCreator = {id: '321', name: 'Mock Creator', username: 'mock.creator'} + const mockProps = { + userId: '123', + content: { + type: 'doc', + content: [{type: 'paragraph', content: [{type: 'text', text: 'Hi'}]}], + }, + replyToCommentId: undefined, + } as any + + ;(sharedUtils.getUser as jest.Mock).mockResolvedValueOnce(mockCreator) + ;(sharedUtils.getPrivateUser as jest.Mock).mockImplementation(async (id: string) => + id === mockAuth.uid ? {id, blockedUserIds: ['123']} : {id, blockedUserIds: []}, + ) + ;(mockPg.oneOrNone as jest.Mock).mockResolvedValue({comments_enabled: true}) + + await expect(createComment(mockProps, mockAuth, {} as any)).rejects.toThrowError( + 'You can no longer interact with this person', + ) + expect(mockPg.one).not.toHaveBeenCalled() + }) + + it('throw if the profile has endorsements turned off', async () => { + const mockAuth = {uid: '321'} as AuthedUser + const mockCreator = {id: '321', name: 'Mock Creator', username: 'mock.creator'} + const mockProps = { + userId: '123', + content: { + type: 'doc', + content: [{type: 'paragraph', content: [{type: 'text', text: 'Hi'}]}], + }, + replyToCommentId: undefined, + } as any + + ;(sharedUtils.getUser as jest.Mock).mockResolvedValueOnce(mockCreator) + ;(sharedUtils.getPrivateUser as jest.Mock).mockImplementation(async (id: string) => ({ + id, + blockedUserIds: [], + })) + ;(mockPg.oneOrNone as jest.Mock).mockResolvedValue({comments_enabled: false}) + + await expect(createComment(mockProps, mockAuth, {} as any)).rejects.toThrowError( + 'This person has turned off endorsements', ) + expect(mockPg.one).not.toHaveBeenCalled() }) it('throw if the comment is too long', async () => { diff --git a/backend/api/tests/unit/delete-message.unit.test.ts b/backend/api/tests/unit/delete-message.unit.test.ts index fe11ae3f3..835d5a0fa 100644 --- a/backend/api/tests/unit/delete-message.unit.test.ts +++ b/backend/api/tests/unit/delete-message.unit.test.ts @@ -1,5 +1,6 @@ jest.mock('shared/supabase/init') jest.mock('api/helpers/private-messages') +jest.mock('api/helpers/blocks') import {deleteMessage} from 'api/delete-message' import {AuthedUser} from 'api/helpers/endpoint' @@ -28,6 +29,8 @@ describe('deleteMessage', () => { } const mockMessage = { channel_id: 'mockChannelId', + member_status: 'member', + other_member_ids: ['456'], } const mockAuth = {uid: '321'} as AuthedUser const mockReq = {} as any @@ -40,9 +43,10 @@ describe('deleteMessage', () => { expect(results.success).toBeTruthy() expect(mockPg.oneOrNone).toBeCalledTimes(1) - expect(mockPg.oneOrNone).toBeCalledWith(sqlMatch('SELECT *'), [ + expect(mockPg.oneOrNone).toBeCalledWith(sqlMatch('from private_user_messages msg'), [ mockMessageId.messageId, mockAuth.uid, + true, ]) expect(mockPg.none).toBeCalledTimes(1) expect(mockPg.none).toBeCalledWith(sqlMatch('DELETE'), [ @@ -76,6 +80,8 @@ describe('deleteMessage', () => { } const mockMessage = { channel_id: 'mockChannelId', + member_status: 'member', + other_member_ids: ['456'], } const mockAuth = {uid: '321'} as AuthedUser const mockReq = {} as any diff --git a/backend/api/tests/unit/delete-subscription.unit.test.ts b/backend/api/tests/unit/delete-subscription.unit.test.ts new file mode 100644 index 000000000..3fb988847 --- /dev/null +++ b/backend/api/tests/unit/delete-subscription.unit.test.ts @@ -0,0 +1,46 @@ +jest.mock('shared/supabase/init') + +import {deleteSubscription} from 'api/delete-subscription' +import {AuthedUser} from 'api/helpers/endpoint' +import {sqlMatch} from 'common/test-utils' +import * as supabaseInit from 'shared/supabase/init' + +describe('deleteSubscription', () => { + let mockPg = {} as any + beforeEach(() => { + jest.resetAllMocks() + mockPg = {none: jest.fn().mockResolvedValue(null)} + ;(supabaseInit.createSupabaseDirectClient as jest.Mock).mockReturnValue(mockPg) + }) + + const mockAuth = {uid: '321'} as AuthedUser + + // Scoped to the caller, so nobody can sign another member's device out of their notifications. + it('removes only the caller’s web subscription', async () => { + const result = await deleteSubscription({endpoint: 'mockEndpoint'}, mockAuth, {} as any) + + expect(result).toEqual({success: true}) + expect(mockPg.none).toBeCalledTimes(1) + expect(mockPg.none).toBeCalledWith( + sqlMatch('delete from push_subscriptions where endpoint = $1 and user_id = $2'), + ['mockEndpoint', mockAuth.uid], + ) + }) + + it('removes only the caller’s mobile subscription', async () => { + await deleteSubscription({token: 'mockToken'}, mockAuth, {} as any) + + expect(mockPg.none).toBeCalledTimes(1) + expect(mockPg.none).toBeCalledWith( + sqlMatch('delete from push_subscriptions_mobile where token = $1 and user_id = $2'), + ['mockToken', mockAuth.uid], + ) + }) + + it('rejects a request that names neither', async () => { + await expect(deleteSubscription({}, mockAuth, {} as any)).rejects.toThrow( + 'Missing endpoint or token', + ) + expect(mockPg.none).not.toBeCalled() + }) +}) diff --git a/backend/api/tests/unit/edit-message.unit.test.ts b/backend/api/tests/unit/edit-message.unit.test.ts index 387c46c7a..505db3306 100644 --- a/backend/api/tests/unit/edit-message.unit.test.ts +++ b/backend/api/tests/unit/edit-message.unit.test.ts @@ -1,6 +1,7 @@ jest.mock('shared/supabase/init') jest.mock('shared/encryption') jest.mock('api/helpers/private-messages') +jest.mock('api/helpers/blocks') import {editMessage} from 'api/edit-message' import {AuthedUser} from 'api/helpers/endpoint' @@ -32,6 +33,8 @@ describe('editMessage', () => { const mockPlainTextContent = JSON.stringify(mockProps.content) const mockMessage = { channel_id: 'mockChannelId', + member_status: 'member', + other_member_ids: ['456'], } const mockAuth = {uid: '321'} as AuthedUser const mockReq = {} as any @@ -53,9 +56,10 @@ describe('editMessage', () => { expect(result.success).toBeTruthy() expect(mockPg.oneOrNone).toBeCalledTimes(1) - expect(mockPg.oneOrNone).toBeCalledWith(sqlMatch('SELECT *'), [ + expect(mockPg.oneOrNone).toBeCalledWith(sqlMatch('from private_user_messages msg'), [ mockProps.messageId, mockAuth.uid, + false, ]) expect(encryptionModules.encryptMessage).toBeCalledTimes(1) expect(encryptionModules.encryptMessage).toBeCalledWith(mockPlainTextContent) @@ -86,9 +90,7 @@ describe('editMessage', () => { ;(mockPg.oneOrNone as jest.Mock).mockResolvedValue(null) - expect(editMessage(mockProps, mockAuth, mockReq)).rejects.toThrow( - 'Message not found or cannot be edited', - ) + expect(editMessage(mockProps, mockAuth, mockReq)).rejects.toThrow('Message not found') }) it('should throw if the message broadcast failed', async () => { @@ -98,6 +100,8 @@ describe('editMessage', () => { } const mockMessage = { channel_id: 'mockChannelId', + member_status: 'member', + other_member_ids: ['456'], } const mockAuth = {uid: '321'} as AuthedUser const mockReq = {} as any diff --git a/backend/api/tests/unit/get-my-stars.unit.test.ts b/backend/api/tests/unit/get-my-stars.unit.test.ts new file mode 100644 index 000000000..3705842c9 --- /dev/null +++ b/backend/api/tests/unit/get-my-stars.unit.test.ts @@ -0,0 +1,32 @@ +import {getMyStars} from 'api/get-my-stars' +import {AuthedUser} from 'api/helpers/endpoint' +import {sqlMatch} from 'common/test-utils' +import * as supabaseInit from 'shared/supabase/init' + +jest.mock('shared/supabase/init') + +describe('getMyStars', () => { + let mockPg = {} as any + + beforeEach(() => { + jest.resetAllMocks() + mockPg = {map: jest.fn()} + ;(supabaseInit.createSupabaseDirectClient as jest.Mock).mockReturnValue(mockPg) + }) + afterEach(() => { + jest.restoreAllMocks() + }) + + it("returns the caller's own stars and nobody else's", async () => { + ;(mockPg.map as jest.Mock).mockResolvedValue(['b', 'a']) + + const result = await getMyStars({}, {uid: 'me'} as AuthedUser, {} as any) + + expect(mockPg.map).toBeCalledWith( + sqlMatch('from profile_stars where creator_id = $1 order by created_time desc'), + ['me'], + expect.any(Function), + ) + expect(result).toEqual({targetIds: ['b', 'a']}) + }) +}) diff --git a/backend/api/tests/unit/get-my-vote-choices.unit.test.ts b/backend/api/tests/unit/get-my-vote-choices.unit.test.ts new file mode 100644 index 000000000..abdbef566 --- /dev/null +++ b/backend/api/tests/unit/get-my-vote-choices.unit.test.ts @@ -0,0 +1,33 @@ +import {getMyVoteChoices} from 'api/get-my-vote-choices' +import {AuthedUser} from 'api/helpers/endpoint' +import {sqlMatch} from 'common/test-utils' +import * as supabaseInit from 'shared/supabase/init' + +jest.mock('shared/supabase/init') + +describe('getMyVoteChoices', () => { + let mockPg = {} as any + + beforeEach(() => { + jest.resetAllMocks() + mockPg = {manyOrNone: jest.fn()} + ;(supabaseInit.createSupabaseDirectClient as jest.Mock).mockReturnValue(mockPg) + }) + afterEach(() => { + jest.restoreAllMocks() + }) + + it("returns the caller's own ballots keyed by proposal", async () => { + ;(mockPg.manyOrNone as jest.Mock).mockResolvedValue([ + {vote_id: 3, choice: 1}, + {vote_id: 7, choice: -1}, + ]) + + const result = await getMyVoteChoices({}, {uid: 'me'} as AuthedUser, {} as any) + + expect(mockPg.manyOrNone).toBeCalledWith(sqlMatch('from vote_results where user_id = $1'), [ + 'me', + ]) + expect(result).toEqual({choices: {3: 1, 7: -1}}) + }) +}) diff --git a/backend/api/tests/unit/get-profiles.unit.test.ts b/backend/api/tests/unit/get-profiles.unit.test.ts index 3347ce991..86e1778a1 100644 --- a/backend/api/tests/unit/get-profiles.unit.test.ts +++ b/backend/api/tests/unit/get-profiles.unit.test.ts @@ -69,6 +69,38 @@ describe('getProfiles', () => { expect(profilesModule.loadProfiles).toHaveBeenCalledTimes(1) }) }) + + describe('when ordering by compatibility score', () => { + const props = { + limit: 20, + orderBy: 'compatibility_score' as const, + compatibleWithUserId: 'someone-else', + projection: 'card' as const, + } + + it("refuses a non-admin asking for another member's scores", async () => { + jest.spyOn(profilesModule, 'loadProfiles').mockResolvedValue({profiles: [], count: 0}) + + await expect( + profilesModule.getProfiles(props as any, {uid: 'me'} as any, {} as any), + ).rejects.toMatchObject({code: 403}) + expect(profilesModule.loadProfiles).not.toHaveBeenCalled() + }) + + it('lets a member order by their own scores', async () => { + jest.spyOn(profilesModule, 'loadProfiles').mockResolvedValue({profiles: [], count: 0}) + + await profilesModule.getProfiles( + {...props, compatibleWithUserId: 'me'} as any, + {uid: 'me'} as any, + {} as any, + ) + + expect(profilesModule.loadProfiles).toHaveBeenCalledWith( + expect.objectContaining({compatibleWithUserId: 'me'}), + ) + }) + }) }) describe('loadProfiles', () => { diff --git a/backend/api/tests/unit/own-message.unit.test.ts b/backend/api/tests/unit/own-message.unit.test.ts new file mode 100644 index 000000000..1a402adc1 --- /dev/null +++ b/backend/api/tests/unit/own-message.unit.test.ts @@ -0,0 +1,56 @@ +jest.mock('api/helpers/blocks') + +import * as blocks from 'api/helpers/blocks' +import {getChangeableOwnMessage} from 'api/helpers/own-message' + +describe('getChangeableOwnMessage', () => { + let mockPg = {} as any + beforeEach(() => { + jest.resetAllMocks() + mockPg = {oneOrNone: jest.fn()} + }) + + it('returns the message for an author still in the chat and not blocked', async () => { + const message = {id: 1, channel_id: 7, member_status: 'member', other_member_ids: ['456']} + ;(mockPg.oneOrNone as jest.Mock).mockResolvedValue(message) + + await expect(getChangeableOwnMessage(mockPg, 1, '321')).resolves.toBe(message) + expect(blocks.assertNotBlocked).toBeCalledWith('321', ['456']) + }) + + it('throws when the message is not the caller’s', async () => { + ;(mockPg.oneOrNone as jest.Mock).mockResolvedValue(null) + + await expect(getChangeableOwnMessage(mockPg, 1, '321')).rejects.toThrow('Message not found') + }) + + // A member who left could otherwise go back and rewrite what they said before leaving. + it('throws once the author has left the chat', async () => { + ;(mockPg.oneOrNone as jest.Mock).mockResolvedValue({ + channel_id: 7, + member_status: 'left', + other_member_ids: ['456'], + }) + + await expect(getChangeableOwnMessage(mockPg, 1, '321')).rejects.toThrow( + 'You are no longer in this chat', + ) + expect(blocks.assertNotBlocked).not.toBeCalled() + }) + + // A blocked harasser could otherwise edit or delete the messages about to be reported. + it('throws when a block stands between the author and another member', async () => { + ;(mockPg.oneOrNone as jest.Mock).mockResolvedValue({ + channel_id: 7, + member_status: 'member', + other_member_ids: ['456'], + }) + ;(blocks.assertNotBlocked as jest.Mock).mockRejectedValue( + new Error('You can no longer interact with this person'), + ) + + await expect(getChangeableOwnMessage(mockPg, 1, '321')).rejects.toThrow( + 'You can no longer interact with this person', + ) + }) +}) diff --git a/backend/api/tests/unit/save-subscription-mobile.unit.test.ts b/backend/api/tests/unit/save-subscription-mobile.unit.test.ts index 3b8d5050a..fc67ed459 100644 --- a/backend/api/tests/unit/save-subscription-mobile.unit.test.ts +++ b/backend/api/tests/unit/save-subscription-mobile.unit.test.ts @@ -47,6 +47,19 @@ describe('saveSubscriptionMobile', () => { expect(mockPg.none).toBeCalledWith(expect.anything(), ['iosToken', 'ios', mockAuth.uid]) }) + // The token used to be reassigned to whoever sent it, which let anyone holding another member's + // token redirect their notifications. + it('never reassigns a token owned by another member', async () => { + const mockAuth = {uid: '321'} as AuthedUser + ;(mockPg.none as jest.Mock).mockResolvedValue(null) + + await saveSubscriptionMobile({token: 'someToken'}, mockAuth, {} as any) + + const sql = (mockPg.none as jest.Mock).mock.calls[0][0] as string + expect(sql).not.toMatch(/set[^]*user_id\s*=[^]*where/) + expect(sql).toMatch(/where push_subscriptions_mobile\.user_id = excluded\.user_id/) + }) + // Android builds shipped before iOS existed send only a token. it('defaults to android when the client does not say, for builds that predate the field', async () => { const mockAuth = {uid: '321'} as AuthedUser diff --git a/backend/api/tests/unit/save-subscription.unit.test.ts b/backend/api/tests/unit/save-subscription.unit.test.ts index 6e1272ac1..64ffe8349 100644 --- a/backend/api/tests/unit/save-subscription.unit.test.ts +++ b/backend/api/tests/unit/save-subscription.unit.test.ts @@ -29,51 +29,36 @@ describe('saveSubscription', () => { } const mockAuth = {uid: '321'} as AuthedUser const mockReq = {} as any - const mockExists = {id: 'mockId'} - ;(mockPg.oneOrNone as jest.Mock).mockResolvedValue(mockExists) ;(mockPg.none as jest.Mock).mockResolvedValue(null) const result = await saveSubscription(mockBody, mockAuth, mockReq) expect(result.success).toBeTruthy() - expect(mockPg.oneOrNone).toBeCalledTimes(1) - expect(mockPg.oneOrNone).toBeCalledWith( - sqlMatch('select id from push_subscriptions where endpoint = $1'), - [mockBody.subscription.endpoint], - ) expect(mockPg.none).toBeCalledTimes(1) expect(mockPg.none).toBeCalledWith( - sqlMatch('update push_subscriptions set keys = $1, user_id = $2 where id = $3'), - [mockBody.subscription.keys, mockAuth.uid, mockExists.id], + sqlMatch('insert into push_subscriptions(endpoint, keys, user_id) values($1, $2, $3)'), + [mockBody.subscription.endpoint, mockBody.subscription.keys, mockAuth.uid], ) }) - it('should save user subscription even if this is their first one', async () => { - const mockBody = { - subscription: { - endpoint: 'mockEndpoint', - keys: 'mockKeys', - }, - } + // The endpoint used to be reassigned to whoever sent it, which let anyone holding another + // member's endpoint redirect their notifications. + it("only updates an existing endpoint that is unowned or already the caller's", async () => { const mockAuth = {uid: '321'} as AuthedUser - const mockReq = {} as any - - ;(mockPg.oneOrNone as jest.Mock).mockResolvedValue(false) ;(mockPg.none as jest.Mock).mockResolvedValue(null) - const result = await saveSubscription(mockBody, mockAuth, mockReq) - - expect(result.success).toBeTruthy() - expect(mockPg.oneOrNone).toBeCalledTimes(1) - expect(mockPg.oneOrNone).toBeCalledWith( - sqlMatch('select id from push_subscriptions where endpoint = $1'), - [mockBody.subscription.endpoint], + await saveSubscription( + {subscription: {endpoint: 'mockEndpoint', keys: 'mockKeys'}} as any, + mockAuth, + {} as any, ) - expect(mockPg.none).toBeCalledTimes(1) + expect(mockPg.none).toBeCalledWith( - sqlMatch('insert into push_subscriptions(endpoint, keys, user_id) values($1, $2, $3)'), - [mockBody.subscription.endpoint, mockBody.subscription.keys, mockAuth.uid], + sqlMatch( + 'where push_subscriptions.user_id is null or push_subscriptions.user_id = excluded.user_id', + ), + expect.anything(), ) }) }) diff --git a/backend/email/emails/new-message.tsx b/backend/email/emails/new-message.tsx index f0c10d678..95ebfaf80 100644 --- a/backend/email/emails/new-message.tsx +++ b/backend/email/emails/new-message.tsx @@ -2,6 +2,7 @@ import {Img, Link, Section, Text} from '@react-email/components' import {DEPLOYED_WEB_URL, DOMAIN} from 'common/envs/constants' import {type ProfileRow} from 'common/profiles/profile' import {type User} from 'common/user' +import {ownImageUrlOrUndefined} from 'common/util/image-url' import { Actions, CTAButton, @@ -45,7 +46,9 @@ export const NewMessageEmail = ({ const profileUrl = `https://${DOMAIN}/${fromUser.username}` // Every client renders reliably, none of them render a CSS-generated placeholder — so an absent // avatar falls back to the same asset the web app uses rather than to a broken image icon. - const avatarUrl = fromUser.avatarUrl + // Only a photo we host: a mail client loads it the moment the email is opened, so one on the + // sender's own server would hand them the reader's IP and a read receipt (see `isOwnImageUrl`). + const avatarUrl = ownImageUrlOrUndefined(fromUser.avatarUrl) const t = createT(locale) // Age and city are the two facts a reader can act on at a glance; anything more turns the card into a diff --git a/backend/email/emails/utils.tsx b/backend/email/emails/utils.tsx index 091710680..6bca3ba25 100644 --- a/backend/email/emails/utils.tsx +++ b/backend/email/emails/utils.tsx @@ -13,6 +13,7 @@ import { Text, } from '@react-email/components' import {DEPLOYED_WEB_URL} from 'common/envs/constants' +import {ownImageUrlOrUndefined} from 'common/util/image-url' import React, {type CSSProperties, type ReactNode} from 'react' import {createT} from 'shared/locale' @@ -90,9 +91,8 @@ export const fonts = { * silhouette that reads as an empty circle sitting next to members who do have a photo. */ export const avatarSrc = (avatarUrl: string | null | undefined, name?: string | null) => - avatarUrl && avatarUrl.length > 0 - ? avatarUrl - : `https://ui-avatars.com/api/?name=${encodeURIComponent((name || 'U')[0])}&size=128&format=png` + ownImageUrlOrUndefined(avatarUrl) ?? + `https://ui-avatars.com/api/?name=${encodeURIComponent((name || 'U')[0])}&size=128&format=png` // ─── Shell ──────────────────────────────────────────────────────────────────── diff --git a/backend/shared/src/profiles/rehost-images.ts b/backend/shared/src/profiles/rehost-images.ts index ff4f6aa34..0a77600b5 100644 --- a/backend/shared/src/profiles/rehost-images.ts +++ b/backend/shared/src/profiles/rehost-images.ts @@ -1,5 +1,6 @@ import {JSONContent} from '@tiptap/core' import {FIREBASE_STORAGE_URL} from 'common/envs/constants' +import {isOwnImageUrl} from 'common/util/image-url' import {YEAR_SECONDS} from 'common/util/time' import {createHash} from 'crypto' import {uniq} from 'lodash' @@ -183,10 +184,12 @@ async function fetchImage(src: string) { /** * The first image in the document that we serve ourselves — the one worth pinning as the profile * photo. Deliberately never an image left hotlinked: an avatar is rendered through next/image - * everywhere, which 400s on any host missing from `remotePatterns`. + * everywhere, which 400s on any host missing from `remotePatterns`. Stricter than {@link isOwnHost}: + * a page can embed an image from somebody else's bucket on the same Google host, and the profile + * schema refuses that as a `pinned_url` (see `isOwnImageUrl`). */ export function firstOwnedImageSrc(content: JSONContent): string | undefined { - return collectImageSrcs(content).find(isOwnHost) + return collectImageSrcs(content).find(isOwnImageUrl) } function shouldRehost(src: string): boolean { diff --git a/backend/shared/tests/unit/rehost-images.unit.test.ts b/backend/shared/tests/unit/rehost-images.unit.test.ts index b00fb2922..97a90ff79 100644 --- a/backend/shared/tests/unit/rehost-images.unit.test.ts +++ b/backend/shared/tests/unit/rehost-images.unit.test.ts @@ -255,11 +255,18 @@ describe('rehostExternalImages', () => { describe('firstOwnedImageSrc', () => { it('returns the first image we serve ourselves', async () => { - const ours = 'https://firebasestorage.googleapis.com/v0/b/b/o/user-images%2Fa%2Fb.jpg?alt=media' + const ours = + 'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/user-images%2Fa%2Fb.jpg?alt=media' expect(firstOwnedImageSrc(docWithImages('https://awlego.com/a.jpg', ours))).toEqual(ours) }) + it("skips an image in somebody else's bucket on the same Google host", async () => { + const theirs = 'https://firebasestorage.googleapis.com/v0/b/someone-else/o/a.jpg?alt=media' + + expect(firstOwnedImageSrc(docWithImages(theirs))).toBeUndefined() + }) + it('never returns an image still hosted elsewhere', async () => { // An avatar goes through next/image, which rejects any host not in `remotePatterns`. expect(firstOwnedImageSrc(docWithImages('https://awlego.com/a.jpg'))).toBeUndefined() diff --git a/backend/supabase/migration.sql b/backend/supabase/migration.sql index 29859353c..ae3a0c9ba 100644 --- a/backend/supabase/migration.sql +++ b/backend/supabase/migration.sql @@ -94,4 +94,6 @@ BEGIN; \i backend/supabase/migrations/20261028_drop_compatibility_answers_free.sql \i backend/supabase/migrations/20261028_lock_likes_ships.sql \i backend/supabase/migrations/20261028_lock_client_read_tables.sql +\i backend/supabase/migrations/20261029_redact_disabled_profiles.sql +\i backend/supabase/migrations/20261029_private_votes_and_stars.sql COMMIT; diff --git a/backend/supabase/migrations/20261029_private_votes_and_stars.sql b/backend/supabase/migrations/20261029_private_votes_and_stars.sql new file mode 100644 index 000000000..9c7808661 --- /dev/null +++ b/backend/supabase/migrations/20261029_private_votes_and_stars.sql @@ -0,0 +1,141 @@ +-- Make ballots, anonymous proposal authors and stars private. +-- Created: 2026-10-29 +-- +-- Three reads were open to anyone holding the public anon key: +-- +-- vote_results "public read" policy: every member's ballot on every proposal. +-- votes "public read" policy: creator_id on every row, including the proposals +-- posted with is_anonymous = true. +-- get_profile_stars security definer, granted to anon, takes any member id: who anyone has saved. +-- +-- Same pattern as 20261028_lock_client_read_tables.sql: drop the public-read policies and revoke the +-- anon/authenticated SELECT grant. The client's remaining read shapes move to: +-- +-- proposal list + tallies get_votes_with_results(), now security definer and with creator_id +-- nulled on anonymous proposals. Tallies stay public; ballots do not. +-- viewer's own ballots the get-my-vote-choices API endpoint (needs the caller's identity, +-- which a function on the anon key does not have). +-- commenters' ballots get_vote_commenter_choices(), below. Shown next to a comment so readers +-- know which way its author voted; limited to members who commented on +-- that proposal, since they argued in public under their own name. +-- proposal/ballot counts the /stats endpoint. +-- viewer's own stars the get-my-stars API endpoint. get_profile_stars() is dropped. +-- +-- Writes to votes and vote_results already go through the service-role backend, which bypasses RLS and +-- keeps its grant. + +drop policy if exists "public read" on votes; +drop policy if exists "public read" on vote_results; + +revoke select on table votes, vote_results from anon, authenticated; + +-- Same columns and ranking as 20260808_vote_top_arguments.sql. Two changes: security definer, so the +-- client roles can still read the tallies without a grant on the tables, and creator_id is null when +-- the proposal is anonymous. +create or replace function get_votes_with_results( + order_by text default 'recent', + only_vote_id bigint default null +) + returns table + ( + id bigint, + title text, + description jsonb, + created_time timestamptz, + creator_id text, + is_anonymous boolean, + status text, + votes_for int, + votes_against int, + votes_abstain int, + priority int, + comment_count int, + top_for jsonb, + top_against jsonb + ) + language sql + stable + security definer + set search_path = public +as +$$ +with results as (select v.id, + v.title, + v.description, + v.created_time, + case when v.is_anonymous then null else v.creator_id end as creator_id, + v.is_anonymous, + v.status, + coalesce(sum(case when r.choice = 1 then 1 else 0 end), 0) as votes_for, + coalesce(sum(case when r.choice = -1 then 1 else 0 end), 0) as votes_against, + coalesce(sum(case when r.choice = 0 then 1 else 0 end), 0) as votes_abstain, + coalesce(sum(r.priority), 0)::float / + greatest(coalesce(sum(case when r.choice = 1 then 1 else 0 end), 1), 1) * 100 / + 3 as priority, + -- Counted in a subquery rather than another left join: joining both children + -- multiplies the rows and inflates every vote tally. + (select count(*) + from vote_comments c + where c.vote_id = v.id + and not c.hidden) as comment_count, + top_argument(v.id, 'for') as top_for, + top_argument(v.id, 'against') as top_against + from votes v + left join vote_results r on v.id = r.vote_id + where only_vote_id is null + or v.id = only_vote_id + group by v.id) +select id, + title, + description, + created_time, + creator_id, + is_anonymous, + status, + votes_for, + votes_against, + votes_abstain, + priority, + comment_count, + top_for, + top_against +from results +order by case when order_by = 'recent' then created_time end desc, + case when order_by = 'mostVoted' then (votes_for + votes_against + votes_abstain) end desc, + case when order_by = 'mostVoted' then created_time end desc, + case when order_by = 'mostDiscussed' then comment_count end desc, + case when order_by = 'mostDiscussed' then created_time end desc, + case when order_by = 'priority' then priority end desc, + case when order_by = 'priority' then created_time end desc; +$$; + +-- The ballot of each member with a visible comment on one proposal. Nobody else's. +create or replace function get_vote_commenter_choices(vid bigint) + returns table + ( + user_id text, + choice smallint + ) + language sql + stable + security definer + set search_path = public +as +$$ +select r.user_id, r.choice +from vote_results r +where r.vote_id = vid + and exists (select 1 + from vote_comments c + where c.vote_id = vid + and c.user_id = r.user_id + and not c.hidden); +$$; + +grant execute on function get_votes_with_results(text, bigint) to anon, authenticated; +grant execute on function get_vote_commenter_choices(bigint) to anon, authenticated; + +drop function if exists get_profile_stars(text); + +-- Make PostgREST pick up the changes immediately (Supabase also auto-reloads on DDL). +notify pgrst, 'reload schema'; diff --git a/backend/supabase/migrations/20261029_redact_disabled_profiles.sql b/backend/supabase/migrations/20261029_redact_disabled_profiles.sql new file mode 100644 index 000000000..1d399fc29 --- /dev/null +++ b/backend/supabase/migrations/20261029_redact_disabled_profiles.sql @@ -0,0 +1,49 @@ +-- Disabled profiles must not be readable with the public anon key either. +-- Created: 2026-10-29 +-- +-- 20260901_redact_member_only_profiles.sql taught get_profile_by_user_id() to redact members-only +-- profiles, but a disabled profile with `visibility = 'public'` still came back in full. The API +-- (`getUserAndProfile`) already hides a disabled profile from everyone but its owner and admins, so +-- this function was the one road left to the whole row. +-- +-- The function runs on the anon key and cannot tell the owner or an admin from a stranger, so it +-- redacts for everyone, exactly as it does for members-only profiles. That costs nothing: the owner's +-- own profile is read through the authenticated `get-profile` endpoint (see web/hooks/use-profile.tsx), +-- and admins review profiles through the API too. The redacted row keeps `disabled`, which is how the +-- page still says "disabled" rather than pretending the account does not exist. + +create or replace function get_profile_by_user_id(uid text) + returns setof profiles + language plpgsql + stable + security definer + set search_path = public +as +$$ +declare + full_row profiles; + redacted_row profiles; +begin + select * into full_row from profiles where user_id = uid limit 1; + if not found then + return; + end if; + + if full_row.visibility <> 'member' and not full_row.disabled then + return next full_row; + return; + end if; + + -- Fields are copied across one by one rather than nulled out of a copy, so a column added to + -- `profiles` later is excluded by default instead of leaking until someone remembers this file. + redacted_row.user_id := full_row.user_id; + redacted_row.visibility := full_row.visibility; + redacted_row.disabled := full_row.disabled; + return next redacted_row; +end; +$$; + +grant execute on function get_profile_by_user_id(text) to anon, authenticated; + +-- Make PostgREST pick up the new definition immediately (Supabase also auto-reloads on DDL). +notify pgrst, 'reload schema'; diff --git a/backend/supabase/vote_results.sql b/backend/supabase/vote_results.sql index 4e566c455..6690c92bf 100644 --- a/backend/supabase/vote_results.sql +++ b/backend/supabase/vote_results.sql @@ -18,10 +18,10 @@ add constraint vote_results_vote_id_fkey foreign key (vote_id) references votes -- Row Level Security ALTER TABLE vote_results ENABLE ROW LEVEL SECURITY; --- Policies -DROP POLICY IF EXISTS "public read" ON vote_results; -CREATE POLICY "public read" ON vote_results -FOR SELECT USING (true); +-- No SELECT policy: RLS is enabled with no read policy AND the anon/authenticated SELECT grant is +-- revoked (migration 20261029_private_votes_and_stars.sql dropped the former "public read" policy). +-- Ballots and anonymous proposal authors are private; the client reads tallies through the security +-- definer get_votes_with_results(). -- Indexes CREATE INDEX IF NOT EXISTS user_id_idx ON vote_results (user_id); diff --git a/backend/supabase/votes.sql b/backend/supabase/votes.sql index 0a2c05643..ee39ba88e 100644 --- a/backend/supabase/votes.sql +++ b/backend/supabase/votes.sql @@ -15,10 +15,10 @@ add constraint votes_creator_id_fkey foreign key (creator_id) references users ( -- Row Level Security ALTER TABLE votes ENABLE ROW LEVEL SECURITY; --- Policies -DROP POLICY IF EXISTS "public read" ON votes; -CREATE POLICY "public read" ON votes -FOR SELECT USING (true); +-- No SELECT policy: RLS is enabled with no read policy AND the anon/authenticated SELECT grant is +-- revoked (migration 20261029_private_votes_and_stars.sql dropped the former "public read" policy). +-- Ballots and anonymous proposal authors are private; the client reads tallies through the security +-- definer get_votes_with_results(). -- Indexes CREATE INDEX IF NOT EXISTS creator_id_idx ON votes (creator_id); diff --git a/common/messages/de.json b/common/messages/de.json index e532ea249..7100f312f 100644 --- a/common/messages/de.json +++ b/common/messages/de.json @@ -505,6 +505,7 @@ "filter.group.background": "Hintergrund", "filter.group.display": "Darstellung", "filter.group.lifestyle": "Lebensstil", + "filter.group.more": "Weitere Filter", "filter.group.personality": "Persönlichkeit", "filter.group.relationship": "Beziehung", "filter.group.values": "Werte & Überzeugungen", diff --git a/common/messages/fr.json b/common/messages/fr.json index ac88cf61d..fcd088ba8 100644 --- a/common/messages/fr.json +++ b/common/messages/fr.json @@ -505,6 +505,7 @@ "filter.group.background": "Milieu", "filter.group.display": "Affichage", "filter.group.lifestyle": "Mode de vie", + "filter.group.more": "Plus de filtres", "filter.group.personality": "Personnalité", "filter.group.relationship": "Amour & Famille", "filter.group.values": "Valeurs & Croyances", diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index b2686016f..f0d5d5f1d 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -3,6 +3,7 @@ import { arraybeSchema, contentSchema, dateSchema, + ownImageUrl, profileSchema, zBoolean, } from 'common/api/zod-types' @@ -478,7 +479,7 @@ export const API = (_apiTypeCheck = { props: z.object({ name: z.string().trim().min(1).optional(), username: z.string().trim().min(1).optional(), - avatarUrl: z.string().optional(), + avatarUrl: ownImageUrl.optional(), }), returns: {} as FullUser, summary: 'Update authenticated user profile and settings', @@ -842,6 +843,15 @@ export const API = (_apiTypeCheck = { summary: 'Star or unstar a profile', tag: 'Relations', }, + 'get-my-stars': { + method: 'GET', + authed: true, + rateLimited: true, + props: z.object({}).strict(), + returns: {} as {targetIds: string[]}, + summary: 'Ids of the profiles the caller has starred, newest first', + tag: 'Relations', + }, 'hide-profile': { method: 'POST', authed: true, @@ -1472,6 +1482,15 @@ export const API = (_apiTypeCheck = { summary: 'Mute or unmute notifications for a proposal discussion', tag: 'Votes', }, + 'get-my-vote-choices': { + method: 'GET', + authed: true, + rateLimited: true, + props: z.object({}).strict(), + returns: {} as {choices: Record}, + summary: "The caller's own ballot on every proposal: 1 for, 0 abstain, -1 against", + tag: 'Votes', + }, 'get-vote-mute': { method: 'GET', authed: true, @@ -1579,6 +1598,19 @@ export const API = (_apiTypeCheck = { summary: 'Save a mobile push subscription for the user', tag: 'Notifications', }, + 'delete-subscription': { + method: 'POST', + authed: true, + rateLimited: true, + returns: {} as {success: boolean}, + props: z.object({ + // The web push endpoint, the native push token, or both — whichever this device holds. + endpoint: z.string().optional(), + token: z.string().optional(), + }), + summary: "Remove this device's push subscription for the user, on sign-out", + tag: 'Notifications', + }, 'create-bookmarked-search': { method: 'POST', authed: true, diff --git a/common/src/api/zod-types.ts b/common/src/api/zod-types.ts index aa09ec232..92e14e183 100644 --- a/common/src/api/zod-types.ts +++ b/common/src/api/zod-types.ts @@ -3,8 +3,17 @@ import {FEED_VISIBILITY_LEVELS} from 'common/feed/feed' import {MAX_PREF_MAX_DISTANCE, MIN_PREF_MAX_DISTANCE} from 'common/max-distance' import {MAX_PROFILE_AGE, MIN_PROFILE_AGE, STORED_BIRTH_DATE_REGEX} from 'common/profiles/birth-date' import {arrify} from 'common/util/array' +import {isOwnImageUrl} from 'common/util/image-url' import {z} from 'zod' +/** + * A photo URL other members will load: only images we host, so a member cannot point theirs at a + * server that logs who views it. See {@link isOwnImageUrl}. + */ +export const ownImageUrl = z + .string() + .refine(isOwnImageUrl, {message: 'Photos must be uploaded to Compass'}) + /* GET request array can be like ?a=1 or ?a=1&a=2 */ export const arraybeSchema = z.array(z.string()).or(z.string()).transform(arrify) @@ -69,8 +78,9 @@ export const profileSchema = z.object({ geodb_city_id: z.string().optional().nullable(), languages: z.array(z.string()).optional().nullable(), looking_for_matches: zBoolean.optional(), - photo_urls: z.array(z.string()).optional().nullable(), - pinned_url: z.string().optional().nullable(), + photo_urls: z.array(ownImageUrl).optional().nullable(), + // Empty means "no photo", as for the other optional strings here. + pinned_url: ownImageUrl.or(z.literal('')).optional().nullable(), pref_age_max: z.number().min(18).max(100).optional().nullable(), pref_age_min: z.number().min(18).max(100).optional().nullable(), pref_gender: genderTypes.optional().nullable(), diff --git a/common/src/choices.ts b/common/src/choices.ts index 8ad6fa0be..bb73740e8 100644 --- a/common/src/choices.ts +++ b/common/src/choices.ts @@ -1,10 +1,12 @@ import {invert} from 'lodash' +// Key order is display order everywhere connection types are listed (checkboxes, connect buttons, +// the "Looking for" text). export const RELATIONSHIP_CHOICES = { // Other: 'other', - Collaboration: 'collaboration', - Friendship: 'friendship', Relationship: 'relationship', + Friendship: 'friendship', + Collaboration: 'collaboration', } as const // Values match the `profile_visibility` enum on `profiles`. Labels describe the *audience*, not the diff --git a/common/src/envs/prod.ts b/common/src/envs/prod.ts index 0e9d79a3f..eacff3f6f 100644 --- a/common/src/envs/prod.ts +++ b/common/src/envs/prod.ts @@ -57,7 +57,7 @@ export const PROD_CONFIG: EnvConfig = { adminIds: [ '0vaZsIJk9zLVOWY4gb61gTrRIU73', // Martin ], - faviconPath: '/favicon-tab.svg', + faviconPath: '/favicon.svg', } export const refreshConfig = () => { diff --git a/common/src/stats.ts b/common/src/stats.ts index 67402194a..815ac4e1b 100644 --- a/common/src/stats.ts +++ b/common/src/stats.ts @@ -73,6 +73,11 @@ export type Stats = { messages: number /** Message channels that carry at least one message — empty channels aren't conversations. */ conversations: number + /** Proposals on /vote. Counted server-side: `votes` is not readable by the client roles. */ + proposals: number + /** Ballots cast across all proposals. Counted server-side: `vote_results` is not readable by the + * client roles. */ + ballots: number genderRatio: Record genderCounts: Record /** Every country with at least one member, by profile count descending. Profiles with no country set diff --git a/common/src/supabase/schema.ts b/common/src/supabase/schema.ts index c0f325aa2..be4348f3d 100644 --- a/common/src/supabase/schema.ts +++ b/common/src/supabase/schema.ts @@ -4,10 +4,48 @@ export type Database = { // Allows to automatically instantiate createClient with right options // instead of createClient(URL, KEY) __InternalSupabase: { - PostgrestVersion: '14.5' + PostgrestVersion: '13.0.4' } public: { Tables: { + banned_networks: { + Row: { + created_at: string + device_token: string | null + expires_at: string | null + id: number + network: unknown + note: string | null + source_user_id: string | null + } + Insert: { + created_at?: string + device_token?: string | null + expires_at?: string | null + id?: never + network?: unknown + note?: string | null + source_user_id?: string | null + } + Update: { + created_at?: string + device_token?: string | null + expires_at?: string | null + id?: never + network?: unknown + note?: string | null + source_user_id?: string | null + } + Relationships: [ + { + foreignKeyName: 'banned_networks_source_user_id_fkey' + columns: ['source_user_id'] + isOneToOne: false + referencedRelation: 'users' + referencedColumns: ['id'] + }, + ] + } blog_posts: { Row: { author_id: string | null @@ -234,51 +272,6 @@ export type Database = { }, ] } - compatibility_answers_free: { - Row: { - created_time: string - creator_id: string - free_response: string | null - id: number - integer: number | null - multiple_choice: number | null - question_id: number - } - Insert: { - created_time?: string - creator_id: string - free_response?: string | null - id?: number - integer?: number | null - multiple_choice?: number | null - question_id: number - } - Update: { - created_time?: string - creator_id?: string - free_response?: string | null - id?: number - integer?: number | null - multiple_choice?: number | null - question_id?: number - } - Relationships: [ - { - foreignKeyName: 'compatibility_answers_free_creator_id_fkey' - columns: ['creator_id'] - isOneToOne: false - referencedRelation: 'users' - referencedColumns: ['id'] - }, - { - foreignKeyName: 'compatibility_answers_free_question_id_fkey' - columns: ['question_id'] - isOneToOne: false - referencedRelation: 'compatibility_prompts' - referencedColumns: ['id'] - }, - ] - } compatibility_prompts: { Row: { answer_count: number | null @@ -537,6 +530,51 @@ export type Database = { } Relationships: [] } + donations: { + Row: { + admin_id: string | null + amount_cents: number + created_time: string + donated_at: string + id: number + note: string | null + user_id: string + } + Insert: { + admin_id?: string | null + amount_cents: number + created_time?: string + donated_at?: string + id?: never + note?: string | null + user_id: string + } + Update: { + admin_id?: string | null + amount_cents?: number + created_time?: string + donated_at?: string + id?: never + note?: string | null + user_id?: string + } + Relationships: [ + { + foreignKeyName: 'donations_admin_id_fkey' + columns: ['admin_id'] + isOneToOne: false + referencedRelation: 'users' + referencedColumns: ['id'] + }, + { + foreignKeyName: 'donations_user_id_fkey' + columns: ['user_id'] + isOneToOne: false + referencedRelation: 'users' + referencedColumns: ['id'] + }, + ] + } email_unsubscribe_tokens: { Row: { created_at: string diff --git a/common/src/util/image-url.ts b/common/src/util/image-url.ts new file mode 100644 index 000000000..e0e677fe8 --- /dev/null +++ b/common/src/util/image-url.ts @@ -0,0 +1,69 @@ +import {FIREBASE_STORAGE_URL, WEB_URL} from 'common/envs/constants' +import {DEV_CONFIG} from 'common/envs/dev' +import {PROD_CONFIG} from 'common/envs/prod' +import {uniq} from 'lodash' + +/** + * Buckets whose objects are ours. Both environments, not just the current one: dev data is seeded + * with prod photo URLs, and either way nobody outside the project can read the bucket's access logs. + */ +const OWN_BUCKETS = uniq([ + PROD_CONFIG.firebaseConfig.storageBucket, + DEV_CONFIG.firebaseConfig.storageBucket, +]) + +const OWN_WEB_ORIGINS = uniq([ + 'https://compassmeet.com', + 'https://www.compassmeet.com', + new URL(WEB_URL).origin, +]) + +const STORAGE_ORIGIN = new URL(FIREBASE_STORAGE_URL).origin + +/** + * Whether `src` is an image we serve ourselves, and so is safe to put in an `` other members (or + * their mail client) will load. + * + * A member's photo URLs are rendered for everyone who views them. If they could point at their own + * server, every viewer's browser would request it: that reveals the viewer's IP address and, in the + * new-message email, works as a read receipt. So only these are accepted: + * - objects in one of our own Storage buckets — checked by bucket, not just by host, because + * `firebasestorage.googleapis.com` and `storage.googleapis.com` serve *anyone's* bucket, and the + * bucket's owner sees its access logs; + * - static assets under `/images/` on our own site (the default avatar, the showcase portraits), + * absolute or root-relative. Any other path on our site is refused: one that redirects would lead + * the image request straight back off-site. + */ +export function isOwnImageUrl(src: string | null | undefined): boolean { + if (!src) return false + let url: URL + try { + // The base only matters for root-relative paths. `//evil.com/x` and `/\evil.com/x` parse to + // evil.com, so they fall through to the host checks below and are refused there. + url = new URL(src, WEB_URL) + } catch { + return false + } + + const segments = url.pathname.split('/').filter(Boolean) + + // Compared by origin, so the scheme counts too: only the Storage emulator and the local dev server + // are plain http. + if (url.origin === STORAGE_ORIGIN || url.origin === 'https://firebasestorage.googleapis.com') { + // `/v0/b//o/` from the SDKs, `//` from `generateAvatarUrl`. + const bucket = segments[0] === 'v0' && segments[1] === 'b' ? segments[2] : segments[0] + return OWN_BUCKETS.includes(bucket) + } + if (url.origin === 'https://storage.googleapis.com') { + return OWN_BUCKETS.includes(segments[0]) + } + if (OWN_WEB_ORIGINS.includes(url.origin)) { + return segments[0] === 'images' + } + return false +} + +/** `src` if it is one of ours (see {@link isOwnImageUrl}), otherwise undefined. */ +export function ownImageUrlOrUndefined(src: string | null | undefined): string | undefined { + return src && isOwnImageUrl(src) ? src : undefined +} diff --git a/common/tests/unit/image-url.test.ts b/common/tests/unit/image-url.test.ts new file mode 100644 index 000000000..35cd74dfd --- /dev/null +++ b/common/tests/unit/image-url.test.ts @@ -0,0 +1,56 @@ +import {profileSchema} from 'common/api/zod-types' +import {isOwnImageUrl} from 'common/util/image-url' + +describe('isOwnImageUrl', () => { + it.each([ + 'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/user-images%2Fa%2Fb.jpg?alt=media&token=x', + 'https://firebasestorage.googleapis.com/v0/b/compass-57c3c.firebasestorage.app/o/a.jpg?alt=media', + // `generateAvatarUrl`'s shape + 'https://firebasestorage.googleapis.com/compass-130ba.firebasestorage.app/user-images/uid.png', + 'https://storage.googleapis.com/compass-130ba.firebasestorage.app/user-images/uid.png', + 'https://www.compassmeet.com/images/default-avatar.png', + 'https://compassmeet.com/images/default-avatar.png', + '/images/showcase/alexmorel-1.jpg', + ])('accepts our own image %s', (src) => { + expect(isOwnImageUrl(src)).toBe(true) + }) + + it.each([ + 'https://attacker.example/pixel.png', + 'http://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/a.jpg', + // Same Google hosts, somebody else's bucket — they can read its access logs + 'https://firebasestorage.googleapis.com/v0/b/attacker-bucket/o/a.jpg?alt=media', + 'https://storage.googleapis.com/attacker-bucket/a.jpg', + 'https://firebasestorage.googleapis.com.attacker.example/v0/b/compass-130ba.firebasestorage.app/o/a.jpg', + // Our site, but not a static image — could redirect off-site + 'https://www.compassmeet.com/api/whatever?url=https://attacker.example', + 'https://www.compassmeet.com/images/../redirect', + '//attacker.example/images/a.png', + '/\\attacker.example/images/a.png', + 'javascript:alert(1)', + 'data:image/png;base64,AAAA', + 'not a url', + '', + null, + undefined, + ])('refuses %s', (src) => { + expect(isOwnImageUrl(src)).toBe(false) + }) +}) + +describe('profileSchema photo URLs', () => { + const ours = 'https://www.compassmeet.com/images/default-avatar.png' + const theirs = 'https://attacker.example/pixel.png' + const parse = (props: object) => profileSchema.partial().safeParse(props).success + + it('accepts our own photos and an empty pinned_url', () => { + expect(parse({pinned_url: ours, photo_urls: [ours]})).toBe(true) + expect(parse({pinned_url: ''})).toBe(true) + expect(parse({pinned_url: null, photo_urls: null})).toBe(true) + }) + + it('refuses a photo hosted elsewhere', () => { + expect(parse({pinned_url: theirs})).toBe(false) + expect(parse({photo_urls: [ours, theirs]})).toBe(false) + }) +}) diff --git a/docs/filters.md b/docs/filters.md index 17a564387..0dd33b3e6 100644 --- a/docs/filters.md +++ b/docs/filters.md @@ -26,6 +26,13 @@ - Big Five — ranges of the Big Five personality traits. - Last Active — how recently they were active on the platform. +The panel splits them in two. The likely dealbreakers are always visible: Seeking, Age, Gender, Living, +plus Status, Style and Wants Kids for members looking for a relationship. These match +`CORE_FILTER_KEYS` in `profile-grid.tsx`, which the empty state keeps when it suggests what to relax; +Languages and Orientation are the exceptions and count as refinements in the panel. Everything else is a +nice-to-have and sits under "More filters", in collapsed groups (Background, Lifestyle, Values & +Beliefs, Personality, Advanced). + Two toggles sit above the list, both of which write a whole search rather than one field: - **Who I'm looking for** — copies the "Who I'm looking for" section of your own profile into the diff --git a/web/components/events/user-link.tsx b/web/components/events/user-link.tsx index de437e791..782f35928 100644 --- a/web/components/events/user-link.tsx +++ b/web/components/events/user-link.tsx @@ -1,3 +1,4 @@ +import {isOwnImageUrl} from 'common/util/image-url' import Link from 'next/link' import {useUserInStore} from 'web/hooks/use-user-supabase' @@ -22,8 +23,9 @@ export function UserLink({ href={`/${user.username}`} className={`hover:text-primary-700 flex items-center gap-1 ${className}`} > - {user.avatar_url && ( - {user.name} + {/* A plain loads straight from the viewer's browser, so only a photo we host. */} + {isOwnImageUrl(user.avatar_url) && ( + {user.name} )} {user.name} diff --git a/web/components/filters/filters.tsx b/web/components/filters/filters.tsx index 5be442c8a..91b0a5336 100644 --- a/web/components/filters/filters.tsx +++ b/web/components/filters/filters.tsx @@ -312,32 +312,6 @@ function Filters(props: { - {/* LOCATION - Always visible */} - - } - > - updateFilter({country}), - }} - /> - - {/* AGE RANGE - Always visible */} - {/* ACCORDION GROUPS */} + {/* LOCATION - Always visible */} + + } + > + updateFilter({country}), + }} + /> + - {/* Relationship Group */} + {/* Relationship criteria - shown whenever the member is looking for a relationship. Orientation + and Has kids sit under Background instead: refinements, rarely dealbreakers on their own. */} {includeRelationshipFilters && ( - } - > + <> {/* Relationship Status */} - - } - > - - - {/* Romantic Style */} - {/* Has Kids */} - - } - > - - - {/* Wants Kids */} - + )} + {/* SECONDARY FILTERS - nice to have, so each group stays collapsed until opened. Primary = the + same dealbreakers the empty state keeps when it suggests what to relax (CORE_FILTER_KEYS in + profile-grid.tsx), except languages and orientation, which stay refinements here. */} +

+ {t('filter.group.more', 'More filters')} +

+ + {/* ACCORDION GROUPS */} + {/* Background Group */} + + {/* Orientation - gender already narrows who shows up, so this is a refinement on top. */} + {includeRelationshipFilters && ( + + } + > + + + )} + + {/* Has Kids */} + {includeRelationshipFilters && ( + + } + > + + + )} {/* Lifestyle Group */} diff --git a/web/components/messaging/send-message-button.tsx b/web/components/messaging/send-message-button.tsx index 67abb2e21..58c2c782f 100644 --- a/web/components/messaging/send-message-button.tsx +++ b/web/components/messaging/send-message-button.tsx @@ -11,6 +11,7 @@ import { } from 'common/moderation/first-message' import {Profile} from 'common/profiles/profile' import {User} from 'common/user' +import {isOwnImageUrl} from 'common/util/image-url' import {richTextToString} from 'common/util/parse' import {findKey} from 'lodash' import {useRouter} from 'next/router' @@ -386,9 +387,10 @@ export const SendMessageButton = (props: { the content it was introducing. */} - {profile.pinned_url && ( + {/* A plain loads straight from the viewer's browser, so only a photo we host. */} + {isOwnImageUrl(profile.pinned_url) && ( ({ utility: user ? buildArray(Settings, Help, isAdminId(user.id) && Admin) : [Help], }) +// The rows the native apps would otherwise lose: on the web they live in the footer, which the apps +// don't render, and nothing else links to /organization or /social. +const appOnly = [Faq, Organization, Social] + // The drawer behind the bottom bar's menu button. People, Messages, Notifs and Profile are already -// on the bar itself, so only Invite is left of the daily-use band. +// on the bar itself, so only Invite is left of the daily-use band. Reading `isNativeApp()` here is +// hydration-safe: the drawer only mounts once opened, long after the first client render. const getMobileSidebar = (user: User | null | undefined, _toggleModal: () => void): NavGroups => ({ primary: user ? [Invite] : [], - secondary, + secondary: isNativeApp() ? [...secondary, appOnly] : secondary, utility: user ? buildArray(Settings, Help, isAdminId(user.id) && Admin) : [Help], }) diff --git a/web/components/widgets/avatar.tsx b/web/components/widgets/avatar.tsx index fd76d366e..269a3a880 100644 --- a/web/components/widgets/avatar.tsx +++ b/web/components/widgets/avatar.tsx @@ -1,5 +1,6 @@ import {UserIcon, UsersIcon} from '@heroicons/react/24/solid' import clsx from 'clsx' +import {ownImageUrlOrUndefined} from 'common/util/image-url' import {floor} from 'lodash' import Image from 'next/image' import {useRouter} from 'next/router' @@ -45,10 +46,11 @@ export const Avatar = memo( } } const fallbackInitial = (username || 'U')[0] // first character, not encoded string + // Rows written before photo URLs were validated can still point anywhere; in the app build + // next/image loads them straight from the viewer's device (see `isOwnImageUrl`). const url: string = - avatarUrl && avatarUrl.length > 0 - ? avatarUrl - : `https://ui-avatars.com/api/?name=${encodeURIComponent(fallbackInitial)}` + ownImageUrlOrUndefined(avatarUrl) ?? + `https://ui-avatars.com/api/?name=${encodeURIComponent(fallbackInitial)}` // console.log(username, fallbackInitial, url) // there can be no avatar URL or username in the feed, we show a "submit comment" diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index 64ecf4a3e..d464f56fb 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -491,6 +491,10 @@ export async function revokeAppleToken(): Promise<'revoked' | 'not-applicable' | * stored token, and neither is a reason to leave someone half signed out. */ export async function firebaseLogout() { + // Before signing out: removing the device's push subscription needs the session that owns it. + // Imported lazily because web/lib/api imports this file. + const {removePushSubscription} = await import('web/lib/service/push-logout') + await removePushSubscription() if (isNativeApp()) { await Promise.all([ SocialLogin.logout({provider: 'google'}).catch((e) => debug('Google logout skipped', e)), diff --git a/web/lib/profile/seeking.ts b/web/lib/profile/seeking.ts index 513738c21..b5ce75aec 100644 --- a/web/lib/profile/seeking.ts +++ b/web/lib/profile/seeking.ts @@ -1,5 +1,6 @@ +import {RELATIONSHIP_CHOICES} from 'common/choices' import {Profile} from 'common/profiles/profile' -import {capitalize} from 'lodash' +import {capitalize, sortBy} from 'lodash' import {convertRelationshipType, RelationshipType} from 'web/lib/util/convert-types' import stringOrStringArrayToText from 'web/lib/util/string-or-string-array-to-text' @@ -7,14 +8,15 @@ export function getSeekingConnectionText(profile: Profile, t: any, _short?: bool const relationshipTypes = profile.pref_relation_styles const seekingGenderText = stringOrStringArrayToText({ text: relationshipTypes?.length - ? relationshipTypes - .map((rel) => - t( - `profile.relationship.${rel}`, - convertRelationshipType(rel as RelationshipType), - ).toLowerCase(), - ) - .sort() + ? // Same order as RELATIONSHIP_CHOICES, not alphabetical, so it reads the same in every language. + sortBy(relationshipTypes, (rel) => + Object.values(RELATIONSHIP_CHOICES).indexOf(rel as any), + ).map((rel) => + t( + `profile.relationship.${rel}`, + convertRelationshipType(rel as RelationshipType), + ).toLowerCase(), + ) : [t('profile.connection.default', 'connection')], // preText: !short ? t('profile.seeking', 'Seeking') : undefined, asSentence: true, diff --git a/web/lib/service/native-push.ts b/web/lib/service/native-push.ts index e7061b966..12504a4f3 100644 --- a/web/lib/service/native-push.ts +++ b/web/lib/service/native-push.ts @@ -3,6 +3,8 @@ import {debug} from 'common/logger' import {useEffect} from 'react' import {useUser} from 'web/hooks/use-user' import {api} from 'web/lib/api' +import {NATIVE_PUSH_TOKEN_KEY} from 'web/lib/service/push-logout' +import {safeLocalStorage} from 'web/lib/util/local' import {isNativeApp, nativePlatform} from 'web/lib/util/webview' /** @@ -40,6 +42,8 @@ export default function NativePush() { platform: nativePlatform() === 'ios' ? 'ios' : 'android', }) debug('Mobile subscription saved:', data) + // Kept so sign-out can remove this device's row (see `removePushSubscription`). + safeLocalStorage?.setItem(NATIVE_PUSH_TOKEN_KEY, token.value) } catch (err) { console.error('Failed saving mobile push subscription', err) } diff --git a/web/lib/service/push-logout.ts b/web/lib/service/push-logout.ts new file mode 100644 index 000000000..535991797 --- /dev/null +++ b/web/lib/service/push-logout.ts @@ -0,0 +1,42 @@ +import {debug} from 'common/logger' +import {api} from 'web/lib/api' +import {safeLocalStorage} from 'web/lib/util/local' +import {isNativeApp} from 'web/lib/util/webview' + +/** Where `NativePush` keeps the device's push token, so sign-out can name the row to remove. */ +export const NATIVE_PUSH_TOKEN_KEY = 'native-push-token' + +/** + * Detaches this device from the member who is signing out. Must run before `auth.signOut()`: the + * server only removes the caller's own subscription, so it needs the session that owns it. + * + * The server no longer reassigns a token that already belongs to someone else (see + * `save-subscription*`), so without this a shared device would keep delivering the previous + * member's notifications, and the next member to sign in on it would get none. + * + * On the web the browser subscription itself is dropped too: `WebPush` does not save a subscription + * that already exists, so the next member would otherwise never register one. Best-effort + * throughout — nothing here is a reason to leave someone half signed out. + */ +export async function removePushSubscription() { + try { + if (isNativeApp()) { + const token = safeLocalStorage?.getItem(NATIVE_PUSH_TOKEN_KEY) + if (!token) return + await api('delete-subscription', {token}) + safeLocalStorage?.removeItem(NATIVE_PUSH_TOKEN_KEY) + return + } + + if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return + const registration = await navigator.serviceWorker.getRegistration() + const subscription = await registration?.pushManager.getSubscription() + if (!subscription) return + await api('delete-subscription', {endpoint: subscription.endpoint}).catch((e) => + debug('Failed to remove push subscription on the server', e), + ) + await subscription.unsubscribe() + } catch (e) { + debug('Push subscription cleanup skipped', e) + } +} diff --git a/web/lib/supabase/stars.ts b/web/lib/supabase/stars.ts index c355366f2..dc1cac80d 100644 --- a/web/lib/supabase/stars.ts +++ b/web/lib/supabase/stars.ts @@ -1,16 +1,16 @@ import {DisplayUser} from 'common/api/user-types' import {convertPartialUser} from 'common/supabase/users' import {run} from 'common/supabase/utils' +import {api} from 'web/lib/api' import {db} from 'web/lib/supabase/db' -export const getStars = async (creatorId: string) => { - // Direct SELECT on profile_stars is revoked from the anon/authenticated roles; read this creator's - // stars through the capped get_profile_stars() SECURITY DEFINER function (ordered newest-first). - const {data} = await run(db.rpc('get_profile_stars' as any, {creator: creatorId})) +// `_userId` is only there to key the cache on the signed-in member, so switching accounts refetches. +// The stars are always the caller's own: who someone has saved is private, so the API takes no member +// id (the get_profile_stars() function this replaced took any member's id on the anon key). +export const getStars = async (_userId: string) => { + const {targetIds: ids} = await api('get-my-stars', {}) + if (!ids.length) return [] - if (!data) return [] - - const ids = (data as any[]).map((d) => d.target_id as string) // Direct SELECT on `users` is revoked from the anon/authenticated roles (bulk-read cap); go through // the capped get_display_users() function. const {data: users} = await run(db.rpc('get_display_users' as any, {ids})) diff --git a/web/lib/supabase/votes.ts b/web/lib/supabase/votes.ts index c49968553..3f3084881 100644 --- a/web/lib/supabase/votes.ts +++ b/web/lib/supabase/votes.ts @@ -1,4 +1,5 @@ import {OrderBy} from 'common/votes/constants' +import {api} from 'web/lib/api' import {db} from 'web/lib/supabase/db' export const getVotes = async (params: {orderBy: OrderBy}) => { @@ -25,62 +26,41 @@ export const getVote = async (params: {voteId: number}) => { } // What the `` of a proposal page needs, read on the server before the page is sent — which -// means it is read as the anon role, since nobody is signed in at that point. -// -// `get_votes_with_results` can't be used here even though it returns a superset: it joins -// `vote_comments` to rank the highlighted arguments, and anon has no grant on that table, so the -// whole call errors rather than coming back without them. The proposal row and the ballots are both -// anon-readable, and the tallies are the same arithmetic the function does. +// means it is read as the anon role, since nobody is signed in at that point. Same function as the +// page itself; it is security definer, so the anon role can call it without reading the tables. export const getVoteMeta = async (params: {voteId: number}) => { - const {voteId} = params - const [vote, results] = await Promise.all([ - db.from('votes').select('id, title, description, status').eq('id', voteId).limit(1), - db.from('vote_results').select('choice').eq('vote_id', voteId), - ]) - if (vote.error) throw vote.error - if (results.error) throw results.error - - const row = vote.data?.[0] - if (!row) return null + const vote = await getVote(params) + if (!vote) return null - const choices = (results.data ?? []).map((r) => r.choice) - return { - ...row, - votes_for: choices.filter((c) => c === 1).length, - votes_against: choices.filter((c) => c === -1).length, - votes_abstain: choices.filter((c) => c === 0).length, + const {id, title, description, status, votes_for, votes_against, votes_abstain} = vote as any + return {id, title, description, status, votes_for, votes_against, votes_abstain} as { + id: number + title: string + description: any + status: string | null + votes_for: number + votes_against: number + votes_abstain: number } } export type VoteMeta = NonNullable>> // The viewer's own ballot on every proposal, so each card can highlight the button they picked. One -// query for the whole list rather than a per-card lookup — it's a single index scan on `user_id`, and -// the alternative is teaching `get_votes_with_results` about the caller, which it has no way to know -// (the list is fetched with the anon key, not as the signed-in Postgres role). -export const getMyVoteChoices = async (params: {userId: string}) => { - const {userId} = params - const {data, error} = await db - .from('vote_results') - .select('vote_id, choice') - .eq('user_id', userId) - if (error) throw error - - return Object.fromEntries((data ?? []).map((r) => [r.vote_id, r.choice])) as Record< - number, - number - > +// call for the whole list rather than a per-card lookup. Ballots are private, so this goes through +// the API as the caller; `userId` only keys the cache, so signing in as someone else refetches. +export const getMyVoteChoices = async (_params: {userId: string}) => { + const {choices} = await api('get-my-vote-choices', {}) + return choices } -// The author's own recorded vote, shown next to their comments so a reader can see whether an -// argument comes from someone who ended up voting for or against. +// The recorded vote of each member who commented on the proposal, shown next to their comments so a +// reader can see whether an argument comes from someone who ended up voting for or against. Only +// commenters: they argued in public under their name. Everyone else's ballot stays private. export const getVoteResultsByUser = async (params: {voteId: number}) => { const {voteId} = params - const {data, error} = await db - .from('vote_results') - .select('user_id, choice') - .eq('vote_id', voteId) + const {data, error} = await db.rpc('get_vote_commenter_choices' as any, {vid: voteId}) if (error) throw error - return Object.fromEntries((data ?? []).map((r) => [r.user_id, r.choice])) + return Object.fromEntries(((data ?? []) as any[]).map((r) => [r.user_id, r.choice])) } diff --git a/web/pages/stats.tsx b/web/pages/stats.tsx index 334ee60df..aa9f9e6be 100644 --- a/web/pages/stats.tsx +++ b/web/pages/stats.tsx @@ -203,10 +203,14 @@ function ActivitySection({ data, discussions, messages, + proposals, + ballots, }: { data: Record discussions: number | null | undefined messages: number | null | undefined + proposals: number | null | undefined + ballots: number | null | undefined }) { const t = useT() const panels = [ @@ -233,8 +237,8 @@ function ActivitySection({ icon: BuildingLibraryIcon, title: t('stats.group.democracy', 'Democracy'), rows: [ - {label: t('stats.proposals', 'Proposals'), value: data.votes}, - {label: t('stats.votes', 'Votes cast'), value: data.vote_results}, + {label: t('stats.proposals', 'Proposals'), value: proposals}, + {label: t('stats.votes', 'Votes cast'), value: ballots}, ], }, ].filter((p) => p.rows.some((r) => !!r.value)) @@ -408,12 +412,9 @@ export default function Stats() { // (`conversations` = channels with at least one message), so the stats page and the // home/about stat bands report the same number. Counting the channels table instead also // counted channels created but never written in, which read higher than the about page. - const tables = [ - 'compatibility_prompts', - 'compatibility_answers', - 'votes', - 'vote_results', - ] as const + // No 'votes' or 'vote_results' either: neither is readable by the client roles (ballots and + // anonymous authors are private), so both counts come from /stats. + const tables = ['compatibility_prompts', 'compatibility_answers'] as const const [settled, statsResult] = await Promise.allSettled([ Promise.allSettled(tables.map((tbl) => getCount(tbl))), @@ -503,6 +504,8 @@ export default function Stats() { data={data} discussions={statsData?.conversations} messages={statsData?.messages} + proposals={statsData?.proposals} + ballots={statsData?.ballots} /> )} diff --git a/web/public/favicon.svg b/web/public/favicon.svg index 7e3786046..83c2d3a05 100644 --- a/web/public/favicon.svg +++ b/web/public/favicon.svg @@ -1,86 +1,20 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + +