fix(server): force full sync for non-admins when public users is disabled

Without this, devices that had already synced the full user list keep
those cached records until a manual logout, since the sync protocol
only upserts users and never prunes ones that quietly become invisible.
This commit is contained in:
br4yd 2026-08-05 00:56:44 +02:00
parent 5cba070a2b
commit cbf8e6da91
No known key found for this signature in database
4 changed files with 89 additions and 3 deletions

View file

@ -88,6 +88,20 @@ set
where
"userId" = $2
-- SessionRepository.requireFullSyncForNonAdmins
update "session"
set
"isPendingSyncReset" = $1
where
"userId" in (
select
"user"."id"
from
"user"
where
"user"."isAdmin" = $2
)
-- SessionRepository.resetSyncProgress
begin
update "session"

View file

@ -137,6 +137,15 @@ export class SessionRepository {
await this.db.updateTable('session').set({ pinExpiresAt: null }).where('userId', '=', userId).execute();
}
@GenerateSql({ params: [] })
async requireFullSyncForNonAdmins() {
await this.db
.updateTable('session')
.set({ isPendingSyncReset: true })
.where((eb) => eb('userId', 'in', eb.selectFrom('user').select('user.id').where('user.isAdmin', '=', false)))
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
async resetSyncProgress(sessionId: string) {
await this.db.transaction().execute((tx) => {

View file

@ -2,7 +2,7 @@ import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/com
import { Insertable } from 'kysely';
import { DateTime, Duration } from 'luxon';
import { Writable } from 'node:stream';
import { OnJob } from 'src/decorators';
import { OnEvent, OnJob } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import {
SyncAckDeleteDto,
@ -12,7 +12,8 @@ import {
SyncItem,
SyncStreamDto,
} from 'src/dtos/sync.dto';
import { JobName, QueueName, SyncEntityType, SyncRequestType } from 'src/enum';
import { ImmichWorker, JobName, QueueName, SyncEntityType, SyncRequestType } from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
import { SyncQueryOptions } from 'src/repositories/sync.repository';
import { SessionSyncCheckpointTable } from 'src/schema/tables/sync-checkpoint.table';
import { BaseService } from 'src/services/base.service';
@ -86,6 +87,13 @@ const throwSessionRequired = () => {
@Injectable()
export class SyncService extends BaseService {
@OnEvent({ name: 'ConfigUpdate', workers: [ImmichWorker.Microservices] })
async onConfigUpdate({ newConfig, oldConfig }: ArgOf<'ConfigUpdate'>) {
if (oldConfig.server.publicUsers && !newConfig.server.publicUsers) {
await this.sessionRepository.requireFullSyncForNonAdmins();
}
}
getAcks(auth: AuthDto) {
const sessionId = auth.session?.id;
if (!sessionId) {

View file

@ -1,9 +1,11 @@
import { schemaFromCode } from '@immich/sql-tools';
import { Kysely } from 'kysely';
import { DateTime } from 'luxon';
import { SystemConfig } from 'src/config';
import { AssetMetadataKey, UserMetadataKey } from 'src/enum';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { SessionRepository } from 'src/repositories/session.repository';
import { BaseSync, SyncRepository } from 'src/repositories/sync.repository';
import { DB } from 'src/schema';
import { SyncService } from 'src/services/sync.service';
@ -16,7 +18,7 @@ let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
return newMediumService(SyncService, {
database: db || defaultDatabase,
real: [DatabaseRepository, SyncRepository],
real: [DatabaseRepository, SyncRepository, SessionRepository],
mock: [LoggingRepository],
});
};
@ -33,6 +35,17 @@ const assertTableCount = async <T extends keyof DB>(db: Kysely<DB>, t: T, count:
expect(results).toHaveLength(count);
};
const withPublicUsers = (publicUsers: boolean) => ({ server: { publicUsers } }) as SystemConfig;
const isPendingSyncReset = async (db: Kysely<DB>, sessionId: string) => {
const session = await db
.selectFrom('session')
.select('isPendingSyncReset')
.where('id', '=', sessionId)
.executeTakeFirstOrThrow();
return session.isPendingSyncReset;
};
describe(SyncService.name, () => {
describe('onAuditTableCleanup', () => {
it('should work', async () => {
@ -240,4 +253,46 @@ describe(SyncService.name, () => {
}
});
});
describe('onConfigUpdate', () => {
it('should require a full sync for non-admins when public users is disabled', async () => {
const { sut, ctx } = setup(await getKyselyDB());
const { user } = await ctx.newUser();
const { session } = await ctx.newSession({ userId: user.id });
await sut.onConfigUpdate({ oldConfig: withPublicUsers(true), newConfig: withPublicUsers(false) });
await expect(isPendingSyncReset(ctx.database, session.id)).resolves.toBe(true);
});
it('should not require a full sync for admins when public users is disabled', async () => {
const { sut, ctx } = setup(await getKyselyDB());
const { user } = await ctx.newUser({ isAdmin: true });
const { session } = await ctx.newSession({ userId: user.id });
await sut.onConfigUpdate({ oldConfig: withPublicUsers(true), newConfig: withPublicUsers(false) });
await expect(isPendingSyncReset(ctx.database, session.id)).resolves.toBe(false);
});
it('should not require a full sync when public users is enabled', async () => {
const { sut, ctx } = setup(await getKyselyDB());
const { user } = await ctx.newUser();
const { session } = await ctx.newSession({ userId: user.id });
await sut.onConfigUpdate({ oldConfig: withPublicUsers(false), newConfig: withPublicUsers(true) });
await expect(isPendingSyncReset(ctx.database, session.id)).resolves.toBe(false);
});
it('should not require a full sync when public users was already disabled', async () => {
const { sut, ctx } = setup(await getKyselyDB());
const { user } = await ctx.newUser();
const { session } = await ctx.newSession({ userId: user.id });
await sut.onConfigUpdate({ oldConfig: withPublicUsers(false), newConfig: withPublicUsers(false) });
await expect(isPendingSyncReset(ctx.database, session.id)).resolves.toBe(false);
});
});
});