mirror of
https://github.com/immich-app/immich.git
synced 2025-12-18 10:29:15 -08:00
Compare commits
137 Commits
chore/log-
...
feat/datab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21e2e9415c | ||
|
|
4c71336e95 | ||
|
|
5ddc509cc4 | ||
|
|
2ceeb589de | ||
|
|
f0b069adb9 | ||
|
|
276d02e12b | ||
|
|
ded9535434 | ||
|
|
997aec2441 | ||
|
|
cb2bd47816 | ||
|
|
f1c8377ca0 | ||
|
|
8416397589 | ||
|
|
dc29635b67 | ||
|
|
00290e1e71 | ||
|
|
3ef4c4f315 | ||
|
|
b10a8baf53 | ||
|
|
77926383db | ||
|
|
35eda735c8 | ||
|
|
8f7a71d1cf | ||
|
|
33cdea88aa | ||
|
|
e958516318 | ||
|
|
0d05c0d4ae | ||
|
|
4e2187acf9 | ||
|
|
adc2d5d1e5 | ||
|
|
6b9cc855a5 | ||
|
|
02265ba224 | ||
|
|
cf3686a509 | ||
|
|
3019091733 | ||
|
|
4296211c61 | ||
|
|
207a8bc55a | ||
|
|
a63b418507 | ||
|
|
fe8eb85e37 | ||
|
|
4659ceb425 | ||
|
|
17dfcedad6 | ||
|
|
20d1e610ce | ||
|
|
305bf60f97 | ||
|
|
f9d2a9707d | ||
|
|
ef944c29d3 | ||
|
|
274775d876 | ||
|
|
0945e18564 | ||
|
|
e0428b565a | ||
|
|
9b955508e9 | ||
|
|
a79b4bdc47 | ||
|
|
94af1bba4d | ||
|
|
b5ff460a55 | ||
|
|
8b1ba11e0b | ||
|
|
a7fd19db52 | ||
|
|
db7169ea01 | ||
|
|
cede65f2dd | ||
|
|
e355dccc48 | ||
|
|
8dd865d054 | ||
|
|
e3f350ea60 | ||
|
|
6ec10a5f15 | ||
|
|
9cb968116b | ||
|
|
52edcdee60 | ||
|
|
96426fec7e | ||
|
|
47f5232a5f | ||
|
|
a091ca76e7 | ||
|
|
c8fea45731 | ||
|
|
390f0b2817 | ||
|
|
1cdffeb3be | ||
|
|
87f34ba505 | ||
|
|
ca116caafb | ||
|
|
86b7b1c44d | ||
|
|
95d9bcb3f1 | ||
|
|
0f145a5b52 | ||
|
|
481ec02edb | ||
|
|
9f5f90b2ff | ||
|
|
1ad2282166 | ||
|
|
b99d92961c | ||
|
|
45b5752cbf | ||
|
|
220d63e035 | ||
|
|
3be039b953 | ||
|
|
e2ca0c6f67 | ||
|
|
f84bdc14d5 | ||
|
|
fd6f043aa4 | ||
|
|
5a6083f53c | ||
|
|
a61f9d7a26 | ||
|
|
3863ff73ef | ||
|
|
534a9f50b6 | ||
|
|
b46d6cda65 | ||
|
|
86d8e1a092 | ||
|
|
0940c313ac | ||
|
|
f6316ca0c8 | ||
|
|
539167eb88 | ||
|
|
5bca8808a1 | ||
|
|
e93652a4a5 | ||
|
|
ac9a587063 | ||
|
|
f7b59f50ed | ||
|
|
53ef26a5e4 | ||
|
|
6cefb9ca95 | ||
|
|
fdacf0ec57 | ||
|
|
cbf3a2c3cb | ||
|
|
d2a4dd67d8 | ||
|
|
874782edf0 | ||
|
|
a7245627fc | ||
|
|
174670a1b7 | ||
|
|
a3c6d71a58 | ||
|
|
19ba23056c | ||
|
|
3d2d7fa64c | ||
|
|
fccb31d1d8 | ||
|
|
8405a9bf0c | ||
|
|
3933b23e2c | ||
|
|
824f6e5b05 | ||
|
|
270d7e3cdc | ||
|
|
8463968712 | ||
|
|
5be08274ff | ||
|
|
161918e9ca | ||
|
|
d6e3d26cfc | ||
|
|
d5351de26f | ||
|
|
ed4a850a01 | ||
|
|
9d4ad11cff | ||
|
|
b887d4f557 | ||
|
|
2e15012257 | ||
|
|
56a4159295 | ||
|
|
f69c49a60f | ||
|
|
f778a4260b | ||
|
|
31f4665d35 | ||
|
|
53a74a7279 | ||
|
|
dd1cf12aaa | ||
|
|
31410c3c20 | ||
|
|
26587dd690 | ||
|
|
442fe6e3d0 | ||
|
|
af741a4761 | ||
|
|
7c2e8b1d62 | ||
|
|
56c93a71c0 | ||
|
|
c090a1a9d9 | ||
|
|
d040de2d52 | ||
|
|
73ae766d9f | ||
|
|
edc1333db1 | ||
|
|
b01b63b25a | ||
|
|
7e7d6af66b | ||
|
|
0ae03f68cf | ||
|
|
0419539c08 | ||
|
|
f67153e44b | ||
|
|
cc7895244d | ||
|
|
a6fb942fca | ||
|
|
9cea3d7b2f |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,7 +7,7 @@
|
|||||||
.idea
|
.idea
|
||||||
|
|
||||||
docker/upload
|
docker/upload
|
||||||
docker/library
|
docker/library*
|
||||||
uploads
|
uploads
|
||||||
coverage
|
coverage
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/micromatch": "^4.0.9",
|
"@types/micromatch": "^4.0.9",
|
||||||
"@types/mock-fs": "^4.13.1",
|
"@types/mock-fs": "^4.13.1",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.3",
|
||||||
"@vitest/coverage-v8": "^3.0.0",
|
"@vitest/coverage-v8": "^3.0.0",
|
||||||
"byte-size": "^9.0.0",
|
"byte-size": "^9.0.0",
|
||||||
"cli-progress": "^3.12.0",
|
"cli-progress": "^3.12.0",
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
"@playwright/test": "^1.44.1",
|
"@playwright/test": "^1.44.1",
|
||||||
"@socket.io/component-emitter": "^3.1.2",
|
"@socket.io/component-emitter": "^3.1.2",
|
||||||
"@types/luxon": "^3.4.2",
|
"@types/luxon": "^3.4.2",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.3",
|
||||||
"@types/oidc-provider": "^9.0.0",
|
"@types/oidc-provider": "^9.0.0",
|
||||||
"@types/pg": "^8.15.1",
|
"@types/pg": "^8.15.1",
|
||||||
"@types/pngjs": "^6.0.4",
|
"@types/pngjs": "^6.0.4",
|
||||||
|
|||||||
267
e2e/src/api/specs/database-backups.e2e-spec.ts
Normal file
267
e2e/src/api/specs/database-backups.e2e-spec.ts
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import { LoginResponseDto, ManualJobName } from '@immich/sdk';
|
||||||
|
import { errorDto } from 'src/responses';
|
||||||
|
import { app, utils } from 'src/utils';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
describe('/admin/database-backups', () => {
|
||||||
|
let cookie: string | undefined;
|
||||||
|
let admin: LoginResponseDto;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await utils.resetDatabase();
|
||||||
|
admin = await utils.adminSetup();
|
||||||
|
await utils.resetBackups(admin.accessToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /', async () => {
|
||||||
|
it('should succeed and be empty', async () => {
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.get('/admin/database-backups')
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body).toEqual({
|
||||||
|
backups: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain a created backup', async () => {
|
||||||
|
await utils.createJob(admin.accessToken, {
|
||||||
|
name: ManualJobName.BackupDatabase,
|
||||||
|
});
|
||||||
|
|
||||||
|
await utils.waitForQueueFinish(admin.accessToken, 'backupDatabase');
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.get('/admin/database-backups')
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
|
|
||||||
|
expect(status).toBe(200);
|
||||||
|
return body;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
interval: 500,
|
||||||
|
timeout: 10_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
backups: [expect.stringMatching(/immich-db-backup-\d{8}T\d{6}-v.*-pg.*\.sql\.gz$/)],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /', async () => {
|
||||||
|
it('should delete backup', async () => {
|
||||||
|
const filename = await utils.createBackup(admin.accessToken);
|
||||||
|
|
||||||
|
const { status } = await request(app)
|
||||||
|
.delete(`/admin/database-backups`)
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
|
.send({ backups: [filename] });
|
||||||
|
|
||||||
|
expect(status).toBe(200);
|
||||||
|
|
||||||
|
const { status: listStatus, body } = await request(app)
|
||||||
|
.get('/admin/database-backups')
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||||
|
|
||||||
|
expect(listStatus).toBe(200);
|
||||||
|
expect(body).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
backups: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// => action: restore database flow
|
||||||
|
|
||||||
|
describe.sequential('POST /start-restore', () => {
|
||||||
|
afterAll(async () => {
|
||||||
|
await request(app).post('/admin/maintenance').set('cookie', cookie!).send({ action: 'end' });
|
||||||
|
await utils.poll(
|
||||||
|
() => request(app).get('/server/config'),
|
||||||
|
({ status, body }) => status === 200 && !body.maintenanceMode,
|
||||||
|
);
|
||||||
|
|
||||||
|
admin = await utils.adminSetup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.sequential('should not work when the server is configured', async () => {
|
||||||
|
const { status, body } = await request(app).post('/admin/database-backups/start-restore').send();
|
||||||
|
|
||||||
|
expect(status).toBe(400);
|
||||||
|
expect(body).toEqual(errorDto.badRequest('The server already has an admin'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it.sequential('should enter maintenance mode in "database restore mode"', async () => {
|
||||||
|
await utils.resetDatabase(); // reset database before running this test
|
||||||
|
|
||||||
|
const { status, headers } = await request(app).post('/admin/database-backups/start-restore').send();
|
||||||
|
|
||||||
|
expect(status).toBe(201);
|
||||||
|
|
||||||
|
cookie = headers['set-cookie'][0].split(';')[0];
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const { status, body } = await request(app).get('/server/config');
|
||||||
|
expect(status).toBe(200);
|
||||||
|
return body.maintenanceMode;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
interval: 500,
|
||||||
|
timeout: 10_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toBeTruthy();
|
||||||
|
|
||||||
|
const { status: status2, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||||
|
expect(status2).toBe(200);
|
||||||
|
expect(body).toEqual({
|
||||||
|
active: true,
|
||||||
|
action: 'restore_database',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// => action: restore database
|
||||||
|
|
||||||
|
describe.sequential('POST /backups/restore', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await utils.disconnectDatabase();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await utils.connectDatabase();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.sequential('should restore a backup', { timeout: 60_000 }, async () => {
|
||||||
|
const filename = await utils.createBackup(admin.accessToken);
|
||||||
|
|
||||||
|
const { status } = await request(app)
|
||||||
|
.post('/admin/maintenance')
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
|
.send({
|
||||||
|
action: 'restore_database',
|
||||||
|
restoreBackupFilename: filename,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(status).toBe(201);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const { status, body } = await request(app).get('/server/config');
|
||||||
|
expect(status).toBe(200);
|
||||||
|
return body.maintenanceMode;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
interval: 500,
|
||||||
|
timeout: 10_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toBeTruthy();
|
||||||
|
|
||||||
|
const { status: status2, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||||
|
expect(status2).toBe(200);
|
||||||
|
expect(body).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
active: true,
|
||||||
|
action: 'restore_database',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const { status, body } = await request(app).get('/server/config');
|
||||||
|
expect(status).toBe(200);
|
||||||
|
return body.maintenanceMode;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
interval: 500,
|
||||||
|
timeout: 60_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.sequential('fail to restore a corrupted backup', { timeout: 60_000 }, async () => {
|
||||||
|
await utils.prepareTestBackup('corrupted');
|
||||||
|
|
||||||
|
const { status, headers } = await request(app)
|
||||||
|
.post('/admin/maintenance')
|
||||||
|
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||||
|
.send({
|
||||||
|
action: 'restore_database',
|
||||||
|
restoreBackupFilename: 'development-corrupted.sql.gz',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(status).toBe(201);
|
||||||
|
cookie = headers['set-cookie'][0].split(';')[0];
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const { status, body } = await request(app).get('/server/config');
|
||||||
|
expect(status).toBe(200);
|
||||||
|
return body.maintenanceMode;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
interval: 500,
|
||||||
|
timeout: 10_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toBeTruthy();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||||
|
expect(status).toBe(200);
|
||||||
|
return body;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
interval: 500,
|
||||||
|
timeout: 10_000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
active: true,
|
||||||
|
action: 'restore_database',
|
||||||
|
error: 'Something went wrong, see logs!',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { status: status2, body: body2 } = await request(app)
|
||||||
|
.get('/admin/maintenance/status')
|
||||||
|
.set('cookie', cookie!)
|
||||||
|
.send({ token: 'token' });
|
||||||
|
expect(status2).toBe(200);
|
||||||
|
expect(body2).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
active: true,
|
||||||
|
action: 'restore_database',
|
||||||
|
error: expect.stringContaining('IM CORRUPTED'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await request(app).post('/admin/maintenance').set('cookie', cookie!).send({
|
||||||
|
action: 'end',
|
||||||
|
});
|
||||||
|
|
||||||
|
await utils.poll(
|
||||||
|
() => request(app).get('/server/config'),
|
||||||
|
({ status, body }) => status === 200 && !body.maintenanceMode,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,6 +14,7 @@ describe('/admin/maintenance', () => {
|
|||||||
await utils.resetDatabase();
|
await utils.resetDatabase();
|
||||||
admin = await utils.adminSetup();
|
admin = await utils.adminSetup();
|
||||||
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
|
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
|
||||||
|
await utils.resetBackups(admin.accessToken);
|
||||||
});
|
});
|
||||||
|
|
||||||
// => outside of maintenance mode
|
// => outside of maintenance mode
|
||||||
@@ -26,6 +27,17 @@ describe('/admin/maintenance', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('GET /status', async () => {
|
||||||
|
it('to always indicate we are not in maintenance mode', async () => {
|
||||||
|
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body).toEqual({
|
||||||
|
active: false,
|
||||||
|
action: 'end',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('POST /login', async () => {
|
describe('POST /login', async () => {
|
||||||
it('should not work out of maintenance mode', async () => {
|
it('should not work out of maintenance mode', async () => {
|
||||||
const { status, body } = await request(app).post('/admin/maintenance/login').send({ token: 'token' });
|
const { status, body } = await request(app).post('/admin/maintenance/login').send({ token: 'token' });
|
||||||
@@ -39,6 +51,7 @@ describe('/admin/maintenance', () => {
|
|||||||
describe.sequential('POST /', () => {
|
describe.sequential('POST /', () => {
|
||||||
it('should require authentication', async () => {
|
it('should require authentication', async () => {
|
||||||
const { status, body } = await request(app).post('/admin/maintenance').send({
|
const { status, body } = await request(app).post('/admin/maintenance').send({
|
||||||
|
active: false,
|
||||||
action: 'end',
|
action: 'end',
|
||||||
});
|
});
|
||||||
expect(status).toBe(401);
|
expect(status).toBe(401);
|
||||||
@@ -69,6 +82,7 @@ describe('/admin/maintenance', () => {
|
|||||||
.send({
|
.send({
|
||||||
action: 'start',
|
action: 'start',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(status).toBe(201);
|
expect(status).toBe(201);
|
||||||
|
|
||||||
cookie = headers['set-cookie'][0].split(';')[0];
|
cookie = headers['set-cookie'][0].split(';')[0];
|
||||||
@@ -79,12 +93,13 @@ describe('/admin/maintenance', () => {
|
|||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const { body } = await request(app).get('/server/config');
|
const { status, body } = await request(app).get('/server/config');
|
||||||
|
expect(status).toBe(200);
|
||||||
return body.maintenanceMode;
|
return body.maintenanceMode;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
interval: 5e2,
|
interval: 500,
|
||||||
timeout: 1e4,
|
timeout: 10_000,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.toBeTruthy();
|
.toBeTruthy();
|
||||||
@@ -102,6 +117,17 @@ describe('/admin/maintenance', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('GET /status', async () => {
|
||||||
|
it('to indicate we are in maintenance mode', async () => {
|
||||||
|
const { status, body } = await request(app).get('/admin/maintenance/status').send({ token: 'token' });
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(body).toEqual({
|
||||||
|
active: true,
|
||||||
|
action: 'start',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('POST /login', async () => {
|
describe('POST /login', async () => {
|
||||||
it('should fail without cookie or token in body', async () => {
|
it('should fail without cookie or token in body', async () => {
|
||||||
const { status, body } = await request(app).post('/admin/maintenance/login').send({});
|
const { status, body } = await request(app).post('/admin/maintenance/login').send({});
|
||||||
@@ -158,12 +184,13 @@ describe('/admin/maintenance', () => {
|
|||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const { body } = await request(app).get('/server/config');
|
const { status, body } = await request(app).get('/server/config');
|
||||||
|
expect(status).toBe(200);
|
||||||
return body.maintenanceMode;
|
return body.maintenanceMode;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
interval: 5e2,
|
interval: 500,
|
||||||
timeout: 1e4,
|
timeout: 10_000,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.toBeFalsy();
|
.toBeFalsy();
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import {
|
|||||||
CheckExistingAssetsDto,
|
CheckExistingAssetsDto,
|
||||||
CreateAlbumDto,
|
CreateAlbumDto,
|
||||||
CreateLibraryDto,
|
CreateLibraryDto,
|
||||||
|
JobCreateDto,
|
||||||
MaintenanceAction,
|
MaintenanceAction,
|
||||||
|
ManualJobName,
|
||||||
MetadataSearchDto,
|
MetadataSearchDto,
|
||||||
Permission,
|
Permission,
|
||||||
PersonCreateDto,
|
PersonCreateDto,
|
||||||
@@ -21,6 +23,7 @@ import {
|
|||||||
checkExistingAssets,
|
checkExistingAssets,
|
||||||
createAlbum,
|
createAlbum,
|
||||||
createApiKey,
|
createApiKey,
|
||||||
|
createJob,
|
||||||
createLibrary,
|
createLibrary,
|
||||||
createPartner,
|
createPartner,
|
||||||
createPerson,
|
createPerson,
|
||||||
@@ -28,10 +31,12 @@ import {
|
|||||||
createStack,
|
createStack,
|
||||||
createUserAdmin,
|
createUserAdmin,
|
||||||
deleteAssets,
|
deleteAssets,
|
||||||
|
deleteDatabaseBackup,
|
||||||
getAssetInfo,
|
getAssetInfo,
|
||||||
getConfig,
|
getConfig,
|
||||||
getConfigDefaults,
|
getConfigDefaults,
|
||||||
getQueuesLegacy,
|
getQueuesLegacy,
|
||||||
|
listDatabaseBackups,
|
||||||
login,
|
login,
|
||||||
runQueueCommandLegacy,
|
runQueueCommandLegacy,
|
||||||
scanLibrary,
|
scanLibrary,
|
||||||
@@ -52,11 +57,15 @@ import {
|
|||||||
import { BrowserContext } from '@playwright/test';
|
import { BrowserContext } from '@playwright/test';
|
||||||
import { exec, spawn } from 'node:child_process';
|
import { exec, spawn } from 'node:child_process';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
import { createWriteStream, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { mkdtemp } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { dirname, resolve } from 'node:path';
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
|
import { pipeline } from 'node:stream/promises';
|
||||||
import { setTimeout as setAsyncTimeout } from 'node:timers/promises';
|
import { setTimeout as setAsyncTimeout } from 'node:timers/promises';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
|
import { createGzip } from 'node:zlib';
|
||||||
import pg from 'pg';
|
import pg from 'pg';
|
||||||
import { io, type Socket } from 'socket.io-client';
|
import { io, type Socket } from 'socket.io-client';
|
||||||
import { loginDto, signupDto } from 'src/fixtures';
|
import { loginDto, signupDto } from 'src/fixtures';
|
||||||
@@ -84,8 +93,9 @@ export const asBearerAuth = (accessToken: string) => ({ Authorization: `Bearer $
|
|||||||
export const asKeyAuth = (key: string) => ({ 'x-api-key': key });
|
export const asKeyAuth = (key: string) => ({ 'x-api-key': key });
|
||||||
export const immichCli = (args: string[]) =>
|
export const immichCli = (args: string[]) =>
|
||||||
executeCommand('pnpm', ['exec', 'immich', '-d', `/${tempDir}/immich/`, ...args], { cwd: '../cli' }).promise;
|
executeCommand('pnpm', ['exec', 'immich', '-d', `/${tempDir}/immich/`, ...args], { cwd: '../cli' }).promise;
|
||||||
export const immichAdmin = (args: string[]) =>
|
export const dockerExec = (args: string[]) =>
|
||||||
executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', `immich-admin ${args.join(' ')}`]);
|
executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', args.join(' ')]);
|
||||||
|
export const immichAdmin = (args: string[]) => dockerExec([`immich-admin ${args.join(' ')}`]);
|
||||||
export const specialCharStrings = ["'", '"', ',', '{', '}', '*'];
|
export const specialCharStrings = ["'", '"', ',', '{', '}', '*'];
|
||||||
export const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
export const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||||
|
|
||||||
@@ -149,12 +159,26 @@ const onEvent = ({ event, id }: { event: EventType; id: string }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const utils = {
|
export const utils = {
|
||||||
|
connectDatabase: async () => {
|
||||||
|
if (!client) {
|
||||||
|
client = new pg.Client(dbUrl);
|
||||||
|
client.on('end', () => (client = null));
|
||||||
|
client.on('error', () => (client = null));
|
||||||
|
await client.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return client;
|
||||||
|
},
|
||||||
|
|
||||||
|
disconnectDatabase: async () => {
|
||||||
|
if (client) {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
resetDatabase: async (tables?: string[]) => {
|
resetDatabase: async (tables?: string[]) => {
|
||||||
try {
|
try {
|
||||||
if (!client) {
|
client = await utils.connectDatabase();
|
||||||
client = new pg.Client(dbUrl);
|
|
||||||
await client.connect();
|
|
||||||
}
|
|
||||||
|
|
||||||
tables = tables || [
|
tables = tables || [
|
||||||
// TODO e2e test for deleting a stack, since it is quite complex
|
// TODO e2e test for deleting a stack, since it is quite complex
|
||||||
@@ -481,6 +505,9 @@ export const utils = {
|
|||||||
tagAssets: (accessToken: string, tagId: string, assetIds: string[]) =>
|
tagAssets: (accessToken: string, tagId: string, assetIds: string[]) =>
|
||||||
tagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } }, { headers: asBearerAuth(accessToken) }),
|
tagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } }, { headers: asBearerAuth(accessToken) }),
|
||||||
|
|
||||||
|
createJob: async (accessToken: string, jobCreateDto: JobCreateDto) =>
|
||||||
|
createJob({ jobCreateDto }, { headers: asBearerAuth(accessToken) }),
|
||||||
|
|
||||||
queueCommand: async (accessToken: string, name: QueueName, queueCommandDto: QueueCommandDto) =>
|
queueCommand: async (accessToken: string, name: QueueName, queueCommandDto: QueueCommandDto) =>
|
||||||
runQueueCommandLegacy({ name, queueCommandDto }, { headers: asBearerAuth(accessToken) }),
|
runQueueCommandLegacy({ name, queueCommandDto }, { headers: asBearerAuth(accessToken) }),
|
||||||
|
|
||||||
@@ -559,6 +586,36 @@ export const utils = {
|
|||||||
mkdirSync(`${testAssetDir}/temp`, { recursive: true });
|
mkdirSync(`${testAssetDir}/temp`, { recursive: true });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
createBackup: async (accessToken: string) => {
|
||||||
|
await utils.createJob(accessToken, {
|
||||||
|
name: ManualJobName.BackupDatabase,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await utils.poll(
|
||||||
|
() => request(app).get('/admin/database-backups').set('Authorization', `Bearer ${accessToken}`),
|
||||||
|
({ status, body }) => status === 200 && body.backups.length === 1,
|
||||||
|
({ body }) => body.backups[0],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
resetBackups: async (accessToken: string) => {
|
||||||
|
const { backups } = await listDatabaseBackups({ headers: asBearerAuth(accessToken) });
|
||||||
|
await deleteDatabaseBackup({ databaseBackupDeleteDto: { backups } }, { headers: asBearerAuth(accessToken) });
|
||||||
|
},
|
||||||
|
|
||||||
|
prepareTestBackup: async (generate: 'corrupted') => {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), 'test-'));
|
||||||
|
const fn = join(dir, 'file');
|
||||||
|
|
||||||
|
const sql = Readable.from('IM CORRUPTED;');
|
||||||
|
const gzip = createGzip();
|
||||||
|
const writeStream = createWriteStream(fn);
|
||||||
|
await pipeline(sql, gzip, writeStream);
|
||||||
|
|
||||||
|
await executeCommand('docker', ['cp', fn, `immich-e2e-server:/data/backups/development-${generate}.sql.gz`])
|
||||||
|
.promise;
|
||||||
|
},
|
||||||
|
|
||||||
resetAdminConfig: async (accessToken: string) => {
|
resetAdminConfig: async (accessToken: string) => {
|
||||||
const defaultConfig = await getConfigDefaults({ headers: asBearerAuth(accessToken) });
|
const defaultConfig = await getConfigDefaults({ headers: asBearerAuth(accessToken) });
|
||||||
await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
|
await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
|
||||||
@@ -601,6 +658,25 @@ export const utils = {
|
|||||||
await utils.waitForQueueFinish(accessToken, 'sidecar');
|
await utils.waitForQueueFinish(accessToken, 'sidecar');
|
||||||
await utils.waitForQueueFinish(accessToken, 'metadataExtraction');
|
await utils.waitForQueueFinish(accessToken, 'metadataExtraction');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async poll<T>(cb: () => Promise<T>, validate: (value: T) => boolean, map?: (value: T) => any) {
|
||||||
|
let timeout = 0;
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const data = await cb();
|
||||||
|
if (validate(data)) {
|
||||||
|
return map ? map(data) : data;
|
||||||
|
}
|
||||||
|
timeout++;
|
||||||
|
if (timeout >= 10) {
|
||||||
|
throw 'Could not clean up test.';
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5e2));
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
utils.initSdk();
|
utils.initSdk();
|
||||||
|
|||||||
75
e2e/src/web/specs/database-backups.e2e-spec.ts
Normal file
75
e2e/src/web/specs/database-backups.e2e-spec.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { LoginResponseDto } from '@immich/sdk';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { utils } from 'src/utils';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
test.describe('Database Backups', () => {
|
||||||
|
let admin: LoginResponseDto;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
utils.initSdk();
|
||||||
|
await utils.resetDatabase();
|
||||||
|
admin = await utils.adminSetup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restore a backup from settings', async ({ context, page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
await utils.resetBackups(admin.accessToken);
|
||||||
|
await utils.createBackup(admin.accessToken);
|
||||||
|
await utils.setAuthCookies(context, admin.accessToken);
|
||||||
|
|
||||||
|
await page.goto('/admin/maintenance?isOpen=backups');
|
||||||
|
await page.getByRole('button', { name: 'Restore', exact: true }).click();
|
||||||
|
await page.locator('#bits-c2').getByRole('button', { name: 'Restore' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/maintenance?**');
|
||||||
|
await page.waitForURL('/admin/maintenance**', { timeout: 60_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handle backup restore failure', async ({ context, page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
await utils.resetBackups(admin.accessToken);
|
||||||
|
await utils.prepareTestBackup('corrupted');
|
||||||
|
await utils.setAuthCookies(context, admin.accessToken);
|
||||||
|
|
||||||
|
await page.goto('/admin/maintenance?isOpen=backups');
|
||||||
|
await page.getByRole('button', { name: 'Restore', exact: true }).click();
|
||||||
|
await page.locator('#bits-c2').getByRole('button', { name: 'Restore' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/maintenance?**');
|
||||||
|
await expect(page.getByText('IM CORRUPTED')).toBeVisible({ timeout: 60_000 });
|
||||||
|
await page.getByRole('button', { name: 'End maintenance mode' }).click();
|
||||||
|
await page.waitForURL('/admin/maintenance**');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restore a backup from onboarding', async ({ context, page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
await utils.resetBackups(admin.accessToken);
|
||||||
|
await utils.createBackup(admin.accessToken);
|
||||||
|
await utils.setAuthCookies(context, admin.accessToken);
|
||||||
|
await utils.resetDatabase();
|
||||||
|
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: 'Restore from backup' }).click();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.waitForURL('/maintenance**');
|
||||||
|
} catch {
|
||||||
|
// when chained with the rest of the tests
|
||||||
|
// this navigation may fail..? not sure why...
|
||||||
|
await page.goto('/maintenance');
|
||||||
|
await page.waitForURL('/maintenance**');
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Next' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Restore', exact: true }).click();
|
||||||
|
await page.locator('#bits-c2').getByRole('button', { name: 'Restore' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/maintenance?**');
|
||||||
|
await page.waitForURL('/photos', { timeout: 60_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,12 +16,12 @@ test.describe('Maintenance', () => {
|
|||||||
test('enter and exit maintenance mode', async ({ context, page }) => {
|
test('enter and exit maintenance mode', async ({ context, page }) => {
|
||||||
await utils.setAuthCookies(context, admin.accessToken);
|
await utils.setAuthCookies(context, admin.accessToken);
|
||||||
|
|
||||||
await page.goto('/admin/system-settings?isOpen=maintenance');
|
await page.goto('/admin/maintenance');
|
||||||
await page.getByRole('button', { name: 'Start maintenance mode' }).click();
|
await page.getByRole('button', { name: 'Switch to maintenance mode' }).click();
|
||||||
|
|
||||||
await expect(page.getByText('Temporarily Unavailable')).toBeVisible({ timeout: 10_000 });
|
await expect(page.getByText('Temporarily Unavailable')).toBeVisible({ timeout: 10_000 });
|
||||||
await page.getByRole('button', { name: 'End maintenance mode' }).click();
|
await page.getByRole('button', { name: 'End maintenance mode' }).click();
|
||||||
await page.waitForURL('**/admin/system-settings*', { timeout: 10_000 });
|
await page.waitForURL('**/admin/maintenance*', { timeout: 10_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('maintenance shows no options to users until they authenticate', async ({ page }) => {
|
test('maintenance shows no options to users until they authenticate', async ({ page }) => {
|
||||||
|
|||||||
33
i18n/en.json
33
i18n/en.json
@@ -181,10 +181,19 @@
|
|||||||
"machine_learning_smart_search_enabled": "Enable smart search",
|
"machine_learning_smart_search_enabled": "Enable smart search",
|
||||||
"machine_learning_smart_search_enabled_description": "If disabled, images will not be encoded for smart search.",
|
"machine_learning_smart_search_enabled_description": "If disabled, images will not be encoded for smart search.",
|
||||||
"machine_learning_url_description": "The URL of the machine learning server. If more than one URL is provided, each server will be attempted one-at-a-time until one responds successfully, in order from first to last. Servers that don't respond will be temporarily ignored until they come back online.",
|
"machine_learning_url_description": "The URL of the machine learning server. If more than one URL is provided, each server will be attempted one-at-a-time until one responds successfully, in order from first to last. Servers that don't respond will be temporarily ignored until they come back online.",
|
||||||
|
"maintenance_delete_backup": "Delete Backup",
|
||||||
|
"maintenance_delete_backup_description": "This file will be irrevocably deleted.",
|
||||||
|
"maintenance_delete_error": "Failed to delete backup.",
|
||||||
|
"maintenance_restore_backup": "Restore Backup",
|
||||||
|
"maintenance_restore_backup_description": "Immich will be wiped and restored from the chosen backup. A backup will be created before continuing.",
|
||||||
|
"maintenance_restore_database_backup": "Restore database backup",
|
||||||
|
"maintenance_restore_database_backup_description": "Rollback to an earlier database state using a backup file",
|
||||||
"maintenance_settings": "Maintenance",
|
"maintenance_settings": "Maintenance",
|
||||||
"maintenance_settings_description": "Put Immich into maintenance mode.",
|
"maintenance_settings_description": "Put Immich into maintenance mode.",
|
||||||
"maintenance_start": "Start maintenance mode",
|
"maintenance_start": "Switch to maintenance mode",
|
||||||
"maintenance_start_error": "Failed to start maintenance mode.",
|
"maintenance_start_error": "Failed to start maintenance mode.",
|
||||||
|
"maintenance_upload_backup": "Upload database backup file",
|
||||||
|
"maintenance_upload_backup_error": "Could not upload backup, is it an .sql/.sql.gz file?",
|
||||||
"manage_concurrency": "Manage Concurrency",
|
"manage_concurrency": "Manage Concurrency",
|
||||||
"manage_concurrency_description": "Navigate to the jobs page to manage job concurrency",
|
"manage_concurrency_description": "Navigate to the jobs page to manage job concurrency",
|
||||||
"manage_log_settings": "Manage log settings",
|
"manage_log_settings": "Manage log settings",
|
||||||
@@ -803,6 +812,12 @@
|
|||||||
"create_user": "Create user",
|
"create_user": "Create user",
|
||||||
"created": "Created",
|
"created": "Created",
|
||||||
"created_at": "Created",
|
"created_at": "Created",
|
||||||
|
"created_day_ago": "Created 1 day ago",
|
||||||
|
"created_days_ago": "Created {count} days ago",
|
||||||
|
"created_hour_ago": "Created 1 hour ago",
|
||||||
|
"created_hours_ago": "Created {count} hours ago",
|
||||||
|
"created_minute_ago": "Created 1 minute ago",
|
||||||
|
"created_minutes_ago": "Created {count} minutes ago",
|
||||||
"creating_linked_albums": "Creating linked albums...",
|
"creating_linked_albums": "Creating linked albums...",
|
||||||
"crop": "Crop",
|
"crop": "Crop",
|
||||||
"curated_object_page_title": "Things",
|
"curated_object_page_title": "Things",
|
||||||
@@ -1343,10 +1358,26 @@
|
|||||||
"loop_videos_description": "Enable to automatically loop a video in the detail viewer.",
|
"loop_videos_description": "Enable to automatically loop a video in the detail viewer.",
|
||||||
"main_branch_warning": "You're using a development version; we strongly recommend using a release version!",
|
"main_branch_warning": "You're using a development version; we strongly recommend using a release version!",
|
||||||
"main_menu": "Main menu",
|
"main_menu": "Main menu",
|
||||||
|
"maintenance_action_restore": "Restoring Database",
|
||||||
"maintenance_description": "Immich has been put into <link>maintenance mode</link>.",
|
"maintenance_description": "Immich has been put into <link>maintenance mode</link>.",
|
||||||
"maintenance_end": "End maintenance mode",
|
"maintenance_end": "End maintenance mode",
|
||||||
"maintenance_end_error": "Failed to end maintenance mode.",
|
"maintenance_end_error": "Failed to end maintenance mode.",
|
||||||
"maintenance_logged_in_as": "Currently logged in as {user}",
|
"maintenance_logged_in_as": "Currently logged in as {user}",
|
||||||
|
"maintenance_restore_from_backup": "Restore From Backup",
|
||||||
|
"maintenance_restore_library": "Restore Your Library",
|
||||||
|
"maintenance_restore_library_confirm": "If this looks correct, continue to restoring a backup!",
|
||||||
|
"maintenance_restore_library_description": "Restoring Database",
|
||||||
|
"maintenance_restore_library_folder_has_files": "{folder} has {count} folder(s)",
|
||||||
|
"maintenance_restore_library_folder_no_files": "{folder} is missing files!",
|
||||||
|
"maintenance_restore_library_folder_pass": "readable and writable",
|
||||||
|
"maintenance_restore_library_folder_read_fail": "not readable",
|
||||||
|
"maintenance_restore_library_folder_write_fail": "not writable",
|
||||||
|
"maintenance_restore_library_hint_missing_files": "You may be missing important files",
|
||||||
|
"maintenance_restore_library_hint_regenerate_later": "You can regenerate these later in settings",
|
||||||
|
"maintenance_restore_library_hint_storage_template_missing_files": "Using storage template? You may be missing files",
|
||||||
|
"maintenance_restore_library_loading": "Loading integrity checks and heuristics…",
|
||||||
|
"maintenance_task_backup": "Creating a backup of the existing database…",
|
||||||
|
"maintenance_task_restore": "Restoring the chosen backup…",
|
||||||
"maintenance_title": "Temporarily Unavailable",
|
"maintenance_title": "Temporarily Unavailable",
|
||||||
"make": "Make",
|
"make": "Make",
|
||||||
"manage_geolocation": "Manage location",
|
"manage_geolocation": "Manage location",
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
|||||||
|
|
||||||
class AdvancedInfoActionButton extends ConsumerWidget {
|
class AdvancedInfoActionButton extends ConsumerWidget {
|
||||||
final ActionSource source;
|
final ActionSource source;
|
||||||
|
final bool iconOnly;
|
||||||
|
final bool menuItem;
|
||||||
|
|
||||||
const AdvancedInfoActionButton({super.key, required this.source});
|
const AdvancedInfoActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
@@ -26,6 +28,8 @@ class AdvancedInfoActionButton extends ConsumerWidget {
|
|||||||
maxWidth: 115.0,
|
maxWidth: 115.0,
|
||||||
iconData: Icons.help_outline_rounded,
|
iconData: Icons.help_outline_rounded,
|
||||||
label: "troubleshoot".t(context: context),
|
label: "troubleshoot".t(context: context),
|
||||||
|
iconOnly: iconOnly,
|
||||||
|
menuItem: menuItem,
|
||||||
onPressed: () => _onTap(context, ref),
|
onPressed: () => _onTap(context, ref),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,8 +35,10 @@ Future<void> performArchiveAction(BuildContext context, WidgetRef ref, {required
|
|||||||
|
|
||||||
class ArchiveActionButton extends ConsumerWidget {
|
class ArchiveActionButton extends ConsumerWidget {
|
||||||
final ActionSource source;
|
final ActionSource source;
|
||||||
|
final bool iconOnly;
|
||||||
|
final bool menuItem;
|
||||||
|
|
||||||
const ArchiveActionButton({super.key, required this.source});
|
const ArchiveActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||||
|
|
||||||
Future<void> _onTap(BuildContext context, WidgetRef ref) async {
|
Future<void> _onTap(BuildContext context, WidgetRef ref) async {
|
||||||
await performArchiveAction(context, ref, source: source);
|
await performArchiveAction(context, ref, source: source);
|
||||||
@@ -47,6 +49,8 @@ class ArchiveActionButton extends ConsumerWidget {
|
|||||||
return BaseActionButton(
|
return BaseActionButton(
|
||||||
iconData: Icons.archive_outlined,
|
iconData: Icons.archive_outlined,
|
||||||
label: "to_archive".t(context: context),
|
label: "to_archive".t(context: context),
|
||||||
|
iconOnly: iconOnly,
|
||||||
|
menuItem: menuItem,
|
||||||
onPressed: () => _onTap(context, ref),
|
onPressed: () => _onTap(context, ref),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,15 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|||||||
class DeleteActionButton extends ConsumerWidget {
|
class DeleteActionButton extends ConsumerWidget {
|
||||||
final ActionSource source;
|
final ActionSource source;
|
||||||
final bool showConfirmation;
|
final bool showConfirmation;
|
||||||
const DeleteActionButton({super.key, required this.source, this.showConfirmation = false});
|
final bool iconOnly;
|
||||||
|
final bool menuItem;
|
||||||
|
const DeleteActionButton({
|
||||||
|
super.key,
|
||||||
|
required this.source,
|
||||||
|
this.showConfirmation = false,
|
||||||
|
this.iconOnly = false,
|
||||||
|
this.menuItem = false,
|
||||||
|
});
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
@@ -74,6 +82,8 @@ class DeleteActionButton extends ConsumerWidget {
|
|||||||
maxWidth: 110.0,
|
maxWidth: 110.0,
|
||||||
iconData: Icons.delete_sweep_outlined,
|
iconData: Icons.delete_sweep_outlined,
|
||||||
label: "delete".t(context: context),
|
label: "delete".t(context: context),
|
||||||
|
iconOnly: iconOnly,
|
||||||
|
menuItem: menuItem,
|
||||||
onPressed: () => _onTap(context, ref),
|
onPressed: () => _onTap(context, ref),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|||||||
/// - Prompt to delete the asset locally
|
/// - Prompt to delete the asset locally
|
||||||
class DeleteLocalActionButton extends ConsumerWidget {
|
class DeleteLocalActionButton extends ConsumerWidget {
|
||||||
final ActionSource source;
|
final ActionSource source;
|
||||||
|
final bool iconOnly;
|
||||||
|
final bool menuItem;
|
||||||
|
|
||||||
const DeleteLocalActionButton({super.key, required this.source});
|
const DeleteLocalActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
@@ -55,6 +57,8 @@ class DeleteLocalActionButton extends ConsumerWidget {
|
|||||||
maxWidth: 95.0,
|
maxWidth: 95.0,
|
||||||
iconData: Icons.no_cell_outlined,
|
iconData: Icons.no_cell_outlined,
|
||||||
label: "control_bottom_app_bar_delete_from_local".t(context: context),
|
label: "control_bottom_app_bar_delete_from_local".t(context: context),
|
||||||
|
iconOnly: iconOnly,
|
||||||
|
menuItem: menuItem,
|
||||||
onPressed: () => _onTap(context, ref),
|
onPressed: () => _onTap(context, ref),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|||||||
/// - Prompt to delete the asset locally
|
/// - Prompt to delete the asset locally
|
||||||
class DeletePermanentActionButton extends ConsumerWidget {
|
class DeletePermanentActionButton extends ConsumerWidget {
|
||||||
final ActionSource source;
|
final ActionSource source;
|
||||||
|
final bool iconOnly;
|
||||||
|
final bool menuItem;
|
||||||
|
|
||||||
const DeletePermanentActionButton({super.key, required this.source});
|
const DeletePermanentActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
@@ -51,6 +53,8 @@ class DeletePermanentActionButton extends ConsumerWidget {
|
|||||||
maxWidth: 110.0,
|
maxWidth: 110.0,
|
||||||
iconData: Icons.delete_forever,
|
iconData: Icons.delete_forever,
|
||||||
label: "delete_permanently".t(context: context),
|
label: "delete_permanently".t(context: context),
|
||||||
|
iconOnly: iconOnly,
|
||||||
|
menuItem: menuItem,
|
||||||
onPressed: () => _onTap(context, ref),
|
onPressed: () => _onTap(context, ref),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class LikeActivityActionButton extends ConsumerWidget {
|
|||||||
|
|
||||||
return BaseActionButton(
|
return BaseActionButton(
|
||||||
maxWidth: 60,
|
maxWidth: 60,
|
||||||
iconData: liked != null ? Icons.favorite : Icons.favorite_border,
|
iconData: liked != null ? Icons.thumb_up : Icons.thumb_up_off_alt,
|
||||||
label: "like".t(context: context),
|
label: "like".t(context: context),
|
||||||
onPressed: () => onTap(liked),
|
onPressed: () => onTap(liked),
|
||||||
iconOnly: iconOnly,
|
iconOnly: iconOnly,
|
||||||
@@ -57,7 +57,7 @@ class LikeActivityActionButton extends ConsumerWidget {
|
|||||||
|
|
||||||
// default to empty heart during loading
|
// default to empty heart during loading
|
||||||
loading: () => BaseActionButton(
|
loading: () => BaseActionButton(
|
||||||
iconData: Icons.favorite_border,
|
iconData: Icons.thumb_up_off_alt,
|
||||||
label: "like".t(context: context),
|
label: "like".t(context: context),
|
||||||
iconOnly: iconOnly,
|
iconOnly: iconOnly,
|
||||||
menuItem: menuItem,
|
menuItem: menuItem,
|
||||||
|
|||||||
@@ -38,8 +38,10 @@ Future<void> performMoveToLockFolderAction(BuildContext context, WidgetRef ref,
|
|||||||
|
|
||||||
class MoveToLockFolderActionButton extends ConsumerWidget {
|
class MoveToLockFolderActionButton extends ConsumerWidget {
|
||||||
final ActionSource source;
|
final ActionSource source;
|
||||||
|
final bool iconOnly;
|
||||||
|
final bool menuItem;
|
||||||
|
|
||||||
const MoveToLockFolderActionButton({super.key, required this.source});
|
const MoveToLockFolderActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||||
|
|
||||||
Future<void> _onTap(BuildContext context, WidgetRef ref) async {
|
Future<void> _onTap(BuildContext context, WidgetRef ref) async {
|
||||||
await performMoveToLockFolderAction(context, ref, source: source);
|
await performMoveToLockFolderAction(context, ref, source: source);
|
||||||
@@ -51,6 +53,8 @@ class MoveToLockFolderActionButton extends ConsumerWidget {
|
|||||||
maxWidth: 115.0,
|
maxWidth: 115.0,
|
||||||
iconData: Icons.lock_outline_rounded,
|
iconData: Icons.lock_outline_rounded,
|
||||||
label: "move_to_locked_folder".t(context: context),
|
label: "move_to_locked_folder".t(context: context),
|
||||||
|
iconOnly: iconOnly,
|
||||||
|
menuItem: menuItem,
|
||||||
onPressed: () => _onTap(context, ref),
|
onPressed: () => _onTap(context, ref),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||||
@@ -11,8 +13,16 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|||||||
class RemoveFromAlbumActionButton extends ConsumerWidget {
|
class RemoveFromAlbumActionButton extends ConsumerWidget {
|
||||||
final String albumId;
|
final String albumId;
|
||||||
final ActionSource source;
|
final ActionSource source;
|
||||||
|
final bool iconOnly;
|
||||||
|
final bool menuItem;
|
||||||
|
|
||||||
const RemoveFromAlbumActionButton({super.key, required this.albumId, required this.source});
|
const RemoveFromAlbumActionButton({
|
||||||
|
super.key,
|
||||||
|
required this.albumId,
|
||||||
|
required this.source,
|
||||||
|
this.iconOnly = false,
|
||||||
|
this.menuItem = false,
|
||||||
|
});
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
@@ -22,6 +32,10 @@ class RemoveFromAlbumActionButton extends ConsumerWidget {
|
|||||||
final result = await ref.read(actionProvider.notifier).removeFromAlbum(source, albumId);
|
final result = await ref.read(actionProvider.notifier).removeFromAlbum(source, albumId);
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
ref.read(multiSelectProvider.notifier).reset();
|
||||||
|
|
||||||
|
if (source == ActionSource.viewer) {
|
||||||
|
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
||||||
|
}
|
||||||
|
|
||||||
final successMessage = 'remove_from_album_action_prompt'.t(
|
final successMessage = 'remove_from_album_action_prompt'.t(
|
||||||
context: context,
|
context: context,
|
||||||
args: {'count': result.count.toString()},
|
args: {'count': result.count.toString()},
|
||||||
@@ -42,6 +56,8 @@ class RemoveFromAlbumActionButton extends ConsumerWidget {
|
|||||||
return BaseActionButton(
|
return BaseActionButton(
|
||||||
iconData: Icons.remove_circle_outline,
|
iconData: Icons.remove_circle_outline,
|
||||||
label: "remove_from_album".t(context: context),
|
label: "remove_from_album".t(context: context),
|
||||||
|
iconOnly: iconOnly,
|
||||||
|
menuItem: menuItem,
|
||||||
onPressed: () => _onTap(context, ref),
|
onPressed: () => _onTap(context, ref),
|
||||||
maxWidth: 100,
|
maxWidth: 100,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,8 +10,15 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|||||||
|
|
||||||
class RemoveFromLockFolderActionButton extends ConsumerWidget {
|
class RemoveFromLockFolderActionButton extends ConsumerWidget {
|
||||||
final ActionSource source;
|
final ActionSource source;
|
||||||
|
final bool iconOnly;
|
||||||
|
final bool menuItem;
|
||||||
|
|
||||||
const RemoveFromLockFolderActionButton({super.key, required this.source});
|
const RemoveFromLockFolderActionButton({
|
||||||
|
super.key,
|
||||||
|
required this.source,
|
||||||
|
this.iconOnly = false,
|
||||||
|
this.menuItem = false,
|
||||||
|
});
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
@@ -42,6 +49,8 @@ class RemoveFromLockFolderActionButton extends ConsumerWidget {
|
|||||||
maxWidth: 100.0,
|
maxWidth: 100.0,
|
||||||
iconData: Icons.lock_open_rounded,
|
iconData: Icons.lock_open_rounded,
|
||||||
label: "remove_from_locked_folder".t(context: context),
|
label: "remove_from_locked_folder".t(context: context),
|
||||||
|
iconOnly: iconOnly,
|
||||||
|
menuItem: menuItem,
|
||||||
onPressed: () => _onTap(context, ref),
|
onPressed: () => _onTap(context, ref),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,8 +31,10 @@ class _SharePreparingDialog extends StatelessWidget {
|
|||||||
|
|
||||||
class ShareActionButton extends ConsumerWidget {
|
class ShareActionButton extends ConsumerWidget {
|
||||||
final ActionSource source;
|
final ActionSource source;
|
||||||
|
final bool iconOnly;
|
||||||
|
final bool menuItem;
|
||||||
|
|
||||||
const ShareActionButton({super.key, required this.source});
|
const ShareActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
@@ -74,6 +76,8 @@ class ShareActionButton extends ConsumerWidget {
|
|||||||
return BaseActionButton(
|
return BaseActionButton(
|
||||||
iconData: Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded,
|
iconData: Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded,
|
||||||
label: 'share'.t(context: context),
|
label: 'share'.t(context: context),
|
||||||
|
iconOnly: iconOnly,
|
||||||
|
menuItem: menuItem,
|
||||||
onPressed: () => _onTap(context, ref),
|
onPressed: () => _onTap(context, ref),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
|||||||
|
|
||||||
class ShareLinkActionButton extends ConsumerWidget {
|
class ShareLinkActionButton extends ConsumerWidget {
|
||||||
final ActionSource source;
|
final ActionSource source;
|
||||||
|
final bool iconOnly;
|
||||||
|
final bool menuItem;
|
||||||
|
|
||||||
const ShareLinkActionButton({super.key, required this.source});
|
const ShareLinkActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||||
|
|
||||||
_onTap(BuildContext context, WidgetRef ref) async {
|
_onTap(BuildContext context, WidgetRef ref) async {
|
||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
@@ -23,6 +25,8 @@ class ShareLinkActionButton extends ConsumerWidget {
|
|||||||
return BaseActionButton(
|
return BaseActionButton(
|
||||||
iconData: Icons.link_rounded,
|
iconData: Icons.link_rounded,
|
||||||
label: "share_link".t(context: context),
|
label: "share_link".t(context: context),
|
||||||
|
iconOnly: iconOnly,
|
||||||
|
menuItem: menuItem,
|
||||||
onPressed: () => _onTap(context, ref),
|
onPressed: () => _onTap(context, ref),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ import 'package:immich_mobile/routing/router.dart';
|
|||||||
|
|
||||||
class SimilarPhotosActionButton extends ConsumerWidget {
|
class SimilarPhotosActionButton extends ConsumerWidget {
|
||||||
final String assetId;
|
final String assetId;
|
||||||
|
final bool iconOnly;
|
||||||
|
final bool menuItem;
|
||||||
|
|
||||||
const SimilarPhotosActionButton({super.key, required this.assetId});
|
const SimilarPhotosActionButton({super.key, required this.assetId, this.iconOnly = false, this.menuItem = false});
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
@@ -44,6 +46,8 @@ class SimilarPhotosActionButton extends ConsumerWidget {
|
|||||||
return BaseActionButton(
|
return BaseActionButton(
|
||||||
iconData: Icons.compare,
|
iconData: Icons.compare,
|
||||||
label: "view_similar_photos".t(context: context),
|
label: "view_similar_photos".t(context: context),
|
||||||
|
iconOnly: iconOnly,
|
||||||
|
menuItem: menuItem,
|
||||||
onPressed: () => _onTap(context, ref),
|
onPressed: () => _onTap(context, ref),
|
||||||
maxWidth: 100,
|
maxWidth: 100,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|||||||
/// which will be permanently deleted after the number of days configure by the admin
|
/// which will be permanently deleted after the number of days configure by the admin
|
||||||
class TrashActionButton extends ConsumerWidget {
|
class TrashActionButton extends ConsumerWidget {
|
||||||
final ActionSource source;
|
final ActionSource source;
|
||||||
|
final bool iconOnly;
|
||||||
|
final bool menuItem;
|
||||||
|
|
||||||
const TrashActionButton({super.key, required this.source});
|
const TrashActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
@@ -48,6 +50,8 @@ class TrashActionButton extends ConsumerWidget {
|
|||||||
maxWidth: 85.0,
|
maxWidth: 85.0,
|
||||||
iconData: Icons.delete_outline_rounded,
|
iconData: Icons.delete_outline_rounded,
|
||||||
label: "control_bottom_app_bar_trash_from_immich".t(context: context),
|
label: "control_bottom_app_bar_trash_from_immich".t(context: context),
|
||||||
|
iconOnly: iconOnly,
|
||||||
|
menuItem: menuItem,
|
||||||
onPressed: () => _onTap(context, ref),
|
onPressed: () => _onTap(context, ref),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,8 +37,10 @@ Future<void> performUnArchiveAction(BuildContext context, WidgetRef ref, {requir
|
|||||||
|
|
||||||
class UnArchiveActionButton extends ConsumerWidget {
|
class UnArchiveActionButton extends ConsumerWidget {
|
||||||
final ActionSource source;
|
final ActionSource source;
|
||||||
|
final bool iconOnly;
|
||||||
|
final bool menuItem;
|
||||||
|
|
||||||
const UnArchiveActionButton({super.key, required this.source});
|
const UnArchiveActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||||
|
|
||||||
Future<void> _onTap(BuildContext context, WidgetRef ref) async {
|
Future<void> _onTap(BuildContext context, WidgetRef ref) async {
|
||||||
await performUnArchiveAction(context, ref, source: source);
|
await performUnArchiveAction(context, ref, source: source);
|
||||||
@@ -49,6 +51,8 @@ class UnArchiveActionButton extends ConsumerWidget {
|
|||||||
return BaseActionButton(
|
return BaseActionButton(
|
||||||
iconData: Icons.unarchive_outlined,
|
iconData: Icons.unarchive_outlined,
|
||||||
label: "unarchive".t(context: context),
|
label: "unarchive".t(context: context),
|
||||||
|
iconOnly: iconOnly,
|
||||||
|
menuItem: menuItem,
|
||||||
onPressed: () => _onTap(context, ref),
|
onPressed: () => _onTap(context, ref),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|||||||
|
|
||||||
class UnStackActionButton extends ConsumerWidget {
|
class UnStackActionButton extends ConsumerWidget {
|
||||||
final ActionSource source;
|
final ActionSource source;
|
||||||
|
final bool iconOnly;
|
||||||
|
final bool menuItem;
|
||||||
|
|
||||||
const UnStackActionButton({super.key, required this.source});
|
const UnStackActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
@@ -38,6 +40,8 @@ class UnStackActionButton extends ConsumerWidget {
|
|||||||
return BaseActionButton(
|
return BaseActionButton(
|
||||||
iconData: Icons.layers_clear_outlined,
|
iconData: Icons.layers_clear_outlined,
|
||||||
label: "unstack".t(context: context),
|
label: "unstack".t(context: context),
|
||||||
|
iconOnly: iconOnly,
|
||||||
|
menuItem: menuItem,
|
||||||
onPressed: () => _onTap(context, ref),
|
onPressed: () => _onTap(context, ref),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|||||||
|
|
||||||
class UploadActionButton extends ConsumerWidget {
|
class UploadActionButton extends ConsumerWidget {
|
||||||
final ActionSource source;
|
final ActionSource source;
|
||||||
|
final bool iconOnly;
|
||||||
|
final bool menuItem;
|
||||||
|
|
||||||
const UploadActionButton({super.key, required this.source});
|
const UploadActionButton({super.key, required this.source, this.iconOnly = false, this.menuItem = false});
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
@@ -39,6 +41,8 @@ class UploadActionButton extends ConsumerWidget {
|
|||||||
return BaseActionButton(
|
return BaseActionButton(
|
||||||
iconData: Icons.backup_outlined,
|
iconData: Icons.backup_outlined,
|
||||||
label: "upload".t(context: context),
|
label: "upload".t(context: context),
|
||||||
|
iconOnly: iconOnly,
|
||||||
|
menuItem: menuItem,
|
||||||
onPressed: () => _onTap(context, ref),
|
onPressed: () => _onTap(context, ref),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ class ActivitiesBottomSheet extends HookConsumerWidget {
|
|||||||
expand: false,
|
expand: false,
|
||||||
shouldCloseOnMinExtent: false,
|
shouldCloseOnMinExtent: false,
|
||||||
resizeOnScroll: false,
|
resizeOnScroll: false,
|
||||||
backgroundColor: context.isDarkTheme ? Colors.black : Colors.white,
|
backgroundColor: context.isDarkTheme ? context.colorScheme.surface : Colors.white,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ class AssetViewer extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const double _kBottomSheetMinimumExtent = 0.4;
|
const double _kBottomSheetMinimumExtent = 0.4;
|
||||||
const double _kBottomSheetSnapExtent = 0.7;
|
const double _kBottomSheetSnapExtent = 0.67;
|
||||||
|
|
||||||
class _AssetViewerState extends ConsumerState<AssetViewer> {
|
class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||||
static final _dummyListener = ImageStreamListener((image, _) => image.dispose());
|
static final _dummyListener = ImageStreamListener((image, _) => image.dispose());
|
||||||
@@ -399,10 +399,14 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
final isDraggingDown = currentExtent < previousExtent;
|
final isDraggingDown = currentExtent < previousExtent;
|
||||||
previousExtent = currentExtent;
|
previousExtent = currentExtent;
|
||||||
// Closes the bottom sheet if the user is dragging down
|
// Closes the bottom sheet if the user is dragging down
|
||||||
if (isDraggingDown && delta.extent < 0.55) {
|
if (isDraggingDown && delta.extent < 0.67) {
|
||||||
if (dragInProgress) {
|
if (dragInProgress) {
|
||||||
blockGestures = true;
|
blockGestures = true;
|
||||||
}
|
}
|
||||||
|
// Jump to a lower position before starting close animation to prevent glitch
|
||||||
|
if (bottomSheetController.isAttached) {
|
||||||
|
bottomSheetController.jumpTo(0.67);
|
||||||
|
}
|
||||||
sheetCloseController?.close();
|
sheetCloseController?.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,7 +484,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
previousExtent = _kBottomSheetMinimumExtent;
|
previousExtent = _kBottomSheetMinimumExtent;
|
||||||
sheetCloseController = showBottomSheet(
|
sheetCloseController = showBottomSheet(
|
||||||
context: ctx,
|
context: ctx,
|
||||||
sheetAnimationStyle: const AnimationStyle(duration: Durations.short4, reverseDuration: Durations.short2),
|
sheetAnimationStyle: const AnimationStyle(duration: Durations.medium2, reverseDuration: Durations.medium2),
|
||||||
constraints: const BoxConstraints(maxWidth: double.infinity),
|
constraints: const BoxConstraints(maxWidth: double.infinity),
|
||||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20.0))),
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20.0))),
|
||||||
backgroundColor: ctx.colorScheme.surfaceContainerLowest,
|
backgroundColor: ctx.colorScheme.surfaceContainerLowest,
|
||||||
@@ -688,16 +692,20 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
backgroundDecoration: BoxDecoration(color: backgroundColor),
|
backgroundDecoration: BoxDecoration(color: backgroundColor),
|
||||||
enablePanAlways: true,
|
enablePanAlways: true,
|
||||||
),
|
),
|
||||||
|
if (!showingBottomSheet)
|
||||||
|
const Positioned(
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [AssetStackRow(), ViewerBottomBar()],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
bottomNavigationBar: showingBottomSheet
|
|
||||||
? const SizedBox.shrink()
|
|
||||||
: const Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [AssetStackRow(), ViewerBottomBar()],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,14 +42,17 @@ class ViewerBottomBar extends ConsumerWidget {
|
|||||||
|
|
||||||
final actions = <Widget>[
|
final actions = <Widget>[
|
||||||
const ShareActionButton(source: ActionSource.viewer),
|
const ShareActionButton(source: ActionSource.viewer),
|
||||||
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
|
|
||||||
if (asset.type == AssetType.image) const EditImageActionButton(),
|
|
||||||
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
|
|
||||||
|
|
||||||
if (isOwner) ...[
|
if (!isInLockedView) ...[
|
||||||
asset.isLocalOnly
|
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
|
||||||
? const DeleteLocalActionButton(source: ActionSource.viewer)
|
if (asset.type == AssetType.image) const EditImageActionButton(),
|
||||||
: const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
|
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
|
||||||
|
|
||||||
|
if (isOwner) ...[
|
||||||
|
asset.isLocalOnly
|
||||||
|
? const DeleteLocalActionButton(source: ActionSource.viewer)
|
||||||
|
: const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -76,7 +79,7 @@ class ViewerBottomBar extends ConsumerWidget {
|
|||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
if (asset.isVideo) const VideoControls(),
|
if (asset.isVideo) const VideoControls(),
|
||||||
if (!isInLockedView && !isReadonlyModeEnabled)
|
if (!isReadonlyModeEnabled)
|
||||||
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
|
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/setting.model.dart';
|
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
@@ -21,14 +20,9 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_shee
|
|||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/utils/action_button.utils.dart';
|
|
||||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||||
import 'package:immich_mobile/utils/timezone.dart';
|
import 'package:immich_mobile/utils/timezone.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
@@ -48,29 +42,8 @@ class AssetDetailBottomSheet extends ConsumerWidget {
|
|||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
|
|
||||||
final isOwner = asset is RemoteAsset && asset.ownerId == ref.watch(currentUserProvider)?.id;
|
|
||||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
|
||||||
final currentAlbum = ref.watch(currentRemoteAlbumProvider);
|
|
||||||
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
|
|
||||||
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
|
|
||||||
|
|
||||||
final buttonContext = ActionButtonContext(
|
|
||||||
asset: asset,
|
|
||||||
isOwner: isOwner,
|
|
||||||
isArchived: isArchived,
|
|
||||||
isTrashEnabled: isTrashEnable,
|
|
||||||
isInLockedView: isInLockedView,
|
|
||||||
isStacked: asset is RemoteAsset && asset.stackId != null,
|
|
||||||
currentAlbum: currentAlbum,
|
|
||||||
advancedTroubleshooting: advancedTroubleshooting,
|
|
||||||
source: ActionSource.viewer,
|
|
||||||
);
|
|
||||||
|
|
||||||
final actions = ActionButtonBuilder.build(buttonContext);
|
|
||||||
|
|
||||||
return BaseBottomSheet(
|
return BaseBottomSheet(
|
||||||
actions: actions,
|
actions: [],
|
||||||
slivers: const [_AssetDetailBottomSheet()],
|
slivers: const [_AssetDetailBottomSheet()],
|
||||||
controller: controller,
|
controller: controller,
|
||||||
initialChildSize: initialChildSize,
|
initialChildSize: initialChildSize,
|
||||||
@@ -79,7 +52,7 @@ class AssetDetailBottomSheet extends ConsumerWidget {
|
|||||||
expand: false,
|
expand: false,
|
||||||
shouldCloseOnMinExtent: false,
|
shouldCloseOnMinExtent: false,
|
||||||
resizeOnScroll: false,
|
resizeOnScroll: false,
|
||||||
backgroundColor: context.isDarkTheme ? Colors.black : Colors.white,
|
backgroundColor: context.isDarkTheme ? context.colorScheme.surface : Colors.white,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -326,7 +299,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
|||||||
// Appears in (Albums)
|
// Appears in (Albums)
|
||||||
Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)),
|
Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)),
|
||||||
// padding at the bottom to avoid cut-off
|
// padding at the bottom to avoid cut-off
|
||||||
const SizedBox(height: 100),
|
const SizedBox(height: 30),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/setting.model.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/utils/action_button.utils.dart';
|
import 'package:immich_mobile/utils/action_button.utils.dart';
|
||||||
|
|
||||||
@@ -24,16 +30,28 @@ class ViewerKebabMenu extends ConsumerWidget {
|
|||||||
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
||||||
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
||||||
final timelineOrigin = ref.read(timelineServiceProvider).origin;
|
final timelineOrigin = ref.read(timelineServiceProvider).origin;
|
||||||
|
final isTrashEnable = ref.watch(serverInfoProvider.select((state) => state.serverFeatures.trash));
|
||||||
|
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||||
|
final currentAlbum = ref.watch(currentRemoteAlbumProvider);
|
||||||
|
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
|
||||||
|
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
|
||||||
|
|
||||||
final kebabContext = ViewerKebabMenuButtonContext(
|
final actionContext = ActionButtonContext(
|
||||||
asset: asset,
|
asset: asset,
|
||||||
isOwner: isOwner,
|
isOwner: isOwner,
|
||||||
|
isArchived: isArchived,
|
||||||
|
isTrashEnabled: isTrashEnable,
|
||||||
|
isStacked: asset is RemoteAsset && asset.stackId != null,
|
||||||
|
isInLockedView: isInLockedView,
|
||||||
|
currentAlbum: currentAlbum,
|
||||||
|
advancedTroubleshooting: advancedTroubleshooting,
|
||||||
|
source: ActionSource.viewer,
|
||||||
isCasting: isCasting,
|
isCasting: isCasting,
|
||||||
timelineOrigin: timelineOrigin,
|
timelineOrigin: timelineOrigin,
|
||||||
originalTheme: originalTheme,
|
originalTheme: originalTheme,
|
||||||
);
|
);
|
||||||
|
|
||||||
final menuChildren = ViewerKebabMenuButtonBuilder.build(kebabContext, context, ref);
|
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref);
|
||||||
|
|
||||||
return MenuAnchor(
|
return MenuAnchor(
|
||||||
consumeOutsideTap: true,
|
consumeOutsideTap: true,
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_b
|
|||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
@@ -47,10 +46,7 @@ class ArchiveBottomSheet extends ConsumerWidget {
|
|||||||
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
|
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
|
||||||
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
|
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
|
||||||
],
|
],
|
||||||
if (multiselect.hasLocal) ...[
|
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
|
||||||
const DeleteLocalActionButton(source: ActionSource.timeline),
|
|
||||||
const UploadActionButton(source: ActionSource.timeline),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ class _BaseDraggableScrollableSheetState extends ConsumerState<BaseBottomSheet>
|
|||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
slivers: [
|
slivers: [
|
||||||
const SliverPersistentHeader(delegate: _DragHandleDelegate(), pinned: true),
|
const SliverToBoxAdapter(child: _DragHandle()),
|
||||||
if (widget.actions.isNotEmpty)
|
if (widget.actions.isNotEmpty)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -108,31 +108,13 @@ class _BaseDraggableScrollableSheetState extends ConsumerState<BaseBottomSheet>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DragHandleDelegate extends SliverPersistentHeaderDelegate {
|
|
||||||
const _DragHandleDelegate();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
|
|
||||||
return const _DragHandle();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool shouldRebuild(_DragHandleDelegate oldDelegate) => false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
double get minExtent => 50.0;
|
|
||||||
|
|
||||||
@override
|
|
||||||
double get maxExtent => 50.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DragHandle extends StatelessWidget {
|
class _DragHandle extends StatelessWidget {
|
||||||
const _DragHandle();
|
const _DragHandle();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 50,
|
height: 38,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 32,
|
width: 32,
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_b
|
|||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||||
@@ -86,10 +85,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
|
|||||||
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
|
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
|
||||||
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
|
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
|
||||||
],
|
],
|
||||||
if (multiselect.hasLocal) ...[
|
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
|
||||||
const DeleteLocalActionButton(source: ActionSource.timeline),
|
|
||||||
const UploadActionButton(source: ActionSource.timeline),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
slivers: multiselect.hasRemote
|
slivers: multiselect.hasRemote
|
||||||
? [const AddToAlbumHeader(), AlbumSelector(onAlbumSelected: addAssetsToAlbum)]
|
? [const AddToAlbumHeader(), AlbumSelector(onAlbumSelected: addAssetsToAlbum)]
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_act
|
|||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/stack_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||||
@@ -112,10 +111,7 @@ class _RemoteAlbumBottomSheetState extends ConsumerState<RemoteAlbumBottomSheet>
|
|||||||
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
|
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
if (multiselect.hasLocal) ...[
|
if (multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
|
||||||
const DeleteLocalActionButton(source: ActionSource.timeline),
|
|
||||||
const UploadActionButton(source: ActionSource.timeline),
|
|
||||||
],
|
|
||||||
if (ownsAlbum) RemoveFromAlbumActionButton(source: ActionSource.timeline, albumId: widget.album.id),
|
if (ownsAlbum) RemoveFromAlbumActionButton(source: ActionSource.timeline, albumId: widget.album.id),
|
||||||
],
|
],
|
||||||
slivers: ownsAlbum
|
slivers: ownsAlbum
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import 'package:immich_mobile/routing/router.dart';
|
|||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
import 'package:immich_mobile/services/share_intent_service.dart';
|
import 'package:immich_mobile/services/share_intent_service.dart';
|
||||||
import 'package:immich_mobile/services/upload.service.dart';
|
import 'package:immich_mobile/services/upload.service.dart';
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
import 'package:path/path.dart';
|
import 'package:path/path.dart';
|
||||||
|
|
||||||
final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
|
final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
|
||||||
@@ -26,7 +25,6 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
|
|||||||
final AppRouter router;
|
final AppRouter router;
|
||||||
final UploadService _uploadService;
|
final UploadService _uploadService;
|
||||||
final ShareIntentService _shareIntentService;
|
final ShareIntentService _shareIntentService;
|
||||||
final Logger _logger = Logger('ShareIntentUploadStateNotifier');
|
|
||||||
|
|
||||||
ShareIntentUploadStateNotifier(this.router, this._uploadService, this._shareIntentService) : super([]) {
|
ShareIntentUploadStateNotifier(this.router, this._uploadService, this._shareIntentService) : super([]) {
|
||||||
_uploadService.taskStatusStream.listen(_updateUploadStatus);
|
_uploadService.taskStatusStream.listen(_updateUploadStatus);
|
||||||
@@ -88,8 +86,6 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
|
|||||||
for (final attachment in state)
|
for (final attachment in state)
|
||||||
if (attachment.id == taskId.toInt()) attachment.copyWith(status: uploadStatus) else attachment,
|
if (attachment.id == taskId.toInt()) attachment.copyWith(status: uploadStatus) else attachment,
|
||||||
];
|
];
|
||||||
|
|
||||||
_logger.fine("Upload failed for asset: ${task.task.filename}, exception: ${task.exception}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _taskProgressCallback(TaskProgressUpdate update) {
|
void _taskProgressCallback(TaskProgressUpdate update) {
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ class PersonApiRepository extends ApiRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<PersonDto> update(String id, {String? name, DateTime? birthday}) async {
|
Future<PersonDto> update(String id, {String? name, DateTime? birthday}) async {
|
||||||
final dto = await checkNull(_api.updatePerson(id, PersonUpdateDto(name: name, birthDate: birthday)));
|
final birthdayUtc = birthday == null ? null : DateTime.utc(birthday.year, birthday.month, birthday.day);
|
||||||
return _toPerson(dto);
|
final dto = PersonUpdateDto(name: name, birthDate: birthdayUtc);
|
||||||
|
final response = await checkNull(_api.updatePerson(id, dto));
|
||||||
|
return _toPerson(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
static PersonDto _toPerson(PersonResponseDto dto) => PersonDto(
|
static PersonDto _toPerson(PersonResponseDto dto) => PersonDto(
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ enum AppSettingsEnum<T> {
|
|||||||
thumbnailCacheSize<int>(StoreKey.thumbnailCacheSize, "thumbnailCacheSize", 10000),
|
thumbnailCacheSize<int>(StoreKey.thumbnailCacheSize, "thumbnailCacheSize", 10000),
|
||||||
imageCacheSize<int>(StoreKey.imageCacheSize, "imageCacheSize", 350),
|
imageCacheSize<int>(StoreKey.imageCacheSize, "imageCacheSize", 350),
|
||||||
albumThumbnailCacheSize<int>(StoreKey.albumThumbnailCacheSize, "albumThumbnailCacheSize", 200),
|
albumThumbnailCacheSize<int>(StoreKey.albumThumbnailCacheSize, "albumThumbnailCacheSize", 200),
|
||||||
selectedAlbumSortOrder<int>(StoreKey.selectedAlbumSortOrder, "selectedAlbumSortOrder", 0),
|
selectedAlbumSortOrder<int>(StoreKey.selectedAlbumSortOrder, "selectedAlbumSortOrder", 2),
|
||||||
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
|
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
|
||||||
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
|
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
|
||||||
logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5
|
logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5
|
||||||
@@ -42,7 +42,7 @@ enum AppSettingsEnum<T> {
|
|||||||
mapRelativeDate<int>(StoreKey.mapRelativeDate, null, 0),
|
mapRelativeDate<int>(StoreKey.mapRelativeDate, null, 0),
|
||||||
allowSelfSignedSSLCert<bool>(StoreKey.selfSignedCert, null, false),
|
allowSelfSignedSSLCert<bool>(StoreKey.selfSignedCert, null, false),
|
||||||
ignoreIcloudAssets<bool>(StoreKey.ignoreIcloudAssets, null, false),
|
ignoreIcloudAssets<bool>(StoreKey.ignoreIcloudAssets, null, false),
|
||||||
selectedAlbumSortReverse<bool>(StoreKey.selectedAlbumSortReverse, null, false),
|
selectedAlbumSortReverse<bool>(StoreKey.selectedAlbumSortReverse, null, true),
|
||||||
enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true),
|
enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true),
|
||||||
syncAlbums<bool>(StoreKey.syncAlbums, null, false),
|
syncAlbums<bool>(StoreKey.syncAlbums, null, false),
|
||||||
autoEndpointSwitching<bool>(StoreKey.autoEndpointSwitching, null, false),
|
autoEndpointSwitching<bool>(StoreKey.autoEndpointSwitching, null, false),
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
|||||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||||
@@ -40,6 +39,9 @@ class ActionButtonContext {
|
|||||||
final RemoteAlbum? currentAlbum;
|
final RemoteAlbum? currentAlbum;
|
||||||
final bool advancedTroubleshooting;
|
final bool advancedTroubleshooting;
|
||||||
final ActionSource source;
|
final ActionSource source;
|
||||||
|
final bool isCasting;
|
||||||
|
final TimelineOrigin timelineOrigin;
|
||||||
|
final ThemeData? originalTheme;
|
||||||
|
|
||||||
const ActionButtonContext({
|
const ActionButtonContext({
|
||||||
required this.asset,
|
required this.asset,
|
||||||
@@ -51,27 +53,33 @@ class ActionButtonContext {
|
|||||||
required this.currentAlbum,
|
required this.currentAlbum,
|
||||||
required this.advancedTroubleshooting,
|
required this.advancedTroubleshooting,
|
||||||
required this.source,
|
required this.source,
|
||||||
|
this.isCasting = false,
|
||||||
|
this.timelineOrigin = TimelineOrigin.main,
|
||||||
|
this.originalTheme,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ActionButtonType {
|
enum ActionButtonType {
|
||||||
advancedInfo,
|
openInfo,
|
||||||
|
likeActivity,
|
||||||
share,
|
share,
|
||||||
shareLink,
|
shareLink,
|
||||||
|
cast,
|
||||||
similarPhotos,
|
similarPhotos,
|
||||||
|
viewInTimeline,
|
||||||
|
download,
|
||||||
|
upload,
|
||||||
|
unstack,
|
||||||
archive,
|
archive,
|
||||||
unarchive,
|
unarchive,
|
||||||
download,
|
|
||||||
trash,
|
|
||||||
deletePermanent,
|
|
||||||
delete,
|
|
||||||
moveToLockFolder,
|
moveToLockFolder,
|
||||||
removeFromLockFolder,
|
removeFromLockFolder,
|
||||||
deleteLocal,
|
|
||||||
upload,
|
|
||||||
removeFromAlbum,
|
removeFromAlbum,
|
||||||
unstack,
|
trash,
|
||||||
likeActivity;
|
deleteLocal,
|
||||||
|
deletePermanent,
|
||||||
|
delete,
|
||||||
|
advancedInfo;
|
||||||
|
|
||||||
bool shouldShow(ActionButtonContext context) {
|
bool shouldShow(ActionButtonContext context) {
|
||||||
return switch (this) {
|
return switch (this) {
|
||||||
@@ -138,132 +146,163 @@ enum ActionButtonType {
|
|||||||
ActionButtonType.similarPhotos =>
|
ActionButtonType.similarPhotos =>
|
||||||
!context.isInLockedView && //
|
!context.isInLockedView && //
|
||||||
context.asset is RemoteAsset,
|
context.asset is RemoteAsset,
|
||||||
};
|
ActionButtonType.openInfo => true,
|
||||||
}
|
ActionButtonType.viewInTimeline =>
|
||||||
|
|
||||||
Widget buildButton(ActionButtonContext context) {
|
|
||||||
return switch (this) {
|
|
||||||
ActionButtonType.advancedInfo => AdvancedInfoActionButton(source: context.source),
|
|
||||||
ActionButtonType.share => ShareActionButton(source: context.source),
|
|
||||||
ActionButtonType.shareLink => ShareLinkActionButton(source: context.source),
|
|
||||||
ActionButtonType.archive => ArchiveActionButton(source: context.source),
|
|
||||||
ActionButtonType.unarchive => UnArchiveActionButton(source: context.source),
|
|
||||||
ActionButtonType.download => DownloadActionButton(source: context.source),
|
|
||||||
ActionButtonType.trash => TrashActionButton(source: context.source),
|
|
||||||
ActionButtonType.deletePermanent => DeletePermanentActionButton(source: context.source),
|
|
||||||
ActionButtonType.delete => DeleteActionButton(source: context.source),
|
|
||||||
ActionButtonType.moveToLockFolder => MoveToLockFolderActionButton(source: context.source),
|
|
||||||
ActionButtonType.removeFromLockFolder => RemoveFromLockFolderActionButton(source: context.source),
|
|
||||||
ActionButtonType.deleteLocal => DeleteLocalActionButton(source: context.source),
|
|
||||||
ActionButtonType.upload => UploadActionButton(source: context.source),
|
|
||||||
ActionButtonType.removeFromAlbum => RemoveFromAlbumActionButton(
|
|
||||||
albumId: context.currentAlbum!.id,
|
|
||||||
source: context.source,
|
|
||||||
),
|
|
||||||
ActionButtonType.likeActivity => const LikeActivityActionButton(),
|
|
||||||
ActionButtonType.unstack => UnStackActionButton(source: context.source),
|
|
||||||
ActionButtonType.similarPhotos => SimilarPhotosActionButton(assetId: (context.asset as RemoteAsset).id),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ActionButtonBuilder {
|
|
||||||
static const List<ActionButtonType> _actionTypes = ActionButtonType.values;
|
|
||||||
|
|
||||||
static List<Widget> build(ActionButtonContext context) {
|
|
||||||
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ViewerKebabMenuButtonContext {
|
|
||||||
final BaseAsset asset;
|
|
||||||
final bool isOwner;
|
|
||||||
final bool isCasting;
|
|
||||||
final TimelineOrigin timelineOrigin;
|
|
||||||
final ThemeData? originalTheme;
|
|
||||||
|
|
||||||
const ViewerKebabMenuButtonContext({
|
|
||||||
required this.asset,
|
|
||||||
required this.isOwner,
|
|
||||||
required this.isCasting,
|
|
||||||
required this.timelineOrigin,
|
|
||||||
this.originalTheme,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ViewerKebabMenuButtonType {
|
|
||||||
openInfo,
|
|
||||||
viewInTimeline,
|
|
||||||
cast,
|
|
||||||
download;
|
|
||||||
|
|
||||||
/// Defines which group each button belongs to.
|
|
||||||
/// Buttons in the same group will be displayed together,
|
|
||||||
/// with dividers separating different groups.
|
|
||||||
int get group => switch (this) {
|
|
||||||
ViewerKebabMenuButtonType.openInfo => 0,
|
|
||||||
ViewerKebabMenuButtonType.viewInTimeline => 1,
|
|
||||||
ViewerKebabMenuButtonType.cast => 1,
|
|
||||||
ViewerKebabMenuButtonType.download => 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
bool shouldShow(ViewerKebabMenuButtonContext context) {
|
|
||||||
return switch (this) {
|
|
||||||
ViewerKebabMenuButtonType.openInfo => true,
|
|
||||||
ViewerKebabMenuButtonType.viewInTimeline =>
|
|
||||||
context.timelineOrigin != TimelineOrigin.main &&
|
context.timelineOrigin != TimelineOrigin.main &&
|
||||||
context.timelineOrigin != TimelineOrigin.deepLink &&
|
context.timelineOrigin != TimelineOrigin.deepLink &&
|
||||||
context.timelineOrigin != TimelineOrigin.trash &&
|
context.timelineOrigin != TimelineOrigin.trash &&
|
||||||
|
context.timelineOrigin != TimelineOrigin.lockedFolder &&
|
||||||
context.timelineOrigin != TimelineOrigin.archive &&
|
context.timelineOrigin != TimelineOrigin.archive &&
|
||||||
context.timelineOrigin != TimelineOrigin.localAlbum &&
|
context.timelineOrigin != TimelineOrigin.localAlbum &&
|
||||||
context.isOwner,
|
context.isOwner,
|
||||||
ViewerKebabMenuButtonType.cast => context.isCasting || context.asset.hasRemote,
|
ActionButtonType.cast => context.isCasting || context.asset.hasRemote,
|
||||||
ViewerKebabMenuButtonType.download => context.asset.isRemoteOnly,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
ConsumerWidget buildButton(ViewerKebabMenuButtonContext context, BuildContext buildContext) {
|
ConsumerWidget buildButton(
|
||||||
|
ActionButtonContext context, [
|
||||||
|
BuildContext? buildContext,
|
||||||
|
bool iconOnly = false,
|
||||||
|
bool menuItem = false,
|
||||||
|
]) {
|
||||||
return switch (this) {
|
return switch (this) {
|
||||||
ViewerKebabMenuButtonType.openInfo => BaseActionButton(
|
ActionButtonType.advancedInfo => AdvancedInfoActionButton(
|
||||||
|
source: context.source,
|
||||||
|
iconOnly: iconOnly,
|
||||||
|
menuItem: menuItem,
|
||||||
|
),
|
||||||
|
ActionButtonType.share => ShareActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||||
|
ActionButtonType.shareLink => ShareLinkActionButton(
|
||||||
|
source: context.source,
|
||||||
|
iconOnly: iconOnly,
|
||||||
|
menuItem: menuItem,
|
||||||
|
),
|
||||||
|
ActionButtonType.archive => ArchiveActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||||
|
ActionButtonType.unarchive => UnArchiveActionButton(
|
||||||
|
source: context.source,
|
||||||
|
iconOnly: iconOnly,
|
||||||
|
menuItem: menuItem,
|
||||||
|
),
|
||||||
|
ActionButtonType.download => DownloadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||||
|
ActionButtonType.trash => TrashActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||||
|
ActionButtonType.deletePermanent => DeletePermanentActionButton(
|
||||||
|
source: context.source,
|
||||||
|
iconOnly: iconOnly,
|
||||||
|
menuItem: menuItem,
|
||||||
|
),
|
||||||
|
ActionButtonType.delete => DeleteActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||||
|
ActionButtonType.moveToLockFolder => MoveToLockFolderActionButton(
|
||||||
|
source: context.source,
|
||||||
|
iconOnly: iconOnly,
|
||||||
|
menuItem: menuItem,
|
||||||
|
),
|
||||||
|
ActionButtonType.removeFromLockFolder => RemoveFromLockFolderActionButton(
|
||||||
|
source: context.source,
|
||||||
|
iconOnly: iconOnly,
|
||||||
|
menuItem: menuItem,
|
||||||
|
),
|
||||||
|
ActionButtonType.deleteLocal => DeleteLocalActionButton(
|
||||||
|
source: context.source,
|
||||||
|
iconOnly: iconOnly,
|
||||||
|
menuItem: menuItem,
|
||||||
|
),
|
||||||
|
ActionButtonType.upload => UploadActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||||
|
ActionButtonType.removeFromAlbum => RemoveFromAlbumActionButton(
|
||||||
|
albumId: context.currentAlbum!.id,
|
||||||
|
source: context.source,
|
||||||
|
iconOnly: iconOnly,
|
||||||
|
menuItem: menuItem,
|
||||||
|
),
|
||||||
|
ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem),
|
||||||
|
ActionButtonType.unstack => UnStackActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||||
|
ActionButtonType.similarPhotos => SimilarPhotosActionButton(
|
||||||
|
assetId: (context.asset as RemoteAsset).id,
|
||||||
|
iconOnly: iconOnly,
|
||||||
|
menuItem: menuItem,
|
||||||
|
),
|
||||||
|
ActionButtonType.openInfo => BaseActionButton(
|
||||||
label: 'info'.tr(),
|
label: 'info'.tr(),
|
||||||
iconData: Icons.info_outline,
|
iconData: Icons.info_outline,
|
||||||
iconColor: context.originalTheme?.iconTheme.color,
|
iconColor: context.originalTheme?.iconTheme.color,
|
||||||
menuItem: true,
|
menuItem: true,
|
||||||
onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()),
|
onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()),
|
||||||
),
|
),
|
||||||
|
ActionButtonType.viewInTimeline => BaseActionButton(
|
||||||
ViewerKebabMenuButtonType.viewInTimeline => BaseActionButton(
|
label: 'view_in_timeline'.tr(),
|
||||||
label: 'view_in_timeline'.t(context: buildContext),
|
|
||||||
iconData: Icons.image_search,
|
iconData: Icons.image_search,
|
||||||
iconColor: context.originalTheme?.iconTheme.color,
|
iconColor: context.originalTheme?.iconTheme.color,
|
||||||
menuItem: true,
|
iconOnly: iconOnly,
|
||||||
onPressed: () async {
|
menuItem: menuItem,
|
||||||
await buildContext.maybePop();
|
onPressed: buildContext == null
|
||||||
await buildContext.navigateTo(const TabShellRoute(children: [MainTimelineRoute()]));
|
? null
|
||||||
EventStream.shared.emit(ScrollToDateEvent(context.asset.createdAt));
|
: () async {
|
||||||
},
|
await buildContext.maybePop();
|
||||||
|
await buildContext.navigateTo(const TabShellRoute(children: [MainTimelineRoute()]));
|
||||||
|
EventStream.shared.emit(ScrollToDateEvent(context.asset.createdAt));
|
||||||
|
},
|
||||||
),
|
),
|
||||||
ViewerKebabMenuButtonType.cast => const CastActionButton(menuItem: true),
|
ActionButtonType.cast => CastActionButton(iconOnly: iconOnly, menuItem: menuItem),
|
||||||
ViewerKebabMenuButtonType.download => const DownloadActionButton(source: ActionSource.viewer, menuItem: true),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Defines which group each button belongs to for kebab menu.
|
||||||
|
/// Buttons in the same group will be displayed together,
|
||||||
|
/// with dividers separating different groups.
|
||||||
|
int get kebabMenuGroup => switch (this) {
|
||||||
|
// 0: info
|
||||||
|
ActionButtonType.openInfo => 0,
|
||||||
|
// 10: move,remove, and delete
|
||||||
|
ActionButtonType.trash => 10,
|
||||||
|
ActionButtonType.deletePermanent => 10,
|
||||||
|
ActionButtonType.removeFromLockFolder => 10,
|
||||||
|
ActionButtonType.removeFromAlbum => 10,
|
||||||
|
ActionButtonType.unstack => 10,
|
||||||
|
ActionButtonType.archive => 10,
|
||||||
|
ActionButtonType.unarchive => 10,
|
||||||
|
ActionButtonType.moveToLockFolder => 10,
|
||||||
|
ActionButtonType.deleteLocal => 10,
|
||||||
|
ActionButtonType.delete => 10,
|
||||||
|
// 90: advancedInfo
|
||||||
|
ActionButtonType.advancedInfo => 90,
|
||||||
|
// 1: others
|
||||||
|
_ => 1,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class ViewerKebabMenuButtonBuilder {
|
class ActionButtonBuilder {
|
||||||
static List<Widget> build(ViewerKebabMenuButtonContext context, BuildContext buildContext, WidgetRef ref) {
|
static const List<ActionButtonType> _actionTypes = ActionButtonType.values;
|
||||||
final visibleButtons = ViewerKebabMenuButtonType.values.where((type) => type.shouldShow(context)).toList();
|
static const List<ActionButtonType> defaultViewerKebabMenuOrder = _actionTypes;
|
||||||
|
static const Set<ActionButtonType> defaultViewerBottomBarButtons = {
|
||||||
|
ActionButtonType.share,
|
||||||
|
ActionButtonType.moveToLockFolder,
|
||||||
|
ActionButtonType.upload,
|
||||||
|
ActionButtonType.delete,
|
||||||
|
ActionButtonType.archive,
|
||||||
|
ActionButtonType.unarchive,
|
||||||
|
};
|
||||||
|
|
||||||
if (visibleButtons.isEmpty) return [];
|
static List<Widget> build(ActionButtonContext context) {
|
||||||
|
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<Widget> buildViewerKebabMenu(ActionButtonContext context, BuildContext buildContext, WidgetRef ref) {
|
||||||
|
final visibleButtons = defaultViewerKebabMenuOrder
|
||||||
|
.where((type) => !defaultViewerBottomBarButtons.contains(type) && type.shouldShow(context))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (visibleButtons.isEmpty) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
final List<Widget> result = [];
|
final List<Widget> result = [];
|
||||||
int? lastGroup;
|
int? lastGroup;
|
||||||
|
|
||||||
for (final type in visibleButtons) {
|
for (final type in visibleButtons) {
|
||||||
if (lastGroup != null && type.group != lastGroup) {
|
if (lastGroup != null && type.kebabMenuGroup != lastGroup) {
|
||||||
result.add(const Divider(height: 1));
|
result.add(const Divider(height: 1));
|
||||||
}
|
}
|
||||||
result.add(type.buildButton(context, buildContext).build(buildContext, ref));
|
result.add(type.buildButton(context, buildContext, false, true).build(buildContext, ref));
|
||||||
lastGroup = type.group;
|
lastGroup = type.kebabMenuGroup;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:easy_localization/easy_localization.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/providers/activity.provider.dart';
|
import 'package:immich_mobile/providers/activity.provider.dart';
|
||||||
import 'package:immich_mobile/providers/album/current_album.provider.dart';
|
import 'package:immich_mobile/providers/album/current_album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||||
@@ -68,11 +69,11 @@ class ActivityTextField extends HookConsumerWidget {
|
|||||||
suffixIcon: Padding(
|
suffixIcon: Padding(
|
||||||
padding: const EdgeInsets.only(right: 10),
|
padding: const EdgeInsets.only(right: 10),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
icon: Icon(liked ? Icons.favorite_rounded : Icons.favorite_border_rounded),
|
icon: Icon(liked ? Icons.thumb_up : Icons.thumb_up_off_alt),
|
||||||
onPressed: liked ? removeLike : addLike,
|
onPressed: liked ? removeLike : addLike,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
suffixIconColor: liked ? Colors.red[700] : null,
|
suffixIconColor: liked ? context.primaryColor : null,
|
||||||
hintText: !isEnabled ? 'shared_album_activities_input_disable'.tr() : 'say_something'.tr(),
|
hintText: !isEnabled ? 'shared_album_activities_input_disable'.tr() : 'say_something'.tr(),
|
||||||
hintStyle: TextStyle(fontWeight: FontWeight.normal, fontSize: 14, color: Colors.grey[600]),
|
hintStyle: TextStyle(fontWeight: FontWeight.normal, fontSize: 14, color: Colors.grey[600]),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class ActivityTile extends HookConsumerWidget {
|
|||||||
? Container(
|
? Container(
|
||||||
width: isBottomSheet ? 30 : 44,
|
width: isBottomSheet ? 30 : 44,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Icon(Icons.favorite_rounded, color: Colors.red[700]),
|
child: Icon(Icons.thumb_up, color: context.primaryColor),
|
||||||
)
|
)
|
||||||
: isBottomSheet
|
: isBottomSheet
|
||||||
? UserCircleAvatar(user: activity.user, size: 30, radius: 15)
|
? UserCircleAvatar(user: activity.user, size: 30, radius: 15)
|
||||||
|
|||||||
@@ -67,8 +67,8 @@ class CommentBubble extends ConsumerWidget {
|
|||||||
bottom: 6,
|
bottom: 6,
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(4),
|
padding: const EdgeInsets.all(4),
|
||||||
decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.7), shape: BoxShape.circle),
|
decoration: BoxDecoration(color: context.colorScheme.surfaceContainer, shape: BoxShape.circle),
|
||||||
child: Icon(Icons.favorite, color: Colors.red[600], size: 18),
|
child: Icon(Icons.thumb_up, color: context.primaryColor, size: 18),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -81,8 +81,8 @@ class CommentBubble extends ConsumerWidget {
|
|||||||
if (isLike && !showThumbnail) {
|
if (isLike && !showThumbnail) {
|
||||||
likes = Container(
|
likes = Container(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.7), shape: BoxShape.circle),
|
decoration: BoxDecoration(color: context.colorScheme.surfaceContainer, shape: BoxShape.circle),
|
||||||
child: Icon(Icons.favorite, color: Colors.red[600], size: 18),
|
child: Icon(Icons.thumb_up, color: context.primaryColor, size: 18),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
13
mobile/openapi/README.md
generated
13
mobile/openapi/README.md
generated
@@ -133,6 +133,11 @@ Class | Method | HTTP request | Description
|
|||||||
*AuthenticationApi* | [**unlockAuthSession**](doc//AuthenticationApi.md#unlockauthsession) | **POST** /auth/session/unlock | Unlock auth session
|
*AuthenticationApi* | [**unlockAuthSession**](doc//AuthenticationApi.md#unlockauthsession) | **POST** /auth/session/unlock | Unlock auth session
|
||||||
*AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | Validate access token
|
*AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | Validate access token
|
||||||
*AuthenticationAdminApi* | [**unlinkAllOAuthAccountsAdmin**](doc//AuthenticationAdminApi.md#unlinkalloauthaccountsadmin) | **POST** /admin/auth/unlink-all | Unlink all OAuth accounts
|
*AuthenticationAdminApi* | [**unlinkAllOAuthAccountsAdmin**](doc//AuthenticationAdminApi.md#unlinkalloauthaccountsadmin) | **POST** /admin/auth/unlink-all | Unlink all OAuth accounts
|
||||||
|
*DatabaseBackupsAdminApi* | [**deleteDatabaseBackup**](doc//DatabaseBackupsAdminApi.md#deletedatabasebackup) | **DELETE** /admin/database-backups | Delete database backup
|
||||||
|
*DatabaseBackupsAdminApi* | [**downloadDatabaseBackup**](doc//DatabaseBackupsAdminApi.md#downloaddatabasebackup) | **GET** /admin/database-backups/{filename} | Download database backup
|
||||||
|
*DatabaseBackupsAdminApi* | [**listDatabaseBackups**](doc//DatabaseBackupsAdminApi.md#listdatabasebackups) | **GET** /admin/database-backups | List database backups
|
||||||
|
*DatabaseBackupsAdminApi* | [**startDatabaseRestoreFlow**](doc//DatabaseBackupsAdminApi.md#startdatabaserestoreflow) | **POST** /admin/database-backups/start-restore | Start database backup restore flow
|
||||||
|
*DatabaseBackupsAdminApi* | [**uploadDatabaseBackup**](doc//DatabaseBackupsAdminApi.md#uploaddatabasebackup) | **POST** /admin/database-backups/upload | Upload database backup
|
||||||
*DeprecatedApi* | [**createPartnerDeprecated**](doc//DeprecatedApi.md#createpartnerdeprecated) | **POST** /partners/{id} | Create a partner
|
*DeprecatedApi* | [**createPartnerDeprecated**](doc//DeprecatedApi.md#createpartnerdeprecated) | **POST** /partners/{id} | Create a partner
|
||||||
*DeprecatedApi* | [**getAllUserAssetsByDeviceId**](doc//DeprecatedApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} | Retrieve assets by device ID
|
*DeprecatedApi* | [**getAllUserAssetsByDeviceId**](doc//DeprecatedApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} | Retrieve assets by device ID
|
||||||
*DeprecatedApi* | [**getDeltaSync**](doc//DeprecatedApi.md#getdeltasync) | **POST** /sync/delta-sync | Get delta sync for user
|
*DeprecatedApi* | [**getDeltaSync**](doc//DeprecatedApi.md#getdeltasync) | **POST** /sync/delta-sync | Get delta sync for user
|
||||||
@@ -161,6 +166,8 @@ Class | Method | HTTP request | Description
|
|||||||
*LibrariesApi* | [**scanLibrary**](doc//LibrariesApi.md#scanlibrary) | **POST** /libraries/{id}/scan | Scan a library
|
*LibrariesApi* | [**scanLibrary**](doc//LibrariesApi.md#scanlibrary) | **POST** /libraries/{id}/scan | Scan a library
|
||||||
*LibrariesApi* | [**updateLibrary**](doc//LibrariesApi.md#updatelibrary) | **PUT** /libraries/{id} | Update a library
|
*LibrariesApi* | [**updateLibrary**](doc//LibrariesApi.md#updatelibrary) | **PUT** /libraries/{id} | Update a library
|
||||||
*LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate | Validate library settings
|
*LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate | Validate library settings
|
||||||
|
*MaintenanceAdminApi* | [**detectPriorInstall**](doc//MaintenanceAdminApi.md#detectpriorinstall) | **GET** /admin/maintenance/detect-install | Detect existing install
|
||||||
|
*MaintenanceAdminApi* | [**getMaintenanceStatus**](doc//MaintenanceAdminApi.md#getmaintenancestatus) | **GET** /admin/maintenance/status | Get maintenance mode status
|
||||||
*MaintenanceAdminApi* | [**maintenanceLogin**](doc//MaintenanceAdminApi.md#maintenancelogin) | **POST** /admin/maintenance/login | Log into maintenance mode
|
*MaintenanceAdminApi* | [**maintenanceLogin**](doc//MaintenanceAdminApi.md#maintenancelogin) | **POST** /admin/maintenance/login | Log into maintenance mode
|
||||||
*MaintenanceAdminApi* | [**setMaintenanceMode**](doc//MaintenanceAdminApi.md#setmaintenancemode) | **POST** /admin/maintenance | Set maintenance mode
|
*MaintenanceAdminApi* | [**setMaintenanceMode**](doc//MaintenanceAdminApi.md#setmaintenancemode) | **POST** /admin/maintenance | Set maintenance mode
|
||||||
*MapApi* | [**getMapMarkers**](doc//MapApi.md#getmapmarkers) | **GET** /map/markers | Retrieve map markers
|
*MapApi* | [**getMapMarkers**](doc//MapApi.md#getmapmarkers) | **GET** /map/markers | Retrieve map markers
|
||||||
@@ -387,6 +394,8 @@ Class | Method | HTTP request | Description
|
|||||||
- [CreateLibraryDto](doc//CreateLibraryDto.md)
|
- [CreateLibraryDto](doc//CreateLibraryDto.md)
|
||||||
- [CreateProfileImageResponseDto](doc//CreateProfileImageResponseDto.md)
|
- [CreateProfileImageResponseDto](doc//CreateProfileImageResponseDto.md)
|
||||||
- [DatabaseBackupConfig](doc//DatabaseBackupConfig.md)
|
- [DatabaseBackupConfig](doc//DatabaseBackupConfig.md)
|
||||||
|
- [DatabaseBackupDeleteDto](doc//DatabaseBackupDeleteDto.md)
|
||||||
|
- [DatabaseBackupListResponseDto](doc//DatabaseBackupListResponseDto.md)
|
||||||
- [DownloadArchiveInfo](doc//DownloadArchiveInfo.md)
|
- [DownloadArchiveInfo](doc//DownloadArchiveInfo.md)
|
||||||
- [DownloadInfoDto](doc//DownloadInfoDto.md)
|
- [DownloadInfoDto](doc//DownloadInfoDto.md)
|
||||||
- [DownloadResponse](doc//DownloadResponse.md)
|
- [DownloadResponse](doc//DownloadResponse.md)
|
||||||
@@ -416,7 +425,10 @@ Class | Method | HTTP request | Description
|
|||||||
- [MachineLearningAvailabilityChecksDto](doc//MachineLearningAvailabilityChecksDto.md)
|
- [MachineLearningAvailabilityChecksDto](doc//MachineLearningAvailabilityChecksDto.md)
|
||||||
- [MaintenanceAction](doc//MaintenanceAction.md)
|
- [MaintenanceAction](doc//MaintenanceAction.md)
|
||||||
- [MaintenanceAuthDto](doc//MaintenanceAuthDto.md)
|
- [MaintenanceAuthDto](doc//MaintenanceAuthDto.md)
|
||||||
|
- [MaintenanceDetectInstallResponseDto](doc//MaintenanceDetectInstallResponseDto.md)
|
||||||
|
- [MaintenanceDetectInstallStorageFolderDto](doc//MaintenanceDetectInstallStorageFolderDto.md)
|
||||||
- [MaintenanceLoginDto](doc//MaintenanceLoginDto.md)
|
- [MaintenanceLoginDto](doc//MaintenanceLoginDto.md)
|
||||||
|
- [MaintenanceStatusResponseDto](doc//MaintenanceStatusResponseDto.md)
|
||||||
- [ManualJobName](doc//ManualJobName.md)
|
- [ManualJobName](doc//ManualJobName.md)
|
||||||
- [MapMarkerResponseDto](doc//MapMarkerResponseDto.md)
|
- [MapMarkerResponseDto](doc//MapMarkerResponseDto.md)
|
||||||
- [MapReverseGeocodeResponseDto](doc//MapReverseGeocodeResponseDto.md)
|
- [MapReverseGeocodeResponseDto](doc//MapReverseGeocodeResponseDto.md)
|
||||||
@@ -528,6 +540,7 @@ Class | Method | HTTP request | Description
|
|||||||
- [StackResponseDto](doc//StackResponseDto.md)
|
- [StackResponseDto](doc//StackResponseDto.md)
|
||||||
- [StackUpdateDto](doc//StackUpdateDto.md)
|
- [StackUpdateDto](doc//StackUpdateDto.md)
|
||||||
- [StatisticsSearchDto](doc//StatisticsSearchDto.md)
|
- [StatisticsSearchDto](doc//StatisticsSearchDto.md)
|
||||||
|
- [StorageFolder](doc//StorageFolder.md)
|
||||||
- [SyncAckDeleteDto](doc//SyncAckDeleteDto.md)
|
- [SyncAckDeleteDto](doc//SyncAckDeleteDto.md)
|
||||||
- [SyncAckDto](doc//SyncAckDto.md)
|
- [SyncAckDto](doc//SyncAckDto.md)
|
||||||
- [SyncAckSetDto](doc//SyncAckSetDto.md)
|
- [SyncAckSetDto](doc//SyncAckSetDto.md)
|
||||||
|
|||||||
7
mobile/openapi/lib/api.dart
generated
7
mobile/openapi/lib/api.dart
generated
@@ -36,6 +36,7 @@ part 'api/albums_api.dart';
|
|||||||
part 'api/assets_api.dart';
|
part 'api/assets_api.dart';
|
||||||
part 'api/authentication_api.dart';
|
part 'api/authentication_api.dart';
|
||||||
part 'api/authentication_admin_api.dart';
|
part 'api/authentication_admin_api.dart';
|
||||||
|
part 'api/database_backups_admin_api.dart';
|
||||||
part 'api/deprecated_api.dart';
|
part 'api/deprecated_api.dart';
|
||||||
part 'api/download_api.dart';
|
part 'api/download_api.dart';
|
||||||
part 'api/duplicates_api.dart';
|
part 'api/duplicates_api.dart';
|
||||||
@@ -139,6 +140,8 @@ part 'model/create_album_dto.dart';
|
|||||||
part 'model/create_library_dto.dart';
|
part 'model/create_library_dto.dart';
|
||||||
part 'model/create_profile_image_response_dto.dart';
|
part 'model/create_profile_image_response_dto.dart';
|
||||||
part 'model/database_backup_config.dart';
|
part 'model/database_backup_config.dart';
|
||||||
|
part 'model/database_backup_delete_dto.dart';
|
||||||
|
part 'model/database_backup_list_response_dto.dart';
|
||||||
part 'model/download_archive_info.dart';
|
part 'model/download_archive_info.dart';
|
||||||
part 'model/download_info_dto.dart';
|
part 'model/download_info_dto.dart';
|
||||||
part 'model/download_response.dart';
|
part 'model/download_response.dart';
|
||||||
@@ -168,7 +171,10 @@ part 'model/logout_response_dto.dart';
|
|||||||
part 'model/machine_learning_availability_checks_dto.dart';
|
part 'model/machine_learning_availability_checks_dto.dart';
|
||||||
part 'model/maintenance_action.dart';
|
part 'model/maintenance_action.dart';
|
||||||
part 'model/maintenance_auth_dto.dart';
|
part 'model/maintenance_auth_dto.dart';
|
||||||
|
part 'model/maintenance_detect_install_response_dto.dart';
|
||||||
|
part 'model/maintenance_detect_install_storage_folder_dto.dart';
|
||||||
part 'model/maintenance_login_dto.dart';
|
part 'model/maintenance_login_dto.dart';
|
||||||
|
part 'model/maintenance_status_response_dto.dart';
|
||||||
part 'model/manual_job_name.dart';
|
part 'model/manual_job_name.dart';
|
||||||
part 'model/map_marker_response_dto.dart';
|
part 'model/map_marker_response_dto.dart';
|
||||||
part 'model/map_reverse_geocode_response_dto.dart';
|
part 'model/map_reverse_geocode_response_dto.dart';
|
||||||
@@ -280,6 +286,7 @@ part 'model/stack_create_dto.dart';
|
|||||||
part 'model/stack_response_dto.dart';
|
part 'model/stack_response_dto.dart';
|
||||||
part 'model/stack_update_dto.dart';
|
part 'model/stack_update_dto.dart';
|
||||||
part 'model/statistics_search_dto.dart';
|
part 'model/statistics_search_dto.dart';
|
||||||
|
part 'model/storage_folder.dart';
|
||||||
part 'model/sync_ack_delete_dto.dart';
|
part 'model/sync_ack_delete_dto.dart';
|
||||||
part 'model/sync_ack_dto.dart';
|
part 'model/sync_ack_dto.dart';
|
||||||
part 'model/sync_ack_set_dto.dart';
|
part 'model/sync_ack_set_dto.dart';
|
||||||
|
|||||||
269
mobile/openapi/lib/api/database_backups_admin_api.dart
generated
Normal file
269
mobile/openapi/lib/api/database_backups_admin_api.dart
generated
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.18
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
part of openapi.api;
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseBackupsAdminApi {
|
||||||
|
DatabaseBackupsAdminApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
|
||||||
|
|
||||||
|
final ApiClient apiClient;
|
||||||
|
|
||||||
|
/// Delete database backup
|
||||||
|
///
|
||||||
|
/// Delete a backup by its filename
|
||||||
|
///
|
||||||
|
/// Note: This method returns the HTTP [Response].
|
||||||
|
///
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [DatabaseBackupDeleteDto] databaseBackupDeleteDto (required):
|
||||||
|
Future<Response> deleteDatabaseBackupWithHttpInfo(DatabaseBackupDeleteDto databaseBackupDeleteDto,) async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final apiPath = r'/admin/database-backups';
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody = databaseBackupDeleteDto;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
const contentTypes = <String>['application/json'];
|
||||||
|
|
||||||
|
|
||||||
|
return apiClient.invokeAPI(
|
||||||
|
apiPath,
|
||||||
|
'DELETE',
|
||||||
|
queryParams,
|
||||||
|
postBody,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete database backup
|
||||||
|
///
|
||||||
|
/// Delete a backup by its filename
|
||||||
|
///
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [DatabaseBackupDeleteDto] databaseBackupDeleteDto (required):
|
||||||
|
Future<void> deleteDatabaseBackup(DatabaseBackupDeleteDto databaseBackupDeleteDto,) async {
|
||||||
|
final response = await deleteDatabaseBackupWithHttpInfo(databaseBackupDeleteDto,);
|
||||||
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download database backup
|
||||||
|
///
|
||||||
|
/// Downloads the database backup file
|
||||||
|
///
|
||||||
|
/// Note: This method returns the HTTP [Response].
|
||||||
|
///
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [String] filename (required):
|
||||||
|
Future<Response> downloadDatabaseBackupWithHttpInfo(String filename,) async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final apiPath = r'/admin/database-backups/{filename}'
|
||||||
|
.replaceAll('{filename}', filename);
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
const contentTypes = <String>[];
|
||||||
|
|
||||||
|
|
||||||
|
return apiClient.invokeAPI(
|
||||||
|
apiPath,
|
||||||
|
'GET',
|
||||||
|
queryParams,
|
||||||
|
postBody,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download database backup
|
||||||
|
///
|
||||||
|
/// Downloads the database backup file
|
||||||
|
///
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [String] filename (required):
|
||||||
|
Future<MultipartFile?> downloadDatabaseBackup(String filename,) async {
|
||||||
|
final response = await downloadDatabaseBackupWithHttpInfo(filename,);
|
||||||
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
|
}
|
||||||
|
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||||
|
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||||
|
// FormatException when trying to decode an empty string.
|
||||||
|
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||||
|
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile;
|
||||||
|
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List database backups
|
||||||
|
///
|
||||||
|
/// Get the list of the successful and failed backups
|
||||||
|
///
|
||||||
|
/// Note: This method returns the HTTP [Response].
|
||||||
|
Future<Response> listDatabaseBackupsWithHttpInfo() async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final apiPath = r'/admin/database-backups';
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
const contentTypes = <String>[];
|
||||||
|
|
||||||
|
|
||||||
|
return apiClient.invokeAPI(
|
||||||
|
apiPath,
|
||||||
|
'GET',
|
||||||
|
queryParams,
|
||||||
|
postBody,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List database backups
|
||||||
|
///
|
||||||
|
/// Get the list of the successful and failed backups
|
||||||
|
Future<DatabaseBackupListResponseDto?> listDatabaseBackups() async {
|
||||||
|
final response = await listDatabaseBackupsWithHttpInfo();
|
||||||
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
|
}
|
||||||
|
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||||
|
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||||
|
// FormatException when trying to decode an empty string.
|
||||||
|
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||||
|
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'DatabaseBackupListResponseDto',) as DatabaseBackupListResponseDto;
|
||||||
|
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start database backup restore flow
|
||||||
|
///
|
||||||
|
/// Put Immich into maintenance mode to restore a backup (Immich must not be configured)
|
||||||
|
///
|
||||||
|
/// Note: This method returns the HTTP [Response].
|
||||||
|
Future<Response> startDatabaseRestoreFlowWithHttpInfo() async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final apiPath = r'/admin/database-backups/start-restore';
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
const contentTypes = <String>[];
|
||||||
|
|
||||||
|
|
||||||
|
return apiClient.invokeAPI(
|
||||||
|
apiPath,
|
||||||
|
'POST',
|
||||||
|
queryParams,
|
||||||
|
postBody,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start database backup restore flow
|
||||||
|
///
|
||||||
|
/// Put Immich into maintenance mode to restore a backup (Immich must not be configured)
|
||||||
|
Future<void> startDatabaseRestoreFlow() async {
|
||||||
|
final response = await startDatabaseRestoreFlowWithHttpInfo();
|
||||||
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload database backup
|
||||||
|
///
|
||||||
|
/// Uploads .sql/.sql.gz file to restore backup from
|
||||||
|
///
|
||||||
|
/// Note: This method returns the HTTP [Response].
|
||||||
|
///
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [MultipartFile] file:
|
||||||
|
Future<Response> uploadDatabaseBackupWithHttpInfo({ MultipartFile? file, }) async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final apiPath = r'/admin/database-backups/upload';
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
const contentTypes = <String>['multipart/form-data'];
|
||||||
|
|
||||||
|
bool hasFields = false;
|
||||||
|
final mp = MultipartRequest('POST', Uri.parse(apiPath));
|
||||||
|
if (file != null) {
|
||||||
|
hasFields = true;
|
||||||
|
mp.fields[r'file'] = file.field;
|
||||||
|
mp.files.add(file);
|
||||||
|
}
|
||||||
|
if (hasFields) {
|
||||||
|
postBody = mp;
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiClient.invokeAPI(
|
||||||
|
apiPath,
|
||||||
|
'POST',
|
||||||
|
queryParams,
|
||||||
|
postBody,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload database backup
|
||||||
|
///
|
||||||
|
/// Uploads .sql/.sql.gz file to restore backup from
|
||||||
|
///
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [MultipartFile] file:
|
||||||
|
Future<void> uploadDatabaseBackup({ MultipartFile? file, }) async {
|
||||||
|
final response = await uploadDatabaseBackupWithHttpInfo( file: file, );
|
||||||
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
96
mobile/openapi/lib/api/maintenance_admin_api.dart
generated
96
mobile/openapi/lib/api/maintenance_admin_api.dart
generated
@@ -16,6 +16,102 @@ class MaintenanceAdminApi {
|
|||||||
|
|
||||||
final ApiClient apiClient;
|
final ApiClient apiClient;
|
||||||
|
|
||||||
|
/// Detect existing install
|
||||||
|
///
|
||||||
|
/// Collect integrity checks and other heuristics about local data.
|
||||||
|
///
|
||||||
|
/// Note: This method returns the HTTP [Response].
|
||||||
|
Future<Response> detectPriorInstallWithHttpInfo() async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final apiPath = r'/admin/maintenance/detect-install';
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
const contentTypes = <String>[];
|
||||||
|
|
||||||
|
|
||||||
|
return apiClient.invokeAPI(
|
||||||
|
apiPath,
|
||||||
|
'GET',
|
||||||
|
queryParams,
|
||||||
|
postBody,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect existing install
|
||||||
|
///
|
||||||
|
/// Collect integrity checks and other heuristics about local data.
|
||||||
|
Future<MaintenanceDetectInstallResponseDto?> detectPriorInstall() async {
|
||||||
|
final response = await detectPriorInstallWithHttpInfo();
|
||||||
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
|
}
|
||||||
|
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||||
|
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||||
|
// FormatException when trying to decode an empty string.
|
||||||
|
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||||
|
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MaintenanceDetectInstallResponseDto',) as MaintenanceDetectInstallResponseDto;
|
||||||
|
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get maintenance mode status
|
||||||
|
///
|
||||||
|
/// Fetch information about the currently running maintenance action.
|
||||||
|
///
|
||||||
|
/// Note: This method returns the HTTP [Response].
|
||||||
|
Future<Response> getMaintenanceStatusWithHttpInfo() async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final apiPath = r'/admin/maintenance/status';
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
const contentTypes = <String>[];
|
||||||
|
|
||||||
|
|
||||||
|
return apiClient.invokeAPI(
|
||||||
|
apiPath,
|
||||||
|
'GET',
|
||||||
|
queryParams,
|
||||||
|
postBody,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get maintenance mode status
|
||||||
|
///
|
||||||
|
/// Fetch information about the currently running maintenance action.
|
||||||
|
Future<MaintenanceStatusResponseDto?> getMaintenanceStatus() async {
|
||||||
|
final response = await getMaintenanceStatusWithHttpInfo();
|
||||||
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
|
}
|
||||||
|
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||||
|
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||||
|
// FormatException when trying to decode an empty string.
|
||||||
|
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||||
|
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MaintenanceStatusResponseDto',) as MaintenanceStatusResponseDto;
|
||||||
|
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Log into maintenance mode
|
/// Log into maintenance mode
|
||||||
///
|
///
|
||||||
/// Login with maintenance token or cookie to receive current information and perform further actions.
|
/// Login with maintenance token or cookie to receive current information and perform further actions.
|
||||||
|
|||||||
12
mobile/openapi/lib/api_client.dart
generated
12
mobile/openapi/lib/api_client.dart
generated
@@ -326,6 +326,10 @@ class ApiClient {
|
|||||||
return CreateProfileImageResponseDto.fromJson(value);
|
return CreateProfileImageResponseDto.fromJson(value);
|
||||||
case 'DatabaseBackupConfig':
|
case 'DatabaseBackupConfig':
|
||||||
return DatabaseBackupConfig.fromJson(value);
|
return DatabaseBackupConfig.fromJson(value);
|
||||||
|
case 'DatabaseBackupDeleteDto':
|
||||||
|
return DatabaseBackupDeleteDto.fromJson(value);
|
||||||
|
case 'DatabaseBackupListResponseDto':
|
||||||
|
return DatabaseBackupListResponseDto.fromJson(value);
|
||||||
case 'DownloadArchiveInfo':
|
case 'DownloadArchiveInfo':
|
||||||
return DownloadArchiveInfo.fromJson(value);
|
return DownloadArchiveInfo.fromJson(value);
|
||||||
case 'DownloadInfoDto':
|
case 'DownloadInfoDto':
|
||||||
@@ -384,8 +388,14 @@ class ApiClient {
|
|||||||
return MaintenanceActionTypeTransformer().decode(value);
|
return MaintenanceActionTypeTransformer().decode(value);
|
||||||
case 'MaintenanceAuthDto':
|
case 'MaintenanceAuthDto':
|
||||||
return MaintenanceAuthDto.fromJson(value);
|
return MaintenanceAuthDto.fromJson(value);
|
||||||
|
case 'MaintenanceDetectInstallResponseDto':
|
||||||
|
return MaintenanceDetectInstallResponseDto.fromJson(value);
|
||||||
|
case 'MaintenanceDetectInstallStorageFolderDto':
|
||||||
|
return MaintenanceDetectInstallStorageFolderDto.fromJson(value);
|
||||||
case 'MaintenanceLoginDto':
|
case 'MaintenanceLoginDto':
|
||||||
return MaintenanceLoginDto.fromJson(value);
|
return MaintenanceLoginDto.fromJson(value);
|
||||||
|
case 'MaintenanceStatusResponseDto':
|
||||||
|
return MaintenanceStatusResponseDto.fromJson(value);
|
||||||
case 'ManualJobName':
|
case 'ManualJobName':
|
||||||
return ManualJobNameTypeTransformer().decode(value);
|
return ManualJobNameTypeTransformer().decode(value);
|
||||||
case 'MapMarkerResponseDto':
|
case 'MapMarkerResponseDto':
|
||||||
@@ -608,6 +618,8 @@ class ApiClient {
|
|||||||
return StackUpdateDto.fromJson(value);
|
return StackUpdateDto.fromJson(value);
|
||||||
case 'StatisticsSearchDto':
|
case 'StatisticsSearchDto':
|
||||||
return StatisticsSearchDto.fromJson(value);
|
return StatisticsSearchDto.fromJson(value);
|
||||||
|
case 'StorageFolder':
|
||||||
|
return StorageFolderTypeTransformer().decode(value);
|
||||||
case 'SyncAckDeleteDto':
|
case 'SyncAckDeleteDto':
|
||||||
return SyncAckDeleteDto.fromJson(value);
|
return SyncAckDeleteDto.fromJson(value);
|
||||||
case 'SyncAckDto':
|
case 'SyncAckDto':
|
||||||
|
|||||||
3
mobile/openapi/lib/api_helper.dart
generated
3
mobile/openapi/lib/api_helper.dart
generated
@@ -157,6 +157,9 @@ String parameterToString(dynamic value) {
|
|||||||
if (value is SourceType) {
|
if (value is SourceType) {
|
||||||
return SourceTypeTypeTransformer().encode(value).toString();
|
return SourceTypeTypeTransformer().encode(value).toString();
|
||||||
}
|
}
|
||||||
|
if (value is StorageFolder) {
|
||||||
|
return StorageFolderTypeTransformer().encode(value).toString();
|
||||||
|
}
|
||||||
if (value is SyncEntityType) {
|
if (value is SyncEntityType) {
|
||||||
return SyncEntityTypeTypeTransformer().encode(value).toString();
|
return SyncEntityTypeTypeTransformer().encode(value).toString();
|
||||||
}
|
}
|
||||||
|
|||||||
101
mobile/openapi/lib/model/database_backup_delete_dto.dart
generated
Normal file
101
mobile/openapi/lib/model/database_backup_delete_dto.dart
generated
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.18
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
part of openapi.api;
|
||||||
|
|
||||||
|
class DatabaseBackupDeleteDto {
|
||||||
|
/// Returns a new [DatabaseBackupDeleteDto] instance.
|
||||||
|
DatabaseBackupDeleteDto({
|
||||||
|
this.backups = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
List<String> backups;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => identical(this, other) || other is DatabaseBackupDeleteDto &&
|
||||||
|
_deepEquality.equals(other.backups, backups);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
// ignore: unnecessary_parenthesis
|
||||||
|
(backups.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'DatabaseBackupDeleteDto[backups=$backups]';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
json[r'backups'] = this.backups;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [DatabaseBackupDeleteDto] instance and imports its values from
|
||||||
|
/// [value] if it's a [Map], null otherwise.
|
||||||
|
// ignore: prefer_constructors_over_static_methods
|
||||||
|
static DatabaseBackupDeleteDto? fromJson(dynamic value) {
|
||||||
|
upgradeDto(value, "DatabaseBackupDeleteDto");
|
||||||
|
if (value is Map) {
|
||||||
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
|
return DatabaseBackupDeleteDto(
|
||||||
|
backups: json[r'backups'] is Iterable
|
||||||
|
? (json[r'backups'] as Iterable).cast<String>().toList(growable: false)
|
||||||
|
: const [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<DatabaseBackupDeleteDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <DatabaseBackupDeleteDto>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = DatabaseBackupDeleteDto.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, DatabaseBackupDeleteDto> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, DatabaseBackupDeleteDto>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = DatabaseBackupDeleteDto.fromJson(entry.value);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps a json object with a list of DatabaseBackupDeleteDto-objects as value to a dart map
|
||||||
|
static Map<String, List<DatabaseBackupDeleteDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<DatabaseBackupDeleteDto>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
json = json.cast<String, dynamic>();
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
map[entry.key] = DatabaseBackupDeleteDto.listFromJson(entry.value, growable: growable,);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of required keys that must be present in a JSON.
|
||||||
|
static const requiredKeys = <String>{
|
||||||
|
'backups',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
101
mobile/openapi/lib/model/database_backup_list_response_dto.dart
generated
Normal file
101
mobile/openapi/lib/model/database_backup_list_response_dto.dart
generated
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.18
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
part of openapi.api;
|
||||||
|
|
||||||
|
class DatabaseBackupListResponseDto {
|
||||||
|
/// Returns a new [DatabaseBackupListResponseDto] instance.
|
||||||
|
DatabaseBackupListResponseDto({
|
||||||
|
this.backups = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
List<String> backups;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => identical(this, other) || other is DatabaseBackupListResponseDto &&
|
||||||
|
_deepEquality.equals(other.backups, backups);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
// ignore: unnecessary_parenthesis
|
||||||
|
(backups.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'DatabaseBackupListResponseDto[backups=$backups]';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
json[r'backups'] = this.backups;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [DatabaseBackupListResponseDto] instance and imports its values from
|
||||||
|
/// [value] if it's a [Map], null otherwise.
|
||||||
|
// ignore: prefer_constructors_over_static_methods
|
||||||
|
static DatabaseBackupListResponseDto? fromJson(dynamic value) {
|
||||||
|
upgradeDto(value, "DatabaseBackupListResponseDto");
|
||||||
|
if (value is Map) {
|
||||||
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
|
return DatabaseBackupListResponseDto(
|
||||||
|
backups: json[r'backups'] is Iterable
|
||||||
|
? (json[r'backups'] as Iterable).cast<String>().toList(growable: false)
|
||||||
|
: const [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<DatabaseBackupListResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <DatabaseBackupListResponseDto>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = DatabaseBackupListResponseDto.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, DatabaseBackupListResponseDto> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, DatabaseBackupListResponseDto>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = DatabaseBackupListResponseDto.fromJson(entry.value);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps a json object with a list of DatabaseBackupListResponseDto-objects as value to a dart map
|
||||||
|
static Map<String, List<DatabaseBackupListResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<DatabaseBackupListResponseDto>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
json = json.cast<String, dynamic>();
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
map[entry.key] = DatabaseBackupListResponseDto.listFromJson(entry.value, growable: growable,);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of required keys that must be present in a JSON.
|
||||||
|
static const requiredKeys = <String>{
|
||||||
|
'backups',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
3
mobile/openapi/lib/model/maintenance_action.dart
generated
3
mobile/openapi/lib/model/maintenance_action.dart
generated
@@ -25,11 +25,13 @@ class MaintenanceAction {
|
|||||||
|
|
||||||
static const start = MaintenanceAction._(r'start');
|
static const start = MaintenanceAction._(r'start');
|
||||||
static const end = MaintenanceAction._(r'end');
|
static const end = MaintenanceAction._(r'end');
|
||||||
|
static const restoreDatabase = MaintenanceAction._(r'restore_database');
|
||||||
|
|
||||||
/// List of all possible values in this [enum][MaintenanceAction].
|
/// List of all possible values in this [enum][MaintenanceAction].
|
||||||
static const values = <MaintenanceAction>[
|
static const values = <MaintenanceAction>[
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
|
restoreDatabase,
|
||||||
];
|
];
|
||||||
|
|
||||||
static MaintenanceAction? fromJson(dynamic value) => MaintenanceActionTypeTransformer().decode(value);
|
static MaintenanceAction? fromJson(dynamic value) => MaintenanceActionTypeTransformer().decode(value);
|
||||||
@@ -70,6 +72,7 @@ class MaintenanceActionTypeTransformer {
|
|||||||
switch (data) {
|
switch (data) {
|
||||||
case r'start': return MaintenanceAction.start;
|
case r'start': return MaintenanceAction.start;
|
||||||
case r'end': return MaintenanceAction.end;
|
case r'end': return MaintenanceAction.end;
|
||||||
|
case r'restore_database': return MaintenanceAction.restoreDatabase;
|
||||||
default:
|
default:
|
||||||
if (!allowNull) {
|
if (!allowNull) {
|
||||||
throw ArgumentError('Unknown enum value to decode: $data');
|
throw ArgumentError('Unknown enum value to decode: $data');
|
||||||
|
|||||||
99
mobile/openapi/lib/model/maintenance_detect_install_response_dto.dart
generated
Normal file
99
mobile/openapi/lib/model/maintenance_detect_install_response_dto.dart
generated
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.18
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
part of openapi.api;
|
||||||
|
|
||||||
|
class MaintenanceDetectInstallResponseDto {
|
||||||
|
/// Returns a new [MaintenanceDetectInstallResponseDto] instance.
|
||||||
|
MaintenanceDetectInstallResponseDto({
|
||||||
|
this.storage = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
List<MaintenanceDetectInstallStorageFolderDto> storage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => identical(this, other) || other is MaintenanceDetectInstallResponseDto &&
|
||||||
|
_deepEquality.equals(other.storage, storage);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
// ignore: unnecessary_parenthesis
|
||||||
|
(storage.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'MaintenanceDetectInstallResponseDto[storage=$storage]';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
json[r'storage'] = this.storage;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [MaintenanceDetectInstallResponseDto] instance and imports its values from
|
||||||
|
/// [value] if it's a [Map], null otherwise.
|
||||||
|
// ignore: prefer_constructors_over_static_methods
|
||||||
|
static MaintenanceDetectInstallResponseDto? fromJson(dynamic value) {
|
||||||
|
upgradeDto(value, "MaintenanceDetectInstallResponseDto");
|
||||||
|
if (value is Map) {
|
||||||
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
|
return MaintenanceDetectInstallResponseDto(
|
||||||
|
storage: MaintenanceDetectInstallStorageFolderDto.listFromJson(json[r'storage']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<MaintenanceDetectInstallResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <MaintenanceDetectInstallResponseDto>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = MaintenanceDetectInstallResponseDto.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, MaintenanceDetectInstallResponseDto> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, MaintenanceDetectInstallResponseDto>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = MaintenanceDetectInstallResponseDto.fromJson(entry.value);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps a json object with a list of MaintenanceDetectInstallResponseDto-objects as value to a dart map
|
||||||
|
static Map<String, List<MaintenanceDetectInstallResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<MaintenanceDetectInstallResponseDto>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
json = json.cast<String, dynamic>();
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
map[entry.key] = MaintenanceDetectInstallResponseDto.listFromJson(entry.value, growable: growable,);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of required keys that must be present in a JSON.
|
||||||
|
static const requiredKeys = <String>{
|
||||||
|
'storage',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
123
mobile/openapi/lib/model/maintenance_detect_install_storage_folder_dto.dart
generated
Normal file
123
mobile/openapi/lib/model/maintenance_detect_install_storage_folder_dto.dart
generated
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.18
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
part of openapi.api;
|
||||||
|
|
||||||
|
class MaintenanceDetectInstallStorageFolderDto {
|
||||||
|
/// Returns a new [MaintenanceDetectInstallStorageFolderDto] instance.
|
||||||
|
MaintenanceDetectInstallStorageFolderDto({
|
||||||
|
required this.files,
|
||||||
|
required this.folder,
|
||||||
|
required this.readable,
|
||||||
|
required this.writable,
|
||||||
|
});
|
||||||
|
|
||||||
|
num files;
|
||||||
|
|
||||||
|
StorageFolder folder;
|
||||||
|
|
||||||
|
bool readable;
|
||||||
|
|
||||||
|
bool writable;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => identical(this, other) || other is MaintenanceDetectInstallStorageFolderDto &&
|
||||||
|
other.files == files &&
|
||||||
|
other.folder == folder &&
|
||||||
|
other.readable == readable &&
|
||||||
|
other.writable == writable;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
// ignore: unnecessary_parenthesis
|
||||||
|
(files.hashCode) +
|
||||||
|
(folder.hashCode) +
|
||||||
|
(readable.hashCode) +
|
||||||
|
(writable.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'MaintenanceDetectInstallStorageFolderDto[files=$files, folder=$folder, readable=$readable, writable=$writable]';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
json[r'files'] = this.files;
|
||||||
|
json[r'folder'] = this.folder;
|
||||||
|
json[r'readable'] = this.readable;
|
||||||
|
json[r'writable'] = this.writable;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [MaintenanceDetectInstallStorageFolderDto] instance and imports its values from
|
||||||
|
/// [value] if it's a [Map], null otherwise.
|
||||||
|
// ignore: prefer_constructors_over_static_methods
|
||||||
|
static MaintenanceDetectInstallStorageFolderDto? fromJson(dynamic value) {
|
||||||
|
upgradeDto(value, "MaintenanceDetectInstallStorageFolderDto");
|
||||||
|
if (value is Map) {
|
||||||
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
|
return MaintenanceDetectInstallStorageFolderDto(
|
||||||
|
files: num.parse('${json[r'files']}'),
|
||||||
|
folder: StorageFolder.fromJson(json[r'folder'])!,
|
||||||
|
readable: mapValueOfType<bool>(json, r'readable')!,
|
||||||
|
writable: mapValueOfType<bool>(json, r'writable')!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<MaintenanceDetectInstallStorageFolderDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <MaintenanceDetectInstallStorageFolderDto>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = MaintenanceDetectInstallStorageFolderDto.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, MaintenanceDetectInstallStorageFolderDto> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, MaintenanceDetectInstallStorageFolderDto>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = MaintenanceDetectInstallStorageFolderDto.fromJson(entry.value);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps a json object with a list of MaintenanceDetectInstallStorageFolderDto-objects as value to a dart map
|
||||||
|
static Map<String, List<MaintenanceDetectInstallStorageFolderDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<MaintenanceDetectInstallStorageFolderDto>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
json = json.cast<String, dynamic>();
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
map[entry.key] = MaintenanceDetectInstallStorageFolderDto.listFromJson(entry.value, growable: growable,);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of required keys that must be present in a JSON.
|
||||||
|
static const requiredKeys = <String>{
|
||||||
|
'files',
|
||||||
|
'folder',
|
||||||
|
'readable',
|
||||||
|
'writable',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
158
mobile/openapi/lib/model/maintenance_status_response_dto.dart
generated
Normal file
158
mobile/openapi/lib/model/maintenance_status_response_dto.dart
generated
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.18
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
part of openapi.api;
|
||||||
|
|
||||||
|
class MaintenanceStatusResponseDto {
|
||||||
|
/// Returns a new [MaintenanceStatusResponseDto] instance.
|
||||||
|
MaintenanceStatusResponseDto({
|
||||||
|
required this.action,
|
||||||
|
required this.active,
|
||||||
|
this.error,
|
||||||
|
this.progress,
|
||||||
|
this.task,
|
||||||
|
});
|
||||||
|
|
||||||
|
MaintenanceAction action;
|
||||||
|
|
||||||
|
bool active;
|
||||||
|
|
||||||
|
///
|
||||||
|
/// Please note: This property should have been non-nullable! Since the specification file
|
||||||
|
/// does not include a default value (using the "default:" property), however, the generated
|
||||||
|
/// source code must fall back to having a nullable type.
|
||||||
|
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||||
|
///
|
||||||
|
String? error;
|
||||||
|
|
||||||
|
///
|
||||||
|
/// Please note: This property should have been non-nullable! Since the specification file
|
||||||
|
/// does not include a default value (using the "default:" property), however, the generated
|
||||||
|
/// source code must fall back to having a nullable type.
|
||||||
|
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||||
|
///
|
||||||
|
num? progress;
|
||||||
|
|
||||||
|
///
|
||||||
|
/// Please note: This property should have been non-nullable! Since the specification file
|
||||||
|
/// does not include a default value (using the "default:" property), however, the generated
|
||||||
|
/// source code must fall back to having a nullable type.
|
||||||
|
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||||
|
///
|
||||||
|
String? task;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => identical(this, other) || other is MaintenanceStatusResponseDto &&
|
||||||
|
other.action == action &&
|
||||||
|
other.active == active &&
|
||||||
|
other.error == error &&
|
||||||
|
other.progress == progress &&
|
||||||
|
other.task == task;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
// ignore: unnecessary_parenthesis
|
||||||
|
(action.hashCode) +
|
||||||
|
(active.hashCode) +
|
||||||
|
(error == null ? 0 : error!.hashCode) +
|
||||||
|
(progress == null ? 0 : progress!.hashCode) +
|
||||||
|
(task == null ? 0 : task!.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'MaintenanceStatusResponseDto[action=$action, active=$active, error=$error, progress=$progress, task=$task]';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
json[r'action'] = this.action;
|
||||||
|
json[r'active'] = this.active;
|
||||||
|
if (this.error != null) {
|
||||||
|
json[r'error'] = this.error;
|
||||||
|
} else {
|
||||||
|
// json[r'error'] = null;
|
||||||
|
}
|
||||||
|
if (this.progress != null) {
|
||||||
|
json[r'progress'] = this.progress;
|
||||||
|
} else {
|
||||||
|
// json[r'progress'] = null;
|
||||||
|
}
|
||||||
|
if (this.task != null) {
|
||||||
|
json[r'task'] = this.task;
|
||||||
|
} else {
|
||||||
|
// json[r'task'] = null;
|
||||||
|
}
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [MaintenanceStatusResponseDto] instance and imports its values from
|
||||||
|
/// [value] if it's a [Map], null otherwise.
|
||||||
|
// ignore: prefer_constructors_over_static_methods
|
||||||
|
static MaintenanceStatusResponseDto? fromJson(dynamic value) {
|
||||||
|
upgradeDto(value, "MaintenanceStatusResponseDto");
|
||||||
|
if (value is Map) {
|
||||||
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
|
return MaintenanceStatusResponseDto(
|
||||||
|
action: MaintenanceAction.fromJson(json[r'action'])!,
|
||||||
|
active: mapValueOfType<bool>(json, r'active')!,
|
||||||
|
error: mapValueOfType<String>(json, r'error'),
|
||||||
|
progress: num.parse('${json[r'progress']}'),
|
||||||
|
task: mapValueOfType<String>(json, r'task'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<MaintenanceStatusResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <MaintenanceStatusResponseDto>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = MaintenanceStatusResponseDto.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, MaintenanceStatusResponseDto> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, MaintenanceStatusResponseDto>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = MaintenanceStatusResponseDto.fromJson(entry.value);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps a json object with a list of MaintenanceStatusResponseDto-objects as value to a dart map
|
||||||
|
static Map<String, List<MaintenanceStatusResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<MaintenanceStatusResponseDto>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
json = json.cast<String, dynamic>();
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
map[entry.key] = MaintenanceStatusResponseDto.listFromJson(entry.value, growable: growable,);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of required keys that must be present in a JSON.
|
||||||
|
static const requiredKeys = <String>{
|
||||||
|
'action',
|
||||||
|
'active',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
12
mobile/openapi/lib/model/permission.dart
generated
12
mobile/openapi/lib/model/permission.dart
generated
@@ -58,6 +58,10 @@ class Permission {
|
|||||||
static const authPeriodChangePassword = Permission._(r'auth.changePassword');
|
static const authPeriodChangePassword = Permission._(r'auth.changePassword');
|
||||||
static const authDevicePeriodDelete = Permission._(r'authDevice.delete');
|
static const authDevicePeriodDelete = Permission._(r'authDevice.delete');
|
||||||
static const archivePeriodRead = Permission._(r'archive.read');
|
static const archivePeriodRead = Permission._(r'archive.read');
|
||||||
|
static const backupPeriodList = Permission._(r'backup.list');
|
||||||
|
static const backupPeriodDownload = Permission._(r'backup.download');
|
||||||
|
static const backupPeriodUpload = Permission._(r'backup.upload');
|
||||||
|
static const backupPeriodDelete = Permission._(r'backup.delete');
|
||||||
static const duplicatePeriodRead = Permission._(r'duplicate.read');
|
static const duplicatePeriodRead = Permission._(r'duplicate.read');
|
||||||
static const duplicatePeriodDelete = Permission._(r'duplicate.delete');
|
static const duplicatePeriodDelete = Permission._(r'duplicate.delete');
|
||||||
static const facePeriodCreate = Permission._(r'face.create');
|
static const facePeriodCreate = Permission._(r'face.create');
|
||||||
@@ -206,6 +210,10 @@ class Permission {
|
|||||||
authPeriodChangePassword,
|
authPeriodChangePassword,
|
||||||
authDevicePeriodDelete,
|
authDevicePeriodDelete,
|
||||||
archivePeriodRead,
|
archivePeriodRead,
|
||||||
|
backupPeriodList,
|
||||||
|
backupPeriodDownload,
|
||||||
|
backupPeriodUpload,
|
||||||
|
backupPeriodDelete,
|
||||||
duplicatePeriodRead,
|
duplicatePeriodRead,
|
||||||
duplicatePeriodDelete,
|
duplicatePeriodDelete,
|
||||||
facePeriodCreate,
|
facePeriodCreate,
|
||||||
@@ -389,6 +397,10 @@ class PermissionTypeTransformer {
|
|||||||
case r'auth.changePassword': return Permission.authPeriodChangePassword;
|
case r'auth.changePassword': return Permission.authPeriodChangePassword;
|
||||||
case r'authDevice.delete': return Permission.authDevicePeriodDelete;
|
case r'authDevice.delete': return Permission.authDevicePeriodDelete;
|
||||||
case r'archive.read': return Permission.archivePeriodRead;
|
case r'archive.read': return Permission.archivePeriodRead;
|
||||||
|
case r'backup.list': return Permission.backupPeriodList;
|
||||||
|
case r'backup.download': return Permission.backupPeriodDownload;
|
||||||
|
case r'backup.upload': return Permission.backupPeriodUpload;
|
||||||
|
case r'backup.delete': return Permission.backupPeriodDelete;
|
||||||
case r'duplicate.read': return Permission.duplicatePeriodRead;
|
case r'duplicate.read': return Permission.duplicatePeriodRead;
|
||||||
case r'duplicate.delete': return Permission.duplicatePeriodDelete;
|
case r'duplicate.delete': return Permission.duplicatePeriodDelete;
|
||||||
case r'face.create': return Permission.facePeriodCreate;
|
case r'face.create': return Permission.facePeriodCreate;
|
||||||
|
|||||||
@@ -14,25 +14,41 @@ class SetMaintenanceModeDto {
|
|||||||
/// Returns a new [SetMaintenanceModeDto] instance.
|
/// Returns a new [SetMaintenanceModeDto] instance.
|
||||||
SetMaintenanceModeDto({
|
SetMaintenanceModeDto({
|
||||||
required this.action,
|
required this.action,
|
||||||
|
this.restoreBackupFilename,
|
||||||
});
|
});
|
||||||
|
|
||||||
MaintenanceAction action;
|
MaintenanceAction action;
|
||||||
|
|
||||||
|
///
|
||||||
|
/// Please note: This property should have been non-nullable! Since the specification file
|
||||||
|
/// does not include a default value (using the "default:" property), however, the generated
|
||||||
|
/// source code must fall back to having a nullable type.
|
||||||
|
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||||
|
///
|
||||||
|
String? restoreBackupFilename;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) => identical(this, other) || other is SetMaintenanceModeDto &&
|
bool operator ==(Object other) => identical(this, other) || other is SetMaintenanceModeDto &&
|
||||||
other.action == action;
|
other.action == action &&
|
||||||
|
other.restoreBackupFilename == restoreBackupFilename;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
// ignore: unnecessary_parenthesis
|
// ignore: unnecessary_parenthesis
|
||||||
(action.hashCode);
|
(action.hashCode) +
|
||||||
|
(restoreBackupFilename == null ? 0 : restoreBackupFilename!.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'SetMaintenanceModeDto[action=$action]';
|
String toString() => 'SetMaintenanceModeDto[action=$action, restoreBackupFilename=$restoreBackupFilename]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
json[r'action'] = this.action;
|
json[r'action'] = this.action;
|
||||||
|
if (this.restoreBackupFilename != null) {
|
||||||
|
json[r'restoreBackupFilename'] = this.restoreBackupFilename;
|
||||||
|
} else {
|
||||||
|
// json[r'restoreBackupFilename'] = null;
|
||||||
|
}
|
||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +62,7 @@ class SetMaintenanceModeDto {
|
|||||||
|
|
||||||
return SetMaintenanceModeDto(
|
return SetMaintenanceModeDto(
|
||||||
action: MaintenanceAction.fromJson(json[r'action'])!,
|
action: MaintenanceAction.fromJson(json[r'action'])!,
|
||||||
|
restoreBackupFilename: mapValueOfType<String>(json, r'restoreBackupFilename'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
97
mobile/openapi/lib/model/storage_folder.dart
generated
Normal file
97
mobile/openapi/lib/model/storage_folder.dart
generated
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.18
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
part of openapi.api;
|
||||||
|
|
||||||
|
|
||||||
|
class StorageFolder {
|
||||||
|
/// Instantiate a new enum with the provided [value].
|
||||||
|
const StorageFolder._(this.value);
|
||||||
|
|
||||||
|
/// The underlying value of this enum member.
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => value;
|
||||||
|
|
||||||
|
String toJson() => value;
|
||||||
|
|
||||||
|
static const encodedVideo = StorageFolder._(r'encoded-video');
|
||||||
|
static const library_ = StorageFolder._(r'library');
|
||||||
|
static const upload = StorageFolder._(r'upload');
|
||||||
|
static const profile = StorageFolder._(r'profile');
|
||||||
|
static const thumbs = StorageFolder._(r'thumbs');
|
||||||
|
static const backups = StorageFolder._(r'backups');
|
||||||
|
|
||||||
|
/// List of all possible values in this [enum][StorageFolder].
|
||||||
|
static const values = <StorageFolder>[
|
||||||
|
encodedVideo,
|
||||||
|
library_,
|
||||||
|
upload,
|
||||||
|
profile,
|
||||||
|
thumbs,
|
||||||
|
backups,
|
||||||
|
];
|
||||||
|
|
||||||
|
static StorageFolder? fromJson(dynamic value) => StorageFolderTypeTransformer().decode(value);
|
||||||
|
|
||||||
|
static List<StorageFolder> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <StorageFolder>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = StorageFolder.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transformation class that can [encode] an instance of [StorageFolder] to String,
|
||||||
|
/// and [decode] dynamic data back to [StorageFolder].
|
||||||
|
class StorageFolderTypeTransformer {
|
||||||
|
factory StorageFolderTypeTransformer() => _instance ??= const StorageFolderTypeTransformer._();
|
||||||
|
|
||||||
|
const StorageFolderTypeTransformer._();
|
||||||
|
|
||||||
|
String encode(StorageFolder data) => data.value;
|
||||||
|
|
||||||
|
/// Decodes a [dynamic value][data] to a StorageFolder.
|
||||||
|
///
|
||||||
|
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
|
||||||
|
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
|
||||||
|
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
|
||||||
|
///
|
||||||
|
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
|
||||||
|
/// and users are still using an old app with the old code.
|
||||||
|
StorageFolder? decode(dynamic data, {bool allowNull = true}) {
|
||||||
|
if (data != null) {
|
||||||
|
switch (data) {
|
||||||
|
case r'encoded-video': return StorageFolder.encodedVideo;
|
||||||
|
case r'library': return StorageFolder.library_;
|
||||||
|
case r'upload': return StorageFolder.upload;
|
||||||
|
case r'profile': return StorageFolder.profile;
|
||||||
|
case r'thumbs': return StorageFolder.thumbs;
|
||||||
|
case r'backups': return StorageFolder.backups;
|
||||||
|
default:
|
||||||
|
if (!allowNull) {
|
||||||
|
throw ArgumentError('Unknown enum value to decode: $data');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Singleton [StorageFolderTypeTransformer] instance.
|
||||||
|
static StorageFolderTypeTransformer? _instance;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -77,15 +77,15 @@ void main() {
|
|||||||
overrides: overrides,
|
overrides: overrides,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(find.widgetWithIcon(IconButton, Icons.favorite_rounded), findsOneWidget);
|
expect(find.widgetWithIcon(IconButton, Icons.thumb_up), findsOneWidget);
|
||||||
expect(find.widgetWithIcon(IconButton, Icons.favorite_border_rounded), findsNothing);
|
expect(find.widgetWithIcon(IconButton, Icons.thumb_up_off_alt), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Bordered icon if likedId == null', (tester) async {
|
testWidgets('Bordered icon if likedId == null', (tester) async {
|
||||||
await tester.pumpConsumerWidget(ActivityTextField(onSubmit: (_) {}), overrides: overrides);
|
await tester.pumpConsumerWidget(ActivityTextField(onSubmit: (_) {}), overrides: overrides);
|
||||||
|
|
||||||
expect(find.widgetWithIcon(IconButton, Icons.favorite_border_rounded), findsOneWidget);
|
expect(find.widgetWithIcon(IconButton, Icons.thumb_up_off_alt), findsOneWidget);
|
||||||
expect(find.widgetWithIcon(IconButton, Icons.favorite_rounded), findsNothing);
|
expect(find.widgetWithIcon(IconButton, Icons.thumb_up), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Adds new like', (tester) async {
|
testWidgets('Adds new like', (tester) async {
|
||||||
|
|||||||
@@ -91,17 +91,17 @@ void main() {
|
|||||||
group('Like Activity', () {
|
group('Like Activity', () {
|
||||||
final activity = Activity(id: '1', createdAt: DateTime(100), type: ActivityType.like, user: UserStub.admin);
|
final activity = Activity(id: '1', createdAt: DateTime(100), type: ActivityType.like, user: UserStub.admin);
|
||||||
|
|
||||||
testWidgets('Like contains filled heart as leading', (tester) async {
|
testWidgets('Like contains filled thumbs-up as leading', (tester) async {
|
||||||
await tester.pumpConsumerWidget(ActivityTile(activity), overrides: overrides);
|
await tester.pumpConsumerWidget(ActivityTile(activity), overrides: overrides);
|
||||||
|
|
||||||
// Leading widget should not be null
|
// Leading widget should not be null
|
||||||
final listTile = tester.widget<ListTile>(find.byType(ListTile));
|
final listTile = tester.widget<ListTile>(find.byType(ListTile));
|
||||||
expect(listTile.leading, isNotNull);
|
expect(listTile.leading, isNotNull);
|
||||||
|
|
||||||
// And should have a favorite icon
|
// And should have a thumb_up icon
|
||||||
final favoIconFinder = find.widgetWithIcon(listTile.leading!.runtimeType, Icons.favorite_rounded);
|
final thumbUpIconFinder = find.widgetWithIcon(listTile.leading!.runtimeType, Icons.thumb_up);
|
||||||
|
|
||||||
expect(favoIconFinder, findsOneWidget);
|
expect(thumbUpIconFinder, findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Like title is center aligned', (tester) async {
|
testWidgets('Like title is center aligned', (tester) async {
|
||||||
|
|||||||
@@ -322,6 +322,237 @@
|
|||||||
"x-immich-state": "Stable"
|
"x-immich-state": "Stable"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/admin/database-backups": {
|
||||||
|
"delete": {
|
||||||
|
"description": "Delete a backup by its filename",
|
||||||
|
"operationId": "deleteDatabaseBackup",
|
||||||
|
"parameters": [],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/DatabaseBackupDeleteDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Delete database backup",
|
||||||
|
"tags": [
|
||||||
|
"Database Backups (admin)"
|
||||||
|
],
|
||||||
|
"x-immich-admin-only": true,
|
||||||
|
"x-immich-history": [
|
||||||
|
{
|
||||||
|
"version": "v2.4.0",
|
||||||
|
"state": "Added"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "v2.4.0",
|
||||||
|
"state": "Alpha"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-immich-permission": "backup.delete",
|
||||||
|
"x-immich-state": "Alpha"
|
||||||
|
},
|
||||||
|
"get": {
|
||||||
|
"description": "Get the list of the successful and failed backups",
|
||||||
|
"operationId": "listDatabaseBackups",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/DatabaseBackupListResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "List database backups",
|
||||||
|
"tags": [
|
||||||
|
"Database Backups (admin)"
|
||||||
|
],
|
||||||
|
"x-immich-admin-only": true,
|
||||||
|
"x-immich-history": [
|
||||||
|
{
|
||||||
|
"version": "v2.4.0",
|
||||||
|
"state": "Added"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "v2.4.0",
|
||||||
|
"state": "Alpha"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-immich-permission": "maintenance",
|
||||||
|
"x-immich-state": "Alpha"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/admin/database-backups/start-restore": {
|
||||||
|
"post": {
|
||||||
|
"description": "Put Immich into maintenance mode to restore a backup (Immich must not be configured)",
|
||||||
|
"operationId": "startDatabaseRestoreFlow",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": "Start database backup restore flow",
|
||||||
|
"tags": [
|
||||||
|
"Database Backups (admin)"
|
||||||
|
],
|
||||||
|
"x-immich-history": [
|
||||||
|
{
|
||||||
|
"version": "v2.4.0",
|
||||||
|
"state": "Added"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "v2.4.0",
|
||||||
|
"state": "Alpha"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-immich-state": "Alpha"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/admin/database-backups/upload": {
|
||||||
|
"post": {
|
||||||
|
"description": "Uploads .sql/.sql.gz file to restore backup from",
|
||||||
|
"operationId": "uploadDatabaseBackup",
|
||||||
|
"parameters": [],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"multipart/form-data": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/DatabaseBackupUploadDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Backup Upload",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Upload database backup",
|
||||||
|
"tags": [
|
||||||
|
"Database Backups (admin)"
|
||||||
|
],
|
||||||
|
"x-immich-admin-only": true,
|
||||||
|
"x-immich-history": [
|
||||||
|
{
|
||||||
|
"version": "v2.4.0",
|
||||||
|
"state": "Added"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "v2.4.0",
|
||||||
|
"state": "Alpha"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-immich-permission": "backup.upload",
|
||||||
|
"x-immich-state": "Alpha"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/admin/database-backups/{filename}": {
|
||||||
|
"get": {
|
||||||
|
"description": "Downloads the database backup file",
|
||||||
|
"operationId": "downloadDatabaseBackup",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "filename",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"format": "string",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/octet-stream": {
|
||||||
|
"schema": {
|
||||||
|
"format": "binary",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Download database backup",
|
||||||
|
"tags": [
|
||||||
|
"Database Backups (admin)"
|
||||||
|
],
|
||||||
|
"x-immich-admin-only": true,
|
||||||
|
"x-immich-history": [
|
||||||
|
{
|
||||||
|
"version": "v2.4.0",
|
||||||
|
"state": "Added"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "v2.4.0",
|
||||||
|
"state": "Alpha"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-immich-permission": "backup.download",
|
||||||
|
"x-immich-state": "Alpha"
|
||||||
|
}
|
||||||
|
},
|
||||||
"/admin/maintenance": {
|
"/admin/maintenance": {
|
||||||
"post": {
|
"post": {
|
||||||
"description": "Put Immich into or take it out of maintenance mode",
|
"description": "Put Immich into or take it out of maintenance mode",
|
||||||
@@ -372,6 +603,53 @@
|
|||||||
"x-immich-state": "Alpha"
|
"x-immich-state": "Alpha"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/admin/maintenance/detect-install": {
|
||||||
|
"get": {
|
||||||
|
"description": "Collect integrity checks and other heuristics about local data.",
|
||||||
|
"operationId": "detectPriorInstall",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/MaintenanceDetectInstallResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Detect existing install",
|
||||||
|
"tags": [
|
||||||
|
"Maintenance (admin)"
|
||||||
|
],
|
||||||
|
"x-immich-admin-only": true,
|
||||||
|
"x-immich-history": [
|
||||||
|
{
|
||||||
|
"version": "v2.4.0",
|
||||||
|
"state": "Added"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "v2.4.0",
|
||||||
|
"state": "Alpha"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-immich-permission": "maintenance",
|
||||||
|
"x-immich-state": "Alpha"
|
||||||
|
}
|
||||||
|
},
|
||||||
"/admin/maintenance/login": {
|
"/admin/maintenance/login": {
|
||||||
"post": {
|
"post": {
|
||||||
"description": "Login with maintenance token or cookie to receive current information and perform further actions.",
|
"description": "Login with maintenance token or cookie to receive current information and perform further actions.",
|
||||||
@@ -416,6 +694,40 @@
|
|||||||
"x-immich-state": "Alpha"
|
"x-immich-state": "Alpha"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/admin/maintenance/status": {
|
||||||
|
"get": {
|
||||||
|
"description": "Fetch information about the currently running maintenance action.",
|
||||||
|
"operationId": "getMaintenanceStatus",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/MaintenanceStatusResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"summary": "Get maintenance mode status",
|
||||||
|
"tags": [
|
||||||
|
"Maintenance (admin)"
|
||||||
|
],
|
||||||
|
"x-immich-history": [
|
||||||
|
{
|
||||||
|
"version": "v2.4.0",
|
||||||
|
"state": "Added"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "v2.4.0",
|
||||||
|
"state": "Alpha"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-immich-state": "Alpha"
|
||||||
|
}
|
||||||
|
},
|
||||||
"/admin/notifications": {
|
"/admin/notifications": {
|
||||||
"post": {
|
"post": {
|
||||||
"description": "Create a new notification for a specific user.",
|
"description": "Create a new notification for a specific user.",
|
||||||
@@ -14296,6 +14608,10 @@
|
|||||||
"name": "Authentication (admin)",
|
"name": "Authentication (admin)",
|
||||||
"description": "Administrative endpoints related to authentication."
|
"description": "Administrative endpoints related to authentication."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Database Backups (admin)",
|
||||||
|
"description": "Manage backups of the Immich database."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Deprecated",
|
"name": "Deprecated",
|
||||||
"description": "Deprecated endpoints that are planned for removal in the next major release."
|
"description": "Deprecated endpoints that are planned for removal in the next major release."
|
||||||
@@ -16233,6 +16549,43 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"DatabaseBackupDeleteDto": {
|
||||||
|
"properties": {
|
||||||
|
"backups": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"backups"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"DatabaseBackupListResponseDto": {
|
||||||
|
"properties": {
|
||||||
|
"backups": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"backups"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"DatabaseBackupUploadDto": {
|
||||||
|
"properties": {
|
||||||
|
"file": {
|
||||||
|
"format": "binary",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"DownloadArchiveInfo": {
|
"DownloadArchiveInfo": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"assetIds": {
|
"assetIds": {
|
||||||
@@ -16899,7 +17252,8 @@
|
|||||||
"MaintenanceAction": {
|
"MaintenanceAction": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"start",
|
"start",
|
||||||
"end"
|
"end",
|
||||||
|
"restore_database"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -16914,6 +17268,47 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"MaintenanceDetectInstallResponseDto": {
|
||||||
|
"properties": {
|
||||||
|
"storage": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/MaintenanceDetectInstallStorageFolderDto"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"storage"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"MaintenanceDetectInstallStorageFolderDto": {
|
||||||
|
"properties": {
|
||||||
|
"files": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"folder": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/StorageFolder"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"readable": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"writable": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"files",
|
||||||
|
"folder",
|
||||||
|
"readable",
|
||||||
|
"writable"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"MaintenanceLoginDto": {
|
"MaintenanceLoginDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"token": {
|
"token": {
|
||||||
@@ -16922,6 +17317,34 @@
|
|||||||
},
|
},
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"MaintenanceStatusResponseDto": {
|
||||||
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/MaintenanceAction"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"active": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"task": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"action",
|
||||||
|
"active"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"ManualJobName": {
|
"ManualJobName": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"person-cleanup",
|
"person-cleanup",
|
||||||
@@ -17862,6 +18285,10 @@
|
|||||||
"auth.changePassword",
|
"auth.changePassword",
|
||||||
"authDevice.delete",
|
"authDevice.delete",
|
||||||
"archive.read",
|
"archive.read",
|
||||||
|
"backup.list",
|
||||||
|
"backup.download",
|
||||||
|
"backup.upload",
|
||||||
|
"backup.delete",
|
||||||
"duplicate.read",
|
"duplicate.read",
|
||||||
"duplicate.delete",
|
"duplicate.delete",
|
||||||
"face.create",
|
"face.create",
|
||||||
@@ -19600,6 +20027,9 @@
|
|||||||
"$ref": "#/components/schemas/MaintenanceAction"
|
"$ref": "#/components/schemas/MaintenanceAction"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"restoreBackupFilename": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -20172,6 +20602,17 @@
|
|||||||
},
|
},
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"StorageFolder": {
|
||||||
|
"enum": [
|
||||||
|
"encoded-video",
|
||||||
|
"library",
|
||||||
|
"upload",
|
||||||
|
"profile",
|
||||||
|
"thumbs",
|
||||||
|
"backups"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"SyncAckDeleteDto": {
|
"SyncAckDeleteDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"types": {
|
"types": {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"@oazapfts/runtime": "^1.0.2"
|
"@oazapfts/runtime": "^1.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.3",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -40,8 +40,27 @@ export type ActivityStatisticsResponseDto = {
|
|||||||
comments: number;
|
comments: number;
|
||||||
likes: number;
|
likes: number;
|
||||||
};
|
};
|
||||||
|
export type DatabaseBackupDeleteDto = {
|
||||||
|
backups: string[];
|
||||||
|
};
|
||||||
|
export type DatabaseBackupListResponseDto = {
|
||||||
|
backups: string[];
|
||||||
|
};
|
||||||
|
export type DatabaseBackupUploadDto = {
|
||||||
|
file?: Blob;
|
||||||
|
};
|
||||||
export type SetMaintenanceModeDto = {
|
export type SetMaintenanceModeDto = {
|
||||||
action: MaintenanceAction;
|
action: MaintenanceAction;
|
||||||
|
restoreBackupFilename?: string;
|
||||||
|
};
|
||||||
|
export type MaintenanceDetectInstallStorageFolderDto = {
|
||||||
|
files: number;
|
||||||
|
folder: StorageFolder;
|
||||||
|
readable: boolean;
|
||||||
|
writable: boolean;
|
||||||
|
};
|
||||||
|
export type MaintenanceDetectInstallResponseDto = {
|
||||||
|
storage: MaintenanceDetectInstallStorageFolderDto[];
|
||||||
};
|
};
|
||||||
export type MaintenanceLoginDto = {
|
export type MaintenanceLoginDto = {
|
||||||
token?: string;
|
token?: string;
|
||||||
@@ -49,6 +68,13 @@ export type MaintenanceLoginDto = {
|
|||||||
export type MaintenanceAuthDto = {
|
export type MaintenanceAuthDto = {
|
||||||
username: string;
|
username: string;
|
||||||
};
|
};
|
||||||
|
export type MaintenanceStatusResponseDto = {
|
||||||
|
action: MaintenanceAction;
|
||||||
|
active: boolean;
|
||||||
|
error?: string;
|
||||||
|
progress?: number;
|
||||||
|
task?: string;
|
||||||
|
};
|
||||||
export type NotificationCreateDto = {
|
export type NotificationCreateDto = {
|
||||||
data?: object;
|
data?: object;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
@@ -1850,6 +1876,63 @@ export function unlinkAllOAuthAccountsAdmin(opts?: Oazapfts.RequestOpts) {
|
|||||||
method: "POST"
|
method: "POST"
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Delete database backup
|
||||||
|
*/
|
||||||
|
export function deleteDatabaseBackup({ databaseBackupDeleteDto }: {
|
||||||
|
databaseBackupDeleteDto: DatabaseBackupDeleteDto;
|
||||||
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchText("/admin/database-backups", oazapfts.json({
|
||||||
|
...opts,
|
||||||
|
method: "DELETE",
|
||||||
|
body: databaseBackupDeleteDto
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* List database backups
|
||||||
|
*/
|
||||||
|
export function listDatabaseBackups(opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
|
status: 200;
|
||||||
|
data: DatabaseBackupListResponseDto;
|
||||||
|
}>("/admin/database-backups", {
|
||||||
|
...opts
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Start database backup restore flow
|
||||||
|
*/
|
||||||
|
export function startDatabaseRestoreFlow(opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchText("/admin/database-backups/start-restore", {
|
||||||
|
...opts,
|
||||||
|
method: "POST"
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Upload database backup
|
||||||
|
*/
|
||||||
|
export function uploadDatabaseBackup({ databaseBackupUploadDto }: {
|
||||||
|
databaseBackupUploadDto: DatabaseBackupUploadDto;
|
||||||
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchText("/admin/database-backups/upload", oazapfts.multipart({
|
||||||
|
...opts,
|
||||||
|
method: "POST",
|
||||||
|
body: databaseBackupUploadDto
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Download database backup
|
||||||
|
*/
|
||||||
|
export function downloadDatabaseBackup({ filename }: {
|
||||||
|
filename: string;
|
||||||
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchBlob<{
|
||||||
|
status: 200;
|
||||||
|
data: Blob;
|
||||||
|
}>(`/admin/database-backups/${encodeURIComponent(filename)}`, {
|
||||||
|
...opts
|
||||||
|
}));
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Set maintenance mode
|
* Set maintenance mode
|
||||||
*/
|
*/
|
||||||
@@ -1862,6 +1945,17 @@ export function setMaintenanceMode({ setMaintenanceModeDto }: {
|
|||||||
body: setMaintenanceModeDto
|
body: setMaintenanceModeDto
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Detect existing install
|
||||||
|
*/
|
||||||
|
export function detectPriorInstall(opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
|
status: 200;
|
||||||
|
data: MaintenanceDetectInstallResponseDto;
|
||||||
|
}>("/admin/maintenance/detect-install", {
|
||||||
|
...opts
|
||||||
|
}));
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Log into maintenance mode
|
* Log into maintenance mode
|
||||||
*/
|
*/
|
||||||
@@ -1877,6 +1971,17 @@ export function maintenanceLogin({ maintenanceLoginDto }: {
|
|||||||
body: maintenanceLoginDto
|
body: maintenanceLoginDto
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Get maintenance mode status
|
||||||
|
*/
|
||||||
|
export function getMaintenanceStatus(opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
|
status: 200;
|
||||||
|
data: MaintenanceStatusResponseDto;
|
||||||
|
}>("/admin/maintenance/status", {
|
||||||
|
...opts
|
||||||
|
}));
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Create a notification
|
* Create a notification
|
||||||
*/
|
*/
|
||||||
@@ -5140,7 +5245,16 @@ export enum UserAvatarColor {
|
|||||||
}
|
}
|
||||||
export enum MaintenanceAction {
|
export enum MaintenanceAction {
|
||||||
Start = "start",
|
Start = "start",
|
||||||
End = "end"
|
End = "end",
|
||||||
|
RestoreDatabase = "restore_database"
|
||||||
|
}
|
||||||
|
export enum StorageFolder {
|
||||||
|
EncodedVideo = "encoded-video",
|
||||||
|
Library = "library",
|
||||||
|
Upload = "upload",
|
||||||
|
Profile = "profile",
|
||||||
|
Thumbs = "thumbs",
|
||||||
|
Backups = "backups"
|
||||||
}
|
}
|
||||||
export enum NotificationLevel {
|
export enum NotificationLevel {
|
||||||
Success = "success",
|
Success = "success",
|
||||||
@@ -5234,6 +5348,10 @@ export enum Permission {
|
|||||||
AuthChangePassword = "auth.changePassword",
|
AuthChangePassword = "auth.changePassword",
|
||||||
AuthDeviceDelete = "authDevice.delete",
|
AuthDeviceDelete = "authDevice.delete",
|
||||||
ArchiveRead = "archive.read",
|
ArchiveRead = "archive.read",
|
||||||
|
BackupList = "backup.list",
|
||||||
|
BackupDownload = "backup.download",
|
||||||
|
BackupUpload = "backup.upload",
|
||||||
|
BackupDelete = "backup.delete",
|
||||||
DuplicateRead = "duplicate.read",
|
DuplicateRead = "duplicate.read",
|
||||||
DuplicateDelete = "duplicate.delete",
|
DuplicateDelete = "duplicate.delete",
|
||||||
FaceCreate = "face.create",
|
FaceCreate = "face.create",
|
||||||
|
|||||||
3733
pnpm-lock.yaml
generated
3733
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -69,7 +69,7 @@
|
|||||||
"compression": "^1.8.0",
|
"compression": "^1.8.0",
|
||||||
"cookie": "^1.0.2",
|
"cookie": "^1.0.2",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cron": "4.3.3",
|
"cron": "4.3.5",
|
||||||
"exiftool-vendored": "^34.0.0",
|
"exiftool-vendored": "^34.0.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"fast-glob": "^3.3.2",
|
"fast-glob": "^3.3.2",
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
"@types/luxon": "^3.6.2",
|
"@types/luxon": "^3.6.2",
|
||||||
"@types/mock-fs": "^4.13.1",
|
"@types/mock-fs": "^4.13.1",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.3",
|
||||||
"@types/nodemailer": "^7.0.0",
|
"@types/nodemailer": "^7.0.0",
|
||||||
"@types/picomatch": "^4.0.0",
|
"@types/picomatch": "^4.0.0",
|
||||||
"@types/pngjs": "^6.0.5",
|
"@types/pngjs": "^6.0.5",
|
||||||
|
|||||||
@@ -21,8 +21,11 @@ import { LoggingInterceptor } from 'src/middleware/logging.interceptor';
|
|||||||
import { repositories } from 'src/repositories';
|
import { repositories } from 'src/repositories';
|
||||||
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 { EventRepository } from 'src/repositories/event.repository';
|
import { EventRepository } from 'src/repositories/event.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 { teardownTelemetry, TelemetryRepository } from 'src/repositories/telemetry.repository';
|
import { teardownTelemetry, TelemetryRepository } from 'src/repositories/telemetry.repository';
|
||||||
import { WebsocketRepository } from 'src/repositories/websocket.repository';
|
import { WebsocketRepository } from 'src/repositories/websocket.repository';
|
||||||
@@ -103,6 +106,9 @@ export class ApiModule extends BaseModule {}
|
|||||||
providers: [
|
providers: [
|
||||||
ConfigRepository,
|
ConfigRepository,
|
||||||
LoggingRepository,
|
LoggingRepository,
|
||||||
|
StorageRepository,
|
||||||
|
ProcessRepository,
|
||||||
|
DatabaseRepository,
|
||||||
SystemMetadataRepository,
|
SystemMetadataRepository,
|
||||||
AppRepository,
|
AppRepository,
|
||||||
MaintenanceWebsocketRepository,
|
MaintenanceWebsocketRepository,
|
||||||
@@ -116,9 +122,14 @@ export class MaintenanceModule {
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject(IWorker) private worker: ImmichWorker,
|
@Inject(IWorker) private worker: ImmichWorker,
|
||||||
logger: LoggingRepository,
|
logger: LoggingRepository,
|
||||||
|
private maintenanceWorkerService: MaintenanceWorkerService,
|
||||||
) {
|
) {
|
||||||
logger.setAppName(this.worker);
|
logger.setAppName(this.worker);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.maintenanceWorkerService.init();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ export const endpointTags: Record<ApiTag, string> = {
|
|||||||
[ApiTag.Assets]: 'An asset is an image or video that has been uploaded to Immich.',
|
[ApiTag.Assets]: 'An asset is an image or video that has been uploaded to Immich.',
|
||||||
[ApiTag.Authentication]: 'Endpoints related to user authentication, including OAuth.',
|
[ApiTag.Authentication]: 'Endpoints related to user authentication, including OAuth.',
|
||||||
[ApiTag.AuthenticationAdmin]: 'Administrative endpoints related to authentication.',
|
[ApiTag.AuthenticationAdmin]: 'Administrative endpoints related to authentication.',
|
||||||
|
[ApiTag.DatabaseBackups]: 'Manage backups of the Immich database.',
|
||||||
[ApiTag.Deprecated]: 'Deprecated endpoints that are planned for removal in the next major release.',
|
[ApiTag.Deprecated]: 'Deprecated endpoints that are planned for removal in the next major release.',
|
||||||
[ApiTag.Download]: 'Endpoints for downloading assets or collections of assets.',
|
[ApiTag.Download]: 'Endpoints for downloading assets or collections of assets.',
|
||||||
[ApiTag.Duplicates]: 'Endpoints for managing and identifying duplicate assets.',
|
[ApiTag.Duplicates]: 'Endpoints for managing and identifying duplicate assets.',
|
||||||
|
|||||||
101
server/src/controllers/database-backup.controller.ts
Normal file
101
server/src/controllers/database-backup.controller.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Next, Param, Post, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { NextFunction, Response } from 'express';
|
||||||
|
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||||
|
import {
|
||||||
|
DatabaseBackupDeleteDto,
|
||||||
|
DatabaseBackupListResponseDto,
|
||||||
|
DatabaseBackupUploadDto,
|
||||||
|
} from 'src/dtos/database-backup.dto';
|
||||||
|
import { ApiTag, ImmichCookie, Permission } from 'src/enum';
|
||||||
|
import { Authenticated, FileResponse, GetLoginDetails } from 'src/middleware/auth.guard';
|
||||||
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
|
import { LoginDetails } from 'src/services/auth.service';
|
||||||
|
import { DatabaseBackupService } from 'src/services/database-backup.service';
|
||||||
|
import { MaintenanceService } from 'src/services/maintenance.service';
|
||||||
|
import { sendFile } from 'src/utils/file';
|
||||||
|
import { respondWithCookie } from 'src/utils/response';
|
||||||
|
import { FilenameParamDto } from 'src/validation';
|
||||||
|
|
||||||
|
@ApiTags(ApiTag.DatabaseBackups)
|
||||||
|
@Controller('admin/database-backups')
|
||||||
|
export class DatabaseBackupController {
|
||||||
|
constructor(
|
||||||
|
private logger: LoggingRepository,
|
||||||
|
private service: DatabaseBackupService,
|
||||||
|
private maintenanceService: MaintenanceService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Endpoint({
|
||||||
|
summary: 'List database backups',
|
||||||
|
description: 'Get the list of the successful and failed backups',
|
||||||
|
history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'),
|
||||||
|
})
|
||||||
|
@Authenticated({ permission: Permission.Maintenance, admin: true })
|
||||||
|
listDatabaseBackups(): Promise<DatabaseBackupListResponseDto> {
|
||||||
|
return this.service.listBackups();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':filename')
|
||||||
|
@FileResponse()
|
||||||
|
@Endpoint({
|
||||||
|
summary: 'Download database backup',
|
||||||
|
description: 'Downloads the database backup file',
|
||||||
|
history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'),
|
||||||
|
})
|
||||||
|
@Authenticated({ permission: Permission.BackupDownload, admin: true })
|
||||||
|
async downloadDatabaseBackup(
|
||||||
|
@Param() { filename }: FilenameParamDto,
|
||||||
|
@Res() res: Response,
|
||||||
|
@Next() next: NextFunction,
|
||||||
|
): Promise<void> {
|
||||||
|
await sendFile(res, next, () => this.service.downloadBackup(filename), this.logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete()
|
||||||
|
@Endpoint({
|
||||||
|
summary: 'Delete database backup',
|
||||||
|
description: 'Delete a backup by its filename',
|
||||||
|
history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'),
|
||||||
|
})
|
||||||
|
@Authenticated({ permission: Permission.BackupDelete, admin: true })
|
||||||
|
async deleteDatabaseBackup(@Body() dto: DatabaseBackupDeleteDto): Promise<void> {
|
||||||
|
return this.service.deleteBackup(dto.backups);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('start-restore')
|
||||||
|
@Endpoint({
|
||||||
|
summary: 'Start database backup restore flow',
|
||||||
|
description: 'Put Immich into maintenance mode to restore a backup (Immich must not be configured)',
|
||||||
|
history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'),
|
||||||
|
})
|
||||||
|
async startDatabaseRestoreFlow(
|
||||||
|
@GetLoginDetails() loginDetails: LoginDetails,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const { jwt } = await this.maintenanceService.startRestoreFlow();
|
||||||
|
return respondWithCookie(res, undefined, {
|
||||||
|
isSecure: loginDetails.isSecure,
|
||||||
|
values: [{ key: ImmichCookie.MaintenanceToken, value: jwt }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('upload')
|
||||||
|
@Authenticated({ permission: Permission.BackupUpload, admin: true })
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiBody({ description: 'Backup Upload', type: DatabaseBackupUploadDto })
|
||||||
|
@Endpoint({
|
||||||
|
summary: 'Upload database backup',
|
||||||
|
description: 'Uploads .sql/.sql.gz file to restore backup from',
|
||||||
|
history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'),
|
||||||
|
})
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
uploadDatabaseBackup(
|
||||||
|
@UploadedFile()
|
||||||
|
file: Express.Multer.File,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.service.uploadBackup(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { AssetMediaController } from 'src/controllers/asset-media.controller';
|
|||||||
import { AssetController } from 'src/controllers/asset.controller';
|
import { AssetController } from 'src/controllers/asset.controller';
|
||||||
import { AuthAdminController } from 'src/controllers/auth-admin.controller';
|
import { AuthAdminController } from 'src/controllers/auth-admin.controller';
|
||||||
import { AuthController } from 'src/controllers/auth.controller';
|
import { AuthController } from 'src/controllers/auth.controller';
|
||||||
|
import { DatabaseBackupController } from 'src/controllers/database-backup.controller';
|
||||||
import { DownloadController } from 'src/controllers/download.controller';
|
import { DownloadController } from 'src/controllers/download.controller';
|
||||||
import { DuplicateController } from 'src/controllers/duplicate.controller';
|
import { DuplicateController } from 'src/controllers/duplicate.controller';
|
||||||
import { FaceController } from 'src/controllers/face.controller';
|
import { FaceController } from 'src/controllers/face.controller';
|
||||||
@@ -46,6 +47,7 @@ export const controllers = [
|
|||||||
AssetMediaController,
|
AssetMediaController,
|
||||||
AuthController,
|
AuthController,
|
||||||
AuthAdminController,
|
AuthAdminController,
|
||||||
|
DatabaseBackupController,
|
||||||
DownloadController,
|
DownloadController,
|
||||||
DuplicateController,
|
DuplicateController,
|
||||||
FaceController,
|
FaceController,
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { BadRequestException, Body, Controller, Post, Res } from '@nestjs/common';
|
import { BadRequestException, Body, Controller, Get, Post, Res } from '@nestjs/common';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { MaintenanceAuthDto, MaintenanceLoginDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
|
import {
|
||||||
|
MaintenanceAuthDto,
|
||||||
|
MaintenanceDetectInstallResponseDto,
|
||||||
|
MaintenanceLoginDto,
|
||||||
|
MaintenanceStatusResponseDto,
|
||||||
|
SetMaintenanceModeDto,
|
||||||
|
} from 'src/dtos/maintenance.dto';
|
||||||
import { ApiTag, ImmichCookie, MaintenanceAction, Permission } from 'src/enum';
|
import { ApiTag, ImmichCookie, MaintenanceAction, Permission } from 'src/enum';
|
||||||
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
|
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
|
||||||
import { LoginDetails } from 'src/services/auth.service';
|
import { LoginDetails } from 'src/services/auth.service';
|
||||||
@@ -15,6 +21,27 @@ import { respondWithCookie } from 'src/utils/response';
|
|||||||
export class MaintenanceController {
|
export class MaintenanceController {
|
||||||
constructor(private service: MaintenanceService) {}
|
constructor(private service: MaintenanceService) {}
|
||||||
|
|
||||||
|
@Get('status')
|
||||||
|
@Endpoint({
|
||||||
|
summary: 'Get maintenance mode status',
|
||||||
|
description: 'Fetch information about the currently running maintenance action.',
|
||||||
|
history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'),
|
||||||
|
})
|
||||||
|
getMaintenanceStatus(): MaintenanceStatusResponseDto {
|
||||||
|
return this.service.getMaintenanceStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('detect-install')
|
||||||
|
@Endpoint({
|
||||||
|
summary: 'Detect existing install',
|
||||||
|
description: 'Collect integrity checks and other heuristics about local data.',
|
||||||
|
history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'),
|
||||||
|
})
|
||||||
|
@Authenticated({ permission: Permission.Maintenance, admin: true })
|
||||||
|
detectPriorInstall(): Promise<MaintenanceDetectInstallResponseDto> {
|
||||||
|
return this.service.detectPriorInstall();
|
||||||
|
}
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@Endpoint({
|
@Endpoint({
|
||||||
summary: 'Log into maintenance mode',
|
summary: 'Log into maintenance mode',
|
||||||
@@ -38,8 +65,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 }],
|
||||||
|
|||||||
16
server/src/dtos/database-backup.dto.ts
Normal file
16
server/src/dtos/database-backup.dto.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class DatabaseBackupListResponseDto {
|
||||||
|
backups!: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DatabaseBackupUploadDto {
|
||||||
|
@ApiProperty({ type: 'string', format: 'binary', required: false })
|
||||||
|
file?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DatabaseBackupDeleteDto {
|
||||||
|
@IsString({ each: true })
|
||||||
|
backups!: string[];
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import { MaintenanceAction } from 'src/enum';
|
import { MaintenanceAction, StorageFolder } from 'src/enum';
|
||||||
import { ValidateEnum, ValidateString } from 'src/validation';
|
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 {
|
||||||
@@ -14,3 +17,26 @@ export class MaintenanceLoginDto {
|
|||||||
export class MaintenanceAuthDto {
|
export class MaintenanceAuthDto {
|
||||||
username!: string;
|
username!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class MaintenanceStatusResponseDto {
|
||||||
|
active!: boolean;
|
||||||
|
|
||||||
|
@ValidateEnum({ enum: MaintenanceAction, name: 'MaintenanceAction' })
|
||||||
|
action!: MaintenanceAction;
|
||||||
|
|
||||||
|
progress?: number;
|
||||||
|
task?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MaintenanceDetectInstallStorageFolderDto {
|
||||||
|
@ValidateEnum({ enum: StorageFolder, name: 'StorageFolder' })
|
||||||
|
folder!: StorageFolder;
|
||||||
|
readable!: boolean;
|
||||||
|
writable!: boolean;
|
||||||
|
files!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MaintenanceDetectInstallResponseDto {
|
||||||
|
storage!: MaintenanceDetectInstallStorageFolderDto[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -128,6 +128,11 @@ export enum Permission {
|
|||||||
|
|
||||||
ArchiveRead = 'archive.read',
|
ArchiveRead = 'archive.read',
|
||||||
|
|
||||||
|
BackupList = 'backup.list',
|
||||||
|
BackupDownload = 'backup.download',
|
||||||
|
BackupUpload = 'backup.upload',
|
||||||
|
BackupDelete = 'backup.delete',
|
||||||
|
|
||||||
DuplicateRead = 'duplicate.read',
|
DuplicateRead = 'duplicate.read',
|
||||||
DuplicateDelete = 'duplicate.delete',
|
DuplicateDelete = 'duplicate.delete',
|
||||||
|
|
||||||
@@ -679,12 +684,14 @@ export enum DatabaseLock {
|
|||||||
MediaLocation = 700,
|
MediaLocation = 700,
|
||||||
GetSystemConfig = 69,
|
GetSystemConfig = 69,
|
||||||
BackupDatabase = 42,
|
BackupDatabase = 42,
|
||||||
|
MaintenanceOperation = 621,
|
||||||
MemoryCreation = 777,
|
MemoryCreation = 777,
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum MaintenanceAction {
|
export enum MaintenanceAction {
|
||||||
Start = 'start',
|
Start = 'start',
|
||||||
End = 'end',
|
End = 'end',
|
||||||
|
RestoreDatabase = 'restore_database',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ExitCode {
|
export enum ExitCode {
|
||||||
@@ -831,6 +838,7 @@ export enum ApiTag {
|
|||||||
Authentication = 'Authentication',
|
Authentication = 'Authentication',
|
||||||
AuthenticationAdmin = 'Authentication (admin)',
|
AuthenticationAdmin = 'Authentication (admin)',
|
||||||
Assets = 'Assets',
|
Assets = 'Assets',
|
||||||
|
DatabaseBackups = 'Database Backups (admin)',
|
||||||
Deprecated = 'Deprecated',
|
Deprecated = 'Deprecated',
|
||||||
Download = 'Download',
|
Download = 'Download',
|
||||||
Duplicates = 'Duplicates',
|
Duplicates = 'Duplicates',
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Kysely } from 'kysely';
|
import { Kysely, sql } from 'kysely';
|
||||||
import { CommandFactory } from 'nest-commander';
|
import { CommandFactory } from 'nest-commander';
|
||||||
import { ChildProcess, fork } from 'node:child_process';
|
import { ChildProcess, fork } from 'node:child_process';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { Worker } from 'node:worker_threads';
|
import { Worker } from 'node:worker_threads';
|
||||||
import { PostgresError } from 'postgres';
|
import { PostgresError } from 'postgres';
|
||||||
import { ImmichAdminModule } from 'src/app.module';
|
import { ImmichAdminModule } from 'src/app.module';
|
||||||
import { ExitCode, ImmichWorker, LogLevel, SystemMetadataKey } from 'src/enum';
|
import { DatabaseLock, ExitCode, ImmichWorker, LogLevel, SystemMetadataKey } from 'src/enum';
|
||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||||
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
|
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
|
||||||
import { type DB } from 'src/schema';
|
import { type DB } from 'src/schema';
|
||||||
@@ -35,16 +35,14 @@ class Workers {
|
|||||||
if (isMaintenanceMode) {
|
if (isMaintenanceMode) {
|
||||||
this.startWorker(ImmichWorker.Maintenance);
|
this.startWorker(ImmichWorker.Maintenance);
|
||||||
} else {
|
} else {
|
||||||
|
await this.waitForFreeLock();
|
||||||
|
|
||||||
for (const worker of workers) {
|
for (const worker of workers) {
|
||||||
this.startWorker(worker);
|
this.startWorker(worker);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialise a short-lived Nest application to build configuration
|
|
||||||
* @returns System configuration
|
|
||||||
*/
|
|
||||||
private async isMaintenanceMode(): Promise<boolean> {
|
private async isMaintenanceMode(): Promise<boolean> {
|
||||||
const { database } = new ConfigRepository().getEnv();
|
const { database } = new ConfigRepository().getEnv();
|
||||||
const kysely = new Kysely<DB>(getKyselyConfig(database.config));
|
const kysely = new Kysely<DB>(getKyselyConfig(database.config));
|
||||||
@@ -65,6 +63,32 @@ class Workers {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async waitForFreeLock() {
|
||||||
|
const { database } = new ConfigRepository().getEnv();
|
||||||
|
const kysely = new Kysely<DB>(getKyselyConfig(database.config));
|
||||||
|
|
||||||
|
let locked = false;
|
||||||
|
while (!locked) {
|
||||||
|
locked = await kysely.connection().execute(async (conn) => {
|
||||||
|
const { rows } = await sql<{
|
||||||
|
pg_try_advisory_lock: boolean;
|
||||||
|
}>`SELECT pg_try_advisory_lock(${DatabaseLock.MaintenanceOperation})`.execute(conn);
|
||||||
|
|
||||||
|
const isLocked = rows[0].pg_try_advisory_lock;
|
||||||
|
|
||||||
|
if (isLocked) {
|
||||||
|
await sql`SELECT pg_advisory_unlock(${DatabaseLock.MaintenanceOperation})`.execute(conn);
|
||||||
|
}
|
||||||
|
|
||||||
|
return isLocked;
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
await kysely.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start an individual worker
|
* Start an individual worker
|
||||||
* @param name Worker
|
* @param name Worker
|
||||||
|
|||||||
@@ -7,17 +7,24 @@ import {
|
|||||||
WebSocketServer,
|
WebSocketServer,
|
||||||
} from '@nestjs/websockets';
|
} from '@nestjs/websockets';
|
||||||
import { Server, Socket } from 'socket.io';
|
import { Server, Socket } from 'socket.io';
|
||||||
|
import { MaintenanceAuthDto, MaintenanceStatusResponseDto } from 'src/dtos/maintenance.dto';
|
||||||
import { AppRepository } from 'src/repositories/app.repository';
|
import { AppRepository } from 'src/repositories/app.repository';
|
||||||
import { AppRestartEvent, ArgsOf } from 'src/repositories/event.repository';
|
import { AppRestartEvent } from 'src/repositories/event.repository';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
|
|
||||||
export const serverEvents = ['AppRestart'] as const;
|
interface ServerEventMap {
|
||||||
export type ServerEvents = (typeof serverEvents)[number];
|
AppRestart: [AppRestartEvent];
|
||||||
|
MaintenanceStatus: [MaintenanceStatusResponseDto];
|
||||||
export interface ClientEventMap {
|
|
||||||
AppRestartV1: [AppRestartEvent];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ClientEventMap {
|
||||||
|
AppRestartV1: [AppRestartEvent];
|
||||||
|
MaintenanceStatusV1: [MaintenanceStatusResponseDto];
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthFn = (client: Socket) => Promise<MaintenanceAuthDto>;
|
||||||
|
type StatusUpdateFn = (status: MaintenanceStatusResponseDto) => void;
|
||||||
|
|
||||||
@WebSocketGateway({
|
@WebSocketGateway({
|
||||||
cors: true,
|
cors: true,
|
||||||
path: '/api/socket.io',
|
path: '/api/socket.io',
|
||||||
@@ -25,8 +32,11 @@ export interface ClientEventMap {
|
|||||||
})
|
})
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MaintenanceWebsocketRepository implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit {
|
export class MaintenanceWebsocketRepository implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit {
|
||||||
|
private authFn?: AuthFn;
|
||||||
|
private statusUpdateFn?: StatusUpdateFn;
|
||||||
|
|
||||||
@WebSocketServer()
|
@WebSocketServer()
|
||||||
private websocketServer?: Server;
|
private server?: Server;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private logger: LoggingRepository,
|
private logger: LoggingRepository,
|
||||||
@@ -35,25 +45,46 @@ export class MaintenanceWebsocketRepository implements OnGatewayConnection, OnGa
|
|||||||
this.logger.setContext(MaintenanceWebsocketRepository.name);
|
this.logger.setContext(MaintenanceWebsocketRepository.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
afterInit(websocketServer: Server) {
|
afterInit(server: Server) {
|
||||||
this.logger.log('Initialized websocket server');
|
this.logger.log('Initialized websocket server');
|
||||||
websocketServer.on('AppRestart', () => this.appRepository.exitApp());
|
server.on('AppRestart', () => this.appRepository.exitApp());
|
||||||
|
server.on('MaintenanceStatus', (status) => this.statusUpdateFn?.(status));
|
||||||
|
}
|
||||||
|
|
||||||
|
clientSend<T extends keyof ClientEventMap>(event: T, room: string, ...data: ClientEventMap[T]) {
|
||||||
|
this.server?.to(room).emit(event, ...data);
|
||||||
}
|
}
|
||||||
|
|
||||||
clientBroadcast<T extends keyof ClientEventMap>(event: T, ...data: ClientEventMap[T]) {
|
clientBroadcast<T extends keyof ClientEventMap>(event: T, ...data: ClientEventMap[T]) {
|
||||||
this.websocketServer?.emit(event, ...data);
|
this.server?.emit(event, ...data);
|
||||||
}
|
}
|
||||||
|
|
||||||
serverSend<T extends ServerEvents>(event: T, ...args: ArgsOf<T>): void {
|
serverSend<T extends keyof ServerEventMap>(event: T, ...args: ServerEventMap[T]): void {
|
||||||
this.logger.debug(`Server event: ${event} (send)`);
|
this.logger.debug(`Server event: ${event} (send)`);
|
||||||
this.websocketServer?.serverSideEmit(event, ...args);
|
this.server?.serverSideEmit(event, ...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleConnection(client: Socket) {
|
async handleConnection(client: Socket) {
|
||||||
this.logger.log(`Websocket Connect: ${client.id}`);
|
try {
|
||||||
|
await this.authFn!(client);
|
||||||
|
await client.join('private');
|
||||||
|
this.logger.log(`Websocket Connect: ${client.id} (private)`);
|
||||||
|
} catch {
|
||||||
|
await client.join('public');
|
||||||
|
this.logger.log(`Websocket Connect: ${client.id} (public)`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDisconnect(client: Socket) {
|
async handleDisconnect(client: Socket) {
|
||||||
this.logger.log(`Websocket Disconnect: ${client.id}`);
|
this.logger.log(`Websocket Disconnect: ${client.id}`);
|
||||||
|
await Promise.allSettled([client.leave('private'), client.leave('public')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuthFn(fn: (client: Socket) => Promise<MaintenanceAuthDto>) {
|
||||||
|
this.authFn = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatusUpdateFn(fn: (status: MaintenanceStatusResponseDto) => void) {
|
||||||
|
this.statusUpdateFn = fn;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,109 @@
|
|||||||
import { Body, Controller, Get, Post, Req, Res } from '@nestjs/common';
|
import {
|
||||||
import { Request, Response } from 'express';
|
Body,
|
||||||
import { MaintenanceAuthDto, MaintenanceLoginDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Next,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UploadedFile,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { NextFunction, Request, Response } from 'express';
|
||||||
|
import {
|
||||||
|
MaintenanceAuthDto,
|
||||||
|
MaintenanceDetectInstallResponseDto,
|
||||||
|
MaintenanceLoginDto,
|
||||||
|
MaintenanceStatusResponseDto,
|
||||||
|
SetMaintenanceModeDto,
|
||||||
|
} from 'src/dtos/maintenance.dto';
|
||||||
import { ServerConfigDto } from 'src/dtos/server.dto';
|
import { ServerConfigDto } from 'src/dtos/server.dto';
|
||||||
import { ImmichCookie, MaintenanceAction } from 'src/enum';
|
import { ImmichCookie } from 'src/enum';
|
||||||
import { MaintenanceRoute } from 'src/maintenance/maintenance-auth.guard';
|
import { MaintenanceRoute } from 'src/maintenance/maintenance-auth.guard';
|
||||||
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
|
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
|
||||||
import { GetLoginDetails } from 'src/middleware/auth.guard';
|
import { GetLoginDetails } from 'src/middleware/auth.guard';
|
||||||
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
import { LoginDetails } from 'src/services/auth.service';
|
import { LoginDetails } from 'src/services/auth.service';
|
||||||
|
import { sendFile } from 'src/utils/file';
|
||||||
import { respondWithCookie } from 'src/utils/response';
|
import { respondWithCookie } from 'src/utils/response';
|
||||||
|
import { FilenameParamDto } from 'src/validation';
|
||||||
|
|
||||||
|
import type { DatabaseBackupController as _DatabaseBackupController } from 'src/controllers/database-backup.controller';
|
||||||
|
import type { ServerController as _ServerController } from 'src/controllers/server.controller';
|
||||||
|
import { DatabaseBackupDeleteDto, DatabaseBackupListResponseDto } from 'src/dtos/database-backup.dto';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class MaintenanceWorkerController {
|
export class MaintenanceWorkerController {
|
||||||
constructor(private service: MaintenanceWorkerService) {}
|
constructor(
|
||||||
|
private logger: LoggingRepository,
|
||||||
|
private service: MaintenanceWorkerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link _ServerController.getServerConfig }
|
||||||
|
*/
|
||||||
@Get('server/config')
|
@Get('server/config')
|
||||||
getServerConfig(): Promise<ServerConfigDto> {
|
getServerConfig(): ServerConfigDto {
|
||||||
return this.service.getSystemConfig();
|
return this.service.getSystemConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link _DatabaseBackupController.listDatabaseBackups}
|
||||||
|
*/
|
||||||
|
@Get('admin/database-backups')
|
||||||
|
@MaintenanceRoute()
|
||||||
|
listDatabaseBackups(): Promise<DatabaseBackupListResponseDto> {
|
||||||
|
return this.service.listBackups();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link _DatabaseBackupController.downloadDatabaseBackup}
|
||||||
|
*/
|
||||||
|
@Get('admin/database-backups/:filename')
|
||||||
|
@MaintenanceRoute()
|
||||||
|
async downloadDatabaseBackup(
|
||||||
|
@Param() { filename }: FilenameParamDto,
|
||||||
|
@Res() res: Response,
|
||||||
|
@Next() next: NextFunction,
|
||||||
|
) {
|
||||||
|
await sendFile(res, next, () => this.service.downloadBackup(filename), this.logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link _DatabaseBackupController.deleteDatabaseBackup}
|
||||||
|
*/
|
||||||
|
@Delete('admin/database-backups')
|
||||||
|
@MaintenanceRoute()
|
||||||
|
async deleteDatabaseBackup(@Body() dto: DatabaseBackupDeleteDto): Promise<void> {
|
||||||
|
return this.service.deleteBackup(dto.backups);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link _DatabaseBackupController.uploadDatabaseBackup}
|
||||||
|
*/
|
||||||
|
@Post('admin/database-backups/upload')
|
||||||
|
@MaintenanceRoute()
|
||||||
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
|
uploadDatabaseBackup(
|
||||||
|
@UploadedFile()
|
||||||
|
file: Express.Multer.File,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.service.uploadBackup(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('admin/maintenance/status')
|
||||||
|
maintenanceStatus(@Req() request: Request): Promise<MaintenanceStatusResponseDto> {
|
||||||
|
return this.service.status(request.cookies[ImmichCookie.MaintenanceToken]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('admin/maintenance/detect-install')
|
||||||
|
detectPriorInstall(): Promise<MaintenanceDetectInstallResponseDto> {
|
||||||
|
return this.service.detectPriorInstall();
|
||||||
|
}
|
||||||
|
|
||||||
@Post('admin/maintenance/login')
|
@Post('admin/maintenance/login')
|
||||||
async maintenanceLogin(
|
async maintenanceLogin(
|
||||||
@Req() request: Request,
|
@Req() request: Request,
|
||||||
@@ -35,9 +121,7 @@ export class MaintenanceWorkerController {
|
|||||||
|
|
||||||
@Post('admin/maintenance')
|
@Post('admin/maintenance')
|
||||||
@MaintenanceRoute()
|
@MaintenanceRoute()
|
||||||
async setMaintenanceMode(@Body() dto: SetMaintenanceModeDto): Promise<void> {
|
setMaintenanceMode(@Body() dto: SetMaintenanceModeDto): void {
|
||||||
if (dto.action === MaintenanceAction.End) {
|
void this.service.setAction(dto);
|
||||||
await this.service.endMaintenance();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,44 @@
|
|||||||
import { UnauthorizedException } from '@nestjs/common';
|
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||||
import { SignJWT } from 'jose';
|
import { SignJWT } from 'jose';
|
||||||
import { SystemMetadataKey } from 'src/enum';
|
import { DateTime } from 'luxon';
|
||||||
|
import { PassThrough, Readable } from 'node:stream';
|
||||||
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
|
import { MaintenanceAction, StorageFolder, 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, AutoMocked, getMocks, mockDuplex, mockSpawn, ServiceMocks } from 'test/utils';
|
||||||
|
|
||||||
|
function* mockData() {
|
||||||
|
yield '';
|
||||||
|
}
|
||||||
|
|
||||||
describe(MaintenanceWorkerService.name, () => {
|
describe(MaintenanceWorkerService.name, () => {
|
||||||
let sut: MaintenanceWorkerService;
|
let sut: MaintenanceWorkerService;
|
||||||
let mocks: ServiceMocks;
|
let mocks: ServiceMocks;
|
||||||
let maintenanceWorkerRepositoryMock: MaintenanceWebsocketRepository;
|
let maintenanceWebsocketRepositoryMock: AutoMocked<MaintenanceWebsocketRepository>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks = getMocks();
|
mocks = getMocks();
|
||||||
maintenanceWorkerRepositoryMock = automock(MaintenanceWebsocketRepository, { args: [mocks.logger], strict: false });
|
maintenanceWebsocketRepositoryMock = automock(MaintenanceWebsocketRepository, {
|
||||||
|
args: [mocks.logger],
|
||||||
|
strict: false,
|
||||||
|
});
|
||||||
|
|
||||||
sut = new MaintenanceWorkerService(
|
sut = new MaintenanceWorkerService(
|
||||||
mocks.logger as never,
|
mocks.logger as never,
|
||||||
mocks.app,
|
mocks.app,
|
||||||
mocks.config,
|
mocks.config,
|
||||||
mocks.systemMetadata as never,
|
mocks.systemMetadata as never,
|
||||||
maintenanceWorkerRepositoryMock,
|
maintenanceWebsocketRepositoryMock,
|
||||||
|
mocks.storage as never,
|
||||||
|
mocks.process,
|
||||||
|
mocks.database as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
sut.mock({
|
||||||
|
active: true,
|
||||||
|
action: MaintenanceAction.Start,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should work', () => {
|
it('should work', () => {
|
||||||
@@ -27,14 +46,43 @@ describe(MaintenanceWorkerService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('getSystemConfig', () => {
|
describe('getSystemConfig', () => {
|
||||||
it('should respond the server is in maintenance mode', async () => {
|
it('should respond the server is in maintenance mode', () => {
|
||||||
await expect(sut.getSystemConfig()).resolves.toMatchObject(
|
expect(sut.getSystemConfig()).toMatchObject(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
maintenanceMode: true,
|
maintenanceMode: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.systemMetadata.get).toHaveBeenCalled();
|
expect(mocks.systemMetadata.get).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skip('ssr');
|
||||||
|
describe.skip('detectMediaLocation');
|
||||||
|
|
||||||
|
describe('setStatus', () => {
|
||||||
|
it('should broadcast status', () => {
|
||||||
|
sut.setStatus({
|
||||||
|
active: true,
|
||||||
|
action: MaintenanceAction.Start,
|
||||||
|
task: 'abc',
|
||||||
|
error: 'def',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(maintenanceWebsocketRepositoryMock.serverSend).toHaveBeenCalled();
|
||||||
|
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledTimes(2);
|
||||||
|
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledWith('MaintenanceStatusV1', 'private', {
|
||||||
|
active: true,
|
||||||
|
action: 'start',
|
||||||
|
task: 'abc',
|
||||||
|
error: 'def',
|
||||||
|
});
|
||||||
|
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledWith('MaintenanceStatusV1', 'public', {
|
||||||
|
active: true,
|
||||||
|
action: 'start',
|
||||||
|
task: 'abc',
|
||||||
|
error: 'Something went wrong, see logs!',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -42,7 +90,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 +118,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({
|
||||||
@@ -73,13 +134,102 @@ describe(MaintenanceWorkerService.name, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('status', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sut.mock({
|
||||||
|
active: true,
|
||||||
|
action: MaintenanceAction.Start,
|
||||||
|
error: 'secret value!',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates private status', async () => {
|
||||||
|
const jwt = await new SignJWT({ _mockValue: true })
|
||||||
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime('4h')
|
||||||
|
.sign(new TextEncoder().encode('secret'));
|
||||||
|
|
||||||
|
await expect(sut.status(jwt)).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: 'secret value!',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates public status', async () => {
|
||||||
|
await expect(sut.status()).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: 'Something went wrong, see logs!',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('detectPriorInstall', () => {
|
||||||
|
it('generate report about prior installation', async () => {
|
||||||
|
mocks.storage.readdir.mockResolvedValue(['.immich', 'file1', 'file2']);
|
||||||
|
mocks.storage.readFile.mockResolvedValue(undefined as never);
|
||||||
|
mocks.storage.overwriteFile.mockRejectedValue(undefined as never);
|
||||||
|
|
||||||
|
await expect(sut.detectPriorInstall()).resolves.toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"storage": [
|
||||||
|
{
|
||||||
|
"files": 2,
|
||||||
|
"folder": "encoded-video",
|
||||||
|
"readable": true,
|
||||||
|
"writable": false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": 2,
|
||||||
|
"folder": "library",
|
||||||
|
"readable": true,
|
||||||
|
"writable": false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": 2,
|
||||||
|
"folder": "upload",
|
||||||
|
"readable": true,
|
||||||
|
"writable": false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": 2,
|
||||||
|
"folder": "profile",
|
||||||
|
"readable": true,
|
||||||
|
"writable": false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": 2,
|
||||||
|
"folder": "thumbs",
|
||||||
|
"readable": true,
|
||||||
|
"writable": false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": 2,
|
||||||
|
"folder": "backups",
|
||||||
|
"readable": true,
|
||||||
|
"writable": false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('login', () => {
|
describe('login', () => {
|
||||||
it('should fail without token', async () => {
|
it('should fail without token', async () => {
|
||||||
await expect(sut.login()).rejects.toThrowError(new UnauthorizedException('Missing JWT Token'));
|
await expect(sut.login()).rejects.toThrowError(new UnauthorizedException('Missing JWT Token'));
|
||||||
});
|
});
|
||||||
|
|
||||||
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 +241,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' })
|
||||||
@@ -107,22 +263,232 @@ describe(MaintenanceWorkerService.name, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('endMaintenance', () => {
|
describe.skip('setAction'); // just calls setStatus+runAction
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actions
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('action: start', () => {
|
||||||
|
it('should not do anything', async () => {
|
||||||
|
await sut.runAction({
|
||||||
|
action: MaintenanceAction.Start,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.logger.log).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('action: end', () => {
|
||||||
it('should set maintenance mode', async () => {
|
it('should set maintenance mode', async () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false });
|
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false });
|
||||||
await expect(sut.endMaintenance()).resolves.toBeUndefined();
|
await sut.runAction({
|
||||||
|
action: MaintenanceAction.End,
|
||||||
|
});
|
||||||
|
|
||||||
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
|
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
|
||||||
isMaintenanceMode: false,
|
isMaintenanceMode: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(maintenanceWorkerRepositoryMock.clientBroadcast).toHaveBeenCalledWith('AppRestartV1', {
|
expect(maintenanceWebsocketRepositoryMock.clientBroadcast).toHaveBeenCalledWith('AppRestartV1', {
|
||||||
isMaintenanceMode: false,
|
isMaintenanceMode: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(maintenanceWorkerRepositoryMock.serverSend).toHaveBeenCalledWith('AppRestart', {
|
expect(maintenanceWebsocketRepositoryMock.serverSend).toHaveBeenCalledWith('AppRestart', {
|
||||||
isMaintenanceMode: false,
|
isMaintenanceMode: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('action: restore database', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.database.tryLock.mockResolvedValueOnce(true);
|
||||||
|
|
||||||
|
mocks.storage.readdir.mockResolvedValue([]);
|
||||||
|
mocks.process.spawn.mockReturnValue(mockSpawn(0, 'data', ''));
|
||||||
|
mocks.process.createSpawnDuplexStream.mockImplementation(() => mockDuplex('command', 0, 'data', ''));
|
||||||
|
mocks.storage.rename.mockResolvedValue();
|
||||||
|
mocks.storage.unlink.mockResolvedValue();
|
||||||
|
mocks.storage.createPlainReadStream.mockReturnValue(Readable.from(mockData()));
|
||||||
|
mocks.storage.createWriteStream.mockReturnValue(new PassThrough());
|
||||||
|
mocks.storage.createGzip.mockReturnValue(new PassThrough());
|
||||||
|
mocks.storage.createGunzip.mockReturnValue(new PassThrough());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update maintenance mode state', async () => {
|
||||||
|
await sut.runAction({
|
||||||
|
action: MaintenanceAction.RestoreDatabase,
|
||||||
|
restoreBackupFilename: 'filename',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.database.tryLock).toHaveBeenCalled();
|
||||||
|
expect(mocks.logger.log).toHaveBeenCalledWith('Running maintenance action restore_database');
|
||||||
|
|
||||||
|
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
|
||||||
|
isMaintenanceMode: true,
|
||||||
|
secret: 'secret',
|
||||||
|
action: {
|
||||||
|
action: 'start',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail to restore invalid backup', async () => {
|
||||||
|
await sut.runAction({
|
||||||
|
action: MaintenanceAction.RestoreDatabase,
|
||||||
|
restoreBackupFilename: 'filename',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledWith('MaintenanceStatusV1', 'private', {
|
||||||
|
active: true,
|
||||||
|
action: MaintenanceAction.RestoreDatabase,
|
||||||
|
error: 'Error: Invalid backup file format!',
|
||||||
|
task: 'error',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should successfully run a backup', async () => {
|
||||||
|
await sut.runAction({
|
||||||
|
action: MaintenanceAction.RestoreDatabase,
|
||||||
|
restoreBackupFilename: 'development-filename.sql',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledWith(
|
||||||
|
'MaintenanceStatusV1',
|
||||||
|
expect.any(String),
|
||||||
|
{
|
||||||
|
active: true,
|
||||||
|
action: MaintenanceAction.RestoreDatabase,
|
||||||
|
task: 'ready',
|
||||||
|
progress: expect.any(Number),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenLastCalledWith(
|
||||||
|
'MaintenanceStatusV1',
|
||||||
|
expect.any(String),
|
||||||
|
{
|
||||||
|
active: true,
|
||||||
|
action: 'end',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail if backup creation fails', async () => {
|
||||||
|
mocks.process.createSpawnDuplexStream.mockReturnValueOnce(mockDuplex('pg_dump', 1, '', 'error'));
|
||||||
|
|
||||||
|
await sut.runAction({
|
||||||
|
action: MaintenanceAction.RestoreDatabase,
|
||||||
|
restoreBackupFilename: 'development-filename.sql',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledWith('MaintenanceStatusV1', 'private', {
|
||||||
|
active: true,
|
||||||
|
action: MaintenanceAction.RestoreDatabase,
|
||||||
|
error: 'Error: pg_dump non-zero exit code (1)\nerror',
|
||||||
|
task: 'error',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenLastCalledWith(
|
||||||
|
'MaintenanceStatusV1',
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({
|
||||||
|
task: 'error',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail if restore itself fails', async () => {
|
||||||
|
mocks.process.createSpawnDuplexStream
|
||||||
|
.mockReturnValueOnce(mockDuplex('pg_dump', 0, 'data', ''))
|
||||||
|
.mockReturnValueOnce(mockDuplex('gzip', 0, 'data', ''))
|
||||||
|
.mockReturnValueOnce(mockDuplex('psql', 1, '', 'error'));
|
||||||
|
|
||||||
|
await sut.runAction({
|
||||||
|
action: MaintenanceAction.RestoreDatabase,
|
||||||
|
restoreBackupFilename: 'development-filename.sql',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledWith('MaintenanceStatusV1', 'private', {
|
||||||
|
active: true,
|
||||||
|
action: MaintenanceAction.RestoreDatabase,
|
||||||
|
error: 'Error: psql non-zero exit code (1)\nerror',
|
||||||
|
task: 'error',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenLastCalledWith(
|
||||||
|
'MaintenanceStatusV1',
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({
|
||||||
|
task: 'error',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backups
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('listBackups', () => {
|
||||||
|
it('should give us all 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',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteBackup', () => {
|
||||||
|
it('should reject invalid file names', async () => {
|
||||||
|
await expect(sut.deleteBackup(['filename'])).rejects.toThrowError(
|
||||||
|
new BadRequestException('Invalid backup name!'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should unlink the target file', async () => {
|
||||||
|
await sut.deleteBackup(['filename.sql']);
|
||||||
|
expect(mocks.storage.unlink).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.storage.unlink).toHaveBeenCalledWith(
|
||||||
|
`${StorageCore.getBaseFolder(StorageFolder.Backups)}/filename.sql`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('uploadBackup', () => {
|
||||||
|
it('should reject invalid file names', async () => {
|
||||||
|
await expect(sut.uploadBackup({ originalname: 'invalid backup' } as never)).rejects.toThrowError(
|
||||||
|
new BadRequestException('Invalid backup name!'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should write file', async () => {
|
||||||
|
await sut.uploadBackup({ originalname: 'path.sql.gz', buffer: 'buffer' } as never);
|
||||||
|
expect(mocks.storage.createOrOverwriteFile).toBeCalledWith('/data/backups/uploaded-path.sql.gz', 'buffer');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('downloadBackup', () => {
|
||||||
|
it('should reject invalid file names', () => {
|
||||||
|
expect(() => sut.downloadBackup('invalid backup')).toThrowError(new BadRequestException('Invalid backup name!'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get backup path', () => {
|
||||||
|
expect(sut.downloadBackup('hello.sql.gz')).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
path: '/data/backups/hello.sql.gz',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,19 +4,38 @@ import { NextFunction, Request, Response } from 'express';
|
|||||||
import { jwtVerify } from 'jose';
|
import { jwtVerify } from 'jose';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { IncomingHttpHeaders } from 'node:http';
|
import { IncomingHttpHeaders } from 'node:http';
|
||||||
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
import { ImmichCookie, SystemMetadataKey } from 'src/enum';
|
import {
|
||||||
|
MaintenanceAuthDto,
|
||||||
|
MaintenanceDetectInstallResponseDto,
|
||||||
|
MaintenanceStatusResponseDto,
|
||||||
|
SetMaintenanceModeDto,
|
||||||
|
} from 'src/dtos/maintenance.dto';
|
||||||
|
import { ServerConfigDto } from 'src/dtos/server.dto';
|
||||||
|
import { DatabaseLock, ImmichCookie, MaintenanceAction, 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 DatabaseBackupService as _DatabaseBackupService } from 'src/services/database-backup.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 { getConfig } from 'src/utils/config';
|
import { getConfig } from 'src/utils/config';
|
||||||
import { createMaintenanceLoginUrl } from 'src/utils/maintenance';
|
import {
|
||||||
|
deleteDatabaseBackup,
|
||||||
|
downloadDatabaseBackup,
|
||||||
|
listDatabaseBackups,
|
||||||
|
restoreDatabaseBackup,
|
||||||
|
uploadDatabaseBackup,
|
||||||
|
} from 'src/utils/database-backups';
|
||||||
|
import { ImmichFileResponse } from 'src/utils/file';
|
||||||
|
import { createMaintenanceLoginUrl, detectPriorInstall } from 'src/utils/maintenance';
|
||||||
import { getExternalDomain } from 'src/utils/misc';
|
import { getExternalDomain } from 'src/utils/misc';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,16 +43,50 @@ import { getExternalDomain } from 'src/utils/misc';
|
|||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MaintenanceWorkerService {
|
export class MaintenanceWorkerService {
|
||||||
|
#secret: string = null!;
|
||||||
|
#status: MaintenanceStatusResponseDto = {
|
||||||
|
active: true,
|
||||||
|
action: MaintenanceAction.Start,
|
||||||
|
};
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
protected logger: LoggingRepository,
|
protected logger: LoggingRepository,
|
||||||
private appRepository: AppRepository,
|
private appRepository: AppRepository,
|
||||||
private configRepository: ConfigRepository,
|
private configRepository: ConfigRepository,
|
||||||
private systemMetadataRepository: SystemMetadataRepository,
|
private systemMetadataRepository: SystemMetadataRepository,
|
||||||
private maintenanceWorkerRepository: MaintenanceWebsocketRepository,
|
private maintenanceWebsocketRepository: MaintenanceWebsocketRepository,
|
||||||
|
private storageRepository: StorageRepository,
|
||||||
|
private processRepository: ProcessRepository,
|
||||||
|
private databaseRepository: DatabaseRepository,
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(this.constructor.name);
|
this.logger.setContext(this.constructor.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mock(status: MaintenanceStatusResponseDto) {
|
||||||
|
this.#secret = 'secret';
|
||||||
|
this.#status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
const state = (await this.systemMetadataRepository.get(
|
||||||
|
SystemMetadataKey.MaintenanceMode,
|
||||||
|
)) as MaintenanceModeState & { isMaintenanceMode: true };
|
||||||
|
|
||||||
|
this.#secret = state.secret;
|
||||||
|
this.#status = {
|
||||||
|
active: true,
|
||||||
|
action: state.action.action,
|
||||||
|
};
|
||||||
|
|
||||||
|
StorageCore.setMediaLocation(this.detectMediaLocation());
|
||||||
|
|
||||||
|
this.maintenanceWebsocketRepository.setAuthFn(async (client) => this.authenticate(client.request.headers));
|
||||||
|
this.maintenanceWebsocketRepository.setStatusUpdateFn((status) => (this.#status = status));
|
||||||
|
|
||||||
|
await this.logSecret();
|
||||||
|
void this.runAction(state.action);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@link _BaseService.configRepos}
|
* {@link _BaseService.configRepos}
|
||||||
*/
|
*/
|
||||||
@@ -55,22 +108,10 @@ export class MaintenanceWorkerService {
|
|||||||
/**
|
/**
|
||||||
* {@link _ServerService.getSystemConfig}
|
* {@link _ServerService.getSystemConfig}
|
||||||
*/
|
*/
|
||||||
async getSystemConfig() {
|
getSystemConfig() {
|
||||||
const config = await this.getConfig({ withCache: false });
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loginPageMessage: config.server.loginPageMessage,
|
|
||||||
trashDays: config.trash.days,
|
|
||||||
userDeleteDelay: config.user.deleteDelay,
|
|
||||||
oauthButtonText: config.oauth.buttonText,
|
|
||||||
isInitialized: true,
|
|
||||||
isOnboarded: true,
|
|
||||||
externalDomain: config.server.externalDomain,
|
|
||||||
publicUsers: config.server.publicUsers,
|
|
||||||
mapDarkStyleUrl: config.map.darkStyle,
|
|
||||||
mapLightStyleUrl: config.map.lightStyle,
|
|
||||||
maintenanceMode: true,
|
maintenanceMode: true,
|
||||||
};
|
} as ServerConfigDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -106,12 +147,89 @@ export class MaintenanceWorkerService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async secret(): Promise<string> {
|
/**
|
||||||
const state = (await this.systemMetadataRepository.get(SystemMetadataKey.MaintenanceMode)) as {
|
* {@link _StorageService.detectMediaLocation}
|
||||||
secret: string;
|
*/
|
||||||
};
|
detectMediaLocation(): string {
|
||||||
|
const envData = this.configRepository.getEnv();
|
||||||
|
if (envData.storage.mediaLocation) {
|
||||||
|
return envData.storage.mediaLocation;
|
||||||
|
}
|
||||||
|
|
||||||
return state.secret;
|
const targets: string[] = [];
|
||||||
|
const candidates = ['/data', '/usr/src/app/upload'];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const exists = this.storageRepository.existsSync(candidate);
|
||||||
|
if (exists) {
|
||||||
|
targets.push(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targets.length === 1) {
|
||||||
|
return targets[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return '/usr/src/app/upload';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link _DatabaseBackupService.listBackups}
|
||||||
|
*/
|
||||||
|
async listBackups(): Promise<{ backups: string[] }> {
|
||||||
|
return { backups: await listDatabaseBackups(this.backupRepos) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link _DatabaseBackupService.deleteBackup}
|
||||||
|
*/
|
||||||
|
async deleteBackup(files: string[]): Promise<void> {
|
||||||
|
return deleteDatabaseBackup(this.backupRepos, files);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link _DatabaseBackupService.uploadBackup}
|
||||||
|
*/
|
||||||
|
async uploadBackup(file: Express.Multer.File): Promise<void> {
|
||||||
|
return uploadDatabaseBackup(this.backupRepos, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link _DatabaseBackupService.downloadBackup}
|
||||||
|
*/
|
||||||
|
downloadBackup(fileName: string): ImmichFileResponse {
|
||||||
|
return downloadDatabaseBackup(fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get backupRepos() {
|
||||||
|
return {
|
||||||
|
logger: this.logger,
|
||||||
|
storage: this.storageRepository,
|
||||||
|
config: this.configRepository,
|
||||||
|
process: this.processRepository,
|
||||||
|
database: this.databaseRepository,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getStatus(): MaintenanceStatusResponseDto {
|
||||||
|
return this.#status;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPublicStatus(): MaintenanceStatusResponseDto {
|
||||||
|
const state = structuredClone(this.#status);
|
||||||
|
|
||||||
|
if (state.error) {
|
||||||
|
state.error = 'Something went wrong, see logs!';
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(status: MaintenanceStatusResponseDto): void {
|
||||||
|
this.#status = status;
|
||||||
|
this.maintenanceWebsocketRepository.serverSend('MaintenanceStatus', status);
|
||||||
|
this.maintenanceWebsocketRepository.clientSend('MaintenanceStatusV1', 'private', status);
|
||||||
|
this.maintenanceWebsocketRepository.clientSend('MaintenanceStatusV1', 'public', this.getPublicStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
async logSecret(): Promise<void> {
|
async logSecret(): Promise<void> {
|
||||||
@@ -123,7 +241,7 @@ export class MaintenanceWorkerService {
|
|||||||
{
|
{
|
||||||
username: 'immich-admin',
|
username: 'immich-admin',
|
||||||
},
|
},
|
||||||
await this.secret(),
|
this.#secret,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`\n\n🚧 Immich is in maintenance mode, you can log in using the following URL:\n${url}\n`);
|
this.logger.log(`\n\n🚧 Immich is in maintenance mode, you can log in using the following URL:\n${url}\n`);
|
||||||
@@ -134,28 +252,120 @@ export class MaintenanceWorkerService {
|
|||||||
return this.login(jwtToken);
|
return this.login(jwtToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async status(potentiallyJwt?: string): Promise<MaintenanceStatusResponseDto> {
|
||||||
|
try {
|
||||||
|
await this.login(potentiallyJwt);
|
||||||
|
return this.getStatus();
|
||||||
|
} catch {
|
||||||
|
return this.getPublicStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
detectPriorInstall(): Promise<MaintenanceDetectInstallResponseDto> {
|
||||||
|
return detectPriorInstall(this.storageRepository);
|
||||||
|
}
|
||||||
|
|
||||||
async login(jwt?: string): Promise<MaintenanceAuthDto> {
|
async login(jwt?: string): Promise<MaintenanceAuthDto> {
|
||||||
if (!jwt) {
|
if (!jwt) {
|
||||||
throw new UnauthorizedException('Missing JWT Token');
|
throw new UnauthorizedException('Missing JWT Token');
|
||||||
}
|
}
|
||||||
|
|
||||||
const secret = await this.secret();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await jwtVerify<MaintenanceAuthDto>(jwt, new TextEncoder().encode(secret));
|
const result = await jwtVerify<MaintenanceAuthDto>(jwt, new TextEncoder().encode(this.#secret));
|
||||||
return result.payload;
|
return result.payload;
|
||||||
} catch {
|
} catch {
|
||||||
throw new UnauthorizedException('Invalid JWT Token');
|
throw new UnauthorizedException('Invalid JWT Token');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async endMaintenance(): Promise<void> {
|
async setAction(action: SetMaintenanceModeDto) {
|
||||||
|
this.setStatus({
|
||||||
|
active: true,
|
||||||
|
action: action.action,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.runAction(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
async runAction(action: SetMaintenanceModeDto) {
|
||||||
|
switch (action.action) {
|
||||||
|
case MaintenanceAction.Start: {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case MaintenanceAction.End: {
|
||||||
|
return this.endMaintenance();
|
||||||
|
}
|
||||||
|
case MaintenanceAction.RestoreDatabase: {
|
||||||
|
if (!action.restoreBackupFilename) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lock = await this.databaseRepository.tryLock(DatabaseLock.MaintenanceOperation);
|
||||||
|
if (!lock) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Running maintenance action ${action.action}`);
|
||||||
|
|
||||||
|
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, {
|
||||||
|
isMaintenanceMode: true,
|
||||||
|
secret: this.#secret,
|
||||||
|
action: {
|
||||||
|
action: MaintenanceAction.Start,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (action.action) {
|
||||||
|
case MaintenanceAction.RestoreDatabase: {
|
||||||
|
await this.restoreBackup(action.restoreBackupFilename);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Encountered error running action: ${error}`);
|
||||||
|
this.setStatus({
|
||||||
|
active: true,
|
||||||
|
action: action.action,
|
||||||
|
task: 'error',
|
||||||
|
error: '' + error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async restoreBackup(filename: string): Promise<void> {
|
||||||
|
this.setStatus({
|
||||||
|
active: true,
|
||||||
|
action: MaintenanceAction.RestoreDatabase,
|
||||||
|
task: 'ready',
|
||||||
|
progress: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
await restoreDatabaseBackup(this.backupRepos, filename, (task, progress) =>
|
||||||
|
this.setStatus({
|
||||||
|
active: true,
|
||||||
|
action: MaintenanceAction.RestoreDatabase,
|
||||||
|
progress,
|
||||||
|
task,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.setAction({
|
||||||
|
action: MaintenanceAction.End,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async endMaintenance(): Promise<void> {
|
||||||
const state: MaintenanceModeState = { isMaintenanceMode: false as const };
|
const state: MaintenanceModeState = { isMaintenanceMode: false as const };
|
||||||
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state);
|
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state);
|
||||||
|
|
||||||
// => corresponds to notification.service.ts#onAppRestart
|
// => corresponds to notification.service.ts#onAppRestart
|
||||||
this.maintenanceWorkerRepository.clientBroadcast('AppRestartV1', state);
|
this.maintenanceWebsocketRepository.clientBroadcast('AppRestartV1', state);
|
||||||
this.maintenanceWorkerRepository.serverSend('AppRestart', state);
|
this.maintenanceWebsocketRepository.serverSend('AppRestart', state);
|
||||||
this.appRepository.exitApp();
|
this.appRepository.exitApp();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
85
server/src/repositories/process.repository.spec.ts
Normal file
85
server/src/repositories/process.repository.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||||
|
import { Readable, Writable } from 'node:stream';
|
||||||
|
import { pipeline } from 'node:stream/promises';
|
||||||
|
import { ProcessRepository } from 'src/repositories/process.repository';
|
||||||
|
|
||||||
|
function* data() {
|
||||||
|
yield 'Hello, world!';
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(ProcessRepository.name, () => {
|
||||||
|
let sut: ProcessRepository;
|
||||||
|
let sink: Writable;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
sut = new ProcessRepository();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sink = new Writable({
|
||||||
|
write(_chunk, _encoding, callback) {
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
|
||||||
|
final(callback) {
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createSpawnDuplexStream', () => {
|
||||||
|
it('should work (drain to stdout)', async () => {
|
||||||
|
const process = sut.createSpawnDuplexStream('bash', ['-c', 'exit 0']);
|
||||||
|
await pipeline(process, sink);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw on non-zero exit code', async () => {
|
||||||
|
const process = sut.createSpawnDuplexStream('bash', ['-c', 'echo "error message" >&2; exit 1']);
|
||||||
|
await expect(pipeline(process, sink)).rejects.toThrowErrorMatchingInlineSnapshot(`
|
||||||
|
[Error: bash non-zero exit code (1)
|
||||||
|
error message
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept stdin / output stdout', async () => {
|
||||||
|
let output = '';
|
||||||
|
const sink = new Writable({
|
||||||
|
write(chunk, _encoding, callback) {
|
||||||
|
output += chunk;
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
|
||||||
|
final(callback) {
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const echoProcess = sut.createSpawnDuplexStream('cat');
|
||||||
|
await pipeline(Readable.from(data()), echoProcess, sink);
|
||||||
|
expect(output).toBe('Hello, world!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should drain stdin on process exit', async () => {
|
||||||
|
let resolve1: () => void;
|
||||||
|
let resolve2: () => void;
|
||||||
|
const promise1 = new Promise<void>((r) => (resolve1 = r));
|
||||||
|
const promise2 = new Promise<void>((r) => (resolve2 = r));
|
||||||
|
|
||||||
|
async function* data() {
|
||||||
|
yield 'Hello, world!';
|
||||||
|
await promise1;
|
||||||
|
await promise2;
|
||||||
|
yield 'Write after stdin close / process exit!';
|
||||||
|
}
|
||||||
|
|
||||||
|
const process = sut.createSpawnDuplexStream('bash', ['-c', 'exit 0']);
|
||||||
|
|
||||||
|
const realProcess = (process as never as { _process: ChildProcessWithoutNullStreams })._process;
|
||||||
|
realProcess.on('close', () => setImmediate(() => resolve1()));
|
||||||
|
realProcess.stdin.on('close', () => setImmediate(() => resolve2()));
|
||||||
|
|
||||||
|
await pipeline(Readable.from(data()), process);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,109 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ChildProcessWithoutNullStreams, spawn, SpawnOptionsWithoutStdio } from 'node:child_process';
|
import { ChildProcessWithoutNullStreams, spawn, SpawnOptionsWithoutStdio } from 'node:child_process';
|
||||||
|
import { Duplex } from 'node:stream';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ProcessRepository {
|
export class ProcessRepository {
|
||||||
spawn(command: string, args: readonly string[], options?: SpawnOptionsWithoutStdio): ChildProcessWithoutNullStreams {
|
spawn(command: string, args?: readonly string[], options?: SpawnOptionsWithoutStdio): ChildProcessWithoutNullStreams {
|
||||||
return spawn(command, args, options);
|
return spawn(command, args, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createSpawnDuplexStream(command: string, args?: readonly string[], options?: SpawnOptionsWithoutStdio): Duplex {
|
||||||
|
let stdinClosed = false;
|
||||||
|
let drainCallback: undefined | (() => void);
|
||||||
|
|
||||||
|
const process = this.spawn(command, args, options);
|
||||||
|
const duplex = new Duplex({
|
||||||
|
// duplex -> stdin
|
||||||
|
write(chunk, encoding, callback) {
|
||||||
|
// drain the input if process dies
|
||||||
|
if (stdinClosed) {
|
||||||
|
return callback();
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle stream backpressure
|
||||||
|
if (process.stdin.write(chunk, encoding)) {
|
||||||
|
callback();
|
||||||
|
} else {
|
||||||
|
drainCallback = callback;
|
||||||
|
process.stdin.once('drain', () => {
|
||||||
|
drainCallback = undefined;
|
||||||
|
callback();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
read() {
|
||||||
|
// no-op
|
||||||
|
},
|
||||||
|
|
||||||
|
final(callback) {
|
||||||
|
if (stdinClosed) {
|
||||||
|
callback();
|
||||||
|
} else {
|
||||||
|
process.stdin.end(callback);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// stdout -> duplex
|
||||||
|
process.stdout.on('data', (chunk) => {
|
||||||
|
// handle stream backpressure
|
||||||
|
if (!duplex.push(chunk)) {
|
||||||
|
process.stdout.pause();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
duplex.on('resume', () => process.stdout.resume());
|
||||||
|
|
||||||
|
// end handling
|
||||||
|
let stdoutClosed = false;
|
||||||
|
function close(error?: Error) {
|
||||||
|
stdinClosed = true;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
duplex.destroy(error);
|
||||||
|
} else if (stdoutClosed && typeof process.exitCode === 'number') {
|
||||||
|
duplex.push(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.on('close', () => {
|
||||||
|
stdoutClosed = true;
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// error handling
|
||||||
|
process.on('error', close);
|
||||||
|
process.stdout.on('error', close);
|
||||||
|
process.stdin.on('error', (error) => {
|
||||||
|
if ((error as { code?: 'EPIPE' })?.code === 'EPIPE') {
|
||||||
|
try {
|
||||||
|
drainCallback!();
|
||||||
|
} catch (error) {
|
||||||
|
close(error as Error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
close(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let stderr = '';
|
||||||
|
process.stderr.on('data', (chunk) => (stderr += chunk));
|
||||||
|
|
||||||
|
process.on('exit', (code) => {
|
||||||
|
console.info(`${command} exited (${code})`);
|
||||||
|
|
||||||
|
if (code === 0) {
|
||||||
|
close();
|
||||||
|
} else {
|
||||||
|
close(new Error(`${command} non-zero exit code (${code})\n${stderr}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// attach _process to Duplex for testing suite
|
||||||
|
(duplex as never as { _process: ChildProcessWithoutNullStreams })._process = process;
|
||||||
|
|
||||||
|
return duplex;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { escapePath, glob, globStream } from 'fast-glob';
|
|||||||
import { constants, createReadStream, createWriteStream, existsSync, mkdirSync, ReadOptionsWithBuffer } from 'node:fs';
|
import { constants, createReadStream, createWriteStream, existsSync, mkdirSync, ReadOptionsWithBuffer } from 'node:fs';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { Readable, Writable } from 'node:stream';
|
import { PassThrough, Readable, Writable } from 'node:stream';
|
||||||
|
import { createGunzip, createGzip } from 'node:zlib';
|
||||||
import { CrawlOptionsDto, WalkOptionsDto } from 'src/dtos/library.dto';
|
import { CrawlOptionsDto, WalkOptionsDto } from 'src/dtos/library.dto';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
import { mimeTypes } from 'src/utils/mime-types';
|
import { mimeTypes } from 'src/utils/mime-types';
|
||||||
@@ -93,6 +94,18 @@ export class StorageRepository {
|
|||||||
return { stream: archive, addFile, finalize };
|
return { stream: archive, addFile, finalize };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createGzip(): PassThrough {
|
||||||
|
return createGzip();
|
||||||
|
}
|
||||||
|
|
||||||
|
createGunzip(): PassThrough {
|
||||||
|
return createGunzip();
|
||||||
|
}
|
||||||
|
|
||||||
|
createPlainReadStream(filepath: string): Readable {
|
||||||
|
return createReadStream(filepath);
|
||||||
|
}
|
||||||
|
|
||||||
async createReadStream(filepath: string, mimeType?: string | null): Promise<ImmichReadStream> {
|
async createReadStream(filepath: string, mimeType?: string | null): Promise<ImmichReadStream> {
|
||||||
const { size } = await fs.stat(filepath);
|
const { size } = await fs.stat(filepath);
|
||||||
await fs.access(filepath, constants.R_OK);
|
await fs.access(filepath, constants.R_OK);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { StorageCore } from 'src/cores/storage.core';
|
|||||||
import { ImmichWorker, JobStatus, StorageFolder } from 'src/enum';
|
import { ImmichWorker, JobStatus, StorageFolder } from 'src/enum';
|
||||||
import { BackupService } from 'src/services/backup.service';
|
import { BackupService } from 'src/services/backup.service';
|
||||||
import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
||||||
import { mockSpawn, newTestService, ServiceMocks } from 'test/utils';
|
import { mockDuplex, mockSpawn, newTestService, ServiceMocks } from 'test/utils';
|
||||||
import { describe } from 'vitest';
|
import { describe } from 'vitest';
|
||||||
|
|
||||||
describe(BackupService.name, () => {
|
describe(BackupService.name, () => {
|
||||||
@@ -147,6 +147,7 @@ describe(BackupService.name, () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.storage.readdir.mockResolvedValue([]);
|
mocks.storage.readdir.mockResolvedValue([]);
|
||||||
mocks.process.spawn.mockReturnValue(mockSpawn(0, 'data', ''));
|
mocks.process.spawn.mockReturnValue(mockSpawn(0, 'data', ''));
|
||||||
|
mocks.process.createSpawnDuplexStream.mockImplementation(() => mockDuplex('command', 0, 'data', ''));
|
||||||
mocks.storage.rename.mockResolvedValue();
|
mocks.storage.rename.mockResolvedValue();
|
||||||
mocks.storage.unlink.mockResolvedValue();
|
mocks.storage.unlink.mockResolvedValue();
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled);
|
||||||
@@ -165,7 +166,7 @@ describe(BackupService.name, () => {
|
|||||||
({ sut, mocks } = newTestService(BackupService, { config: configMock }));
|
({ sut, mocks } = newTestService(BackupService, { config: configMock }));
|
||||||
|
|
||||||
mocks.storage.readdir.mockResolvedValue([]);
|
mocks.storage.readdir.mockResolvedValue([]);
|
||||||
mocks.process.spawn.mockReturnValue(mockSpawn(0, 'data', ''));
|
mocks.process.createSpawnDuplexStream.mockImplementation(() => mockDuplex('command', 0, 'data', ''));
|
||||||
mocks.storage.rename.mockResolvedValue();
|
mocks.storage.rename.mockResolvedValue();
|
||||||
mocks.storage.unlink.mockResolvedValue();
|
mocks.storage.unlink.mockResolvedValue();
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.backupEnabled);
|
||||||
@@ -174,14 +175,16 @@ describe(BackupService.name, () => {
|
|||||||
|
|
||||||
await sut.handleBackupDatabase();
|
await sut.handleBackupDatabase();
|
||||||
|
|
||||||
expect(mocks.process.spawn).toHaveBeenCalled();
|
expect(mocks.process.createSpawnDuplexStream).toHaveBeenCalled();
|
||||||
const call = mocks.process.spawn.mock.calls[0];
|
const call = mocks.process.createSpawnDuplexStream.mock.calls[0];
|
||||||
const args = call[1] as string[];
|
const args = call[1] as string[];
|
||||||
// ['--dbname', '<url>', '--clean', '--if-exists']
|
expect(args).toMatchInlineSnapshot(`
|
||||||
expect(args[0]).toBe('--dbname');
|
[
|
||||||
const passedUrl = args[1];
|
"postgresql://postgres:pwd@host:5432/immich?sslmode=require",
|
||||||
expect(passedUrl).not.toContain('uselibpqcompat');
|
"--clean",
|
||||||
expect(passedUrl).toContain('sslmode=require');
|
"--if-exists",
|
||||||
|
]
|
||||||
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should run a database backup successfully', async () => {
|
it('should run a database backup successfully', async () => {
|
||||||
@@ -196,21 +199,21 @@ describe(BackupService.name, () => {
|
|||||||
expect(mocks.storage.rename).toHaveBeenCalled();
|
expect(mocks.storage.rename).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fail if pg_dumpall fails', async () => {
|
it('should fail if pg_dump fails', async () => {
|
||||||
mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
|
mocks.process.createSpawnDuplexStream.mockReturnValueOnce(mockDuplex('pg_dump', 1, '', 'error'));
|
||||||
await expect(sut.handleBackupDatabase()).rejects.toThrow('Backup failed with code 1');
|
await expect(sut.handleBackupDatabase()).rejects.toThrow('pg_dump non-zero exit code (1)');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not rename file if pgdump fails and gzip succeeds', async () => {
|
it('should not rename file if pgdump fails and gzip succeeds', async () => {
|
||||||
mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
|
mocks.process.createSpawnDuplexStream.mockReturnValueOnce(mockDuplex('pg_dump', 1, '', 'error'));
|
||||||
await expect(sut.handleBackupDatabase()).rejects.toThrow('Backup failed with code 1');
|
await expect(sut.handleBackupDatabase()).rejects.toThrow('pg_dump non-zero exit code (1)');
|
||||||
expect(mocks.storage.rename).not.toHaveBeenCalled();
|
expect(mocks.storage.rename).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fail if gzip fails', async () => {
|
it('should fail if gzip fails', async () => {
|
||||||
mocks.process.spawn.mockReturnValueOnce(mockSpawn(0, 'data', ''));
|
mocks.process.createSpawnDuplexStream.mockReturnValueOnce(mockDuplex('pg_dump', 0, 'data', ''));
|
||||||
mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
|
mocks.process.createSpawnDuplexStream.mockReturnValueOnce(mockDuplex('gzip', 1, '', 'error'));
|
||||||
await expect(sut.handleBackupDatabase()).rejects.toThrow('Gzip failed with code 1');
|
await expect(sut.handleBackupDatabase()).rejects.toThrow('gzip non-zero exit code (1)');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fail if write stream fails', async () => {
|
it('should fail if write stream fails', async () => {
|
||||||
@@ -226,9 +229,9 @@ describe(BackupService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should ignore unlink failing and still return failed job status', async () => {
|
it('should ignore unlink failing and still return failed job status', async () => {
|
||||||
mocks.process.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error'));
|
mocks.process.createSpawnDuplexStream.mockReturnValueOnce(mockDuplex('pg_dump', 1, '', 'error'));
|
||||||
mocks.storage.unlink.mockRejectedValue(new Error('error'));
|
mocks.storage.unlink.mockRejectedValue(new Error('error'));
|
||||||
await expect(sut.handleBackupDatabase()).rejects.toThrow('Backup failed with code 1');
|
await expect(sut.handleBackupDatabase()).rejects.toThrow('pg_dump non-zero exit code (1)');
|
||||||
expect(mocks.storage.unlink).toHaveBeenCalled();
|
expect(mocks.storage.unlink).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -242,12 +245,12 @@ describe(BackupService.name, () => {
|
|||||||
${'17.15.1'} | ${17}
|
${'17.15.1'} | ${17}
|
||||||
${'18.0.0'} | ${18}
|
${'18.0.0'} | ${18}
|
||||||
`(
|
`(
|
||||||
`should use pg_dumpall $expectedVersion with postgres version $postgresVersion`,
|
`should use pg_dump $expectedVersion with postgres version $postgresVersion`,
|
||||||
async ({ postgresVersion, expectedVersion }) => {
|
async ({ postgresVersion, expectedVersion }) => {
|
||||||
mocks.database.getPostgresVersion.mockResolvedValue(postgresVersion);
|
mocks.database.getPostgresVersion.mockResolvedValue(postgresVersion);
|
||||||
await sut.handleBackupDatabase();
|
await sut.handleBackupDatabase();
|
||||||
expect(mocks.process.spawn).toHaveBeenCalledWith(
|
expect(mocks.process.createSpawnDuplexStream).toHaveBeenCalledWith(
|
||||||
`/usr/lib/postgresql/${expectedVersion}/bin/pg_dumpall`,
|
`/usr/lib/postgresql/${expectedVersion}/bin/pg_dump`,
|
||||||
expect.any(Array),
|
expect.any(Array),
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import semver from 'semver';
|
|
||||||
import { serverVersion } from 'src/constants';
|
|
||||||
import { StorageCore } from 'src/cores/storage.core';
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
import { OnEvent, OnJob } from 'src/decorators';
|
import { OnEvent, OnJob } from 'src/decorators';
|
||||||
import { DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName, StorageFolder } from 'src/enum';
|
import { DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName, StorageFolder } from 'src/enum';
|
||||||
import { ArgOf } from 'src/repositories/event.repository';
|
import { ArgOf } from 'src/repositories/event.repository';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
|
import {
|
||||||
|
createDatabaseBackup,
|
||||||
|
isFailedDatabaseBackupName,
|
||||||
|
isValidDatabaseRoutineBackupName,
|
||||||
|
UnsupportedPostgresError,
|
||||||
|
} from 'src/utils/database-backups';
|
||||||
import { handlePromiseError } from 'src/utils/misc';
|
import { handlePromiseError } from 'src/utils/misc';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -53,16 +56,11 @@ export class BackupService extends BaseService {
|
|||||||
|
|
||||||
const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups);
|
const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups);
|
||||||
const files = await this.storageRepository.readdir(backupsFolder);
|
const files = await this.storageRepository.readdir(backupsFolder);
|
||||||
const failedBackups = files.filter((file) => file.match(/immich-db-backup-.*\.sql\.gz\.tmp$/));
|
|
||||||
const backups = files
|
const backups = files
|
||||||
.filter((file) => {
|
.filter((fn) => isValidDatabaseRoutineBackupName(fn))
|
||||||
const oldBackupStyle = file.match(/immich-db-backup-\d+\.sql\.gz$/);
|
|
||||||
//immich-db-backup-20250729T114018-v1.136.0-pg14.17.sql.gz
|
|
||||||
const newBackupStyle = file.match(/immich-db-backup-\d{8}T\d{6}-v.*-pg.*\.sql\.gz$/);
|
|
||||||
return oldBackupStyle || newBackupStyle;
|
|
||||||
})
|
|
||||||
.toSorted()
|
.toSorted()
|
||||||
.toReversed();
|
.toReversed();
|
||||||
|
const failedBackups = files.filter((fn) => isFailedDatabaseBackupName(fn));
|
||||||
|
|
||||||
const toDelete = backups.slice(config.keepLastAmount);
|
const toDelete = backups.slice(config.keepLastAmount);
|
||||||
toDelete.push(...failedBackups);
|
toDelete.push(...failedBackups);
|
||||||
@@ -75,123 +73,27 @@ export class BackupService extends BaseService {
|
|||||||
|
|
||||||
@OnJob({ name: JobName.DatabaseBackup, queue: QueueName.BackupDatabase })
|
@OnJob({ name: JobName.DatabaseBackup, queue: QueueName.BackupDatabase })
|
||||||
async handleBackupDatabase(): Promise<JobStatus> {
|
async handleBackupDatabase(): Promise<JobStatus> {
|
||||||
this.logger.debug(`Database Backup Started`);
|
|
||||||
const { database } = this.configRepository.getEnv();
|
|
||||||
const config = database.config;
|
|
||||||
|
|
||||||
const isUrlConnection = config.connectionType === 'url';
|
|
||||||
|
|
||||||
let connectionUrl: string = isUrlConnection ? config.url : '';
|
|
||||||
if (URL.canParse(connectionUrl)) {
|
|
||||||
// remove known bad url parameters for pg_dumpall
|
|
||||||
const url = new URL(connectionUrl);
|
|
||||||
url.searchParams.delete('uselibpqcompat');
|
|
||||||
connectionUrl = url.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
const databaseParams = isUrlConnection
|
|
||||||
? ['--dbname', connectionUrl]
|
|
||||||
: [
|
|
||||||
'--username',
|
|
||||||
config.username,
|
|
||||||
'--host',
|
|
||||||
config.host,
|
|
||||||
'--port',
|
|
||||||
`${config.port}`,
|
|
||||||
'--database',
|
|
||||||
config.database,
|
|
||||||
];
|
|
||||||
|
|
||||||
databaseParams.push('--clean', '--if-exists');
|
|
||||||
const databaseVersion = await this.databaseRepository.getPostgresVersion();
|
|
||||||
const backupFilePath = path.join(
|
|
||||||
StorageCore.getBaseFolder(StorageFolder.Backups),
|
|
||||||
`immich-db-backup-${DateTime.now().toFormat("yyyyLLdd'T'HHmmss")}-v${serverVersion.toString()}-pg${databaseVersion.split(' ')[0]}.sql.gz.tmp`,
|
|
||||||
);
|
|
||||||
const databaseSemver = semver.coerce(databaseVersion);
|
|
||||||
const databaseMajorVersion = databaseSemver?.major;
|
|
||||||
|
|
||||||
if (!databaseMajorVersion || !databaseSemver || !semver.satisfies(databaseSemver, '>=14.0.0 <19.0.0')) {
|
|
||||||
this.logger.error(`Database Backup Failure: Unsupported PostgreSQL version: ${databaseVersion}`);
|
|
||||||
return JobStatus.Failed;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Database Backup Starting. Database Version: ${databaseMajorVersion}`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await createDatabaseBackup(this.backupRepos);
|
||||||
const pgdump = this.processRepository.spawn(
|
|
||||||
`/usr/lib/postgresql/${databaseMajorVersion}/bin/pg_dumpall`,
|
|
||||||
databaseParams,
|
|
||||||
{
|
|
||||||
env: {
|
|
||||||
PATH: process.env.PATH,
|
|
||||||
PGPASSWORD: isUrlConnection ? new URL(connectionUrl).password : config.password,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// NOTE: `--rsyncable` is only supported in GNU gzip
|
|
||||||
const gzip = this.processRepository.spawn(`gzip`, ['--rsyncable']);
|
|
||||||
pgdump.stdout.pipe(gzip.stdin);
|
|
||||||
|
|
||||||
const fileStream = this.storageRepository.createWriteStream(backupFilePath);
|
|
||||||
|
|
||||||
gzip.stdout.pipe(fileStream);
|
|
||||||
|
|
||||||
pgdump.on('error', (err) => {
|
|
||||||
this.logger.error(`Backup failed with error: ${err}`);
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
gzip.on('error', (err) => {
|
|
||||||
this.logger.error(`Gzip failed with error: ${err}`);
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
let pgdumpLogs = '';
|
|
||||||
let gzipLogs = '';
|
|
||||||
|
|
||||||
pgdump.stderr.on('data', (data) => (pgdumpLogs += data));
|
|
||||||
gzip.stderr.on('data', (data) => (gzipLogs += data));
|
|
||||||
|
|
||||||
pgdump.on('exit', (code) => {
|
|
||||||
if (code !== 0) {
|
|
||||||
this.logger.error(`Backup failed with code ${code}`);
|
|
||||||
reject(`Backup failed with code ${code}`);
|
|
||||||
this.logger.error(pgdumpLogs);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (pgdumpLogs) {
|
|
||||||
this.logger.debug(`pgdump_all logs\n${pgdumpLogs}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
gzip.on('exit', (code) => {
|
|
||||||
if (code !== 0) {
|
|
||||||
this.logger.error(`Gzip failed with code ${code}`);
|
|
||||||
reject(`Gzip failed with code ${code}`);
|
|
||||||
this.logger.error(gzipLogs);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (pgdump.exitCode !== 0) {
|
|
||||||
this.logger.error(`Gzip exited with code 0 but pgdump exited with ${pgdump.exitCode}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
await this.storageRepository.rename(backupFilePath, backupFilePath.replace('.tmp', ''));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Database Backup Failure: ${error}`);
|
if (error instanceof UnsupportedPostgresError) {
|
||||||
await this.storageRepository
|
return JobStatus.Failed;
|
||||||
.unlink(backupFilePath)
|
}
|
||||||
.catch((error) => this.logger.error(`Failed to delete failed backup file: ${error}`));
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`Database Backup Success`);
|
|
||||||
await this.cleanupDatabaseBackups();
|
await this.cleanupDatabaseBackups();
|
||||||
return JobStatus.Success;
|
return JobStatus.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get backupRepos() {
|
||||||
|
return {
|
||||||
|
logger: this.logger,
|
||||||
|
storage: this.storageRepository,
|
||||||
|
config: this.configRepository,
|
||||||
|
process: this.processRepository,
|
||||||
|
database: this.databaseRepository,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { jwtVerify } from 'jose';
|
import { jwtVerify } from 'jose';
|
||||||
import { SystemMetadataKey } from 'src/enum';
|
import { MaintenanceAction, SystemMetadataKey } from 'src/enum';
|
||||||
import { CliService } from 'src/services/cli.service';
|
import { CliService } from 'src/services/cli.service';
|
||||||
import { factory } from 'test/small.factory';
|
import { factory } from 'test/small.factory';
|
||||||
import { newTestService, ServiceMocks } from 'test/utils';
|
import { newTestService, ServiceMocks } from 'test/utils';
|
||||||
@@ -94,7 +94,14 @@ describe(CliService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should disable maintenance mode', async () => {
|
it('should disable maintenance mode', async () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
|
mocks.systemMetadata.get.mockResolvedValue({
|
||||||
|
isMaintenanceMode: true,
|
||||||
|
secret: 'secret',
|
||||||
|
action: {
|
||||||
|
action: MaintenanceAction.Start,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
await expect(sut.disableMaintenanceMode()).resolves.toEqual({
|
await expect(sut.disableMaintenanceMode()).resolves.toEqual({
|
||||||
alreadyDisabled: false,
|
alreadyDisabled: false,
|
||||||
});
|
});
|
||||||
@@ -107,7 +114,14 @@ describe(CliService.name, () => {
|
|||||||
|
|
||||||
describe('enableMaintenanceMode', () => {
|
describe('enableMaintenanceMode', () => {
|
||||||
it('should not do anything if in maintenance mode', async () => {
|
it('should not do anything if in maintenance mode', async () => {
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
|
mocks.systemMetadata.get.mockResolvedValue({
|
||||||
|
isMaintenanceMode: true,
|
||||||
|
secret: 'secret',
|
||||||
|
action: {
|
||||||
|
action: MaintenanceAction.Start,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
await expect(sut.enableMaintenanceMode()).resolves.toEqual(
|
await expect(sut.enableMaintenanceMode()).resolves.toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
alreadyEnabled: true,
|
alreadyEnabled: true,
|
||||||
@@ -129,13 +143,22 @@ describe(CliService.name, () => {
|
|||||||
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',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
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 return a valid login URL', async () => {
|
it('should return 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const result = await sut.enableMaintenanceMode();
|
const result = await sut.enableMaintenanceMode();
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { isAbsolute } from 'node:path';
|
|||||||
import { SALT_ROUNDS } from 'src/constants';
|
import { SALT_ROUNDS } from 'src/constants';
|
||||||
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
|
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
|
||||||
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
|
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
|
||||||
import { SystemMetadataKey } from 'src/enum';
|
import { MaintenanceAction, SystemMetadataKey } from 'src/enum';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { createMaintenanceLoginUrl, generateMaintenanceSecret, sendOneShotAppRestart } from 'src/utils/maintenance';
|
import { createMaintenanceLoginUrl, generateMaintenanceSecret, sendOneShotAppRestart } from 'src/utils/maintenance';
|
||||||
import { getExternalDomain } from 'src/utils/misc';
|
import { getExternalDomain } from 'src/utils/misc';
|
||||||
@@ -87,6 +87,9 @@ export class CliService extends BaseService {
|
|||||||
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, {
|
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, {
|
||||||
isMaintenanceMode: true,
|
isMaintenanceMode: true,
|
||||||
secret,
|
secret,
|
||||||
|
action: {
|
||||||
|
action: MaintenanceAction.Start,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
sendOneShotAppRestart({
|
sendOneShotAppRestart({
|
||||||
|
|||||||
82
server/src/services/database-backup.service.spec.ts
Normal file
82
server/src/services/database-backup.service.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
|
import { StorageFolder } from 'src/enum';
|
||||||
|
import { DatabaseBackupService } from 'src/services/database-backup.service';
|
||||||
|
import { MaintenanceService } from 'src/services/maintenance.service';
|
||||||
|
import { newTestService, ServiceMocks } from 'test/utils';
|
||||||
|
|
||||||
|
describe(MaintenanceService.name, () => {
|
||||||
|
let sut: DatabaseBackupService;
|
||||||
|
let mocks: ServiceMocks;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ sut, mocks } = newTestService(DatabaseBackupService));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work', () => {
|
||||||
|
expect(sut).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listBackups', () => {
|
||||||
|
it('should give us all 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',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteBackup', () => {
|
||||||
|
it('should reject invalid file names', async () => {
|
||||||
|
await expect(sut.deleteBackup(['filename'])).rejects.toThrowError(
|
||||||
|
new BadRequestException('Invalid backup name!'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should unlink the target file', async () => {
|
||||||
|
await sut.deleteBackup(['filename.sql']);
|
||||||
|
expect(mocks.storage.unlink).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.storage.unlink).toHaveBeenCalledWith(
|
||||||
|
`${StorageCore.getBaseFolder(StorageFolder.Backups)}/filename.sql`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('uploadBackup', () => {
|
||||||
|
it('should reject invalid file names', async () => {
|
||||||
|
await expect(sut.uploadBackup({ originalname: 'invalid backup' } as never)).rejects.toThrowError(
|
||||||
|
new BadRequestException('Invalid backup name!'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should write file', async () => {
|
||||||
|
await sut.uploadBackup({ originalname: 'path.sql.gz', buffer: 'buffer' } as never);
|
||||||
|
expect(mocks.storage.createOrOverwriteFile).toBeCalledWith('/data/backups/uploaded-path.sql.gz', 'buffer');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('downloadBackup', () => {
|
||||||
|
it('should reject invalid file names', () => {
|
||||||
|
expect(() => sut.downloadBackup('invalid backup')).toThrowError(new BadRequestException('Invalid backup name!'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get backup path', () => {
|
||||||
|
expect(sut.downloadBackup('hello.sql.gz')).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
path: '/data/backups/hello.sql.gz',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
41
server/src/services/database-backup.service.ts
Normal file
41
server/src/services/database-backup.service.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { BaseService } from 'src/services/base.service';
|
||||||
|
import {
|
||||||
|
deleteDatabaseBackup,
|
||||||
|
downloadDatabaseBackup,
|
||||||
|
listDatabaseBackups,
|
||||||
|
uploadDatabaseBackup,
|
||||||
|
} from 'src/utils/database-backups';
|
||||||
|
import { ImmichFileResponse } from 'src/utils/file';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This service is available outside of maintenance mode to manage maintenance mode
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class DatabaseBackupService extends BaseService {
|
||||||
|
async listBackups(): Promise<{ backups: string[] }> {
|
||||||
|
return { backups: await listDatabaseBackups(this.backupRepos) };
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteBackup(files: string[]): Promise<void> {
|
||||||
|
return deleteDatabaseBackup(this.backupRepos, files);
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadBackup(file: Express.Multer.File): Promise<void> {
|
||||||
|
return uploadDatabaseBackup(this.backupRepos, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadBackup(fileName: string): ImmichFileResponse {
|
||||||
|
return downloadDatabaseBackup(fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get backupRepos() {
|
||||||
|
return {
|
||||||
|
logger: this.logger,
|
||||||
|
storage: this.storageRepository,
|
||||||
|
config: this.configRepository,
|
||||||
|
process: this.processRepository,
|
||||||
|
database: this.databaseRepository,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { AuthAdminService } from 'src/services/auth-admin.service';
|
|||||||
import { AuthService } from 'src/services/auth.service';
|
import { AuthService } from 'src/services/auth.service';
|
||||||
import { BackupService } from 'src/services/backup.service';
|
import { BackupService } from 'src/services/backup.service';
|
||||||
import { CliService } from 'src/services/cli.service';
|
import { CliService } from 'src/services/cli.service';
|
||||||
|
import { DatabaseBackupService } from 'src/services/database-backup.service';
|
||||||
import { DatabaseService } from 'src/services/database.service';
|
import { DatabaseService } from 'src/services/database.service';
|
||||||
import { DownloadService } from 'src/services/download.service';
|
import { DownloadService } from 'src/services/download.service';
|
||||||
import { DuplicateService } from 'src/services/duplicate.service';
|
import { DuplicateService } from 'src/services/duplicate.service';
|
||||||
@@ -59,6 +60,7 @@ export const services = [
|
|||||||
AuthAdminService,
|
AuthAdminService,
|
||||||
BackupService,
|
BackupService,
|
||||||
CliService,
|
CliService,
|
||||||
|
DatabaseBackupService,
|
||||||
DatabaseService,
|
DatabaseService,
|
||||||
DownloadService,
|
DownloadService,
|
||||||
DuplicateService,
|
DuplicateService,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SystemMetadataKey } from 'src/enum';
|
import { MaintenanceAction, 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,28 +36,96 @@ 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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('integrityCheck', () => {
|
||||||
|
it('generate integrity report', async () => {
|
||||||
|
mocks.storage.readdir.mockResolvedValue(['.immich', 'file1', 'file2']);
|
||||||
|
mocks.storage.readFile.mockResolvedValue(undefined as never);
|
||||||
|
mocks.storage.overwriteFile.mockRejectedValue(undefined as never);
|
||||||
|
|
||||||
|
await expect(sut.detectPriorInstall()).resolves.toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"storage": [
|
||||||
|
{
|
||||||
|
"files": 2,
|
||||||
|
"folder": "encoded-video",
|
||||||
|
"readable": true,
|
||||||
|
"writable": false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": 2,
|
||||||
|
"folder": "library",
|
||||||
|
"readable": true,
|
||||||
|
"writable": false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": 2,
|
||||||
|
"folder": "upload",
|
||||||
|
"readable": true,
|
||||||
|
"writable": false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": 2,
|
||||||
|
"folder": "profile",
|
||||||
|
"readable": true,
|
||||||
|
"writable": false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": 2,
|
||||||
|
"folder": "thumbs",
|
||||||
|
"readable": true,
|
||||||
|
"writable": false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": 2,
|
||||||
|
"folder": "backups",
|
||||||
|
"readable": true,
|
||||||
|
"writable": false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('startMaintenance', () => {
|
describe('startMaintenance', () => {
|
||||||
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 +146,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({
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { OnEvent } from 'src/decorators';
|
import { OnEvent } from 'src/decorators';
|
||||||
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
|
import {
|
||||||
import { SystemMetadataKey } from 'src/enum';
|
MaintenanceAuthDto,
|
||||||
|
MaintenanceDetectInstallResponseDto,
|
||||||
|
MaintenanceStatusResponseDto,
|
||||||
|
SetMaintenanceModeDto,
|
||||||
|
} from 'src/dtos/maintenance.dto';
|
||||||
|
import { MaintenanceAction, 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 { createMaintenanceLoginUrl, generateMaintenanceSecret, signMaintenanceJwt } from 'src/utils/maintenance';
|
import {
|
||||||
|
createMaintenanceLoginUrl,
|
||||||
|
detectPriorInstall,
|
||||||
|
generateMaintenanceSecret,
|
||||||
|
signMaintenanceJwt,
|
||||||
|
} from 'src/utils/maintenance';
|
||||||
import { getExternalDomain } from 'src/utils/misc';
|
import { getExternalDomain } from 'src/utils/misc';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,9 +28,25 @@ export class MaintenanceService extends BaseService {
|
|||||||
.then((state) => state ?? { isMaintenanceMode: false });
|
.then((state) => state ?? { isMaintenanceMode: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
async startMaintenance(username: string): Promise<{ jwt: string }> {
|
getMaintenanceStatus(): MaintenanceStatusResponseDto {
|
||||||
|
return {
|
||||||
|
active: false,
|
||||||
|
action: MaintenanceAction.End,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
detectPriorInstall(): Promise<MaintenanceDetectInstallResponseDto> {
|
||||||
|
return detectPriorInstall(this.storageRepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
@@ -30,6 +56,20 @@ export class MaintenanceService extends BaseService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async startRestoreFlow(): Promise<{ jwt: string }> {
|
||||||
|
const adminUser = await this.userRepository.getAdmin();
|
||||||
|
if (adminUser) {
|
||||||
|
throw new BadRequestException('The server already has an admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.startMaintenance(
|
||||||
|
{
|
||||||
|
action: MaintenanceAction.RestoreDatabase,
|
||||||
|
},
|
||||||
|
'admin',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@OnEvent({ name: 'AppRestart', server: true })
|
@OnEvent({ name: 'AppRestart', server: true })
|
||||||
onRestart(): void {
|
onRestart(): void {
|
||||||
this.appRepository.exitApp();
|
this.appRepository.exitApp();
|
||||||
|
|||||||
@@ -1034,7 +1034,10 @@ describe(MetadataService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should use Duration from exif', async () => {
|
it('should use Duration from exif', async () => {
|
||||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({
|
||||||
|
...assetStub.image,
|
||||||
|
originalPath: '/original/path.webp',
|
||||||
|
});
|
||||||
mockReadTags({ Duration: 123 }, {});
|
mockReadTags({ Duration: 123 }, {});
|
||||||
|
|
||||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||||
@@ -1046,6 +1049,7 @@ describe(MetadataService.name, () => {
|
|||||||
it('should prefer Duration from exif over sidecar', async () => {
|
it('should prefer Duration from exif over sidecar', async () => {
|
||||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({
|
||||||
...assetStub.image,
|
...assetStub.image,
|
||||||
|
originalPath: '/original/path.webp',
|
||||||
files: [
|
files: [
|
||||||
{
|
{
|
||||||
id: 'some-id',
|
id: 'some-id',
|
||||||
@@ -1063,6 +1067,16 @@ describe(MetadataService.name, () => {
|
|||||||
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: '00:02:03.000' }));
|
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: '00:02:03.000' }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should ignore all Duration tags for definitely static images', async () => {
|
||||||
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.imageDng);
|
||||||
|
mockReadTags({ Duration: 123 }, { Duration: 456 });
|
||||||
|
|
||||||
|
await sut.handleMetadataExtraction({ id: assetStub.imageDng.id });
|
||||||
|
|
||||||
|
expect(mocks.metadata.readTags).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: null }));
|
||||||
|
});
|
||||||
|
|
||||||
it('should ignore Duration from exif for videos', async () => {
|
it('should ignore Duration from exif for videos', async () => {
|
||||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.video);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.video);
|
||||||
mockReadTags({ Duration: 123 }, {});
|
mockReadTags({ Duration: 123 }, {});
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { BaseService } from 'src/services/base.service';
|
|||||||
import { JobItem, JobOf } from 'src/types';
|
import { JobItem, JobOf } from 'src/types';
|
||||||
import { getAssetFiles } from 'src/utils/asset.util';
|
import { getAssetFiles } from 'src/utils/asset.util';
|
||||||
import { isAssetChecksumConstraint } from 'src/utils/database';
|
import { isAssetChecksumConstraint } from 'src/utils/database';
|
||||||
|
import { mimeTypes } from 'src/utils/mime-types';
|
||||||
import { isFaceImportEnabled } from 'src/utils/misc';
|
import { isFaceImportEnabled } from 'src/utils/misc';
|
||||||
import { upsertTags } from 'src/utils/tag';
|
import { upsertTags } from 'src/utils/tag';
|
||||||
|
|
||||||
@@ -486,7 +487,8 @@ export class MetadataService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// prefer duration from video tags
|
// prefer duration from video tags
|
||||||
if (videoTags) {
|
// don't save duration if asset is definitely not an animated image (see e.g. CR3 with Duration: 1s)
|
||||||
|
if (videoTags || !mimeTypes.isPossiblyAnimatedImage(asset.originalPath)) {
|
||||||
delete mediaTags.Duration;
|
delete mediaTags.Duration;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { VECTOR_EXTENSIONS } from 'src/constants';
|
|||||||
import { Asset, AssetFile } from 'src/database';
|
import { Asset, AssetFile } 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 {
|
||||||
AssetOrder,
|
AssetOrder,
|
||||||
AssetType,
|
AssetType,
|
||||||
@@ -487,7 +488,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;
|
||||||
|
|||||||
385
server/src/utils/database-backups.ts
Normal file
385
server/src/utils/database-backups.ts
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { debounce } from 'lodash';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import path, { basename, join } from 'node:path';
|
||||||
|
import { PassThrough, Readable, Writable } from 'node:stream';
|
||||||
|
import { pipeline } from 'node:stream/promises';
|
||||||
|
import semver from 'semver';
|
||||||
|
import { serverVersion } from 'src/constants';
|
||||||
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
|
import { CacheControl, StorageFolder } from 'src/enum';
|
||||||
|
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||||
|
import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||||
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
|
import { ProcessRepository } from 'src/repositories/process.repository';
|
||||||
|
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||||
|
|
||||||
|
export function isValidDatabaseBackupName(filename: string) {
|
||||||
|
return filename.match(/^[\d\w-.]+\.sql(?:\.gz)?$/);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidDatabaseRoutineBackupName(filename: string) {
|
||||||
|
const oldBackupStyle = filename.match(/^immich-db-backup-\d+\.sql\.gz$/);
|
||||||
|
//immich-db-backup-20250729T114018-v1.136.0-pg14.17.sql.gz
|
||||||
|
const newBackupStyle = filename.match(/^immich-db-backup-\d{8}T\d{6}-v.*-pg.*\.sql\.gz$/);
|
||||||
|
return oldBackupStyle || newBackupStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFailedDatabaseBackupName(filename: string) {
|
||||||
|
return filename.match(/^immich-db-backup-.*\.sql\.gz\.tmp$/);
|
||||||
|
}
|
||||||
|
|
||||||
|
type BackupRepos = {
|
||||||
|
logger: LoggingRepository;
|
||||||
|
storage: StorageRepository;
|
||||||
|
config: ConfigRepository;
|
||||||
|
process: ProcessRepository;
|
||||||
|
database: DatabaseRepository;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class UnsupportedPostgresError extends Error {
|
||||||
|
constructor(databaseVersion: string) {
|
||||||
|
super(`Unsupported PostgreSQL version: ${databaseVersion}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildPostgresLaunchArguments(
|
||||||
|
{ logger, config, database }: Pick<BackupRepos, 'logger' | 'config' | 'database'>,
|
||||||
|
bin: 'pg_dump' | 'pg_dumpall' | 'psql',
|
||||||
|
): Promise<{
|
||||||
|
bin: string;
|
||||||
|
args: string[];
|
||||||
|
databasePassword: string;
|
||||||
|
databaseVersion: string;
|
||||||
|
databaseMajorVersion?: number;
|
||||||
|
}> {
|
||||||
|
const {
|
||||||
|
database: { config: databaseConfig },
|
||||||
|
} = config.getEnv();
|
||||||
|
const isUrlConnection = databaseConfig.connectionType === 'url';
|
||||||
|
|
||||||
|
const databaseVersion = await database.getPostgresVersion();
|
||||||
|
const databaseSemver = semver.coerce(databaseVersion);
|
||||||
|
const databaseMajorVersion = databaseSemver?.major;
|
||||||
|
|
||||||
|
const args: string[] = [];
|
||||||
|
|
||||||
|
if (isUrlConnection) {
|
||||||
|
if (bin !== 'pg_dump') {
|
||||||
|
args.push('--dbname');
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = databaseConfig.url;
|
||||||
|
if (URL.canParse(databaseConfig.url)) {
|
||||||
|
const parsedUrl = new URL(databaseConfig.url);
|
||||||
|
// remove known bad parameters
|
||||||
|
parsedUrl.searchParams.delete('uselibpqcompat');
|
||||||
|
url = parsedUrl.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push(url);
|
||||||
|
} else {
|
||||||
|
args.push(
|
||||||
|
'--username',
|
||||||
|
databaseConfig.username,
|
||||||
|
'--host',
|
||||||
|
databaseConfig.host,
|
||||||
|
'--port',
|
||||||
|
databaseConfig.port.toString(),
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (bin) {
|
||||||
|
case 'pg_dumpall': {
|
||||||
|
args.push('--database');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'psql': {
|
||||||
|
args.push('--dbname');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push(databaseConfig.database);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (bin) {
|
||||||
|
case 'pg_dump':
|
||||||
|
case 'pg_dumpall': {
|
||||||
|
args.push('--clean', '--if-exists');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'psql': {
|
||||||
|
args.push(
|
||||||
|
// don't commit any transaction on failure
|
||||||
|
'--single-transaction',
|
||||||
|
// exit with non-zero code on error
|
||||||
|
'--set',
|
||||||
|
'ON_ERROR_STOP=on',
|
||||||
|
// used for progress monitoring
|
||||||
|
'--echo-all',
|
||||||
|
'--output=/dev/null',
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!databaseMajorVersion || !databaseSemver || !semver.satisfies(databaseSemver, '>=14.0.0 <19.0.0')) {
|
||||||
|
logger.error(`Database Restore Failure: Unsupported PostgreSQL version: ${databaseVersion}`);
|
||||||
|
throw new UnsupportedPostgresError(databaseVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bin: `/usr/lib/postgresql/${databaseMajorVersion}/bin/${bin}`,
|
||||||
|
args,
|
||||||
|
databasePassword: isUrlConnection ? new URL(databaseConfig.url).password : databaseConfig.password,
|
||||||
|
databaseVersion,
|
||||||
|
databaseMajorVersion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDatabaseBackup(
|
||||||
|
{ logger, storage, process: processRepository, ...pgRepos }: BackupRepos,
|
||||||
|
filenamePrefix: string = '',
|
||||||
|
): Promise<void> {
|
||||||
|
logger.debug(`Database Backup Started`);
|
||||||
|
|
||||||
|
const { bin, args, databasePassword, databaseVersion, databaseMajorVersion } = await buildPostgresLaunchArguments(
|
||||||
|
{ logger, ...pgRepos },
|
||||||
|
'pg_dump',
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.log(`Database Backup Starting. Database Version: ${databaseMajorVersion}`);
|
||||||
|
|
||||||
|
const backupFilePath = join(
|
||||||
|
StorageCore.getBaseFolder(StorageFolder.Backups),
|
||||||
|
`${filenamePrefix}immich-db-backup-${DateTime.now().toFormat("yyyyLLdd'T'HHmmss")}-v${serverVersion.toString()}-pg${databaseVersion.split(' ')[0]}.sql.gz.tmp`,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pgdump = processRepository.createSpawnDuplexStream(bin, args, {
|
||||||
|
env: {
|
||||||
|
PATH: process.env.PATH,
|
||||||
|
PGPASSWORD: databasePassword,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const gzip = processRepository.createSpawnDuplexStream('gzip', ['--rsyncable']);
|
||||||
|
const fileStream = storage.createWriteStream(backupFilePath);
|
||||||
|
|
||||||
|
await pipeline(pgdump, gzip, fileStream);
|
||||||
|
await storage.rename(backupFilePath, backupFilePath.replace('.tmp', ''));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Database Backup Failure: ${error}`);
|
||||||
|
await storage
|
||||||
|
.unlink(backupFilePath)
|
||||||
|
.catch((error) => logger.error(`Failed to delete failed backup file: ${error}`));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log(`Database Backup Success`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreDatabaseBackup(
|
||||||
|
{ logger, storage, process: processRepository, ...pgRepos }: BackupRepos,
|
||||||
|
filename: string,
|
||||||
|
progressCb?: (action: 'backup' | 'restore', progress: number) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
logger.debug(`Database Restore Started`);
|
||||||
|
|
||||||
|
let complete = false;
|
||||||
|
try {
|
||||||
|
if (!isValidDatabaseBackupName(filename)) {
|
||||||
|
throw new Error('Invalid backup file format!');
|
||||||
|
}
|
||||||
|
|
||||||
|
const backupFilePath = path.join(StorageCore.getBaseFolder(StorageFolder.Backups), filename);
|
||||||
|
await storage.stat(backupFilePath); // => check file exists
|
||||||
|
|
||||||
|
const { bin, args, databasePassword, databaseMajorVersion } = await buildPostgresLaunchArguments(
|
||||||
|
{ logger, ...pgRepos },
|
||||||
|
'psql',
|
||||||
|
);
|
||||||
|
|
||||||
|
progressCb?.('backup', 0.05);
|
||||||
|
|
||||||
|
await createDatabaseBackup({ logger, storage, process: processRepository, ...pgRepos }, 'restore-point-');
|
||||||
|
|
||||||
|
logger.log(`Database Restore Starting. Database Version: ${databaseMajorVersion}`);
|
||||||
|
|
||||||
|
let inputStream: Readable;
|
||||||
|
if (backupFilePath.endsWith('.gz')) {
|
||||||
|
const fileStream = storage.createPlainReadStream(backupFilePath);
|
||||||
|
const gunzip = storage.createGunzip();
|
||||||
|
fileStream.pipe(gunzip);
|
||||||
|
inputStream = gunzip;
|
||||||
|
} else {
|
||||||
|
inputStream = storage.createPlainReadStream(backupFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function* sql() {
|
||||||
|
yield `
|
||||||
|
-- drop all other database connections
|
||||||
|
SELECT pg_terminate_backend(pid)
|
||||||
|
FROM pg_stat_activity
|
||||||
|
WHERE datname = current_database()
|
||||||
|
AND pid <> pg_backend_pid();
|
||||||
|
|
||||||
|
-- re-create the default schema
|
||||||
|
DROP SCHEMA public CASCADE;
|
||||||
|
CREATE SCHEMA public;
|
||||||
|
|
||||||
|
-- restore access to schema
|
||||||
|
GRANT ALL ON SCHEMA public TO postgres;
|
||||||
|
GRANT ALL ON SCHEMA public TO public;
|
||||||
|
`;
|
||||||
|
|
||||||
|
for await (const chunk of inputStream) {
|
||||||
|
yield chunk;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqlStream = Readable.from(sql());
|
||||||
|
const psql = processRepository.createSpawnDuplexStream(bin, args, {
|
||||||
|
env: {
|
||||||
|
PATH: process.env.PATH,
|
||||||
|
PGPASSWORD: databasePassword,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [progressSource, progressSink] = createSqlProgressStreams((progress) => {
|
||||||
|
if (complete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log(`Restore progress ~ ${(progress * 100).toFixed(2)}%`);
|
||||||
|
progressCb?.('restore', progress);
|
||||||
|
});
|
||||||
|
|
||||||
|
await pipeline(sqlStream, progressSource, psql, progressSink);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Database Restore Failure: ${error}`);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
complete = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log(`Database Restore Success`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDatabaseBackup({ storage }: Pick<BackupRepos, 'storage'>, files: string[]): Promise<void> {
|
||||||
|
const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups);
|
||||||
|
|
||||||
|
if (files.some((filename) => !isValidDatabaseBackupName(filename))) {
|
||||||
|
throw new BadRequestException('Invalid backup name!');
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(files.map((filename) => storage.unlink(path.join(backupsFolder, filename))));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listDatabaseBackups({ storage }: Pick<BackupRepos, 'storage'>): Promise<string[]> {
|
||||||
|
const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups);
|
||||||
|
const files = await storage.readdir(backupsFolder);
|
||||||
|
return files
|
||||||
|
.filter((fn) => isValidDatabaseBackupName(fn))
|
||||||
|
.toSorted((a, b) => (a.startsWith('uploaded-') === b.startsWith('uploaded-') ? a.localeCompare(b) : 1))
|
||||||
|
.toReversed();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadDatabaseBackup(
|
||||||
|
{ storage }: Pick<BackupRepos, 'storage'>,
|
||||||
|
file: Express.Multer.File,
|
||||||
|
): Promise<void> {
|
||||||
|
const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups);
|
||||||
|
const fn = basename(file.originalname);
|
||||||
|
if (!isValidDatabaseBackupName(fn)) {
|
||||||
|
throw new BadRequestException('Invalid backup name!');
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = join(backupsFolder, `uploaded-${fn}`);
|
||||||
|
await storage.createOrOverwriteFile(path, file.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadDatabaseBackup(fileName: string) {
|
||||||
|
if (!isValidDatabaseBackupName(fileName)) {
|
||||||
|
throw new BadRequestException('Invalid backup name!');
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = join(StorageCore.getBaseFolder(StorageFolder.Backups), fileName);
|
||||||
|
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
fileName,
|
||||||
|
cacheControl: CacheControl.PrivateWithoutCache,
|
||||||
|
contentType: fileName.endsWith('.gz') ? 'application/gzip' : 'application/sql',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSqlProgressStreams(cb: (progress: number) => void) {
|
||||||
|
const STDIN_START_MARKER = new TextEncoder().encode('FROM stdin');
|
||||||
|
const STDIN_END_MARKER = new TextEncoder().encode(String.raw`\.`);
|
||||||
|
|
||||||
|
let readingStdin = false;
|
||||||
|
let sequenceIdx = 0;
|
||||||
|
|
||||||
|
let linesSent = 0;
|
||||||
|
let linesProcessed = 0;
|
||||||
|
|
||||||
|
const startedAt = +Date.now();
|
||||||
|
const cbDebounced = debounce(
|
||||||
|
() => {
|
||||||
|
const progress = source.writableEnded
|
||||||
|
? Math.min(1, linesProcessed / linesSent)
|
||||||
|
: // progress simulation while we're in an indeterminate state
|
||||||
|
Math.min(0.3, 0.1 + (Date.now() - startedAt) / 1e4);
|
||||||
|
cb(progress);
|
||||||
|
},
|
||||||
|
100,
|
||||||
|
{
|
||||||
|
maxWait: 100,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let lastByte = -1;
|
||||||
|
const source = new PassThrough({
|
||||||
|
transform(chunk, _encoding, callback) {
|
||||||
|
for (const byte of chunk) {
|
||||||
|
if (!readingStdin && byte === 10 && lastByte !== 10) {
|
||||||
|
linesSent += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastByte = byte;
|
||||||
|
|
||||||
|
const sequence = readingStdin ? STDIN_END_MARKER : STDIN_START_MARKER;
|
||||||
|
if (sequence[sequenceIdx] === byte) {
|
||||||
|
sequenceIdx += 1;
|
||||||
|
|
||||||
|
if (sequence.length === sequenceIdx) {
|
||||||
|
sequenceIdx = 0;
|
||||||
|
readingStdin = !readingStdin;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sequenceIdx = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cbDebounced();
|
||||||
|
this.push(chunk);
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sink = new Writable({
|
||||||
|
write(chunk, _encoding, callback) {
|
||||||
|
for (const byte of chunk) {
|
||||||
|
if (byte === 10) {
|
||||||
|
linesProcessed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cbDebounced();
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return [source, sink];
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ const cacheControlHeaders: Record<CacheControl, string | null> = {
|
|||||||
export const sendFile = async (
|
export const sendFile = async (
|
||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction,
|
next: NextFunction,
|
||||||
handler: () => Promise<ImmichFileResponse>,
|
handler: () => Promise<ImmichFileResponse> | ImmichFileResponse,
|
||||||
logger: LoggingRepository,
|
logger: LoggingRepository,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
// promisified version of 'res.sendFile' for cleaner async handling
|
// promisified version of 'res.sendFile' for cleaner async handling
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ import { createAdapter } from '@socket.io/redis-adapter';
|
|||||||
import Redis from 'ioredis';
|
import Redis from 'ioredis';
|
||||||
import { SignJWT } from 'jose';
|
import { SignJWT } from 'jose';
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { join } from 'node:path';
|
||||||
import { Server as SocketIO } from 'socket.io';
|
import { Server as SocketIO } from 'socket.io';
|
||||||
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
|
import { MaintenanceAuthDto, MaintenanceDetectInstallResponseDto } from 'src/dtos/maintenance.dto';
|
||||||
|
import { StorageFolder } from 'src/enum';
|
||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||||
import { AppRestartEvent } from 'src/repositories/event.repository';
|
import { AppRestartEvent } from 'src/repositories/event.repository';
|
||||||
|
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||||
|
|
||||||
export function sendOneShotAppRestart(state: AppRestartEvent): void {
|
export function sendOneShotAppRestart(state: AppRestartEvent): void {
|
||||||
const server = new SocketIO();
|
const server = new SocketIO();
|
||||||
@@ -72,3 +76,41 @@ export async function signMaintenanceJwt(secret: string, data: MaintenanceAuthDt
|
|||||||
export function generateMaintenanceSecret(): string {
|
export function generateMaintenanceSecret(): string {
|
||||||
return randomBytes(64).toString('hex');
|
return randomBytes(64).toString('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function detectPriorInstall(
|
||||||
|
storageRepository: StorageRepository,
|
||||||
|
): Promise<MaintenanceDetectInstallResponseDto> {
|
||||||
|
return {
|
||||||
|
storage: await Promise.all(
|
||||||
|
Object.values(StorageFolder).map(async (folder) => {
|
||||||
|
const path = StorageCore.getBaseFolder(folder);
|
||||||
|
const files = await storageRepository.readdir(path);
|
||||||
|
const fn = join(StorageCore.getBaseFolder(folder), '.immich');
|
||||||
|
|
||||||
|
let readable = false,
|
||||||
|
writable = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await storageRepository.readFile(fn);
|
||||||
|
readable = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await storageRepository.overwriteFile(fn, Buffer.from(`${Date.now()}`));
|
||||||
|
writable = true;
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
folder,
|
||||||
|
readable,
|
||||||
|
writable,
|
||||||
|
files: files.filter((fn) => fn !== '.immich').length,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -152,6 +152,26 @@ describe('mimeTypes', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('animated image', () => {
|
||||||
|
for (const img of ['a.avif', 'a.gif', 'a.webp']) {
|
||||||
|
it('should identify animated image mime types as such', () => {
|
||||||
|
expect(mimeTypes.isPossiblyAnimatedImage(img)).toBeTruthy();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const img of ['a.cr3', 'a.jpg', 'a.tiff']) {
|
||||||
|
it('should identify static image mime types as such', () => {
|
||||||
|
expect(mimeTypes.isPossiblyAnimatedImage(img)).toBeFalsy();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const extension of Object.keys(mimeTypes.video)) {
|
||||||
|
it('should not identify video mime types as animated', () => {
|
||||||
|
expect(mimeTypes.isPossiblyAnimatedImage(extension)).toBeFalsy();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
describe('video', () => {
|
describe('video', () => {
|
||||||
it('should contain only lowercase mime types', () => {
|
it('should contain only lowercase mime types', () => {
|
||||||
const keys = Object.keys(mimeTypes.video);
|
const keys = Object.keys(mimeTypes.video);
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ const image: Record<string, string[]> = {
|
|||||||
'.tiff': ['image/tiff'],
|
'.tiff': ['image/tiff'],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const possiblyAnimatedImageExtensions = new Set(['.avif', '.gif', '.heic', '.heif', '.jxl', '.png', '.webp']);
|
||||||
|
const possiblyAnimatedImage: Record<string, string[]> = Object.fromEntries(
|
||||||
|
Object.entries(image).filter(([key]) => possiblyAnimatedImageExtensions.has(key)),
|
||||||
|
);
|
||||||
|
|
||||||
const extensionOverrides: Record<string, string> = {
|
const extensionOverrides: Record<string, string> = {
|
||||||
'image/jpeg': '.jpg',
|
'image/jpeg': '.jpg',
|
||||||
};
|
};
|
||||||
@@ -119,6 +124,7 @@ export const mimeTypes = {
|
|||||||
isAsset: (filename: string) => isType(filename, image) || isType(filename, video),
|
isAsset: (filename: string) => isType(filename, image) || isType(filename, video),
|
||||||
isImage: (filename: string) => isType(filename, image),
|
isImage: (filename: string) => isType(filename, image),
|
||||||
isWebSupportedImage: (filename: string) => isType(filename, webSupportedImage),
|
isWebSupportedImage: (filename: string) => isType(filename, webSupportedImage),
|
||||||
|
isPossiblyAnimatedImage: (filename: string) => isType(filename, possiblyAnimatedImage),
|
||||||
isProfile: (filename: string) => isType(filename, profile),
|
isProfile: (filename: string) => isType(filename, profile),
|
||||||
isSidecar: (filename: string) => isType(filename, sidecar),
|
isSidecar: (filename: string) => isType(filename, sidecar),
|
||||||
isVideo: (filename: string) => isType(filename, video),
|
isVideo: (filename: string) => isType(filename, video),
|
||||||
|
|||||||
@@ -96,6 +96,16 @@ export class UUIDAssetIDParamDto {
|
|||||||
assetId!: string;
|
assetId!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class FilenameParamDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@ApiProperty({ format: 'string' })
|
||||||
|
@Matches(/^[a-zA-Z0-9_\-.]+$/, {
|
||||||
|
message: 'Filename contains invalid characters',
|
||||||
|
})
|
||||||
|
filename!: string;
|
||||||
|
}
|
||||||
|
|
||||||
type PinCodeOptions = { optional?: boolean } & OptionalOptions;
|
type PinCodeOptions = { optional?: boolean } & OptionalOptions;
|
||||||
export const PinCode = (options?: PinCodeOptions & ApiPropertyOptions) => {
|
export const PinCode = (options?: PinCodeOptions & ApiPropertyOptions) => {
|
||||||
const { optional, nullable, emptyToNull, ...apiPropertyOptions } = {
|
const { optional, nullable, emptyToNull, ...apiPropertyOptions } = {
|
||||||
|
|||||||
@@ -12,12 +12,11 @@ async function bootstrap() {
|
|||||||
|
|
||||||
const app = await NestFactory.create<NestExpressApplication>(MaintenanceModule, { bufferLogs: true });
|
const app = await NestFactory.create<NestExpressApplication>(MaintenanceModule, { bufferLogs: true });
|
||||||
app.get(AppRepository).setCloseFn(() => app.close());
|
app.get(AppRepository).setCloseFn(() => app.close());
|
||||||
|
|
||||||
void configureExpress(app, {
|
void configureExpress(app, {
|
||||||
permitSwaggerWrite: false,
|
permitSwaggerWrite: false,
|
||||||
ssr: MaintenanceWorkerService,
|
ssr: MaintenanceWorkerService,
|
||||||
});
|
});
|
||||||
|
|
||||||
void app.get(MaintenanceWorkerService).logSecret();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrap().catch((error) => {
|
bootstrap().catch((error) => {
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ export const newStorageRepositoryMock = (): Mocked<RepositoryInterface<StorageRe
|
|||||||
return {
|
return {
|
||||||
createZipStream: vitest.fn(),
|
createZipStream: vitest.fn(),
|
||||||
createReadStream: vitest.fn(),
|
createReadStream: vitest.fn(),
|
||||||
|
createPlainReadStream: vitest.fn(),
|
||||||
|
createGzip: vitest.fn(),
|
||||||
|
createGunzip: vitest.fn(),
|
||||||
readFile: vitest.fn(),
|
readFile: vitest.fn(),
|
||||||
readTextFile: vitest.fn(),
|
readTextFile: vitest.fn(),
|
||||||
createFile: vitest.fn(),
|
createFile: vitest.fn(),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { NextFunction } from 'express';
|
|||||||
import { Kysely } from 'kysely';
|
import { Kysely } from 'kysely';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import { ChildProcessWithoutNullStreams } from 'node:child_process';
|
import { ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||||
import { Readable, Writable } from 'node:stream';
|
import { Duplex, Readable, Writable } from 'node:stream';
|
||||||
import { PNG } from 'pngjs';
|
import { PNG } from 'pngjs';
|
||||||
import postgres from 'postgres';
|
import postgres from 'postgres';
|
||||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||||
@@ -492,6 +492,37 @@ export const mockSpawn = vitest.fn((exitCode: number, stdout: string, stderr: st
|
|||||||
} as unknown as ChildProcessWithoutNullStreams;
|
} as unknown as ChildProcessWithoutNullStreams;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const mockDuplex = vitest.fn(
|
||||||
|
(command: string, exitCode: number, stdout: string, stderr: string, error?: unknown) => {
|
||||||
|
const duplex = new Duplex({
|
||||||
|
write(_chunk, _encoding, callback) {
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
|
||||||
|
read() {},
|
||||||
|
|
||||||
|
final(callback) {
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setImmediate(() => {
|
||||||
|
if (error) {
|
||||||
|
duplex.destroy(error as Error);
|
||||||
|
} else if (exitCode === 0) {
|
||||||
|
/* eslint-disable unicorn/prefer-single-call */
|
||||||
|
duplex.push(stdout);
|
||||||
|
duplex.push(null);
|
||||||
|
/* eslint-enable unicorn/prefer-single-call */
|
||||||
|
} else {
|
||||||
|
duplex.destroy(new Error(`${command} non-zero exit code (${exitCode})\n${stderr}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return duplex;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export async function* makeStream<T>(items: T[] = []): AsyncIterableIterator<T> {
|
export async function* makeStream<T>(items: T[] = []): AsyncIterableIterator<T> {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user