From ecfe089051490445347987ee5cdc36aaf4adbe69 Mon Sep 17 00:00:00 2001 From: Dominik Date: Mon, 17 Aug 2026 01:30:41 +0200 Subject: [PATCH] fix(flare): enforce real profile photos --- ...flareProfilePhotos.server.contract.test.ts | 84 +++++++++ frontend/src/stores/flare.test.ts | 28 +++ frontend/src/stores/flare.ts | 16 ++ frontend/src/stores/phone.ts | 7 +- frontend/src/utils/media.test.ts | 35 +++- frontend/src/utils/media.ts | 4 +- .../src/views/apps/FlareApp.contract.test.ts | 112 +++++++++-- frontend/src/views/apps/FlareApp.vue | 175 +++++++++++------- .../views/apps/GalleryApp.contract.test.ts | 24 +++ frontend/src/views/apps/GalleryApp.vue | 26 ++- frontend/testserver/index.cjs | 114 ++++++++---- frontend/testserver/smoke.cjs | 109 ++++++++++- sky_phone/config/locales/en.lua | 6 +- sky_phone/source/server/flare.lua | 63 ++++--- sky_phone/source/server/media.lua | 27 ++- 15 files changed, 680 insertions(+), 150 deletions(-) create mode 100644 frontend/src/flareProfilePhotos.server.contract.test.ts diff --git a/frontend/src/flareProfilePhotos.server.contract.test.ts b/frontend/src/flareProfilePhotos.server.contract.test.ts new file mode 100644 index 0000000..a4b4376 --- /dev/null +++ b/frontend/src/flareProfilePhotos.server.contract.test.ts @@ -0,0 +1,84 @@ +import { readFileSync } from 'node:fs' + +import { describe, expect, it } from 'vitest' + +const flareServer = readFileSync( + new URL('../../sky_phone/source/server/flare.lua', import.meta.url), + 'utf8', +) +const mediaServer = readFileSync( + new URL('../../sky_phone/source/server/media.lua', import.meta.url), + 'utf8', +) + +function sourceBlock(source: string, startMarker: string, endMarker: string) { + const start = source.indexOf(startMarker) + const end = source.indexOf(endMarker, start) + + expect(start).toBeGreaterThanOrEqual(0) + expect(end).toBeGreaterThan(start) + return source.slice(start, end) +} + +describe('Flare server profile photo invariants', () => { + it('requires one to six unique owned photos for every profile save', () => { + const validation = sourceBlock( + flareServer, + 'local function validate_profile(source, data)', + 'local function load_match', + ) + + expect(validation).toContain('type(data.photoMediaIds) ~= "table"') + expect(validation).toContain('or #data.photoMediaIds < 1') + expect(validation).toContain('or #data.photoMediaIds > 6') + expect(validation).toContain('or seen_media[media_id]') + expect(validation).toContain( + 'SkyPhoneMedia.ResolveOwnedMedia(source, media_id, "photo")', + ) + expect(validation).toContain('replace_photos = true') + }) + + it('filters profiles without a valid owned HTTPS photo before limiting suggestions', () => { + const suggestions = sourceBlock( + flareServer, + 'local function list_suggestions(account_id, profile)', + 'local function list_likes', + ) + const exists = suggestions.indexOf('AND EXISTS (') + const order = suggestions.indexOf('ORDER BY target.`updated_at`') + const limit = suggestions.indexOf('LIMIT 30') + + expect(suggestions).toContain('type(profile.photo_urls) ~= "table"') + expect(suggestions).toContain('or #profile.photo_urls < 1') + expect(exists).toBeGreaterThanOrEqual(0) + expect(exists).toBeLessThan(order) + expect(order).toBeLessThan(limit) + expect(suggestions).toContain( + 'target_media.`account_id` = target.`account_id`', + ) + expect(suggestions).toContain("target_media.`media_type` = 'photo'") + expect(suggestions).toContain("target_media.`url` LIKE 'https://%'") + }) + + it('protects the last valid Flare photo for single and sequential bulk deletes', () => { + const deletion = sourceBlock( + mediaServer, + 'local function is_required_flare_profile_photo(media_id)', + 'function SkyPhoneMedia.GetDeviceRemoteIds', + ) + + expect(deletion).toContain('other_photo.`media_id` <> photo.`media_id`') + expect(deletion).toContain( + 'other_media.`account_id` = profile.`account_id`', + ) + expect(deletion).toContain("other_media.`media_type` = 'photo'") + expect(deletion).toContain("other_media.`url` LIKE 'https://%'") + expect(deletion).toContain( + 'row.media_type == "photo" and is_required_flare_profile_photo(media_id)', + ) + expect(deletion).toContain('return false, "profile_photo_required"') + expect(deletion).toMatch( + /for _, media_id in ipairs\(media_ids\) do[\s\S]*?delete_owned_media\(src, owner, media_id\)/, + ) + }) +}) diff --git a/frontend/src/stores/flare.test.ts b/frontend/src/stores/flare.test.ts index 7fe4789..39ef392 100644 --- a/frontend/src/stores/flare.test.ts +++ b/frontend/src/stores/flare.test.ts @@ -121,6 +121,34 @@ describe('flare store', () => { ]) }) + it.each([ + { label: 'no photos', photoMediaIds: [] }, + { label: 'more than six photos', photoMediaIds: [1, 2, 3, 4, 5, 6, 7] }, + { label: 'duplicate photos', photoMediaIds: [42, 42] }, + ])( + 'rejects a profile with $label before calling NUI', + async ({ photoMediaIds }) => { + const draft: FlareProfileDraft = { + age: bootstrap.profile!.age, + avatar: bootstrap.profile!.avatar, + bio: bootstrap.profile!.bio, + gender: bootstrap.profile!.gender, + interestedIn: bootstrap.profile!.interestedIn, + interests: [...bootstrap.profile!.interests], + lookingFor: bootstrap.profile!.lookingFor, + maxAge: bootstrap.profile!.maxAge, + minAge: bootstrap.profile!.minAge, + name: bootstrap.profile!.name, + photoMediaIds, + } + const flare = useFlareStore() + + expect(await flare.saveProfile(draft)).toBe(false) + expect(flare.error).toBe('invalid_profile_photos') + expect(mockNuiCall).not.toHaveBeenCalled() + }, + ) + it('uses a real Super Like and removes the target from both decks', async () => { const match: FlareMatch = { id: 'match-1', diff --git a/frontend/src/stores/flare.ts b/frontend/src/stores/flare.ts index fb215d2..a22b641 100644 --- a/frontend/src/stores/flare.ts +++ b/frontend/src/stores/flare.ts @@ -10,6 +10,18 @@ import type { } from '@/types/flare' import { nuiCall } from '@/utils/nui' +function hasValidProfilePhotos( + photoMediaIds: unknown, +): photoMediaIds is number[] { + return ( + Array.isArray(photoMediaIds) && + photoMediaIds.length >= 1 && + photoMediaIds.length <= 6 && + new Set(photoMediaIds).size === photoMediaIds.length && + photoMediaIds.every((mediaId) => Number.isInteger(mediaId) && mediaId > 0) + ) +} + export const useFlareStore = defineStore('flare', { state: () => ({ activeMatchId: '' as string, @@ -54,6 +66,10 @@ export const useFlareStore = defineStore('flare', { return response.success }, async saveProfile(draft: FlareProfileDraft): Promise { + if (!hasValidProfilePhotos(draft.photoMediaIds)) { + this.error = 'invalid_profile_photos' + return false + } const response = await nuiCall( 'flare:save-profile', draft, diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index e88bb9b..4dbd3e4 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -867,7 +867,8 @@ const defaultLocales: LocaleTree = { photo: 'Profile photo', profilePhotos: 'Profile photos', profilePhotosBody: - 'Add up to six photos from Photos or Camera. Your first photo is shown first.', + 'Add one to six photos from Photos or Camera. Your first photo is shown first.', + profilePhotoRequired: 'Add at least one profile photo to continue.', addPhotos: 'Add photos', choosePhotos: 'Choose from Photos', primaryPhoto: 'Main', @@ -1006,7 +1007,7 @@ const defaultLocales: LocaleTree = { invalid_profile: 'Check your name, age and profile text.', profile_not_found: 'This Flare account is no longer available.', invalid_profile_photos: - 'Choose or take up to six photos saved in your own Photos library.', + 'Choose or take at least one and up to six photos saved in your own Photos library.', request_failed: 'Flare could not save those changes. Try again.', invalid_target: 'This profile is no longer available.', invalid_choice: 'That swipe could not be saved.', @@ -4060,6 +4061,8 @@ const defaultLocales: LocaleTree = { import_url_unavailable: 'The linked media could not be reached.', import_size_unavailable: 'The website did not provide the media size.', not_found: 'The media item no longer exists.', + profile_photo_required: + 'This is the last photo on your Flare profile. Add another profile photo before deleting it.', operation_in_progress: 'Another media operation is already in progress.', owner_changed: 'The active phone account changed.', diff --git a/frontend/src/utils/media.test.ts b/frontend/src/utils/media.test.ts index 4fa6c6d..9dfd136 100644 --- a/frontend/src/utils/media.test.ts +++ b/frontend/src/utils/media.test.ts @@ -13,8 +13,20 @@ import { } from './media' const media = [ - { createdAt: 10, favorite: false, id: 1, mediaType: 'photo' as const, url: 'photo' }, - { createdAt: 20, favorite: false, id: 2, mediaType: 'video' as const, url: 'video' }, + { + createdAt: 10, + favorite: false, + id: 1, + mediaType: 'photo' as const, + url: 'photo', + }, + { + createdAt: 20, + favorite: false, + id: 2, + mediaType: 'video' as const, + url: 'video', + }, ] describe('media utilities', () => { @@ -27,8 +39,20 @@ describe('media utilities', () => { it('merges pages without duplicates and keeps newest first', () => { expect( mergeMedia(media, [ - { createdAt: 30, favorite: true, id: 1, mediaType: 'photo', url: 'updated' }, - { createdAt: 25, favorite: false, id: 3, mediaType: 'photo', url: 'new' }, + { + createdAt: 30, + favorite: true, + id: 1, + mediaType: 'photo', + url: 'updated', + }, + { + createdAt: 25, + favorite: false, + id: 3, + mediaType: 'photo', + url: 'new', + }, ]).map((entry) => [entry.id, entry.url]), ).toEqual([ [1, 'updated'], @@ -131,6 +155,9 @@ describe('media utilities', () => { expect(mediaErrorKey('import_url_not_allowed')).toBe( 'import_url_not_allowed', ) + expect(mediaErrorKey('profile_photo_required')).toBe( + 'profile_photo_required', + ) expect(mediaErrorKey('private_provider_error')).toBe('request_failed') }) }) diff --git a/frontend/src/utils/media.ts b/frontend/src/utils/media.ts index afb0618..2ec67c6 100644 --- a/frontend/src/utils/media.ts +++ b/frontend/src/utils/media.ts @@ -44,8 +44,7 @@ export function orderMedia( return sortOrder === 'oldest' ? orderMediaOldestFirst(media) : [...media].sort( - (left, right) => - right.createdAt - left.createdAt || right.id - left.id, + (left, right) => right.createdAt - left.createdAt || right.id - left.id, ) } @@ -110,6 +109,7 @@ export function mediaErrorKey(error?: string): string { 'not_found', 'operation_in_progress', 'owner_changed', + 'profile_photo_required', 'rate_limited', 'request_failed', 'request_timeout', diff --git a/frontend/src/views/apps/FlareApp.contract.test.ts b/frontend/src/views/apps/FlareApp.contract.test.ts index e552677..457a26b 100644 --- a/frontend/src/views/apps/FlareApp.contract.test.ts +++ b/frontend/src/views/apps/FlareApp.contract.test.ts @@ -17,7 +17,7 @@ const localeSource = readFileSync( ) describe('FlareApp profile editing contract', () => { - it('opens the central photo source picker while creating an account', () => { + it('shows direct Gallery and Camera actions while creating an account', () => { const onboardingStart = source.indexOf( '