mirror of
https://github.com/immich-app/immich
synced 2026-08-29 13:15:45 +00:00
fix(server): respect public users setting in mobile sync v2
Sync v2's UserV1 feed sent every user unconditionally, bypassing the publicUsers/admin check that the web REST endpoint already applies. Non-admins now only sync themselves, partners, and album co-members when public users are disabled. Fixes #24528 Fixes #30250
This commit is contained in:
parent
1c7c28bb0d
commit
5cba070a2b
5 changed files with 170 additions and 4 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -332,7 +332,7 @@ export class SyncTestContext extends MediumTestContext<SyncService> {
|
|||
constructor(database: Kysely<DB>) {
|
||||
super(SyncService, {
|
||||
database,
|
||||
real: [SyncRepository, SyncCheckpointRepository, SessionRepository],
|
||||
real: [SyncRepository, SyncCheckpointRepository, SessionRepository, ConfigRepository, SystemMetadataRepository],
|
||||
mock: [LoggingRepository],
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<DB>;
|
||||
|
|
@ -13,6 +14,15 @@ const setup = async (db?: Kysely<DB>) => {
|
|||
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]));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue