From c754c860fd0ef09e69662a3328e6ec4c932227a7 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 16 Dec 2022 14:26:12 -0600 Subject: [PATCH] feat(server) user-defined storage structure (#1098) [Breaking] newly uploaded file will conform to the default structure of `{uploadLocation}/{userId}/year/year-month-day/filename.ext` --- mobile/openapi/.openapi-generator/FILES | 6 + mobile/openapi/README.md | Bin 11576 -> 11896 bytes mobile/openapi/doc/SystemConfigApi.md | Bin 4096 -> 5484 bytes mobile/openapi/doc/SystemConfigDto.md | Bin 524 -> 624 bytes .../doc/SystemConfigStorageTemplateDto.md | Bin 0 -> 428 bytes .../SystemConfigTemplateStorageOptionDto.md | Bin 0 -> 842 bytes mobile/openapi/lib/api.dart | Bin 4017 -> 4132 bytes mobile/openapi/lib/api/system_config_api.dart | Bin 4806 -> 6323 bytes mobile/openapi/lib/api_client.dart | Bin 14985 -> 15221 bytes .../openapi/lib/model/system_config_dto.dart | Bin 3586 -> 3955 bytes .../system_config_storage_template_dto.dart | Bin 0 -> 3658 bytes ...em_config_template_storage_option_dto.dart | Bin 0 -> 6090 bytes .../openapi/test/system_config_api_test.dart | Bin 841 -> 1003 bytes .../openapi/test/system_config_dto_test.dart | Bin 689 -> 828 bytes ...stem_config_storage_template_dto_test.dart | Bin 0 -> 614 bytes ...nfig_template_storage_option_dto_test.dart | Bin 0 -> 1516 bytes notes.md | 10 + .../immich/src/api-v1/asset/asset.module.ts | 2 + .../src/api-v1/asset/asset.service.spec.ts | 5 +- .../immich/src/api-v1/asset/asset.service.ts | 13 +- .../dto/system-config-storage-template.dto.ts | 7 + .../system-config/dto/system-config.dto.ts | 4 + ...stem-config-template-storage-option.dto.ts | 9 + .../system-config/system-config.controller.ts | 6 + .../system-config/system-config.service.ts | 28 ++- .../src/api-v1/user/user.service.spec.ts | 18 +- .../src/processors/thumbnail.processor.ts | 5 +- server/immich-openapi-specs.json | 101 +++++++- .../src/entities/system-config.entity.ts | 4 + .../immich-config/src/immich-config.module.ts | 19 +- .../src/immich-config.service.ts | 35 ++- .../constants/supported-datetime-template.ts | 20 ++ server/libs/storage/src/index.ts | 2 + .../interfaces/immich-storage.interface.ts | 6 + server/libs/storage/src/storage.module.ts | 13 + server/libs/storage/src/storage.service.ts | 153 ++++++++++++ server/libs/storage/tsconfig.lib.json | 9 + server/nest-cli.json | 11 +- server/package-lock.json | 180 +++++++++++++- server/package.json | 6 +- server/tsconfig.json | 46 +++- web/package-lock.json | 106 +++++++- web/package.json | 3 + web/src/api/open-api/api.ts | 130 +++++++++- web/src/api/open-api/base.ts | 2 +- web/src/api/open-api/common.ts | 2 +- web/src/api/open-api/configuration.ts | 2 +- web/src/api/open-api/index.ts | 2 +- web/src/app.css | 4 +- .../settings/ffmpeg/ffmpeg-settings.svelte | 102 ++++---- .../settings/oauth/oauth-settings.svelte | 109 +++++---- .../settings/setting-buttons-row.svelte | 6 +- .../settings/setting-input-field.svelte | 14 +- .../admin-page/settings/setting-switch.svelte | 2 +- .../storage-template-settings.svelte | 227 ++++++++++++++++++ .../supported-datetime-panel.svelte | 78 ++++++ .../supported-variables-panel.svelte | 21 ++ web/src/routes/admin/settings/+page.svelte | 11 +- .../routes/admin/user-management/+page.svelte | 1 - 59 files changed, 1371 insertions(+), 169 deletions(-) create mode 100644 mobile/openapi/doc/SystemConfigStorageTemplateDto.md create mode 100644 mobile/openapi/doc/SystemConfigTemplateStorageOptionDto.md create mode 100644 mobile/openapi/lib/model/system_config_storage_template_dto.dart create mode 100644 mobile/openapi/lib/model/system_config_template_storage_option_dto.dart create mode 100644 mobile/openapi/test/system_config_storage_template_dto_test.dart create mode 100644 mobile/openapi/test/system_config_template_storage_option_dto_test.dart create mode 100644 notes.md create mode 100644 server/apps/immich/src/api-v1/system-config/dto/system-config-storage-template.dto.ts create mode 100644 server/apps/immich/src/api-v1/system-config/response-dto/system-config-template-storage-option.dto.ts create mode 100644 server/libs/storage/src/constants/supported-datetime-template.ts create mode 100644 server/libs/storage/src/index.ts create mode 100644 server/libs/storage/src/interfaces/immich-storage.interface.ts create mode 100644 server/libs/storage/src/storage.module.ts create mode 100644 server/libs/storage/src/storage.service.ts create mode 100644 server/libs/storage/tsconfig.lib.json create mode 100644 web/src/lib/components/admin-page/settings/storate-template/storage-template-settings.svelte create mode 100644 web/src/lib/components/admin-page/settings/storate-template/supported-datetime-panel.svelte create mode 100644 web/src/lib/components/admin-page/settings/storate-template/supported-variables-panel.svelte diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 608df54b10..a596cc0d83 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -64,6 +64,8 @@ doc/SystemConfigApi.md doc/SystemConfigDto.md doc/SystemConfigFFmpegDto.md doc/SystemConfigOAuthDto.md +doc/SystemConfigStorageTemplateDto.md +doc/SystemConfigTemplateStorageOptionDto.md doc/TagApi.md doc/TagResponseDto.md doc/TagTypeEnum.md @@ -152,6 +154,8 @@ lib/model/smart_info_response_dto.dart lib/model/system_config_dto.dart lib/model/system_config_f_fmpeg_dto.dart lib/model/system_config_o_auth_dto.dart +lib/model/system_config_storage_template_dto.dart +lib/model/system_config_template_storage_option_dto.dart lib/model/tag_response_dto.dart lib/model/tag_type_enum.dart lib/model/thumbnail_format.dart @@ -227,6 +231,8 @@ test/system_config_api_test.dart test/system_config_dto_test.dart test/system_config_f_fmpeg_dto_test.dart test/system_config_o_auth_dto_test.dart +test/system_config_storage_template_dto_test.dart +test/system_config_template_storage_option_dto_test.dart test/tag_api_test.dart test/tag_response_dto_test.dart test/tag_type_enum_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 4b4d076f649d7c65fd94fcb18341c54e48345472..e29aec936e16092b74066318623bb5d61c8c866d 100644 GIT binary patch delta 227 zcmdlH^&@7&97dy!bK@i>(^E@=OY(~n(^ErIa|?13OH%y{N;32FiYFUN%8M35q)MPt z`CzHZdn9GVp)$HKDP4%%=KGSp0+Y{Z@))C;>Qa&)tC5nQtgjzjSq#+VoS&DLnU1Px Z@_tQ82beRUQV`c**Ns)_<^-)9OaL+bS-b!M delta 22 ecmewnvmWDw7YEDb`e| zQP9$IcMZ|fQqV81ECx#JCgokB3!AZM^S zFsDFW262%~NxlM3BQ!R@;(Ew9`4+R5I9BfzYfSd%4ae=U{s;Wj*P+>Vv&rS0cK(ufB*mh delta 11 Scmeys(!;VLopJJM#-#upA_SoT diff --git a/mobile/openapi/doc/SystemConfigStorageTemplateDto.md b/mobile/openapi/doc/SystemConfigStorageTemplateDto.md new file mode 100644 index 0000000000000000000000000000000000000000..88bfe4569bdf63593c75d329479f58f21a7162e3 GIT binary patch literal 428 zcma)2v1-FG5Z&)94m_k0$l2Q|tuqvw5bA6pU=dv$QA;P}>k#P2S8@YQyR=%wJH2~P z?^Vc=f{Cv7Y-y<5%P8cZIGkMH3MbRkj(i-9P$LXbRrtcjqMQk*4UFdamRZnsoi$0o z`N&CdcAei}^%upX5M~dBIw@^aeoW$MgpKipAGo{3ay2v_lZ)awWVB()41II*@CIr9 z7Y3zrswst1nXFVR$%TFb83XM1k2wblKgB#^=xU1{M>uTAoSGBj5<>R}jAg26F ipE&8MfpL!C+sp0qKX-EKx$Ing+>vq}5d@+*W z)03>10XekN>C%wGw%qoC(L9qq=;IEJYbE2B$;%_YI@V<8OhRNCEJ-Th6T-j)C!6<( z5&QjKNzXzXN1kDF$=_a7W=SalCU*%sRNDCX=viYBYiSTZVAs2J%`r4y52`U*6GudG zbz<@=DvIA?k>@>1f1V@cdBo@WQU|`@ve)+bFr;F(Qh1PE%fJK;*}zcMbz~c|d>T(z zvL8=AQTLzDwe9$_!7-BujQ^miVWEXK#zg`D#%b sl__dEgO+7-X>lN549*Nw_+me9UjE39J}uBHN80kY5@*35;!`2SC$bz6UH||9 literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index b30e4f6814864817f4480d8dea82a8938bf04935..e227bb752916237ed9683893093b87d6778d630c 100644 GIT binary patch delta 68 zcmdlezeHg}E6-#d9zOo!lKi5?^wjv0)ZBuc#FEL1+)|Tud3eR4{M2}ug8YJ#%>2B` N6M1Adck*a30{}#a7&!m{ delta 12 TcmZ3Yuu*hqnfZB>_1Nvjf+2DtP$_?~ zOz~tnrbJ0x`idtn;I-boi>Zy#7^V$s3PiU{Nxr?ELV9WmR;M5YuDzo_^r!$i% VRwE}LU=zb3Ghaw`b39Kv8vxK}Tz&um delta 12 TcmdmNcuaLe1<&SGAwM<%B98=u diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 628b533e7e2a45f80c6ef604f1b4056de28d1db9..0fd1705933797f079eb752c0be11e7fde8149a40 100644 GIT binary patch delta 118 zcmeAy{aUtRmhR*SIy`*ACHX~(>8T;9xdl0iC6fi@Bqnp}VhB6Rsen|ALY1XLb@>;R YWaj5h7L*eQX~H4PsG+=hmhO8V09s`$uK)l5 delta 12 Tcmexb)>*n?mhR>@z4tr-EZ_!1 diff --git a/mobile/openapi/lib/model/system_config_dto.dart b/mobile/openapi/lib/model/system_config_dto.dart index a667236e748f7773dfde26275195319700d6fd24..22701819a931a5780a792878e8883e176a156c10 100644 GIT binary patch delta 383 zcmZpY`7F0#6QjI>LQ!gAX=YJsib6?7X0cvzNq$jcdTK~&Zb43BN$TWvjNW>|mBl5g zxz71{X_@K4sPZl)`3mS-zA!E{R8UjnQUHPcl8n?MbW;^Ge9j9=!Q|JUahvcssm%Gag>UyZ0Z5Fg$rXoj&VlKFL>SnLALP&1XLdgY+*(Ht0gpD9&Pf|_9x(C%b2kuA|G+s`S#5}fjC->(^D6# zmv4eZXWtJ?NDB*YVad$$t;#tD;%qbsBVb9MabuX?5HPUUOKya5Vo}I92?l&M+L?#S zDE|<-WV?r}J_&aF&%@KS>uUOmmA`sJG2|PBtH=gm)s0aSE&FE8g=7V!pK%TP@I?G! zd*$ooMJBo%d~~NMA6~qWcEIwIw6djU{|m$clR0=&8W7gNS5{Qqz+9_UZF%CB5TtVy z=n|$UnDsOUSrv#O0?b+%huv12O3mj;Eyqe-Wr#A968=&osTEm@d|s-Y+U6II;Qp11 z$d$D&vyU{?7=w#koPet10B1y)KebR==LxtFdX$+;QG)H|pfHRU}7rC$-nDLFm4$L^QRVsN- zMg6C_3o?g!WyA>lHAwEVL{mcIBq%sD7$EMQ)M4PD;@U zlGm6UL{#1wT0}oPq1jEJI;yREt^HCa4cTkDd=!M?gQwBp6%;7-4vYSXINtk%I|3G3 zt=X)=K?`$M7#?o}9z1U}H&tQX*gEWMGWC@sB9a4YJH-g6r$FzCFi4Hzsou@vxU33;s0(D#Qy;pdoadJ z3q?t|3uix_+H`xtJI$)Xtr-WrrWuT%$jJN|xx<;eyEV!mNVgd`g9kSfcUM*SzsFSv GC*I#X)vv4o literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/system_config_template_storage_option_dto.dart b/mobile/openapi/lib/model/system_config_template_storage_option_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..a0a799bd09d16c7f4af2200b3a9dccf2a60ded7f GIT binary patch literal 6090 zcmds5ZExE)5dQ98aV?Bm!Bl7Mry-r(7L7afEe4wG!(bQ!Bhxlpnes@wfsy9F?~c?9 zX}PhMei*O-vCZR=eE9Iu-BG8fz0*^8{pZ!>=^x{Z@#oi9<8yfX?&COy^9fu`uHe(; z{N3BX9-$j~zM3;-lIO!`&wJDqOIawME((<{M9$A(E4P}@Q=aoRmnN>-V!bJpIXHsl zH*BZV&DNxf|GgCohfB7`&ka-fZMf1nxzdM+XNA;;Nt22>E+`g)D?3~tTr3xo>ohOq z65Y&-$@I5h(`?0*?)7jv1H}T04VP>qhWP)Y*UNIIHC*nr;p;brT!`frUvF||_@yZn zTk^@qh(ey50&aT%r%1|YZnjElU?BMoOuLMofw0@i_z)-QG9seY0L)70;f^!qU}#9d zG6K_C+&HWYX;$}o%-QbV{#CI(vV@RZQCq1cL%#{cLzJP-LHyoTq%?yC)!~(#|ix6I(4&kEU~( zs)0wxV1`4y_P(o6kM;Ep`_2tx*0pKwJ9-RSPp`f28W8Fb_!RHE2Mk;Hz_RZiP;T7= zi*r{t|2p=_az|<7rxPkyiXG0-sXTaTaaq}wGIV$= z4I;$IqNRj^AS*k7Q)eko>oUw(opKvhM=l*x5rUf;OTASb%MoVqgTtC_s1Pv>>l$mQ zYCHJKcbbZva^OW_I6OPEJ|&|RF9!y)70Ay?AyMH0QmjK*{nMBw^R z2wupqF?hD4AVS}Vq6o4}jU%ugMY8YxP%J^Bs<8ytqgeL6ABrW&f;E=FdKAmP_rtK1 zk>SohT;;P*(WFkqWfF7OxDMCq(M!nj;eCri@I6&gRXU?5J%(F*V_K@>hF#}aiNk{B zn#Xrlly=z+ZUtyau;oP$mVmGn{OOL!kqRdDbeEBKBfsvW$#xYN{C(d-`?;=A|6?9nFfM1NG` zP;KL0a-Bi_J}Sk`i$;3G<*syiw$16IRqd+tA5u$jUG4B^bzmnSS`B%pdPEOM=+q6g zWkTXIdzGy#8C2haC~#=~Pu)_yb-78ZnG+ZSV}P8T;9xdl0iC8;hY`3l9TLX+PzD)OU?H8L3h0Heej AhX4Qo delta 18 ZcmdnPwvlziawaY=1%+BoYc8%@E&w$G1fl={ diff --git a/mobile/openapi/test/system_config_storage_template_dto_test.dart b/mobile/openapi/test/system_config_storage_template_dto_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..9f5a2e5e10af409d59d58741cf664be490b7c461 GIT binary patch literal 614 zcmah_%}T>S5WeRr##39UjrJrhg2kj#&<1Qgc-k)MBwb8)*WHN{r0?!*YEKq=*x4EQ z{$}Dhiep&3<=OR9x=vpgd78j-^_-3&$zYx3@RB8~<@*)G0`g%e9o$$s?x@x zD2)@Ps&NLbZawY<*4Ut?@o83#HIB|QWc@8WFKkP~;b*HH)3t24TIu*Fbe_eXKTj`= z_9Qh4wPQn7Ds(;FpEawMMlWikD~8ctYH@cXioJ9`ijEm!U}fGIi)L3d(8p&j(>FkS1nc+|jyl+$ cfZL;v^qnsH=z+-b^DN1FkwXZ^UH>1VU&L9>w*UYD literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/system_config_template_storage_option_dto_test.dart b/mobile/openapi/test/system_config_template_storage_option_dto_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..2824cc64d2667f1ee5998054f45b30f21a3041f4 GIT binary patch literal 1516 zcmb`GPixyi5XJBM6w{M!aHBM*k~F2Ea!MeLAXW~g6lIV`(h_NRyE{q|CEqj2D2>uBzg+7nw`Ip? hJdP*1p!xJu+VL5WV;y `notes-1234567890.md` +* Filename will be unique in the same folder \ No newline at end of file diff --git a/server/apps/immich/src/api-v1/asset/asset.module.ts b/server/apps/immich/src/api-v1/asset/asset.module.ts index c8501426e2..bdd489223d 100644 --- a/server/apps/immich/src/api-v1/asset/asset.module.ts +++ b/server/apps/immich/src/api-v1/asset/asset.module.ts @@ -13,6 +13,7 @@ import { DownloadModule } from '../../modules/download/download.module'; import { TagModule } from '../tag/tag.module'; import { AlbumModule } from '../album/album.module'; import { UserModule } from '../user/user.module'; +import { StorageModule } from '@app/storage'; const ASSET_REPOSITORY_PROVIDER = { provide: ASSET_REPOSITORY, @@ -28,6 +29,7 @@ const ASSET_REPOSITORY_PROVIDER = { UserModule, AlbumModule, TagModule, + StorageModule, forwardRef(() => AlbumModule), BullModule.registerQueue({ name: QueueNameEnum.ASSET_UPLOADED, diff --git a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts index 7f19e0684e..fed76c9646 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts @@ -11,7 +11,8 @@ import { DownloadService } from '../../modules/download/download.service'; import { BackgroundTaskService } from '../../modules/background-task/background-task.service'; import { IAssetUploadedJob, IVideoTranscodeJob } from '@app/job'; import { Queue } from 'bull'; -import { IAlbumRepository } from "../album/album-repository"; +import { IAlbumRepository } from '../album/album-repository'; +import { StorageService } from '@app/storage'; describe('AssetService', () => { let sui: AssetService; @@ -22,6 +23,7 @@ describe('AssetService', () => { let backgroundTaskServiceMock: jest.Mocked; let assetUploadedQueueMock: jest.Mocked>; let videoConversionQueueMock: jest.Mocked>; + let storageSeriveMock: jest.Mocked; const authUser: AuthUserDto = Object.freeze({ id: 'user_id_1', email: 'auth@test.com', @@ -139,6 +141,7 @@ describe('AssetService', () => { assetUploadedQueueMock, videoConversionQueueMock, downloadServiceMock as DownloadService, + storageSeriveMock, ); }); diff --git a/server/apps/immich/src/api-v1/asset/asset.service.ts b/server/apps/immich/src/api-v1/asset/asset.service.ts index 577e08525c..b0f9b39f36 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.ts @@ -55,6 +55,7 @@ import { Queue } from 'bull'; import { DownloadService } from '../../modules/download/download.service'; import { DownloadDto } from './dto/download-library.dto'; import { ALBUM_REPOSITORY, IAlbumRepository } from '../album/album-repository'; +import { StorageService } from '@app/storage'; const fileInfo = promisify(stat); @@ -79,6 +80,8 @@ export class AssetService { private videoConversionQueue: Queue, private downloadService: DownloadService, + + private storageService: StorageService, ) {} public async handleUploadedAsset( @@ -113,6 +116,8 @@ export class AssetService { throw new BadRequestException('Asset not created'); } + await this.storageService.moveAsset(livePhotoAssetEntity, originalAssetData.originalname); + await this.videoConversionQueue.add( mp4ConversionProcessorName, { asset: livePhotoAssetEntity }, @@ -139,13 +144,15 @@ export class AssetService { throw new BadRequestException('Asset not created'); } + const movedAsset = await this.storageService.moveAsset(assetEntity, originalAssetData.originalname); + await this.assetUploadedQueue.add( assetUploadedProcessorName, - { asset: assetEntity, fileName: originalAssetData.originalname }, - { jobId: assetEntity.id }, + { asset: movedAsset, fileName: originalAssetData.originalname }, + { jobId: movedAsset.id }, ); - return new AssetFileUploadResponseDto(assetEntity.id); + return new AssetFileUploadResponseDto(movedAsset.id); } catch (err) { await this.backgroundTaskService.deleteFileOnDisk([ { diff --git a/server/apps/immich/src/api-v1/system-config/dto/system-config-storage-template.dto.ts b/server/apps/immich/src/api-v1/system-config/dto/system-config-storage-template.dto.ts new file mode 100644 index 0000000000..f00006583c --- /dev/null +++ b/server/apps/immich/src/api-v1/system-config/dto/system-config-storage-template.dto.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class SystemConfigStorageTemplateDto { + @IsNotEmpty() + @IsString() + template!: string; +} diff --git a/server/apps/immich/src/api-v1/system-config/dto/system-config.dto.ts b/server/apps/immich/src/api-v1/system-config/dto/system-config.dto.ts index 1cbb5e3666..72e1356ba4 100644 --- a/server/apps/immich/src/api-v1/system-config/dto/system-config.dto.ts +++ b/server/apps/immich/src/api-v1/system-config/dto/system-config.dto.ts @@ -2,6 +2,7 @@ import { SystemConfig } from '@app/database/entities/system-config.entity'; import { ValidateNested } from 'class-validator'; import { SystemConfigFFmpegDto } from './system-config-ffmpeg.dto'; import { SystemConfigOAuthDto } from './system-config-oauth.dto'; +import { SystemConfigStorageTemplateDto } from './system-config-storage-template.dto'; export class SystemConfigDto { @ValidateNested() @@ -9,6 +10,9 @@ export class SystemConfigDto { @ValidateNested() oauth!: SystemConfigOAuthDto; + + @ValidateNested() + storageTemplate!: SystemConfigStorageTemplateDto; } export function mapConfig(config: SystemConfig): SystemConfigDto { diff --git a/server/apps/immich/src/api-v1/system-config/response-dto/system-config-template-storage-option.dto.ts b/server/apps/immich/src/api-v1/system-config/response-dto/system-config-template-storage-option.dto.ts new file mode 100644 index 0000000000..c9150f1ddd --- /dev/null +++ b/server/apps/immich/src/api-v1/system-config/response-dto/system-config-template-storage-option.dto.ts @@ -0,0 +1,9 @@ +export class SystemConfigTemplateStorageOptionDto { + yearOptions!: string[]; + monthOptions!: string[]; + dayOptions!: string[]; + hourOptions!: string[]; + minuteOptions!: string[]; + secondOptions!: string[]; + presetOptions!: string[]; +} diff --git a/server/apps/immich/src/api-v1/system-config/system-config.controller.ts b/server/apps/immich/src/api-v1/system-config/system-config.controller.ts index 4b8cb29799..ed247b382e 100644 --- a/server/apps/immich/src/api-v1/system-config/system-config.controller.ts +++ b/server/apps/immich/src/api-v1/system-config/system-config.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, Get, Put, ValidationPipe } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { Authenticated } from '../../decorators/authenticated.decorator'; +import { SystemConfigTemplateStorageOptionDto } from './response-dto/system-config-template-storage-option.dto'; import { SystemConfigDto } from './dto/system-config.dto'; import { SystemConfigService } from './system-config.service'; @@ -25,4 +26,9 @@ export class SystemConfigController { public updateConfig(@Body(ValidationPipe) dto: SystemConfigDto): Promise { return this.systemConfigService.updateConfig(dto); } + + @Get('storage-template-options') + public getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto { + return this.systemConfigService.getStorageTemplateOptions(); + } } diff --git a/server/apps/immich/src/api-v1/system-config/system-config.service.ts b/server/apps/immich/src/api-v1/system-config/system-config.service.ts index 5426d5a310..db02cb5f20 100644 --- a/server/apps/immich/src/api-v1/system-config/system-config.service.ts +++ b/server/apps/immich/src/api-v1/system-config/system-config.service.ts @@ -1,6 +1,16 @@ +import { + supportedDayTokens, + supportedHourTokens, + supportedMinuteTokens, + supportedMonthTokens, + supportedPresetTokens, + supportedSecondTokens, + supportedYearTokens, +} from '@app/storage/constants/supported-datetime-template'; import { Injectable } from '@nestjs/common'; import { ImmichConfigService } from 'libs/immich-config/src'; import { mapConfig, SystemConfigDto } from './dto/system-config.dto'; +import { SystemConfigTemplateStorageOptionDto } from './response-dto/system-config-template-storage-option.dto'; @Injectable() export class SystemConfigService { @@ -17,7 +27,21 @@ export class SystemConfigService { } public async updateConfig(dto: SystemConfigDto): Promise { - await this.immichConfigService.updateConfig(dto); - return this.getConfig(); + const config = await this.immichConfigService.updateConfig(dto); + return mapConfig(config); + } + + public getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto { + const options = new SystemConfigTemplateStorageOptionDto(); + + options.dayOptions = supportedDayTokens; + options.monthOptions = supportedMonthTokens; + options.yearOptions = supportedYearTokens; + options.hourOptions = supportedHourTokens; + options.minuteOptions = supportedMinuteTokens; + options.secondOptions = supportedSecondTokens; + options.presetOptions = supportedPresetTokens; + + return options; } } diff --git a/server/apps/immich/src/api-v1/user/user.service.spec.ts b/server/apps/immich/src/api-v1/user/user.service.spec.ts index 126d925607..4ac08c4f94 100644 --- a/server/apps/immich/src/api-v1/user/user.service.spec.ts +++ b/server/apps/immich/src/api-v1/user/user.service.spec.ts @@ -19,7 +19,7 @@ describe('UserService', () => { email: 'immich@test.com', }); - const adminUser: UserEntity = Object.freeze({ + const adminUser: UserEntity = { id: 'admin_id', email: 'admin@test.com', password: 'admin_password', @@ -32,9 +32,9 @@ describe('UserService', () => { profileImagePath: '', createdAt: '2021-01-01', tags: [], - }); + }; - const immichUser: UserEntity = Object.freeze({ + const immichUser: UserEntity = { id: 'immich_id', email: 'immich@test.com', password: 'immich_password', @@ -47,9 +47,9 @@ describe('UserService', () => { profileImagePath: '', createdAt: '2021-01-01', tags: [], - }); + }; - const updatedImmichUser: UserEntity = Object.freeze({ + const updatedImmichUser: UserEntity = { id: 'immich_id', email: 'immich@test.com', password: 'immich_password', @@ -62,7 +62,7 @@ describe('UserService', () => { profileImagePath: '', createdAt: '2021-01-01', tags: [], - }); + }; beforeAll(() => { userRepositoryMock = newUserRepositoryMock(); @@ -75,7 +75,7 @@ describe('UserService', () => { }); describe('Update user', () => { - it('should update user', () => { + it('should update user', async () => { const requestor = immichAuthUser; const userToUpdate = immichUser; @@ -83,11 +83,11 @@ describe('UserService', () => { userRepositoryMock.get.mockImplementationOnce(() => Promise.resolve(userToUpdate)); userRepositoryMock.update.mockImplementationOnce(() => Promise.resolve(updatedImmichUser)); - const result = sui.updateUser(requestor, { + const result = await sui.updateUser(requestor, { id: userToUpdate.id, shouldChangePassword: true, }); - expect(result).resolves.toBeDefined(); + expect(result.shouldChangePassword).toEqual(true); }); it('user can only update its information', () => { diff --git a/server/apps/microservices/src/processors/thumbnail.processor.ts b/server/apps/microservices/src/processors/thumbnail.processor.ts index 6cf684c5cd..c01e3da2aa 100644 --- a/server/apps/microservices/src/processors/thumbnail.processor.ts +++ b/server/apps/microservices/src/processors/thumbnail.processor.ts @@ -44,6 +44,7 @@ export class ThumbnailGeneratorProcessor { private configService: ConfigService, ) { this.logLevel = this.configService.get('LOG_LEVEL') || ImmichLogLevel.SIMPLE; + // TODO - Add observable paterrn to listen to the config change } @Process({ name: generateJPEGThumbnailProcessorName, concurrency: 3 }) @@ -59,9 +60,7 @@ export class ThumbnailGeneratorProcessor { mkdirSync(resizePath, { recursive: true }); } - const temp = asset.originalPath.split('/'); - const originalFilename = temp[temp.length - 1].split('.')[0]; - const jpegThumbnailPath = join(resizePath, `${originalFilename}.jpeg`); + const jpegThumbnailPath = join(resizePath, `${asset.id}.jpeg`); if (asset.type == AssetType.IMAGE) { try { diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 8adc1924d5..f306868cf6 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -2169,12 +2169,38 @@ } ] } + }, + "/system-config/storage-template-options": { + "get": { + "operationId": "getStorageTemplateOptions", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SystemConfigTemplateStorageOptionDto" + } + } + } + } + }, + "tags": [ + "System Config" + ], + "security": [ + { + "bearer": [] + } + ] + } } }, "info": { "title": "Immich", "description": "Immich API", - "version": "1.38.0", + "version": "1.38.2", "contact": {} }, "tags": [], @@ -3664,6 +3690,17 @@ "autoRegister" ] }, + "SystemConfigStorageTemplateDto": { + "type": "object", + "properties": { + "template": { + "type": "string" + } + }, + "required": [ + "template" + ] + }, "SystemConfigDto": { "type": "object", "properties": { @@ -3672,11 +3709,71 @@ }, "oauth": { "$ref": "#/components/schemas/SystemConfigOAuthDto" + }, + "storageTemplate": { + "$ref": "#/components/schemas/SystemConfigStorageTemplateDto" } }, "required": [ "ffmpeg", - "oauth" + "oauth", + "storageTemplate" + ] + }, + "SystemConfigTemplateStorageOptionDto": { + "type": "object", + "properties": { + "yearOptions": { + "type": "array", + "items": { + "type": "string" + } + }, + "monthOptions": { + "type": "array", + "items": { + "type": "string" + } + }, + "dayOptions": { + "type": "array", + "items": { + "type": "string" + } + }, + "hourOptions": { + "type": "array", + "items": { + "type": "string" + } + }, + "minuteOptions": { + "type": "array", + "items": { + "type": "string" + } + }, + "secondOptions": { + "type": "array", + "items": { + "type": "string" + } + }, + "presetOptions": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "yearOptions", + "monthOptions", + "dayOptions", + "hourOptions", + "minuteOptions", + "secondOptions", + "presetOptions" ] } } diff --git a/server/libs/database/src/entities/system-config.entity.ts b/server/libs/database/src/entities/system-config.entity.ts index ce3fd7a96e..646a89f9f4 100644 --- a/server/libs/database/src/entities/system-config.entity.ts +++ b/server/libs/database/src/entities/system-config.entity.ts @@ -25,6 +25,7 @@ export enum SystemConfigKey { OAUTH_SCOPE = 'oauth.scope', OAUTH_BUTTON_TEXT = 'oauth.buttonText', OAUTH_AUTO_REGISTER = 'oauth.autoRegister', + STORAGE_TEMPLATE = 'storageTemplate.template', } export interface SystemConfig { @@ -44,4 +45,7 @@ export interface SystemConfig { buttonText: string; autoRegister: boolean; }; + storageTemplate: { + template: string; + }; } diff --git a/server/libs/immich-config/src/immich-config.module.ts b/server/libs/immich-config/src/immich-config.module.ts index 14e6897830..dc2b93569f 100644 --- a/server/libs/immich-config/src/immich-config.module.ts +++ b/server/libs/immich-config/src/immich-config.module.ts @@ -1,11 +1,24 @@ import { SystemConfigEntity } from '@app/database/entities/system-config.entity'; -import { Module } from '@nestjs/common'; +import { Module, Provider } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ImmichConfigService } from './immich-config.service'; +export const INITIAL_SYSTEM_CONFIG = 'INITIAL_SYSTEM_CONFIG'; + +const providers: Provider[] = [ + ImmichConfigService, + { + provide: INITIAL_SYSTEM_CONFIG, + inject: [ImmichConfigService], + useFactory: async (configService: ImmichConfigService) => { + return configService.getConfig(); + }, + }, +]; + @Module({ imports: [TypeOrmModule.forFeature([SystemConfigEntity])], - providers: [ImmichConfigService], - exports: [ImmichConfigService], + providers: [...providers], + exports: [...providers], }) export class ImmichConfigModule {} diff --git a/server/libs/immich-config/src/immich-config.service.ts b/server/libs/immich-config/src/immich-config.service.ts index e2656f6fe9..157b2d7a62 100644 --- a/server/libs/immich-config/src/immich-config.service.ts +++ b/server/libs/immich-config/src/immich-config.service.ts @@ -1,9 +1,12 @@ import { SystemConfig, SystemConfigEntity, SystemConfigKey } from '@app/database/entities/system-config.entity'; -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import * as _ from 'lodash'; +import { Subject } from 'rxjs'; import { DeepPartial, In, Repository } from 'typeorm'; +export type SystemConfigValidator = (config: SystemConfig) => void | Promise; + const defaults: SystemConfig = Object.freeze({ ffmpeg: { crf: '23', @@ -21,10 +24,19 @@ const defaults: SystemConfig = Object.freeze({ buttonText: 'Login with OAuth', autoRegister: true, }, + + storageTemplate: { + template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', + }, }); @Injectable() export class ImmichConfigService { + private logger = new Logger(ImmichConfigService.name); + private validators: SystemConfigValidator[] = []; + + public config$ = new Subject(); + constructor( @InjectRepository(SystemConfigEntity) private systemConfigRepository: Repository, @@ -34,6 +46,10 @@ export class ImmichConfigService { return defaults; } + public addValidator(validator: SystemConfigValidator) { + this.validators.push(validator); + } + public async getConfig() { const overrides = await this.systemConfigRepository.find(); const config: DeepPartial = {}; @@ -45,7 +61,16 @@ export class ImmichConfigService { return _.defaultsDeep(config, defaults) as SystemConfig; } - public async updateConfig(config: DeepPartial | null): Promise { + public async updateConfig(config: SystemConfig): Promise { + try { + for (const validator of this.validators) { + await validator(config); + } + } catch (e) { + this.logger.warn(`Unable to save system config due to a validation error: ${e}`); + throw new BadRequestException(e instanceof Error ? e.message : e); + } + const updates: SystemConfigEntity[] = []; const deletes: SystemConfigEntity[] = []; @@ -70,5 +95,11 @@ export class ImmichConfigService { if (deletes.length > 0) { await this.systemConfigRepository.delete({ key: In(deletes.map((item) => item.key)) }); } + + const newConfig = await this.getConfig(); + + this.config$.next(newConfig); + + return newConfig; } } diff --git a/server/libs/storage/src/constants/supported-datetime-template.ts b/server/libs/storage/src/constants/supported-datetime-template.ts new file mode 100644 index 0000000000..e6d9bc723d --- /dev/null +++ b/server/libs/storage/src/constants/supported-datetime-template.ts @@ -0,0 +1,20 @@ +export const supportedYearTokens = ['y', 'yy']; +export const supportedMonthTokens = ['M', 'MM', 'MMM', 'MMMM']; +export const supportedDayTokens = ['d', 'dd']; +export const supportedHourTokens = ['h', 'hh', 'H', 'HH']; +export const supportedMinuteTokens = ['m', 'mm']; +export const supportedSecondTokens = ['s', 'ss']; +export const supportedPresetTokens = [ + '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', + '{{y}}/{{MM}}-{{dd}}/{{filename}}', + '{{y}}/{{MMMM}}-{{dd}}/{{filename}}', + '{{y}}/{{MM}}/{{filename}}', + '{{y}}/{{MMM}}/{{filename}}', + '{{y}}/{{MMMM}}/{{filename}}', + '{{y}}/{{MM}}/{{dd}}/{{filename}}', + '{{y}}/{{MMMM}}/{{dd}}/{{filename}}', + '{{y}}/{{y}}-{{MM}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', + '{{y}}-{{MM}}-{{dd}}/{{filename}}', + '{{y}}-{{MMM}}-{{dd}}/{{filename}}', + '{{y}}-{{MMMM}}-{{dd}}/{{filename}}', +]; diff --git a/server/libs/storage/src/index.ts b/server/libs/storage/src/index.ts new file mode 100644 index 0000000000..5c65021d00 --- /dev/null +++ b/server/libs/storage/src/index.ts @@ -0,0 +1,2 @@ +export * from './storage.module'; +export * from './storage.service'; diff --git a/server/libs/storage/src/interfaces/immich-storage.interface.ts b/server/libs/storage/src/interfaces/immich-storage.interface.ts new file mode 100644 index 0000000000..c5d848e1d6 --- /dev/null +++ b/server/libs/storage/src/interfaces/immich-storage.interface.ts @@ -0,0 +1,6 @@ +export interface IImmichStorage { + write(): Promise; + read(): Promise; +} + +export enum IStorageType {} diff --git a/server/libs/storage/src/storage.module.ts b/server/libs/storage/src/storage.module.ts new file mode 100644 index 0000000000..2b29959a2e --- /dev/null +++ b/server/libs/storage/src/storage.module.ts @@ -0,0 +1,13 @@ +import { AssetEntity } from '@app/database/entities/asset.entity'; +import { SystemConfigEntity } from '@app/database/entities/system-config.entity'; +import { ImmichConfigModule } from '@app/immich-config'; +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { StorageService } from './storage.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([AssetEntity, SystemConfigEntity]), ImmichConfigModule], + providers: [StorageService], + exports: [StorageService], +}) +export class StorageModule {} diff --git a/server/libs/storage/src/storage.service.ts b/server/libs/storage/src/storage.service.ts new file mode 100644 index 0000000000..e714a10390 --- /dev/null +++ b/server/libs/storage/src/storage.service.ts @@ -0,0 +1,153 @@ +import { APP_UPLOAD_LOCATION } from '@app/common'; +import { AssetEntity } from '@app/database/entities/asset.entity'; +import { SystemConfig } from '@app/database/entities/system-config.entity'; +import { ImmichConfigService, INITIAL_SYSTEM_CONFIG } from '@app/immich-config'; +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import fsPromise from 'fs/promises'; +import handlebar from 'handlebars'; +import * as luxon from 'luxon'; +import mv from 'mv'; +import { constants } from 'node:fs'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import sanitize from 'sanitize-filename'; +import { Repository } from 'typeorm'; +import { + supportedDayTokens, + supportedHourTokens, + supportedMinuteTokens, + supportedMonthTokens, + supportedSecondTokens, + supportedYearTokens, +} from './constants/supported-datetime-template'; + +const moveFile = promisify(mv); + +@Injectable() +export class StorageService { + readonly log = new Logger(StorageService.name); + + private storageTemplate: HandlebarsTemplateDelegate; + + constructor( + @InjectRepository(AssetEntity) + private assetRepository: Repository, + private immichConfigService: ImmichConfigService, + @Inject(INITIAL_SYSTEM_CONFIG) config: SystemConfig, + ) { + this.storageTemplate = this.compile(config.storageTemplate.template); + + this.immichConfigService.addValidator((config) => this.validateConfig(config)); + + this.immichConfigService.config$.subscribe((config) => { + this.log.debug(`Received new config, recompiling storage template: ${config.storageTemplate.template}`); + this.storageTemplate = this.compile(config.storageTemplate.template); + }); + } + + public async moveAsset(asset: AssetEntity, filename: string): Promise { + try { + const source = asset.originalPath; + const ext = path.extname(source).split('.').pop() as string; + const sanitized = sanitize(path.basename(filename, `.${ext}`)); + const rootPath = path.join(APP_UPLOAD_LOCATION, asset.userId); + const storagePath = this.render(this.storageTemplate, asset, sanitized, ext); + const fullPath = path.normalize(path.join(rootPath, storagePath)); + + if (!fullPath.startsWith(rootPath)) { + this.log.warn(`Skipped attempt to access an invalid path: ${fullPath}. Path should start with ${rootPath}`); + return asset; + } + + let duplicateCount = 0; + let destination = `${fullPath}.${ext}`; + + while (true) { + const exists = await this.checkFileExist(destination); + if (!exists) { + break; + } + + duplicateCount++; + destination = `${fullPath}_${duplicateCount}.${ext}`; + } + + await this.safeMove(source, destination); + + asset.originalPath = destination; + return await this.assetRepository.save(asset); + } catch (error: any) { + this.log.error(error, error.stack); + return asset; + } + } + + private safeMove(source: string, destination: string): Promise { + return moveFile(source, destination, { mkdirp: true, clobber: false }); + } + + private async checkFileExist(path: string): Promise { + try { + await fsPromise.access(path, constants.F_OK); + return true; + } catch (_) { + return false; + } + } + + private validateConfig(config: SystemConfig) { + this.validateStorageTemplate(config.storageTemplate.template); + } + + private validateStorageTemplate(templateString: string) { + try { + const template = this.compile(templateString); + + // test render an asset + this.render( + template, + { + createdAt: new Date().toISOString(), + originalPath: '/upload/test/IMG_123.jpg', + } as AssetEntity, + 'IMG_123', + 'jpg', + ); + } catch (e) { + this.log.warn(`Storage template validation failed: ${e}`); + throw new Error(`Invalid storage template: ${e}`); + } + } + + private compile(template: string) { + return handlebar.compile(template, { + knownHelpers: undefined, + strict: true, + }); + } + + private render(template: HandlebarsTemplateDelegate, asset: AssetEntity, filename: string, ext: string) { + const substitutions: Record = { + filename, + ext, + }; + + const dt = luxon.DateTime.fromISO(new Date(asset.createdAt).toISOString()); + + const dateTokens = [ + ...supportedYearTokens, + ...supportedMonthTokens, + ...supportedDayTokens, + ...supportedHourTokens, + ...supportedMinuteTokens, + ...supportedSecondTokens, + ]; + + for (const token of dateTokens) { + substitutions[token] = dt.toFormat(token); + } + + return template(substitutions); + } +} diff --git a/server/libs/storage/tsconfig.lib.json b/server/libs/storage/tsconfig.lib.json new file mode 100644 index 0000000000..b99dca8010 --- /dev/null +++ b/server/libs/storage/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "outDir": "../../dist/libs/storage" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] +} diff --git a/server/nest-cli.json b/server/nest-cli.json index 861e733dab..03040b77ca 100644 --- a/server/nest-cli.json +++ b/server/nest-cli.json @@ -79,6 +79,15 @@ "compilerOptions": { "tsConfigPath": "libs/immich-config/tsconfig.lib.json" } + }, + "storage": { + "type": "library", + "root": "libs/storage", + "entryFile": "index", + "sourceRoot": "libs/storage/src", + "compilerOptions": { + "tsConfigPath": "libs/storage/tsconfig.lib.json" + } } } -} +} \ No newline at end of file diff --git a/server/package-lock.json b/server/package-lock.json index 43af398572..c920dd314a 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -36,11 +36,13 @@ "fdir": "^5.3.0", "fluent-ffmpeg": "^2.1.2", "geo-tz": "^7.0.2", + "handlebars": "^4.7.7", "i18n-iso-countries": "^7.5.0", "joi": "^17.5.0", "local-reverse-geocoder": "^0.12.5", "lodash": "^4.17.21", "luxon": "^3.0.3", + "mv": "^2.1.1", "nest-commander": "^3.3.0", "openid-client": "^5.2.1", "passport": "^0.6.0", @@ -76,6 +78,7 @@ "@types/jest": "27.0.2", "@types/lodash": "^4.14.178", "@types/multer": "^1.4.7", + "@types/mv": "^2.1.2", "@types/node": "^16.0.0", "@types/passport-jwt": "^3.0.6", "@types/sharp": "^0.30.2", @@ -2544,6 +2547,12 @@ "@types/express": "*" } }, + "node_modules/@types/mv": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@types/mv/-/mv-2.1.2.tgz", + "integrity": "sha512-IvAjPuiQ2exDicnTrMidt1m+tj3gZ60BM0PaoRsU0m9Cn+lrOyemuO9Tf8CvHFmXlxMjr1TVCfadi9sfwbSuKg==", + "dev": true + }, "node_modules/@types/node": { "version": "16.11.21", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.21.tgz", @@ -6168,6 +6177,34 @@ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.9.tgz", "integrity": "sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ==" }, + "node_modules/handlebars": { + "version": "4.7.7", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz", + "integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.0", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/handlebars/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", @@ -8178,6 +8215,45 @@ "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==" }, + "node_modules/mv": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz", + "integrity": "sha512-at/ZndSy3xEGJ8i0ygALh8ru9qy7gWW1cmkaqBN29JmMlIvM//MEO9y1sk/avxuwnPcfhkejkLsuPxH81BrkSg==", + "dependencies": { + "mkdirp": "~0.5.1", + "ncp": "~2.0.0", + "rimraf": "~2.4.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/mv/node_modules/glob": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz", + "integrity": "sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==", + "dependencies": { + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "2 || 3", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mv/node_modules/rimraf": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz", + "integrity": "sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ==", + "dependencies": { + "glob": "^6.0.1" + }, + "bin": { + "rimraf": "bin.js" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -8204,6 +8280,14 @@ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, + "node_modules/ncp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz", + "integrity": "sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA==", + "bin": { + "ncp": "bin/ncp" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -8215,8 +8299,7 @@ "node_modules/neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, "node_modules/nest-commander": { "version": "3.3.0", @@ -11006,6 +11089,18 @@ "node": ">=4.2.0" } }, + "node_modules/uglify-js": { + "version": "3.17.4", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz", + "integrity": "sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/uid2": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz", @@ -11329,6 +11424,11 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==" + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -13393,6 +13493,12 @@ "@types/express": "*" } }, + "@types/mv": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@types/mv/-/mv-2.1.2.tgz", + "integrity": "sha512-IvAjPuiQ2exDicnTrMidt1m+tj3gZ60BM0PaoRsU0m9Cn+lrOyemuO9Tf8CvHFmXlxMjr1TVCfadi9sfwbSuKg==", + "dev": true + }, "@types/node": { "version": "16.11.21", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.21.tgz", @@ -16213,6 +16319,25 @@ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.9.tgz", "integrity": "sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ==" }, + "handlebars": { + "version": "4.7.7", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz", + "integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==", + "requires": { + "minimist": "^1.2.5", + "neo-async": "^2.6.0", + "source-map": "^0.6.1", + "uglify-js": "^3.1.4", + "wordwrap": "^1.0.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + } + } + }, "har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", @@ -17773,6 +17898,38 @@ "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==" }, + "mv": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz", + "integrity": "sha512-at/ZndSy3xEGJ8i0ygALh8ru9qy7gWW1cmkaqBN29JmMlIvM//MEO9y1sk/avxuwnPcfhkejkLsuPxH81BrkSg==", + "requires": { + "mkdirp": "~0.5.1", + "ncp": "~2.0.0", + "rimraf": "~2.4.0" + }, + "dependencies": { + "glob": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz", + "integrity": "sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==", + "requires": { + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "2 || 3", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "rimraf": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz", + "integrity": "sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ==", + "requires": { + "glob": "^6.0.1" + } + } + } + }, "mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -17799,6 +17956,11 @@ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, + "ncp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz", + "integrity": "sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA==" + }, "negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -17807,8 +17969,7 @@ "neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, "nest-commander": { "version": "3.3.0", @@ -19794,6 +19955,12 @@ "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==", "devOptional": true }, + "uglify-js": { + "version": "3.17.4", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz", + "integrity": "sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==", + "optional": true + }, "uid2": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz", @@ -20049,6 +20216,11 @@ "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", "dev": true }, + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==" + }, "wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/server/package.json b/server/package.json index 834484af84..8e781aeada 100644 --- a/server/package.json +++ b/server/package.json @@ -59,11 +59,13 @@ "fdir": "^5.3.0", "fluent-ffmpeg": "^2.1.2", "geo-tz": "^7.0.2", + "handlebars": "^4.7.7", "i18n-iso-countries": "^7.5.0", "joi": "^17.5.0", "local-reverse-geocoder": "^0.12.5", "lodash": "^4.17.21", "luxon": "^3.0.3", + "mv": "^2.1.1", "nest-commander": "^3.3.0", "openid-client": "^5.2.1", "passport": "^0.6.0", @@ -96,6 +98,7 @@ "@types/jest": "27.0.2", "@types/lodash": "^4.14.178", "@types/multer": "^1.4.7", + "@types/mv": "^2.1.2", "@types/node": "^16.0.0", "@types/passport-jwt": "^3.0.6", "@types/sharp": "^0.30.2", @@ -142,7 +145,8 @@ "@app/database/config": "/libs/database/src/config", "@app/common": "/libs/common/src", "^@app/job(|/.*)$": "/libs/job/src/$1", - "^@app/immich-config(|/.*)$": "/libs/immich-config/src/$1" + "^@app/immich-config(|/.*)$": "/libs/immich-config/src/$1", + "^@app/storage(|/.*)$": "/libs/storage/src/$1" } } } diff --git a/server/tsconfig.json b/server/tsconfig.json index ee830c64a4..530c06b340 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -16,15 +16,41 @@ "esModuleInterop": true, "baseUrl": "./", "paths": { - "@app/common": ["libs/common/src"], - "@app/common/*": ["libs/common/src/*"], - "@app/database": ["libs/database/src"], - "@app/database/*": ["libs/database/src/*"], - "@app/job": ["libs/job/src"], - "@app/job/*": ["libs/job/src/*"], - "@app/immich-config": ["libs/immich-config/src"], - "@app/immich-config/*": ["libs/immich-config/src/*"] + "@app/common": [ + "libs/common/src" + ], + "@app/common/*": [ + "libs/common/src/*" + ], + "@app/database": [ + "libs/database/src" + ], + "@app/database/*": [ + "libs/database/src/*" + ], + "@app/job": [ + "libs/job/src" + ], + "@app/job/*": [ + "libs/job/src/*" + ], + "@app/immich-config": [ + "libs/immich-config/src" + ], + "@app/immich-config/*": [ + "libs/immich-config/src/*" + ], + "@app/storage": [ + "libs/storage/src" + ], + "@app/storage/*": [ + "libs/storage/src/*" + ] } }, - "exclude": ["dist", "node_modules", "upload"] -} + "exclude": [ + "dist", + "node_modules", + "upload" + ] +} \ No newline at end of file diff --git a/web/package-lock.json b/web/package-lock.json index cdce9994ea..1b28b5b08a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -12,9 +12,11 @@ "cookie": "^0.4.2", "copy-image-clipboard": "^2.1.2", "exifr": "^7.1.3", + "handlebars": "^4.7.7", "leaflet": "^1.8.0", "lodash": "^4.17.21", "lodash-es": "^4.17.21", + "luxon": "^3.1.1", "socket.io-client": "^4.5.1", "svelte-keydown": "^0.5.0", "svelte-material-icons": "^2.0.2" @@ -34,6 +36,7 @@ "@types/leaflet": "^1.7.10", "@types/lodash": "^4.14.182", "@types/lodash-es": "^4.17.6", + "@types/luxon": "^3.1.0", "@types/socket.io-client": "^3.0.0", "@typescript-eslint/eslint-plugin": "^5.27.0", "@typescript-eslint/parser": "^5.27.0", @@ -3319,6 +3322,12 @@ "@types/lodash": "*" } }, + "node_modules/@types/luxon": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.1.0.tgz", + "integrity": "sha512-gCd/HcCgjqSxfMrgtqxCgYk/22NBQfypwFUG7ZAyG/4pqs51WLTcUzVp1hqTbieDYeHS3WoVEh2Yv/2l+7B0Vg==", + "dev": true + }, "node_modules/@types/node": { "version": "18.11.11", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.11.tgz", @@ -6149,6 +6158,26 @@ "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", "dev": true }, + "node_modules/handlebars": { + "version": "4.7.7", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz", + "integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.0", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -8976,6 +9005,14 @@ "node": ">=10" } }, + "node_modules/luxon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.1.1.tgz", + "integrity": "sha512-Ah6DloGmvseB/pX1cAmjbFvyU/pKuwQMQqz7d0yvuDlVYLTs2WeDHQMpC8tGjm1da+BriHROW/OEIT/KfYg6xw==", + "engines": { + "node": ">=12" + } + }, "node_modules/lz-string": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz", @@ -9114,7 +9151,6 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.7.tgz", "integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -9178,6 +9214,11 @@ "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", "dev": true }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -10280,7 +10321,6 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -10835,6 +10875,18 @@ "node": ">=4.2.0" } }, + "node_modules/uglify-js": { + "version": "3.17.4", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz", + "integrity": "sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/undici": { "version": "5.13.0", "resolved": "https://registry.npmjs.org/undici/-/undici-5.13.0.tgz", @@ -11163,6 +11215,11 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==" + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -13726,6 +13783,12 @@ "@types/lodash": "*" } }, + "@types/luxon": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.1.0.tgz", + "integrity": "sha512-gCd/HcCgjqSxfMrgtqxCgYk/22NBQfypwFUG7ZAyG/4pqs51WLTcUzVp1hqTbieDYeHS3WoVEh2Yv/2l+7B0Vg==", + "dev": true + }, "@types/node": { "version": "18.11.11", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.11.tgz", @@ -15703,6 +15766,18 @@ "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", "dev": true }, + "handlebars": { + "version": "4.7.7", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz", + "integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==", + "requires": { + "minimist": "^1.2.5", + "neo-async": "^2.6.0", + "source-map": "^0.6.1", + "uglify-js": "^3.1.4", + "wordwrap": "^1.0.0" + } + }, "has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -17789,6 +17864,11 @@ "yallist": "^4.0.0" } }, + "luxon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.1.1.tgz", + "integrity": "sha512-Ah6DloGmvseB/pX1cAmjbFvyU/pKuwQMQqz7d0yvuDlVYLTs2WeDHQMpC8tGjm1da+BriHROW/OEIT/KfYg6xw==" + }, "lz-string": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz", @@ -17887,8 +17967,7 @@ "minimist": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.7.tgz", - "integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==", - "dev": true + "integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==" }, "mkdirp": { "version": "0.5.6", @@ -17934,6 +18013,11 @@ "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", "dev": true }, + "neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" + }, "node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -18708,8 +18792,7 @@ "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" }, "source-map-js": { "version": "1.0.2", @@ -19092,6 +19175,12 @@ "integrity": "sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA==", "dev": true }, + "uglify-js": { + "version": "3.17.4", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz", + "integrity": "sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==", + "optional": true + }, "undici": { "version": "5.13.0", "resolved": "https://registry.npmjs.org/undici/-/undici-5.13.0.tgz", @@ -19304,6 +19393,11 @@ "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", "dev": true }, + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==" + }, "wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/web/package.json b/web/package.json index 2aea1179ff..ac3e092d7b 100644 --- a/web/package.json +++ b/web/package.json @@ -32,6 +32,7 @@ "@types/leaflet": "^1.7.10", "@types/lodash": "^4.14.182", "@types/lodash-es": "^4.17.6", + "@types/luxon": "^3.1.0", "@types/socket.io-client": "^3.0.0", "@typescript-eslint/eslint-plugin": "^5.27.0", "@typescript-eslint/parser": "^5.27.0", @@ -62,9 +63,11 @@ "cookie": "^0.4.2", "copy-image-clipboard": "^2.1.2", "exifr": "^7.1.3", + "handlebars": "^4.7.7", "leaflet": "^1.8.0", "lodash": "^4.17.21", "lodash-es": "^4.17.21", + "luxon": "^3.1.1", "socket.io-client": "^4.5.1", "svelte-keydown": "^0.5.0", "svelte-material-icons": "^2.0.2" diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 1fe7093729..7392b8fb14 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.38.0 + * The version of the OpenAPI document: 1.38.2 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). @@ -1443,6 +1443,12 @@ export interface SystemConfigDto { * @memberof SystemConfigDto */ 'oauth': SystemConfigOAuthDto; + /** + * + * @type {SystemConfigStorageTemplateDto} + * @memberof SystemConfigDto + */ + 'storageTemplate': SystemConfigStorageTemplateDto; } /** * @@ -1530,6 +1536,68 @@ export interface SystemConfigOAuthDto { */ 'autoRegister': boolean; } +/** + * + * @export + * @interface SystemConfigStorageTemplateDto + */ +export interface SystemConfigStorageTemplateDto { + /** + * + * @type {string} + * @memberof SystemConfigStorageTemplateDto + */ + 'template': string; +} +/** + * + * @export + * @interface SystemConfigTemplateStorageOptionDto + */ +export interface SystemConfigTemplateStorageOptionDto { + /** + * + * @type {Array} + * @memberof SystemConfigTemplateStorageOptionDto + */ + 'yearOptions': Array; + /** + * + * @type {Array} + * @memberof SystemConfigTemplateStorageOptionDto + */ + 'monthOptions': Array; + /** + * + * @type {Array} + * @memberof SystemConfigTemplateStorageOptionDto + */ + 'dayOptions': Array; + /** + * + * @type {Array} + * @memberof SystemConfigTemplateStorageOptionDto + */ + 'hourOptions': Array; + /** + * + * @type {Array} + * @memberof SystemConfigTemplateStorageOptionDto + */ + 'minuteOptions': Array; + /** + * + * @type {Array} + * @memberof SystemConfigTemplateStorageOptionDto + */ + 'secondOptions': Array; + /** + * + * @type {Array} + * @memberof SystemConfigTemplateStorageOptionDto + */ + 'presetOptions': Array; +} /** * * @export @@ -5312,6 +5380,39 @@ export const SystemConfigApiAxiosParamCreator = function (configuration?: Config + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getStorageTemplateOptions: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/system-config/storage-template-options`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -5388,6 +5489,15 @@ export const SystemConfigApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.getDefaults(options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getStorageTemplateOptions(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getStorageTemplateOptions(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {SystemConfigDto} systemConfigDto @@ -5424,6 +5534,14 @@ export const SystemConfigApiFactory = function (configuration?: Configuration, b getDefaults(options?: any): AxiosPromise { return localVarFp.getDefaults(options).then((request) => request(axios, basePath)); }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getStorageTemplateOptions(options?: any): AxiosPromise { + return localVarFp.getStorageTemplateOptions(options).then((request) => request(axios, basePath)); + }, /** * * @param {SystemConfigDto} systemConfigDto @@ -5463,6 +5581,16 @@ export class SystemConfigApi extends BaseAPI { return SystemConfigApiFp(this.configuration).getDefaults(options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SystemConfigApi + */ + public getStorageTemplateOptions(options?: AxiosRequestConfig) { + return SystemConfigApiFp(this.configuration).getStorageTemplateOptions(options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {SystemConfigDto} systemConfigDto diff --git a/web/src/api/open-api/base.ts b/web/src/api/open-api/base.ts index 80827a1d03..f00f196d8b 100644 --- a/web/src/api/open-api/base.ts +++ b/web/src/api/open-api/base.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.38.0 + * The version of the OpenAPI document: 1.38.2 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/web/src/api/open-api/common.ts b/web/src/api/open-api/common.ts index 7eead1834c..a946fabb54 100644 --- a/web/src/api/open-api/common.ts +++ b/web/src/api/open-api/common.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.38.0 + * The version of the OpenAPI document: 1.38.2 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/web/src/api/open-api/configuration.ts b/web/src/api/open-api/configuration.ts index d582ee0639..48794159a3 100644 --- a/web/src/api/open-api/configuration.ts +++ b/web/src/api/open-api/configuration.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.38.0 + * The version of the OpenAPI document: 1.38.2 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/web/src/api/open-api/index.ts b/web/src/api/open-api/index.ts index dc9b61d191..f0b9d9c785 100644 --- a/web/src/api/open-api/index.ts +++ b/web/src/api/open-api/index.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.38.0 + * The version of the OpenAPI document: 1.38.2 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/web/src/app.css b/web/src/app.css index 766d4d19f3..a565023d3f 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -59,11 +59,11 @@ input:focus-visible { @layer utilities { .immich-form-input { - @apply bg-slate-100 p-2 rounded-md focus:border-immich-primary text-sm dark:bg-gray-600 dark:text-immich-dark-fg disabled:bg-gray-500 dark:disabled:bg-gray-900 disabled:cursor-not-allowed; + @apply bg-slate-200 p-2 rounded-lg focus:border-immich-primary text-sm dark:bg-gray-600 dark:text-immich-dark-fg disabled:bg-gray-400 dark:disabled:bg-gray-800 disabled:cursor-not-allowed disabled:text-gray-200; } .immich-form-label { - @apply font-medium text-sm text-gray-500 dark:text-gray-300; + @apply font-medium text-gray-500 dark:text-gray-300; } .immich-btn-primary { diff --git a/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte b/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte index 4359784d01..a3d9d6fddb 100644 --- a/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte +++ b/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte @@ -25,12 +25,12 @@ const { data: configs } = await api.systemConfigApi.getConfig(); const result = await api.systemConfigApi.updateConfig({ - ffmpeg: ffmpegConfig, - oauth: configs.oauth + ...configs, + ffmpeg: ffmpegConfig }); - ffmpegConfig = result.data.ffmpeg; - savedConfig = result.data.ffmpeg; + ffmpegConfig = { ...result.data.ffmpeg }; + savedConfig = { ...result.data.ffmpeg }; notificationController.show({ message: 'FFmpeg settings saved', @@ -48,8 +48,8 @@ async function reset() { const { data: resetConfig } = await api.systemConfigApi.getConfig(); - ffmpegConfig = resetConfig.ffmpeg; - savedConfig = resetConfig.ffmpeg; + ffmpegConfig = { ...resetConfig.ffmpeg }; + savedConfig = { ...resetConfig.ffmpeg }; notificationController.show({ message: 'Reset FFmpeg settings to the recent saved settings', @@ -60,8 +60,8 @@ async function resetToDefault() { const { data: configs } = await api.systemConfigApi.getDefaults(); - ffmpegConfig = configs.ffmpeg; - defaultConfig = configs.ffmpeg; + ffmpegConfig = { ...configs.ffmpeg }; + defaultConfig = { ...configs.ffmpeg }; notificationController.show({ message: 'Reset FFmpeg settings to default', @@ -74,52 +74,56 @@ {#await getConfigs() then}
- +
+ - + - + - + - + +
- +
+ +
{/await} diff --git a/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte b/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte index e35e9b1e95..f444a0a963 100644 --- a/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte +++ b/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte @@ -25,8 +25,8 @@ async function reset() { const { data: resetConfig } = await api.systemConfigApi.getConfig(); - oauthConfig = resetConfig.oauth; - savedConfig = resetConfig.oauth; + oauthConfig = { ...resetConfig.oauth }; + savedConfig = { ...resetConfig.oauth }; notificationController.show({ message: 'Reset OAuth settings to the last saved settings', @@ -39,12 +39,12 @@ const { data: currentConfig } = await api.systemConfigApi.getConfig(); const result = await api.systemConfigApi.updateConfig({ - ffmpeg: currentConfig.ffmpeg, + ...currentConfig, oauth: oauthConfig }); - oauthConfig = result.data.oauth; - savedConfig = result.data.oauth; + oauthConfig = { ...result.data.oauth }; + savedConfig = { ...result.data.oauth }; notificationController.show({ message: 'OAuth settings saved', @@ -62,7 +62,7 @@ async function resetToDefault() { const { data: defaultConfig } = await api.systemConfigApi.getDefaults(); - oauthConfig = defaultConfig.oauth; + oauthConfig = { ...defaultConfig.oauth }; notificationController.show({ message: 'Reset OAuth settings to default', @@ -80,51 +80,52 @@
+
+ - + - + - + - - - + +
- +
+ +
{/await} diff --git a/web/src/lib/components/admin-page/settings/setting-buttons-row.svelte b/web/src/lib/components/admin-page/settings/setting-buttons-row.svelte index 0bdd9dc834..3605fc4067 100644 --- a/web/src/lib/components/admin-page/settings/setting-buttons-row.svelte +++ b/web/src/lib/components/admin-page/settings/setting-buttons-row.svelte @@ -6,11 +6,11 @@ export let showResetToDefault = true; -
+
{#if showResetToDefault} diff --git a/web/src/lib/components/admin-page/settings/setting-input-field.svelte b/web/src/lib/components/admin-page/settings/setting-input-field.svelte index f45ff08f9b..5319d7cf5c 100644 --- a/web/src/lib/components/admin-page/settings/setting-input-field.svelte +++ b/web/src/lib/components/admin-page/settings/setting-input-field.svelte @@ -12,19 +12,19 @@ export let inputType: SettingInputFieldType; export let value: string; - export let label: string; + export let label = ''; export let required = false; export let disabled = false; - export let isEdited: boolean; + export let isEdited = false; const handleInput = (e: Event) => { value = (e.target as HTMLInputElement).value; }; -
-
- +
+
+ {#if required}
*
{/if} @@ -32,14 +32,14 @@ {#if isEdited}
Unsaved change
{/if}
-

+

{title.toUpperCase()}

diff --git a/web/src/lib/components/admin-page/settings/storate-template/storage-template-settings.svelte b/web/src/lib/components/admin-page/settings/storate-template/storage-template-settings.svelte new file mode 100644 index 0000000000..b21924a876 --- /dev/null +++ b/web/src/lib/components/admin-page/settings/storate-template/storage-template-settings.svelte @@ -0,0 +1,227 @@ + + +
+ {#await getConfigs() then} +
+

+ Variables +

+ +
+ {#await getSupportDateTimeFormat()} + + {:then options} +
+ +
+ {/await} +
+ +
+ +
+ +
+

+ Template +

+ +
+

PREVIEW

+
+ +

+ Approximately path length limit : {parsedTemplate().length + user.id.length + 'UPLOAD_LOCATION'.length}/260 +

+ +

+ {user.id} is the user's ID +

+ +

+ UPLOAD_LOCATION/{user.id}/{parsedTemplate()}.jpeg +

+ +
+
+ + +
+
+ + +
+ +
+
+ + + +
+
+ {/await} +
diff --git a/web/src/lib/components/admin-page/settings/storate-template/supported-datetime-panel.svelte b/web/src/lib/components/admin-page/settings/storate-template/supported-datetime-panel.svelte new file mode 100644 index 0000000000..14c817b1ed --- /dev/null +++ b/web/src/lib/components/admin-page/settings/storate-template/supported-datetime-panel.svelte @@ -0,0 +1,78 @@ + + +
+

DATE & TIME

+
+ +
+
+

Asset's creation timestamp is used for the datetime information

+

Sample time 2022-09-04T20:03:05.250

+
+
+
+

YEAR

+
    + {#each options.yearOptions as yearFormat} +
  • {'{{'}{yearFormat}{'}}'} - {getLuxonExample(yearFormat)}
  • + {/each} +
+
+ +
+

MONTH

+
    + {#each options.monthOptions as monthFormat} +
  • {'{{'}{monthFormat}{'}}'} - {getLuxonExample(monthFormat)}
  • + {/each} +
+
+ +
+

DAY

+
    + {#each options.dayOptions as dayFormat} +
  • {'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}
  • + {/each} +
+
+ +
+

HOUR

+
    + {#each options.hourOptions as dayFormat} +
  • {'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}
  • + {/each} +
+
+ +
+

MINUTE

+
    + {#each options.minuteOptions as dayFormat} +
  • {'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}
  • + {/each} +
+
+ +
+

SECOND

+
    + {#each options.secondOptions as dayFormat} +
  • {'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}
  • + {/each} +
+
+
+
diff --git a/web/src/lib/components/admin-page/settings/storate-template/supported-variables-panel.svelte b/web/src/lib/components/admin-page/settings/storate-template/supported-variables-panel.svelte new file mode 100644 index 0000000000..1eb252e0ac --- /dev/null +++ b/web/src/lib/components/admin-page/settings/storate-template/supported-variables-panel.svelte @@ -0,0 +1,21 @@ +
+

OTHER VARIABLES

+
+ +
+
+
+

FILE NAME

+
    +
  • {`{{filename}}`}
  • +
+
+ +
+

FILE EXTENSION

+
    +
  • {`{{ext}}`}
  • +
+
+
+
diff --git a/web/src/routes/admin/settings/+page.svelte b/web/src/routes/admin/settings/+page.svelte index c236ab4c24..4698e84c87 100644 --- a/web/src/routes/admin/settings/+page.svelte +++ b/web/src/routes/admin/settings/+page.svelte @@ -2,11 +2,13 @@ import FFmpegSettings from '$lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte'; import OAuthSettings from '$lib/components/admin-page/settings/oauth/oauth-settings.svelte'; import SettingAccordion from '$lib/components/admin-page/settings/setting-accordion.svelte'; + import StorageTemplateSettings from '$lib/components/admin-page/settings/storate-template/storage-template-settings.svelte'; import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; import { api, SystemConfigDto } from '@api'; + import type { PageData } from './$types'; let systemConfig: SystemConfigDto; - + export let data: PageData; const getConfig = async () => { const { data } = await api.systemConfigApi.getConfig(); systemConfig = data; @@ -33,5 +35,12 @@ + + + + {/await} diff --git a/web/src/routes/admin/user-management/+page.svelte b/web/src/routes/admin/user-management/+page.svelte index a5cb6de6ea..6efc6c6966 100644 --- a/web/src/routes/admin/user-management/+page.svelte +++ b/web/src/routes/admin/user-management/+page.svelte @@ -22,7 +22,6 @@ onMount(() => { allUsers = $page.data.allUsers; - console.log('getting all users', allUsers); }); const isDeleted = (user: UserResponseDto): boolean => {