mirror of
https://github.com/immich-app/immich.git
synced 2025-12-18 09:13:15 +03:00
feat: list/delete backups (maintenance services)
This commit is contained in:
@@ -38,8 +38,8 @@ export class MaintenanceController {
|
|||||||
@GetLoginDetails() loginDetails: LoginDetails,
|
@GetLoginDetails() loginDetails: LoginDetails,
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (dto.action === MaintenanceAction.Start) {
|
if (dto.action !== MaintenanceAction.End) {
|
||||||
const { jwt } = await this.service.startMaintenance(auth.user.name);
|
const { jwt } = await this.service.startMaintenance(dto, auth.user.name);
|
||||||
return respondWithCookie(res, undefined, {
|
return respondWithCookie(res, undefined, {
|
||||||
isSecure: loginDetails.isSecure,
|
isSecure: loginDetails.isSecure,
|
||||||
values: [{ key: ImmichCookie.MaintenanceToken, value: jwt }],
|
values: [{ key: ImmichCookie.MaintenanceToken, value: jwt }],
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { ValidateEnum, ValidateString } from 'src/validation';
|
|||||||
export class SetMaintenanceModeDto {
|
export class SetMaintenanceModeDto {
|
||||||
@ValidateEnum({ enum: MaintenanceAction, name: 'MaintenanceAction' })
|
@ValidateEnum({ enum: MaintenanceAction, name: 'MaintenanceAction' })
|
||||||
action!: MaintenanceAction;
|
action!: MaintenanceAction;
|
||||||
|
|
||||||
|
@ValidateString({ optional: true })
|
||||||
|
restoreBackupFilename?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MaintenanceLoginDto {
|
export class MaintenanceLoginDto {
|
||||||
|
|||||||
@@ -664,7 +664,6 @@ export enum DatabaseLock {
|
|||||||
export enum MaintenanceAction {
|
export enum MaintenanceAction {
|
||||||
Start = 'start',
|
Start = 'start',
|
||||||
End = 'end',
|
End = 'end',
|
||||||
RestoreFlow = 'restore_flow',
|
|
||||||
RestoreDatabase = 'restore_database',
|
RestoreDatabase = 'restore_database',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { UnauthorizedException } from '@nestjs/common';
|
import { UnauthorizedException } from '@nestjs/common';
|
||||||
import { SignJWT } from 'jose';
|
import { SignJWT } from 'jose';
|
||||||
import { SystemMetadataKey } from 'src/enum';
|
import { MaintenanceAction, SystemMetadataKey } from 'src/enum';
|
||||||
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
|
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
|
||||||
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
|
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
|
||||||
import { automock, getMocks, ServiceMocks } from 'test/utils';
|
import { automock, getMocks, ServiceMocks } from 'test/utils';
|
||||||
@@ -19,6 +19,9 @@ describe(MaintenanceWorkerService.name, () => {
|
|||||||
mocks.config,
|
mocks.config,
|
||||||
mocks.systemMetadata as never,
|
mocks.systemMetadata as never,
|
||||||
maintenanceWorkerRepositoryMock,
|
maintenanceWorkerRepositoryMock,
|
||||||
|
mocks.storage as never,
|
||||||
|
mocks.process,
|
||||||
|
mocks.database as never,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -42,7 +45,14 @@ describe(MaintenanceWorkerService.name, () => {
|
|||||||
const RE_LOGIN_URL = /https:\/\/my.immich.app\/maintenance\?token=([A-Za-z0-9-_]*\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*)/;
|
const RE_LOGIN_URL = /https:\/\/my.immich.app\/maintenance\?token=([A-Za-z0-9-_]*\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*)/;
|
||||||
|
|
||||||
it('should log a valid login URL', async () => {
|
it('should log a valid login URL', async () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
|
mocks.systemMetadata.get.mockResolvedValue({
|
||||||
|
isMaintenanceMode: true,
|
||||||
|
secret: 'secret',
|
||||||
|
action: {
|
||||||
|
action: MaintenanceAction.Start,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
await expect(sut.logSecret()).resolves.toBeUndefined();
|
await expect(sut.logSecret()).resolves.toBeUndefined();
|
||||||
expect(mocks.logger.log).toHaveBeenCalledWith(expect.stringMatching(RE_LOGIN_URL));
|
expect(mocks.logger.log).toHaveBeenCalledWith(expect.stringMatching(RE_LOGIN_URL));
|
||||||
|
|
||||||
@@ -63,7 +73,13 @@ describe(MaintenanceWorkerService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should parse cookie properly', async () => {
|
it('should parse cookie properly', async () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
|
mocks.systemMetadata.get.mockResolvedValue({
|
||||||
|
isMaintenanceMode: true,
|
||||||
|
secret: 'secret',
|
||||||
|
action: {
|
||||||
|
action: MaintenanceAction.Start,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.authenticate({
|
sut.authenticate({
|
||||||
@@ -79,7 +95,13 @@ describe(MaintenanceWorkerService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should fail with expired JWT', async () => {
|
it('should fail with expired JWT', async () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
|
mocks.systemMetadata.get.mockResolvedValue({
|
||||||
|
isMaintenanceMode: true,
|
||||||
|
secret: 'secret',
|
||||||
|
action: {
|
||||||
|
action: MaintenanceAction.Start,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const jwt = await new SignJWT({})
|
const jwt = await new SignJWT({})
|
||||||
.setProtectedHeader({ alg: 'HS256' })
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
@@ -91,7 +113,13 @@ describe(MaintenanceWorkerService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should succeed with valid JWT', async () => {
|
it('should succeed with valid JWT', async () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
|
mocks.systemMetadata.get.mockResolvedValue({
|
||||||
|
isMaintenanceMode: true,
|
||||||
|
secret: 'secret',
|
||||||
|
action: {
|
||||||
|
action: MaintenanceAction.Start,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const jwt = await new SignJWT({ _mockValue: true })
|
const jwt = await new SignJWT({ _mockValue: true })
|
||||||
.setProtectedHeader({ alg: 'HS256' })
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
|||||||
@@ -9,12 +9,16 @@ import { ImmichCookie, SystemMetadataKey } from 'src/enum';
|
|||||||
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
|
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
|
||||||
import { AppRepository } from 'src/repositories/app.repository';
|
import { AppRepository } from 'src/repositories/app.repository';
|
||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||||
|
import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
|
import { ProcessRepository } from 'src/repositories/process.repository';
|
||||||
|
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||||
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
|
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
|
||||||
import { type ApiService as _ApiService } from 'src/services/api.service';
|
import { type ApiService as _ApiService } from 'src/services/api.service';
|
||||||
import { type BaseService as _BaseService } from 'src/services/base.service';
|
import { type BaseService as _BaseService } from 'src/services/base.service';
|
||||||
import { type ServerService as _ServerService } from 'src/services/server.service';
|
import { type ServerService as _ServerService } from 'src/services/server.service';
|
||||||
import { MaintenanceModeState } from 'src/types';
|
import { MaintenanceModeState } from 'src/types';
|
||||||
|
import { deleteBackup, listBackups } from 'src/utils/backups';
|
||||||
import { getConfig } from 'src/utils/config';
|
import { getConfig } from 'src/utils/config';
|
||||||
import { createMaintenanceLoginUrl } from 'src/utils/maintenance';
|
import { createMaintenanceLoginUrl } from 'src/utils/maintenance';
|
||||||
import { getExternalDomain } from 'src/utils/misc';
|
import { getExternalDomain } from 'src/utils/misc';
|
||||||
@@ -30,6 +34,9 @@ export class MaintenanceWorkerService {
|
|||||||
private configRepository: ConfigRepository,
|
private configRepository: ConfigRepository,
|
||||||
private systemMetadataRepository: SystemMetadataRepository,
|
private systemMetadataRepository: SystemMetadataRepository,
|
||||||
private maintenanceWorkerRepository: MaintenanceWebsocketRepository,
|
private maintenanceWorkerRepository: MaintenanceWebsocketRepository,
|
||||||
|
private storageRepository: StorageRepository,
|
||||||
|
private processRepository: ProcessRepository,
|
||||||
|
private databaseRepository: DatabaseRepository,
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(this.constructor.name);
|
this.logger.setContext(this.constructor.name);
|
||||||
}
|
}
|
||||||
@@ -158,4 +165,26 @@ export class MaintenanceWorkerService {
|
|||||||
this.maintenanceWorkerRepository.serverSend('AppRestart', state);
|
this.maintenanceWorkerRepository.serverSend('AppRestart', state);
|
||||||
this.appRepository.exitApp();
|
this.appRepository.exitApp();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backups
|
||||||
|
*/
|
||||||
|
|
||||||
|
async listBackups(): Promise<Record<'backups' | 'failedBackups', string[]>> {
|
||||||
|
return listBackups(this.backupRepos);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteBackup(filename: string): Promise<void> {
|
||||||
|
return deleteBackup(this.backupRepos, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get backupRepos() {
|
||||||
|
return {
|
||||||
|
logger: this.logger,
|
||||||
|
storage: this.storageRepository,
|
||||||
|
config: this.configRepository,
|
||||||
|
process: this.processRepository,
|
||||||
|
database: this.databaseRepository,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { SystemMetadataKey } from 'src/enum';
|
import { DateTime } from 'luxon';
|
||||||
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
|
import { MaintenanceAction, StorageFolder, SystemMetadataKey } from 'src/enum';
|
||||||
import { MaintenanceService } from 'src/services/maintenance.service';
|
import { MaintenanceService } from 'src/services/maintenance.service';
|
||||||
import { newTestService, ServiceMocks } from 'test/utils';
|
import { newTestService, ServiceMocks } from 'test/utils';
|
||||||
|
|
||||||
@@ -36,11 +38,18 @@ describe(MaintenanceService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return true if enabled', async () => {
|
it('should return true if enabled', async () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: '' });
|
mocks.systemMetadata.get.mockResolvedValue({
|
||||||
|
isMaintenanceMode: true,
|
||||||
|
secret: '',
|
||||||
|
action: { action: MaintenanceAction.Start },
|
||||||
|
});
|
||||||
|
|
||||||
await expect(sut.getMaintenanceMode()).resolves.toEqual({
|
await expect(sut.getMaintenanceMode()).resolves.toEqual({
|
||||||
isMaintenanceMode: true,
|
isMaintenanceMode: true,
|
||||||
secret: '',
|
secret: '',
|
||||||
|
action: {
|
||||||
|
action: 'start',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mocks.systemMetadata.get).toHaveBeenCalled();
|
expect(mocks.systemMetadata.get).toHaveBeenCalled();
|
||||||
@@ -51,13 +60,23 @@ describe(MaintenanceService.name, () => {
|
|||||||
it('should set maintenance mode and return a secret', async () => {
|
it('should set maintenance mode and return a secret', async () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false });
|
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false });
|
||||||
|
|
||||||
await expect(sut.startMaintenance('admin')).resolves.toMatchObject({
|
await expect(
|
||||||
|
sut.startMaintenance(
|
||||||
|
{
|
||||||
|
action: MaintenanceAction.Start,
|
||||||
|
},
|
||||||
|
'admin',
|
||||||
|
),
|
||||||
|
).resolves.toMatchObject({
|
||||||
jwt: expect.any(String),
|
jwt: expect.any(String),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
|
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
|
||||||
isMaintenanceMode: true,
|
isMaintenanceMode: true,
|
||||||
secret: expect.stringMatching(/^\w{128}$/),
|
secret: expect.stringMatching(/^\w{128}$/),
|
||||||
|
action: {
|
||||||
|
action: 'start',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mocks.event.emit).toHaveBeenCalledWith('AppRestart', {
|
expect(mocks.event.emit).toHaveBeenCalledWith('AppRestart', {
|
||||||
@@ -78,7 +97,13 @@ describe(MaintenanceService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should generate a login url with JWT', async () => {
|
it('should generate a login url with JWT', async () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
|
mocks.systemMetadata.get.mockResolvedValue({
|
||||||
|
isMaintenanceMode: true,
|
||||||
|
secret: 'secret',
|
||||||
|
action: {
|
||||||
|
action: MaintenanceAction.Start,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.createLoginUrl({
|
sut.createLoginUrl({
|
||||||
@@ -106,4 +131,36 @@ describe(MaintenanceService.name, () => {
|
|||||||
expect(mocks.systemMetadata.get).toHaveBeenCalledTimes(1);
|
expect(mocks.systemMetadata.get).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backups
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('listBackups', () => {
|
||||||
|
it('should give us all valid and failed backups', async () => {
|
||||||
|
mocks.storage.readdir.mockResolvedValue([
|
||||||
|
`immich-db-backup-${DateTime.fromISO('2025-07-25T11:02:16Z').toFormat("yyyyLLdd'T'HHmmss")}-v1.234.5-pg14.5.sql.gz.tmp`,
|
||||||
|
`immich-db-backup-${DateTime.fromISO('2025-07-27T11:01:16Z').toFormat("yyyyLLdd'T'HHmmss")}-v1.234.5-pg14.5.sql.gz`,
|
||||||
|
'immich-db-backup-1753789649000.sql.gz',
|
||||||
|
`immich-db-backup-${DateTime.fromISO('2025-07-29T11:01:16Z').toFormat("yyyyLLdd'T'HHmmss")}-v1.234.5-pg14.5.sql.gz`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(sut.listBackups()).resolves.toMatchObject({
|
||||||
|
backups: [
|
||||||
|
'immich-db-backup-20250729T110116-v1.234.5-pg14.5.sql.gz',
|
||||||
|
'immich-db-backup-20250727T110116-v1.234.5-pg14.5.sql.gz',
|
||||||
|
'immich-db-backup-1753789649000.sql.gz',
|
||||||
|
],
|
||||||
|
failedBackups: ['immich-db-backup-20250725T110216-v1.234.5-pg14.5.sql.gz.tmp'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteBackup', () => {
|
||||||
|
it('should unlink the target file', async () => {
|
||||||
|
await sut.deleteBackup('filename');
|
||||||
|
expect(mocks.storage.unlink).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.storage.unlink).toHaveBeenCalledWith(`${StorageCore.getBaseFolder(StorageFolder.Backups)}/filename`);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { OnEvent } from 'src/decorators';
|
import { OnEvent } from 'src/decorators';
|
||||||
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
|
import { MaintenanceAuthDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
|
||||||
import { SystemMetadataKey } from 'src/enum';
|
import { SystemMetadataKey } from 'src/enum';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { MaintenanceModeState } from 'src/types';
|
import { MaintenanceModeState } from 'src/types';
|
||||||
|
import { deleteBackup, listBackups } from 'src/utils/backups';
|
||||||
import { createMaintenanceLoginUrl, generateMaintenanceSecret, signMaintenanceJwt } from 'src/utils/maintenance';
|
import { createMaintenanceLoginUrl, generateMaintenanceSecret, signMaintenanceJwt } from 'src/utils/maintenance';
|
||||||
import { getExternalDomain } from 'src/utils/misc';
|
import { getExternalDomain } from 'src/utils/misc';
|
||||||
|
|
||||||
@@ -18,9 +19,14 @@ export class MaintenanceService extends BaseService {
|
|||||||
.then((state) => state ?? { isMaintenanceMode: false });
|
.then((state) => state ?? { isMaintenanceMode: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
async startMaintenance(username: string): Promise<{ jwt: string }> {
|
async startMaintenance(action: SetMaintenanceModeDto, username: string): Promise<{ jwt: string }> {
|
||||||
const secret = generateMaintenanceSecret();
|
const secret = generateMaintenanceSecret();
|
||||||
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, { isMaintenanceMode: true, secret });
|
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, {
|
||||||
|
isMaintenanceMode: true,
|
||||||
|
secret,
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
|
||||||
await this.eventRepository.emit('AppRestart', { isMaintenanceMode: true });
|
await this.eventRepository.emit('AppRestart', { isMaintenanceMode: true });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -50,4 +56,26 @@ export class MaintenanceService extends BaseService {
|
|||||||
|
|
||||||
return await createMaintenanceLoginUrl(baseUrl, auth, secret);
|
return await createMaintenanceLoginUrl(baseUrl, auth, secret);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backups
|
||||||
|
*/
|
||||||
|
|
||||||
|
async listBackups(): Promise<Record<'backups' | 'failedBackups', string[]>> {
|
||||||
|
return listBackups(this.backupRepos);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteBackup(filename: string): Promise<void> {
|
||||||
|
return deleteBackup(this.backupRepos, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get backupRepos() {
|
||||||
|
return {
|
||||||
|
logger: this.logger,
|
||||||
|
storage: this.storageRepository,
|
||||||
|
config: this.configRepository,
|
||||||
|
process: this.processRepository,
|
||||||
|
database: this.databaseRepository,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { VECTOR_EXTENSIONS } from 'src/constants';
|
|||||||
import { Asset } from 'src/database';
|
import { Asset } from 'src/database';
|
||||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
|
import { SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
|
||||||
import {
|
import {
|
||||||
AssetMetadataKey,
|
AssetMetadataKey,
|
||||||
AssetOrder,
|
AssetOrder,
|
||||||
@@ -493,7 +494,9 @@ export interface MemoryData {
|
|||||||
|
|
||||||
export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string };
|
export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string };
|
||||||
export type SystemFlags = { mountChecks: Record<StorageFolder, boolean> };
|
export type SystemFlags = { mountChecks: Record<StorageFolder, boolean> };
|
||||||
export type MaintenanceModeState = { isMaintenanceMode: true; secret: string } | { isMaintenanceMode: false };
|
export type MaintenanceModeState =
|
||||||
|
| { isMaintenanceMode: true; secret: string; action: SetMaintenanceModeDto }
|
||||||
|
| { isMaintenanceMode: false };
|
||||||
export type MemoriesState = {
|
export type MemoriesState = {
|
||||||
/** memories have already been created through this date */
|
/** memories have already been created through this date */
|
||||||
lastOnThisDayDate: string;
|
lastOnThisDayDate: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user