From 9366c6d468002076dcc451d07620113601d1dba7 Mon Sep 17 00:00:00 2001 From: "Carlos R. L. Rodrigues" <37986729+carlos-r-l-rodrigues@users.noreply.github.com> Date: Sun, 14 Dec 2025 08:02:53 -0300 Subject: [PATCH] feat: order export and upload stream (#14243) * feat: order export * Merge branch 'develop' of https://github.com/medusajs/medusa into feat/order-export * normalize status * rm util * serialize totals * test * lock * comments * configurable order list --- .../dashboard-app/routes/get-route.map.tsx | 6 + .../admin/dashboard/src/hooks/api/orders.tsx | 19 +- .../src/i18n/translations/$schema.json | 40 ++++ .../dashboard/src/i18n/translations/en.json | 12 ++ .../components/export-filters.tsx | 22 +++ .../src/routes/orders/order-export/index.ts | 1 + .../orders/order-export/order-export.tsx | 66 +++++++ .../configurable-order-list-table.tsx | 20 +- .../order-list-table/order-list-table.tsx | 10 +- .../src/order/steps/export-orders.ts | 178 ++++++++++++++++++ .../core/core-flows/src/order/steps/index.ts | 3 +- .../src/order/workflows/export-orders.ts | 142 ++++++++++++++ .../core-flows/src/order/workflows/index.ts | 5 +- packages/core/js-sdk/src/admin/order.ts | 34 +++- packages/core/types/src/file/provider.ts | 34 +++- packages/core/types/src/file/service.ts | 21 ++- .../types/src/http/order/admin/responses.ts | 7 + .../utils/src/file/abstract-file-provider.ts | 32 +++- .../src/api/admin/orders/export/route.ts | 20 ++ .../src/api/admin/orders/middlewares.ts | 10 + .../src/api/admin/orders/query-config.ts | 30 +++ .../file/src/services/file-module-service.ts | 29 ++- .../src/services/file-provider-service.ts | 11 +- .../__fixtures__/catphoto.jpg | Bin 0 -> 24003 bytes .../__tests__/services.spec.ts | 119 ++++++++++++ .../modules/providers/file-local/package.json | 1 + .../file-local/src/services/local-file.ts | 57 +++++- .../__tests__/services.spec.ts | 48 ++++- .../modules/providers/file-s3/package.json | 1 + .../providers/file-s3/src/services/s3-file.ts | 54 +++++- yarn.lock | 46 ++++- 31 files changed, 1041 insertions(+), 37 deletions(-) create mode 100644 packages/admin/dashboard/src/routes/orders/order-export/components/export-filters.tsx create mode 100644 packages/admin/dashboard/src/routes/orders/order-export/index.ts create mode 100644 packages/admin/dashboard/src/routes/orders/order-export/order-export.tsx create mode 100644 packages/core/core-flows/src/order/steps/export-orders.ts create mode 100644 packages/core/core-flows/src/order/workflows/export-orders.ts create mode 100644 packages/medusa/src/api/admin/orders/export/route.ts create mode 100644 packages/modules/providers/file-local/integration-tests/__fixtures__/catphoto.jpg create mode 100644 packages/modules/providers/file-local/integration-tests/__tests__/services.spec.ts diff --git a/packages/admin/dashboard/src/dashboard-app/routes/get-route.map.tsx b/packages/admin/dashboard/src/dashboard-app/routes/get-route.map.tsx index e2a7732707..182bf48bb4 100644 --- a/packages/admin/dashboard/src/dashboard-app/routes/get-route.map.tsx +++ b/packages/admin/dashboard/src/dashboard-app/routes/get-route.map.tsx @@ -300,6 +300,12 @@ export function getRouteMap({ { path: "", lazy: () => import("../../routes/orders/order-list"), + children: [ + { + path: "export", + lazy: () => import("../../routes/orders/order-export"), + }, + ], }, { path: ":id", diff --git a/packages/admin/dashboard/src/hooks/api/orders.tsx b/packages/admin/dashboard/src/hooks/api/orders.tsx index d7bfffe7ac..299f75d80b 100644 --- a/packages/admin/dashboard/src/hooks/api/orders.tsx +++ b/packages/admin/dashboard/src/hooks/api/orders.tsx @@ -10,8 +10,8 @@ import { import { sdk } from "../../lib/client" import { queryClient } from "../../lib/query-client" import { queryKeysFactory, TQueryKey } from "../../lib/query-key-factory" -import { reservationItemsQueryKeys } from "./reservations" import { inventoryItemsQueryKeys } from "./inventory" +import { reservationItemsQueryKeys } from "./reservations" const ORDERS_QUERY_KEY = "orders" as const const _orderKeys = queryKeysFactory(ORDERS_QUERY_KEY) as TQueryKey<"orders"> & { @@ -438,3 +438,20 @@ export const useUpdateOrderChange = ( ...options, }) } + +export const useExportOrders = ( + query?: HttpTypes.AdminOrderFilters, + options?: UseMutationOptions< + { transaction_id: string }, + FetchError, + HttpTypes.AdminOrderFilters + > +) => { + return useMutation({ + mutationFn: () => sdk.admin.order.export(query), + onSuccess: (data, variables, context) => { + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} diff --git a/packages/admin/dashboard/src/i18n/translations/$schema.json b/packages/admin/dashboard/src/i18n/translations/$schema.json index d1da922930..cb547d3c13 100644 --- a/packages/admin/dashboard/src/i18n/translations/$schema.json +++ b/packages/admin/dashboard/src/i18n/translations/$schema.json @@ -4049,6 +4049,45 @@ "required": ["noRecordsMessage"], "additionalProperties": false }, + "export": { + "type": "object", + "properties": { + "header": { + "type": "string" + }, + "description": { + "type": "string" + }, + "success": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": ["title", "description"], + "additionalProperties": false + }, + "filters": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": ["title", "description"], + "additionalProperties": false + } + }, + "required": ["header", "description", "success", "filters"], + "additionalProperties": false + }, "status": { "type": "object", "properties": { @@ -5740,6 +5779,7 @@ "orderCanceled", "onDateFromSalesChannel", "list", + "export", "status", "summary", "transfer", diff --git a/packages/admin/dashboard/src/i18n/translations/en.json b/packages/admin/dashboard/src/i18n/translations/en.json index 6c7ee59d6c..08a9b566c9 100644 --- a/packages/admin/dashboard/src/i18n/translations/en.json +++ b/packages/admin/dashboard/src/i18n/translations/en.json @@ -1080,6 +1080,18 @@ "list": { "noRecordsMessage": "Your orders will show up here." }, + "export": { + "header": "Export Order List", + "description": "Export the order list to a CSV file.", + "success": { + "title": "Export started", + "description": "You will be notified when the export is ready." + }, + "filters": { + "title": "Filters", + "description": "The following filters will be applied to the export." + } + }, "status": { "not_paid": "Not paid", "pending": "Pending", diff --git a/packages/admin/dashboard/src/routes/orders/order-export/components/export-filters.tsx b/packages/admin/dashboard/src/routes/orders/order-export/components/export-filters.tsx new file mode 100644 index 0000000000..594e28d939 --- /dev/null +++ b/packages/admin/dashboard/src/routes/orders/order-export/components/export-filters.tsx @@ -0,0 +1,22 @@ +import { Heading, Text } from "@medusajs/ui" +import { useTranslation } from "react-i18next" +import { DataTableFilter } from "../../../../components/table/data-table/data-table-filter" +import { useOrderTableFilters } from "../../order-list/components/order-list-table/use-order-table-filters" + +export const ExportFilters = () => { + const { t } = useTranslation() + const filters = useOrderTableFilters() + + return ( +
y0)qQ2&ZJhFSXj_K+kKOH_q zeKfWo4|ME&%Ym355&q#Srux!vI#tms7lghKIqdxH`pTg8n}%a0uIL?_W_I3jRemyt zjX1govrvnKMc$)b?p}^>I5IKeQ)tJpvtDV_Vvs}MO@9)8Xt^|xEftts?l86^)3S~g8Vcct6*DxMJO zkF#|n-sLVgq!E_CF1s`-0=gL0jGx#+&u1m3PR_xpJ&B(gD4Dyi!Q!$uNtq#Vf-sqG zt $!Jn#qE|`e`>YUn1yoZS7GQ{<*Ngl^ zHO_n`as@B1PN?&wQG~{b|GAY%%|Y*+mRQJ_t~9RAd5Ou5yXn1*K-4S=r#S@ouYEd( zY teTWiM$w3~>hV>x=r?}ID*zgyoborU z;MgriSLeEd((#NDq8=ByDzG;0f)0i}e!v|KH!lydLG$bC^kwDY)(`+cD%-N!<@gls zgu%lYmg-r+gsyI{nu4vb1=#&h(qJB@phg*0?4fM6;rDuCb$vR13D+@yajJO}ix@Ql z`-9L-|JiTlT3;fEiLT~-RM(v}jNFUh(;AOiwZ1E^$Hb0Qc;|#~m%eY8ZM03#iJ#4Q z3C64|uS#Y{Dg@DsL 6LfwGV0?rxmB)? z_|))Aq`B-?dqDYMf1}~*B{}dMHKMLa!faQsNmYf2ETd AaOfEy}ef?I5(rY z7 ikEEc NQvtb={_h}`u0fvX&*^TRG}QhZs;GJ1GR z%g|);se8f1u$t_rPOlrLZwTh-EbR5qH1?oZ_gSiL1ruH{SC{@cb>LdXwzol_VqwJJ z?m5daGIwp;J4xi~V&Eyz40B{0Sj5=S`)x|4snlTqB+=piE}||Iffv-v6$efiZc%Th zB(7OM7X)=mGJE|4klZ=)e-`PLoOFAgTQN#6Dwpg>a(Ep=cij2Vh>SxL88)1Ns5^P7 zShHaN09Ei+xEnX3_GNdgqVq?V1!GaaPyWVoe^w+O(auG(4W4wmgOGX_XJju6B9irK z#+WYqXSi>o8~9Nex@DHFv>8=p8cV%$1U70}ifHP{1*I0nfs>~Zu{lcC*!}FRG+DAB z!%}B43YH+MlZMP&&}7-6*~<~)gcLS=5;Arsbox%fUYArrZD294PGSkT%jfV-2xC l!$ZlHF6>&(8PAL zyeQys=(XxE$tlUWyK738rlNj6L3snB#whweOCW^xA7JA!-aV_!&GF{3>F{Qb?X%Ju zuanc9dFO9xwwTeOv!33rg5=VTH%x$@0!l?8nKf$s4}?pKotdJ0%aBLA42(LvOn@{X z5c-W0$9q$oo|j}dvjEFSROd?m_HIvNDUTN?JDWk3wbNSXErrnt!E%jitz*c@@I zczrT`{2&$;R#uv>4XcGsl(bj#vWHV^d=cL|mrK($(nu{~pPOM!3E2(x=Bd$msRFPH z`n2Kb)0uO*hS$SXXQnc=t0vsAl$f&6G%ZM(COO3`&2eD*rds+)2~%>ia%0tCgGlRy z`Ra|G;QpAV^va_hTgHUwS?A<6*mW`btx6mQm%^`^=q**&%mNxih7D11iBaKhSBi#O z!E2Jj=IM{5lh~i|^PBud**oAP5_|rQ4re9F!;{x7{sFBpfvSn4@^D2u7hg2$Q^4G& zxw7s7Z<1`nQNV-9qZW^8^n3ZZJ?dJcfXvl3nduKT?w0fE#EST~0TqwoYA0MzCcXfP z{+oHQHik5_%AAZqjlcu7ESp!N6K!%Bd4ba!QAbr?d4q--ts5GQ?C8MCeNsc|cQRvk z@Z6$T5yHuhRw`vwMtJ6Ha3_ksQtDo$3UkQbw{7>5#(2hu>bu38V<&-3%D~=YZRLLe zeC>aLvwr|D-+PG`&oMrb{%h4qyvPm(t3C{r?UpN@bZteE?u@*8kb;C)31fS8r~%Ed zR4SNf#}CDIqwdsA@r3uK`FrxY_+gJlQFh{&t1@4RCvG{FdHe<0UhT=%`l~d4jkS%mKJ}7$s93dm}tG5lb zfS!V`ABU+*gXY~?VN?So;8~f*stPhtZa@_6yEv^yGtm(QI?)AnN{e;g+IXiOp$9fN zW}#)Mk7jYf`-nf4c C!i<8Tp>jdpA6$WDiKnDm|b1e6RsehknrCzaR zDqsjkUKzT40C|wT`97;_s~Z$n+Q0yNhVubGfD={fQDfQDKir8|Gzx70i>TsV6?u zcg&0b0Lg@G1)1H!=8x_guZKAc>eW3Xy!yu&ino-jx@X&D-5=dI@G&5^um2RYpVbZi z_R7V5L`bGK8|srj?53bMR9rtipcQL5Xx!mN-ZYaMRM1^^JFx%e_|t~1w$R7L`tE?f zyST>94nvt{8@?9+cyrj>GTqfHv$RP+S&*HQ6&WPJ6MOX<2n!n}e4^VIN 6`r3j56XA#P- ztlVt9JW17E&!ShV!maZohKlrBDv10~Nv@^`ts8vUiqJnmx4m6P^;t{Ky3Z!S+PN22 z%x>@!c#O1)k)E-L-$Tg7Rzt*Po%KfU3A@R ZCp$B0u&+eR>Hz zeN^AJsO|IF8g<>NbFxWYXO{9Id4EUa^1$<)t+!aQ#7f?!`gpoRw6lbChA!hv&xJZq zMb7q>7yygvG%9f2P-L^4y^6cE?4gu#YQvzgF&Tq|{=>s4ZLsR9MG+{k`kdRcquHHB zv&3WhiyGLhY=FNmdJg;W80jm#la=m_MT<1_&W5G-9-v`RTndwej9N2V6{t1A;9vdy z)2eq%`4@ZT9Fm!{y8fS2O3KJ>1k}A!nGl!TEp1@w@e!jJD#5wv@zb%Ud{&CVClQ-N z)lPArD>~}gglkX-gyzRS4K}FYyIh*EE()BK%V9rOI+m4w)b(qqa6QboQgBuR*Fh}Y z3 0vGe16i(gwt!Q9!EUvR 8?H?ekh`6}noD$pW z%u5>DV5E+&In$S)UVT!gTIzY@HzmyBSoOk5p=PVA#h2f&v-M5>Ybg>#ww4XvtFP&K zFmcDwoGdh%LED}7IQeHIj_EdW@v9dibW{SF_sb@1wN-B4z-O>FZ#0Itf4lEEK5v$} z!kOXa<*||Kj_@6`=z}0xpLW3j#4_H(5;vKNq&tVZyVdJ3EmI?BgMoM6Sg!g{ow81c z(jH$M_jr7DaV_OYm};CafIM&-a1X(l? Dc*TF$o{#7-9|b`58+=z&f>Qe-;KIRCUlD=WI}(oRduk;|X1$g~YO zB0p-iUwrRkhlwOCaK HCTU(eiWr01v1Qu{UrYUdNNEWe8JtBcYtRh-7_rE zmM^ZqemhHjjL;*|BowEicB!aQH&)3`{+$t2y?)G$sG1lWHvycO5WP!oG{DanWc5Tk z-h9M&Mu{b~!1~4zxY1On(WzhP0!8HU6}Z!k1);+|(@c$R`PXcRYq-j1r@W&wOGnlE z$C?&>in1O*+${vO(rsVX0QM|j8emqH&dWbrmls8U>8J(7-p=A}f%Gs~igH%|2vy>| zny~1`4#uFmc4^Oh^=_xuNaZYNrV-8bLGFdLJ8GycEy+`khpvEoun{#cWM#T`$r=V% z=u!D=Np5^7sRH{wlPDFR6+fL8xN);6m7?DnJlZ<5%a#{+%f-M*IzGznwOBsM|N3{* zT LMMcCHWO=W~xoITs_nGK=c?g->%W1qVGTT$&J-f=~uXdL_H7I4L*s7 z*7dL(vS=aeV=T6m-D~S8KUqoUN~UJb|I*CyqrnYLcd}r+12?s@hGZWb?;(YL zA)L4n8b=k51}14IOlS-8VhbAdNWXCJmgpl)^`@u{AxNhX=%54d$KOceEsIS?h(5X_ zn(k1aWCR}u7?;W+->dt2JB%2AvPPz=l9L-GZq?Lu2LTIcVq6SH5ja8*>XW?@-c MquO{sESyRMh?f9@5+E{sH &3m|T-;##REwB_lfpt(MXKRj6{dsUs#lf&0k#vFqFvwX>(3utI@V z4RLyk0t|{js0VW_xA$1F;)Ke^)k=yc7OXb5eDIXN{bBTK+H}ZuOwGjeFl0&Mkq3dY z*dKDX+QY=pRhrjIoQ`qBl^bgss*=7mSs0t8FyItr2>$j(Pz3|hqXI+BS2??jcNXrr zvlm5=fE}Z`lfyyoDg7J{W;j?GbNC(hrtx63I>u@qFPX!d8rNCQ1f*xaLosg7F3(l1 z1FgE;dxD|(BOzEeM)7z3-`aB3$Ke{Yvr&xNGJ2IAvt~I4p;=$`6hCFE4fTd(O3{C4 z{#h4a`G|V`4?rHIspHW2kylykXJl10(J(0nk5=(Fys#$S>yUw^oaK&MqM@0DS#yJK z$#+E}-3?TS;B!3bS@Da-E?c~bUAm5r<|U^K=kglPaFQLL#*Ss9F)N D zo&2PYe1&ucUkTc#O@xq*FQ8SgzpmzJo#%GY%-#KsF)Z_YtL9skTvZ(A!(#rd!e8a+ zcL&7X?bQPXxSTHSV`MK3RB^-^llld-wwSRMqWo@_=9iW}IC+_A2tyhM*u i@8V+Z0%ZW2#l<*+)J{6Ol?78QaU+{|%AWYRyWOdj4&cx5DBi=v)~u;SwGUzhI_ zA`WAI54QKEk_Q^)+zP>~<+WUQUz9qb*V)-XkCIewm>g){bZqc$x_G&=hEkZ1UbvO6 zk&RI+8K_B<_*-Csy{2CeOco4~q>kffD*B{oOKa;z@}Is90c4 2_cAHZrnQ&3l}srr7ihUm zd5#Q)P?|%u1hj=T2T||bHcjCRYnHHD^g;1$gulL;{Ya52Ph|{BK+^4=`9FYsB&dD! z*q!5?yq-XjpJe>x0C0#y%=!g@uL(e5f+o_oC2GoWMe4Y5dhsgSI;Rg5dypC&47_43 z%$$yD@*5>{c&&*chpxgJO*By8Gd?#TR6m0M^>a=AMfMsBSQSTlZ?QndVLfZTz2F4S zdF u71PF`Np_M@qs8_DpFg^E1PY6OPw6Nzkhz=ujV z#8|opxdmP9NmU_>uQ x{C2zyDkP zARo<2E71cmiOT!V1;UctmjuV%qKzr$>XGjmJ{=DheANbQbU}NrpU;(Q{M_n`qGTm$ zw0hK}Zpp+ehA5SwbKGuJsyn ?c7hj*$E9TB;n$Oq_<8N zjx5D)Xugh?3-5vKn7(a=FN@F?FG1tdS=d>tHyL-;pVb#Wc!*` zR9uq411Z|N(B1aeKpcqV`cR;aIn*hHayT*mh`dw1F)A{1a34tlngG51UyB3j+~9kZ zi^`&YU<-xl%Z=t5XSUeWe&ov&*9xDfja&%{nLaD#AS;cV`q7q6i*C8RaooQvdsxe6 zA7zl{xq9>+6w~1Qgyb(_WbYoo{^+UIHYHa1owCPtBs1qg$2uax UGHr4Ve7c$%7v+LYXET14FQwkFh-H8}MI@ncnAN*oRxx|OdW-G0a=2t%OK F~z7g8mGQV#kv}yZt 5c_+;0mQCX zx;+dFaG^HcbTXn0(hdNDv>GOul?>uoD8;XR31xc|!aZsmKFc^Zt-FPLaW$A|f?Ix+ zNoJ?)#Nx%(XIt*b=*XmF0lWSIZaOOAFCj&|#&_ZQ `BfIzovL;;mGu%P=da+1`b7h3RkBP)R=@>)jvf38J8}!^;=g!lvltGuEZ!Hl&fvs zRgr2CwQ&M)W9Mj;icrJv6S+9STLQX_&qlQv8kzWHu$KZcy`KC|+uIj95x=h*Z6e8v z%NmG{)q#uS*BP8UUtmBFWJ?Tg#`ClD!f5}61YFPrxllMCgmR0KR9jh3?x8CzBK-9Q z$@nJ~M>9>sr82s0vG?LL)ZS4)BYBRG9uIdq%m?VD4QANi@=}aGxvG)8MRzHJ7nLu* zJt^OG^3hBmqOBln;i*^Dm fuZ9G|$+sVyc^*}sG9Y~)jn|0yvV>`Wd zM}dJ7ur=;}LU63?WjGf^$U=UFQ@fKO+=t GLOv+>F)9JX$J IK4d2d@5c&0hm06*L>KcRo zox4r?-Lp3*$tV{W4|5cP?pR)zDJ{m&p-!&DZzO889kVKP5vW>r#8!puW~lyXq)an` zr_96Rci%)%|Ko2J6_9h_y3kj|;>p_VoP0^As1^?&ya-OTm&-3Qv6Tly$I<>KK)5-_ z5#gj2{D%66W!>h>VfQw3xyZHfwJ8w-Z8p#d4%c${wpA{sufGHBB1je3Qn+K`Z5MjH zR$!62UX{dcvh(T1yg@%2&|WKXqt{sl2+b(R7V7Wc^t_?e$vOI9MK-U@W+fu7PUEIK zHE4k`(LYLPBw! W8-c~yn)V zs=J@wIYFCBtHoSZceXVbgNhkCdz6R-6|J7Axyd+fF^bA=h!8XtB>Y3et*WX*zhm;8 z(N~!+H~6WfgLQ~%O`zwShjN=m$tRvz2j!I{@$HKh5cY5H4ROPbeSGj$R8Dsl=czlo zNS3r(Kb!DEe4yiYQ9>d+bTSrv4H2pof=i}>gk 0+>QW&1hA53MB+>(50p*4ZmKGO+M4v;tbVn`T)34}YV~1e9&c`@p& R@cX)bs7-5!9NAz0Yta^8}-eLEJ!$z%3OAhXKp~E%9(5Y~L zb}}nB=N4@Lq#t1_T}?~bH*e(O%}YQDsflD|w%YPCCnGHJN9(L!_# 7E`0zU1Rc(QdIo%f=!~hgG z9#!>HY$`kimDuTY2IS3nX*lm6D^2FOTa?y^t1rM$BivddEzgE+Nru`pfWc4w+iAi# zBl96UYW_r?$L|YfNLjD^h#%fjBxdVvRJd4E<}98z_U8L6pqfq%O8l(!LDhBm?Mkgd zlWi9ih-s@EorG T9`6A!zCtK&Ja1hxt(^?)Xj0|A8Qc4o~I|LAAQfO zA}@1Yr@zy*TJV;GhE&SBqOmIe09J?HJO*d%^KxUv|NClP(tg1!gexgYd=u>7$P^b4 zCqiiu$@G%aJ@5?{7R}*zM2jU4C=Ea5BauTxI(6eQN~)vP*MMDBBfZ HnBvLN4};ItP39*VJfM zJep4~T6BZ!L-dzE-5Mb;C?Q6BQt@|O0eMsm22sNQ01KNSpQCqr-B#x}6e0MjJ1-|L z6^aMB0J!hhPf;WerT1lQmRll)j|*-tUE#GP{_XcC8dJNR>_ZnRd{pdkd$Onq54 r2MQ z%P?vJypSJlle>(zZ=1Sk IpI4S=69wCVaaUd1>cN+sPJHs`-`16$glYCoaWcF-g$6il56X!^V YP8p8c}qb|iDh9?4ACVd4H_{$;Q(F}lxT zM~&>tvJ6kL&{PhQp-{6C#FRNNEkSOh9<9mfwN-)wGfAOQ9dq_$YVv7^s>iY@@*l zNN~nv%h(}GMsE5frfHKA?kS ^v#+VI=bG<0g+bs26TpOX#JYj!7`4cDeSYm3q6*Xyp<`ti*da z#Zib*H<3MziBTe=kyVSt2RChBPV0K%{sT}lk~<#5G9V)!#Y5d&XsS7$$=jf*CYwk= z`8-cx&KQyeozF-~9a(#DxQp?(Qj(>)UPkJ5AE!*@w>rptE&UW>L=%R*T#k;!Hmd02 zHyQTu4na%i&W$)Hwt9HCwjL7sL-cB&W5C!FAp7Sd
zEK_@x@8qQGoq+V!mX}F%N)hSBdEEIz9{Z;$8yTh9G8Hk-yj+kp0FOjn(!h2KdaRA| zhv@b>tcAq0SJ4xNti01G5_s|;X-{o^tIGmhx~Ty%DXc@5*t(=0@x$+En3rWDJlZ#q z1eQUfj9g1GL69Uu0GJ6qEHb5!QTC7P~--Ts$cr5F|3|3H*Lu zHxGKJ`6Z7(gJoMH-I5~}Z~MKn;9U3YtXyGq_%Uj-@xA$ZapDBsw2gIr)b<~K?y{_S z3(F`g^DJ~=f0uZjQc&46GA$GjLpj8FTZL8icGK3C4 dN1nc%*}u^re_>MgVLB z5CL{$gm>%omH3Rd&yuQoL;LV6dgYS*Ce@<9{Q7wo)4!}PSr#Xi)*2YZf5w2lg2EJ5 z>~| _Zq#pE% zQg0;fbw);%dOt(=-YPK30o}m(4C*$-HNyB~#_!MX*ffToTi(G<$F})3 v01cH@_wix14Q@HvUQ!__>xP!_`VO_7%pG%z zuqkO|In}R21^2H&KZ6kuf!6T>9Q_XVZ}b7n&867(4L=ej@9W`E2G>1j1}3@pIs1iw zZZV4C+TVLQx$s0$a#c5TOlPXM(%2X&+8aXP${tZ%$ !tQXo z7wY|t*A88?_$6sG_<=!UNIbBnt4NaBHRnd`SWfvPy?F_eRrxuLxFxbYQ?H}(__%Jp zvO70Po?gT#M(INUgE|+^Q|(#pGWW;12QxOds?}`^QfB9n1EcWf8%dOG8@*2GY(Zil zqQ*OjL@kG|3zt&XHZZCxO|V&w-ft+P$hO@>Gj-=V+DlcZbM0Nsm^pe*z2=S#aEdzH z&OkA1tm$S}z_9blOl>xuo6jhh?$O1#21y{CdeT3g!y6@!KZ$;Y#w0$PPofk$)-~6= z>*|)MO|xv8voI`5h>4KF{{CHm OO;-|ecnaI=Q7-$pY$f!q!aHT&mo3? z={d5sl_Eu_ePwP&Fj~52^Tqi)uWj8t%Wm o@OwA!Bvs1v`LcbRF(aUs)(TI@w z m{BKXvUMY3JyY3ewHdkL_`tm{^~o``d+GUUk-F z$yWE#aIyP!E$yQE%Ry5*eAJ$zlygDV-XilM4IYq6uf`hZaDV7xa}6hs7R75!TCDU* zEW0A*_YKO+3Lr3<)cMPi%kV!0&jBfV@>bEMW}i5;&SRJOJ8@R{78%}d-OknfC g$z?-m1!b zME&@eK2ia?RyyoEdO2>CL3N?>`e%H6x?i>Thd#?Q;kE}@ojDU#O&Y|gs0);4nswX> zyYEM%mYg(hS`Om%TJk5B_Mfb7K5Y8C=KOMUBmTn+Ge2a)_l8ZCkGJGmdH(>zi)!?H zK$4BHkSGCYZ0#odqQ*DS5cBYZl9YWy5{ix(re#r2i 5#`GWT z-yTu=$J*0wYVeHMpcfU}xn5n{`)`KQ*VVqthtQ_r6p7g*YXe1BMn*%+%;kYF3GqYP zkno4kA&w!BCwYIRH};T!+n;C=-{JOE39}2V94={Z?J2 jSS}J{iYKffLrf ze_dl~(t$_!{X$E+=;GQXqqD@RJUU}T0j~{~EaT0W?Caaa `Y23$ z?P_!(b0d(;fKMjc?(HBGT%1U%*zz(rEg?jcsTFNe9d!&-I(+nPWJ0cU7&2DMa44sI zql^V-+*W00{ac0rM5s~Vi#kDOD4m0Qt1aR_PjY3y u>i55p}M z{k}3SFFuVKwA2#VI=jMm$4(ke-Kk&^c!fQVmVp>%z!dmF--g;(#?))h+M!Ot#m(8J zjz^Z#Qxp?0arhY3_9T%cLu@ 3&n7K?75Vicy#zgLtg-4t{fqkx z<5?Q9u&MHXw%2N&0UdO)H={aP2FvVfw`9K}nlh#Z%S34vt|Bmh-py)+7pzo9XS+Go z;MZfn%A^XuO0$}JXNuId%mvHv^vRhB({jSgQlvTAjo8Rl10Z7UF($Y4X9$~r03t)s zYd_pDLf}9@`aeMXM_PkE!!dP3y+#W|jlNgBJYW_j&tN@FoQatRxK(vQfdpqwF3xUw zd;lx{Xq`B_++y)^d(E%z6$*PaL7JC#A+grC! 5C{hbsgx zJKxlVIOxBGWVR%=H&hx}4aB6-_YuFnby)vd#pT`8snja(_k}o Kz# zK(*}`ilnRpx9syqh?5_s&=U`6&e1A#aSMM>y71Q$tJg`sn@fs$d$`-0g1LwmOU>Dj z^MI=J())K$=F7_7<)u|HrzvgenEm}bIV;~?jzx)?jAl_yZGS5oiDM*dy6=^tN|!C> z2M! C(amXe(o|@*M7mRd2u{vhE{A6!2nfe`sW<{wPQqg0PILZ0tV?`oE>6>^< z)_D<*q0F==)lMh&FS@MCna7D}(bc^fHLud3bOSKUfY|<7qT(6xziSuvpEXzg94SWK zf-V^|gXQz&|Fq@#eSqo_Z)T>NxSc*4~s0r^E+-~U#nPK z)QpIt8&WHN =G0-G(HJ5{;>ZIk 5W~?Eq zcaYbY*N-RG5LjUdSl|(f+f7!JvJ1kF&Z99;6~lIS$aNo`c?-_o`TX~-4zKE-{-N#R zHIW~}$JyOtJTQgcbSI-q_grFr(9l30mzo(;+MJQXjm{VVdoy&moH|;&l5aIu!NnM* z4TR1a!11rYs42;5IX3?T W1>6EktXQv z);&HiJjp*Pg@2qKk=MD%$)`Xh!93|u6BTV^M*)G1it#n{C^T_tpGRQzWyczvtRlA| z8t?a62 )Y_XPp_+ ^M_HiA_)o|;-4u^jrT~gQc#W^kY^&e{jkVHqZzcff2==lc-fBZ_vATR;mH*ef( z+&UK0m^>`N?&s3z ?Q%QaXzm$Qq#2&c zkkxLqJg>+kZ)1WT#~<6}6_cr3U2-V--MfgEt(RMC3iWk!bC}L1tWe+%tJnc9 7mbzQX%OtKVhHwMnSXISsZ&!| hA% ze_DI2WP*g#bO^HSwkM^k$J#0atg-?AGBd{w6#gao5_&jrE8ZJDWqKkDgY})90pSZ7 z(Lwob;Rv;TC}giN;<$G#?Bk81x Vr#05==Cj)&n_3gtGJK;n%`QmNX1|xGwU`lp1smE3>d-RB$B=*o9HGR zQGVd{WI2!#FtTqplBi<3ULBw5i;9XPdy`om5ry~Fl9^-r8v4?tuNV>~6eAbK9AwiU z5rp96uDk0x;p}j7+u5-4msk_cTAzpB&Lu?onPBwl6z3jg&uFdc=9ePOa#R6i%<@#{ z5)4DSRPlI*_OEi>E4d0h$~U sbIbT+}&PM5Gz@_gd-4qYzs^pDY zR8_Ll8kFkneZ@yt0k#6CV`qqk6&7Hsz`H8${bN|UMkgndtG;r5v?fXMlf-kF1~_;_ z{PDO0;d%Z2xB_uJ<)xlhHW2JJxufzU0IB=t*^1$E1I1>ksmV3vb_S;t9f&!&P6Md0 z yk1>zq{r`WVZ NRE$&HiM)}5IM7%!D0JAaQO8@!Te6bhk yD{H$+4l<~x1^u+DWH3KJ_qcXGSh+G zhczanfoHJ4^}5%>2NhPU6SA*mEAHkqgDRWKauL jBl+}>80%p0yAr)aZ5XA z*3CIe`Ov7KngJayK2^|(jM;3sfhfE6zLJ^Wz_(RB97PmJkp0J4&)NXQsm>nc{yeB$ z9Oc{+qL=c6$zuqFHU8_{! eZ634pr@0!#W(Uw5+5CEW@H z&d||$3r(UJu8$z#xMZFtA}k00xh3c>(?wU>>+m9mF2oUHvlbaCe7LB_T9+iz0_w3Z zul%0?^BfH0&F0V8YBsja3d?U(MGz&Vt(Ij)k34W&jmOY0I+95siNWv T2xsTj-vOddSgb@hR!dF~pqm>_8xKoc`ZjE8;g#->a$~wS~g#8h{PL`EZioPQyG$ zOHLUWrh8zPZsmP77HSp`Qfxqj_x}KYX!lKQp(rMzXst4Z0R=U5l@X7rRfQCvP7Xf0 z&Aun}_1?9S#jexTkPum^<%&pUd>=w&Y-$@F^7YZ^KjTZ4JtaD*@M+eV!GJvm?9TrH zaT(9`Yf-n?+j{29BsB9>)KJKF%i*bk3x7^?oc{oSwx?Cm14cWMQ)gmwZEYQus#x55 z7U4xDvTqg$X^ONmIph <#kthFI uUXR&2=4`jOkP({+JqqC}~NDmg&hrQ(kaTb$=;<0rY) #Vd=&L*a#1$aZZC2`N@{{TQg(_Fpq%IOzI{6xQ2 zQzDO^R7hG#T*L 4UvIc
Y_DYh^^ zqXhTutJQSW7>_P3Y`hJ>T3@?$a z#%9W@v?cG9`8p+zrJhCuo;HGiPTEnq(OaZP5?WW2m0>6rQI^Z2Xg4SWdj_N2pZ>(vG}uiuSRZ_6qnm%gKy8#R5Xe??Y<@)k7LO8)tP|$ zf54Jrvj_Z6>aLZryIO1QwX@u+2394ggd?f`uPMU)Kt9>h1)HU9bu C8o$4;N ze=!tq&*f;uZ6CJ-2iWPB$1Q3_Sv~&%_=86)?(pZS6)`4B^T&{~Mfwsw!PlF_)kc dC=Z*W5kGa6-zNQQYl2AeZDWCO+&qYH!*=i{yjy6?Kkfuor5Bb*{ zhi)4Kj`~cXjJ-lNRDwx=Gfdsf9(LsLG56Cg>ZkF9{{X}3p_)!oNGYBozxqlto_)qO z0hKP%2#v8TPV6dXLAd7x K*ZTv#U7SA=%|`=G%gDKBJv1 z-eg)7@L{H`YJ$KAk)A-KJn+M4C+VlF;z@6fsl~aeP6p%zx&i0|D-3_%8dSN-BPvQf zC@I$fK@_YqeMny9>DZWti*N{wwRa^l_*9`>C=Nd|l6`V>jGv~zU+L20XQ!G}WRjLm ztu#FD&!acfw!BwcVu}iR=B^Bm#Z5c7-pAMh#(+;>Q1Cn+BTsLQ^0ei@l%G#?tu@^d zAwM@1u2gf<$vY&BVP?)lWVhc@Q*C(n&d(x&jkx7Tj$El>l87}Jc;=0XDrF@~=oJi$ z4M=%k!U_q CRK)i5j{g~O#O#_3#zG# zswBi=6lAkBM4bMb`EQga;TxGi8QYzpXirxNeIfdGiW+1xE6T_YM%;3Kx#wP@jMGz@ zB8oC!o$diE^aD`OTHhkE$wwI8KoXPO>*gw#cF9pA+qV&spYNsIDu?2!Z +DX<;2ywgPYh=#8u`5=d>s$o6tGcM zJm*7dB~>IZ@2Tn>$~>N$86-?e$vPH>l62bO=Z~(b%>;=1m&`RZYmV$NKDzkT5$9+- ziSz?HS^LI;x%v`f8;Stl W>W0u`eR?_KigI53@AKlsotam z!mz><=rP^Du>0yN!*;vO8d!>`8xwKz#=gLM54O8nfL%fEp;#}`(MXh*H2DZ6V)IO( zNmS?#0cj7F2|vn!p2Ln#v;MuE>10c#wnw$KcJU=`MME^}8u@T%Ief7v$r&EJ9A{3i zU}mO_)YZtOWtteGAUC$>_5=LRt6zFRbx%xOs;<*Gq_xqz#ETz-o0U%0UgW4epK?Z| z@>G 8}u!Z2Uyz4`TB5wX?v&6EZ#YhEB!QI=JT zoOcQ38$tg7kwyovJZiKT1Jbu3mv$*oThwkS00gl1Ir5KgeTKEpnVt&OidBTt)0Ey8 zM#CSk)4%R@n5 GPJaIYPCI(`(sftD$sxH@Vilh;V6j{%&h8X` zib?Kqk6mmw>#aGKNaf3fz|Vetv~6nb*v2`tj&TxYI>t*4b7IY^Qfah2(Rc2%hgp3cGD9fs6>jVf4qYvvYYYO%A1a zu&5SB=0y#Ut9q8=Dte+?+f`~6dU~6EV?jD|oUDpLKTt+Tq0@yIPTOhgXRml_Bf6pn zsJYTWA*wCv(#8U$z~jGj>c?Jg{c}lA3d>I-ylxKJ(lEhs-CaEo*Nq_GI;yh&02;|_ zX=0K#$avU-2irV i z6>-MkeL>2f=2PEQt`rp4M0jWx8$Iea4v@+CRMmdJgURq)kIGKQ{!+cP#bDh)Dp$Cx zUvrMWCyf#>oRUEttT!nqBoevcH@f5X9kgzSj+#0{PgOMWJ0h%WPcVgu9CDz4kvbhg zN^VtBMSF>UZ~&4|v p~(|e2rZ+kXj`$GP5a~D9eWXvFtxxDZt53C;k;=k>hVNlYrmfBTdk(5kG|s)JaPm z?4@Iq3gaH5XgJW*X^OFGz)4uE C_>!4oWE=n_|=mHu4>m62EV41&UZHWhS0gtPR^B WGO#{~WJtJYg7+EM003E;0`r3MPShs3eerB5`AIwYvD$Bg5{ #6q7g#M%TXI- zaDja_O5tFnr3Iy#o? wR^H-`)_EMvT+wg!l;1*j+XT0Jc|-Mu~sHZ-6Q#Bj22A;;mOYGvoN&6qT@y<+JaNLnTOT_)#C28a9+|aDGG;zLIvjrYqqX2!iELX}##Bjj!H`k2{F{x9L zll9eQU9FYid+C0zLD7w09~X5S#PUxAouL;VZJ-@6S*cd(X%_dR5m8D%8CY&<2t0m+ zzZyoj*OX;felj>z41aBFR56Nc1T`|#sMw|7#$X#^ V)9$irp_) zZtFVMmI(sOQpX4K#|nZ~f6MpOy&+L^x `*nEHt10nwazg=YHC3RAaWH6SkvELdsfYhoe z$=rZ5=jwZBzbD_*8fmmyCyf=Eikx>Rb9dzZ&%UO&!#hN(%ArmU-oOnr(Z*PXh~$o0 zfWRM5zLJ=k&gYvTQK?Rzh|tE%5I!RulicdnaCD@NP@V08^*-lDBct7d+-I=Wdb&^~ zjGjB{hQLu|<0Y8CTCGatmcqK8{)a%W2mb&L&Zta^!~VvN-WbapfHKH%54MF-PL)*U zrc$ntvDe(|HzxEYxXo@W{{RJ!N8bwgMX*T&13uXF)hnf%w`fwz)Xjk)W+OQ4f6GqN zLaJNB0g<@}>-7D!GQEQ%GJ+Tk;2&IjYFepCYnl3VZN7%0O3k3k?Y+F@jQunJ0B2TE z!%jlQA~LY@>;Mu6q1EZ`inF?wRdpkB5=YpNOld=@>t<-CdP5v?L*^n!9Ovi{I^f)8 zlPg{&dD3Yi#D*qqgT>=NTyy*O)^B#LlBFbyId->*HU)KHt)AeXM n3*f1aw>Zg(Y^b$3yq=Gt%dZj?B zs?snyA90)&Ad#I{t*UDABva0dOb9Nl <9lDWMIVxEFZ+G^^GN{QNKkMQag zVF2z_FARTdX(DQwW2oFgPdDO3PBK2EdL3z4B zq?l=ErdNS!T4a|EkgCc%_9vWY(^q3UksjjB(w>$|S9)oOha>01AmJGOHA Xo}q?-+0<3TBy-Lri1Pafn|G$0%i9DEz>~(v z@_EuGLxn|zDf;8 udMwG-x)4(U}HTY3ecw4G`7^TQy zC&l{rujK1Ps@-yeQ U z2koM5ENpPG{L`@VF5Dl`>V+M3bp?5*o+XXkaMIvlduz%`TAZ!iFHuoG=1QzOXDyW( z)i%3QDI*i`al-NbT5Ey-007B9hRcjL2IHzk(-KIzN;B!<4nMY=gBfw57m YSs-vF}fPbe#o*3pOf%6aVuZdD3@-WUcM Iw&Q85N1enfQcJr@M<8PUFeF@`L(SSS+T8>1=JI+6+8XLG( zLKC-3H9jTUkj4)_9HKTqO!pevX;wRxg;Aqe Kh}26HD;Ty9-Ibf~mYQO#QnhmQ?5 z06AjsA>#wvLTToS-Y1z1(#gul86*MjSo&zc$Eq8XT;&w8JvdJ>Zxeai59Zo(KTH#( zUW>b^mlr9fsd90-_zwQcJD+lU4Q2owm4r0Bgw(ANNm$BRM~1(YV+uZ}J^ujLPIQsS z41O3nAmog4PLS=lN}Hsjp45dS1bCGm-$nlbeLPT7r7~^FIOLLYPuE&lv4t9uyAojt zhUV9r*f&Spw{3cW<+F@?Xo-V-c<+IY6Q!(LnlU!AlXmYY$vRJ-IVmb++CL47?;lad zb*5F)o<=f8lT2R}!U3qNUzbgoRu~7K$3OS`=|bspG@+hm zW>tU8tC9z75$oGk`i^;M-a`{UHyK9WGF87X&=H^5Xbo>uQ(Y-uN^%^;>=`)(^Ugk_ z{dEfhb|uI@3=w;}Nl#XfOH`#MJUK%q-f`dCKAK6mU!FrFnIk1ZBp_b?{%`WmsopCj zuazgIXMu@jWFV;9k}`j{I;kWxRTU4gvaT6+0!KWMeRWFX9huu8R9U)R&30e{RRpUL zNM;=KwEK4BzMDEawmO< (pFT}TpD_qq9vM1Ayp)Ev>X$hcg}@Vzs>EEMrUBfRnbVnKDY+}YV>vS)~!?C zss2@7BNS8T%FWMl=zH=pptiUk3skiT+nhGd@gL2PPJ8~EdW}h&xF4vlP}`NZ%=HqZ zZDn9`N8d}f!7EEFi5B=s$q}|2_toB@rjlO@qDFNL3CJ1GxIeCmTPWdzmF8vJF3s6o zfIm%8X;v*kCJC#mrlgUkqcTpen3g%1We>R5nBi-|kLIdLj1R&C(Eart{a`i0(OtJL z3ugd*^iQYC)3U~gxWV4MkFnC+@mmqt#hNnr$iYhkEVHsV7~C>5{#vO|MAN%GF4Exn zOo@Sypd4wgXsN1{K!IJP{{YM+XBq(&Ec8{)O}MOCzzrv-r|GHH>=^R9h3(XlV49S8 z sb%@ZS3F(@&DtJ_{Lp;9!F;#Nd%AAZn3Z(Va8 zvubhuq7tWQRuzh%8BQ^k Gu;jn(-)9Z2-bM(^g-vg8gAeMVTQ!RJ_jP_sUg@2OGbVUdCdro6@ 8ok1v)!f@GgB~& zW?*fcbDc98Ds?6DR5ES?>#F!}6|>uAfY;TN__VR$CgYqejt)=TI>hC)Lk-9+Gc1vV zvO=fxoc>kzLGSC1eYEXUbsxf>D`hk^D&(Mb8{Y+pV$6ABaoqc!G??{Rx=e_q2qZB? z3E*LKxQudn`wwkPtCOYCnys3qIvC-ni^Enb0F4hnbM(_~wsoC%Zp!=n>lf+j$A*dI z9K7-rX7$E-=j*L9*HZo;gt%2y&l=dqc`Sj&7~cg-w7@52_0gD^Dh_$lMHN%6LNE1m z`)QIYmN;)=+x5}ah`WSpAP*qNwwEtf^3cn$4c)ZHWgsgNk_M>NHsp3AxBF^rm{r9X z)N-d&3hFz1c=D$i3dd*E^PO|`RZClLlT%9rgsLj>IvnkOK?m0v{YEv@e-UC3%S$vq zNd&Bf`-T|jSVcv;How!&A>JjBzQQ ot|9rXLty&`T_^qQhC#&8@kINRGGX{N;tbx~C#AYTebkB?uk_tmOp6zK?u$?7q{ z7~@g9G0~I)J|%i9GSaI8+eUdA_4Ux|$SI`COHeS532m4pdmpc+biq>~SZ6MxK!?k_ zxX90aEnUcl63U^6n<2U64te%I-u&nlY|dPxI*WWYbj-2LB}Or{hhf0&_ZaWojdNYX zIV!G`#}mA=G{j+owtL{I9=*G8MzpTI>I#}RsiUR?!VA03cOFhZPW{G|x?0pKE7qQ> zQ!)v|DP R2{17z~UMbH+58QwW0p09 3dOL&({H`p}CM^r>>WPOVc{Jtj`XP!qrP56XVH)R!AlT98Lbl2kw&{{V84 z+yU*T8(lOeBU4VE2b*g~4pjYd@2YqGuB@OG!U70x-0IY8H6(FD=6O* WE$GrAf5n)e zC!Y { + let localService: LocalFileService + + const fixtureImagePath = + process.cwd() + "/integration-tests/__fixtures__/catphoto.jpg" + + const uploadDir = path.join( + process.cwd(), + "integration-tests/__tests__/uploads" + ) + + const fileSystem = new FileSystem(uploadDir) + + beforeAll(async () => { + localService = new LocalFileService( + { + logger: console as any, + }, + { + upload_dir: uploadDir, + backend_url: "http://localhost:9000/static", + } + ) + }) + + afterAll(async () => { + await fileSystem.cleanup() + }) + + it(`should upload, read, and then delete a public file successfully`, async () => { + const fileContent = await fs.readFile(fixtureImagePath) + const fixtureAsBase64 = fileContent.toString("base64") + + const resp = await localService.upload({ + filename: "catphoto.jpg", + mimeType: "image/jpeg", + content: fileContent as any, + access: "public", + }) + + expect(resp).toEqual({ + key: expect.stringMatching(/catphoto.*\.jpg/), + url: expect.stringMatching( + /http:\/\/localhost:9000\/static\/.*catphoto.*\.jpg/ + ), + }) + + // For local file provider, we can verify the file exists on disk + const fileKey = resp.key + const baseDir = uploadDir + const filePath = path.join(baseDir, fileKey) + + const fileOnDisk = await fs.readFile(filePath) + + const fileOnDiskAsBase64 = fileOnDisk.toString("base64") + + expect(fileOnDiskAsBase64).toEqual(fixtureAsBase64) + + const signedUrl = await localService.getPresignedDownloadUrl({ + fileKey: resp.key, + }) + + expect(signedUrl).toEqual(resp.url) + + const buffer = await localService.getAsBuffer({ fileKey: resp.key }) + expect(buffer).toEqual(fileContent) + + await localService.delete({ fileKey: resp.key }) + + await expect(fs.access(filePath)).rejects.toThrow() + }) + + it("uploads using stream", async () => { + const fileContent = await fs.readFile(fixtureImagePath) + + const { writeStream, promise } = await localService.getUploadStream({ + filename: "catphoto-stream.jpg", + mimeType: "image/jpeg", + access: "public", + }) + + writeStream.write(fileContent) + writeStream.end() + + const resp = await promise + + expect(resp).toEqual({ + key: expect.stringMatching(/catphoto-stream.*\.jpg/), + url: expect.stringMatching( + /http:\/\/localhost:9000\/static\/.*catphoto-stream.*\.jpg/ + ), + }) + + const fileKey = resp.key + const filePath = path.join(uploadDir, fileKey) + + const fileOnDisk = await fs.readFile(filePath) + expect(fileOnDisk).toEqual(fileContent) + + const signedUrl = await localService.getPresignedDownloadUrl({ + fileKey: resp.key, + }) + + expect(signedUrl).toEqual(resp.url) + + const buffer = await localService.getAsBuffer({ fileKey: resp.key }) + expect(buffer).toEqual(fileContent) + + await localService.delete({ fileKey: resp.key }) + await expect(fs.access(filePath)).rejects.toThrow() + }) +}) diff --git a/packages/modules/providers/file-local/package.json b/packages/modules/providers/file-local/package.json index 8b63377efc..93f5a18c25 100644 --- a/packages/modules/providers/file-local/package.json +++ b/packages/modules/providers/file-local/package.json @@ -21,6 +21,7 @@ "license": "MIT", "scripts": { "test": "../../../../node_modules/.bin/jest --passWithNoTests src", + "test:integration": "../../../../node_modules/.bin/jest --passWithNoTests --forceExit --testPathPattern=\"integration-tests/__tests__/[^/]*\\.spec\\.ts\"", "build": "yarn run -T rimraf dist && yarn run -T tsc --build ./tsconfig.json", "watch": "yarn run -T tsc --watch" }, diff --git a/packages/modules/providers/file-local/src/services/local-file.ts b/packages/modules/providers/file-local/src/services/local-file.ts index 5ca891fbbe..534d5e5634 100644 --- a/packages/modules/providers/file-local/src/services/local-file.ts +++ b/packages/modules/providers/file-local/src/services/local-file.ts @@ -3,10 +3,10 @@ import { AbstractFileProviderService, MedusaError, } from "@medusajs/framework/utils" -import { createReadStream } from "fs" +import { createReadStream, createWriteStream } from "fs" import fs from "fs/promises" import path from "path" -import type { Readable } from "stream" +import type { Readable, Writable } from "stream" export class LocalFileService extends AbstractFileProviderService { static identifier = "localfs" @@ -78,6 +78,59 @@ export class LocalFileService extends AbstractFileProviderService { } } + async getUploadStream(fileData: FileTypes.ProviderUploadStreamDTO): Promise<{ + writeStream: Writable + promise: Promise + url: string + fileKey: string + }> { + if (!fileData.filename) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `No filename provided` + ) + } + + const parsedFilename = path.parse(fileData.filename) + const baseDir = + fileData.access === "public" ? this.uploadDir_ : this.privateUploadDir_ + await this.ensureDirExists(baseDir, parsedFilename.dir) + + const fileKey = path.join( + parsedFilename.dir, + // We prepend "private" to the file key so deletions and presigned URLs can know which folder to look into + `${fileData.access === "public" ? "" : "private-"}${Date.now()}-${ + parsedFilename.base + }` + ) + + const filePath = this.getUploadFilePath(baseDir, fileKey) + const fileUrl = this.getUploadFileUrl(fileKey) + + const writeStream = createWriteStream(filePath) + + const promise = new Promise ( + (resolve, reject) => { + writeStream.on("finish", () => { + resolve({ + url: fileUrl, + key: fileKey, + }) + }) + writeStream.on("error", (err) => { + reject(err) + }) + } + ) + + return { + writeStream, + promise, + url: fileUrl, + fileKey, + } + } + async delete( files: FileTypes.ProviderDeleteFileDTO | FileTypes.ProviderDeleteFileDTO[] ): Promise { diff --git a/packages/modules/providers/file-s3/integration-tests/__tests__/services.spec.ts b/packages/modules/providers/file-s3/integration-tests/__tests__/services.spec.ts index d205900601..ed914469f7 100644 --- a/packages/modules/providers/file-s3/integration-tests/__tests__/services.spec.ts +++ b/packages/modules/providers/file-s3/integration-tests/__tests__/services.spec.ts @@ -49,7 +49,7 @@ describe.skip("S3 File Plugin", () => { expect(resp).toEqual({ key: expect.stringMatching(/tests\/catphoto.*\.jpg/), - url: expect.stringMatching(/https:\/\/.*\.jpg/), + url: expect.stringMatching(/https?:\/\/.*\.jpg/), }) const urlResp = await axios.get(resp.url).catch((e) => e.response) @@ -95,7 +95,7 @@ describe.skip("S3 File Plugin", () => { expect(resp).toEqual({ key: expect.stringMatching(/tests\/catphoto-か.*\.jpg/), - url: expect.stringMatching(/https:\/\/.*\/catphoto-%E3%81%8B.*\.jpg/), + url: expect.stringMatching(/https?:\/\/.*\/catphoto-%E3%81%8B.*\.jpg/), }) }) @@ -112,7 +112,7 @@ describe.skip("S3 File Plugin", () => { expect(resp).toEqual({ key: expect.stringMatching(/tests\/catphoto.*\.jpg/), - url: expect.stringMatching(/https:\/\/.*\/cat%3Fphoto.*\.jpg/), + url: expect.stringMatching(/https?:\/\/.*\/cat%3Fphoto.*\.jpg/), }) }) @@ -128,7 +128,7 @@ describe.skip("S3 File Plugin", () => { expect(resp).toEqual({ key: expect.stringMatching(/tests\/catphoto.*\.jpg/), - url: expect.stringMatching(/https:\/\/.*catphoto\.jpg/), + url: expect.stringMatching(/https?:\/\/.*catphoto\.jpg/), }) const uploadResp = await axios.put(resp.url, fileContent, { @@ -169,7 +169,7 @@ describe.skip("S3 File Plugin", () => { expect(resp).toEqual({ key: expect.stringMatching(/tests\/testfolder\/catphoto.*\.jpg/), - url: expect.stringMatching(/https:\/\/.*testfolder\/catphoto\.jpg/), + url: expect.stringMatching(/https?:\/\/.*testfolder\/catphoto\.jpg/), }) const uploadResp = await axios.put(resp.url, fileContent, { @@ -221,4 +221,42 @@ describe.skip("S3 File Plugin", () => { { fileKey: cat2.key }, ]) }) + + it("uploads using stream", async () => { + const fileContent = await fs.readFile(fixtureImagePath) + const fixtureAsBinary = fileContent.toString("binary") + + const { writeStream, promise } = await s3Service.getUploadStream({ + filename: "catphoto-stream.jpg", + mimeType: "image/jpeg", + access: "public", + }) + + writeStream.write(fileContent) + writeStream.end() + + const resp = await promise + + expect(resp).toEqual({ + key: expect.stringMatching(/tests\/catphoto-stream.*\.jpg/), + url: expect.stringMatching(/https?:\/\/.*\.jpg/), + }) + + const urlResp = await axios.get(resp.url).catch((e) => e.response) + expect(urlResp.status).toEqual(200) + + const signedUrl = await s3Service.getPresignedDownloadUrl({ + fileKey: resp.key, + }) + + const signedUrlFile = Buffer.from( + await axios + .get(signedUrl, { responseType: "arraybuffer" }) + .then((r) => r.data) + ) + + expect(signedUrlFile.toString("binary")).toEqual(fixtureAsBinary) + + await s3Service.delete({ fileKey: resp.key }) + }) }) diff --git a/packages/modules/providers/file-s3/package.json b/packages/modules/providers/file-s3/package.json index b460bb05e3..54bcf993f3 100644 --- a/packages/modules/providers/file-s3/package.json +++ b/packages/modules/providers/file-s3/package.json @@ -30,6 +30,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.556.0", + "@aws-sdk/lib-storage": "^3.556.0", "@aws-sdk/s3-request-presigner": "^3.556.0", "ulid": "^2.3.0" }, diff --git a/packages/modules/providers/file-s3/src/services/s3-file.ts b/packages/modules/providers/file-s3/src/services/s3-file.ts index f617a722f8..83d76ac66b 100644 --- a/packages/modules/providers/file-s3/src/services/s3-file.ts +++ b/packages/modules/providers/file-s3/src/services/s3-file.ts @@ -7,6 +7,7 @@ import { S3Client, S3ClientConfigType, } from "@aws-sdk/client-s3" +import { Upload } from "@aws-sdk/lib-storage" import { getSignedUrl } from "@aws-sdk/s3-request-presigner" import { FileTypes, @@ -18,7 +19,7 @@ import { MedusaError, } from "@medusajs/framework/utils" import path from "path" -import { Readable } from "stream" +import { PassThrough, Readable, Writable } from "stream" import { ulid } from "ulid" type InjectedDependencies = { @@ -165,6 +166,53 @@ export class S3FileService extends AbstractFileProviderService { } } + async getUploadStream(fileData: FileTypes.ProviderUploadStreamDTO): Promise<{ + writeStream: Writable + promise: Promise + url: string + fileKey: string + }> { + if (!fileData.filename) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `No filename provided` + ) + } + + const parsedFilename = path.parse(fileData.filename) + const fileKey = `${this.config_.prefix}${parsedFilename.name}-${ulid()}${ + parsedFilename.ext + }` + + const pass = new PassThrough() + const upload = new Upload({ + client: this.client_, + params: { + ACL: fileData.access === "public" ? "public-read" : "private", + Bucket: this.config_.bucket, + Key: fileKey, + Body: pass, + ContentType: fileData.mimeType, + CacheControl: this.config_.cacheControl, + Metadata: { + "original-filename": encodeURIComponent(fileData.filename), + }, + }, + }) + + const promise = upload.done().then(() => ({ + url: `${this.config_.fileUrl}/${fileKey}`, + key: fileKey, + })) + + return { + writeStream: pass, + promise, + url: `${this.config_.fileUrl}/${fileKey}`, + fileKey, + } + } + async delete( files: FileTypes.ProviderDeleteFileDTO | FileTypes.ProviderDeleteFileDTO[] ): Promise { @@ -207,7 +255,7 @@ export class S3FileService extends AbstractFileProviderService { Key: `${fileData.fileKey}`, }) - return await getSignedUrl(this.client_, command, { + return await getSignedUrl(this.client_ as any, command as any, { expiresIn: this.config_.downloadFileDuration, }) } @@ -238,7 +286,7 @@ export class S3FileService extends AbstractFileProviderService { Key: fileKey, }) - const signedUrl = await getSignedUrl(this.client_, command, { + const signedUrl = await getSignedUrl(this.client_ as any, command as any, { expiresIn: fileData.expiresIn ?? DEFAULT_UPLOAD_EXPIRATION_DURATION_SECONDS, }) diff --git a/yarn.lock b/yarn.lock index ecedfd8e34..df1b7f2376 100644 --- a/yarn.lock +++ b/yarn.lock @@ -580,6 +580,23 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/lib-storage@npm:^3.556.0": + version: 3.948.0 + resolution: "@aws-sdk/lib-storage@npm:3.948.0" + dependencies: + "@smithy/abort-controller": ^4.2.5 + "@smithy/middleware-endpoint": ^4.3.14 + "@smithy/smithy-client": ^4.9.10 + buffer: 5.6.0 + events: 3.3.0 + stream-browserify: 3.0.0 + tslib: ^2.6.2 + peerDependencies: + "@aws-sdk/client-s3": ^3.948.0 + checksum: 11edd46ee1f2ef74efbf9b5b422f77d2c792693bd90051ef3bda6ab36e76f2b6533f531df5d390da31c757efcc98b0513d70a14a28561eefa13e641847d1831d + languageName: node + linkType: hard + "@aws-sdk/middleware-bucket-endpoint@npm:3.936.0": version: 3.936.0 resolution: "@aws-sdk/middleware-bucket-endpoint@npm:3.936.0" @@ -3580,6 +3597,7 @@ __metadata: resolution: "@medusajs/file-s3@workspace:packages/modules/providers/file-s3" dependencies: "@aws-sdk/client-s3": ^3.556.0 + "@aws-sdk/lib-storage": ^3.556.0 "@aws-sdk/s3-request-presigner": ^3.556.0 "@medusajs/framework": 2.12.2 ulid: ^2.3.0 @@ -13465,7 +13483,7 @@ __metadata: languageName: node linkType: hard -"base64-js@npm:^1.2.0, base64-js@npm:^1.3.1": +"base64-js@npm:^1.0.2, base64-js@npm:^1.2.0, base64-js@npm:^1.3.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" checksum: f23823513b63173a001030fae4f2dabe283b99a9d324ade3ad3d148e218134676f1ee8568c877cd79ec1c53158dcf2d2ba527a97c606618928ba99dd930102bf @@ -13769,6 +13787,16 @@ __metadata: languageName: node linkType: hard +"buffer@npm:5.6.0": + version: 5.6.0 + resolution: "buffer@npm:5.6.0" + dependencies: + base64-js: ^1.0.2 + ieee754: ^1.1.4 + checksum: 07037a0278b07fbc779920f1ba1b473933ffb4a2e2f7b387c55daf6ac64a05b58c27da9e85730a4046e8f97a49f8acd9f7bf89605c0a4dfda88ebfb7e08bfe4a + languageName: node + linkType: hard + "buffer@npm:^5.2.1, buffer@npm:^5.5.0": version: 5.7.1 resolution: "buffer@npm:5.7.1" @@ -16661,7 +16689,7 @@ __metadata: languageName: node linkType: hard -"events@npm:^3.3.0": +"events@npm:3.3.0, events@npm:^3.3.0": version: 3.3.0 resolution: "events@npm:3.3.0" checksum: d6b6f2adbccbcda74ddbab52ed07db727ef52e31a61ed26db9feb7dc62af7fc8e060defa65e5f8af9449b86b52cc1a1f6a79f2eafcf4e62add2b7a1fa4a432f6 @@ -18278,7 +18306,7 @@ __metadata: languageName: node linkType: hard -"ieee754@npm:^1.1.13, ieee754@npm:^1.2.1": +"ieee754@npm:^1.1.13, ieee754@npm:^1.1.4, ieee754@npm:^1.2.1": version: 1.2.1 resolution: "ieee754@npm:1.2.1" checksum: b0782ef5e0935b9f12883a2e2aa37baa75da6e66ce6515c168697b42160807d9330de9a32ec1ed73149aea02e0d822e572bca6f1e22bdcbd2149e13b050b17bb @@ -24170,7 +24198,7 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:^3.0.2, readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0, readable-stream@npm:^3.6.0, readable-stream@npm:^3.6.2": +"readable-stream@npm:^3.0.2, readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0, readable-stream@npm:^3.5.0, readable-stream@npm:^3.6.0, readable-stream@npm:^3.6.2": version: 3.6.2 resolution: "readable-stream@npm:3.6.2" dependencies: @@ -25791,6 +25819,16 @@ __metadata: languageName: node linkType: hard +"stream-browserify@npm:3.0.0": + version: 3.0.0 + resolution: "stream-browserify@npm:3.0.0" + dependencies: + inherits: ~2.0.4 + readable-stream: ^3.5.0 + checksum: ec3b975a4e0aa4b3dc5e70ffae3fc8fd29ac725353a14e72f213dff477b00330140ad014b163a8cbb9922dfe90803f81a5ea2b269e1bbfd8bd71511b88f889ad + languageName: node + linkType: hard + "stream-shift@npm:^1.0.0, stream-shift@npm:^1.0.2": version: 1.0.3 resolution: "stream-shift@npm:1.0.3"