From 9b7b2df94f5515745574606cdb6edc5872238799 Mon Sep 17 00:00:00 2001 From: Fabrizio Date: Tue, 13 Dec 2022 06:29:46 +0000 Subject: [PATCH] feat: image transformation (#128) (#129) Co-authored-by: Inian --- .github/workflows/ci.yml | 1 + .github/workflows/release.yml | 1 + infra/docker-compose.yml | 33 +++++++++++-- infra/postgres/dummy-data.sql | 4 +- infra/storage/Dockerfile | 3 ++ src/lib/types.ts | 18 +++++++ src/packages/StorageFileApi.ts | 72 +++++++++++++++++++++++---- test/fixtures/upload/sadcat.jpg | Bin 0 -> 29526 bytes test/storageFileApi.test.ts | 85 +++++++++++++++++++++++++++++++- 9 files changed, 200 insertions(+), 17 deletions(-) create mode 100644 infra/storage/Dockerfile create mode 100644 test/fixtures/upload/sadcat.jpg diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b21808..2cdc898 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ on: - main - next - rc + - next-rc-1 workflow_dispatch: jobs: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 875729e..caa1133 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,6 +6,7 @@ on: - main - next - rc + - next-rc-1 workflow_dispatch: jobs: diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index aa9a209..ea54bf1 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -17,7 +17,8 @@ services: ports: - '3000:3000' depends_on: - - db + storage: + condition: service_healthy restart: always environment: PGRST_DB_URI: postgres://postgres:postgres@db:5432/postgres @@ -25,12 +26,13 @@ services: PGRST_DB_ANON_ROLE: postgres PGRST_JWT_SECRET: super-secret-jwt-token-with-at-least-32-characters-long storage: - image: supabase/storage-api:v0.20.2 + build: + context: ./storage ports: - '5000:5000' depends_on: - - db - - rest + db: + condition: service_healthy restart: always environment: ANON_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlhdCI6MTYxMzUzMTk4NSwiZXhwIjoxOTI5MTA3OTg1fQ.ReNhHIoXIOa-8tL1DO3e26mJmOTnYuvdgobwIYGzrLQ @@ -47,6 +49,12 @@ services: FILE_SIZE_LIMIT: 52428800 STORAGE_BACKEND: file FILE_STORAGE_BACKEND_PATH: /tmp/storage + ENABLE_IMAGE_TRANSFORMATION: "true" + IMGPROXY_URL: http://imgproxy:8080 + volumes: + - assets-volume:/tmp/storage + healthcheck: + test: ['CMD-SHELL', 'curl -f -LI http://localhost:5000/status'] db: build: context: ./postgres @@ -61,3 +69,20 @@ services: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_PORT: 5432 + healthcheck: + test: [ "CMD-SHELL", "pg_isready" ] + interval: 10s + timeout: 5s + retries: 5 + + imgproxy: + image: darthsim/imgproxy + ports: + - 50020:8080 + volumes: + - assets-volume:/tmp/storage + environment: + - IMGPROXY_LOCAL_FILESYSTEM_ROOT=/ + - IMGPROXY_USE_ETAG=true +volumes: + assets-volume: \ No newline at end of file diff --git a/infra/postgres/dummy-data.sql b/infra/postgres/dummy-data.sql index 0dabe37..8aef852 100644 --- a/infra/postgres/dummy-data.sql +++ b/infra/postgres/dummy-data.sql @@ -51,4 +51,6 @@ CREATE POLICY authenticated_folder ON storage.objects for all USING (bucket_id=' -- allow CRUD access to a folder in bucket2 to its owners CREATE POLICY crud_owner_only ON storage.objects for all USING (bucket_id='bucket2' and (storage.foldername(name))[1] = 'only_owner' and owner = auth.uid()); -- allow CRUD access to bucket4 -CREATE POLICY open_all_update ON storage.objects for all WITH CHECK (bucket_id='bucket4'); \ No newline at end of file +CREATE POLICY open_all_update ON storage.objects for all WITH CHECK (bucket_id='bucket4'); + +CREATE POLICY crud_my_bucket ON storage.objects for all USING (bucket_id='my-private-bucket' and auth.uid()::text = '317eadce-631a-4429-a0bb-f19a7a517b4a'); \ No newline at end of file diff --git a/infra/storage/Dockerfile b/infra/storage/Dockerfile new file mode 100644 index 0000000..31d6653 --- /dev/null +++ b/infra/storage/Dockerfile @@ -0,0 +1,3 @@ +FROM supabase/storage-api:v0.25.1 + +RUN apk add curl --no-cache \ No newline at end of file diff --git a/src/lib/types.ts b/src/lib/types.ts index a533cf3..2a66dd8 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -72,3 +72,21 @@ export interface FetchParameters { export interface Metadata { name: string } + +export interface TransformOptions { + /** + * The width of the image in pixels. + */ + width?: number + /** + * The height of the image in pixels. + */ + height?: number + /** + * The resize mode can be cover, contain or fill. Defaults to cover. + * Cover resizes the image to maintain it's aspect ratio while filling the entire width and height. + * Contain resizes the image to maintain it's aspect ratio while fitting the entire image within the width and height. + * Fill resizes the image to fill the entire width and height. If the object's aspect ratio does not match the width and height, the image will be stretched to fit. + */ + resize?: 'cover' | 'contain' | 'fill' +} diff --git a/src/packages/StorageFileApi.ts b/src/packages/StorageFileApi.ts index 4b0277a..daf3911 100644 --- a/src/packages/StorageFileApi.ts +++ b/src/packages/StorageFileApi.ts @@ -1,7 +1,13 @@ import { isStorageError, StorageError } from '../lib/errors' import { Fetch, get, post, remove } from '../lib/fetch' import { resolveFetch } from '../lib/helpers' -import { FileObject, FileOptions, SearchOptions, FetchParameters } from '../lib/types' +import { + FileObject, + FileOptions, + SearchOptions, + FetchParameters, + TransformOptions, +} from '../lib/types' const DEFAULT_SEARCH_OPTIONS = { limit: 100, @@ -259,11 +265,12 @@ export default class StorageFileApi { * @param path The file path, including the current file name. For example `folder/image.png`. * @param expiresIn The number of seconds until the signed URL expires. For example, `60` for a URL which is valid for one minute. * @param options.download triggers the file as a download if set to true. Set this parameter as the name of the file if you want to trigger the download with a different filename. + * @param options.transform Transform the asset before serving it to the client. */ async createSignedUrl( path: string, expiresIn: number, - options?: { download: string | boolean } + options?: { download?: string | boolean; transform?: TransformOptions } ): Promise< | { data: { signedUrl: string } @@ -275,11 +282,12 @@ export default class StorageFileApi { } > { try { - const _path = this._getFinalPath(path) + let _path = this._getFinalPath(path) + let data = await post( this.fetch, `${this.url}/object/sign/${_path}`, - { expiresIn }, + { expiresIn, ...(options?.transform ? { transform: options.transform } : {}) }, { headers: this.headers } ) const downloadQueryParam = options?.download @@ -351,9 +359,11 @@ export default class StorageFileApi { * Downloads a file. * * @param path The full path and file name of the file to be downloaded. For example `folder/image.png`. + * @param options.transform Transform the asset before serving it to the client. */ async download( - path: string + path: string, + options?: { transform?: TransformOptions } ): Promise< | { data: Blob @@ -364,9 +374,14 @@ export default class StorageFileApi { error: StorageError } > { + const wantsTransformation = typeof options?.transform !== 'undefined' + const renderPath = wantsTransformation ? 'render/image/authenticated' : 'object' + const transformationQuery = this.transformOptsToQueryString(options?.transform || {}) + const queryString = transformationQuery ? `?${transformationQuery}` : '' + try { const _path = this._getFinalPath(path) - const res = await get(this.fetch, `${this.url}/object/${_path}`, { + const res = await get(this.fetch, `${this.url}/${renderPath}/${_path}${queryString}`, { headers: this.headers, noResolveJson: true, }) @@ -386,19 +401,39 @@ export default class StorageFileApi { * This function does not verify if the bucket is public. If a public URL is created for a bucket which is not public, you will not be able to download the asset. * * @param path The path and name of the file to generate the public URL for. For example `folder/image.png`. - * @param options.download triggers the file as a download if set to true. Set this parameter as the name of the file if you want to trigger the download with a different filename. + * @param options.download Triggers the file as a download if set to true. Set this parameter as the name of the file if you want to trigger the download with a different filename. + * @param options.transform Transform the asset before serving it to the client. */ getPublicUrl( path: string, - options?: { download: string | boolean } + options?: { download?: string | boolean; transform?: TransformOptions } ): { data: { publicUrl: string } } { const _path = this._getFinalPath(path) + const _queryString = [] + const downloadQueryParam = options?.download - ? `?download=${options.download === true ? '' : options.download}` + ? `download=${options.download === true ? '' : options.download}` : '' + if (downloadQueryParam !== '') { + _queryString.push(downloadQueryParam) + } + + const wantsTransformation = typeof options?.transform !== 'undefined' + const renderPath = wantsTransformation ? 'render/image' : 'object' + const transformationQuery = this.transformOptsToQueryString(options?.transform || {}) + + if (transformationQuery !== '') { + _queryString.push(transformationQuery) + } + + let queryString = _queryString.join('&') + if (queryString !== '') { + queryString = `?${queryString}` + } + return { - data: { publicUrl: encodeURI(`${this.url}/object/public/${_path}${downloadQueryParam}`) }, + data: { publicUrl: encodeURI(`${this.url}/${renderPath}/public/${_path}${queryString}`) }, } } @@ -543,4 +578,21 @@ export default class StorageFileApi { private _removeEmptyFolders(path: string) { return path.replace(/^\/|\/$/g, '').replace(/\/+/g, '/') } + + private transformOptsToQueryString(transform: TransformOptions) { + const params = [] + if (transform.width) { + params.push(`width=${transform.width}`) + } + + if (transform.height) { + params.push(`height=${transform.height}`) + } + + if (transform.resize) { + params.push(`resize=${transform.resize}`) + } + + return params.join('&') + } } diff --git a/test/fixtures/upload/sadcat.jpg b/test/fixtures/upload/sadcat.jpg new file mode 100644 index 0000000000000000000000000000000000000000..859aa4c33b257b76b63fcaf6b2eef57fc086096a GIT binary patch literal 29526 zcmbTdcT`hB`!2de0-;GSK|+%#Ri!8-^r93|K%^<1P?Z`KLJu7=1PEPe3JNOHM2a9q zkkCO;=@LjFf`arWo3jm*5pwk2-cmk0B+5m9j^bP>vBGCUSEu#D1 zr6Btv@c*`f!T(e|Spkry9=;EK13Y~FL=`W|0Z1LgTaf?sc8dSn=l^SRd=tV?qyROL zB^*=EpFN$3lQw{p5f}v7gF)v2I!+Ln6LiuEh@6gy9`s-Qx7le1q60(d8K8_z%q*uB zK5(249So*}fa&S~^B7S0X*mGlq~{Wo*Jj|p?F2pN&!Z5P_MA~%r@Ecj?CTmr(HRrX z#LUMJ6F7VRf`p`$^p&eh%1D%o?zQWB`UZwK?wDIxT3O${hjwvwbNBGPAMo%|U=TJq zBqla4J|XdO5-~j^Gb=kM_vwq`l9#Va%U-{!d0+dXuD+r1c~>g3OTP9chsM?{vJAGB)Q$QFX{{aJ(;lF_XzhL|?nEnHn|G~*=C!qhd z1cT{MFBV1y#{Vh%zcx;0PM0O$$t1uE2AwV@Feji19C>HWgw^qDHieILtwdW`D;|Ea zeJ_%cC2crJX?0f3m0itH>Fs)g|j_xvpF5hq)39mwJKzSPt#|Y|xdPAn!?jD|w=J?_aJ9 za6Z>tP5452F{6MFJNPs$UhPO_b~p~MsQW3lfs<(UH*Wu)c5SI(BFygjTRkS1c)qN= zE~#42UWyI=aACu%bus68_q5%VgA_h}<&yUOIpcZy!d)i;bXuNb%Cih?<2OKczxzRM|B+?mYRwoLM;SS1b`N!xZe7*1w)hsA%`4!X@UNmlnjt#f|v6JQv>5WElOqL;8M$78lD>CIgu`GliDB3(TGB|EscRI1!ygHJczn6 z`{G4tW&crED50TEbA=UjK_qaHzHyaZ&` zQhGs6m+Mot4pLQD+4ju1y1vtFog_G~?gK%Gk2_NU)z&p4C{#VT*@dLw1FWt}KI@a5 z35pjG=$d8?$m0tCguPHQI^zATWIrHYl#0=kz=swbeF`xCVRk*XD~M|x01bv82Q(C;!Lk4qzY5)t!f-(Qor!#?q8Rk-=Zf3k88D48I*Zg=B zN!=!EtlVSwQ;jPuC$h8XIjuPt8SZRQ+?d==VR2$cfm60yug=5=LvvX;+8>)SJ5@CR zbZzo*3O#c%8;-8|s~)k*>&yr0(UcxXD({f%8QfHqC>vD6o-a#=LGfjIQm&hIy`(8D znunn67}$V_>lV-KMmX^O75vla<}Pb#%QcR3Z_2@>!YP z7x?uw77e+6ox^d8Omdqfn2|YEU{j0pB0gPIw&+R}l(VQk!)FLwI`qL0d)|ombJG2L zCBJMXb^O>CZXegH`o~V2{8hv?$DTKAn;K~CoK5(#o@irTlVpzIAwHVP8C!sqscrCx zWDBw}XJ1)r5kFKVT&}68bZIodT|J_=vF98}EW*F2SL5g>35WuZ45D79>cS*D;dAUP zN+{;$m7Z5gFE|_ubxDp(NFM7vf~l zb00W3S_%7=DlM~mylptBYqZY5kj&q2BB+ZOQd4ob8m~D4#b`zdxrpwQnLjo7K85P= zQ`W~=$QanyK5qm{-(jhEnXjcL!%`j zl3g1j0dz&N({IX+&K7iv$?LlDY2l+R6!^WaB_=}sO{cP(yjg3|7aJoYp3L)Svp$h2 z&r|HS(UgRF&7kO@d6$-j_R@^83$c-X%IHn6WWQTP+T1#$^bRFt1^4Ks0D8 zvoW5VryQw~^xn;2SnvNww6E^G2UR zZ92zhUGU0J;)X>3Z1B08!fO1-$zdc!SQGRB`t+(+8); zLF&PZw$O~k5tGYA7P7DDM3*8?!%;r9jRFz=K+baDMcn0?k>7}V93sV%%1Z$B28T)1 zwlgHL_$m2AvqwkE_%{`H;r#cvCQBrpfq(m!0jYkH;Gd~_Q>JlbzapxuiCQQJ>e#|5 zL#}Qa(uprg<% zOY%4rH^Gv}b^878-rvE66co$o-ro@a<6`L;m@Nt#^AHbI%nLf0Zxp;Nxh!8?<#sop zpXNQKE!2%}2Z^w9PW|O`o0Q`wRnOL^sommMC`^@SQ>MQi4_V{@c*`W}Jog6=9RNc=wa;S_pS^2=Rr10Yc4!KP!^*@pXM{&Mwj)B<+5HxJ% zeiP)FM3flxG-DSJa64^G z$iL@p8cABrj7=w%2ku@(fq+IUp!UNyx?Keo8Lri zAA*-+QhXX;+IS)y&3&loQPF=VK;e(d#B@i5tH}msi2!vlj=;eq%Ky|+X@Z%b2y&4H z`o3elx}W_^#$MhHLFZA_3R;h=N1v-DABBt!M7{4}G(@1Gz-ITVDxu+O+8nR--@3fl z;iUnW*pP$l>H#ZLYruB+AH7l1{($oXj*_Z;QWiPSy&@bV^NO(=7rNOM`Ur+RdLUwY zr`8+5F%wk^TIcnRJ-~%EQBRU%snsa)(i7mVXt<_I!GZePB?hQ5J9@NqUUB7KmcL%i znd!A~|Jl(x{mobRIY~Q#%S@~4>nFgEB6gm2`ZxO`tyG>%?La&Jvcz8wIR-4iFEu7m z0VQc%{Td;>_rP>L;j!C>Xmz|H)#G?q*wH&z9PFP;7vicpS?Tm`E@^)Ht`e_ZKbF)F z612Xg81o^w5xTGiPf3lu|Cgm>zBMjrCtX1<*XZLaOcY052#9JNW9k;=Zd&XC4oPE? zKE96&`RJ#AvN^r-{OWXZM8Qa?D2|#}1`cE}+$UI{-EY0QmpNYv+bcZgOpmzUd<&7C zzL2Z;hrdlg!msTQ_K%`%=TC-jVamQ5DKD}(jIH5e2_9s}I(9e#SQ>g6d|doudQU<( zcZ=5HVBqMffJu7xgq89ZzB8!)3;gTfP-2J?X~*Nd+|*P}ZMJBZV0{pZBpc+I9&>0x zX;IvpD%~Sley`1EST%4ae2*9md8>+3u7nM#KZXRPIcV+`f8pATZ1?|V7WF7#L9VeL zHQ8CG>s}?^u&lT()EciQ=v)44{pa38>9RQe@ISoZ{8EqJzZBSzjt6+*FP}B`;CN~E z@^)w}tBr;Y*Ihjo@EAe9AP4%vU*n8XIA(7Tvn+_cE&s14YSV5UurQGMO?o9yX#7_Y z8FvuN)R<{ECgK0@4G6icIWgp*J&o>Fb66vNPA&WRse0l{gd@R{p0P{2eLooPED(C}2)G}X^9Tk< z6zIBuWZ1W(^wUk4)}k~@9$->O`>YKP(wo&Ko{zUoZ8qM z_|<}JVGJLhO!lv>8$(1hL7@;w9F`>U^ka=Uktf3s8yOac+ymG`&0J_a zwWiRMum1ugp+j zVXX_|G91JhE_;AC{q|VQG6P|;UlPr9h=c6wj*_zFZv+>hm>8T+9hV7Yh?`$B?s(sA z)n>7`9_5hfH+@+^M0!=E^voJbuhDyi=FdVLmyjp+6lQEmr05RuJ~-z6S%};eCtkO8 zU)ZlspH2CU)0E?GpNwlp*5k|32p_RH^HQqe^=grTedLH(JX_=ww}tZu-a;m|U_0cjxt)n!EMhO%=1W36F{s ze4n+fQee1?TGA59O~Xpu4H`$gsn$iEU3kpO+u+?AB(B~r3J;rdbD*C+UJ0vr{KNaw z-okj8hGY99>8w=WFQ*T2->N+36>2^K8Yf9^?RZwY2AOYUosYb(IcMrQic(cz_#!Utzfj*B;=bT$9SBX8K6HSV z|F}hUZ4>3vBMYWS1MpM@R1diR3TS`9ll(6~@ii&8l-4{7P>!SFk^ z%e>93sGmh~?cjpQL5#{4@0Z_o6erVv-8>Bkc0l6VgiCaS#r@-p^ z5}JiqLwN6e_-=4#=q8;_MZjof#A59Ghi*#0({QK9-d~Zj=o8@kJ$V~_So?k=*hkNR zB*Y$B8KL+T$eM{TwbK_@Ti;(pwyO_~RGf;AHqZk9so72H!7i_?JjCqvuC};<=FpTT zji>FcVAymBnUFB6uCgPfUIu0{*RXqEuO(kN2kRa(D?%5L^Vab()*8I{l0Lx-#;&HyxuRlj}?gl>QTg=LU+kMGC;qizMk5)ev;+l9q5#@a4WZm@4j0m$EP5zGzF{IWy>|uN470Dpz}eF7rs+ zJJ*w&+(jO&z&xp?V>;g&8%Iv{v)OC>7tSDUk z%Ag^CJUYvG#UasZZLej~sPX$wiDhTEQgno6*E|4hGzb@Qw?QJ1Dh>&1@>hS9s_F4| zQJMhxB_#}Di8XQkUES1IaENE&hVL{CRU?Tb;BVtX&&dT6MwTep0jc=yP_n;i5w zrGG*0)wSOR&5n6^bb%zeZn*s2p~ajC7xweg4WrHLzVgRBjCVhVq~$%dHzWz{L{9K+ zUQZ#v_|1>*Z`>4gxt#ni6)F*xN8Pg5hd$jOGh3e-i+hJyV|dIcGHGMwVJpUFKvRQr znfz1N7C6^(ey@7HxJwgqC1H&{`chb9OmOyP;#62iH) zU^^<+^!yZLHdfgAF)k(H3SG_5`@T{H*g|T`3@I9Hpa&~E)%K4Tpi7te!j#uV;-7lE zYpr`d&F`1(H#6qaVbdXE44j7Z4yQ%TK$nv=Kzn!0Tb{CWco)pyWs$9}d1NSS*J7#7VSK=&~Vg{k=z|zD=ciscBHC3yI)<#o~{eUVGBN7FP_li3o zLe4K0SB(QA%Jwi8S=_9Smxhx%FDY{kk!JjNzV8`2jR~I%xlHZ=VET(NXfcc z??U=XFK=5N^B1T#?m2P=MPu+zuu-R_f(0u*3Xyhsh7s-Pfa2r~FC@FZ2vnt~u4 z0-9oX<*tAx7qyh)-OLz_sv*%TSz>Y^OB4jp>gXw%^n-Je*EWr)VR|>Ev`M6Y7;-AF zaT~v)JoOty6)_mtioG`$v0I{s@dYLpRJJ$8mFZ8W(voavWXQbr9El z;qC-4r;(yEfNlwB6I$ND{@8-A2y|hvT}(*I6d3vYM|18)ab<+f>K#P7#(b~QpPTaT;s%>YR zWKyb8{IbaeKEv7A6~daw=Ks6xPe|U@Hl;`D77uY6=w~oUwo%=4_>1-=<3KEfCK69- zLB9i!)f`{QfvNBc0FK{KZxmQrhF*QipIjgIdV{N?6a@zl8h}M~SM1LlHLS>tZ)yqe zsJRVy_g%?RHR%WQuwcAJIz{7Jn$Z~2boM97*71?rqhVGdRXwd&ET^ zrPw!9Lkc2A?>`DnaN2a){?DnZC|bqQPfoiHJemaT{M|Fc|6|y|==*=K(I#(`beFc? zHK^J6L9+#M5izBJV*qJy42S4Lx?vc*scDBdZ3Nncu(ixg-M{dQ2XnXx6l?281o zdBx;6hwOqY?+iwut;|-}DGrx(48f~-y1;66kUB%iLs3pG5pB)K6T|zr2{hE!LCWTV z6=(TR{Z22#XDYhxN350#pEC-6d~NsO0KXAnA&&hX5jfqb`WQKcUYRNk8GRCP zW@4%vL3Pjj$41*MV)+V`?B|N3x;*=cdV{s|+P3bcmHQ_E^7-BGe(Xa2TCj+78n@C_ zx!B}jN-PX-}r$Hrd`sq$I(MDEF{ewj;3p~MRL0VmfD_jMq z>#|KUmdX6YrC2Cm)45RZA9EZs7M^qM+;p|rs3Q#SA2TXKU>E-0F}uRg+O6#ReiL2j zaC@xM_a)#ml{qH|MQ+WPG`l99>G!)MKxQvRsoyuUG@Yv^HdDq9zKu2An{{b)1=iTt z2%o~sz_i(<3qKuL9FFRA&Ple92Ym3;9{M@xPqI~F_e!BjKS)Vvg@zgL5|@5t?=rX- zIMmKP;YY-=s-7y?ej#qH#q5u>hJxlU>Oh$FGumPbjG0dWCR&A7JJpdcBvNsN-r;sh8TSY9fFFKOa>5(UQNG(54>HVWo7exz>`+ z_aVrYxpn-YrIaFz^YhdG zMdi#OZ&Oku_|~BK7Vz@N{Mp2o3s^mMv~@#RO0B_PFP=H8T~{1Z0x1E7iB>{aMUo15 z_p;aDY^+Whenr=gTzUON_2`C`v-cvrM?klMwg^j$L+<%F#@$i0YMI-98rUo&T5AVZ-( zjJ|EshLC>e$Jp}L2_-#4gfRM6h=0>Uw-~h}%|>O*x2|^nN%k^GE)gtbiKcsw9GquB^(+VAaUoevbpCtogN*1&qr#dSbiA3d&$QrRg8$f3s^K3g zkC4K|i(W4jurZ8*zA0j`XumdIAZD0KE4APLrD9xKs%K^W-Q*cZO2kB!+UIGYlY=3E z7(#GB2VV{*@o91 zZNEFX6b$y|BAe3F9dMfPusgmx$~G?7`B`C?fL?CAH)0zseIr_nimv=UcF5iOgnSYdso%Bv5 zoyMW}lf93*_TN@LcO3LjOOaFbQ+B#r)v2M4$m+U#DBdnB_CEIG+}aclx}f_0f)*G! zGa(2=<2eUHE%O9@7<9JGkQQ#17VXg2c1dy_uXLTF{McUnu$!~;=aMwFFkm&D9PEU% z&kOX*u=$~Q>mIOuyT&JxsMtw((_k7ITjqpMj^*uYlTR;D2Gx|qQdQv?c8A|u?3B%7 z=qK>fb-?Un{`02_3{0-KiGEWlqM-Ct28^EkG;3P+%bTu^tj#hPDCtBD2Lm}RCLXAE z_TM0WuwS&kAeb$Szio7h(GA0+$b*zPvSI(&hGD;bH@4?t>^-Qb0vo?{-!~KzTiuA0 zSQHRl0Lz>OPDYP9u_JeX>Lr9Ec80a~MvnZ9DBZ&OIa?&zN zf2vrTWSg{fj04*KsE`4s=3KRJxj&Ts?1Cts)>C}YL zoYT+}gr~(c8V>Cekfhi3TJ$`(wYVS0#{BI#Gbcn$?J(dz47DnM0QPr0xWG~ zhm!Z5vB&n~npLWH7n?q9&tebec*DW%^c=S+WxILe7= zxQnDlqq?DL)bbZsSgIMcVFEO92rH!$!lrJl@{*OIBW|X^57mP_pdp1DDHRbxRlf)I z)hL9cp7!qj#+5PA_ol$CM(FC0{r*sC#p=&eA+z$hS+Is7mAp%()#;<41dIX*#(TGM z$mdh=_V#g0g1Q6umyH`=;@KEi+0`hTp{ZI2r>7$%FqVXz826^vqmsiKhHeVy`$v)B zTfqJ69J1Eo$nJ%BnRBSK`YxH~a2?dEg_eeQ^~VRy|R4avIB(;p?L z+1UKkyM>Q_m%DikvS5M$4Iiv}qh?2e{2vn(b=zQUg&^qRY<;wvcdY5K*+!4;;-D?~ z&cit(n*HRrw_gqZ;g2Gq5L?10z`SR*l=T#(CT}yZAef}Ff4H1wg_A?g9IW7CLoSdXA9Y?Tx>mN=3X;$Xj~uY_@%F4`@Slw zrYUz8vSBbJ9yKl8P`PAf*>GFt*2BPZbOBaN!Jubp(C`<#8t`DPbZ2e-%G22-$_V9} z^~K%VmHGi_s7ejqu>cFwmw=`XgEvKX?1ukr-MV8gEZwqM;SI{*X^-PIr(Q|Uw8w3Z z;X@DjKCOTJ`}yf%eTYM@n^4TXFQWgH=;~NYkTWt$-#ZlMv?);DE^JJfFyq>BGTZC9 zUuy4sIGji3Mb$93dau-=#j?~~r+oX{&AE!69xkrWcG>Ag@(z?a*!6|8dhr;c?ubiY zV$sk^D=0bv9^pul9`D{V6vtkQtn9fb6LcAtHYE8+G842K$6~=MX;wnQm2MKvQ|I{&S1qy7$1$Q1m022eIxlJ2q{}b$Vdt_ZJ4* z*(E}#myvDA{Ya7~zt^J&SxT$R{cjZFB7Axc5s!Mj$Du;Os0yyWd*43%u2eR9&x8fh z89u&~F2%%{KM=>K)p9@w zdQ|;?v^@TL2whYAysHw%60!;=ZaBMAqqWe{Sm*+3Fc^u}63IXKPH3&Tyc2U>To0Dt z-On402p9!d{zF9a>;4lz0U%#KKBaUhSf}3fzml}f-q(t~ohbm2;XujuzdnZx*v$^5 z$(z1GbLZaOZz9-P*cl5{b<#!Tg>d4nzB#PjRJ{6bvD7`hfj9V^fZdTboR!MiBl3_^ zg>b`ZzGSD@mhuoyx~?DSssl15U|MIo_ctPbwIg9|S69y(Upj26wdkmFfk78!@#D+J zBexZ6<2bi%*XNJ<*qjL>%?3ydFNVpfXpUZtu^hjLV3mAS(oVQ~F=y>aVWypJUZpB; zp#VDOcb~G|?ACXjiVP1I<3uqV!eAn2VH3xk|0R(e^EsOuKZN+P;&fNhAdZ!}IQ(4s1$d?>!*DRbCl<00n${*-F#Uab@v@Ir<6Ucl zh*K(8vrIe^B@I|a%E7s7*^ou7eXkG46wBNMgIuv`y77~@W8@Hzl}ZslW;~9>cR0_U zhvMH^HMA5~f6|dY-8i>q%s^LD3C3y7M0aK9a@>YIx6rA{nOcCPz6nSz*U6=`lw$3~Npd$`Af*pjs63xM|Cq+G(4PQ^9!rKXQacrr@+MOtp@BkOz<|?G+1mQ_5z1kh78f3goD?-wg)`nH2RliN9B!UX8gMd~-=p z>tjvF`4z+6@j-|5_6}l-61NGUeQZG6mH>nxC!bCLiSjpIcaR}~%=D`PJVAdNNh{S< zQusERATN~EC`t&7N-qqf(>T@LLl+a9Wd2YBT!_X$F^!t0LBe_AR&)>33|JWyhtYVU zPe2c>xA0Q|Amj&JQUNu{G3x{JFc^%2Yy+y`oKblpGtoNb@UY?_b;h2H?Gzy_MM52; zve?yi({Ul*+CQBLl8C~y`bYJMlGkoMd1MaY9m{SKZ( ze8P=5dnD?|4n4pn_%a$;KwWc)X3`F?T(T(29x#i1r8%@`Q53{`XE?-^;ZmLLq{zi8 zQvJZ7vHV6&0p(RVfQT}0mKJZ!Ug6|TbCXu+$ ztS@T}w$)p529av`D!SKup06HHl$!jcGC0R+yCS$hWWx}`MjHC6IgZ27#^4K5%8!{W zo#ND7-w)NlCI<~U>I;N0FCY;6SX+5oik5=RTP{_3{b_wf>J1`FZHp!t5%;MId8DTw zmv0@`5#J4jebU+@`F0c)(IHH!E}T<&IE=vy@|!8*KAA&KOY|Ub_pp(672;h>W{)VY z)BIfCC&{<)!i&p~naBuh2T$TOg7*^2W~Q*1xj$K&>Z$s$i+Od7B&r-nYYWA@=SfMt zngV-ZWxDrbXXM*zTAHqJhOASEA}9V-(jywn8L?We1a-Z$S6BWVqQQnIxDd#QDS_==#1>Ixx;FDqyEByOZ#nejF3 zthx5no@{3~_OBD30Tz$&$N3X#LHiQy9fhnjx2^zc4CfS#xWvKmYJ{szsuCe@-5yaq0kZ8}i##dF}H@A2G`=xWHd@PSV z<`fJ`;-_B6*-|8lcPi}aQNhJ9`8feKyLH}`cW494^U>2h(u4`gcSI(!mOye$LGxMQ z@Xgm_j=d(+CJl?2+9Vdm7G5jjZ2ubSmnwd&2CF&;u}uG>`S?87bjrRfpGCm&x|sr_ z=z$BI^7KG+9*Pg+F0TIYbv3SZBIhln>$5Sc+ea}yCWU6t?Yl0b{q3qqP58%^{AbMl z7#_S!w8}$7MmwsT$_j+P^mQi-zGI%fxvo)=`r<_*x8s=x0y`j7nu#2;%_GQ+u_r|E z&+NJMgqG@hFHraE=0ZRlL_-mI35nIiH~vvy!oHJljs0K>SMOBjtRSDg(;z%9s1Nb( z1`8KYjZ|(0yt#X|fWGBeI|V~D26Zp%_xrcEsZm}6XOk;G+sSlfD}S76S?8)se&NTU zbONwp4B^5|Lvv=QQE4h_w_JnQMLM6n67_&|cIpqrWQwkk5OqxCqg5-|oelmjBauD5 zEvc0iTED81&)`fsPx|mCK76$x=iLdgcY&cPK(n)HDrl1Io7$xhOBGo?oC&}eb_S%f z|BmV4Pp%WN{Yb(I#H98=86+EjHW9Fm$^u8r7dlmEki3}Aq5SAt85zu!Bv4@Y?mgAJ zehmVE)IO=rYW2!^j?~~6x9)Lc44#dSJHIrKPP;N_bcU9gSg3J7wZbn*@sC3x1poeu z7bTCrecEUk8gTZ*xAJ!7;`d(~Yp`5O+cfN{n~p~qSf+dUq5EFS2e(Ih!szNA!CXVv z<(vBS7-SDK8jNJsi@gPkYuHeD(<;IC(p=?@q+KT{{X=}nVZ#+rgNxSuxek9wRDl7k z=TJc=vFTZ6ey+bbldhs8y;bszezHb!aMdm6VvEfV-@S0OwGR2i3YxT0ze4 zu<&APDwC>#^$z*yheJNW3aI<0`996fv-!oF*A2qJ#wb)3j$3}yAydG(ADG`Ja`OG? zEqC|%)OgQ=i;X1N&*B%9uiRCusdm&G*3f$PnD@TGMLCy@gcSdaioB$5_Q5G5DaWsL z0?~00P&D(8izOQ-=dwi~3*vV=l-jE;5h6>j2 zTgGd0m^vb1WG%W)^UHQ_Pr{fMH`t=F;DzbR2FN9Lg!HNWsN;sq~A{`AJb}RY#)b;)MqL) zF#ftYLz20daflMrPCEhKk?!}`u~q9`%)BNcXyu773B?K;{4N-`1uvtX3XIr_?IR}3 zzYZefrDqzTAlXRmV(jJupxhz0}bx#rXpm$Ckyfl>p zqMADfE@Dq<70v+FWiv_XULd>3w9#5rgiQoDzhM;9pN8B#-9CsJ@pu);*~WZ30Y0)B zo7ls`zDL)ngpZyEcTinIEplj_Wjoytzi{NQCHPp*wEE+bzN5|-Nr(YN z!#k3>x^^dBW#x%83K2VzAd+!ZD29EYKCiAJFwH{4?m0lRjTfN-B7P=UA0c$PvZT#7 zKx+P9jYBp7(JfmHRuueVvzXeYEjueFp+*eho{q^Q6H)tbZ(T4HXX$YCAv>lTP6n_1 zovplyemYxSRau)C=Qb^86{MNcYf}#mJmXNC#%40Zncw063pI#Eic1tQiAG})xIm@% zKv2B|Brix~MzL^B=&h|nbsOpnrfM*Ts>T@&B5$5L-4cLpA?S_C#44FAvU{a=%EAdS zM!F9?pZB!zLUbK^w=rGLm}n=9C}@}<_Y>5Ps41jOW&SgG{g z;t&o-XSel>{ci2=4oCUct5T<|eY&01{qv5xcJF;&YkBA8Yu2p_HXG9hKvPhhGW}MHls~N`kL3h%Zndb=?>2 zVsiOI1Akj275I9tPbAO1e&iC7|?$T(gGUGU&T=#b# zx~PVy3%?$r6Q=v!b+h79d7?$yP(RI0i2@DkOX=h$G`3d=cAbWwwuoaF=SWetp^e_1 zHQk#wmvdHgfz=uOCY~Ch<=paUEYj2A&n;_@7R0EV;q9B(t+~R!h|4Z@13@GtO$`q? zVc*ZkH_c5<8CM7U`VcUQens*Ul_qycH|bD-RYBkSkBwy$39I@n!{QXQW^O?)K&Pkm zJB=6~@S`02cK&6{>j;M%grC+v!_sOJK0@&d+CA;i!aD`Q6y@7%KTs$VZ+k9& zDa$we8=7dMiqRH-zKBzPs3}&}rJPDU4LwD-W@w*Bxm zD}naY;H(bXz}#kq>=Qnnn?dy^nKmuj?Jzid8Qc^~%kK1@pnNV6t$F&pZLhw;(o0vB zh%jRpOB`I>e?JP~*wKqBCVxIG{5pQ<-+ZT6%X20sb}(YG1260s6ceXT(sTar+|HD=+Y9J=r(-Yd!b&Gp1w|~_^6!D z(3Uf$Lgh@TLy|E2`X}O$>_81?oqqgH;f@EQf;RpF?GUnCL*rG$TG#^1!wZIYq%LVo z${%Vs3c!T0%P1bu=I#pn{62q&*`!*8?Vq1UjtX&;AWB*uA2j8opMm;LEBRUdZ}fWb zLdi zd-v_Hd||TwgHPjeqT$0>R|wqEKaROy%Zme^hbyHN#szI^r0_JqSh3tx(3%bpDVqdy z`89JHh{qoUm^2!c5~!zv$MLu0Z3&FfhTr4=V!DCtm8c=V8J71lC~_AV%27(~Tyn8dV^_!LA=zs;mW|sGc0KX707nfm5YPHrMAGrj{nw%uE8F`v zcchj3lVIIC+w`#1qBsHPLA?k!rXEYvQz^5zH<$5zO8J~vcSIM`-IU5rPlw^#OZ&~(U@Tx&HPA(XK zeP8ol2??yd2?Dhjv7h`ninU3MZS{0lWN&fxBFClGVV=j2IesyKXZ6O(oXw>$C@c#+ z(V-$`1OFz35>Tj%jRcmS_h@qC&xXJn9G{UW>#`7^bvGXFWy+uc@;~b}wZSe&C#Mzq z?mgObjVJAz4M!Dhd;sAe-QW3!2ao~xzR&ynsGDIcK=*-2TBd>oli*xU&#sn5#wGKz zUvyFgV{`Zu;!R%recto8KVA=iA}V^OyGzH^F-qV}9{XkbT{Qfk{Zv@}*Vfg%9?9@y z>)X<40(}as56#6o3e0&dT;5_-|EBgcY{?u7TFYCR%}T$ec2i?A+-ZFde{m040wA|P z<#{j6xv((bfHmG~qn0VG22NudebXh*WGcI5A~I^~|Jpsh>d__|>+~T<##csA2(Q3k z*v*WQ>z{h%5u0n7E*PXi`Lqa!q^4S>CpYc~Po>NZJP6v!(}sE8yB0X^YC%ukn3A^C z6VscY*w59UK}y5G7^&jSN$WYi%x58f8h4d4#U_mA;zwRq<2i~ilqAiC#ZXn78NsrN zFORP?`=>YbG;nW9qNwhEJv~Ml_-9Xu@Czd@n4WX=)-&&Gg^HrG=+O`y^WntX)MsTc zSsscLjUf@}4R_AXTAUVrK;Ajd=)nAMpz5=REW!TQo(N#D$hmkUw2+aoUifB#^mlXz z50}C9=Bhq~t3Xbf`%eY@Ky4Heq@@KD-G(z!KDu~3PZy)2&YAHfx93PoD^=T~fi(t0 z^|mV>Z(i-p7;G|Wb(YD#)YGN!G5{0xX8#6umzLBh#(Csw&-8RLj7AL?e)<;rsC^!H zv84P-ZAd(=pkPLImsfLA?u*4UH#WtwRWdkQkq|Rgf4><1M0(X0&xL|KME*SxKGNqo z0492DUnIxHxQoM1-7%nc)sT=42I(zD#7Lnm+$FWhB`Pnai3kYl-;9o&fYC<0OGU$H zo1(SOkTmTzHGQP5U5WZ#%8rgw`;BB)x)3T`VHUE8Z58$TQV2K?PN(r(>F*p=HOK{y z(I!DWTVC_l)b_Wr`C%ynBCq@pLagAI#iIJ-)??Dr@?shYOg&UK18Cl1Y5GFMmA%HI zz564E6{~c?j#++F)x!Z}f#3ZzmpjH_MQpPq;fv9|Jvn`6>ESXxaXsAvqG|P_tOpc$ zfP=;Z2UxcD89xGubUrDr-5HAD;EBrH79|v|Zx$wNXp-J7yFP465M*Mu$PHu=Fg>J! z0UVUsSka#8_3w}I)tL28+k`%VC9D`K%1VJDxT7)!PD8N3%R-@)fvrV@l}j4tkh(4| z4u%nJSsgNwk&KbgpB7Oqq(nZheNFpNn?4ke4b&XUGAqHq4T!0AB)d@a(qin{>a5%! zIt3wBm0Wz!q{`PGPV|sW9CYs>to&?@XyUOCSrtls^yEUK+I*Oxf7wt2KyYi<{k$e<`hf|oLXEd~9QA&ssp z1biVF2BEqL8_PB0hoKesKk^gOJF4%WRZbId$VQ84^%e(0F>+X>8e0)sB&)DiKgpr1 zo*Vn1bBAQ|1Fd15i_+oQ_30$4y7-fjl|S?D2|5WqJ{qJWhc(^6^TN~|sUhpXpYGq8 zC$+y*?IJ2ZIQx)`Rcce5ndp7fonFh4!PpoWRAIx3;3AouUBa$bniTD2TIVd@)v2Fx zlOQsf`xA`~#7B9z0!t$!=b=iI3}2K6!5=ky(Xi>{ROsBd&;Wahe@2Hg-&)QbnkU@* z5H*nBSN`?JgkaLf?G)5+ys!!dNqfFl7~6utzQ6rpDM7yM6gyEAjnC=btZ(k0fv}(G9sT}3b z{Zl(A#UorZ6`U=)Ashzu)%AJN#(MmVg9-q2utjKXwgdZcls)Nu{iob1A4?sp06)~P zPir9sBq5Xt$D#DvkN4q=d(M(R-c)9d0Tnkc#U24jfo368NTX^m=AZhZ>Nj%MMQC_R zhINs?c$iCpHD@0BP|mOtPpe(O{CU7wH7$r{{dQe09kT|q>l7}HIoO5_LrD%o$1L|n z*nPM=e{vX}kD9nUpUhnDJL?M*GqxH{BPu+|9U*`E9wwx6@wvh|*BS;E?>P)6$WdOf z289w(i>RC0b35I2$32haTp|4MNWGRIXpCV{cn*@5Gxo>n7rpj6X29N11@SibpAF_L zc50HzJ8bBhUeJPb5<|$TpuMQ)VeoIwCU3qd)x3k-{yz9>FP&*zW5KcCm@ z^?W^_k9X)@KU02wYu-5R&_xn>bmY}tol`?mu!zfF-wra>zc?JbmON{{9fy(}gRq-h zg&vL#tVI59EtOg@3sDSun66@9aavu*5rU=a!(b@jC+L4scaFmMVedn(IDOlZkO*qL z>mf8_|NPlo9x2msGCrjN+X+N%97$!2dtX$RNfgrRoTI>N)Os|4{eBFPp#^Z5=>F$` z=V;rrcc+)6yka3fqblo90JP;TGIO&>!!YL zY!%~kdZ?54uEI;+c@nY7FG!aaj~ zVCHOGFdC9XH)CE~J}D2ck^#Gi(?AthRwXheDz0sja2z!kEwR-hbJBt4S^CqJDWeq! zN$3cIr{?ppnELuay*mc>?cLYIaZXjuMt%}lEq8wAHdXcnwYw~Yq^x_oW>WGUEyxB% z6o=7?YGI^IQ}~TKvWY>R+t8!4t0mGb08j2tGOMNz2z;I>%hzrFHzRDqw`bP2k_i-@AzsoBw@Fsqdfb z(#+|%W;RRg^{F`6Kv&XyU9bB&8Hkv@C-#%3LyfW=!;e?(aSJBCBE;x06&|GL!PKXJ z0Ff4=!xIm91-2_bjhJcE{}hxw=}Ojs6Z~|XQw;tA?3DH76ANbKaW0J_CE4!Lc5M`N zFtJgnr5R!r`<@9uXVLBPhd5b`` z7x8>}o|`B7!9&gCrP?jY0hH zS~UGrE+67^Yiasqu(1j4XKwj4`mMRnHt4THXVA&$u#Kd2QZCW@Owso+NyzPBTz)R1!S9cd;V(nhZLqB z5Kmm84k*igk$g2hhkwjdom0>IUACFXAp{u%!L~%R{bhASJ`$u=sU|)SHfBFGAC=FO zr;m9|X9Av^%uSHIk>^Ro_n$aBa;ybG1HHMbF^_fEZ<_Olo}pY$NKua-^5cF^{VKXA z)OMKx$EQN{1Qy?|xxZXgLPu^YD7ed6)oDWWRZ}6?kH&apGzsI^E$%DH=?%%pOR3Ac z`)G)`sa@{oy2k;sYS6P14~$kGe{v+&+a7F5^R7^)jM^SJ-t*&0+j32-&RH|pWQC7z zmJ1w-4P4!f|L?=&TAB<(em%=_9(-JSjk16u>Nv>&#|5XGs^h*p4_dMzG@nb^Yl_3) z>RHAHNO|#*@?GgRi#OGkc(cF5dM^00Vtuavi`f!=ur|eAw&p8><5f7oJ=|A1PVlQL zsZWyfdVqV5Ks~ymDW+5l6o6}CJ%p{TSdtU-W%a!9Jn~zcsI<*k2O#A z0MXWI=3b!)GDEdG*1E9x!=ZckT>=bqMy(^xsvbf2Fui49U%>U{iM)~BmHFE1sS>Fl zkPp*2T>&5VXoW$RQtqjg_~O%5YhDSKqMwqdD{G~5CF5ywaMcPB6NkRZIOajPha$=9 zSFrV)b8aeN)T4N-@V-J zyCkj65q$1v`@Cj)RK4v%NS9Jf8Yvq6mGxNHogb+%+@rW*Zkrnsru96RVyXpS+&N?F zGhU>~lbYMMUVh9S%9w`VS9%@!((a|v1X4k6(p{k?coH1yB%<{w@5NIIJ>AzyQXx}H zLLF^BJzHJ%>a}ce4k+C-)0Q4XV!RSP7-akdPECcRF3xPdH1^GsBskGoMwotMA3rP; zJ~VQy)T#XVo5hCWtY*XW-iL065^Q-*4ezD08x~3$`7)YHT%(?J-c4sG0%? zoLLE5B_#{MbP*8Gi$C)Gk+YmBQLfSwDHVnEzk|el4K3F&Z`{|bdwi-6pWR!o-&Q36 z-AJX}C|6%&s-BeDc%g=Gaok2}A)CNU)o+lT)v^^VXN^LSz%PfXQ| zk0Cm8Tf4LWGUV{(S0(3z$4h;DWVgku5+oY}iEEW=3Z2-(!N0#~47`13 z>7%pIfpWtj@`DgZFX$lEm(Jq~L zEHyg4s4GJM8u&2VJX_82HkXS^t{s@|(Uu?wRO-_99!<-p@cuQWp@6e0W*Y1HK8N*7 z003vs7-2d?1x?D25sSGGvh^s2eO!kf-o3c|Hbh=p(#$|F%LTn8bPuueSiVBSM87T- zz@kKN5Y-nbn;{bH5x*Id_KP~oh7rB<#kWcf^;|&~AQZ0<3yyH$Io5pIS-(Jg_thQx ze`g2lTW{3Wgt;>brmhT#t}InytBU1R>Wf}~{E-aEuZ_jo5a(`A)l)lfKK41u2!XABe+FSX){tvhke^k!r(90$Sycdpx;%eD; zTb=O(<(76@E|H!aXqQ1oW2n{CD6$vUX6vgq+Pc#p8Nwjtxi#&_%;*A(epB}Zp!F{} z6Rq+6fP6a!!#9fW?!OCOF7~N*;aDum*!r8LaL&wkGNl&7Ozr&!$0hlIJ6U2R?sJv> zhrYNgb#QMhNQ8GcoYx1_>cmfM24&9*JT0vBHeFRqr_yt}v;qet`6IDq{`ULXA{jzn zwH=PfD-b(Ot1H;EOif>>B`aURaKn7{c zuk6GkF;Z_$z`W_h#YElqu!}{H1L#Whc7y&rrz zqYw}D7`9KtNjtMm1vbKtMyI;`KV=dmzsy~xETp7%fwdcOX{;l(D7+ul0Y%nrIdC0R zaE=xsZin+V=GJ5375!qBGv)$IKZcCTj@O-xK4m>3m_@mD!Bef*^?Kb^p_~kW)-H@$E{FT5ei?-{}(d~_=aG+Do->ouba0$o0fFA zf}ioKbANgDpdf`BmjE?0{3^fuKHAo;7~Z_lu`Ep58VzGYvwzQ z=wb%5O}^k{R*!~n497V&J&cqszabsGa7M>RLvLSr!>GNcsy*C8vAeKlrs4z_)tH8D zg|&5Qs}jSm4hFvWty~h@Y%O%pAh9yOqzZ%z8j%jt!|g@d=*itbgRs9!avQxd|FDsp zDk5sp79;13-~4@Ge?-W=&bRTgbFREM&Uyb5kL5I{~xOZ)3y%GJ>q<^MKrftTPNa6Rk|O20-+`uSb{$R{Q2O*$p4linac{W`a35-*1dm z6iFElrsHk1kGX8#$DR?JA=~q+GWqs(gI|qff2aIT`V}={p469W+gAp0N`e>2k? z#5+YITucbSr~M}}aqB1VC_9XoXnNiFFso3lk>e_DvTUvJ zgapFliGFS)vB{4wKrp%ug-ksgvy#`O`TX1-FTlyQ@J$Qu*@EqjHlR+5VegDv6Tm9E zs%o$rhnHTNAazv-(7MMZz;WdEKVToVo2b<8bb;&aZ^{8a&N-icLWP6gDs4dJ zHXMBQTSH~lRLUiRZFam;pN}e8-oC+KS=4qPMPeXf=}0cXraH<;rZkr z4Ck);sG2Me4hk0ZQwvX-D919BzOy{STj~SHT;J#lA)}3VSFHJMt_asQYmx~Wx*C?X z<%Rp^yXxz;k1j^9n)6S{t4pM7ZH@vS6)H^|dtqBH`I*mzzWk!irEDf;Li}*Vpv@j= z93b1xmKwG>p1#@?`#e)aE%chmOx$@&&;Z*XZeU+}R{YChWYn5Tna|m3iyOUnMfl&ad?z5Ug(gtXw=AIilq9M(T8P6B!@K zSDu~P!P$nAmDlU{Qw!<(Uka@os(XH)(fT1k8 zw8YzwHPSj?U06iAje6I0Q?ac8{I+3;7JfycVSO)Q?MU^#gv5)!r-l|nm`PuwJ$tDT z>2S1`EhMJJbKumzqxGR-;Z4UN&G@Sx5}Q$vH-FY4lfr%nDig=cUinI4xpN)9cRLB@ z3$r4=Fcdj!hl3z{Um9xjTVTw+)e2@R=H1W5gfpYxt zK`x#U=Dw8x{Fyf2FqjnA^>T}7T)@m=r2}G@Ue$LmfgmZjGGiNWiPbZnd0{(9-UBZM zGK^uZw~B}?Hw}o~RHrMmuQBVKss0?9yd`+XhRmJINDku<2DgIA>|x^V(qjAJR^NRB zWkof5g?+Qw9VNOExhC{=y)G@_H9_aoe%|c=qy+eTJjSQ|s^e2Xa9ZhO*lOp<0>gIm zL6nz~$JM_d3jDrRqrfqfgxrDXWTJ>8Gn3ZNs0g)HS|I0iCf7BcGq-W#rAd7*5r^c;9}ncyTD7ILoHk zx@qt3c@M?h__{^eE6-?_yQ?yK3$=)czFdq)q)=C)kYxgIRhRXo-pr1bo?WMW@gBSu z(Uy*nNg)X6sVAE(z?0DZN9 z6ozp0c4Yjh_!Y%f6U{Q7AbY@D;jLBpJfrcasK%k5k_!gsCb$^G)Np|=&=h>Mc zBFxA#DLYj)x1i-dE67SGn~IG?PLRQp?l?P;|9<> z5Jf*M7>`}tMxSleb=nUUe-aRbf6KAebK$8SM1DC}56r7`qo1XRE)dssJK{ZamkpZn z^ZApB1oe!5@!Y1$5r8%1mpJXuMVyEK`18C^O3k;8pF3F%KfFzo)ZU13UOYq{f1gsR z+Ryu&^X4Z_(a^V}ORg`*X9z4z;C$epmU=m{!*0HOm4{)xtzb`cO*}_mZKL^!sDP82 zC>z6!cV*MZTwnHdgtN(9FAys)&T7&D~a;ZJa%=GeqMoy^@F;r z-0Hcu<#IaW^tw&&UG=>rkxW(j%B!hzrvwGxs}Yh{f66}YpsFlc)=*UJnSY4V;4Z#= zGWOKv{Fp%v6{J6$AdW#Zx$E6Jum{B_rkYslIXD67e}jKQ7JqaS z(m_ah9@%~*8~?SV-@&6!R@7DJFA)tFTE**r~GzQockZ-!EnJ}R-k*XMX`Ey^|@qF?0k<)c6* zb|P+cQU&I7NM4=Le<0^Wpo z*AQnS^O>G9p^5Jgg|*A1vK*-O(rR(wkY2zDeRoCm>ne)zmJcxTDTb<5-Z}`p(dMB= zc?X6n4Zs%D?3PqkMA>3^Ck0up@qkmn9_F1a@S0-zg^N?u1R>QyZt1&m7kK^QYR%lY zKGx({rxh>&B>ps1F_Z^WoG{6A8_&?aCchJ!0$(@qmyDbJdt+z{oyPsT&OTD zeTSJ0=o$v8mvKPO<91zW?jhjrdengW0 zl3Abxa!b|}`-XUu7A^`!#=aU~nGT)@z|{{iPB~em1vUz^%xh7>XygJ$pJ<7B8qL{_ zanqZ~%{%XF9_{1`UdGSNBNzu1=(1;n0bZfTO=}hS;P6GS-m!@H84~tY z%$QTf0&_m?9OK*{!F8^}d+{KxD`$r^yZv@x^Fz4VOlDww z=2U5S2H?5eRxgixX3scq%uhf)Ef3pbv294o?dZ=Tt!TxC8CFg|BC@7ZSu3@wtNW+0 zh*dtAYVNd9gc;C&Pc4u38l^X+t!$lluh7NH>(l7&fNvO%sHSNFS^P9yBOj!wcPW*> z{{_H+_@7Y2e@o6^K#frLlRCtLCL$`E80c3Msiq3Jx>jBdUP1h@u*(_ys2?onPG#-ZJK{}V~nJI4)ZS>);#ofPi!~WoMM=yX@qfhF3?C*$2 z`p@JXe5!lyZa^LK|0-p~JKa7SmTrs~_xlwx#g?!h|Tl7t;$Jyri^AKQ2WxO{oI1LiLO7TWY!=|E5TORfdhL)KSG@@RMB;@HA51 z#g^~ecN$$*mNAcuPbA6u*ZP14P;{K8(PeYY1yH;PM?>j!qUp__ymwh#ow!)ujDSQpzmKcfo~#I%5LF-Comgx)*$ zqV4Ctaxy@lPlWOR&8(wGq|FY9-9Hv=Amq?{X-+td;QCQ6~@<_mQ#e zA{cVjX$%vc?E0=W{{lKb@NHJn!5M^Uvyr%id@^hO?$BQsB|f zg%+vF(O=@+?uI7kH=ndBJnEII1IYTlpn>Zn{imiAnrZL;EBW|*Gmc}{X7U{kww#tm zwcge?06Zf6Hu`d9hBJ-(8KB92s1zsk3Nb8D8~cYh+;auZ(1nNGn04oy;T(EEt7im{ ze`pjJh^dpap<2h`sBm|IxHX?M4Rzr!lKK|VM~8NU(^a2V!h$<<)OX^O9(0!~>m_u! zXE5%s@|SK!Og!l&7z4)(&ETlvmpK`UY*Ocj=0DZlrM6wH1NZUGEAS1*Vgn7+k2-I2 zzXR*IWd5q#}4tbP?V zZ=t5%i-J0^TvY9UI;nZ5E1t%u$J?Ta*DG`KMf)Mxown}PfJEG^ca_-3%^i%R4|h+D z{4}=vfl2)n1e_iAWxEyf)hWMtkK<-B<%k9z&_$ z;@X^~d_fCveubWZ-=w`u0q?wL-)q$PXZrWPm;UTq=-I#lV?def+WaH48nK^@HF&a& zQIsC)MU-%EL+Apg-!K%4WHmcOf2L+9$A985B$ixL7`~1tw+fvJw#6-3s)}kXtAdREvKFSyUp~Yw8HQRFuG@>Uxq50k0IiJZ>#{b@cOL&vSFQYr z0BaRseVWMMT9j&WC%gFwpnAp4OOH0C^g+~1lvh|RJhAC~0|jqY{TeF*e)$ zDDW6PVfDQ8og!Z7=g9vW>=1s1(x0tyub^Nk1ID;ndA2$ChD=S5?BuH~pnxwX z)eke?g>MDOT(h5)8b{KHD87Ajeni5Orn6Y1->?tJR^Kg#Awt06gkSWQeWPJ>Zh;4&N9>Ql^ zR9Fzngs|UVO6{HhzI7}f+%2}XojCnmUeAAcm7DH!V2&geL%zS9?))$Elbl;cv^;-A z4YxQ5s+MYk##1@x?7>|~UCoH=``;xgLT3vC$}b7Msj@~9Rbt8d(=wcekB;=#1w_}U ziVkmf6)(p1@WeV?TX9!t7n@X1F8R>c6Lqy!(a&0_Fc0)yN6~)D$uzWhTHO9*yzei7 z6w6NJm=tYy70lPc^fMl(Mjcy|jT;}e!jpu=dJRv$c|eS3 zSqlC=pvFbvgs6k4M7U7643NsTW};P#)V112?{=p@*N8SmJtmNIfR@ci}i=vd@?M@E{f!fiwT zuf|U3o_3J%*nR59gK;19=ams#H?!wR$`NBtA=jeJtQ-(w5(+I3O5_Msn z#YGn!U23;5jT=kmvZDc8v?KqnhnAiG)4~ys_hubA%VeVKu5=HjG@cKjy4=$WM66{SOduj-qU@u?T7T0v|8viZ$)L{?f-WXJxV!qO`>h z1^aM>2QmYq-pukmb?MVEQK`mS_*I^^ZA99`oEk093|1nhubSjwW|$n5oCl3SvkOh_+2ji4PuTkLWQo*DHqT%1fgaxkIyMmC%&Cv9L_uQnUxl5d0g zECltVFzjT|(h2To*BO&nYkD{%mut^@|ij#caXJ5T;Nk)G2L+$qKa~+!c84TH%-2+H9tBT?d(571 zwAuE{w-CRYS_|OFf2#;gPO;NZXjkOld6%yi6yUbPeoS**= zw(aK#!ZT;=;vruoOJDvRE}p8b>y*~p9^2tcRQ-l+n{4$pG&j>5kC?V(y?U-8aq9P0 zFWTsFal4~l-N-c@oB+y9P~m~Vei1z>Zbv#^N3piD$+hNd^MU<(p)4GZ+th;Rt&J>| zt~`6SsCw~9?vp@Yha3p z7|!dO`xCRgeKDi-u2R9^#SR;LuFDe~dI#&q2vvC(t+2=;KNe$_r&s0v4H4Q$h{%4y5#4YaT1ARa7Ov!#H8JiW1XQ3**RJ{{1+Xr<*B~*8>E_Z zhJYnm8z(fHl5q5SKdoRn<^EfqK5Fr0$v%LFM!=cAVKG#WFxHUM(q2x^7#Y8?t8xBC zis;EMQcAXWr8_n_m*oWd>SR;Ub;Zwtd*}U%Nt(!lT*f+I~v8x`IsZTw7BeBH9ySEjM z`Q_V6)TFqxOfu|;s`pQu3$)?h7LC8D;k~8)QTViJldvZ1QjE4cGS)SlegwPeAn~BI zoBH@W*K0;w@7&QOuIsq@`I9AdRTj{Q5$3>3TZwasE$6nbn8At1?)=o@YWIi|7vAh3 z<_S@i1zrvea{K+&4ejcT#9&Ph2iGmm1CE>Qpoy?Y5=TAVLtMO31cmo$8aDP%5_u#kRpS&!0dqc;0t1315zgyAQfD7Cq6LiLUxFu5L zHrr*788fxF*X1U|L~n1Ml-93%8THDaUV3dTan|$>FQMd1+gQ53L7N*0kfR+OXsIym z^2Q0=&(ZRa)jLtIRplq1j+>q^)D-kJ!xa6z=5T7(!W816NOI6WlU=;rpe4jSDq`JA z@$cKAS9>%2$B_%KiYKQps7;!L6gOLHc2+AOGyLr4W&1enurP$=Y^lRO2AvRa=u&+p z&@<5AooED{e?#YglRt$TZ|i;#kza2ycI}`?KxD}_!|kMEijnv@MbXC>WGOPbxYx>U zv?04NRQ+n>T#-(}Zg<%o(`;MrRE2bR)somz{hrlZuka)QpmPI%ij3DiW8V(BZt(p* zO7hbFH2kKN0ZlMSFiC)SO9y?Q)Cbf#CIzx^G`gwRVY@ecRy}U&+XK}v15InE9WKWz z_6wiTGoIv_1;BIVFSBPo1sIa(nZ2|O zPE%Q!voXr^MWox8$@sZ69^wIFVOlE?67$PfH5Ig(zG3v#F!zt$#Db%(t&|>aPV&%C zoeAhz;K`iOuA9BtR#}%1^_&GRn?dqvnC>rs0jYXmMYo{N zF+Gvmks$-oVY5d@g)U2BxCx0E2~94u$UJYUVCmq}c{Jn7^3&UtRHd2pYmyJIDRz8bP2;fySy>XN%&_JwT;d zwRA?Q%O@f@&@Xl&x$H{SwViA6r@kxfyO40fXDb*(XokZU5vMHKDy%f4gFdu{F;Uem07 zuj*297mVtk%5rlbOV+LreMM=$#wniAJ9SZEt|X(4J%q-V4wk5AeudB@rZgNiBkm1U z`*qjx<@6c|+I93PaljIhpF;t5X*Um)}u!H36wwd{u(72a(VE3 z>Gr9;<5*|^>HA26T}CKwuF=njs^ zduRUv&pG>zo&EukY)Yg$#eHot&bd`AV1WOg!l|3>Y`$?QQOi8a8ieG7AgF#V`9_nG7NaU3E~vc;DiRrYJN+IyJ1)+?EB9YL)uCSb6dgn~^fl=> zY!p|=m#5*yXeugi&TkSsf$>XaLq-oT`wu8IJACVXHYa?x&y&Dnbo}CN4QKD(e*ItV zNz>DyTKA8xkNTrS2i_gLUj93DDG(T|8}Ys!lK;^T$f*OoPwV)`V0ur~AtYNLb=kZR zd}n|%eRzq>aEP#``$an0Yz-E*6=>tiTC$&i`_0WUvnH!LPsPlBE7Qs^EN3E3`;nsF zFCHt@3u}P?Z&nZ4Opu=wS@1_t10LDg&91QgOoM$*81Heeq19=PwN2}W@b__;uLpO3 z@JhL}1pA)OVI0Vl+Eu2SLJMK}M<|M{YT$#tyUEMPyw?-+A>;dK%n#+cuM z6@IR*slo4Nweg@*Jp8!R;q+jj#&Fi8l|@0%$`ffkxuM+FESzby?wyh;`wR8p!eil> zr03why45`W)M)gPKmB)--AmH{-rvgdI022w57S%h4t(T8^5>C z7)De-0ayO;T1WHxkJJwRKri8le9}1;ICwQCCTZXYK{w+!=MiXstn?=h@$DS!e0AoP zT0{U8$_qimAe-oI33}4XKY*dlzv8@49(?Rcx~^VFlc)+>Kx5Hx($QG?sK?NDR+~+& zne<`)=FhO*`FQW@znF{b@%nm>@(ECOXTrv)bU#7Iqk7$-(elKMMG2k70GD`pg{-FE zZ)6^}^6mPw?z^*Ab-W$#klbTTdaBO)4s3gM6%QS2rg5)sQaIWW0N~1TDJhHBSC?Ob zLtAHZ;!2pHqW4_7g$bhG0?UB*YY1c$Ot37wLfdxL9`EFD_V$R!f0z}zKbMT0!$>bj zKMxg>;e8^3?ADu54?TLWe_lkuFZ?gT#R+#W1LNM)?LWHyUi;s} zi&LI$_3~V=w<$~Z!?^OuO!^5}7%%@I=&XO+Y2dpq(GY6uSEpQ^d9E{F^MFmNU<5gmqM2VK zd&^NVKe?6HGljH(APV@j#P56fT-25_F29|LTb=w+l)_rhaNwzf6z;{D{hZleP8(#p VBgiJ0|3oYtS5&0y!t&^k{U4%^@=O2# literal 0 HcmV?d00001 diff --git a/test/storageFileApi.test.ts b/test/storageFileApi.test.ts index 38a633d..1e808c2 100644 --- a/test/storageFileApi.test.ts +++ b/test/storageFileApi.test.ts @@ -3,6 +3,8 @@ import * as fsp from 'fs/promises' import * as fs from 'fs' import * as path from 'path' import FormData from 'form-data' +import assert from 'assert' +import fetch from 'cross-fetch' // TODO: need to setup storage-api server for this test const URL = 'http://localhost:8000/storage/v1' @@ -17,6 +19,17 @@ const newBucket = async (isPublic = true, prefix = '') => { return bucketName } +const findOrCreateBucket = async (name: string, isPublic = true) => { + const { error: bucketNotFound } = await storage.getBucket(name) + + if (bucketNotFound) { + const { error } = await storage.createBucket(name, { public: isPublic }) + expect(error).toBeNull() + } + + return name +} + const uploadFilePath = (fileName: string) => path.resolve(__dirname, 'fixtures', 'upload', fileName) describe('Object API', () => { @@ -25,8 +38,8 @@ describe('Object API', () => { let uploadPath: string beforeEach(async () => { bucketName = await newBucket() - file = await fsp.readFile(uploadFilePath('file.txt')) - uploadPath = `testpath/file-${Date.now()}.txt` + file = await fsp.readFile(uploadFilePath('sadcat.jpg')) + uploadPath = `testpath/file-${Date.now()}.jpg` }) describe('Generate urls', () => { @@ -72,6 +85,20 @@ describe('Object API', () => { expect(res.data?.signedUrl).toContain(`&download=`) }) + test('sign url with transform options', async () => { + await storage.from(bucketName).upload(uploadPath, file) + const res = await storage.from(bucketName).createSignedUrl(uploadPath, 2000, { + download: true, + transform: { + width: 100, + height: 100, + }, + }) + + expect(res.error).toBeNull() + expect(res.data?.signedUrl).toContain(`${URL}/render/image/sign/${bucketName}/${uploadPath}`) + }) + test('sign url with custom filename for download', async () => { await storage.from(bucketName).upload(uploadPath, file) const res = await storage.from(bucketName).createSignedUrl(uploadPath, 2000, { @@ -187,4 +214,58 @@ describe('Object API', () => { ]) }) }) + + describe('Transformations', () => { + it('gets public url with transformation options', () => { + const res = storage.from(bucketName).getPublicUrl(uploadPath, { + transform: { + width: 200, + height: 300, + }, + }) + expect(res.data.publicUrl).toEqual( + `${URL}/render/image/public/${bucketName}/${uploadPath}?width=200&height=300` + ) + }) + + it('will download an authenticated transformed file', async () => { + const privateBucketName = 'my-private-bucket' + await findOrCreateBucket(privateBucketName) + + const { error: uploadError } = await storage.from(privateBucketName).upload(uploadPath, file) + expect(uploadError).toBeNull() + + const res = await storage.from(privateBucketName).download(uploadPath, { + transform: { + width: 200, + height: 200, + }, + }) + + expect(res.error).toBeNull() + expect(res.data?.size).toBeGreaterThan(0) + expect(res.data?.type).toEqual('image/jpeg') + }) + }) + + it('will get a signed transformed image', async () => { + await storage.from(bucketName).upload(uploadPath, file) + const res = await storage.from(bucketName).createSignedUrl(uploadPath, 60000, { + transform: { + width: 200, + height: 200, + }, + }) + + expect(res.error).toBeNull() + assert(res.data) + + const imageResp = await fetch(`${res.data.signedUrl}`) + + expect(parseInt(imageResp.headers.get('content-length') || '')).toBeGreaterThan(0) + expect(imageResp.status).toEqual(200) + expect(imageResp.headers.get('x-transformations')).toEqual( + 'height:200,width:200,resizing_type:fill' + ) + }) })