diff --git a/server/src/queries/sync.repository.sql b/server/src/queries/sync.repository.sql index 69f7981bf3..58e7046bbb 100644 --- a/server/src/queries/sync.repository.sql +++ b/server/src/queries/sync.repository.sql @@ -1121,6 +1121,58 @@ where order by "user"."updateId" asc +-- SyncRepository.user.getRelatedUpserts +select + "id", + "name", + "email", + "avatarColor", + "deletedAt", + "updateId", + "profileImagePath", + "profileChangedAt" +from + "user" as "user" +where + "user"."updateId" < $1 + and "user"."updateId" > $2 + and ( + "id" = $3 + or "id" in ( + select + "partner"."sharedWithId" as "id" + from + "partner" + where + "partner"."sharedById" = $4 + ) + or "id" in ( + select + "partner"."sharedById" as "id" + from + "partner" + where + "partner"."sharedWithId" = $5 + ) + or "id" in ( + select + "album_user"."userId" as "id" + from + "album_user" + where + "album_user"."albumId" in ( + select + "album_user"."albumId" as "id" + from + "album_user" + where + "album_user"."userId" = $6 + ) + ) + ) +order by + "user"."updateId" asc + -- SyncRepository.userMetadata.getDeletes select "id", diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index eca8d18e66..99ed57c750 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -751,6 +751,42 @@ class UserSync extends BaseSync { getUpserts(options: SyncQueryOptions) { return this.upsertQuery('user', options).select(columns.syncUser).stream(); } + + // used when public users are disabled: only self, partners, and album co-members are visible + @GenerateSql({ params: [dummyQueryOptions], stream: true }) + getRelatedUpserts(options: SyncQueryOptions) { + const userId = options.userId; + return this.upsertQuery('user', options) + .select(columns.syncUser) + .where((eb) => + eb.or([ + eb('id', '=', userId), + eb( + 'id', + 'in', + eb.selectFrom('partner').select('partner.sharedWithId as id').where('partner.sharedById', '=', userId), + ), + eb( + 'id', + 'in', + eb.selectFrom('partner').select('partner.sharedById as id').where('partner.sharedWithId', '=', userId), + ), + eb( + 'id', + 'in', + eb + .selectFrom('album_user') + .select('album_user.userId as id') + .where( + 'album_user.albumId', + 'in', + eb.selectFrom('album_user').select('album_user.albumId as id').where('album_user.userId', '=', userId), + ), + ), + ]), + ) + .stream(); + } } class UserMetadataSync extends BaseSync { diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index e3842b1503..9db65ea3f2 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -166,7 +166,7 @@ export class SyncService extends BaseService { [SyncRequestType.AlbumAssetsV1]: () => this.syncAlbumAssetsV1(), [SyncRequestType.AuthUsersV1]: () => this.syncAuthUsersV1(options, response, checkpointMap), - [SyncRequestType.UsersV1]: () => this.syncUsersV1(options, response, checkpointMap), + [SyncRequestType.UsersV1]: () => this.syncUsersV1(options, response, checkpointMap, auth), [SyncRequestType.PartnersV1]: () => this.syncPartnersV1(options, response, checkpointMap), [SyncRequestType.AssetsV2]: () => this.syncAssetsV2(options, response, checkpointMap), [SyncRequestType.AssetExifsV1]: () => this.syncAssetExifsV1(options, response, checkpointMap), @@ -246,7 +246,7 @@ export class SyncService extends BaseService { } } - private async syncUsersV1(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) { + private async syncUsersV1(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) { const deleteType = SyncEntityType.UserDeleteV1; const deletes = this.syncRepository.user.getDeletes({ ...options, ack: checkpointMap[deleteType] }); for await (const { id, ...data } of deletes) { @@ -254,7 +254,11 @@ export class SyncService extends BaseService { } const upsertType = SyncEntityType.UserV1; - const upserts = this.syncRepository.user.getUpserts({ ...options, ack: checkpointMap[upsertType] }); + const { server } = await this.getConfig({ withCache: false }); + const canViewAllUsers = auth.user.isAdmin || server.publicUsers; + const upserts = canViewAllUsers + ? this.syncRepository.user.getUpserts({ ...options, ack: checkpointMap[upsertType] }) + : this.syncRepository.user.getRelatedUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, profileImagePath, ...data } of upserts) { send(response, { type: upsertType, ids: [updateId], data: { ...data, hasProfileImage: !!profileImagePath } }); } diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index b335a0fba5..e0efd81d70 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -332,7 +332,7 @@ export class SyncTestContext extends MediumTestContext { constructor(database: Kysely) { super(SyncService, { database, - real: [SyncRepository, SyncCheckpointRepository, SessionRepository], + real: [SyncRepository, SyncCheckpointRepository, SessionRepository, ConfigRepository, SystemMetadataRepository], mock: [LoggingRepository], }); } diff --git a/server/test/medium/specs/sync/sync-user.spec.ts b/server/test/medium/specs/sync/sync-user.spec.ts index 7a69e7a411..52a36057b5 100644 --- a/server/test/medium/specs/sync/sync-user.spec.ts +++ b/server/test/medium/specs/sync/sync-user.spec.ts @@ -3,6 +3,7 @@ import { SyncEntityType, SyncRequestType } from 'src/enum'; import { UserRepository } from 'src/repositories/user.repository'; import { DB } from 'src/schema'; import { SyncTestContext } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; import { getKyselyDB } from 'test/utils'; let defaultDatabase: Kysely; @@ -13,6 +14,15 @@ const setup = async (db?: Kysely) => { return { auth, user, session, ctx }; }; +const disablePublicUsers = async (ctx: SyncTestContext) => { + const config = await ctx.sut.getConfig({ withCache: false }); + config.server.publicUsers = false; + await ctx.sut.updateConfig(config); +}; + +const getSyncedUserIds = (response: Array<{ type: string; data: any }>) => + response.filter((item) => item.type === SyncEntityType.UserV1).map((item) => item.data.id); + beforeAll(async () => { defaultDatabase = await getKyselyDB(); }); @@ -134,4 +144,68 @@ describe(SyncEntityType.UserV1, () => { expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), ]); }); + + describe('public users disabled', () => { + it('should only sync the authenticated user when there are no relationships', async () => { + const { auth, ctx } = await setup(await getKyselyDB()); + await disablePublicUsers(ctx); + + const { user: unrelated } = await ctx.newUser(); + + const response = await ctx.syncStream(auth, [SyncRequestType.UsersV1]); + const ids = getSyncedUserIds(response); + + expect(ids).toEqual([auth.user.id]); + expect(ids).not.toContain(unrelated.id); + }); + + it('should sync partners', async () => { + const { auth, ctx } = await setup(await getKyselyDB()); + await disablePublicUsers(ctx); + + const { user: partner } = await ctx.newUser(); + await ctx.newPartner({ sharedById: auth.user.id, sharedWithId: partner.id }); + const { user: unrelated } = await ctx.newUser(); + + const response = await ctx.syncStream(auth, [SyncRequestType.UsersV1]); + const ids = getSyncedUserIds(response); + + expect(ids).toEqual(expect.arrayContaining([auth.user.id, partner.id])); + expect(ids).not.toContain(unrelated.id); + }); + + it('should sync album co-members', async () => { + const { auth, ctx } = await setup(await getKyselyDB()); + await disablePublicUsers(ctx); + + const { user: coMember } = await ctx.newUser(); + const { album } = await ctx.newAlbum({ ownerId: auth.user.id }); + await ctx.newAlbumUser({ albumId: album.id, userId: coMember.id }); + const { user: unrelated } = await ctx.newUser(); + + const response = await ctx.syncStream(auth, [SyncRequestType.UsersV1]); + const ids = getSyncedUserIds(response); + + expect(ids).toEqual(expect.arrayContaining([auth.user.id, coMember.id])); + expect(ids).not.toContain(unrelated.id); + }); + + it('should still sync all users for an admin', async () => { + const { ctx } = await setup(await getKyselyDB()); + await disablePublicUsers(ctx); + + const { user: admin } = await ctx.newUser({ isAdmin: true }); + const { session: adminSession } = await ctx.newSession({ userId: admin.id }); + const adminAuth = factory.auth({ + session: adminSession, + user: { id: admin.id, name: admin.name, email: admin.email, isAdmin: true }, + }); + const { user: unrelated } = await ctx.newUser(); + + const response = await ctx.syncStream(adminAuth, [SyncRequestType.UsersV1]); + const ids = getSyncedUserIds(response); + + expect(ids).toEqual(expect.arrayContaining([admin.id, unrelated.id])); + }); + }); });