From a2364a12cff84031e2c13da8e0e9bfab75f38d85 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Fri, 28 Jun 2024 17:08:19 +0100 Subject: [PATCH] refactor: move /server-info endpoints to /server (#10677) --- e2e/src/api/specs/server.e2e-spec.ts | 200 ++++++++++++++++++ mobile/openapi/README.md | Bin 29135 -> 30140 bytes mobile/openapi/lib/api.dart | Bin 10272 -> 10304 bytes mobile/openapi/lib/api/deprecated_api.dart | Bin 0 -> 13614 bytes mobile/openapi/lib/api/server_info_api.dart | Bin 13029 -> 13614 bytes open-api/immich-openapi-specs.json | 90 ++++++-- open-api/typescript-sdk/src/fetch-client.ts | 27 +++ server/src/controllers/index.ts | 2 + .../src/controllers/server-info.controller.ts | 16 +- server/src/controllers/server.controller.ts | 82 +++++++ .../{server-info.dto.ts => server.dto.ts} | 0 server/src/interfaces/event.interface.ts | 2 +- server/src/services/index.ts | 4 +- ...service.spec.ts => server.service.spec.ts} | 8 +- ...rver-info.service.ts => server.service.ts} | 6 +- server/src/services/version.service.ts | 2 +- 16 files changed, 407 insertions(+), 32 deletions(-) create mode 100644 e2e/src/api/specs/server.e2e-spec.ts create mode 100644 mobile/openapi/lib/api/deprecated_api.dart create mode 100644 server/src/controllers/server.controller.ts rename server/src/dtos/{server-info.dto.ts => server.dto.ts} (100%) rename server/src/services/{server-info.service.spec.ts => server.service.spec.ts} (97%) rename server/src/services/{server-info.service.ts => server.service.ts} (97%) diff --git a/e2e/src/api/specs/server.e2e-spec.ts b/e2e/src/api/specs/server.e2e-spec.ts new file mode 100644 index 0000000000..808ce36363 --- /dev/null +++ b/e2e/src/api/specs/server.e2e-spec.ts @@ -0,0 +1,200 @@ +import { LoginResponseDto } from '@immich/sdk'; +import { createUserDto } from 'src/fixtures'; +import { errorDto } from 'src/responses'; +import { app, utils } from 'src/utils'; +import request from 'supertest'; +import { beforeAll, describe, expect, it } from 'vitest'; + +describe('/server', () => { + let admin: LoginResponseDto; + let nonAdmin: LoginResponseDto; + + beforeAll(async () => { + await utils.resetDatabase(); + admin = await utils.adminSetup({ onboarding: false }); + nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1); + }); + + describe('GET /server/about', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/server/about'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should return about information', async () => { + const { status, body } = await request(app) + .get('/server/about') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ + version: expect.any(String), + versionUrl: expect.any(String), + repository: 'immich-app/immich', + repositoryUrl: 'https://github.com/immich-app/immich', + build: '1234567890', + buildUrl: 'https://github.com/immich-app/immich/actions/runs/1234567890', + buildImage: 'e2e', + buildImageUrl: 'https://github.com/immich-app/immich/pkgs/container/immich-server', + sourceRef: 'e2e', + sourceCommit: 'e2eeeeeeeeeeeeeeeeee', + sourceUrl: 'https://github.com/immich-app/immich/commit/e2eeeeeeeeeeeeeeeeee', + nodejs: expect.any(String), + ffmpeg: expect.any(String), + imagemagick: expect.any(String), + libvips: expect.any(String), + exiftool: expect.any(String), + }); + }); + }); + + describe('GET /server/storage', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/server/storage'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should return the disk information', async () => { + const { status, body } = await request(app) + .get('/server/storage') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ + diskAvailable: expect.any(String), + diskAvailableRaw: expect.any(Number), + diskSize: expect.any(String), + diskSizeRaw: expect.any(Number), + diskUsagePercentage: expect.any(Number), + diskUse: expect.any(String), + diskUseRaw: expect.any(Number), + }); + }); + }); + + describe('GET /server/ping', () => { + it('should respond with pong', async () => { + const { status, body } = await request(app).get('/server/ping'); + expect(status).toBe(200); + expect(body).toEqual({ res: 'pong' }); + }); + }); + + describe('GET /server/version', () => { + it('should respond with the server version', async () => { + const { status, body } = await request(app).get('/server/version'); + expect(status).toBe(200); + expect(body).toEqual({ + major: expect.any(Number), + minor: expect.any(Number), + patch: expect.any(Number), + }); + }); + }); + + describe('GET /server/features', () => { + it('should respond with the server features', async () => { + const { status, body } = await request(app).get('/server/features'); + expect(status).toBe(200); + expect(body).toEqual({ + smartSearch: false, + configFile: false, + duplicateDetection: false, + facialRecognition: false, + map: true, + reverseGeocoding: true, + oauth: false, + oauthAutoLaunch: false, + passwordLogin: true, + search: true, + sidecar: true, + trash: true, + email: false, + }); + }); + }); + + describe('GET /server/config', () => { + it('should respond with the server configuration', async () => { + const { status, body } = await request(app).get('/server/config'); + expect(status).toBe(200); + expect(body).toEqual({ + loginPageMessage: '', + oauthButtonText: 'Login with OAuth', + trashDays: 30, + userDeleteDelay: 7, + isInitialized: true, + externalDomain: '', + isOnboarded: false, + }); + }); + }); + + describe('GET /server/statistics', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/server/statistics'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should only work for admins', async () => { + const { status, body } = await request(app) + .get('/server/statistics') + .set('Authorization', `Bearer ${nonAdmin.accessToken}`); + expect(status).toBe(403); + expect(body).toEqual(errorDto.forbidden); + }); + + it('should return the server stats', async () => { + const { status, body } = await request(app) + .get('/server/statistics') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ + photos: 0, + usage: 0, + usageByUser: [ + { + quotaSizeInBytes: null, + photos: 0, + usage: 0, + userName: 'Immich Admin', + userId: admin.userId, + videos: 0, + }, + { + quotaSizeInBytes: null, + photos: 0, + usage: 0, + userName: 'User 1', + userId: nonAdmin.userId, + videos: 0, + }, + ], + videos: 0, + }); + }); + }); + + describe('GET /server/media-types', () => { + it('should return accepted media types', async () => { + const { status, body } = await request(app).get('/server/media-types'); + expect(status).toBe(200); + expect(body).toEqual({ + sidecar: ['.xmp'], + image: expect.any(Array), + video: expect.any(Array), + }); + }); + }); + + describe('GET /server/theme', () => { + it('should respond with the server theme', async () => { + const { status, body } = await request(app).get('/server/theme'); + expect(status).toBe(200); + expect(body).toEqual({ + customCss: '', + }); + }); + }); +}); diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 691393eb633a9af677ae4868539f76d368c6fc12..046ccb5b6177caaed4cac583850016814a197fef 100644 GIT binary patch delta 333 zcmX^Am~qc*#tl~`jZzDWQj-%)Qd1lYGPM+H6r#1X(o;(ulk!VTJoD1>wX|Y2Qu34a z^MJ30U+P;Sb9D_=kh#$YcF5et25QLM%LY!!TzNxfWUh~)jUmEb z6!!*~)Uy=x?k%w;h)-h4&!cQ^pv C%6dKk delta 14 WcmdnQupnT=5$4TLm}67`C~gIk diff --git a/mobile/openapi/lib/api/deprecated_api.dart b/mobile/openapi/lib/api/deprecated_api.dart new file mode 100644 index 0000000000000000000000000000000000000000..18518cca6957ec166939d4521e95306a1f2bf977 GIT binary patch literal 13614 zcmeI2@lV@06vuz>U-7mdR**`|rb#!k0oOs&NQ#B?@}kT9lGAc|9;PQ zk|s3Ug$bmq>K`bH?e}cI_kADI;7+H}=|J~m|8(nJ@1%F$?e`Ah==ir@3l2}=3Y7jruQp+4aR-~KQaKw^O=Y}*Hz=#VLLf*Epu)Q4$d4Iwc#FADi^&DnQ zk)v#Fzy;hqZ$IDurM*qgXt?`?lSor?tSLow5(OYpCsHWT6NLBu{ux}Jqlzh*c-1DO zqlCKgP1d#tW7OU2DAC7a6n)})@?PuMTyJawQ*$x2F)>r7n%JQ=;)tSb6Ep~r;wYKs zGy}QMHX1WMp}5FqN1?nyxy8w4hnb0*cE?p7ZHy?1Ll!z;r`LbskOCh^O23VQd1~om z`6WR)KQmHNG}D{k^V_}4tJj64OppaA7XvS}(5EI{ z^ye|!6{HT$>?Y%orecxmHWxS1-`G7n-mv`uCQPmLy7{ixZ)P`z&1Fql5VHH?zOuH( z^0SuWBHLW`rZI_YA4C#{q~TJptwm?IX<~9~c5zQ-rt-S5Nw>Pnly<12{j%EbD9mM5 zreZZkHDg@=K%Xved;}YrQJaXN6Lk;;2z$F=qNCkUa^o2j6IDnv#zQ@E!ew$!b@FL~0vKg~O2r9I|9M%82(HNm%&638 zkXx{!(~IqwEtt{x38U1ANK^Tn34v=`X(X$P%>+?JL7=^97PC&4-UpZu4Ex&E(m=F6=hyQs?Oxw@qLm+)nTyY~9V!~gvXjJj8}HWklIAMD`;G10 zWH;INmI|bc;s?U6?HSuM|M{_5+-lWYEogu2o^8GDoOUkS-OdplpZw8jz|k3;o^|2< z+0n`IpHFC*8Tos_q~3ep+<93uw|pofiM>H2djlTgE+isR81yj2F$&$t?)f;5q%O5! z;e<_9FHUq%;@5;rG8b%2pD~m4TccFuT&i;Oek7D;Licz;6Y>E^Y0XvXWFZ%*dSN7n zG)#|}=)K(T^+!yqT8*aD5Dg%TQLvad>E~6gR_ljMDY!&=i}FMaqIS&Tdku;$8&}kP z7;;*|0g$1)yxxS}`5o-#t`80%zyV7_Ju{@q00vyJ5N2(23)|bGkatI1K`d#NQcq#R z6gi3|_L;^2xPaT8=FawS&24fn0f;C)ACvv$lO0qHf#6An1ud|!Kuup-HBcULfcw4w{VqS41V8@iQ0v%k9q3b& zF1phg?Fv$dW_FWtNK-LSb(4$R=x=PFpKREE028K0dR>3p>DIHG+~%^TEC|_seqUMJ zeEC^RevwVCI^&qcbpRp>L(*`e*Vdx5+cYt`GrPE_GE;fEsa zKE_7DWsVx@I*iiEuaV)KsYW#$yJ>@E!ew$!b@F+H0vKg~OvMRK|Fg0b5!{dqm{6(F zAh%#cr{~)*8Ze>pBSxtak*4xD69U(!Zms9Gwq>pP7>&t9a!pdEHm(}*#Sna#GD|vZRoV&MDpY0 zG@xEC#(Kj5E~WjVQk}GHY+PE?<(RVh)zWxiXuDle+y;F3*xV*j(JUU^>jG}4su{8K zQmYK@yirk~p4*<=)ww;>VgcB*)|DakG18+6{V6Qg!oxpf57K!&t=EOtnX(pmoi*Q+ z*qK>Tr=HoK+0~hyX^0eApmwf(Ww>p=Kysyd|Ix{9z6-jUJiFJ0-9}yNBK?BA0Pwl; z?uqcZp@+DCVWNKVg~_5c3nX9cTp65yqW?P3o4#UUJp5BQy@NB;c#y9P$WvYY0Oe`# zJvm;gRsSUJpTw&_i7(5tfbr$-RRFn;Bpc#mBV4&@Ji^xn;g>Gq%LsSv)!|*HPmgVn z?dsTGmSO?h%iXJh?Ibp5g)n%J0cZBq#A73z4jXZ{rOg2ikMVWE_*`DgBwv@TDp(K)!Uu3UIzVq9dZk(s=myD|EVXjGpQ1!gQ;t+3Rt8R~g(Tdi1FF zsIHFcOne18XN~`NAjfoo(;dZnY$ThRJdxLh$mf*p?D?9`bdooui Rb6$i6I_Eq7o6vW)e*qo#ho=Al delta 756 zcmZ3N^)z+ECPwFg)S|TfqTFJI#5@I$kdOccb$8bg1^wdGqO#N?-ORkSeEr0v{L&J2 zh5UllqQsKS{5+u26osPHlG38QVug~7)X56M%A0kVWSBOWvwUC@!*1ea2M!S_yk=-_ ze$Fn%wAp~`A(I<+Gf-TWmYN83L25C6hiOi>6cC%ND;c!;C+{}K&G-0UGkFkUcyS5P zr^O|i$@q-ltRU>ow0V~3KPFcqj06T}F);85ICryzBrqJ4rHM1MxFo+QF+CN(nJSy_ z$VoG8R#W`M { return this.service.getAboutInfo(); } @Get('storage') + @EndpointLifecycle({ deprecatedAt: 'v1.107.0' }) @Authenticated() getStorage(): Promise { return this.service.getStorage(); } @Get('ping') + @EndpointLifecycle({ deprecatedAt: 'v1.107.0' }) pingServer(): ServerPingResponse { return this.service.ping(); } @Get('version') + @EndpointLifecycle({ deprecatedAt: 'v1.107.0' }) getServerVersion(): ServerVersionResponseDto { return this.versionService.getVersion(); } @Get('features') + @EndpointLifecycle({ deprecatedAt: 'v1.107.0' }) getServerFeatures(): Promise { return this.service.getFeatures(); } @Get('theme') + @EndpointLifecycle({ deprecatedAt: 'v1.107.0' }) getTheme(): Promise { return this.service.getTheme(); } @Get('config') + @EndpointLifecycle({ deprecatedAt: 'v1.107.0' }) getServerConfig(): Promise { return this.service.getConfig(); } @Authenticated({ admin: true }) + @EndpointLifecycle({ deprecatedAt: 'v1.107.0' }) @Get('statistics') getServerStatistics(): Promise { return this.service.getStatistics(); } @Get('media-types') + @EndpointLifecycle({ deprecatedAt: 'v1.107.0' }) getSupportedMediaTypes(): ServerMediaTypesResponseDto { return this.service.getSupportedMediaTypes(); } diff --git a/server/src/controllers/server.controller.ts b/server/src/controllers/server.controller.ts new file mode 100644 index 0000000000..45d992908f --- /dev/null +++ b/server/src/controllers/server.controller.ts @@ -0,0 +1,82 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger'; +import { + ServerAboutResponseDto, + ServerConfigDto, + ServerFeaturesDto, + ServerMediaTypesResponseDto, + ServerPingResponse, + ServerStatsResponseDto, + ServerStorageResponseDto, + ServerThemeDto, + ServerVersionResponseDto, +} from 'src/dtos/server.dto'; +import { Authenticated } from 'src/middleware/auth.guard'; +import { ServerService } from 'src/services/server.service'; +import { VersionService } from 'src/services/version.service'; + +@ApiTags('Server') +@Controller('server') +export class ServerController { + constructor( + private service: ServerService, + private versionService: VersionService, + ) {} + + @Get('about') + @Authenticated() + @ApiExcludeEndpoint() + getAboutInfo(): Promise { + return this.service.getAboutInfo(); + } + + @Get('storage') + @Authenticated() + @ApiExcludeEndpoint() + getStorage(): Promise { + return this.service.getStorage(); + } + + @Get('ping') + @ApiExcludeEndpoint() + pingServer(): ServerPingResponse { + return this.service.ping(); + } + + @Get('version') + @ApiExcludeEndpoint() + getServerVersion(): ServerVersionResponseDto { + return this.versionService.getVersion(); + } + + @Get('features') + @ApiExcludeEndpoint() + getServerFeatures(): Promise { + return this.service.getFeatures(); + } + + @Get('theme') + @ApiExcludeEndpoint() + getTheme(): Promise { + return this.service.getTheme(); + } + + @Get('config') + @ApiExcludeEndpoint() + getServerConfig(): Promise { + return this.service.getConfig(); + } + + @Authenticated({ admin: true }) + @Get('statistics') + @ApiExcludeEndpoint() + getServerStatistics(): Promise { + return this.service.getStatistics(); + } + + @Get('media-types') + @ApiExcludeEndpoint() + getSupportedMediaTypes(): ServerMediaTypesResponseDto { + return this.service.getSupportedMediaTypes(); + } +} diff --git a/server/src/dtos/server-info.dto.ts b/server/src/dtos/server.dto.ts similarity index 100% rename from server/src/dtos/server-info.dto.ts rename to server/src/dtos/server.dto.ts diff --git a/server/src/interfaces/event.interface.ts b/server/src/interfaces/event.interface.ts index 6b52f21d8d..da1e7b1aa5 100644 --- a/server/src/interfaces/event.interface.ts +++ b/server/src/interfaces/event.interface.ts @@ -1,6 +1,6 @@ import { SystemConfig } from 'src/config'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; -import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server-info.dto'; +import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; export const IEventRepository = 'IEventRepository'; diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 2e79bf6fd2..ab680f15e3 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -21,7 +21,7 @@ import { NotificationService } from 'src/services/notification.service'; import { PartnerService } from 'src/services/partner.service'; import { PersonService } from 'src/services/person.service'; import { SearchService } from 'src/services/search.service'; -import { ServerInfoService } from 'src/services/server-info.service'; +import { ServerService } from 'src/services/server.service'; import { SessionService } from 'src/services/session.service'; import { SharedLinkService } from 'src/services/shared-link.service'; import { SmartInfoService } from 'src/services/smart-info.service'; @@ -61,7 +61,7 @@ export const services = [ PartnerService, PersonService, SearchService, - ServerInfoService, + ServerService, SessionService, SharedLinkService, SmartInfoService, diff --git a/server/src/services/server-info.service.spec.ts b/server/src/services/server.service.spec.ts similarity index 97% rename from server/src/services/server-info.service.spec.ts rename to server/src/services/server.service.spec.ts index b1200cadc5..b1fbb9c2e9 100644 --- a/server/src/services/server-info.service.spec.ts +++ b/server/src/services/server.service.spec.ts @@ -3,7 +3,7 @@ import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; -import { ServerInfoService } from 'src/services/server-info.service'; +import { ServerService } from 'src/services/server.service'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newServerInfoRepositoryMock } from 'test/repositories/server-info.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; @@ -11,8 +11,8 @@ import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metada import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; import { Mocked } from 'vitest'; -describe(ServerInfoService.name, () => { - let sut: ServerInfoService; +describe(ServerService.name, () => { + let sut: ServerService; let storageMock: Mocked; let userMock: Mocked; let serverInfoMock: Mocked; @@ -26,7 +26,7 @@ describe(ServerInfoService.name, () => { systemMock = newSystemMetadataRepositoryMock(); loggerMock = newLoggerRepositoryMock(); - sut = new ServerInfoService(userMock, storageMock, systemMock, serverInfoMock, loggerMock); + sut = new ServerService(userMock, storageMock, systemMock, serverInfoMock, loggerMock); }); it('should work', () => { diff --git a/server/src/services/server-info.service.ts b/server/src/services/server.service.ts similarity index 97% rename from server/src/services/server-info.service.ts rename to server/src/services/server.service.ts index 3f495aa635..e257b435c1 100644 --- a/server/src/services/server-info.service.ts +++ b/server/src/services/server.service.ts @@ -12,7 +12,7 @@ import { ServerStatsResponseDto, ServerStorageResponseDto, UsageByUserDto, -} from 'src/dtos/server-info.dto'; +} from 'src/dtos/server.dto'; import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; import { OnEvents } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; @@ -25,7 +25,7 @@ import { mimeTypes } from 'src/utils/mime-types'; import { isDuplicateDetectionEnabled, isFacialRecognitionEnabled, isSmartSearchEnabled } from 'src/utils/misc'; @Injectable() -export class ServerInfoService implements OnEvents { +export class ServerService implements OnEvents { private configCore: SystemConfigCore; constructor( @@ -35,7 +35,7 @@ export class ServerInfoService implements OnEvents { @Inject(IServerInfoRepository) private serverInfoRepository: IServerInfoRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { - this.logger.setContext(ServerInfoService.name); + this.logger.setContext(ServerService.name); this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } diff --git a/server/src/services/version.service.ts b/server/src/services/version.service.ts index a42e550be6..8408e53bfe 100644 --- a/server/src/services/version.service.ts +++ b/server/src/services/version.service.ts @@ -4,7 +4,7 @@ import semver, { SemVer } from 'semver'; import { isDev, serverVersion } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { OnServerEvent } from 'src/decorators'; -import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server-info.dto'; +import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { SystemMetadataKey, VersionCheckMetadata } from 'src/entities/system-metadata.entity'; import { ClientEvent, IEventRepository, OnEvents, ServerEvent, ServerEventMap } from 'src/interfaces/event.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';