mirror of
https://github.com/immich-app/immich
synced 2026-08-29 13:15:45 +00:00
Merge remote-tracking branch 'origin/main' into feat/yucca-integration
This commit is contained in:
commit
54e91036b4
20 changed files with 254 additions and 99 deletions
|
|
@ -8,13 +8,13 @@ import {
|
|||
} from 'src/commands/media-location.command';
|
||||
import { DisableOAuthLogin, EnableOAuthLogin } from 'src/commands/oauth-login';
|
||||
import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from 'src/commands/password-login';
|
||||
import { PromptPasswordQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command';
|
||||
import { PromptPasswordResetQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command';
|
||||
import { SchemaCheck } from 'src/commands/schema-check';
|
||||
import { VersionCommand } from 'src/commands/version.command';
|
||||
|
||||
export const commandsAndQuestions = [
|
||||
ResetAdminPasswordCommand,
|
||||
PromptPasswordQuestions,
|
||||
PromptPasswordResetQuestions,
|
||||
PromptEmailQuestion,
|
||||
EnablePasswordLoginCommand,
|
||||
DisablePasswordLoginCommand,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { UserAdminResponseDto } from 'src/dtos/user.dto';
|
|||
import { CliService } from 'src/services/cli.service';
|
||||
|
||||
const prompt = (inquirer: InquirerService) => {
|
||||
return function ask(admin: UserAdminResponseDto) {
|
||||
return (admin: UserAdminResponseDto) => {
|
||||
const { id, oauthId, email, name } = admin;
|
||||
console.log(`Found Admin:
|
||||
- ID=${id}
|
||||
|
|
@ -11,7 +11,7 @@ const prompt = (inquirer: InquirerService) => {
|
|||
- Email=${email}
|
||||
- Name=${name}`);
|
||||
|
||||
return inquirer.ask<{ password: string }>('prompt-password', {}).then(({ password }) => password);
|
||||
return inquirer.ask<{ newPassword: string; invalidateSessions: boolean }>('prompt-password-reset', {});
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -43,13 +43,23 @@ export class ResetAdminPasswordCommand extends CommandRunner {
|
|||
}
|
||||
}
|
||||
|
||||
@QuestionSet({ name: 'prompt-password' })
|
||||
export class PromptPasswordQuestions {
|
||||
@QuestionSet({ name: 'prompt-password-reset' })
|
||||
export class PromptPasswordResetQuestions {
|
||||
@Question({
|
||||
message: 'Please choose a new password (optional)',
|
||||
name: 'password',
|
||||
name: 'newPassword',
|
||||
})
|
||||
parsePassword(value: string) {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Question({
|
||||
type: 'confirm',
|
||||
message: 'Invalidate existing sessions?',
|
||||
default: true,
|
||||
name: 'invalidateSessions',
|
||||
})
|
||||
parseInvalidate(value: boolean): boolean {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -568,6 +568,34 @@ describe(AssetService.name, () => {
|
|||
expect(mocks.stack.delete).toHaveBeenCalledWith(asset.stackId);
|
||||
});
|
||||
|
||||
it('should delete the stack when a non-primary asset is deleted and only the primary would remain', async () => {
|
||||
const asset = AssetFactory.from().build();
|
||||
const deletionAsset = {
|
||||
...getForAssetDeletion(asset),
|
||||
stack: { id: newUuid(), primaryAssetId: newUuid(), assets: [{ id: asset.id }] },
|
||||
};
|
||||
mocks.stack.delete.mockResolvedValue();
|
||||
mocks.assetJob.getForAssetDeletion.mockResolvedValue(deletionAsset);
|
||||
|
||||
await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
|
||||
|
||||
expect(mocks.stack.delete).toHaveBeenCalledWith(deletionAsset.stack.id);
|
||||
});
|
||||
|
||||
it('should keep the stack when a non-primary asset is deleted and the primary plus another asset remain', async () => {
|
||||
const asset = AssetFactory.from().build();
|
||||
const deletionAsset = {
|
||||
...getForAssetDeletion(asset),
|
||||
stack: { id: newUuid(), primaryAssetId: newUuid(), assets: [{ id: asset.id }, { id: newUuid() }] },
|
||||
};
|
||||
mocks.assetJob.getForAssetDeletion.mockResolvedValue(deletionAsset);
|
||||
|
||||
await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
|
||||
|
||||
expect(mocks.stack.delete).not.toHaveBeenCalled();
|
||||
expect(mocks.stack.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should delete a live photo', async () => {
|
||||
const motionAsset = AssetFactory.from({ type: AssetType.Video, visibility: AssetVisibility.Hidden }).build();
|
||||
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
|
||||
|
|
|
|||
|
|
@ -316,18 +316,25 @@ export class AssetService extends BaseService {
|
|||
return JobStatus.Failed;
|
||||
}
|
||||
|
||||
// replace the parent of the stack children with a new asset
|
||||
if (asset.stack?.primaryAssetId === id) {
|
||||
// this only includes timeline visible assets and excludes the primary asset
|
||||
const stackAssetIds = asset.stack.assets.map((a) => a.id);
|
||||
if (stackAssetIds.length >= 2) {
|
||||
const newPrimaryAssetId = stackAssetIds.find((a) => a !== id)!;
|
||||
if (asset.stack) {
|
||||
// asset.stack.assets only includes timeline visible assets and excludes the primary asset
|
||||
const remainingStackAssetIds = asset.stack.assets.map((a) => a.id).filter((assetId) => assetId !== id);
|
||||
|
||||
// the primary survives unless it is the asset being deleted
|
||||
let remainingCount = remainingStackAssetIds.length;
|
||||
if (asset.stack.primaryAssetId !== id) {
|
||||
remainingCount++;
|
||||
}
|
||||
|
||||
if (remainingCount < 2) {
|
||||
// 0 or 1 asset would remain: dissolve the stack so it does not linger as a single-asset stack
|
||||
await this.stackRepository.delete(asset.stack.id);
|
||||
} else if (asset.stack.primaryAssetId === id) {
|
||||
// the primary is being deleted but others remain: promote a new primary
|
||||
await this.stackRepository.update(asset.stack.id, {
|
||||
id: asset.stack.id,
|
||||
primaryAssetId: newPrimaryAssetId,
|
||||
primaryAssetId: remainingStackAssetIds[0],
|
||||
});
|
||||
} else {
|
||||
await this.stackRepository.delete(asset.stack.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ describe(CliService.name, () => {
|
|||
mocks.user.getAdmin.mockResolvedValue(admin);
|
||||
mocks.user.update.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
|
||||
const ask = vitest.fn().mockImplementation(() => {});
|
||||
const ask = vitest.fn().mockResolvedValue({ newPassword: undefined, invalidateSessions: false });
|
||||
|
||||
const response = await sut.resetAdminPassword(ask);
|
||||
|
||||
|
|
@ -47,6 +47,7 @@ describe(CliService.name, () => {
|
|||
expect(ask).toHaveBeenCalled();
|
||||
expect(id).toEqual(admin.id);
|
||||
expect(update.password).toBeDefined();
|
||||
expect(mocks.session.invalidateAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use the supplied password', async () => {
|
||||
|
|
@ -55,7 +56,7 @@ describe(CliService.name, () => {
|
|||
mocks.user.getAdmin.mockResolvedValue(admin);
|
||||
mocks.user.update.mockResolvedValue(admin);
|
||||
|
||||
const ask = vitest.fn().mockResolvedValue('new-password');
|
||||
const ask = vitest.fn().mockResolvedValue({ newPassword: 'new-password', invalidateSessions: false });
|
||||
|
||||
const response = await sut.resetAdminPassword(ask);
|
||||
|
||||
|
|
@ -66,6 +67,20 @@ describe(CliService.name, () => {
|
|||
expect(id).toEqual(admin.id);
|
||||
expect(update.password).toBeDefined();
|
||||
});
|
||||
|
||||
it('should invalidate existing sessions when requested', async () => {
|
||||
const admin = UserFactory.create({ isAdmin: true });
|
||||
|
||||
mocks.user.getAdmin.mockResolvedValue(admin);
|
||||
mocks.user.update.mockResolvedValue(admin);
|
||||
mocks.session.invalidateAll.mockResolvedValue(void 0);
|
||||
|
||||
const ask = vitest.fn().mockResolvedValue({ newPassword: 'new-password', invalidateSessions: true });
|
||||
|
||||
await sut.resetAdminPassword(ask);
|
||||
|
||||
expect(mocks.session.invalidateAll).toHaveBeenCalledWith({ userId: admin.id });
|
||||
});
|
||||
});
|
||||
|
||||
describe('disablePasswordLogin', () => {
|
||||
|
|
|
|||
|
|
@ -58,18 +58,24 @@ export class CliService extends BaseService {
|
|||
return users.map((user) => mapUserAdmin(user));
|
||||
}
|
||||
|
||||
async resetAdminPassword(ask: (admin: UserAdminResponseDto) => Promise<string | undefined>) {
|
||||
async resetAdminPassword(
|
||||
ask: (admin: UserAdminResponseDto) => Promise<{ newPassword: string | undefined; invalidateSessions: boolean }>,
|
||||
) {
|
||||
const admin = await this.userRepository.getAdmin();
|
||||
if (!admin) {
|
||||
throw new Error('Admin account does not exist');
|
||||
}
|
||||
|
||||
const providedPassword = await ask(mapUserAdmin(admin));
|
||||
const { newPassword: providedPassword, invalidateSessions } = await ask(mapUserAdmin(admin));
|
||||
const password = providedPassword || this.cryptoRepository.randomBytesAsText(24);
|
||||
const hashedPassword = await this.cryptoRepository.hashBcrypt(password, SALT_ROUNDS);
|
||||
|
||||
await this.userRepository.update(admin.id, { password: hashedPassword });
|
||||
|
||||
if (invalidateSessions) {
|
||||
await this.sessionRepository.invalidateAll({ userId: admin.id });
|
||||
}
|
||||
|
||||
return { admin, password, provided: !!providedPassword };
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue