From e171fec5aa0674b3f7f3c1d56bf7bd4716e082b4 Mon Sep 17 00:00:00 2001
From: Alex Phillips
Date: Wed, 21 Jun 2023 22:33:20 -0400
Subject: [PATCH] feat(server): support for read-only assets and importing
existing items in the filesystem (#2715)
* Added read-only flag for assets, endpoint to trigger file import vs upload
* updated fixtures with new property
* if upload is 'read-only', ensure there is no existing asset at the designated originalPath
* added test for file import as well as detecting existing image at read-only destination location
* Added storage service test for a case where it should not move read-only assets
* upload doesn't need the read-only flag available, just importing
* default isReadOnly on import endpoint to true
* formatting fixes
* create-asset dto needs isReadOnly, so set it to false by default on create, updated api generation
* updated code to reflect changes in MR
* fixed read stream promise return type
* new index for originalPath, check for existing path on import, reglardless of user, to prevent duplicates
* refactor: import asset
* chore: open api
* chore: tests
* Added externalPath support for individual users, updated UI to allow this to be set by admin
* added missing var for externalPath in ui
* chore: open api
* fix: compilation issues
* fix: server test
* built api, fixed user-response dto to include externalPath
* reverted accidental commit
* bad commit of duplicate externalPath in user response dto
* fixed tests to include externalPath on expected result
* fix: unit tests
* centralized supported filetypes, perform file type checking of asset and sidecar during file import process
* centralized supported filetype check method to keep regex DRY
* fixed typo
* combined migrations into one
* update api
* Removed externalPath from shared-link code, added column to admin user page whether external paths / import is enabled or not
* update mimetype
* Fixed detect correct mimetype
* revert asset-upload config
* reverted domain.constant
* refactor
* fix mime-type issue
* fix format
---------
Co-authored-by: Jason Rasmussen
Co-authored-by: Alex Tran
---
mobile/openapi/.openapi-generator/FILES | 3 +
mobile/openapi/README.md | Bin 17712 -> 17843 bytes
mobile/openapi/doc/AssetApi.md | Bin 53651 -> 55854 bytes
mobile/openapi/doc/CreateUserDto.md | Bin 552 -> 599 bytes
mobile/openapi/doc/ImportAssetDto.md | Bin 0 -> 917 bytes
mobile/openapi/doc/UpdateUserDto.md | Bin 715 -> 762 bytes
mobile/openapi/doc/UserResponseDto.md | Bin 829 -> 865 bytes
mobile/openapi/lib/api.dart | Bin 5570 -> 5606 bytes
mobile/openapi/lib/api/asset_api.dart | Bin 48784 -> 50688 bytes
mobile/openapi/lib/api_client.dart | Bin 17732 -> 17812 bytes
mobile/openapi/lib/model/create_user_dto.dart | Bin 4365 -> 4755 bytes
.../openapi/lib/model/import_asset_dto.dart | Bin 0 -> 8111 bytes
mobile/openapi/lib/model/update_user_dto.dart | Bin 8138 -> 8877 bytes
.../openapi/lib/model/user_response_dto.dart | Bin 6520 -> 6939 bytes
mobile/openapi/test/asset_api_test.dart | Bin 5183 -> 5351 bytes
mobile/openapi/test/create_user_dto_test.dart | Bin 971 -> 1080 bytes
.../openapi/test/import_asset_dto_test.dart | Bin 0 -> 1758 bytes
mobile/openapi/test/update_user_dto_test.dart | Bin 1280 -> 1389 bytes
.../openapi/test/user_response_dto_test.dart | Bin 1716 -> 1825 bytes
server/e2e/user.e2e-spec.ts | 3 +
server/immich-openapi-specs.json | 121 ++++++++-
server/package-lock.json | 12 +
server/package.json | 2 +
server/src/domain/album/album.service.spec.ts | 1 +
server/src/domain/api-key/api-key.core.ts | 1 +
server/src/domain/auth/dto/auth-user.dto.ts | 1 +
server/src/domain/crypto/crypto.repository.ts | 1 +
server/src/domain/domain.constant.ts | 57 ++++
.../domain/partner/partner.service.spec.ts | 2 +
.../storage-template.service.spec.ts | 21 ++
.../storage-template.service.ts | 5 +
server/src/domain/user/dto/create-user.dto.ts | 4 +
server/src/domain/user/dto/update-user.dto.ts | 4 +
.../user/response-dto/user-response.dto.ts | 2 +
server/src/domain/user/user.core.ts | 8 +-
server/src/domain/user/user.service.spec.ts | 5 +
.../immich/api-v1/album/album.service.spec.ts | 1 +
.../immich/api-v1/asset/asset-repository.ts | 18 ++
.../immich/api-v1/asset/asset.controller.ts | 16 +-
server/src/immich/api-v1/asset/asset.core.ts | 9 +-
.../immich/api-v1/asset/asset.service.spec.ts | 46 +++-
.../src/immich/api-v1/asset/asset.service.ts | 103 +++++++-
.../api-v1/asset/dto/create-asset.dto.ts | 35 ++-
.../src/immich/config/asset-upload.config.ts | 54 +---
server/src/infra/entities/asset.entity.ts | 5 +-
server/src/infra/entities/user.entity.ts | 3 +
.../migrations/1686584273471-ImportAsset.ts | 18 ++
.../infra/repositories/crypto.repository.ts | 11 +
server/test/fixtures.ts | 26 ++
.../repositories/crypto.repository.mock.ts | 1 +
web/src/api/open-api/api.ts | 245 ++++++++++++++++--
.../components/forms/edit-user-form.svelte | 21 +-
.../user-profile-settings.svelte | 8 +
.../routes/admin/user-management/+page.svelte | 12 +
web/src/test-data/factories/user-factory.ts | 1 +
55 files changed, 779 insertions(+), 107 deletions(-)
create mode 100644 mobile/openapi/doc/ImportAssetDto.md
create mode 100644 mobile/openapi/lib/model/import_asset_dto.dart
create mode 100644 mobile/openapi/test/import_asset_dto_test.dart
create mode 100644 server/src/infra/migrations/1686584273471-ImportAsset.ts
diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES
index 6ec8dd5643..e351e3c652 100644
--- a/mobile/openapi/.openapi-generator/FILES
+++ b/mobile/openapi/.openapi-generator/FILES
@@ -49,6 +49,7 @@ doc/DownloadFilesDto.md
doc/ExifResponseDto.md
doc/GetAssetByTimeBucketDto.md
doc/GetAssetCountByTimeBucketDto.md
+doc/ImportAssetDto.md
doc/JobApi.md
doc/JobCommand.md
doc/JobCommandDto.md
@@ -181,6 +182,7 @@ lib/model/download_files_dto.dart
lib/model/exif_response_dto.dart
lib/model/get_asset_by_time_bucket_dto.dart
lib/model/get_asset_count_by_time_bucket_dto.dart
+lib/model/import_asset_dto.dart
lib/model/job_command.dart
lib/model/job_command_dto.dart
lib/model/job_counts_dto.dart
@@ -284,6 +286,7 @@ test/download_files_dto_test.dart
test/exif_response_dto_test.dart
test/get_asset_by_time_bucket_dto_test.dart
test/get_asset_count_by_time_bucket_dto_test.dart
+test/import_asset_dto_test.dart
test/job_api_test.dart
test/job_command_dto_test.dart
test/job_command_test.dart
diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md
index 1ae93da9ae83898ed4f8f0591c82b56937903ac8..1e78809c3b4f065683a9f3f4913f471e5b4558e5 100644
GIT binary patch
delta 90
zcmdnc#kjefal;)kuFTwm{Gt-K%$(FoBBD@U8jv^nppXa~L~^pCxY*`5)1J|e*j
z@{VgoNornkW`5peha+y2Jr(doaASm?4_?T
zInSPL^6^s@2xT9(%S^WNP@0^0dMW`EH)otlVVvA>PIdB`vs)&=ic+3@;#?uZAjk8v
gLeVLyX^EvdB?`bmN=wWsPK}+s-%EdU(s@HB06V*_umAu6
delta 127
zcmZ3tg?aK~<_&wl5=<`rk==S`k@MRoG2D{`Az
z&Ni`ZK5&tradO*5tI7TP3X>37!w0qy4y6HgJoVc+UDHxk`9!r~&T!q1Y{4P6;*C4;->t`E3LkN|}me4gEO&l>IJ9s#G5M|kKu~16K
z7NWdTjGil%dWwlr)746q{i8%)n*rnaWiEypX!G3I&qP*g>6}f=DCN>QwJqh`z)E+P
zBsodGgK}SYs%gEWzL3Y}eNSdUKm(Qb7a<4wj5e^r3er)>=91(Vba{V;7VbT00#i^D
zVLyf(zGBV(l>Pns$c4yyat2rUFB*miy>T9C>-@2K$=t$MYuv!ToeSnB=oMM-K=USdu_VoAp4IL7;o0DyG~zyJUM
delta 12
TcmaFJwwG-~8RO)PBC7=G
diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart
index 613084b09f5f3ff9a38f9c13539fa65d93e756b0..9363e99b1d6bbf5d8cf023f2ca317e6902337a54 100644
GIT binary patch
delta 27
jcmX@4{Y-nqT5h(?+=Bd~lF5yHa+BY4vu)nY&Cdk@p+*X2
delta 16
YcmaE+eMo!5TJFgoxVbmq;pXB30769ut^fc4
diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart
index 9155c55b34d265ca8f48566638fbea297d3f75c5..d8d03ca53383a2509dca8b72311de84383204ccb 100644
GIT binary patch
delta 492
zcmbR6m#LwLdBgO{teLq5`9+f-NO*~Pf>Rm?XM-I<3uk6ARPp4A>x3se
zY*pcci&Rc7+$xUDHLMq%th%*n^U94C%$t{O{mw{?}pX~Xo%n|Ws~5t}U6%QpGvzE_hE?K7M_ftP*qtNpo?7ud*8o_@e|@|ha0
z$wxN@Om;q)G?{O+*yP^Lfs;87*-X}VvfrG&lh5vG+k9rvH%2^0O+z!H
hVe<4tF1%@(IjOD{C8>GEnfZB>Zys{rY;pJm69DqbPSOAX
diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart
index 65f3bb544fc5cb4449cbda873f60699eb48113f0..9deee81b7c8dbaf092e862624aa6192eeae159e4 100644
GIT binary patch
delta 45
wcmX@o#Ww`Xoaeo={IadB$NWJ4KoHVA*Ryt2mReod~;PMSLPGlf95}VVR#2B>|
z)YP~XKp?*)Bee)cr-H35iiXLx%rEs7w86?XkWB(9%qz{wQLtAqP_RPP6V0+rMh8X6
zRs~t_W=7UV#`?@O1q}?BD=0#Y)l{g4I8h<1I6p7CNF7q>6TxH24sRTxr|Nh+Z
zMI>TIxXx
zR^GF%T5KAPkNF=Y=8voxgiNBglh0LWICeQ0iX;$flC~!lu6kHl|HMm(RDy|k~
zUELy@Ua@NN-K#~uVp8qx;dUD8CDa>Uv5lDGzwh_<@{%b9b0hz$Qe2rhXEH!jl7iofoSOs93rjYHFuX>lek(cCyg1cE#Dws^F31`?Fob%?
z?rJG?de~E$uf!d95;+b0A(Xf&W5lA7OpCgry&orNB>;02Qr>yhtf5tPxf^Y$Q5{)LT%ix+x1-u{#booL7icy{wwD
zWGLi5d=gd8(V!gEhUch)+=vRK6UV%$xgyr+AbHso9Q~)@OV*Sc?pWDy7&nT9BPK80
z%F&Bff=LFe`kvo$nHiK@UUAe1FC2SSHxfn2YvjCcl!hfMOSobAXF%UYxy`}BiwqjI
zMlMR{ev9FRo+E)3g+&_>0Oj6sK(hhT<1(s@wC5Njd#mvih&(g4MBg7=v<=K5
z0N;FrjPcL#2pt1*4>hlY6bc_+n-gjZ7($*G0S}*3
zl%(Y#ycEz!4h6ttN01af^df{By>-!iGP7vyTNlkmpvCepDU$aY!tu{N{}GjjTduKU
ztJPa7=CfmC{I>6=cDv#^SBl9kwae&DE3SkxXydjCntBcs_>Wx*UeI4NWMupPQfzOO
zoH__Nfmd)8d%}L7E;0VD<$2LxNvu4)9L(BZ54IdyYYmtlB^0fJlL+Mv+9h)E0UB>$
zGIpdg4~{5$F5tRUUE7DT$r_{dE^*b`d3Mk~W>KKe4uZL}(D6EKvUC>PZ*~xwIZFd~
zc959Tug(jygp!d$#$(Die{mc+nL3F?Ov#tGYt%+H2WTRIG$Q5rmhn9`?iI_cjL`j4L^6l|H(r
zOImW3>!1ch1l$1YAh&@Sa9fZ?6-zLcV;Wfu_N|xs!+;kv!U+r`pk?_8yu^#ogF7PT
z8qx!vGQu?EVW$La30u+Z+PvsuQ`2wCofjhUrGTA)r@H=K=i>?f&Q<;0
zx36CN;2B^RJTK)I;Lm0koHeitFuQkau;9Tv?G;dBe%|pdzn#|V5O<9g$>H64Go(9Z@DaLG6CNQ*;2}>4;L!&6mu7flJd4;o(iDNNz|S1&0ILcRW-g>a
zXl1YN3YM~7J5E>THji_lB_fPyOmv0m=Q^`hz_f7pLb)rlo24D$HJ>kWI%Ni1vdOV`
zR14}V7dYp_W^sWdu~l7AjkkbKi+ExrF!{{oH!GRX2V{CXWx(zd#qn^!yd9-IgEGyT
z(!B&Ywi6c$OjOr-r5Z^Wex}V(V9ho-dZ;wR>6ZbtbEVIGt7shH2tVL-0A3#%9J3oL
z2s@aOhJ3~I&%6K}E0XM@5IARC(*pTzmT{n!a2)V<1q>!G(~8Rl(OU0pBG6CVg{p+Zk7p1
z2roJn`v8I0FR*mMS|~H>A+Ld-KV5v7j!?VA-~L6rToI9vk!S9JjUNpssZVn@ull>t
z1kLZ~f`TTE9{4z(1cSTv^gQF1+|(zU!X2dpxhb8`_@t{QE#l~UvTsL-Btoc#M}Us>
z2Tj8+mc;M}x=kIAi97>23W%c#oSa0XQDS4bQ)AQPVoO2b(_)TdF``=cB!>uhGzlk3
z%H5Mp66{12UmBmu0&8e|kfbmg7Jh)%YBE6?po{kGAVaZKY|6T~Ixm*l=OvOQ5MWE_?qfFvX_vbPOc|
z-}#X7Y25n$gBdH_%KDz>&De0_bXW20(Pt-{#U)oXPR91N!}{vl#x<1zVn|7dM8NyD
zDTPy^FfpI6H#*99l!(WUaeYt8lt}%K6mfRZ=9pQhB8A{bp`%3nXN~@#&{$8-^8RyN_eh8Bco7JGdim6MD1SS*ov8`p)Yu;
z1zuVtq;WkOeWbuIF7&w%c_C*PiSHv%7>decLZ^j6pUB{Ok%>HFZyDU^-}uZk-QhW|
zWuoezc@CZ`HE(KzX&I|6a-ggY%n+hk>s15sjT=SIa0r+9jf|L+-F@dqJs0(!*pZs=
zUpJhZYK7a9^E2B41-qM0yCwdGXxnf;elW_2?)bLETL85fBL_%Jcml+t?A|-Y*77D`
RTt6)|lubj7LkA3i{{hbtJbwTH
literal 0
HcmV?d00001
diff --git a/mobile/openapi/lib/model/update_user_dto.dart b/mobile/openapi/lib/model/update_user_dto.dart
index 570eaaa7c39c8ccd63aa53cdff58cc088c293883..1a77bd909226c38c318ae656ad030a8b85d67bc3 100644
GIT binary patch
delta 275
zcmX?Qzt(lbcSfGnijvf#yu_S<#FC87Zx|nQhy<4uW#*;ZE1<|2G9@u?KF@WONd{S1
z!PXX4Su5{;Ms;)*d8Iiy3ib*H3Rb9!4f(YgrH~cdsvz?=M+(?7qquK!pOB;yvJ&-J
n1zUxZjLc#^WYNh7h2&B6ZLSdh&Wa*3*c
zg^NiYT}57LPL6`Tf`Nh+s^V8{O^i~=ifvVpd7HC1w3$RR(-bsHGBS(xkfkQ~a!V>9
q3#i8`*eYNuyUQ();STs>?kB9kBTE20Q&68yl9A~5-d!~_7gfNTK(
delta 46
zcmV+}0MY-OHuy5I7Xq_40yhD(hXb?$vt|YR0kfM2ZvnF=2`K`z-wSsJvzrp+2a}T;
Eh>E8W^Z)<=
diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart
index e404fd846b73087707ba6a1aea2880efdf882ddb..1a2e510cf9eebb2f8a27a0602cd724810f6b2350 100644
GIT binary patch
delta 92
zcmdn5@mzDmHi5|tg=7>ma|`l|O58GYQZ+omOvmEl)DoAHe5lOi_X6^45Ji*uM6@Sy
lDQ`X}V9Yc*OPGK1FQLrImBPLpL8*x;{&_i-llKZY0RVonBIN)8
delta 36
scmaE^xnE<$Hi60dJgk!ixP&*S3K}y_UMHM1d7`k`WC0Q1$$27`0PAiG^Z)<=
diff --git a/mobile/openapi/test/create_user_dto_test.dart b/mobile/openapi/test/create_user_dto_test.dart
index b38665fd3a2272345a217ad516efcf3fd30f2a50..327acbbe38c4ac70a6b58fbf7196011c6d639e07 100644
GIT binary patch
delta 56
zcmX@jzJp`KLFUQtnfQ28D@sy}@)C0b5=$~B&u3CZ70_c=
delta 11
ScmdnNahiR@LFUPNEL;E^>jTyR
diff --git a/mobile/openapi/test/import_asset_dto_test.dart b/mobile/openapi/test/import_asset_dto_test.dart
new file mode 100644
index 0000000000000000000000000000000000000000..ca7526cc24342bd0517f9d89e011e77aee5ae863
GIT binary patch
literal 1758
zcmbW1L2ueX5QXpl71I+Sk+{vNN))N6h6<^PtJICulQZ!SFzR~O-Ca|mD*wH^3p7oN
zWbgswL2uqS&&)balQf0Nw<16LG`*ePO^Rs-v-#)g7_uC0^8#-2Y(D$`#>;~8gFxf1
zFV5dxB%$P`(uT@~HsyjPT|%o`OQIx6H7PgV*IYN+xPu)if8oZKP3uZSKU+4wU19C7
z4I2OL$XIV~?BVuGE9+3Xl7$EIg2}{odk`jBku9ZGOD}UPROS1(WwkvI{
z;F2zNLkb&C{pUQS9$ft=ab#T@E_*?MhT4LEBruPuC)Se7oKDu71=PrDID+2^fY&Zq
zp#+W*DuU}*EyK}O;)P42+h#Zj#;*i}G3?BuT|^a|hNh~m0RuQx-U8xS-b2S5a4Ukw
z_=v`B;Nh?GVQYwXqpCP#==5_Id6q|)r|m@X$49OG?DmdO&1aYvKp
z;v`6hjtZ{HFS?|UhI}a|t~1pOb${TE7U6|(Eb&=>5^{G8X1LadUH4&QyO;LCOiZ=n
rHFa+W+r6|8=AJD-%45RplqW%UhgY-)9c$IO4}0Np5N{V>Q%}hsb1D{!
literal 0
HcmV?d00001
diff --git a/mobile/openapi/test/update_user_dto_test.dart b/mobile/openapi/test/update_user_dto_test.dart
index 4ff0bc8b63f8d1e8729edb411a17aa970acb8153..5e89eca18f956579d836dba7130a8c811b4592bb 100644
GIT binary patch
delta 52
ycmZqRddsyzk!7+!GdoXeMM-K=USdu_VoAni112SIWc~#fwaKzf?2{E)?gIeOt`V64
delta 15
XcmaFM)xfnuk!A9G7S_p=S#ALUF0BRT
diff --git a/mobile/openapi/test/user_response_dto_test.dart b/mobile/openapi/test/user_response_dto_test.dart
index ae05daf6084b0508ac6f54c50ac8d3f71f3c9a72..f5c70f21ff63047167c1641cf57f370e0f1167d8 100644
GIT binary patch
delta 46
qcmdnOyO3|gNoJnZijvf#yu_S<#FC830W6BB0*Y)Vn@=*oW&!}2hY?2r
delta 12
TcmZ3;w}p4ZN#@OFEU%dWAo~Qz
diff --git a/server/e2e/user.e2e-spec.ts b/server/e2e/user.e2e-spec.ts
index d74626cb3a..2f3ead59a3 100644
--- a/server/e2e/user.e2e-spec.ts
+++ b/server/e2e/user.e2e-spec.ts
@@ -105,6 +105,7 @@ describe('User', () => {
updatedAt: expect.anything(),
oauthId: '',
storageLabel: null,
+ externalPath: null,
},
{
email: userTwoEmail,
@@ -119,6 +120,7 @@ describe('User', () => {
updatedAt: expect.anything(),
oauthId: '',
storageLabel: null,
+ externalPath: null,
},
{
email: authUserEmail,
@@ -133,6 +135,7 @@ describe('User', () => {
updatedAt: expect.anything(),
oauthId: '',
storageLabel: 'admin',
+ externalPath: null,
},
]),
);
diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json
index cdf814eec9..bd87580b23 100644
--- a/server/immich-openapi-specs.json
+++ b/server/immich-openapi-specs.json
@@ -1430,6 +1430,48 @@
]
}
},
+ "/asset/import": {
+ "post": {
+ "operationId": "importFile",
+ "parameters": [],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ImportAssetDto"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AssetFileUploadResponseDto"
+ }
+ }
+ }
+ }
+ },
+ "tags": [
+ "Asset"
+ ],
+ "security": [
+ {
+ "bearer": []
+ },
+ {
+ "cookie": []
+ },
+ {
+ "api_key": []
+ }
+ ]
+ }
+ },
"/asset/map-marker": {
"get": {
"operationId": "getMapMarkers",
@@ -5085,6 +5127,13 @@
"type": "string",
"format": "binary"
},
+ "isReadOnly": {
+ "type": "boolean",
+ "default": false
+ },
+ "fileExtension": {
+ "type": "string"
+ },
"deviceAssetId": {
"type": "string"
},
@@ -5108,9 +5157,6 @@
"isVisible": {
"type": "boolean"
},
- "fileExtension": {
- "type": "string"
- },
"duration": {
"type": "string"
}
@@ -5118,12 +5164,12 @@
"required": [
"assetType",
"assetData",
+ "fileExtension",
"deviceAssetId",
"deviceId",
"fileCreatedAt",
"fileModifiedAt",
- "isFavorite",
- "fileExtension"
+ "isFavorite"
]
},
"CreateProfileImageDto": {
@@ -5186,6 +5232,10 @@
"storageLabel": {
"type": "string",
"nullable": true
+ },
+ "externalPath": {
+ "type": "string",
+ "nullable": true
}
},
"required": [
@@ -5461,6 +5511,59 @@
"timeGroup"
]
},
+ "ImportAssetDto": {
+ "type": "object",
+ "properties": {
+ "assetType": {
+ "$ref": "#/components/schemas/AssetTypeEnum"
+ },
+ "isReadOnly": {
+ "type": "boolean",
+ "default": true
+ },
+ "assetPath": {
+ "type": "string"
+ },
+ "sidecarPath": {
+ "type": "string"
+ },
+ "deviceAssetId": {
+ "type": "string"
+ },
+ "deviceId": {
+ "type": "string"
+ },
+ "fileCreatedAt": {
+ "format": "date-time",
+ "type": "string"
+ },
+ "fileModifiedAt": {
+ "format": "date-time",
+ "type": "string"
+ },
+ "isFavorite": {
+ "type": "boolean"
+ },
+ "isArchived": {
+ "type": "boolean"
+ },
+ "isVisible": {
+ "type": "boolean"
+ },
+ "duration": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "assetType",
+ "assetPath",
+ "deviceAssetId",
+ "deviceId",
+ "fileCreatedAt",
+ "fileModifiedAt",
+ "isFavorite"
+ ]
+ },
"JobCommand": {
"type": "string",
"enum": [
@@ -6592,6 +6695,9 @@
"storageLabel": {
"type": "string"
},
+ "externalPath": {
+ "type": "string"
+ },
"isAdmin": {
"type": "boolean"
},
@@ -6665,6 +6771,10 @@
"type": "string",
"nullable": true
},
+ "externalPath": {
+ "type": "string",
+ "nullable": true
+ },
"profileImagePath": {
"type": "string"
},
@@ -6697,6 +6807,7 @@
"firstName",
"lastName",
"storageLabel",
+ "externalPath",
"profileImagePath",
"shouldChangePassword",
"isAdmin",
diff --git a/server/package-lock.json b/server/package-lock.json
index 664345d087..2bb83a4221 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -21,6 +21,7 @@
"@nestjs/typeorm": "^9.0.1",
"@nestjs/websockets": "^9.2.1",
"@socket.io/redis-adapter": "^8.0.1",
+ "@types/mime-types": "^2.1.1",
"archiver": "^5.3.1",
"axios": "^0.26.0",
"bcrypt": "^5.0.1",
@@ -38,6 +39,7 @@
"local-reverse-geocoder": "0.12.5",
"lodash": "^4.17.21",
"luxon": "^3.0.3",
+ "mime-types": "^2.1.35",
"mv": "^2.1.1",
"nest-commander": "^3.3.0",
"openid-client": "^5.2.1",
@@ -3018,6 +3020,11 @@
"integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
"dev": true
},
+ "node_modules/@types/mime-types": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.1.tgz",
+ "integrity": "sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw=="
+ },
"node_modules/@types/multer": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz",
@@ -14296,6 +14303,11 @@
"integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
"dev": true
},
+ "@types/mime-types": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.1.tgz",
+ "integrity": "sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw=="
+ },
"@types/multer": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz",
diff --git a/server/package.json b/server/package.json
index de89b46ef7..235caffdfe 100644
--- a/server/package.json
+++ b/server/package.json
@@ -50,6 +50,7 @@
"@nestjs/typeorm": "^9.0.1",
"@nestjs/websockets": "^9.2.1",
"@socket.io/redis-adapter": "^8.0.1",
+ "@types/mime-types": "^2.1.1",
"archiver": "^5.3.1",
"axios": "^0.26.0",
"bcrypt": "^5.0.1",
@@ -67,6 +68,7 @@
"local-reverse-geocoder": "0.12.5",
"lodash": "^4.17.21",
"luxon": "^3.0.3",
+ "mime-types": "^2.1.35",
"mv": "^2.1.1",
"nest-commander": "^3.3.0",
"openid-client": "^5.2.1",
diff --git a/server/src/domain/album/album.service.spec.ts b/server/src/domain/album/album.service.spec.ts
index 67e031db55..0b4f42a43a 100644
--- a/server/src/domain/album/album.service.spec.ts
+++ b/server/src/domain/album/album.service.spec.ts
@@ -169,6 +169,7 @@ describe(AlbumService.name, () => {
createdAt: new Date('2021-01-01'),
deletedAt: null,
updatedAt: new Date('2021-01-01'),
+ externalPath: null,
},
ownerId: 'admin_id',
shared: false,
diff --git a/server/src/domain/api-key/api-key.core.ts b/server/src/domain/api-key/api-key.core.ts
index f70b3fee57..1b075a9c09 100644
--- a/server/src/domain/api-key/api-key.core.ts
+++ b/server/src/domain/api-key/api-key.core.ts
@@ -19,6 +19,7 @@ export class APIKeyCore {
isAdmin: user.isAdmin,
isPublicUser: false,
isAllowUpload: true,
+ externalPath: user.externalPath,
};
}
diff --git a/server/src/domain/auth/dto/auth-user.dto.ts b/server/src/domain/auth/dto/auth-user.dto.ts
index 9af777e7b0..0f2c9e41d3 100644
--- a/server/src/domain/auth/dto/auth-user.dto.ts
+++ b/server/src/domain/auth/dto/auth-user.dto.ts
@@ -8,4 +8,5 @@ export class AuthUserDto {
isAllowDownload?: boolean;
isShowExif?: boolean;
accessTokenId?: string;
+ externalPath?: string | null;
}
diff --git a/server/src/domain/crypto/crypto.repository.ts b/server/src/domain/crypto/crypto.repository.ts
index d400b017da..67bacfb1e1 100644
--- a/server/src/domain/crypto/crypto.repository.ts
+++ b/server/src/domain/crypto/crypto.repository.ts
@@ -2,6 +2,7 @@ export const ICryptoRepository = 'ICryptoRepository';
export interface ICryptoRepository {
randomBytes(size: number): Buffer;
+ hashFile(filePath: string): Promise;
hashSha256(data: string): string;
hashBcrypt(data: string | Buffer, saltOrRounds: string | number): Promise;
compareBcrypt(data: string | Buffer, encrypted: string): boolean;
diff --git a/server/src/domain/domain.constant.ts b/server/src/domain/domain.constant.ts
index 3aec785d7e..a2bb8238a1 100644
--- a/server/src/domain/domain.constant.ts
+++ b/server/src/domain/domain.constant.ts
@@ -27,3 +27,60 @@ export function assertMachineLearningEnabled() {
throw new BadRequestException('Machine learning is not enabled.');
}
}
+
+const validMimeTypes = [
+ 'image/avif',
+ 'image/gif',
+ 'image/heic',
+ 'image/heif',
+ 'image/jpeg',
+ 'image/jxl',
+ 'image/png',
+ 'image/tiff',
+ 'image/webp',
+ 'image/x-adobe-dng',
+ 'image/x-arriflex-ari',
+ 'image/x-canon-cr2',
+ 'image/x-canon-cr3',
+ 'image/x-canon-crw',
+ 'image/x-epson-erf',
+ 'image/x-fuji-raf',
+ 'image/x-hasselblad-3fr',
+ 'image/x-hasselblad-fff',
+ 'image/x-kodak-dcr',
+ 'image/x-kodak-k25',
+ 'image/x-kodak-kdc',
+ 'image/x-leica-rwl',
+ 'image/x-minolta-mrw',
+ 'image/x-nikon-nef',
+ 'image/x-olympus-orf',
+ 'image/x-olympus-ori',
+ 'image/x-panasonic-raw',
+ 'image/x-pentax-pef',
+ 'image/x-phantom-cin',
+ 'image/x-phaseone-cap',
+ 'image/x-phaseone-iiq',
+ 'image/x-samsung-srw',
+ 'image/x-sigma-x3f',
+ 'image/x-sony-arw',
+ 'image/x-sony-sr2',
+ 'image/x-sony-srf',
+ 'video/3gpp',
+ 'video/mp2t',
+ 'video/mp4',
+ 'video/mpeg',
+ 'video/quicktime',
+ 'video/webm',
+ 'video/x-flv',
+ 'video/x-matroska',
+ 'video/x-ms-wmv',
+ 'video/x-msvideo',
+];
+
+export function isSupportedFileType(mimetype: string): boolean {
+ return validMimeTypes.includes(mimetype);
+}
+
+export function isSidecarFileType(mimeType: string): boolean {
+ return ['application/xml', 'text/xml'].includes(mimeType);
+}
diff --git a/server/src/domain/partner/partner.service.spec.ts b/server/src/domain/partner/partner.service.spec.ts
index 2422d20ce8..c8e0489695 100644
--- a/server/src/domain/partner/partner.service.spec.ts
+++ b/server/src/domain/partner/partner.service.spec.ts
@@ -17,6 +17,7 @@ const responseDto = {
createdAt: new Date('2021-01-01'),
deletedAt: null,
updatedAt: new Date('2021-01-01'),
+ externalPath: null,
},
user1: {
email: 'immich@test.com',
@@ -31,6 +32,7 @@ const responseDto = {
createdAt: new Date('2021-01-01'),
deletedAt: null,
updatedAt: new Date('2021-01-01'),
+ externalPath: null,
},
};
diff --git a/server/src/domain/storage-template/storage-template.service.spec.ts b/server/src/domain/storage-template/storage-template.service.spec.ts
index 812ccc7e36..8c6a8ebc5f 100644
--- a/server/src/domain/storage-template/storage-template.service.spec.ts
+++ b/server/src/domain/storage-template/storage-template.service.spec.ts
@@ -194,5 +194,26 @@ describe(StorageTemplateService.name, () => {
['upload/library/user-id/2023/2023-02-23/asset-id.ext', '/original/path.ext'],
]);
});
+
+ it('should not move read-only asset', async () => {
+ assetMock.getAll.mockResolvedValue({
+ items: [
+ {
+ ...assetEntityStub.image,
+ originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.ext',
+ isReadOnly: true,
+ },
+ ],
+ hasNextPage: false,
+ });
+ assetMock.save.mockResolvedValue(assetEntityStub.image);
+ userMock.getList.mockResolvedValue([userEntityStub.user1]);
+
+ await sut.handleMigration();
+
+ expect(assetMock.getAll).toHaveBeenCalled();
+ expect(storageMock.moveFile).not.toHaveBeenCalled();
+ expect(assetMock.save).not.toHaveBeenCalled();
+ });
});
});
diff --git a/server/src/domain/storage-template/storage-template.service.ts b/server/src/domain/storage-template/storage-template.service.ts
index 3b7df4d98e..a38dbb633e 100644
--- a/server/src/domain/storage-template/storage-template.service.ts
+++ b/server/src/domain/storage-template/storage-template.service.ts
@@ -76,6 +76,11 @@ export class StorageTemplateService {
// TODO: use asset core (once in domain)
async moveAsset(asset: AssetEntity, metadata: MoveAssetMetadata) {
+ if (asset.isReadOnly) {
+ this.logger.verbose(`Not moving read-only asset: ${asset.originalPath}`);
+ return;
+ }
+
const destination = await this.core.getTemplatePath(asset, metadata);
if (asset.originalPath !== destination) {
const source = asset.originalPath;
diff --git a/server/src/domain/user/dto/create-user.dto.ts b/server/src/domain/user/dto/create-user.dto.ts
index 3927ffe2e6..5951be8312 100644
--- a/server/src/domain/user/dto/create-user.dto.ts
+++ b/server/src/domain/user/dto/create-user.dto.ts
@@ -23,6 +23,10 @@ export class CreateUserDto {
@IsString()
@Transform(toSanitized)
storageLabel?: string | null;
+
+ @IsOptional()
+ @IsString()
+ externalPath?: string | null;
}
export class CreateAdminDto {
diff --git a/server/src/domain/user/dto/update-user.dto.ts b/server/src/domain/user/dto/update-user.dto.ts
index 14c16acf1b..fca200ab28 100644
--- a/server/src/domain/user/dto/update-user.dto.ts
+++ b/server/src/domain/user/dto/update-user.dto.ts
@@ -29,6 +29,10 @@ export class UpdateUserDto {
@Transform(toSanitized)
storageLabel?: string;
+ @IsOptional()
+ @IsString()
+ externalPath?: string;
+
@IsNotEmpty()
@IsUUID('4')
@ApiProperty({ format: 'uuid' })
diff --git a/server/src/domain/user/response-dto/user-response.dto.ts b/server/src/domain/user/response-dto/user-response.dto.ts
index 6ad8e848c4..a2bd508837 100644
--- a/server/src/domain/user/response-dto/user-response.dto.ts
+++ b/server/src/domain/user/response-dto/user-response.dto.ts
@@ -6,6 +6,7 @@ export class UserResponseDto {
firstName!: string;
lastName!: string;
storageLabel!: string | null;
+ externalPath!: string | null;
profileImagePath!: string;
shouldChangePassword!: boolean;
isAdmin!: boolean;
@@ -22,6 +23,7 @@ export function mapUser(entity: UserEntity): UserResponseDto {
firstName: entity.firstName,
lastName: entity.lastName,
storageLabel: entity.storageLabel,
+ externalPath: entity.externalPath,
profileImagePath: entity.profileImagePath,
shouldChangePassword: entity.shouldChangePassword,
isAdmin: entity.isAdmin,
diff --git a/server/src/domain/user/user.core.ts b/server/src/domain/user/user.core.ts
index 6a986e4cb0..2b3ed40743 100644
--- a/server/src/domain/user/user.core.ts
+++ b/server/src/domain/user/user.core.ts
@@ -6,7 +6,6 @@ import {
Logger,
NotFoundException,
} from '@nestjs/common';
-import { hash } from 'bcrypt';
import { constants, createReadStream, ReadStream } from 'fs';
import fs from 'fs/promises';
import { AuthUserDto } from '../auth';
@@ -28,6 +27,7 @@ export class UserCore {
// Users can never update the isAdmin property.
delete dto.isAdmin;
delete dto.storageLabel;
+ delete dto.externalPath;
} else if (dto.isAdmin && authUser.id !== id) {
// Admin cannot create another admin.
throw new BadRequestException('The server already has an admin');
@@ -56,6 +56,10 @@ export class UserCore {
dto.storageLabel = null;
}
+ if (dto.externalPath === '') {
+ dto.externalPath = null;
+ }
+
return this.userRepository.update(id, dto);
} catch (e) {
Logger.error(e, 'Failed to update user info');
@@ -79,7 +83,7 @@ export class UserCore {
try {
const payload: Partial = { ...createUserDto };
if (payload.password) {
- payload.password = await hash(payload.password, SALT_ROUNDS);
+ payload.password = await this.cryptoRepository.hashBcrypt(payload.password, SALT_ROUNDS);
}
return this.userRepository.create(payload);
} catch (e) {
diff --git a/server/src/domain/user/user.service.spec.ts b/server/src/domain/user/user.service.spec.ts
index d4229847dd..efb5e3d5d8 100644
--- a/server/src/domain/user/user.service.spec.ts
+++ b/server/src/domain/user/user.service.spec.ts
@@ -53,6 +53,7 @@ const adminUser: UserEntity = Object.freeze({
tags: [],
assets: [],
storageLabel: 'admin',
+ externalPath: null,
});
const immichUser: UserEntity = Object.freeze({
@@ -71,6 +72,7 @@ const immichUser: UserEntity = Object.freeze({
tags: [],
assets: [],
storageLabel: null,
+ externalPath: null,
});
const updatedImmichUser: UserEntity = Object.freeze({
@@ -89,6 +91,7 @@ const updatedImmichUser: UserEntity = Object.freeze({
tags: [],
assets: [],
storageLabel: null,
+ externalPath: null,
});
const adminUserResponse = Object.freeze({
@@ -104,6 +107,7 @@ const adminUserResponse = Object.freeze({
deletedAt: null,
updatedAt: new Date('2021-01-01'),
storageLabel: 'admin',
+ externalPath: null,
});
describe(UserService.name, () => {
@@ -153,6 +157,7 @@ describe(UserService.name, () => {
deletedAt: null,
updatedAt: new Date('2021-01-01'),
storageLabel: 'admin',
+ externalPath: null,
},
]);
});
diff --git a/server/src/immich/api-v1/album/album.service.spec.ts b/server/src/immich/api-v1/album/album.service.spec.ts
index 4b5a74b5eb..77ccbb67ae 100644
--- a/server/src/immich/api-v1/album/album.service.spec.ts
+++ b/server/src/immich/api-v1/album/album.service.spec.ts
@@ -32,6 +32,7 @@ describe('Album service', () => {
tags: [],
assets: [],
storageLabel: null,
+ externalPath: null,
});
const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58';
const sharedAlbumOwnerId = '2222';
diff --git a/server/src/immich/api-v1/asset/asset-repository.ts b/server/src/immich/api-v1/asset/asset-repository.ts
index ec3e39cf88..4fd2f3c064 100644
--- a/server/src/immich/api-v1/asset/asset-repository.ts
+++ b/server/src/immich/api-v1/asset/asset-repository.ts
@@ -20,6 +20,10 @@ export interface AssetCheck {
checksum: Buffer;
}
+export interface AssetOwnerCheck extends AssetCheck {
+ ownerId: string;
+}
+
export interface IAssetRepository {
get(id: string): Promise;
create(
@@ -39,6 +43,7 @@ export interface IAssetRepository {
getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise;
getAssetsByChecksums(userId: string, checksums: Buffer[]): Promise;
getExistingAssets(userId: string, checkDuplicateAssetDto: CheckExistingAssetsDto): Promise;
+ getByOriginalPath(originalPath: string): Promise;
}
export const IAssetRepository = 'IAssetRepository';
@@ -350,4 +355,17 @@ export class AssetRepository implements IAssetRepository {
return assetCountByUserId;
}
+
+ getByOriginalPath(originalPath: string): Promise {
+ return this.assetRepository.findOne({
+ select: {
+ id: true,
+ ownerId: true,
+ checksum: true,
+ },
+ where: {
+ originalPath,
+ },
+ });
+ }
}
diff --git a/server/src/immich/api-v1/asset/asset.controller.ts b/server/src/immich/api-v1/asset/asset.controller.ts
index 1d4228c28b..6b5fc488a0 100644
--- a/server/src/immich/api-v1/asset/asset.controller.ts
+++ b/server/src/immich/api-v1/asset/asset.controller.ts
@@ -33,7 +33,7 @@ import { AssetBulkUploadCheckDto } from './dto/asset-check.dto';
import { AssetSearchDto } from './dto/asset-search.dto';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
-import { CreateAssetDto, mapToUploadFile } from './dto/create-asset.dto';
+import { CreateAssetDto, ImportAssetDto, mapToUploadFile } from './dto/create-asset.dto';
import { DeleteAssetDto } from './dto/delete-asset.dto';
import { DeviceIdDto } from './dto/device-id.dto';
import { DownloadFilesDto } from './dto/download-files.dto';
@@ -114,6 +114,20 @@ export class AssetController {
return responseDto;
}
+ @Post('import')
+ async importFile(
+ @AuthUser() authUser: AuthUserDto,
+ @Body(new ValidationPipe()) dto: ImportAssetDto,
+ @Response({ passthrough: true }) res: Res,
+ ): Promise {
+ const responseDto = await this.assetService.importFile(authUser, dto);
+ if (responseDto.duplicate) {
+ res.status(200);
+ }
+
+ return responseDto;
+ }
+
@SharedLinkRoute()
@Get('/download/:id')
@ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
diff --git a/server/src/immich/api-v1/asset/asset.core.ts b/server/src/immich/api-v1/asset/asset.core.ts
index 031ab58d43..b68f6234cb 100644
--- a/server/src/immich/api-v1/asset/asset.core.ts
+++ b/server/src/immich/api-v1/asset/asset.core.ts
@@ -2,17 +2,17 @@ import { AuthUserDto, IJobRepository, JobName } from '@app/domain';
import { AssetEntity, AssetType, UserEntity } from '@app/infra/entities';
import { parse } from 'node:path';
import { IAssetRepository } from './asset-repository';
-import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
+import { CreateAssetDto, ImportAssetDto, UploadFile } from './dto/create-asset.dto';
export class AssetCore {
constructor(private repository: IAssetRepository, private jobRepository: IJobRepository) {}
async create(
authUser: AuthUserDto,
- dto: CreateAssetDto,
+ dto: CreateAssetDto | ImportAssetDto,
file: UploadFile,
livePhotoAssetId?: string,
- sidecarFile?: UploadFile,
+ sidecarPath?: string,
): Promise {
const asset = await this.repository.create({
owner: { id: authUser.id } as UserEntity,
@@ -41,7 +41,8 @@ export class AssetCore {
sharedLinks: [],
originalFileName: parse(file.originalName).name,
faces: [],
- sidecarPath: sidecarFile?.originalPath || null,
+ sidecarPath: sidecarPath || null,
+ isReadOnly: dto.isReadOnly ?? false,
});
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id, source: 'upload' } });
diff --git a/server/src/immich/api-v1/asset/asset.service.spec.ts b/server/src/immich/api-v1/asset/asset.service.spec.ts
index ddfc74463a..4f51ad23aa 100644
--- a/server/src/immich/api-v1/asset/asset.service.spec.ts
+++ b/server/src/immich/api-v1/asset/asset.service.spec.ts
@@ -1,4 +1,4 @@
-import { IAccessRepository, IJobRepository, IStorageRepository, JobName } from '@app/domain';
+import { IAccessRepository, ICryptoRepository, IJobRepository, IStorageRepository, JobName } from '@app/domain';
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { ForbiddenException } from '@nestjs/common';
import {
@@ -6,6 +6,7 @@ import {
authStub,
fileStub,
newAccessRepositoryMock,
+ newCryptoRepositoryMock,
newJobRepositoryMock,
newStorageRepositoryMock,
} from '@test';
@@ -121,6 +122,7 @@ describe('AssetService', () => {
let a: Repository; // TO BE DELETED AFTER FINISHED REFACTORING
let accessMock: jest.Mocked;
let assetRepositoryMock: jest.Mocked;
+ let cryptoMock: jest.Mocked;
let downloadServiceMock: jest.Mocked>;
let jobMock: jest.Mocked;
let storageMock: jest.Mocked;
@@ -144,13 +146,17 @@ describe('AssetService', () => {
getAssetCountByUserId: jest.fn(),
getArchivedAssetCountByUserId: jest.fn(),
getExistingAssets: jest.fn(),
+ getByOriginalPath: jest.fn(),
};
+ cryptoMock = newCryptoRepositoryMock();
+
downloadServiceMock = {
downloadArchive: jest.fn(),
};
accessMock = newAccessRepositoryMock();
+ cryptoMock = newCryptoRepositoryMock();
jobMock = newJobRepositoryMock();
storageMock = newStorageRepositoryMock();
@@ -158,6 +164,7 @@ describe('AssetService', () => {
accessMock,
assetRepositoryMock,
a,
+ cryptoMock,
downloadServiceMock as DownloadService,
jobMock,
storageMock,
@@ -439,6 +446,43 @@ describe('AssetService', () => {
});
});
+ describe('importFile', () => {
+ it('should handle a file import', async () => {
+ assetRepositoryMock.create.mockResolvedValue(assetEntityStub.image);
+ storageMock.checkFileExists.mockResolvedValue(true);
+
+ await expect(
+ sut.importFile(authStub.external1, {
+ ..._getCreateAssetDto(),
+ assetPath: '/data/user1/fake_path/asset_1.jpeg',
+ isReadOnly: true,
+ }),
+ ).resolves.toEqual({ duplicate: false, id: 'asset-id' });
+
+ expect(assetRepositoryMock.create).toHaveBeenCalled();
+ });
+
+ it('should handle a duplicate if originalPath already exists', async () => {
+ const error = new QueryFailedError('', [], '');
+ (error as any).constraint = 'UQ_userid_checksum';
+
+ assetRepositoryMock.create.mockRejectedValue(error);
+ assetRepositoryMock.getAssetsByChecksums.mockResolvedValue([assetEntityStub.image]);
+ storageMock.checkFileExists.mockResolvedValue(true);
+ cryptoMock.hashFile.mockResolvedValue(Buffer.from('file hash', 'utf8'));
+
+ await expect(
+ sut.importFile(authStub.external1, {
+ ..._getCreateAssetDto(),
+ assetPath: '/data/user1/fake_path/asset_1.jpeg',
+ isReadOnly: true,
+ }),
+ ).resolves.toEqual({ duplicate: true, id: 'asset-id' });
+
+ expect(assetRepositoryMock.create).toHaveBeenCalled();
+ });
+ });
+
describe('getAssetById', () => {
it('should allow owner access', async () => {
accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts
index 0234f10efc..671ab74b1e 100644
--- a/server/src/immich/api-v1/asset/asset.service.ts
+++ b/server/src/immich/api-v1/asset/asset.service.ts
@@ -1,9 +1,13 @@
import {
AssetResponseDto,
+ AuthUserDto,
getLivePhotoMotionFilename,
IAccessRepository,
+ ICryptoRepository,
IJobRepository,
ImmichReadStream,
+ isSidecarFileType,
+ isSupportedFileType,
IStorageRepository,
JobName,
mapAsset,
@@ -21,12 +25,14 @@ import {
StreamableFile,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
+import { R_OK, W_OK } from 'constants';
import { Response as Res } from 'express';
-import { constants, createReadStream, stat } from 'fs';
+import { createReadStream, stat } from 'fs';
import fs from 'fs/promises';
+import mime from 'mime-types';
+import path from 'path';
import { QueryFailedError, Repository } from 'typeorm';
import { promisify } from 'util';
-import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { DownloadService } from '../../modules/download/download.service';
import { IAssetRepository } from './asset-repository';
import { AssetCore } from './asset.core';
@@ -34,7 +40,7 @@ import { AssetBulkUploadCheckDto } from './dto/asset-check.dto';
import { AssetSearchDto } from './dto/asset-search.dto';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
-import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
+import { CreateAssetDto, ImportAssetDto, UploadFile } from './dto/create-asset.dto';
import { DeleteAssetDto } from './dto/delete-asset.dto';
import { DownloadFilesDto } from './dto/download-files.dto';
import { DownloadDto } from './dto/download-library.dto';
@@ -78,6 +84,7 @@ export class AssetService {
@Inject(IAccessRepository) private accessRepository: IAccessRepository,
@Inject(IAssetRepository) private _assetRepository: IAssetRepository,
@InjectRepository(AssetEntity) private assetRepository: Repository,
+ @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
private downloadService: DownloadService,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@@ -107,7 +114,7 @@ export class AssetService {
livePhotoAsset = await this.assetCore.create(authUser, livePhotoDto, livePhotoFile);
}
- const asset = await this.assetCore.create(authUser, dto, file, livePhotoAsset?.id, sidecarFile);
+ const asset = await this.assetCore.create(authUser, dto, file, livePhotoAsset?.id, sidecarFile?.originalPath);
return { id: asset.id, duplicate: false };
} catch (error: any) {
@@ -129,6 +136,73 @@ export class AssetService {
}
}
+ public async importFile(authUser: AuthUserDto, dto: ImportAssetDto): Promise {
+ dto = {
+ ...dto,
+ assetPath: path.resolve(dto.assetPath),
+ sidecarPath: dto.sidecarPath ? path.resolve(dto.sidecarPath) : undefined,
+ };
+
+ const assetPathType = mime.lookup(dto.assetPath) as string;
+ if (!isSupportedFileType(assetPathType)) {
+ throw new BadRequestException(`Unsupported file type ${assetPathType}`);
+ }
+
+ if (dto.sidecarPath) {
+ const sidecarType = mime.lookup(dto.sidecarPath) as string;
+ if (!isSidecarFileType(sidecarType)) {
+ throw new BadRequestException(`Unsupported sidecar file type ${assetPathType}`);
+ }
+ }
+
+ for (const filepath of [dto.assetPath, dto.sidecarPath]) {
+ if (!filepath) {
+ continue;
+ }
+
+ const exists = await this.storageRepository.checkFileExists(filepath, R_OK);
+ if (!exists) {
+ throw new BadRequestException('File does not exist');
+ }
+ }
+
+ if (!authUser.externalPath || !dto.assetPath.match(new RegExp(`^${authUser.externalPath}`))) {
+ throw new BadRequestException("File does not exist within user's external path");
+ }
+
+ const assetFile: UploadFile = {
+ checksum: await this.cryptoRepository.hashFile(dto.assetPath),
+ mimeType: assetPathType,
+ originalPath: dto.assetPath,
+ originalName: path.parse(dto.assetPath).name,
+ };
+
+ try {
+ const asset = await this.assetCore.create(authUser, dto, assetFile, undefined, dto.sidecarPath);
+ return { id: asset.id, duplicate: false };
+ } catch (error: QueryFailedError | Error | any) {
+ // handle duplicates with a success response
+ if (error instanceof QueryFailedError && (error as any).constraint === 'UQ_userid_checksum') {
+ const [duplicate] = await this._assetRepository.getAssetsByChecksums(authUser.id, [assetFile.checksum]);
+ return { id: duplicate.id, duplicate: true };
+ }
+
+ if (error instanceof QueryFailedError && (error as any).constraint === 'UQ_4ed4f8052685ff5b1e7ca1058ba') {
+ const duplicate = await this._assetRepository.getByOriginalPath(dto.assetPath);
+ if (duplicate) {
+ if (duplicate.ownerId === authUser.id) {
+ return { id: duplicate.id, duplicate: true };
+ }
+
+ throw new BadRequestException('Path in use by another user');
+ }
+ }
+
+ this.logger.error(`Error importing file ${error}`, error?.stack);
+ throw new BadRequestException(`Error importing file`, `${error}`);
+ }
+ }
+
public async getUserAssetsByDeviceId(authUser: AuthUserDto, deviceId: string) {
return this._assetRepository.getAllByDeviceId(authUser.id, deviceId);
}
@@ -291,7 +365,7 @@ export class AssetService {
let videoPath = asset.originalPath;
let mimeType = asset.mimeType;
- await fs.access(videoPath, constants.R_OK | constants.W_OK);
+ await fs.access(videoPath, R_OK | W_OK);
if (asset.encodedVideoPath) {
videoPath = asset.encodedVideoPath == '' ? String(asset.originalPath) : String(asset.encodedVideoPath);
@@ -373,13 +447,16 @@ export class AssetService {
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [id] } });
result.push({ id, status: DeleteAssetStatusEnum.SUCCESS });
- deleteQueue.push(
- asset.originalPath,
- asset.webpPath,
- asset.resizePath,
- asset.encodedVideoPath,
- asset.sidecarPath,
- );
+
+ if (!asset.isReadOnly) {
+ deleteQueue.push(
+ asset.originalPath,
+ asset.webpPath,
+ asset.resizePath,
+ asset.encodedVideoPath,
+ asset.sidecarPath,
+ );
+ }
// TODO refactor this to use cascades
if (asset.livePhotoVideoId && !ids.includes(asset.livePhotoVideoId)) {
@@ -665,7 +742,7 @@ export class AssetService {
return;
}
- await fs.access(filepath, constants.R_OK);
+ await fs.access(filepath, R_OK);
return new StreamableFile(createReadStream(filepath));
}
diff --git a/server/src/immich/api-v1/asset/dto/create-asset.dto.ts b/server/src/immich/api-v1/asset/dto/create-asset.dto.ts
index 1c4880fc80..946c0544e8 100644
--- a/server/src/immich/api-v1/asset/dto/create-asset.dto.ts
+++ b/server/src/immich/api-v1/asset/dto/create-asset.dto.ts
@@ -1,9 +1,11 @@
import { AssetType } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger';
-import { IsBoolean, IsEnum, IsNotEmpty, IsOptional } from 'class-validator';
+import { Transform } from 'class-transformer';
+import { IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { ImmichFile } from '../../../config/asset-upload.config';
+import { toSanitized } from '../../../utils/transform.util';
-export class CreateAssetDto {
+export class CreateAssetBase {
@IsNotEmpty()
deviceAssetId!: string;
@@ -32,11 +34,17 @@ export class CreateAssetDto {
@IsBoolean()
isVisible?: boolean;
- @IsNotEmpty()
- fileExtension!: string;
-
@IsOptional()
duration?: string;
+}
+
+export class CreateAssetDto extends CreateAssetBase {
+ @IsOptional()
+ @IsBoolean()
+ isReadOnly?: boolean = false;
+
+ @IsNotEmpty()
+ fileExtension!: string;
// The properties below are added to correctly generate the API docs
// and client SDKs. Validation should be handled in the controller.
@@ -50,6 +58,23 @@ export class CreateAssetDto {
sidecarData?: any;
}
+export class ImportAssetDto extends CreateAssetBase {
+ @IsOptional()
+ @IsBoolean()
+ isReadOnly?: boolean = true;
+
+ @IsString()
+ @IsNotEmpty()
+ @Transform(toSanitized)
+ assetPath!: string;
+
+ @IsString()
+ @IsOptional()
+ @IsNotEmpty()
+ @Transform(toSanitized)
+ sidecarPath?: string;
+}
+
export interface UploadFile {
mimeType: string;
checksum: Buffer;
diff --git a/server/src/immich/config/asset-upload.config.ts b/server/src/immich/config/asset-upload.config.ts
index 889fabe593..714aca0d35 100644
--- a/server/src/immich/config/asset-upload.config.ts
+++ b/server/src/immich/config/asset-upload.config.ts
@@ -1,3 +1,4 @@
+import { isSidecarFileType, isSupportedFileType } from '@app/domain';
import { StorageCore, StorageFolder } from '@app/domain/storage';
import { BadRequestException, Logger, UnauthorizedException } from '@nestjs/common';
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
@@ -49,67 +50,18 @@ export const multerUtils = { fileFilter, filename, destination };
const logger = new Logger('AssetUploadConfig');
-const validMimeTypes = [
- 'image/avif',
- 'image/gif',
- 'image/heic',
- 'image/heif',
- 'image/jpeg',
- 'image/jxl',
- 'image/png',
- 'image/tiff',
- 'image/webp',
- 'image/x-adobe-dng',
- 'image/x-arriflex-ari',
- 'image/x-canon-cr2',
- 'image/x-canon-cr3',
- 'image/x-canon-crw',
- 'image/x-epson-erf',
- 'image/x-fuji-raf',
- 'image/x-hasselblad-3fr',
- 'image/x-hasselblad-fff',
- 'image/x-kodak-dcr',
- 'image/x-kodak-k25',
- 'image/x-kodak-kdc',
- 'image/x-leica-rwl',
- 'image/x-minolta-mrw',
- 'image/x-nikon-nef',
- 'image/x-olympus-orf',
- 'image/x-olympus-ori',
- 'image/x-panasonic-raw',
- 'image/x-pentax-pef',
- 'image/x-phantom-cin',
- 'image/x-phaseone-cap',
- 'image/x-phaseone-iiq',
- 'image/x-samsung-srw',
- 'image/x-sigma-x3f',
- 'image/x-sony-arw',
- 'image/x-sony-sr2',
- 'image/x-sony-srf',
- 'video/3gpp',
- 'video/mp2t',
- 'video/mp4',
- 'video/mpeg',
- 'video/quicktime',
- 'video/webm',
- 'video/x-flv',
- 'video/x-matroska',
- 'video/x-ms-wmv',
- 'video/x-msvideo',
-];
-
function fileFilter(req: AuthRequest, file: any, cb: any) {
if (!req.user || (req.user.isPublicUser && !req.user.isAllowUpload)) {
return cb(new UnauthorizedException());
}
- if (validMimeTypes.includes(file.mimetype)) {
+ if (isSupportedFileType(file.mimetype)) {
cb(null, true);
return;
}
// Additionally support XML but only for sidecar files.
- if (file.fieldname === 'sidecarData' && ['application/xml', 'text/xml'].includes(file.mimetype)) {
+ if (file.fieldname === 'sidecarData' && isSidecarFileType(file.mimetype)) {
return cb(null, true);
}
diff --git a/server/src/infra/entities/asset.entity.ts b/server/src/infra/entities/asset.entity.ts
index 82172bcfac..c070b5cd10 100644
--- a/server/src/infra/entities/asset.entity.ts
+++ b/server/src/infra/entities/asset.entity.ts
@@ -42,7 +42,7 @@ export class AssetEntity {
@Column()
type!: AssetType;
- @Column()
+ @Column({ unique: true })
originalPath!: string;
@Column({ type: 'varchar', nullable: true })
@@ -75,6 +75,9 @@ export class AssetEntity {
@Column({ type: 'boolean', default: false })
isArchived!: boolean;
+ @Column({ type: 'boolean', default: false })
+ isReadOnly!: boolean;
+
@Column({ type: 'varchar', nullable: true })
mimeType!: string | null;
diff --git a/server/src/infra/entities/user.entity.ts b/server/src/infra/entities/user.entity.ts
index f175a603c3..7cdac1f824 100644
--- a/server/src/infra/entities/user.entity.ts
+++ b/server/src/infra/entities/user.entity.ts
@@ -30,6 +30,9 @@ export class UserEntity {
@Column({ type: 'varchar', unique: true, default: null })
storageLabel!: string | null;
+ @Column({ type: 'varchar', default: null })
+ externalPath!: string | null;
+
@Column({ default: '', select: false })
password?: string;
diff --git a/server/src/infra/migrations/1686584273471-ImportAsset.ts b/server/src/infra/migrations/1686584273471-ImportAsset.ts
new file mode 100644
index 0000000000..d9f5819a8d
--- /dev/null
+++ b/server/src/infra/migrations/1686584273471-ImportAsset.ts
@@ -0,0 +1,18 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class ImportAsset1686584273471 implements MigrationInterface {
+ name = 'ImportAsset1686584273471'
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TABLE "assets" ADD "isReadOnly" boolean NOT NULL DEFAULT false`);
+ await queryRunner.query(`ALTER TABLE "assets" ADD CONSTRAINT "UQ_4ed4f8052685ff5b1e7ca1058ba" UNIQUE ("originalPath")`);
+ await queryRunner.query(`ALTER TABLE "users" ADD "externalPath" character varying`);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "UQ_4ed4f8052685ff5b1e7ca1058ba"`);
+ await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "isReadOnly"`);
+ await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "externalPath"`);
+ }
+
+}
diff --git a/server/src/infra/repositories/crypto.repository.ts b/server/src/infra/repositories/crypto.repository.ts
index 6ded5b0201..af76d46a71 100644
--- a/server/src/infra/repositories/crypto.repository.ts
+++ b/server/src/infra/repositories/crypto.repository.ts
@@ -2,6 +2,7 @@ import { ICryptoRepository } from '@app/domain';
import { Injectable } from '@nestjs/common';
import { compareSync, hash } from 'bcrypt';
import { createHash, randomBytes } from 'crypto';
+import { createReadStream } from 'fs';
@Injectable()
export class CryptoRepository implements ICryptoRepository {
@@ -13,4 +14,14 @@ export class CryptoRepository implements ICryptoRepository {
hashSha256(value: string) {
return createHash('sha256').update(value).digest('base64');
}
+
+ hashFile(filepath: string): Promise {
+ return new Promise((resolve, reject) => {
+ const hash = createHash('sha1');
+ const stream = createReadStream(filepath);
+ stream.on('error', (err) => reject(err));
+ stream.on('data', (chunk) => hash.update(chunk));
+ stream.on('end', () => resolve(hash.digest()));
+ });
+ }
}
diff --git a/server/test/fixtures.ts b/server/test/fixtures.ts
index 5003c45d53..970f152829 100644
--- a/server/test/fixtures.ts
+++ b/server/test/fixtures.ts
@@ -50,6 +50,7 @@ export const authStub = {
isAdmin: true,
isPublicUser: false,
isAllowUpload: true,
+ externalPath: null,
}),
user1: Object.freeze({
id: 'user-id',
@@ -60,6 +61,7 @@ export const authStub = {
isAllowDownload: true,
isShowExif: true,
accessTokenId: 'token-id',
+ externalPath: null,
}),
user2: Object.freeze({
id: 'user-2',
@@ -70,6 +72,18 @@ export const authStub = {
isAllowDownload: true,
isShowExif: true,
accessTokenId: 'token-id',
+ externalPath: null,
+ }),
+ external1: Object.freeze({
+ id: 'user-id',
+ email: 'immich@test.com',
+ isAdmin: false,
+ isPublicUser: false,
+ isAllowUpload: true,
+ isAllowDownload: true,
+ isShowExif: true,
+ accessTokenId: 'token-id',
+ externalPath: '/data/user1',
}),
adminSharedLink: Object.freeze({
id: 'admin_id',
@@ -111,6 +125,7 @@ export const userEntityStub = {
firstName: 'admin_first_name',
lastName: 'admin_last_name',
storageLabel: 'admin',
+ externalPath: null,
oauthId: '',
shouldChangePassword: false,
profileImagePath: '',
@@ -126,6 +141,7 @@ export const userEntityStub = {
firstName: 'immich_first_name',
lastName: 'immich_last_name',
storageLabel: null,
+ externalPath: null,
oauthId: '',
shouldChangePassword: false,
profileImagePath: '',
@@ -141,6 +157,7 @@ export const userEntityStub = {
firstName: 'immich_first_name',
lastName: 'immich_last_name',
storageLabel: null,
+ externalPath: null,
oauthId: '',
shouldChangePassword: false,
profileImagePath: '',
@@ -156,6 +173,7 @@ export const userEntityStub = {
firstName: 'immich_first_name',
lastName: 'immich_last_name',
storageLabel: 'label-1',
+ externalPath: null,
oauthId: '',
shouldChangePassword: false,
profileImagePath: '',
@@ -212,6 +230,7 @@ export const assetEntityStub = {
sharedLinks: [],
faces: [],
sidecarPath: null,
+ isReadOnly: false,
}),
noWebpPath: Object.freeze({
id: 'asset-id',
@@ -242,6 +261,7 @@ export const assetEntityStub = {
originalFileName: 'asset-id.ext',
faces: [],
sidecarPath: null,
+ isReadOnly: false,
}),
noThumbhash: Object.freeze({
id: 'asset-id',
@@ -263,6 +283,7 @@ export const assetEntityStub = {
mimeType: null,
isFavorite: true,
isArchived: false,
+ isReadOnly: false,
duration: null,
isVisible: true,
livePhotoVideo: null,
@@ -293,6 +314,7 @@ export const assetEntityStub = {
mimeType: null,
isFavorite: true,
isArchived: false,
+ isReadOnly: false,
duration: null,
isVisible: true,
livePhotoVideo: null,
@@ -324,6 +346,7 @@ export const assetEntityStub = {
mimeType: null,
isFavorite: true,
isArchived: false,
+ isReadOnly: false,
duration: null,
isVisible: true,
livePhotoVideo: null,
@@ -375,6 +398,7 @@ export const assetEntityStub = {
mimeType: null,
isFavorite: false,
isArchived: false,
+ isReadOnly: false,
duration: null,
isVisible: true,
livePhotoVideo: null,
@@ -408,6 +432,7 @@ export const assetEntityStub = {
mimeType: null,
isFavorite: true,
isArchived: false,
+ isReadOnly: false,
duration: null,
isVisible: true,
livePhotoVideo: null,
@@ -865,6 +890,7 @@ export const sharedLinkStub = {
updatedAt: today,
isFavorite: false,
isArchived: false,
+ isReadOnly: false,
mimeType: 'image/jpeg',
smartInfo: {
assetId: 'id_1',
diff --git a/server/test/repositories/crypto.repository.mock.ts b/server/test/repositories/crypto.repository.mock.ts
index bbf3154448..b2f159c1e4 100644
--- a/server/test/repositories/crypto.repository.mock.ts
+++ b/server/test/repositories/crypto.repository.mock.ts
@@ -6,5 +6,6 @@ export const newCryptoRepositoryMock = (): jest.Mocked => {
compareBcrypt: jest.fn().mockReturnValue(true),
hashBcrypt: jest.fn().mockImplementation((input) => Promise.resolve(`${input} (hashed)`)),
hashSha256: jest.fn().mockImplementation((input) => `${input} (hashed)`),
+ hashFile: jest.fn().mockImplementation((input) => `${input} (file-hashed)`),
};
};
diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts
index 41264aafc4..0c73255795 100644
--- a/web/src/api/open-api/api.ts
+++ b/web/src/api/open-api/api.ts
@@ -979,6 +979,12 @@ export interface CreateUserDto {
* @memberof CreateUserDto
*/
'storageLabel'?: string | null;
+ /**
+ *
+ * @type {string}
+ * @memberof CreateUserDto
+ */
+ 'externalPath'?: string | null;
}
/**
*
@@ -1294,6 +1300,87 @@ export interface GetAssetCountByTimeBucketDto {
}
+/**
+ *
+ * @export
+ * @interface ImportAssetDto
+ */
+export interface ImportAssetDto {
+ /**
+ *
+ * @type {AssetTypeEnum}
+ * @memberof ImportAssetDto
+ */
+ 'assetType': AssetTypeEnum;
+ /**
+ *
+ * @type {boolean}
+ * @memberof ImportAssetDto
+ */
+ 'isReadOnly'?: boolean;
+ /**
+ *
+ * @type {string}
+ * @memberof ImportAssetDto
+ */
+ 'assetPath': string;
+ /**
+ *
+ * @type {string}
+ * @memberof ImportAssetDto
+ */
+ 'sidecarPath'?: string;
+ /**
+ *
+ * @type {string}
+ * @memberof ImportAssetDto
+ */
+ 'deviceAssetId': string;
+ /**
+ *
+ * @type {string}
+ * @memberof ImportAssetDto
+ */
+ 'deviceId': string;
+ /**
+ *
+ * @type {string}
+ * @memberof ImportAssetDto
+ */
+ 'fileCreatedAt': string;
+ /**
+ *
+ * @type {string}
+ * @memberof ImportAssetDto
+ */
+ 'fileModifiedAt': string;
+ /**
+ *
+ * @type {boolean}
+ * @memberof ImportAssetDto
+ */
+ 'isFavorite': boolean;
+ /**
+ *
+ * @type {boolean}
+ * @memberof ImportAssetDto
+ */
+ 'isArchived'?: boolean;
+ /**
+ *
+ * @type {boolean}
+ * @memberof ImportAssetDto
+ */
+ 'isVisible'?: boolean;
+ /**
+ *
+ * @type {string}
+ * @memberof ImportAssetDto
+ */
+ 'duration'?: string;
+}
+
+
/**
*
* @export
@@ -2736,6 +2823,12 @@ export interface UpdateUserDto {
* @memberof UpdateUserDto
*/
'storageLabel'?: string;
+ /**
+ *
+ * @type {string}
+ * @memberof UpdateUserDto
+ */
+ 'externalPath'?: string;
/**
*
* @type {boolean}
@@ -2841,6 +2934,12 @@ export interface UserResponseDto {
* @memberof UserResponseDto
*/
'storageLabel': string | null;
+ /**
+ *
+ * @type {string}
+ * @memberof UserResponseDto
+ */
+ 'externalPath': string | null;
/**
*
* @type {string}
@@ -5412,6 +5511,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
options: localVarRequestOptions,
};
},
+ /**
+ *
+ * @param {ImportAssetDto} importAssetDto
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ importFile: async (importAssetDto: ImportAssetDto, options: AxiosRequestConfig = {}): Promise => {
+ // verify required parameter 'importAssetDto' is not null or undefined
+ assertParamExists('importFile', 'importAssetDto', importAssetDto)
+ const localVarPath = `/asset/import`;
+ // 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: 'POST', ...baseOptions, ...options};
+ const localVarHeaderParameter = {} as any;
+ const localVarQueryParameter = {} as any;
+
+ // authentication cookie required
+
+ // authentication api_key required
+ await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
+
+ // authentication bearer required
+ // http bearer authentication required
+ await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+
+
+ localVarHeaderParameter['Content-Type'] = 'application/json';
+
+ setSearchParams(localVarUrlObj, localVarQueryParameter);
+ let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+ localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+ localVarRequestOptions.data = serializeDataIfNeeded(importAssetDto, localVarRequestOptions, configuration)
+
+ return {
+ url: toPathString(localVarUrlObj),
+ options: localVarRequestOptions,
+ };
+ },
/**
*
* @param {SearchAssetDto} searchAssetDto
@@ -5565,26 +5708,29 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
*
* @param {AssetTypeEnum} assetType
* @param {File} assetData
+ * @param {string} fileExtension
* @param {string} deviceAssetId
* @param {string} deviceId
* @param {string} fileCreatedAt
* @param {string} fileModifiedAt
* @param {boolean} isFavorite
- * @param {string} fileExtension
* @param {string} [key]
* @param {File} [livePhotoData]
* @param {File} [sidecarData]
+ * @param {boolean} [isReadOnly]
* @param {boolean} [isArchived]
* @param {boolean} [isVisible]
* @param {string} [duration]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
- uploadFile: async (assetType: AssetTypeEnum, assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, fileExtension: string, key?: string, livePhotoData?: File, sidecarData?: File, isArchived?: boolean, isVisible?: boolean, duration?: string, options: AxiosRequestConfig = {}): Promise => {
+ uploadFile: async (assetType: AssetTypeEnum, assetData: File, fileExtension: string, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, key?: string, livePhotoData?: File, sidecarData?: File, isReadOnly?: boolean, isArchived?: boolean, isVisible?: boolean, duration?: string, options: AxiosRequestConfig = {}): Promise => {
// verify required parameter 'assetType' is not null or undefined
assertParamExists('uploadFile', 'assetType', assetType)
// verify required parameter 'assetData' is not null or undefined
assertParamExists('uploadFile', 'assetData', assetData)
+ // verify required parameter 'fileExtension' is not null or undefined
+ assertParamExists('uploadFile', 'fileExtension', fileExtension)
// verify required parameter 'deviceAssetId' is not null or undefined
assertParamExists('uploadFile', 'deviceAssetId', deviceAssetId)
// verify required parameter 'deviceId' is not null or undefined
@@ -5595,8 +5741,6 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
assertParamExists('uploadFile', 'fileModifiedAt', fileModifiedAt)
// verify required parameter 'isFavorite' is not null or undefined
assertParamExists('uploadFile', 'isFavorite', isFavorite)
- // verify required parameter 'fileExtension' is not null or undefined
- assertParamExists('uploadFile', 'fileExtension', fileExtension)
const localVarPath = `/asset/upload`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@@ -5640,6 +5784,14 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
localVarFormParams.append('sidecarData', sidecarData as any);
}
+ if (isReadOnly !== undefined) {
+ localVarFormParams.append('isReadOnly', isReadOnly as any);
+ }
+
+ if (fileExtension !== undefined) {
+ localVarFormParams.append('fileExtension', fileExtension as any);
+ }
+
if (deviceAssetId !== undefined) {
localVarFormParams.append('deviceAssetId', deviceAssetId as any);
}
@@ -5668,10 +5820,6 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
localVarFormParams.append('isVisible', isVisible as any);
}
- if (fileExtension !== undefined) {
- localVarFormParams.append('fileExtension', fileExtension as any);
- }
-
if (duration !== undefined) {
localVarFormParams.append('duration', duration as any);
}
@@ -5909,6 +6057,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.getUserAssetsByDeviceId(deviceId, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
+ /**
+ *
+ * @param {ImportAssetDto} importAssetDto
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ async importFile(importAssetDto: ImportAssetDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> {
+ const localVarAxiosArgs = await localVarAxiosParamCreator.importFile(importAssetDto, options);
+ return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+ },
/**
*
* @param {SearchAssetDto} searchAssetDto
@@ -5947,23 +6105,24 @@ export const AssetApiFp = function(configuration?: Configuration) {
*
* @param {AssetTypeEnum} assetType
* @param {File} assetData
+ * @param {string} fileExtension
* @param {string} deviceAssetId
* @param {string} deviceId
* @param {string} fileCreatedAt
* @param {string} fileModifiedAt
* @param {boolean} isFavorite
- * @param {string} fileExtension
* @param {string} [key]
* @param {File} [livePhotoData]
* @param {File} [sidecarData]
+ * @param {boolean} [isReadOnly]
* @param {boolean} [isArchived]
* @param {boolean} [isVisible]
* @param {string} [duration]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
- async uploadFile(assetType: AssetTypeEnum, assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, fileExtension: string, key?: string, livePhotoData?: File, sidecarData?: File, isArchived?: boolean, isVisible?: boolean, duration?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> {
- const localVarAxiosArgs = await localVarAxiosParamCreator.uploadFile(assetType, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, fileExtension, key, livePhotoData, sidecarData, isArchived, isVisible, duration, options);
+ async uploadFile(assetType: AssetTypeEnum, assetData: File, fileExtension: string, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, key?: string, livePhotoData?: File, sidecarData?: File, isReadOnly?: boolean, isArchived?: boolean, isVisible?: boolean, duration?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> {
+ const localVarAxiosArgs = await localVarAxiosParamCreator.uploadFile(assetType, assetData, fileExtension, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, livePhotoData, sidecarData, isReadOnly, isArchived, isVisible, duration, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
}
@@ -6166,6 +6325,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
getUserAssetsByDeviceId(deviceId: string, options?: any): AxiosPromise> {
return localVarFp.getUserAssetsByDeviceId(deviceId, options).then((request) => request(axios, basePath));
},
+ /**
+ *
+ * @param {ImportAssetDto} importAssetDto
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ */
+ importFile(importAssetDto: ImportAssetDto, options?: any): AxiosPromise {
+ return localVarFp.importFile(importAssetDto, options).then((request) => request(axios, basePath));
+ },
/**
*
* @param {SearchAssetDto} searchAssetDto
@@ -6201,23 +6369,24 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
*
* @param {AssetTypeEnum} assetType
* @param {File} assetData
+ * @param {string} fileExtension
* @param {string} deviceAssetId
* @param {string} deviceId
* @param {string} fileCreatedAt
* @param {string} fileModifiedAt
* @param {boolean} isFavorite
- * @param {string} fileExtension
* @param {string} [key]
* @param {File} [livePhotoData]
* @param {File} [sidecarData]
+ * @param {boolean} [isReadOnly]
* @param {boolean} [isArchived]
* @param {boolean} [isVisible]
* @param {string} [duration]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
- uploadFile(assetType: AssetTypeEnum, assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, fileExtension: string, key?: string, livePhotoData?: File, sidecarData?: File, isArchived?: boolean, isVisible?: boolean, duration?: string, options?: any): AxiosPromise {
- return localVarFp.uploadFile(assetType, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, fileExtension, key, livePhotoData, sidecarData, isArchived, isVisible, duration, options).then((request) => request(axios, basePath));
+ uploadFile(assetType: AssetTypeEnum, assetData: File, fileExtension: string, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, key?: string, livePhotoData?: File, sidecarData?: File, isReadOnly?: boolean, isArchived?: boolean, isVisible?: boolean, duration?: string, options?: any): AxiosPromise {
+ return localVarFp.uploadFile(assetType, assetData, fileExtension, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, livePhotoData, sidecarData, isReadOnly, isArchived, isVisible, duration, options).then((request) => request(axios, basePath));
},
};
};
@@ -6537,6 +6706,20 @@ export interface AssetApiGetUserAssetsByDeviceIdRequest {
readonly deviceId: string
}
+/**
+ * Request parameters for importFile operation in AssetApi.
+ * @export
+ * @interface AssetApiImportFileRequest
+ */
+export interface AssetApiImportFileRequest {
+ /**
+ *
+ * @type {ImportAssetDto}
+ * @memberof AssetApiImportFile
+ */
+ readonly importAssetDto: ImportAssetDto
+}
+
/**
* Request parameters for searchAsset operation in AssetApi.
* @export
@@ -6627,6 +6810,13 @@ export interface AssetApiUploadFileRequest {
*/
readonly assetData: File
+ /**
+ *
+ * @type {string}
+ * @memberof AssetApiUploadFile
+ */
+ readonly fileExtension: string
+
/**
*
* @type {string}
@@ -6662,13 +6852,6 @@ export interface AssetApiUploadFileRequest {
*/
readonly isFavorite: boolean
- /**
- *
- * @type {string}
- * @memberof AssetApiUploadFile
- */
- readonly fileExtension: string
-
/**
*
* @type {string}
@@ -6690,6 +6873,13 @@ export interface AssetApiUploadFileRequest {
*/
readonly sidecarData?: File
+ /**
+ *
+ * @type {boolean}
+ * @memberof AssetApiUploadFile
+ */
+ readonly isReadOnly?: boolean
+
/**
*
* @type {boolean}
@@ -6934,6 +7124,17 @@ export class AssetApi extends BaseAPI {
return AssetApiFp(this.configuration).getUserAssetsByDeviceId(requestParameters.deviceId, options).then((request) => request(this.axios, this.basePath));
}
+ /**
+ *
+ * @param {AssetApiImportFileRequest} requestParameters Request parameters.
+ * @param {*} [options] Override http request option.
+ * @throws {RequiredError}
+ * @memberof AssetApi
+ */
+ public importFile(requestParameters: AssetApiImportFileRequest, options?: AxiosRequestConfig) {
+ return AssetApiFp(this.configuration).importFile(requestParameters.importAssetDto, options).then((request) => request(this.axios, this.basePath));
+ }
+
/**
*
* @param {AssetApiSearchAssetRequest} requestParameters Request parameters.
@@ -6975,7 +7176,7 @@ export class AssetApi extends BaseAPI {
* @memberof AssetApi
*/
public uploadFile(requestParameters: AssetApiUploadFileRequest, options?: AxiosRequestConfig) {
- return AssetApiFp(this.configuration).uploadFile(requestParameters.assetType, requestParameters.assetData, requestParameters.deviceAssetId, requestParameters.deviceId, requestParameters.fileCreatedAt, requestParameters.fileModifiedAt, requestParameters.isFavorite, requestParameters.fileExtension, requestParameters.key, requestParameters.livePhotoData, requestParameters.sidecarData, requestParameters.isArchived, requestParameters.isVisible, requestParameters.duration, options).then((request) => request(this.axios, this.basePath));
+ return AssetApiFp(this.configuration).uploadFile(requestParameters.assetType, requestParameters.assetData, requestParameters.fileExtension, requestParameters.deviceAssetId, requestParameters.deviceId, requestParameters.fileCreatedAt, requestParameters.fileModifiedAt, requestParameters.isFavorite, requestParameters.key, requestParameters.livePhotoData, requestParameters.sidecarData, requestParameters.isReadOnly, requestParameters.isArchived, requestParameters.isVisible, requestParameters.duration, options).then((request) => request(this.axios, this.basePath));
}
}
diff --git a/web/src/lib/components/forms/edit-user-form.svelte b/web/src/lib/components/forms/edit-user-form.svelte
index 7ec5f05700..3aedf0f28d 100644
--- a/web/src/lib/components/forms/edit-user-form.svelte
+++ b/web/src/lib/components/forms/edit-user-form.svelte
@@ -19,14 +19,15 @@
const editUser = async () => {
try {
- const { id, email, firstName, lastName, storageLabel } = user;
+ const { id, email, firstName, lastName, storageLabel, externalPath } = user;
const { status } = await api.userApi.updateUser({
updateUserDto: {
id,
email,
firstName,
lastName,
- storageLabel: storageLabel || ''
+ storageLabel: storageLabel || '',
+ externalPath: externalPath || ''
}
});
@@ -131,6 +132,22 @@
+
+
+
+
+
+ Note: Absolute path of parent import directory. A user can only import files if they exist
+ at or under this path.
+
+
+
{#if error}
{error}
{/if}
diff --git a/web/src/lib/components/user-settings-page/user-profile-settings.svelte b/web/src/lib/components/user-settings-page/user-profile-settings.svelte
index 3fc770de36..847e517dbf 100644
--- a/web/src/lib/components/user-settings-page/user-profile-settings.svelte
+++ b/web/src/lib/components/user-settings-page/user-profile-settings.svelte
@@ -75,6 +75,14 @@
required={false}
/>
+
+
diff --git a/web/src/routes/admin/user-management/+page.svelte b/web/src/routes/admin/user-management/+page.svelte
index b5a3624bb8..5dc4b82601 100644
--- a/web/src/routes/admin/user-management/+page.svelte
+++ b/web/src/routes/admin/user-management/+page.svelte
@@ -5,6 +5,8 @@
import PencilOutline from 'svelte-material-icons/PencilOutline.svelte';
import TrashCanOutline from 'svelte-material-icons/TrashCanOutline.svelte';
import DeleteRestore from 'svelte-material-icons/DeleteRestore.svelte';
+ import Check from 'svelte-material-icons/Check.svelte';
+ import Close from 'svelte-material-icons/Close.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import CreateUserForm from '$lib/components/forms/create-user-form.svelte';
import EditUserForm from '$lib/components/forms/edit-user-form.svelte';
@@ -171,6 +173,7 @@
Email |
First name |
Last name |
+ Can import |
Action |
@@ -191,6 +194,15 @@
{user.email} |
{user.firstName} |
{user.lastName} |
+
+
+ {#if user.externalPath}
+
+ {:else}
+
+ {/if}
+
+ |
{#if !isDeleted(user)}
|