From 76324c83aa549ab61d25b2425d504935e7ec6219 Mon Sep 17 00:00:00 2001 From: Sergey Rymsha Date: Thu, 4 Mar 2021 10:58:36 +0100 Subject: [PATCH 01/17] Create enonic-gradle.yml --- .github/workflows/enonic-gradle.yml | 116 ++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 .github/workflows/enonic-gradle.yml diff --git a/.github/workflows/enonic-gradle.yml b/.github/workflows/enonic-gradle.yml new file mode 100644 index 0000000..715565b --- /dev/null +++ b/.github/workflows/enonic-gradle.yml @@ -0,0 +1,116 @@ +name: Gradle Build + +on: [push] + +jobs: + build: + + runs-on: ubuntu-latest + + needs: release_notes + if: always() + steps: + - uses: actions/checkout@v2.3.4 + + - uses: actions/setup-java@v1 + with: + java-version: 11 + + - uses: actions/cache@v2.1.4 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} + restore-keys: | + ${{ runner.os }}-gradle- + - run: ./gradlew build + + - uses: codecov/codecov-action@v1.2.1 + + ### PUBLISHING STEPS ### + + - name: Get publishing variables + id: publish_vars + uses: enonic/release-tools/publish-vars@master + env: + PROPERTIES_PATH: './gradle.properties' + JAVA_HOME: '' + + - name: Fail on bad config + if: steps.publish_vars.outputs.version == '' || steps.publish_vars.outputs.projectName == '' + run: exit 1 + + - name: Publish + if: ${{ github.ref == 'refs/heads/master' }} + run: ./gradlew publish -PrepoKey=${{ steps.publish_vars.outputs.repo }} -PrepoUser=${{ secrets.ARTIFACTORY_USERNAME }} -PrepoPassword=${{ secrets.ARTIFACTORY_PASSWORD }} + + - name: Download changelog + if: steps.publish_vars.outputs.release == 'true' + uses: actions/download-artifact@v2 + with: + name: changelog + + - name: Create Release + if: steps.publish_vars.outputs.release == 'true' + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ steps.publish_vars.outputs.tag_name }} + body_path: changelog.md + prerelease: ${{ steps.publish_vars.outputs.prerelease == 'true' }} + + - name: Upload Release Asset + id: upload-release-asset + if: "steps.create_release.outputs.upload_url != ''" + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: "build/libs/${{ steps.publish_vars.outputs.projectName }}-${{ steps.publish_vars.outputs.version }}.jar" + asset_name: "${{ steps.publish_vars.outputs.projectName }}-${{ steps.publish_vars.outputs.version }}.jar" + asset_content_type: application/java-archive + + - name: Write new snapshot version + if: steps.publish_vars.outputs.release == 'true' + uses: christian-draeger/write-properties@1.0.1 + with: + path: './gradle.properties' + property: 'version' + value: ${{ steps.publish_vars.outputs.nextSnapshot }} + + - name: Commit and push new version + if: steps.publish_vars.outputs.release == 'true' + uses: EndBug/add-and-commit@v6.2.0 + with: + add: ./gradle.properties + message: 'Updated to next SNAPSHOT version' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + release_notes: + runs-on: ubuntu-latest + if: "(github.ref == 'refs/heads/master')" + steps: + - uses: actions/checkout@v2.3.4 + with: + fetch-depth: 0 + + - name: Get previous release tag + id: get_previous_release_tag + run: | + PREVIOUS_RELEASE_TAG=$(git tag --sort=-version:refname --merged | grep -E '^v([[:digit:]]+\.){2}[[:digit:]]+$' | head -1) + echo ::set-output name=previous_release_tag::$PREVIOUS_RELEASE_TAG + - name: Generate Release Notes + uses: enonic/release-tools/generate-changelog@master + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ZENHUB_TOKEN: ${{ secrets.ZENHUB_TOKEN }} + PREVIOS_RELEASE_TAG: ${{ steps.get_previous_release_tag.outputs.previous_release_tag }} + OUTPUT_FILE: changelog.md + + - uses: actions/upload-artifact@v2 + with: + name: changelog + path: changelog.md From ed6a82347c0742f1ee5a9a6284f3582c365b06ed Mon Sep 17 00:00:00 2001 From: rymsha Date: Thu, 4 Mar 2021 08:31:38 +0100 Subject: [PATCH 02/17] new gradle wrapper --- gradle/wrapper/gradle-wrapper.jar | Bin 54706 -> 55741 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 2 +- gradlew.bat | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index abc4c7195325930ce37d2f45cc2ca9253463bdd8..457aad0d98108420a977756b7145c93c8910b076 100644 GIT binary patch delta 49846 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 kf?3pR3`-0eoQAA}X}h$iH^~KfJ>jpa1{> delta 48759 zcmZ6SQ*fqj(5*9>*tTt(6Wg|JJI^E&+qUhzaWe75oY=Ol*A zVN9fvi(^@RgO)}h9hx>(leLkthHHm~$oXLg!&!(i-0KO^lrqjnv5DRJjC`|}-kXM< zou=mdwg$`zi}GtVp*cBc<+!D<_GR*u|NH!SwgNNkS7H7ih- z9Em2IsiR?}!5F)3CqB0|D+_K)yFf<)mzK4lvlmqA=*ULzvc++kqyVH-YOv3PY*#0< zFn5@5i+>i+wJsVw_GZm$6emcpr&(fH8bn!AVR7V6@!eKv1_?a0qGFMBF)?RJM3q*n z==><3=qb^~2w38<2U$>iw5rQy+Qy->UfAMHD(V}_{B3M^O)T=12KPPp=cl;Jx=+vO zF*$-j_zSDeP~TjW>kRl%@UCREUn(&Bd`?-;$WOXV-0SIpZ0yQZk_uF4@3Io*DlN#n z`Wc`nom*&?rv40fAE`^V))bo?*M`=!|H2t4`iT^a)6(&~;3vX+|2JU=>Jq z67+Y&1io*wf>Qkk8_5OqGy@67bSp#9a16Z!-@dhArnngOe+TSQS$rj3+H75V!gvDv zJMOhH?Dxf@^2+m!IW3g>rCzju%rL!X>%JzzL03V7oF3 zFT!?$rU&0e`_o@!`^Eefer~px^|Y7mqqot}r-e9S_(xx#U|#qd(&B?O&G}O?Sn@e0 ze3?u$JjKhOje+Kfo_;bWP=pn`>*2FsVQ zUtE?IcSC2y2goNN)bNKAhvqhPbaO1FevM3-E`=He@2on+HR=O)M(!5VYR$@uOJ1lY zV!1(PfJSSE$ROzvBJ|7xe9KEn1^r7aVszzVykE0LG=P71^@-?cK!@BrbYY&|!GaMG z*`=5}-P+HB<&Z1O2xd3it>XfUoz{(3ys$vG_!sTXF~zyx>l43qbLz|ONY2q-nP^%| zQRS8P9R72zjCTR5CK<)IEN6z=f@6uzo&vnD<_>mKJ1m!WYeAkox5!paEMYjvleSmp z$9>w$n&2#=71TWyK&6YPqB@7 zvlhBKuo2YKTp~6q=_q<(YTn4Fk0EtT!U?6_i3s4w|331&m;uFR?(JhCwSmaBJP83x1b{aFLUp?3Mb7XQ`X!}*&5Z!#eE~63 z%pazx8~*408@MY<^24EUq>27-7){iE2k?oA!Q#FXN$u#od@9yMl5sOU7g4m|gtB5e z_VHwq`wx1O6n4}n|1?CW9P)6XlSy1iGU>D-?MYq;qT!=zy;%r3y(2jYB)xpb8!Q?Q2?lp0TXp)k?f4c+LBX} z=Eqwepb6|$XLEwN7f#@C$3+qJFDDL}B|{LE#DO9Xj~ztxU?dCx`@gFHNA$Q8pBpkb z7}$^hYB@);h$2?9KQsm4geHO^_&sIV!p23TVM*4(F0_i_1ru>3#8_TF!ba7>^o=7F zwr2Gz!6e}>THb#id);~u`9=!&8cahJ_HRIj-*KMXwV;>f({a6%Fj&g%iX<8@K9Vce z5?Z^B86iHbJynDKh8`Eld0i622Ywyvie)JOwXi;)UHm>&Fiaaz;DQ`f-9#Qf-`WjQ zc6t%{f!fA|FR-#d&i(GfQnntL-atqh5A`?CFW36{b3*VByu1U7R)>baJs!GjPz*jEC+eS9212HARoTzK9q8;O;gR2v^|r1Gs)tF% z<~O2~h8Dkv)%phn7o`$|=t7%G!>HLK{$*t4#U;Wp+Bwt#RkAKyzS(L9M#D zrWQd>1WG;R;`8?h<9pSjQ}pf)uwKll9SSL=OvH_&W(>5tbd;Nun=#sh(k3HVmQ1@b z^@+=BkzToJoHJ|W4ACIVkMa)`jgr}o7H(!#Gl9gZzVV}N^ZVp*ykRobHh*_*OInmj`PD*2KGzCQ=JiKrqN7}bHkb) z{agz6%R{wU?FwMvM+N&UhQPRN`DM!w??pFe#s|v}o%P#fwX|zr@W*1aQRH~UAyech zVq9WgDiSEjvo{0kOT9Q@lTtR%)|TQQ4%;tfe|PrWcBa0IdG*LnYh_q_5`jy znOC?ovLzQ+(RrN0PJWwWwl4KQM{XlHt<*vb-IgG$`7LV zIHvusVywy+#Ku~j_+9U(WwVLhnfYj!Wn83WXjU&eY1Ht}4szDVOaNR>v&Y z+|vX3k>&}Nf=R(@lY_>95s1Xqn&a*hEH3$+E1R zQnYINL4}x_o!e4w^W0lgca*p(7Z*tDr8lnHo{}bocvVqN>^0WR*8k?Jvi>+R#D+rP zCtD>8S+yC%JgX`&eH$a3e`{mDp0)3)k#sRN{8yyhT}4iB;i)|xV@2O`mGE$B|28=b zur8(0ZJ&{)E+6k{EiQ7?tYX+Sm&Y7+Z{XN1V83MJmDjcLd#?LtCA<5+v;@~)iBTKN z4$3#X3v&RLN)NeGW__OAqvku^>`J&DB=igZx{5jZF!2ca#L3T(x?a`oZ~Z(hO-icj z2&sa}2H(v?8%Ve#=RcR_QNO}hg+I^%2EB8~OBl9^n6q}%#<#QsEiJgDELZ~a_zm3! zi7316`61+Rb7}4%G_X^M-0=TXb$&yV!=!9&GpU5cF*et!HFcMj)KZT)F6yV#)~8 zuR0E~6ebzeE{kRd`l5_Um<61p>}tR#u8xb%f1*i3(V8IIIZ6mialf z5vEG))q~Y=1y-^+JJUrB>Y)rO(=r~~5d~J#FnJeQyiJDCOxmpwYz5TxDVsUFDE20= zKKtpW-CpF37u=7UAIfiZ4pGp;!V`vDpgS$BcmTJqbcYI?1TsA!h2E{wePAv*?=B&w#UHA7{Lhx>n3S5Md_MR5j04mO|bn+U5v-Y+a(UvQ8*9k%QGN zN~d4mhM^RX>b9wZYnT^s5sJ@IeR7b9d(*GtJw0+CkET=L$&$`ARlI@dzfBJ3gJsd0 zWG<#};L`Xb3a-NIS9w0KUuDO3O3{g=+k417xh~)D{YH9WJ^ zRb(p_*%eo^+c8Zk?g%~iA=fIZ1%W5Eqm$|2J|mO(@Lo;Kk*AHLxZ|`=e~n{BkISJ_ z9^~TJ>F39{)+yMd_1RWjVh3krF5M)nXlhv5z}Mc=ZPOY8O3qG)vas*Jt(Bv!qZ#f8 zKWC(>(iKgLOn3)g;Lq-~HGX#f)Qe&pmvtg{c!$2Up-2?o(v>WKVv)Ni-&e9V z@hKUL#fMLyR*l8X;?I}%wb5A3rRohxFBO43RMlxM^RRHhwyD)WU^q=Wwn~}9Q3PaS z8w=%!n$W6B@8D2n%wkrNt+aWVR-Zv|5;BK(pgb&E+IAo#Nmjn88!Z`9{@)#VA{QQm z!oN~{NC6EsD7~2nnIbg#oSetIL(7rmUYeNnbg&Mc@mjeu=yay|d07|rvWTKU1v(B?u?dpb_-^DKVQsN{~6`%r|Ugd(MvdPxtK_n zTA7la#)(RZlbzLSVK}!U zyjS+A@00?|yxm-4Dh7z!Oc@=;AZk}a2xZd^ItZgX?U~M+bO@Ve z9|cOIFJx-La}MQb>;ICLQZ{ha7>P|GE*gcSVnK)K1J`wnl5;JX*WZpwl(_a`Q> zyb+v8J*G4MM)ekwM|U!uDYdqGF%%X0ck#Dx=3ZQ@u1mNv3>S0kxO8hoTK~4_jsl@p9Mze) zVQ~W}Q_N&|TS>!=%Z;2wTcC(bW}t16b6FqswAi{><>VlDsk_0ab-!gbyY(ca9UXoj;H|3#Xr|=`!{ZX+fSIiZzcn(R{%F>w3#QO#aoK{J*gW*+|r^tFzTl} zXH#bAaypgh@*5#__{akSDdiJyVWA%r3_%L%%%1{H@hY7m2WjW+f?H|Biv34OUz=ln z1|MC~Ko@r7vFzLq>9BJhg*j+POdV1Jm6(b$|I-I`=74?f3-lxbYzWT3(t$IFT-%h_ zF!<($RTA{2$6dp0`w_nHMXwO|GDm-RnAGk0u$+?;PcKIe_ig7`5l}cZAymLG#eZg% zU?^?*99~&Bis||EP7A9fd)|DhkR^w#ZF5ai+S12EeW#%uN~QMs`eHyr@(O`&;=vHo z|HLI2Gj%Q@_g9Yt;vsqBw75-ZMppBFl#}#3W04E?OOv1-oH=)Q3i=B~tDwbn2cv8K z!Aq{8p3o-$rV`mr?M%3TdlM3Kug0t6Ztv)?)MOna8>LpfUd zJ5BoK1OWw&)X@vMyzxe)LemT;brJ^fN^|7b#dOzJNnc>U-cQktt{Lc>)d~}z+1GB} z&feH?MYU3Z`sxE_6`$7LKZ2A=(_z=;qGG$vvK`PRuDj5yPY?shV@fRcV@fmvs}=Om zC=Ef{0>715u*%cTZk0m>8PTGKAD^_lSr_{y&tnV*7gO0*gv46&N=1neTR&O1{AW-> z#K(j$upTMoyrFkeu)7$|@h#Od@qj8J{WQz{LS_e!-$l8 z)g<9q9St&dldM&w*v^kWL+ODR;r~5FE-Er_CfDL90kfSJs1%}!i-Gguw0nOhGyW|V zA)geJ#ez16cO8+A9|Hxx)v(;`_?)0qHC9FIkMXq3Yg)P`OAN4@2*9)Xq!$^}PnUnL zJs)Eau&AfTd8Z%AXE*|9D%Il6tRl7y*fr_3GTJn`5#kd(Rw{NnX*I*bp)7AlcT~(s z%Q58V^k^`IE#%>|p$kF}c%D0f{~sU;j9@`EgaQLQga-p-{~tiojY|q>>bapG;|2=p z%&(nuav|}cj&jb5v#kU_{GjWiknC3{acgqf4-s9O5AX4At*+DjDW@bOw^ridE4vov zx{!TDwvcH_g^Ds0qTkBi<5=ETklt~sVCAdw=HJznh}@1o^TOX7ejKoU?572MUP}>w z=LuQo%NyuG5{QK5l(z+V>dE715selShj?3kL~6^{Z;P^wRH?BSZ#+2l)dwjNncD_c zCvXV`X-+vbmPpoJbVfBPdoi2&j*P3b>G_U!TZ)S=IxcVcRED|TUn)*vl$CybdJqTH zp=-G)40<5KeXMBjLI!Q^VV+l|dN2T9T>PjnAbx&^+0^F^ zUE_}al*7f+A5z9zC0fWMcHl)M4V7oSiv5Tsz+cxz6W1Zn%bkcHWCk?mWe|Hl_4314 zMVI4hkK-{9)U~)$bb#TQYZ=3~=KGO}Kt{6-rQXBG>P3xHDq&{Nk>1~`hvw0`^c>%* zUb<0xskk)RCQSgpP2W!Q4I-;$;Cs-VJ4X1WMe2rgOEokonbF6w^6GsavB_t`C1rG1 zUT(46s_u_!LCn~>#=4Pl1U1aqt?WHGKbc6nr<_~bxokNWpE1Tgok|lXA$l`fXw+oY z+~n22Us44IuhB8~hPpyd@8UCcSQwBL% z$Y52;S**6Po+XZr?UFAp>yYUn+Pj9SXEsJ4>*7wCNpC9GXn)OmyI{dJA^LS#s)6qYmJ z9i;WQAEno62fE#6hm-RIgGLtjQ_21m4Q{Nz+yHZT)X4^`NtV2rh z%yhwR^%gc}^mLmYsl?)C#GKzULDWPNltCH5)NcU)r7T5&jgVz)_We$dwV2xu$`FRi%JwBSsO<(6W`zHO=xF^? z8|3AzI>`9FZxB)Q%;bU51UazGv7fsoD~j}28JS@&EIU@{M)Gt`;3%Giq7J5^qm0#( zT_zxNxXp;J;d}Y08%qVbIEdVRB5i!#$!r!%TBM+Pq_Z6H#T;fZQ3tu~w0iB2K$(A(Bqql6Wno*r?^- z1o5MMUl$g)Hy#bQ_7heY!l)YNeRA9Qg>*4YbuqXgz)H^PPh=Dk_&vl?yj?ZUR389P zjfyZfhsLD~;8A86$b@?Of`-VF7Q6~vR=0jg)lU}&P4lxTBHnRfD-GkHbcTxAZapCJ z{oM$F|BOD#bUb@ezkL}PnK%E6RFRZYEOa*Cj`9s(fMGntHL62%z553f%@Ke&@D_G{yrkTsWaiN ztae48f6^Nhw1;nk^9l}sXXlVsFH`{cKOrgoN4wn7D}Vf%Ge>5s79Alb^I{2p!)ID8#umEx=p`q7-)@yeu7>w1I952lFL{L`!)q@#(*a7`|- zzC;A1#){a!!UXb;XXeb*xq&p!(F&h#nd_O|YEl6H<5;R(=COQ`zu=SUt<6~JFn)qS zDeU-IY*Lb9VW_-XNm=b5!?E^@`d3U{klzaR4b$H6vkyVJG4`h9(OUwHktPB2?FX3? z)6Hu4hG{Q;xwEdsj5sZd)xZCjG5im$hp6=3)c=pxe^W^=B;f+|l<-6_z9bV3mL64W zXj(zGdJuZ6x|bRYLlaez>M$g*O6H-kbxd96I`$u0JruUK^P@r!Qk;T=s`P(;b&YjQ zdG)#-O-^153kQO04^#!KOpbXM?a=1QM9{(*8%pZGM50JGknDB@CxG>!_^L>%S}P35 z!O9^d>%Bd_iO>N?Yh!gzr4REWiZf}R3;FJ0&Sxt;oE#Gk)wG%OHYxp0&*CGy$V+;-s` zQ~2Rn*a18igLF7`v>%=9CfCx+H;~X#8{Sn3bPtT>Q^ZVtO~se+Ga||+33C|7W*~V}3c%Rs{vaXEzI@B*ciu^dV!jzFdv;fWA0GffiKaXx^CMML#Oxp3B3mrkqaucifq$y^WuUHQek zc!Lt@h5&>!JPkNrP>Q;iX?Q*{|L=C~yv8=U4DP>>2nP&|?msg+xmqPlDWW6;6;FMAd;1Q9T1`_ROYm5{|oL>zN`doxpMQKZ)f35wiA#TZKu3PPD|kUP~Ty9~9A z-fC=e!gR~2;`0{$reFNOF8+xo_n*Kgr10kZR@?HsVcy5gVEnfg60XXXur3YzA-RJ) z)sAlYej7|mH2vUodhbx;`iFfD*Jr>Mv%X(2aN~T`4zpWPB6X;0;QVkGU%eaEN%h7O zlN0HMkq)$9n{>Q>aOc_4EjAp`{$FSfw{<$&MRBr8m$okmw^eP5xm9k8J5A%QJd8JX zC?rs_^BL&>LxNDKf-D;Lm6J9ddF;?cxa`AQJd`+o{L>6>y2_h?So0RuUmX}8y>Y?J z|BVhORJo%@2ilRr%pbA(c&RCQ%^}X$x`BLsmGF5<5%@?le@G!VN{i{;8`Z+}S6Fjx z)9<|+CA@pa5A;_XZo6@S+`irKO8T$3_Qn(cISi)@3?Z(5feozMv7&>zbqiV}{JhLf ztP^{O>U=}Kyf{DQTWqsx=mP4sNKkmIh?fUl1bOj@8dMt(th0YeglEd@`Of3b@8gP* zsEco{a;|Iu6(EpY|CJ|c>N_aeE4%vX^$mds_# z5OAsfdfRO$P30)|^_Nx}KG=eMt7+8MH8vQMj}4Pjdz0RP$(KIe-v=b=x(BzeygUVc z8Dxt6VZY!Q>V&oJn-~q0Vv~$oM;m{hxo(w~L(mb%WZ-TQMdJ(95-4Q~w3tfjibABM z*y!f6?xn?@SGM%W|9$n9+;4Oa&y+qHL$$de!VixbFS5Mj!IcS?gNKDuEM+8bq^72e z7a8Ti^`SXx$29v85C_&=CGSomdPxj^r!l!+OlUWHdqXi@x_e(1H}OwFTr$LF7Zb!S!&<~HeBGCqsnlj7ObWx?THc;w7vY_-`UXHNQZjy;Pve}*YE332 z%C76%mRVq=?72YqS3*}MvCUgjVREi546#0h{4UCI+#I`&Is7S8ML0dQNB$|f7!j+- z%aI+>OND>VoI@pXWW-5bbblDaXPmveII&k0?2rh~sULmL5woX>*tTPdDCNT5H5Upe zGst|yVW4rMC<%lC@C5Tm2&~zo85nqhkw&yHtzp|cZ9}|N%B~FP>?h3%|D<4TUkC&1 zcadL;gN1Eg*uMr!vS=%EeSK@Bh?j4)J`@JGZ;=sgHY#^9@+$iW4B);hca2`!eybus zdrb5a#5eB1|ZStaxdK>JYdB1I9NAb-N--2Gt=`g~cdJ_cb5eq_4cO z^e_4OD1b6%qfni^BBC^_UHa^tUzDA*QJw*NWvP%lxeODJ^&}1gofISX{PclG5TDPq zOth|(bc>*noR%Fm>Mn`DraV@8bIsjgqK4y=-+wVeH%psTQ#O)Rp(bfB*rY2dTTPL6 z=5Ef`g;i|bF3dh9AsQ{~3Qe6fnV>KQuRg6V65uqgrCN&Q3!3;dc*$8Ozjk zkH#)3+IYbG(neEch>qAEcez^J!{SP48Rp(B$Z?6TWf;u?QbAS7(q_)mi3_$L%xZ^d zqc{Ck6R&p~;>Lw8)1zXDeB170YIHY3F;U?3Hm|L=&5|D7w&?!Yv*9MdgB;y^D&(67 z0v3|x=wHD z@HlHe^jyXsVl@P4Xs!szogWF9q6C;OVjK`5{dmPKYCKmT!5>50IXoXjrC{kMbT^E= zoB20w-R7iO%2&-n7Ld<+jmtgZ)1jeF0BqM+cT5jc=;_F{4`21@IylB4D7cLHVH{Zr zb-HKY=g5P3#g*xaU<%G)_N2e*CU0|0J#ylSIwxs6$t|}rJyDH<`};AK`LSzki^2@VRUYFQ zo6jkh$+(Y?Om8#PsOk~y>p*i)@=VD(?D`M)$%bvBk;WLJ@;3oF`OJ1ukg$o=uj#Se@vM+t+R{@qKifCZniq+-4JJ6=gt+Uck^&T-i^~ zz_!U6`XFNJ-J(rFP%3Eoi~V3xc!B&TOB4!t2Nu{HuV5=fH_^<9W`EO?b}ogu&KJd` z#coN1kfXR9D5z(R{$Y1Y0aWRw7KmD(V%SDB(L@jHEJFD-mHZ`s;8r4|;g-{zSW?<6 z(&Vh*QZ!k0Zm@@&=z!9cH$8+9Rnz7qcu+EagkZ`Wz;o5j9`3~L@*@51!MNESm$o!# zfLI|xo>mN--AKeg7u@5%#<}ui+df$2L`FYsy#dmz0F!w@GiM}~2Z;7u_u7vGBD{u> zw#fat;^y)#GJeDuv5_F(e&Y>$O}Phi6wlH2Ga(D22nhWCkA18(g#c*}M^%}oI(P6G z=OYD_8Q+Z1ky~as>Jog*V>5wzvdeAcz!~4?@o<1~7KZ=*5#2fiVpH!tIP2~Q zJ5ptd=@06vIf-=pHXv5_qG3Qm9sFroWt~Q&H^d+)z0*fS(Nkf%j~#OLZ*IE5A;{-_5Pm7bwvXM4=N=H9y z*rMxNOu`&aFD#qNd01!M8nMNq@zIbW@%Qi4Ua3Y0@4)@v6hK|pZ}#cV@fM|>Gj>o7 zb3?oiqqn=(5T)A7qNVY7;GX7C=?Nah4SV>&qF@ZmT7 zrFE%Gx!p4nf8^=h$PXPXzbld0WYEUNRFRP{fcBl8SA6H7QUVs2!Gd)1U;wV`CxsWh zSI0(M`v!eEi*y-g3QJHxD6WSZQMzn;wDFuw4fa3e|J~ajUue4}=Q4=^x82on-$j%k z4BObaWcknBg|eZEe}|*-r{cnlrGLEM`)?A1VV(G$_`<*h7SU63D#0Y5QXOker_ijL zV22uwbLd&AhBT9tYHymVq-1JZAq!6U1+X?&Jco$1eCSM_iw$UJr<8_Kt2T_^6rqH1 z;tkxGF?YzWKBYvrQ^b6+7aysG|8Q1@?g+uspKy@#xLkR#zy1w;|KD~RE!;q*C$d{`6Nt754=e28Z(f@w-y=lEvTyr;PZ`!B99Qi!u z@NWC3Kl^X#XDE3GQMOzxGL~#+U zg*$UfXW63#vPO%H#Qwy71yi(A3LymMpeAYmM0GSCaw@B2p747zpCWYPekqAvvnKy0 zbmCEJzFQf8*)Sz)XOW?NnZ4#0eYE^^PSAR_Y#?fbc0VSub)%-ni{3p-v7hdJFs;0yv-hs1GRmoAy4`99 zpSLWZ5U>xCtQKdy=A*7P9cC9-D&#))9k!oihXZ8_p5vA#omx;2_*CemK2D zi_0}j#SOh;$#CEqzQDoEAw%5zYSFk_t!k(fP6;{7N{}{rX(w0*o6QZ-B`wn7lxL*~!cpyu z^|@#}>>{Tg?Du<%-A6<{O8xZCvmdNv@BhYQl>8yoj300)Bx`oElJZaPau!s{XYF1n z!v?CDz2uvfi9BccKz0}yi?D1Z_BkBkdbmaJjK}AYKZgrERHDWn^>z)Z~}*mmjj|Pv-T4D zU!#sQ%p z*>qi=!`$&WUmWOW2ltkl2Hy1V&R1#gcG(Ubt{ztSRb1HjY9x-Z)> z{D5q8kY{#*gV6}z8i-nJni1<)3loi4Z8(EQ*dk&s>nLdAY-6jgVH7Xn9rA9cR*b09 z%KjNqs8muCJ@j?7OHZ}4fg_k+s;lK1O?=kz2|#uRp+ruFLM`cqCU3y+ZrV^WU~sv8C4YwkuvKZdi}Uq5wUa{Z0Vq#QL=6P*>+c0|bv# z{geRPugr2wmirw9%e`%|2LkZr2QaY(PMBgt_XRjyQYe z3WI5;xben16d?<@!%ziy?AuzI`%LNmOzn2n_NypQ1c zsZ!z6zko4cG1FMq$SZ-aRmdawp(Kv_W))i>SI>QHraT1NhAZ4)a{bt?MC8%zfrzI zOt`G6s_DyF1m-?HTIy-@3L z1phKe@=@DY*98P4rCu~mR0%1v#16DXE=OrHiA)aKoHOL(*hc7lugfpZI1R(DM{ss& zC{<)fx}FjXM@7S+j1n8T(<+Ou@6aCN{uWGj?ed_W^dXP_dWF~`?#>>9&VaiCG=^t5 zUf%yd9+eB<@q+>#4D5*(42RuLkv|)NxlxnD>anON0me+J^K}$5ll+-)#Q0kY6?La1R(mWcoX z1gmgwBs;i#w4zf1?xFHKfm7PFBaYDHaBHM^WEa@L=B@^*E|MWm zb#j?u7F1z_E=>;p0q6We#f=Xf!1PB!#E_nFij*&WS*BTN==dHgR8O=eY!uq3Sb$qb z4R>#4+&LN~;T-3a4=@Xn=?BNgtP)b@}o6j&E)iCqsS zfNerOKuT<5kMCN+sAYn^Q}mIP$jBJDMe+EZWR3Q^F_kWBMw^2$r|6h4k`_rD>P5SO zUgVgWyTvx1ql zSRsodrxgcHv*uD7_^SCC1<^RG{1|$=)*Gs_b3|(XTUi#owHA$MnR?1v(atBrX4>rB z)PP4db5JQ1ShnkbyWrL4*gSEFFH1UNGHb=p zFY8GeJ8QH^b#%?&>?~;ueN^Xc8XjO*y`XF$SR*>!nd*et~ZU_dls)98IBlJB~@y zl^V|NpR@U9t_&HG3#{%Y%tVq>SavKVOZttoUG|=}{wDjW4LEt?$>?c%Ml8Uy3*hZ< z(&D=#3WlkK;etC<#vujyPJ=WeKp-J+qc)E+0TvHR<18-8DvfEn*^E`2mDbxomV<2k zZ%q!Zq={f*STXZXHNIvAYAKiNzT~TpnVWSJAE*n#eCV5)IPsd?nQ?h)Qlh=}elSmK z6&+S_2O!lY;L6oPNy^9|eXf_CwpT}&O>=GE_SbK9(>iQpv7SxEQ)9WJQsw64C_1H$?0~-at5;+c*%ta zma&!+6Yd9ab&$48kIC?)x$4gtWjqCk<_397mf%2a0AJGlSE1BKPDTrPWJ>P0qCsDe zfv8i^rTmRl;^2?+F2q-!iHh^u5c6dXTJ|&5fjr6NQN;vQGl=PBi(n@SD2VB(5gu*b zN=F=FKiNMD5O!#+v$)5BiA-J3x0v?V!KP{7td+Koa9>bKQ?jFsEJbg5^BPkwBlO7~^#X8> zcsxhuZ4XMIa1$vYrOaJ_LQJD1dgy#69fzdQW(TNXHHLZ9S(>A@&Y(^ZvzoUlGvUkn zOPF<(lkyb`&TXcr{>rG56>v*#Q)gN-X3YOZ1sJI_V9ygqPwO=pe^X07PUCFp%XOz4 zKQ$nk61BlXY}Tcz>Gm2W)y#W0D67CFYmtV@^|=daiq0d|jgD73Z||^(SVAk*@L_8K zAu$`$Mb8Ka4O3@px%pxvyXjErYv#0cH5Xe%EZ!wAX=1hUoNh>TXXR;8E~xwF87iNC z2j2Wr-)w%A<}Wa-`)1{-oZ`Llr`DA*S3JN7R?QRjLd7Um4WvjE+)8vRmTtP&4s>bs zru9|ad2~zdGLMa>YxL$hDZkSDe+M0#(Q`T#qP$m)8|~MNh1w&cPBGfq(yNrv_!p0J z-*Th;T}5v)#S}k*40Y0c#p=~KTz~OQ1+I7a6Lq`ophB-zU$Z|LQ?HMD=(Y!+W!>_} zpQE9WD?Uhiei;~h--fAgaG6O`>9HI5*A*wE8PHhYM!js!7Hq4vFci+D1#0CY$4YPvycdO&lpb;VpW&5sFP9rev879?ALug=z_9Gw_ATegZ!?&}E(v3Fx zZX(v2vm~fLX+sEGEt!1sUN|sQ{Q-VW9JR)(K`{M6^xnc>b{GATB>mXLQ6JNwTsE%u zs#usc~<(<+iW6Wc_MZvh! z{fv5G__hS%na+E~1OHdYK%erfpvWlm>nEvPeC3?_7pDJYAJ)Yr0dN`PK_1m+vr5bB zC;f2~)DEdPOMHqRDy;s>?H@bdcetLY7vDo&@I-i)m>~7oc5PGPyNm*L#1W^{7mEtjyr@H4;wG>y5yAYS$@$FT985Xj5$Wsvs z_c4rq4&}(g5z7-e0-CH!{wiwoUi&f>l+UoW=0T%FudcwatNnR#>FPupkO_%v-*@KL zqT$(BV`6hQ4tt9Wy!AZko831y^v%gm7$Z7`KZS{pFm_$9nwPXrIg2|1r3jxmo8U#1 zNZh2Sj3bVfBxa6vP)@d?+l4X9!O7tX-eUcBDP6d7-TUDk3FIES^A?vlUVKb`$__Vr zJZ2HKpo_W25bJD-(Xy|ioP%715ZrleR(XeF3VR)D`~)6pACly+D3gyYSPGGu@Xcak zy1tK;O81YcsE4hLC9Vm|bj=y9r^$<}fk&+jsfVgmj`27i)03TOo@}Hh{g0iFf?FZRoZ-v2#WGARR_lx`6UO~g+C+g*+ z=q@R)Dq5A5ltkQMioFnLjH5}bVFbM|860J5TvVGajgNezS?IwtvygHx8KauKPSg;Z ziZqtP$|{;5JfA+Esdr~)T?HZ{StE2AC*^_0u?~1X;Jze*_efIa&f;i5vT5|qy>i@r z4K~w!7-f0`ENPLETRmHs?ceSnJnh|spJn36Xw-1ACPb@l0zNbqBr#hagrhL!lE;n= zr=8pPQ8fBV4#EFz8iwY5hpA)|?BFuNMN}aVn?(41FdA#f|Ha*7!b8J4jDP|=4ZsBi$2p1@DoQkMyxJnYL3x7vFH}SoMsK~|Q zFkG6pnnygSXt#=*6bvdlvoV-1gU+~Djg=(zu#dUdbmcEaC9c%%h z&%+|EraLhZ4CY9q?70D~O)HEkkTTBl9sbnWC z8gMv|`+(Sqt1j9S;usVVpsgp_HcvQf5B1QUd^J$h!I9h>*#;H>U;U}=!@acAeReh& zdjfBAuoi3+PTTF7P}Y`)JR4)V&GxS(^1;1FZV=`^t507wFU#Eco@QiDMQJA3rV3$&a*yWN3V=Y8RK zgX2SxpYCUA4P*xQpbMnC-eRYa)Id5St_j<$kSFknO<8nn`hrG8rtXBJSZ#T+-^j>Ig3;Y*U zUk=2Ka#k{BJEmU-ZrSwtB?~x0SzOZhdhoIzGs-sA^+ks)-!>1WSagHbo45t_d3{%& zDgF;v=NOzxv~}TlVw)4&wr$(y#7^D`Cbn(cwr$(CoiF$McW+g9RsZekU472p>sjll z9x7l9bkP85TExe!LRh!~^YH>ye&N7^c5>!h)`P;jZo&5vacZ;%;pYzvUPC>UMWGcO zBYM`koE7^u$W8Zv^fl(*iI!29W9FP;0}69|dovp_|7x@p8pQ5}=xxoNi4X53Ep3b? zEeWj9mn|IdiAjUWOMeDiuG&vbxnwwkW9o^%h741Nj4r+lD@+~?1AIFN1|O?oEuTTknS#3u?RosXz=O`b;W#@ zGw8m+qR!Voajl)C9b(ZE-7wa-A(Bz$95 z;sWFCM4dQe3wgdycvA^*W*fkUzCq6U1SXd#R^a577O(New*SfqXv4ZWeZEbi?lVQL zkbP%_wA$;;h+$)(>A-^j zxv(H_{XBanXk~|2;8Mv1_yo%27r-G=ZZPN_!WNxqE^Q=xAwv6?=s!uf(BGTnyH*G8 zA&T4wKKX7@HttHTcfYv|lMdWIXLGbJ&YaxqAAaMA*oHxr_e>l;a_?|}gkgiBh_vQq z@abh(`(As>^A`(%Fl!{@19wy{G|^1h=Y@EzPTW;3E^{q9z!O5Tg208)zZJdr>7en3 zXk?6N&ja$i3t{IIz6N>Q6*Pl4WC-JwRO8dihM#_(dZ#7WY_|Nb5#k1oXQWI zXUNvyWP+g$f_eEFw-FDodB3M&tZ9G@zY7n4>Q>*iZd4H9&)hnEgVO;Mb*F>fU~-SI zXKz%$^R02gv$D3bhoBd~s3U%k&NuM3pUepw2Tv3b|MaX%jxDwVF}92gWr0UIOV*Z4 zAC_`ElMV1obT;h`Ll%C2F>V`YY_pX^Qo(=R==dURlY(1T>S1G%wByp_v%joyWS z{X#9b-^j3l2PTb9e*;@yEO`$+h4Gd>zl*c0E> z;s=LVPPE1P+(%=v-H3tg|lpO(SlMZ}_r-TZR^V2wZ9ca-f!|zqU=5R_cfco65W2b&Bp1Uv}(TuntvI5 zVK)5(R5GD=hJF!t{@Vac1L?uJJ)D98-T(k$0LaFhj6R}w4$W52XUPpHvkt^vW_9#J z%ko@Ozv-~`6;BsXdEcYHplVJ;d5Xo7oxc5_wsW8K6&5})pxe8JPQIrEKtz~{(1m+H zqe2E1Zi&s4)Auhn`GAT-zE(#ou#m_hPN#DKS79rEIrD0i)S@bBIdl@haQ&HagVMPF zeX&j||p+Q^2 zt>;dE@1PQGm2cW3m^47QX!%Wj&wP8%-V$&~a(#;fq|-fkD~Kh@A(``;p70(D4wC3~ zdtnQIQ?$N&BD%UhKmNwUYps@t?UTDWox=UYmVGtVUbg{NVGsY_5z)KV5N!xMW~fLT z>~wlB2Qeks=LE3ZFD*fS`3C-PwW-j9p-t_FIRuK2m8Jl^|Cv)j9qvvZrmAKfvX@Cg z$%GbF5BkGM7Gwg2L;z(*_FkEgUL|driyHYI2Ka|PI6&|L`jH=T+ysZLQ6H1_lMBF^ zeNP4W`g()gL6nb|*f9f1wx{-6FCkM5pb^mx;TGCRwEry_J7W+MT@2xdwF1?@#@x@e zaSU5~CV2@+(X;jH!K%~k->WX}=X2XC^@*2bYi}w_8!j`3Lcc4Bji4xzA0jTqgT4oK2$jjy zKlJ_a=gnLvAEbGTI@GEvtd{=enNL=SuK>nNN)fyprC)7p4q8VGOg(r(;w!^=ZA(I< zXz*uW(vXd|w>vnmA4)kT-s$`g+UFB$Cg*e&b-w^0mhk|)BxyFvB^2l@p!5YrmhAic zF0lfVO`?ie>W;~1g-i;GbNa3$H0c+Qtb%3ciV&ee7i@~=D>qSg>_?D=PtXce3mBB? zTs7U_dMEoNxh7tFByQjrRiLTYodkra?&NL&O)hm{mmJ|c%*h6BWw3aFfg5L!ipT95h#g=)IEa^iJor zGTw~=0O}Yl=bSL8ghY^+eVD+V_UU*~Bzv)C9f?-SX4pOn%O+CO?5IY5_1!3=mc6+% zW-YluWW!Fq8o;An3k5^~EN8E!@c>A#w_66ky(UGhhCB#V%I=~AJI@|&C1*!%?qhbA z>Y7MsSX8L^;k;x^yP+VE`^1)zM{{S!4)*h`BaqQ;tEZkCt>B#!3j=q4%NACV`WJeb zxRo`I(IWe5uf)E7leT9mx!fOJh4Bn)vtp6!*XP=eo|9xCt^(LvfJAv95e;~IaLqtp zQXXps-Q57u7eiW-B5R@i?Co`lDA20)V|OfU75CY@xBSWi7CiQ-*uny(CNyjCe~=Mg zGmbB~aa+9U#kGkQ9&qFyJOhOu%)p)_&f(E%f>RYI7EBiqXb1&EsZGs{Y0zH-A|sNF ziSz9m@C{9W$n1Mr?`e@^ii|JfXB$QBz8y~k83}i9@|8{~zE;qSolsu&Roa)!flQ1K z3DkpfLg)?X=OKx8?RB~8o~^Ri)Vyu^Th;_g>#6P1~!w(m5ey zuY%W;UTN(2nb#BP&EH?s=ieaq@ZS-;JQ2bPRl^EBVi9qa{cu=W>WT@MvYHxHQjCBB zgAqB+0*t&GQ&%H}-7#0upmZv`L3>$ zeJ3Vwy+Ks@#XaE^ebXwA>p3+Pfu8taF8pUC$tMghzL@HqHD9w=m z%vx0V1Lnnalg$**CTIgLzT~}F+Lds>eQcxd(Q&ng*{Q9aaj1l?)N+2d_P}>}@6w{z z{`tN(&BpYc5@6P_JSjA|BDG>}OH z?{`3Zc$JE()vJ|t_AddyW6oz%$jgV3M=)*;hK*8m^tMvbJP#mPjCi_vMmv0(^iW$lY!!7j= zI+IvGAl+Daer2k+VGX(u(j38Q_eYB!lfO`6jas~iy{uR>&qzhbD!V93%a6-ohy&_Nx6Y z#CvbiQ`KhXS2C+Ls#+~CUhCcRR49*7)JA1(Y8X(8RI6H`x>>{qp2{Oq`nGS};M{&v zPRc~Q)i+$&*ahHNH;nHBriQ}R4ViI9)0@Ho7Vf&p3}L!USpbtSBmcrghD?z`&n>D= zqLcw`MTzW41G8m2%<~Q;gzm}l(f-liBP*V*g~g#x>~pE6iZioA&gwYgy|FQBOkZs` zE(*$11v^W^mH_i~Y&#g?@vlt7=sQLT7=nI{akY-_%I3S8=3Z;BQbqf>?+2`Ia+A(y zn@o)JWzdUFXZZ9!PSX}4YZJi}!rv09XPkqUxNbZ*X}s1R!n!0>3^kgOjY{uQAWui}s0e zOz^S^$0gW5>&!r#+EsxYE6+M3jbfYI2~~6bkf!{LCth?{YPeB=IBtO~!z=@*#e^b&LrP4ZPHV|VI^1L$^NOA0bP(A39Vq~fVc*^-mJ;h4}Y~~A@s&_ z{)px2lfT=>$b!mckO0wh+Tj6zlKarz?r!S_4*FN_BUnWxoK_C;)iS4+b2rgbI zARvbSN4h%I2>{y4+N!8uY?2&=xMQ6x650igf!a#+5~K}Os3d40q>zPrXM`DK!9h-Y zqmsaX9^bU|am~*H{=iRhOf4icj70c6iI4Huf=Gz!*SLz@id`Es0B)-}a22b;M@oQSqvhnczn%bM| zBN}{=IY5teN?-G#O_-Yyp*UIzN`-`Bk<~OCnw2pZl{R(pdxPp{$*=~g>gfsl>ovBP z)95k9@CdMQwbpG;ZhTCj3($0+ZOmH@%D{-UBeygG3p%FZMv_`t6O%%)UDx8OT+dyx z8=7rKw+%-bo?)&}lWd+uT|;S8K1JgopeV4`$i*Tq4E_Z@UtwGzbgz|m-Vrove6p7G zw;L9RabB}{he(E@c8VBaV(3X;RgBlqbn3J@?*_nAk@C7r4oQokAjxH$BdS}?-xdYQ|Mo<;th!W0|p_#r36Bon5-c{TNr6;hgwa&{=s&x z-~nuMKJEma!Rea#lqll9M@dZ_FzPI0Bo4CpY?L#mLc*%!N8hwwf1@`{mEz6~vM4|V zVQr#dq$#(y)O3;V{q=b9m2x$*pS)-d%7BKdJ{LCQ@KW!L((XtvJEhV zY)&N@Q|9+H)uKW%=f5>=%A6?MC3y{6BuZ{9mp?TV0?`QGCz*V1;G)S&@g*rXyS}%skfMrdXc_eypk7eV|N` z*;vpFboan>l{T=UOy;+VdAD zrY9`M>xpj2ifyklZ(X>3QAj|M#YT0!~jX z_Gd!y3K3tc!wvXeKVAyp^Z%QdM|4S;k+Z`Lg`mo)3X3M;EnH=vM0^;vq04%!Y)Qm; z`;VXp!8D^&JEvlOfStLX`9AyeQs@1BdRiao=!PO#I%U*=EgUxj_R4{Oc#?nwn9DX_lC_xj zxsy2nG$?mV<-R9FU&F>V8gownV&4KeI&W)%dA&A(H0TG)o{Z6Oc`nWRvl8{#{qCq->scFIpH!$gJ5N=-PghlXQJxlD_We{QCf*)rF0zVL+HAf{ z?7OWLr9vRbFFtk6E0So@QW7FOf9dJ91e1R4tJ1im`GpOK20N>#Z%0?nde*Kz`F3hQrN7F$|#ys$#Ed_`lov+p;AZCQeo9{X3pzM*JMcvx@ zyaB*cof33XGJj7_+V|Nnkdoa`|1Qzsvd;QM$9pjtn(O#iul>2ah{JhnX`kcN$ zIicNomyMwhQG@plzgvrlkFY<GDL)3emjMeUe+!t>7IL@7`~$Ol$&@w3ZNjpH zg%$2V7w9&o5#|I(@=AxlHQDd=vg769PT;gnF!alvZBRuX%0PI>8&P!^>QM6PM|Dk) zy6_A3v_8Q6iJ7qDw=u#|ijU+F`;9C_C(SAaPQWH@ULn#i@+6>@ykdqO5GL--eRSvGiJ2TU;mxmj8w2*nU`bSYwj%(S}HQW}=& zdUX#v=3|@W_c+u_;t#m1+ZNJiV&wBAKfLo@{98gGtxpi=BByEDYQ~bskP3`*)qM-E z8%%0ksa9KTv9N4>kff)a71nu;-JC^AJ+kXyS?_h=ij&%S!U?7ycv~cmW>bUwv^8j9 zDimN6SB-pSNg_K|(P>yk*?eu)q#LgjRk^MoSZ0w!3!pH4z66TQwybeTP)0jUBH>)I z8V|;6&aBmY%V8jUj(YOc?qq4&y8vBQinkP=Ab~;(fyL+oHEyJB1MV?-hKqG}2t$EQ z%BcrBHWq2}($# zkWsYPi{iX~8@PgbQn9zSP-4Zl!^`K0nHNJ}yc z!+|b2LVaC5$Pb8^v6XG!>T>oOhqH;a)DSRL5b^%FL$wWay9bjg-|hNXGX-!fF} znCj$z={UHTBs4!@O;$2MNe&lTfCG5AB!@)U|IpY)nZXg=2ca!aDV^FBrj;7N$=M4- z?{Y(TgDn3xTr%d7vruY-=1Q(E>Q!dgqmQc1t;Xp(G z{`MV6DfdB3L$ zg8P6m`-sDm<@1U2U$ISa)I_4NlbdDcV`PoQLYl9{E!C&QG}r%0zv%A$gZ2@n-u;Vo zd(KAneWJFv7QBCA!?G|OkKNkf-2j^5v3~Hi~oXO|N*ZT75S?9d$c>F^->8l&DKu^2G zhd*w-c#SU2y2!(rp{nxpAys8X`FFJ-r@FOGFE+G^n%H6e-WB|oO5B`;Mbe>S@ z#sS>8=^@*#jqvj6Ls(yONQxG}Q=HAT_OH4Ew;|t%Mzmc6ocO`0^o=H~+}Ea>VexXT zVYMIz8<8!Z@52a0s+jJWj@~!I%Wn)3&B)AaiYuD|6r=aBP}+OMKUoNyz6cw8srvK} zUK*oSd8FQU&z}(`4giAllXC#$=a{7c41Q05NM~3Hmkse-Ot7A)b8@hruyb>8SMW(c zz9;W2FTN)+(MFCX-Y!0^+1VNkJ>jO$3Hc~+UYnvkb&e_U+zH5;*nX_2DHu8;E^U5S z#7xFtoPt^OjX>1MFdxWjlo>coM_>XWiJQ1vkHGBVGjNs&F~C8Ov<6QbMOGJdVFc;2 zhUSL6I5GMxCV2$d+RW~3qVon2e^e9Q>F-f+3v)U>G2&fX(hgJg8lJ2Ue+eE3a9Jp( zJ&-B#IQ3d0-&77$mTBWc4KcM_mgq&(hKc;eEp6lL#?a2jr!C&6txnXG6P~5j^z+L} zIH8)&t#ylQTL7|GI(?ptfP44g6JPI^TLMzIYv@Jvo+eLl%Dx*$(rim)$EpB7aI-PE ztAevz+=5-(81F!9bd?vqUbIh2uWv>_FxNK_A?^V9Ml{CBnlh0iaGtsm*suu6v38TFhGNybH$E~mh(!u7v0`AZ-hWp6Fbu6VbcxFiNC5g z6txet!bz3XKDMHpPWD;g3?y4n7e{uETZpF01jynj%gS-GxmyFjz#>(@K9?ssu1)@W z)(zz80XY5Il*2Q1CQg&AN%gjLU2IZ<``d#}(H2=iCWK^D)Een+h?3V=(P&pxg%(>> zCG}itt&xIKj#aoEX;_uX|Df>uaed{ek7P+eKX45H-1Cq~07AP#I85tDi& z)XWNRLkm?$uRH@Y8|~$hS5ZLyMM|OC_w?zP)Z?P<0h(D*P*e_@dU7BljY6dz$J0ibRQ zv8~*mK{(8JUgt{{l7O7G5Gq5O8yGglTDCPvh84~S^lj(Sv&;?<>_jtdX~rldH9Qy| zZ*h8Ew>lkdZSn&EUl8^o#Gp_YCo8Ooy-~n;(}~YQ9-Md{c;I-VLGFW(I+~^-M|Z`r zTASDQL5o4>xABJ6lcM8RZcvi!0Ppp)EUiA(cc_xl>8roF3pf9ax%$qWTP@vm>zD8) z3|H%}o;{P66-`{WxsQb$70p&!CLSBVv#(FcL=x4?bx7GZ@rhxFTZd9z>{4~dohF)5 z^EAepSK6PjnoT(gf+WI8ap&rqLj0>F=d9@#d1*!>pJ^F){0*)3dM0|e0lyegJ5Lf4 zu_`G4opbs%QCONV==s!o=(lyT&(sdvm!EQFkp}x$T^Lxh<&FJvCZMX^uL-llY!i|x z`GJ24pwqiuRSD|VA8qv)CHs18gC<;-6GfojB~v&g_VD4c!q`&~gBCi-f-Xr#{*6zF zECw=PILOPT3&G_geH?HJ0)$SVoSD@=xYgO;5YL zd|;lk``_S%@~K!T4mR>*%whhtA^xHl@&oHYp22zn&!DW@5-pKGMkt?!o>#G7)9Ir6<~y@FLgu69`dU{rAIwu@c-Vc@N0AeY zdVraA&=dnws2M_nFsY*AgCmi7dYT^DtYq|w?xI>ln3hq>)$IgTn%4N9!tBg*gX1R2 z$nJ<`4c<%>>O7GS4-zQr^8~}Ql}8+#R@GFT9TBl*?TQR=KOO@1vPr2h^{R~6<`Pw? zOJPz(R$i$iYd}~JlS)DH*=&-*s?1Tx?xRg@Rx>PvB$QPhTH>onV9kf=n`FtVl>2yXn z`&nh(+*a~42B~cwMm$RlgUx<*QR>FJwR+~14XWVkUNDIcjvIaOJ`N)41ibXo_z38L ze53TbFVDZBD?+W6vLk7{fM0Opd8g#L^q2-Qz-={G0nzP4|AzEtX?eG?&fe!9?Fe{H zn#GoUx9rA-`GDW90`&UvH&f4$9pHj3?ZZPX?TduLL%8XA!U^TLg#DG9zh>$?h$T+3 z)UqbBmp6PfCt;UDp%7Y&03aa5?QMM%U4u3p8*fwx5dNBD48mfo!V@-oL9%vk>A3;v z((Wrj#C2e3!-WL*?Q_oOYJu1Vxp_s_DQQ&Ol?z!Zps-5xPJpm}KSfLf9~D}c0a#8wKX_60_U1Cp0Jze#EQ zUOK%iX%W?~{e7(aznkzvjk4*2eoL%H7xP8^jLmBr^00li|APrf5J9=dph=um4*vK%SuVhZFPsRB=FZ zjSd3)C(UUFrkV}onm$Ne1mTQoS$H)5S4_C9W%SsHF!LsGXCRsCK}d8M>E0g@;+4!4 zRf>r-@(;Q`CG_Ep(*S`oIZt|Lh(^-FqO@BFu_4+0y$F^}iZAtb$(}JsGf(od5Dl!b zYeJ=`s`YmLZG5E^I6*PcSm(Fd-ap-Jg)NybO!} z0q9xM3pV7HPP-X-kK+~D(ivHy3fEfe&+!V_x~ejVf>A}+)zOSXl`KwryvQFQ9J;M| zDl(N44!L&anHt|PNLyz;p>(#ZGy=yjr_!qfuK+N8ldgLK=&upf^{XoB0u}}?X|zHp zwCR_t6{fdrxHzme`YIb~Qp0NS_8J4$L%Gt(*6b0Py{W^Edg{ z7MGiW)R-%ZO~%E5d+VeJ*6iOyk^2OxJ?K3EYGZ)*)?IpN9cm+p%2-g0FKX(n)IWxW zu#>nT&z)u{XV_kxVmq=Iv5eGV2^B$FOmfHz*`)m7BuO)AhSgWRv;2rE%I=_u#au&Z zI6;p^wb;lS`Dv*euhb51k>;ofSDQV&pWH)8@zTgtk~#DA7b*T4VhuLyf@3OKI<^ad z7|Vf4ac0az+pSkZB4|@tb4IEuN&Vkwg;n~3RLHqmSDirSiUd1kn}v4bT*VcQ6vqbS zT3vW+UL`s#Z8H1AMoD8sLE1lu%>}Z}5j*5rq|JrQcx0thpa@3s`bBVI9Q>46M65>W zGzu(vIh7=I7B8(1l%C2CtBH~(x~D7vla+^MmJS7MPC+%aYc1* zw6u`Ck9|`Q=nPFXI^IOps@ejf-a^KxDu}HFLLfy$wc;|7jgca4_EQV7Aq5`MRFBq~ zBagFOjJ2%z`@A)iG6+YiBl$f)4#`!~qW5_zPGh6vpn;jT(4T1)=lmQf2$Wq-{Tcx_YhFAjX|Pjz)j33@E89+ECDLoLBRGxp}20m=palF1c?mVWEB zI+Hd(8h?55`S1Q@=>f`eqvOW=wj3|{k+YbavYp>Ha}DUbd0KiVsNmeS(qfu^0iDoK zA>v{dRM1y@SrPBh_y#h)?=J!XAva@r6Yr88V($s~C7tMV(eIu1#^%t#Pb|Tm9a4OYcZ^zxJBsU$U ziHbc}{Rs^&FM`ypcF!nYXnk;Gqc%6o+8LKl1Rb-{nxt&(J1?R3=@KNsyzW_WA#`Z) z^0MMz%tikkV=fzNNIX?((#e8egLz9!UOPo$=5YVsir$pK%hTEKx+qRHRZ=2!Xk0>g zrY}fny_JHj-ia38$gZvij{4QC<$?fO+>w1HT~2J@XQ;U_()CJ#~m=i=#a_9x}cW z&cBv{`_Rw{`ViPE0n7f2e8AUR=+;{7z7v9x_f^3+YTHaW{!v}JMo{P>Pa&gLhe$Xq zZBXpab?PTi>QTD7eacG+v^WD;%bXl8>78)`T9xXg1AnzTWo8Z_*-DM`DsYku__EeI z#wna4;e8`VmHfN|NR^y;p))8xE8bcM9Fj90Ez$kr7|m@FlqbVM_fciiyP|G669PKHT+K2P9({oW4=HSI;%LjJa1&uIZ#Og=+VL=v52g;@U^;%I*~5DAyH?>k%mN z9t|gw4!zL6Uj1Bn{O&&FgAZOzzsljG6@Lm^#e%2XdRw#Q=uE!hWiscK=Yb{(Mc&cUbZ3QZWCp%Y zu~bzeSJlITOivKMSPP;m^}3?8HK>hao;&)s@0~cus^0eXKNfH%ebeZoA3E^l=cb4U zP9llFnf(R$7a<7U$ABpMZPBO&<9gT@Vl6zafC?tsi!$I^LTd>k`Djna^EVh-S*OjO z@1CE)Z0r2f9b^PC7*bdy@WjThX~(uQrDjBST}t=LdXn4{Z{3CXI_`%G6v9$JKkM0C zOdvE1EUZl|sh~j%7h2pq_DDn$2yxS@pGtU(5TYseGL2A^|5*_FJvNg$7pX&HK2q*M8aG)UThLWYXkAXJ zs*G-upM&X8hp*1M*fGqocy9Am{f|_Swl@5y{dSLgE9;x-1HaeCp$@xM(Ho5)$eycr z^p#{&>7s|`-Ovqc8n9MoBvZ{dFl$l^i-DbsD54Z&qQ`)S){NwM!vt`^2k$+YA<#xk z%6gE%%C;pjn)b{5Nh_;@l3;3B>+nINl%GL0PZM#f>bD zGrBqO5jwom5mgQEVawx+uh%S8fQ%3;l?t}9h~R5yZkAysNCm{RzbqH#;d%no zOA`Dw$|WKG!~uv7|2sHqLWI?i);1~j`4_@Hgv>vC=M6;uh5AhA#qaI)EBoeJPy;@; zh;YPpwx#};L+bivZ#yn@yu{hII@={~%ii(d3r?+7n1*^&uodd@%p9LtKW-=;arV~U zR-A!WiW3Tu7O3Xv{0DSh8D)!)0)xQ`c;l+$;8h{07JEG)x}4~pltJ*y5>5=El`*UC ze~f=&M93*XCm;6CNJdrjmU)OgL;98l$c-?80ak;Em%^Z<^QPFl)wHH=>DI-M)e#rpfb@`e*TAS|8B-PKY_rZ2$_cQ7l28~%?n{v^oAT2 zL2(Z4V05#_TdZPr6TKyV(e)+_mC6hKkATh7T%KPS>(40bInDXJvCVnB<+PsN-Sr8i zFcb!XHjiDGa!M|#&R9+u=>bQEu9Bci!*p=wTR+NMzUR)??>>kDS5F9NIb1W_beSO6 zX|S<&**5=2=rn$tvBTDX;9U1ElStTkrVU0f(f{UJZx&wMaTI6TvbWr-eb^W4X(IDW zTP~asaBON2KKut{__?6%$fHTa#iQIb-U>egHyn$MkZkXq^4uuCyLuG|7(;M!d zV{qGX{!p)f12S-*Rm}{*hNYy#V&bS94-49bAzaeZ$FPvJu4u`WgU#j@<24{fmlsa^ zyd8Zv{kqk5{aH%(tPbJkwU~rUk1si>3c*l;x0@0_dRT*&kO}@u#Vmts3Kh__`zO5Z zvnRHaaqWyn=AMf5mC^fT@%%g2aIf;E`qXu>5NCq+{7de)dt&?g z6Cr^gmDI!FgizCW%>`xLTh0g{@i>qrf~2y=#HkHJn4PzvRk8|rzyrQWnA+0J@Yzht zKaf?i^(%@!FhnT;^7sj7Xe?x#NdAM85&?Gw>;dUU7-IswFyVM<3}a9|(mqmU&IyIL z_*-i(KupSP<@pq-1`9;mb{W0~5xCGfisawHeG6vpwHT{)oRznz*mkj%V-S>yCUsKT z5}~!Nqar#V{_Rz4bVT-SByY_oC{|a9Y09ZRQwyqHdIA|g1D2UHrw*qmIrxdQ92G2V z?6M{HbipCQObT6eL?@(_c z0hidcA`u{<2Ps%CfCz&WgB)s`z_!vXt1-W5vb<_j2FT^l{qCD;2uLb@(l&o9{Lbgk zn|E?Eh9s-$btuxm8gHHEJ#L-O@_PDuy#7NERN$aLq&bn9XQn5H>4b}gg^X@Wv!ewh z-7<9>nnL4L0l5Nz!(vk7T!N^Xa!?+E;7eo-k&ViTKy2c;!)?ZTyS#q%bPh74G1p2| z8B}sB*DBLlRMxClQ;yusu(N1ZEw9qV5~4o~vZH6LkoWA@7w0(IWYaOTN*5eUYsc=# zGF-7*kVfjl$jnAthQO&drvVOL~Uv`Sj1Onj&6WH{$f>C zjg%@g&~8&4b=E5CD0O=wgZEu5gFNN>m~Cdp;|V>0-vCcLLXcJNgs|hpw~D9Qj&RUn zYfXU__X7sNB5X@hdDP2spB{NJ5vMg+m|^90UKq!~nDtXBr-k6+UiHYpw4!D7hxi+I zj5`DqdJtg^9<@U^-GUD%Lx-&xhPX?X?BI79r94MCFS}c$L`Tw%x?h!$f@3g{Y50sI z@^kgnsah_u^y~R3Ev03D3C)`pq@s^f?Go8diIM?^&Qa!JdjmculJPA})AzZ-sicUE zQg_RaW7(oPX6&EAcazWh6WPmubs{< z>QQOZCO3NWatf$R;)`c&=GHSEoVfn+u_3DAgCm(cJ60Z*l9Dsx`v9ty1rO-b0wK z>@V+pBD{1D$Yu;s6FuZW&vQqB2jq3kk04~1JUu*nMw_u)UKQ(w4g&tsqsw3Pelt({ zxM#1;8%6306KD(l)gAt2f>XrWXPn^OkEIA`B*p0$Z&`VGV~3a#?`%RS#gwn%eXt$$ z^}EJ;Lz(XNc>x85nG1O!pR0OZkcYe!rW>-v4nflMXp8POI%#;|oOnLsAVNt;bO80*b3(i{xVJ02=5All0mj*Ov?kr zA^>0f4g$mTNjQ)+!d5~g(QNZ*;i;5Za1y6=%~9*N??=yMV8ob%?q`%fV>Wjlot2KD z+c!S;(sA(C84O;ij&U_V<@C;?Q9K(az17hZ$j}?4JVV0$>rQ7*h~E#>BHF_6m@UcY zE&ItH_d)RAL$iZq7?wh=pLK2NXFf0X|LdCPJSm`1S@#dJA|kKbtA++fc!OwVX(bx1 zzN@r;gdbw!fC-9`ti_wJAIz57s;z7CQ1>2SXD~VK#lZVX9P5}4CjxwM0P$)flh?^? z>f+*=K)(ybVrSrQRGeP~my&y4R52I4YD-0aS$$LE+ zIfLyon(y$g;BSgum*xB6rIll0aSc#$t-wrsx0151ec#_7bJ$~K0em5L3qyvjUcJ>V zhxTqZld+SB5`#s!*Gvgn>k6JpK;gsW1vUT*abgIwZ3F&G{Sd(wohTI>ylG6t-Q_ZC zmc^Pzgkji1fFQGpm*d5qec40P*TM`-Le!(RM5Sq@Z61^7)&fBYyHaQ0o>D7uqDfy5 zEsA)J3~s;C$aVo!Z73}s%UJUFI}^Y-8^&lwt1x8O;IjdfZGVyD{-XB$RcO)t=X}jt zGAqhD7?%DoF3+?eIRZ4(S@Wd8q9YOZcsq2>HK2mB>|7rrap9<`252kG4YKVF&>BCG zg{m3I{R5=jcoUe$H&*1!&1i|ssf6qd+7xK$T+^7PItCWdA~UgyMGv!yvJP9+d@1k^ zQu%y*nT>uQlDq1XBRQRWPUHtr$3@LG_JXsAZZXfN|G&bn z=5Ya=8rJTphiJatCh?kNph5qD{M3rmVoaP6fWv1R2=fYp1aM>`8rBTmWFXj?*Hc%) zE(WEvFE{mO*wnmgA=fzO=7W8WJ9+&duzn?tbczoWrvnzSc&0K%-Cu>6a-;dLO z%sd-iPVZA!gWp*IU;*x}#9DB-svJ_nd=!8&vJ4iXMugK}uaLd6eoeDv2E=O`pFK5#YpU#@Mn<(aFzkQ44ElEr2HMeGui zb~yRNl187!W$n-q3-gwF%A4<`t8+V9Iv`k}wOVlJY`o@;M?@DXT^!xuR%M_bGqiBR zfmJ=(KCHzD1p!=G{rgNSFu336V$j9N%R4ljUIGo$lW6A~-74lKT z;cf{{W-c3>yI}oSgD_N|$Xi8966S(Uz%ww@z!tKGTfC)gN|!3 z`(^Axid`E>``LZ}V$FsMlr@$|thWP5`O0(J<#Ml_Y(`sjXh!p(x)7B2+Yt zz|QtJ!l`CP+otqx9I@P+%L0f(gS1GCEJw`|uuath&Q9%)E(|?o zB+-}9)09WIZOs91nwZV$e3%1JK^v%_oF{4Ae)}CnmD0-zRS!L7EZ6JgF4lJst=AI{ z?K|9ivX(B@049-@th!uss9N&aboIS8;M&t=vE#a=*t4;G^`=Q#m z4YpvEShj<_h1Bu{`efx7McNdFTl9Zjodr-FO}B;#?(Qt^?(S{@LU0Z4?gUvJf(2ND zyAzz??(XhRa0>*NyI=0D`~CU5YO1<+pYbz2J>BQLXCB6BeDqixQfbth?4}5h73de( zveV9C#WlBL&0K7UKU*^E$a*P5e5$AIeq)E@_4zwuyCaiOZaU?8NpV@SVe%S5*p#kN zM}S~Om|-Tou*~|G5bk*IuZJ~B$dctUYm6ljy3Z1i^1T=r1!@(h5kU-?rx`e zix$&&FL^lpDx@A2Za;L?y0BgJ6Fd<}W`+oOS2P*;&7ArxC$8QCCN}g^gUN99NPi4) z)>O4;$j+4V!hiEm@R(8AaFOyVAfxM13%x7s?s=NA+xD|Q&w04`9Hdy?TAL7#^YurF zC7!?o)ftHty>UR1)h@O|o(U|laGF0P%<=;kK6?4If4DQr!tG6cr#E2<5t zlW%cWeM0Yagd!QP@OdIZO$r$j6<&at9fTVUCE5{fky{heEpa=~FK{*cg(Q6q;X)A= ztdu9WyJ`eP)L%P91!5w@tF2K-oXpG*y~_z%VzNTHoKUsKa(?v>SeGjUNo9Y1MPPBv z=ug8$j?bwt=4@OVB0*OX$5a9RF0RqZ5Ks2PZQm*P40i9h9YcA%vPO=xhlF!-HQGhj zQQ76OkyrF0)Bu%O`2EnPW&85YRao-G3htc=z5B;uL0{wW{C0Ih#unGb~ zg*jID3Rz=A^p+kp^afpu+ z2*f4ezU3V_bXVv<WrS%^APS!XsI(>$c z+$s=!D+a%A$l1Jmh}x^aTwB)veVnn#aE(1?CrzWwS(Tnh;_iheRxF8rB$6EY4!2jL zfmF-^L|$v5L6}|#uOa5KtSAzk{ zciq!0X;~EpYK>jTM)w)JMIcaCwRS)G8HY7ZUpIdV$#)pJAF~b6&aG6K$4|%=`-ts5MWb-qJN)Ro@b#_qro&W4(BfS>|v& zy6qc2P3&$@j)S((ZLI%fTolLWJ`3Gj$zHOLzj&&|`w>u(@Mo(dhzNlb+ihXg(X!&@ z53jco#@~~1Imd1{#KPX+d9b}FG3E2mHw|-blKjqlX`q&DY*8!B8hu=EQdNYMOujx& zVEq`-Pp)OHtsG1RL(WewKtOROVom;;=GQ4iuJ1N&&r?Cz5WtLtX3`Vi=pwfkjZfY( zzk^>n9$Fbc3WQ?>@ex*lrA9wh222 zS4db)HFol~QRUnDDU!))S@^84Bxy3Fk}*ZV8u@7TW;ImxOM{u5A+N^LTJ@;mZlN0t z$WU5ml2eu&ia^SWkJ<|30gKTX`WK#NkMs=ZjTFP}f9bzBbXB{ifX(|m*u4Mg*fM~I z=Wu})8fVH_;7_MUgO)Ro8k}7JEjSa57HmZBp<+Z#b}E##Ij(MCVXYA_3s27QH0hnKbF0?1_o7RM#TUpXaa7zhB@2yvu3h`cO~=YJXWvu34JBA%F^{d{HD5IJ zVYtG%wI`JNg{)-AgMj0!;bYQ?UE5EJ4~$Q1#cdsDd__xj%?GI=dZ=d{tJz;(ZCi$c zo0>|$LVyv*_^AwMS6XdGuEnZ!PdzS#WzEINsw{-H+gVgGeeNFm2N7i6<;zbmhdqKK z8%3HPdU+NOI={R)gb#H}b0|aWVt;5)u}5$UIGC#U=fzSpm1$4cL~@6Pe%Oz(?4WO8 z+J4yAo++TN)c31R#F4+TT9)pRK;hH$Y6%K%8m>3( z6Qg)A*)0;ldbhuB#jtvXt}BvxJfBRHb)0^Q5@NlKdo3i8hX-fa?F;3C_n^7}Nwh#@cvsHFeJ2$IY~N2rC_X_v-5x_A8h z%Zy68{9`lNfGa>R-$a2Lw&0;`e1GM|X;lII-MlfR2sKTk#CJg-q?}}an7soLV2UP9 zk0n%6+si%^yrOs9!$ebsO}^6Y_$VcfIDnOehXdOys& zjIC>4cio5(!tu!0YBV}5J%w31i(p1}TaH=|of5+(og}XRtl1_AJYlE98Dwv-wZHF> zkJ$fy!d(|;30Nu8Y-En9R_f*7GZw+ma{T;|E<(p!y77~4>ZykrRYFmiBv*5&c(q6o zdG-o+@shg5c6JA-xqKY&Y+}{0%~hwnrK2o~<_?`o??!ir8awtm$5U}GGDHcs?yAO| z{-}+)Cs{h|**Jd2uaCi+59}?P1qo8?VV0 zh5#9yc#IjLieTY<>ApHClagygulwbI+KzFln<%Y4{mcls6pNz6-^1< zix*Ziao?cCA9{|-^{Rd=QJ>s#*L7rbXSSi8W7XP1Z37c^gG*F^LYU{710VPm;HLdM3LCGD+9a@jrJZ}KJqMak@i@g2xwY%im6QKTw-_X8-Vow9F&lqWo@Lal=En@SG0U> zW^R-c2DOC;T`R4shiw6Qr`+U(fr(MptK|n}$7U0C0pz6ZPTxJVOl9)#XeS2QSLjZe z)|iu*88BUnC`W6MX*MF9L!j4?DQv#!9p-a zAe2-w;70>RB5`ctA38rgl$7eyc08l$W#}ESKT%OwiD3sJ#tYr^$cLQ`S!i0&QorSQ z7N~@Z+(SL)M|$$S!$7k3AeqQ)J;)jr_I!PQc|qPn|Kw{{ebn1u6rK=nA9h-*K9j+2 z_T9%Gwh`kFTC#)wTNA;Jpv302vQ%on#oE4VI?&eoVzrZk`*wIpd)+w`ew|}& zs%owNqXaKS7F7`4;J9|Uc!@VFrZ{f`I2ih_`FL3*zhy))UZB^voNn%?4Dxt0s&FE~ z&yAZ>jXN5}PMqJS(z?MNAHuftR($t1z0sXE3%h}am!gRokeR% ztTz}*rb`RbSYbA&-=U2dAjG14hlLFLhH|EzWapW_-hY^X5B>U0ifpN6?fg~sG4DCXARXPAndw6Y9l zoIdqJh4I~`BRAIrg75EMrqkQAQ`s0w zvY9Q~X_a3zdW5_sUGPbG{5oD+-sxc8rAvkPJPQ!t{D2~8PUpMd(o@AR1P^_tYn!!M zmnP!K*YjPlLE~DN`JBfrigyhyE?Yq<>_)kmXtJ~R&K<}u>Xk5jYkRpkyU0bgq zh_FSx8E=v7?`hJfvq4-M*0=q38wJOFk@0}A@&k#LM?*(uDl7fUlG|AaAIwoVrjj5;K)fRA`l+$5)|qjqm)!HzI`J6W${y z#}qLs=Z-%FdX{~tgl^O*)TtWiL{Kug%^`8LvoUyoFTc zFR>~jqYxIHXjum!f58F0@q|s-`3i1xk=fQ5_e);P#dxtjVCQAwR=BWQlZaoye$9rY z6*Q8J9GBffNs#TyP2F!c?L%$ZN()NQq@?AXVj~_G>y1teym814Xo~N#Upq3E*=b3& zwoFkfW1nFkW6Xi30dKNr@QCgimajeq>FkWu^>`#m7odNsh-`@fw-qXk@Gruw7` z*mPelTFj4hbAII;)kkRO77l$BgQq7?prKJBWbckUw5J1cn6)Z;H1+{ulsANyqVVLn z*t1^pqa7P?zU6U~cx-%q*znnG^ZxOv)9)EMC&V2IVN9?3#)QJrq&p~3x?PB;rl3)N zg(gWU5g)c8v8=~FOuM6c(nuZHCOORZQLWN9NOd(v?Uta7TS9)``diPUrkL_IK=E>1Bm9KeR0ypU^ZQI>6gsj_>v{ zyvC(s>b$$AJbd3!CD$gXE8@~`Qj6h)<(hXCxfwjTPIar^i({-v>UW|NqMPe-n*FkJ zEPT#WSbN)+^eK5Vv7^;ZrJ0&IQ3_L1zT9NI4=b$Lk6%-YtjvLIT`cri{T8+RMFif! zndS>Z0l~*bZ%(`CgRy5|Qu!=X*j0K6O-dPmqhhbXlq=k# zD^wXfpDQif^`WfpG`0zhgR|3S%}I3Lm29InljI0I1mUR;1|cON5b;tK`I_DAf$!%% z^e;ACoIQdCnop}(Z%d+{>nii3=)+DFjmSORy)40`{jymwwDn9D>0QQ!??J}uCeZ&C z(g|WvhH#aKV`(DKW|zQ&X^xo-K2-YO|W~4y4*wfh{Q1Zsq&HTQN%}K z-H7ZND_6)~B?D;T_A@kOt>!~++2{C(rPMdF!ZLvlc*$fmy>+pejCf4RO?TLi1Gk_n z?V;StxIK;HnbnXUZQr9-yVAo$wLO@GYQu}HD8=o2iP#Lo0wI1IUS$Lw);2_W1nwCo z%N=;ezzm5W)rxd9k0=|(u90K(txcH(c0c2g_KeA@hB|%y2v3PFvl#qP9PxGyErI+-I zgwM}617aT!&dDg+wEcgDRP>Lp(q?-|4saJQI9R1eM|;3 zp{s9&@H`OTi5SgfE1XX9Q>^UbXA139V|RdWy~Q4kA4*zK{t#Jznx1FK~Y$!fvp=_YqUtEwtHw6 zmi9jJ)8!|9Ih?Nyc>V!zTLX(dF+{vA)osG5h!_&o&f7mX&R6d;I{ePZ_g*d z^62MOW0QX8n2haTd4&gvNvV1s3e*mGTJQC$c4UsXu93;l36Y1tSw6!|>a6j&*k;+T zz!LW(g=>dx=Z}e~)Wl|!#ly_Mx@@}wKzOUU7sYr3P4PS@SDYq`%jhbOQ#75M+ zUH1w>U825D-_t($#79THMy23i1X#qo^x+`bC(o@&1Fc9^U4D`0Q)n-#^etDlBw%W2 z9?P+^!08hekR3&(@FWq7Lag$CoQES7RU0qW!0G)ac7QJfm1!#XBoL)Ba1kBCp3NMk z{HquHiz%*+_Ot244wr`a7a>d&I}2yw^5_3@n+Ywf!?VPMfEWNLvZet)8X)K-G9Zho zow2KHrVf&q`h2}1<24@$1Vt4O5iCT3Hf)*zArWxNA*G%O%?2M9?j@(35IFokB^5%o zByW!4w`y6Qk)Kv+g{HK8glc)kg^7Kc-@1t1#o6x-y;UDapViN{dPkkNsi|Y{QE6^> zcTa_Vs@fM;POdKMJvW~3o3W(0qJSztXHXx356{vfq{9bQgfGm=ugat+d%0=?XGSxn zUvzc>nQqnw0tBZE%e{uF3tYF>8R>%p2S@5%Vag9e61(vNI9DY?69+*;EPI@LoVvRd znW&feq+jQ)g}%-%Rn1b7TzyE#3Wd$6b_492Q z;B-S|i`v7CQFQ-qa_gL0xBm-aC=&5O9Wj_;TTo5phm=aag9k}=uj`&#&Xs;9u510k zwVUq9HMe1obByw+SFUn>n_L90ZJSi_bw8q_d&>t4-0Nn-#bdV`HvIutUwv9leie(P zC#zyP{!b!!hrZ-haU0TvpE7{5CtihLPq-c8&KWS8R|ajRE);#&=sFi|d`p$J;CJ2i z$=ZMKLCA0AoMGzal@Zk%{#V#ud-wQQWloy`O4XP~cBM)nr|CkZ(-`7l z(AAbQV|oPrreS7>gWznFV()5iyn(TgOFco1&h$VNK_eHc_)dRu>WLA96Fw>@&ODqc zdqjh!ZN9w?qED%PjGn3wSCMmwbsZtUD1J;oQEP)l6)NW%Bt=0>$Jxq$G8fx}mIJ3}nufuZQ3-=h zy-sVHX1cQ#j^Pj&g9&46x!|y=I-z^}GD6zgfRWx(w5N1U?|$l!sMBn6&NWSQPVFWR zO#3pMs5EBsvhV!Z;~;j z)v{&vjsR>rG@_N-;CKe^IuXJ?HtlS7B?sJ$Z)%gDf-lfCn;<0 z@wkdSNcDs*0u8#;^%C%^jFjURR+$7SBNu%+f*T|uglZzjjt!PlzVZrHAqo_%qCRoM zv-FB7Kfl=l?D}VbcB+v;#WXReAzPArgl<$jh|Ez>UcKRfK9Zlw1*#PRG_}3kUpNzc z5PaIUNBZ7FgpwD{>MIqu8kY-PS|ZbrI@M z;tQoEKBu+2I-m>K z#9$!k`DHGawDMqBY6e^}>n9l?B-Ceu0N6n zG(>_^58+q=9Kjimf99&!gf-18wQZe8Sz59VYayF64c%3JXkT-P;)wT7BTUkWuWW+O z$qyS6%a9~K<{L6|_IJVOqU1~rbIC$44_ufxKDS6qvlkz{%uK4!osDC5TM3(G-);JQ zJ~as16}W4VL&s<+p|bZgpu(xZZID916U~|I5hOwpws!3$5HSfY< zXVe(-YV@dYh*TLs!K`~f9?_&pPs%ZQ_bx1I#LQGN(~}C+yEa!maj4F4Y~lI?CrT+@ z(3NKPCLvcm2OCCg;ye|K@nCesHSCAWhv@{f8ZqrXYX7m1--u8Vte#Hjo-oX5F1jqEK-S~iZF;&Yfn zYZ}ezZNnn{{6XXn0v(RWE0@4_kj8mO@Q@D4)4FYS%b{Z?C;W)LXeT!|So}18fbf&~ zCCLUOvM6PCS?c$2pKz!P#uQq``&xuA=8^KgQMJ-Ni2{S@L?=8Q+K9?14k>_||E3Y5y!8?06suVMVQIT8I$Y|IHkB za~Ll4kR|xyrzC_uH|s2iVtAjm_m`T;@o(mGqg;GSn(3qY({giLy?HlF-8w|b^>PxN zw?KC^;P95ugGlfNo}^HhC;l^;sM%$&@6ZT}Z1Su|*Y4QJYK0S`YlW3U!PL1tI49*N8PCAHJ~vXP3&<`_8U z1djdS(bJ8jw6;|$N1>HXvrp4riynybgxNjo2hWbE6S^U-f4>@q?d++tc}_Fy`~99M ze7J@W6V)A$SGLEQ6F)33^UMN&^$TozRQl?6jYWty@)dop`VVuNeOlP^GvAm`y!R-o zW)j0ig+4s)h8jeWo9#qu?UBssqY6M2e-K8ST39r-KI#NJG^S9`bAQwReXci!!0#e8&`bBfJ~cx z$mXq0i7R7z{A_1!O?z=cn<5VwrJ%`|v_naCINMO&ZH)9bcD8zzX<;Ujg0IuQI0hJS z6z%hTlA~`MlPvZ#Zeo=X8S+{bU^3<4KD0 zujDBmoA8JfF78r>LeiLNcFLkmS;>p9ocJvsua|;~@@tRWnb-7iOhw8Hx0Y&Bf%_n| zdga3*))w4Ro~dMMcyLfOppn!_#)2Zpe(Jhh+m$QD_v|E*Hu?dThoIJg#RtJWO8v^RV-AaOP2H>PE6-5uN@=j?Qh+Kk!+^|I8ID5( zhn*Q~RU;m=+m?Rz9xp>o`tvw^%8W^WkZKvVI~o@BVNb|Hw37y~@mfXhb9iUululi6 zTEvjzZlRY9tE|IC-;MNqC-Iweyw*$bcjiPN5!F||X2zX6x~ZL&*o)*#TlVJZUMn3@ z@3h#2#^gG~U&0mmYf)9PNmleR1+GUTH^}K3=f!Xl*ky27uz4hMUH;0s-kljo|5qXO4^Zr&7 zS6lwEcbqR#WGIlIEVFp0rmChB;A8z>(wj7QSf-u(=`u^Q)0gaA8^EUiu)bAP+> z=?_PDd#vZVKg!y9%rWRJ;46MEKHu+mNK`O)=OI;>s51*FPPYgne60TM(?;JiEL(cY zh7;HVm?B%X^2W)*DyXO_>GrL zbLt!;Bz=zDUkiI__T8Hsm9?UG8<}2g+0O{-*f5I!~ldxR1gITZEk?sA4 zJm|>zGi1ALoE=AzIA?EGC<{*}thL(V$qm&JKypGf6sLL0rY!BZxPwEUmO|C+Y|d;> zaQi}d=F7W<&zPOI5B#5Dh0(rU3s(2B%l`&=a)5KU_&`5yFLZTEI}pnzDer$$B^8EC zzoTWBQ5DXPui)ux4|qlR%pNG<%1%L0^{tt_$PNPDZmWez0iXcI!VIGim#MRdY~swS zy>`%LLYnYGoetBQ+ZJcJ7NK5zS3-XfTOf(9%ii3RITSlT%mNKrDq2G-bnBRu9tvxD zAn8KyB+(Y{v?taxH-fW~kF+cm5UWhT>lfxDuGN~l*z9&TZFMhqj#sK-_mYWN<^5P8 zA61PjR{Qtr+3+DC%EqL;_Xvr%B*B%^V*}70W0Bz!D(#ovOI=-QTk-^q?*gP|GWR=} zlg~$OVUgG!x6>{udD#=;M=T}IT6DH^5WSeUeDv)h;%z+XNTK71)v9t4(VcX|dnRl; zU&a>fSejqgpp)Ha6vgUT$UdI=2Nd1=DdW$|{xN=bJed6c#Me)#*f^09xDp5Gf!w>{lUI%GSkSdH9^8d4D`MuUfNDvOvb(Yh?RH4l%3WlbcLb}o^okhG3g zp-gu33iIyPHDvk;K6#ToL_}iB_dTsnaU;Z+&28V$=?7Du5BiTJi_>?_F&oZ~hCVccp(u)u&)Ml>aPwbzO+PLL9Gdm<5DTOwRtsz>0wCqoU zA-8vPk@Hi(>3Y@`xy>XK;c8J}{0UV)T00|8Ex|UuiL^)6aBr0iP?dnJ12@nP!{HzF z{Zsv=L}f7VA9NlOxSXc#I^b3|WE@UUVIE5rIOWs$t?Y$?Ni)|65E^<5tZWw7UX=a2 z!UcsatfOBZ50b5N;SA_8C5_9=5CS9AZlfg z8WG~`!_CbWX-7_=Xp@@0uS=hQL7T1=i}&$}ktHvqRgJoTDy=WAW{!u*8rYrNP!w0^ zOW0dYxL*Bn@b#k{)9ke5b}vGj9M8BvFT~S;zwR1Up(U&P0vz^z!R}o_q&h?EH^Xx= z%y%0|$N<)PF33&MX{$$0GT@73nhG$`ULjuzUv{27Js26?YEm3n736_;>RTA(!HXJ#!BDx*hf-Xa^CE?~qC%DPf_ty!LP5J3 z{{G>j??^jTQ-~7RLo`dnQmZ&KisX^hIHLbevC~ugbcAIl6Ys5C73eZ?N%cnhGHuL` zCdqO7yLhgmM0XrLMl{&t<^#6&xrl_rBB#10zM%4zCEZQkcXHUs5SzUrp_|uoD!+(w z+-QKbK~vQi6TH0j7(qSW74SHlqz}%Du+?4ApyiZd#dA~eZL@m|?wLD&c8G`Q%4A+U z$+fhVX!tE`9dC>4Ap^w9Wbi9Z9dED$@zf2md=f-jmBW?I*rCGUy?b07}b8z(5zCfBCC)N&dgzOuf@1De zXkj5tT9O}URA2!Y0*z=A@BP&Tkz?DX7J7#uC!yj(6FVy>Sn}&m@W9uyc}Dd0X;2jE zo^3-V2P+JjGY~x*cTY9RnLe-t*jn9}PtmKK)4yIyQxh^OuistkB}BQaLy)bQgChgE zdZmw<4OSMLq`iXdKp%|a=t$YBVWpmmO*Zr84|KqH_LXk?6uXOa<*#AYMx*S{{iSNV zR@>hI!dG(O#JMeA46P%zFWpP=&{t8YgPFu}_&zq|%wl;#Dc{A+d!fM%m_L+R8g-l? zpk4oaW~)w@Bz}sc3=d_#Qa3FG6!S@T-CUkKGMJ@nDA5`RE-Fcv&N}#-+V9eN{<~Eu zf~&JCH=mYFr_6nxbwuoPxEMog#5# z4b&E!7qAY#o2}?`P12NPv_O$o;B9ZUrs?rVZ9t=^s7r-Jrh3tuw4g z_ufpLE_>0)37W7TeuG%2@jV`~h`GHb3!`%5nyJ>WRCVSP1KymU1HaToLz|l_T1pS> zH0Uti-`aipfSIVMF}pAj(^F82lot#lb>q;yiZj!^syo9UznpUk$>`=b+7kHMz*O>0SNLp*$M$AAeF0{^ta;Xx^n(%YLZdc2|H;eQz^#e>3@@5=g~ih+b8h~u z-aUEgiU8N{%$IRgy2lxV7vH+*rT3*BwoC1RwKfiCY%4Jc=Q&7gl144qQ(7c{EBPKL zT&60B9xRm)0epDj)-->X>852b-fFxjmi1nbHY&!R|2STjul1ArYj|d*O6o?Ig5v<& z*?aNUu0y0J=>e9ZrlKtYO`NvyV(e#i(M8C0DVNzpE~Y-oW}vT?Y?Xb8djp_k;9%ZIOzWQ*ZE<^NR3 zsUAdt(c~k&0bBuWX6J1QtuXYSM7hm+@S9>GCW$7euzT+8$~B=TJFp2awjZe8A1qcY z^=M=i#3fv6*=0A!Xdc(0$ig~Mw1@dnA%4lzl+_$-;(=^A>D&8g1C7; zQJ}ITuaE)oO_1;n(hN}swou;Ex=n<-Mk{uNX8v%+Ag3W_XXtA}e+#IzMU94^5nkB^ z@|~w0->t!aasFw6@U|@o&d{-XNYi+yEIXk88crD`9KI|m@hc_f{4?Aa-^Y&hC3l|e zjkC6vJomE8rIF0e1IL|LTaopr%#lM$06*2CbP`UoWd*U>R4-T(!J(;qAL4h@>4lMhzJN#{`j3X{6p+g+DR%f+6HawnSk-<5)J~be zs7psh2+lL-^k2VyIKSCeE5Cbe!4b^9wSGaSHuNxj#>oo_jkS^#XyuhU&@H|O_D-xG zrYc@U@y)NXBHrSDeoOdNwNi&=9H>;J+95`Z2cYF-G0FsorqVZ*TeYL_U#Hxq5W1y6 z<>krqG)R?JQs>D;d~}gbB3WMEOT-Dn?E3 zj~7WpBl8(Wp+2@a9bPW;NF-j{hcak(a_hbV*kDIVL7lLF9H5vnfQXYc9rrn36yl`( z!10-rr!0lo^1i8={7;z|zMK4CHZTP<>AxB!TwGmYugB7MU*3R1g=C zHNeb7C4bSc=$dVmP{u zXwl-I9Q0$DNwVs5Y<2WLM9%F;)VOZ}m&7y75)F+O?{ofj`(?*&GAMLq>jrD-)EY?nrYJ?xy zQdl#RrAU;~r80yA-O@6I@!Gm{)8cH8V<55df=&=3?2-4boaPXo=JF13L(uIp zZ4p{m=1#nl>Q$q+x_uk)4l_6PNnR`oPo9VkJC9)8fN9q&{uah4TeHFcNcPQ5*4oG* z81ZemqpZikmaEC=N0~&Rhggvpe4$tL&;ohh zCyb}l{HiI2O>#O)8Gg#TSxBKuleq)Z-uk#Q0vBeSPICW%4O6Oy+GFwr(Wp=8f)Z#1 zexzY)wA+UmGZ@c7x1|QoDO^D|rRw?F_k*PpBv{1r$t&}N^dwhhCvFLEI|;uNnYQ7I z1rv6SU8dpy{WR=qIJ#ak7}i=Ks%zQ9m@Rit#-DLC{FEIZNQr(8uX9=W61Z7P4t%6z zB)Zv32!bl9K{IcGlC9?yTX`2`zQGv00M&iFEAD157;(B0zIe9x(?bH!G%1)`}5lvJ_(3^6dm9n z!G>W7juF^L0y{GPwelkV|E^3x28slO(50W(^}&au3O*d>zrZk35XKl0XnK?ia8hmq zDnbMwFFphWcp~~g3Nr;55`sQB)piA`j^RQQF@v{`3j&Uj05Z$)@8`gWz=H+>LGd4} zKtRB-fFUZ-`WQMO6RBB@3LK<+OAl^v{(=a&!4LrSffp9UIF1hZ|JFifPXZRg~0bm z4Se40f31>k4kEWB099ENf>@@|0sk*}3%*Z+e}ObsU{LrE^p9SpEZ9j|5xm8}w>D}A z%JBz)Hm7I-|7bB1fFVmT1imf*QH&k_KqAwmfWIUO{{w-5=yv=A@h<@Yf5~3`1tq(K zAxf|c79QX)L8-r>U=J`v2iBIN2K*(3^A|+$84PiQlEIhY|20OyM*mOj*S`hD@6Ybg zXBh!|$^Rif@Gk+bsRsUmKF@Lk{)!*}3%UsY1D($@0scGG>|YKe`VXWt#|ZcEzzX#pMZm#gSs@J;If!8Cxs{Pg~UCNlnbFL^+BJ_;={wfBjwlg;4837hM#f{Y6~BUpO?_R=%?CJ(BFPTGvdO>2#%w+#G{QtD| b;6@1o1H6Eny#FYBy`Y$723X;~e}4NPrbYNy diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 0b430a8..442d913 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.3-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.0-bin.zip diff --git a/gradlew b/gradlew index cccdd3d..af6708f 100755 --- a/gradlew +++ b/gradlew @@ -28,7 +28,7 @@ APP_NAME="Gradle" APP_BASE_NAME=`basename "$0"` # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" +DEFAULT_JVM_OPTS='"-Xmx64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD="maximum" diff --git a/gradlew.bat b/gradlew.bat index e95643d..0f8d593 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -14,7 +14,7 @@ set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= +set DEFAULT_JVM_OPTS="-Xmx64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome From 0cbfc25f06b74dca75dced3b98842acab5a0ad80 Mon Sep 17 00:00:00 2001 From: rymsha Date: Thu, 4 Mar 2021 10:53:59 +0100 Subject: [PATCH 03/17] Java implementation can be improved #30 Removed dependency on lib-io to untie lib-static from specific XP version Removed mavenLocal repo per Gradle recommendations Replaced MD5 with framhash (it is more performant) Replaced custom caching logic with XP ResourceProcessor API Surrownded etag with double quotes per spec Do not get ResourceService in initialization (OSGi magic) --- build.gradle | 2 - .../java/lib/enonic/libStatic/IoService.java | 59 ++++ .../enonic/libStatic/etag/CachedHasher.java | 40 --- .../enonic/libStatic/etag/EtagService.java | 123 +++---- .../libStatic/etag/ForceRecacheDecider.java | 53 --- .../lib/enonic/libStatic/etag/Hasher.java | 17 +- .../resources/lib/enonic/static/index.es6 | 2 +- src/main/resources/lib/enonic/static/io.es6 | 26 ++ .../resources/lib/enonic/static/options.es6 | 2 +- .../libStatic/etag/CachedHasherTest.java | 261 --------------- .../etag/ForceRecacheDeciderTest.java | 306 ------------------ 11 files changed, 160 insertions(+), 731 deletions(-) create mode 100644 src/main/java/lib/enonic/libStatic/IoService.java delete mode 100644 src/main/java/lib/enonic/libStatic/etag/CachedHasher.java delete mode 100644 src/main/java/lib/enonic/libStatic/etag/ForceRecacheDecider.java create mode 100644 src/main/resources/lib/enonic/static/io.es6 delete mode 100644 src/test/java/lib/enonic/libStatic/etag/CachedHasherTest.java delete mode 100644 src/test/java/lib/enonic/libStatic/etag/ForceRecacheDeciderTest.java diff --git a/build.gradle b/build.gradle index 1c0155e..fa700ee 100644 --- a/build.gradle +++ b/build.gradle @@ -13,14 +13,12 @@ dependencies { compileOnly "com.enonic.xp:core-api:${xpVersion}" compileOnly "com.enonic.xp:script-api:${xpVersion}" - compile "com.enonic.xp:lib-io:${xpVersion}" testCompile "com.enonic.xp:testing:${xpVersion}" testImplementation "org.mockito:mockito-inline:3.7.7" } repositories { - mavenLocal() jcenter() xp.enonicRepo() } diff --git a/src/main/java/lib/enonic/libStatic/IoService.java b/src/main/java/lib/enonic/libStatic/IoService.java new file mode 100644 index 0000000..b5828c6 --- /dev/null +++ b/src/main/java/lib/enonic/libStatic/IoService.java @@ -0,0 +1,59 @@ +package lib.enonic.libStatic; + +import java.util.function.Supplier; + +import com.google.common.net.MediaType; + +import com.enonic.xp.resource.Resource; +import com.enonic.xp.resource.ResourceKey; +import com.enonic.xp.resource.ResourceService; +import com.enonic.xp.script.bean.BeanContext; +import com.enonic.xp.script.bean.ScriptBean; +import com.enonic.xp.util.MediaTypes; + +public class IoService + implements ScriptBean +{ + private Supplier resourceServiceSupplier; + + private ResourceKey parentResourceKey; + + public String getMimeType( final Object key ) + { + if ( key == null ) + { + return MediaType.OCTET_STREAM.toString(); + } + + return MediaTypes.instance().fromFile( key.toString() ).toString(); + } + + public Resource getResource( final Object key ) + { + final ResourceKey resourceKey = toResourceKey( key ); + final ResourceService service = this.resourceServiceSupplier.get(); + return service.getResource( resourceKey ); + } + + private ResourceKey toResourceKey( final Object value ) + { + if ( value == null ) + { + return null; + } + + if ( value instanceof ResourceKey ) + { + return (ResourceKey) value; + } + + return parentResourceKey.resolve( value.toString() ); + } + + @Override + public void initialize( BeanContext context ) + { + this.resourceServiceSupplier = context.getService( ResourceService.class ); + this.parentResourceKey = context.getResourceKey(); + } +} diff --git a/src/main/java/lib/enonic/libStatic/etag/CachedHasher.java b/src/main/java/lib/enonic/libStatic/etag/CachedHasher.java deleted file mode 100644 index 21590fc..0000000 --- a/src/main/java/lib/enonic/libStatic/etag/CachedHasher.java +++ /dev/null @@ -1,40 +0,0 @@ -package lib.enonic.libStatic.etag; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.enonic.xp.resource.Resource; - -import java.util.HashMap; - - -public class CachedHasher { - private final static Logger LOG = LoggerFactory.getLogger( CachedHasher.class ); - - protected HashMap cache = new HashMap<>(); - protected Hasher hasher = new Hasher(); - - - protected String getCachedHash(String path, Resource resource, boolean forceReCache) { - synchronized (cache) { - try { - if (forceReCache || !cache.containsKey(path)) { - // Long t0 = System.nanoTime(); - byte[] contentBytes = resource.getBytes().read(); - // Long t1 = System.nanoTime(); - // LOG.info("Read bytes in: "+ ((t1-t0)/1000000000F)); - - String etag = hasher.getHash(contentBytes); - cache.put(path, etag); - return etag; - } - return cache.get(path); - - } catch (Exception e) { - LOG.error("Couldn't generate ETag from resource '" + path + "'", e); - cache.remove(path); - return null; - } - } - } -} diff --git a/src/main/java/lib/enonic/libStatic/etag/EtagService.java b/src/main/java/lib/enonic/libStatic/etag/EtagService.java index bda9725..2cbfb59 100644 --- a/src/main/java/lib/enonic/libStatic/etag/EtagService.java +++ b/src/main/java/lib/enonic/libStatic/etag/EtagService.java @@ -1,36 +1,42 @@ package lib.enonic.libStatic.etag; +import java.util.Map; +import java.util.function.Supplier; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import com.enonic.xp.resource.Resource; import com.enonic.xp.resource.ResourceKey; +import com.enonic.xp.resource.ResourceProcessor; import com.enonic.xp.resource.ResourceService; import com.enonic.xp.script.bean.BeanContext; import com.enonic.xp.script.bean.ScriptBean; import com.enonic.xp.server.RunMode; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import java.util.Map; +public class EtagService + implements ScriptBean +{ + private static final Logger LOG = LoggerFactory.getLogger( EtagService.class ); -public class EtagService implements ScriptBean { - private final static Logger LOG = LoggerFactory.getLogger( EtagService.class ); + private static final Hasher HASHER = new Hasher(); protected static boolean isDev = RunMode.get() != RunMode.PROD; - protected CachedHasher cachedHasher = new CachedHasher(); - protected ForceRecacheDecider forceRecacheDecider = new ForceRecacheDecider(isDev); - protected ResourceService resourceService; + private Supplier resourceServiceSupplier; public static final String STATUS_KEY = "status"; + public static final String ERROR_KEY = "error"; - public static final String ETAG_KEY = "etag"; + public static final String ETAG_KEY = "etag"; - public Map getEtag(String path) { - return getEtag(path, 0); + public Map getEtag( String path ) + { + return getEtag( path, 0 ); } - - /** Gets a content string and MD5-contenthash etag string. + /** Gets a content string and contenthash etag string. * * * @param path (string) Absolute (i.e. JAR-root-relative) path, name and extension to the file @@ -42,63 +48,64 @@ public Map getEtag(String path) { * * If -1 (actually, < 0) : skips all etag processing and returns no etag string, even in prod. * @return (String array) [statusCode, contentOrErrorMessage, etag] */ - public Map getEtag(String path, Integer etagOverrideMode) { + public Map getEtag( String path, Integer etagOverrideMode ) + { path = path.trim(); - if (path.endsWith(":") || path.endsWith(":/")) { - return Map.of( - STATUS_KEY, "400", - ERROR_KEY, "Empty (root) path not allowed." - ); + if ( path.endsWith( ":" ) || path.endsWith( ":/" ) ) + { + return Map.of( STATUS_KEY, "400", ERROR_KEY, "Empty (root) path not allowed." ); } - if (etagOverrideMode == null) { + if ( etagOverrideMode == null ) + { etagOverrideMode = 0; } - Resource resource; - - try { - synchronized (resourceService) { - resource = resourceService.getResource(ResourceKey.from(path)); - if (!resource.exists()) { - return Map.of( - STATUS_KEY, "404", - ERROR_KEY, "Resource not found: '" + path + "'" - ); - } + final ResourceService resourceService = resourceServiceSupplier.get(); + try + { + Resource resource = resourceService.getResource( ResourceKey.from( path ) ); + if ( !resource.exists() ) + { + return Map.of( STATUS_KEY, "404", ERROR_KEY, "Resource not found: '" + path + "'" ); } - // TODO: Will this prevent simultaneous access to the same resource? Is it necessary? - synchronized (resource) { - boolean forceReCache = forceRecacheDecider.shouldReCache(path, etagOverrideMode, resource); - boolean doProcessEtag = etagOverrideMode > (isDev ? 0 : -1); // 0: true in prod, false in dev. 1 forces true in dev, -1 forces false in prod. - - if (doProcessEtag) { - String etag = cachedHasher.getCachedHash(path, resource, forceReCache); - return Map.of( - STATUS_KEY, "200", - ETAG_KEY, etag - ); - - } else { - return Map.of( - STATUS_KEY, "200" - ); - } + boolean doProcessEtag = + etagOverrideMode > ( isDev ? 0 : -1 ); // 0: true in prod, false in dev. 1 forces true in dev, -1 forces false in prod. + + if ( doProcessEtag ) + { + final String etag = resourceService.processResource( createEtagProcessor( resource.getKey() ) ); + return Map.of( STATUS_KEY, "200", ETAG_KEY, etag ); + + } + else + { + return Map.of( STATUS_KEY, "200" ); } - } catch (Exception e) { - Long errorRnd = (long)(Math.random() * Long.MAX_VALUE); - String errorId = Long.toString(errorRnd, 36); - LOG.error("Couldn't process etag: '" + path + "' (error ID: " + errorId + ")", e); - return Map.of( - STATUS_KEY, "500", - ERROR_KEY, "Couldn't process etag: '" + path + "' (error ID: " + errorId + ")" - ); + } + catch ( Exception e ) + { + long errorRnd = (long) ( Math.random() * Long.MAX_VALUE ); + String errorId = Long.toString( errorRnd, 36 ); + LOG.error( "Couldn't process etag: '" + path + "' (error ID: " + errorId + ")", e ); + return Map.of( STATUS_KEY, "500", ERROR_KEY, "Couldn't process etag: '" + path + "' (error ID: " + errorId + ")" ); } } @Override - public void initialize(BeanContext context) { - this.resourceService = context.getService( ResourceService.class ).get(); + public void initialize( BeanContext context ) + { + this.resourceServiceSupplier = context.getService( ResourceService.class ); + } + + private static ResourceProcessor createEtagProcessor( final ResourceKey key ) + { + return new ResourceProcessor.Builder(). + key( key ). + segment( "lib-static" ). + keyTranslator( k -> k ). + processor( resource -> "\"" + HASHER.getHash( resource.readBytes() ) + "\"" ). + build(); } } diff --git a/src/main/java/lib/enonic/libStatic/etag/ForceRecacheDecider.java b/src/main/java/lib/enonic/libStatic/etag/ForceRecacheDecider.java deleted file mode 100644 index db6ac27..0000000 --- a/src/main/java/lib/enonic/libStatic/etag/ForceRecacheDecider.java +++ /dev/null @@ -1,53 +0,0 @@ -package lib.enonic.libStatic.etag; - -import com.enonic.xp.resource.Resource; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.HashMap; - -public class ForceRecacheDecider { - private final static Logger LOG = LoggerFactory.getLogger( ForceRecacheDecider.class ); - - protected HashMap prevLastmodifiedDates = new HashMap<>(); - - private final boolean isDev; - - public ForceRecacheDecider(boolean isDev) { - this.isDev = isDev; - } - - public boolean shouldReCache(String path, Integer etagOverrideMode, Resource resource) { - // Prod mode: always false -> Always use (and never wipe) etag cache, since static files are immutable in prod - if (!isDev) { - return false; - } - - // Rest: dev mode, where static files may be mutable and we need to decide - synchronized (prevLastmodifiedDates) { - if (etagOverrideMode > 0) { - Long lastModified; - try { - lastModified = resource.getTimestamp(); - - } catch (Exception e) { - LOG.error("Couldn't read resource last-modified timestamp: '" + path + "'"); - if (prevLastmodifiedDates.containsKey(path)) { - prevLastmodifiedDates.remove(path); - } - return true; - } - - if ( - !prevLastmodifiedDates.containsKey(path) || - !lastModified.equals(prevLastmodifiedDates.get(path)) - ) { - prevLastmodifiedDates.put(path, lastModified); - return true; - } - } - } - - return false; - } -} diff --git a/src/main/java/lib/enonic/libStatic/etag/Hasher.java b/src/main/java/lib/enonic/libStatic/etag/Hasher.java index dcbc31c..97f7e1d 100644 --- a/src/main/java/lib/enonic/libStatic/etag/Hasher.java +++ b/src/main/java/lib/enonic/libStatic/etag/Hasher.java @@ -1,15 +1,14 @@ package lib.enonic.libStatic.etag; -import java.math.BigInteger; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; +import com.google.common.hash.Hashing; -public class Hasher { - public String getHash(byte[] contentBytes) throws NoSuchAlgorithmException { - MessageDigest md = MessageDigest.getInstance("MD5"); - md.update(contentBytes); - byte[] digested = md.digest(); - return new BigInteger(1, digested).toString(36).toLowerCase(); +public class Hasher +{ + public String getHash( byte[] contentBytes ) + { + return Hashing.farmHashFingerprint64(). + hashBytes( contentBytes ). + toString(); } } diff --git a/src/main/resources/lib/enonic/static/index.es6 b/src/main/resources/lib/enonic/static/index.es6 index a333989..5eb7017 100644 --- a/src/main/resources/lib/enonic/static/index.es6 +++ b/src/main/resources/lib/enonic/static/index.es6 @@ -1,6 +1,6 @@ const etagReader = require('/lib/enonic/static/etagReader'); const optionsParser = require('/lib/enonic/static/options'); -const ioLib = require('/lib/xp/io'); +const ioLib = require('/lib/enonic/static/io'); const makeResponse200 = (status, path, contentTypeFunc, cacheControlFunc, etagValue) => { diff --git a/src/main/resources/lib/enonic/static/io.es6 b/src/main/resources/lib/enonic/static/io.es6 new file mode 100644 index 0000000..6e297cc --- /dev/null +++ b/src/main/resources/lib/enonic/static/io.es6 @@ -0,0 +1,26 @@ +const ioService = __.newBean('lib.enonic.libStatic.IoService'); + +exports.getMimeType = (name) => { + return ioService.getMimeType(name); +}; + +exports.getResource = (key) => { + const res = ioService.getResource(key); + return new Resource(res); +}; + +function Resource(native) { + this.res = native; +} + +Resource.prototype.getStream = function () { + return this.res.getBytes(); +}; + +Resource.prototype.getSize = function () { + return this.res.getSize(); +}; + +Resource.prototype.exists = function () { + return this.res.exists(); +}; diff --git a/src/main/resources/lib/enonic/static/options.es6 b/src/main/resources/lib/enonic/static/options.es6 index 3e76f76..4ac1a0c 100644 --- a/src/main/resources/lib/enonic/static/options.es6 +++ b/src/main/resources/lib/enonic/static/options.es6 @@ -1,4 +1,4 @@ -const ioLib = require('/lib/xp/io'); +const ioLib = require('/lib/enonic/static/io'); const DEFAULT_MAX_AGE = 31536000; diff --git a/src/test/java/lib/enonic/libStatic/etag/CachedHasherTest.java b/src/test/java/lib/enonic/libStatic/etag/CachedHasherTest.java deleted file mode 100644 index ddbf16b..0000000 --- a/src/test/java/lib/enonic/libStatic/etag/CachedHasherTest.java +++ /dev/null @@ -1,261 +0,0 @@ -package lib.enonic.libStatic.etag; - -import com.enonic.xp.resource.Resource; -import com.enonic.xp.resource.ResourceKey; -import com.enonic.xp.resource.ResourceService; -import com.enonic.xp.script.bean.BeanContext; -import com.enonic.xp.testing.ScriptTestSupport; -import org.junit.Before; -import org.junit.Test; -import org.mockito.Mockito; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.security.NoSuchAlgorithmException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -/* Tests: getCachedEtag - - If not forceReCache: cache (use more time) the first call for a specific path, and use cache (save a lot of time for a big asset) for subsequent calls on the same path - - If not forceReCache: subsequent cached calls return same etag as the first one - - If not forceReCache: only call etagger.getEtag on first call, not subsequent ones - - If forceReCache: cache on the first call AND subsequent calls (use same time, order of magnitude) on the same path - - If forceReCache: subsequent calls return same etag as the first one - - If forceReCache: call etagger.getEtag on every call - - If something fails, should catch it and return null - - If something fails, should wipe path from cache: on first call after failure, should again use time, and save time (and not call cacheAndGetEtag) on later calls after that. - */ -public class CachedHasherTest extends ScriptTestSupport { - private final static Logger LOG = LoggerFactory.getLogger( CachedHasherTest.class ); - - private CachedHasher cachedHasher; - private Hasher hasherMock; - - - private final String HUGE_PATH = "myapplication:/static/hugh.jazzit.blob"; - private final String TEXT_PATH = "myapplication:/static/static-test-text.txt"; - private final String GIF_PATH = "myapplication:/static/w3c_home.gif"; - - private Resource hugeAsset1, - hugeAsset2, - hugeAsset3, - hugeAsset4, - hugeAsset5, - hugeAsset6, - textAsset1, - textAsset2, - textAsset3, - gifAsset1; - - - @Override - protected void initialize() throws Exception { - super.initialize(); - - BeanContext context = newBeanContext(ResourceKey.from("myapplication:/test")); - ResourceService resourceService = context.getService( ResourceService.class ).get(); - - hugeAsset1 = resourceService.getResource(ResourceKey.from(HUGE_PATH)); - hugeAsset2 = resourceService.getResource(ResourceKey.from(HUGE_PATH)); - hugeAsset3 = resourceService.getResource(ResourceKey.from(HUGE_PATH)); - hugeAsset4 = resourceService.getResource(ResourceKey.from(HUGE_PATH)); - hugeAsset5 = resourceService.getResource(ResourceKey.from(HUGE_PATH)); - hugeAsset6 = resourceService.getResource(ResourceKey.from(HUGE_PATH)); - - textAsset1 = resourceService.getResource(ResourceKey.from(TEXT_PATH)); - textAsset2 = resourceService.getResource(ResourceKey.from(TEXT_PATH)); - textAsset3 = resourceService.getResource(ResourceKey.from(TEXT_PATH)); - - - gifAsset1 = resourceService.getResource(ResourceKey.from(GIF_PATH)); - } - - @Before - public void setUp() { - cachedHasher = new CachedHasher(); - hasherMock = mock(Hasher.class); - } - - - - - - @Test - public void testGetCachedHash_noForceRecache_shouldConsistentEtagAndSubsequentCallsMuchFasterThanFirstCall() { - - Long time0 = System.nanoTime(); - String etag1 = cachedHasher.getCachedHash(HUGE_PATH, hugeAsset1, false); - Long time1 = System.nanoTime(); - - String etag2 = cachedHasher.getCachedHash(HUGE_PATH, hugeAsset2, false); - String etag3 = cachedHasher.getCachedHash(HUGE_PATH, hugeAsset3, false); - String etag4 = cachedHasher.getCachedHash(HUGE_PATH, hugeAsset4, false); - String etag5 = cachedHasher.getCachedHash(HUGE_PATH, hugeAsset5, false); - String etag6 = cachedHasher.getCachedHash(HUGE_PATH, hugeAsset6, false); - Long time6 = System.nanoTime(); - - // Nanoseconds - Long delta1 = (time1 - time0); - Long avgDelta6 = (long)((time6 - time1) / 5f); - - LOG.info("delta1: " + delta1); - LOG.info("avgDelta6: " + avgDelta6); - - // Expected: the first call of getCachedEtag took much, much longer than the average of the subsequent five calls, since the huge asset is 23 MB and the subsequent calls are cached - assertTrue(delta1 / avgDelta6 > 100); - - assertEquals(etag1, etag2); - assertEquals(etag2, etag3); - assertEquals(etag3, etag4); - assertEquals(etag4, etag5); - assertEquals(etag5, etag6); - } - - - @Test - public void testGetCachedHash_noForceRecache_shouldOnlyCallGetEtagOnFirstCallByPath() throws NoSuchAlgorithmException { - - // Mixing up order of paths. Shouldn't matter: subsequent calls not call etagger.getEtag - when(hasherMock.getHash(any(byte[].class))).thenReturn("Im a mock etag"); - cachedHasher.hasher = hasherMock; - - cachedHasher.getCachedHash(HUGE_PATH, hugeAsset1, false); - cachedHasher.getCachedHash(TEXT_PATH, textAsset1, false); - cachedHasher.getCachedHash(TEXT_PATH, textAsset2, false); - cachedHasher.getCachedHash(HUGE_PATH, hugeAsset2, false); - cachedHasher.getCachedHash(HUGE_PATH, hugeAsset3, false); - cachedHasher.getCachedHash(TEXT_PATH, textAsset3, false); - - // etaggerMock.getEtag called exactly twice, because two different paths are used, cached once each. - verify(hasherMock, times(2)).getHash(any(byte[].class)); - } - - - - @Test - public void testGetCachedHash_forceRecache_shouldConsistentEtagAndCallTimes() { - ArrayList deltas = new ArrayList<>(); - Long old, neww; - old = System.nanoTime(); - - String etag1 = cachedHasher.getCachedHash(HUGE_PATH, hugeAsset1, true); - neww = System.nanoTime(); - deltas.add(neww-old); - old = neww; - - String etag2 = cachedHasher.getCachedHash(HUGE_PATH, hugeAsset2, true); - neww = System.nanoTime(); - deltas.add(neww-old); - old = neww; - - String etag3 = cachedHasher.getCachedHash(HUGE_PATH, hugeAsset3, true); - neww = System.nanoTime(); - deltas.add(neww-old); - old = neww; - - String etag4 = cachedHasher.getCachedHash(HUGE_PATH, hugeAsset4, true); - neww = System.nanoTime(); - deltas.add(neww-old); - old = neww; - - String etag5 = cachedHasher.getCachedHash(HUGE_PATH, hugeAsset5, true); - neww = System.nanoTime(); - deltas.add(neww-old); - old = neww; - - String etag6 = cachedHasher.getCachedHash(HUGE_PATH, hugeAsset6, true); - neww = System.nanoTime(); - deltas.add(neww-old); - - Collections.sort(deltas); - - - // Delta times for the six calls now sorted ASC. - Long shortest = deltas.get(0); - Long longest = deltas.get(5); - LOG.info("Shortest delta: " + shortest); - LOG.info("Longest delta: " + longest); - - // Testing for performance consistency: - // allowing some slack for fastest and slowest outliers, they still shouldn't be too far apart, say, below a difference factor of 3. - assertTrue(longest / shortest < 3); - - // They should still generate the same tag every time - assertEquals(etag1, etag2); - assertEquals(etag2, etag3); - assertEquals(etag3, etag4); - assertEquals(etag4, etag5); - assertEquals(etag5, etag6); - } - - @Test - public void testGetCachedHash_forceRecache_shouldCallGetEtagOnEveryCall() throws NoSuchAlgorithmException { - - // Mixing up order of paths. Shouldn't matter: subsequent calls not call etagger.getEtag - when(hasherMock.getHash(Mockito.any(byte[].class))).thenReturn("Im a mock etag"); - cachedHasher.hasher = hasherMock; - - cachedHasher.getCachedHash(HUGE_PATH, hugeAsset1, true); - cachedHasher.getCachedHash(TEXT_PATH, textAsset1, true); - cachedHasher.getCachedHash(TEXT_PATH, textAsset2, true); - cachedHasher.getCachedHash(HUGE_PATH, hugeAsset2, true); - cachedHasher.getCachedHash(HUGE_PATH, hugeAsset3, true); - cachedHasher.getCachedHash(TEXT_PATH, textAsset3, true); - - // etaggerMock.getEtag called every time twice, because forceRecache forces a new etag. - Mockito.verify(hasherMock, Mockito.times(6)).getHash(Mockito.any(byte[].class)); - } - - - @Test - public void testGetCachedHash_handleFailure_shouldReturnNullAndRemoveCachedPath() throws NoSuchAlgorithmException, IOException { - - byte[] hugeAssetBytes1 = hugeAsset1.getBytes().read(); - byte[] hugeAssetBytes2 = hugeAsset2.getBytes().read(); - byte[] textAssetBytes1 = textAsset1.getBytes().read(); - byte[] gifAssetBytes1 = gifAsset1.getBytes().read(); - - cachedHasher.hasher = hasherMock; - when(hasherMock.getHash(hugeAssetBytes1)).thenReturn("I am huge etag"); - when(hasherMock.getHash(hugeAssetBytes2)).thenReturn("I am huge etag"); - when(hasherMock.getHash(textAssetBytes1)).thenReturn("I am text etag"); - when(hasherMock.getHash(gifAssetBytes1)).thenThrow(new RuntimeException("Oh no I can't remember how GIF is pronounced")); - - String etag1 = cachedHasher.getCachedHash(HUGE_PATH, hugeAsset1, false); - // Should have been called once - verify(hasherMock, times(1)).getHash(any(byte[].class)); - - String etag2 = cachedHasher.getCachedHash(HUGE_PATH, hugeAsset2, false); - // Should still have been called only once - the first time, since the second time was cached - verify(hasherMock, times(1)).getHash(any(byte[].class)); - - String etag3 = cachedHasher.getCachedHash(TEXT_PATH, textAsset1, false); - // Should have been called one more time now - new path - verify(hasherMock, times(2)).getHash(any(byte[].class)); - - HashMap etagCacheMock = mock(HashMap.class); - when(etagCacheMock.containsKey(any(String.class))).thenReturn(false); - cachedHasher.cache = etagCacheMock; - - String etag4 = cachedHasher.getCachedHash(GIF_PATH, gifAsset1, true); - - verify(etagCacheMock, never()).put(any(String.class), any(String.class)); - verify(etagCacheMock, times(1)).remove(GIF_PATH); - - assertEquals("I am huge etag", etag1); - assertEquals("I am huge etag", etag2); - assertEquals("I am text etag", etag3); - assertEquals(null, etag4); - } -} diff --git a/src/test/java/lib/enonic/libStatic/etag/ForceRecacheDeciderTest.java b/src/test/java/lib/enonic/libStatic/etag/ForceRecacheDeciderTest.java deleted file mode 100644 index 0c75364..0000000 --- a/src/test/java/lib/enonic/libStatic/etag/ForceRecacheDeciderTest.java +++ /dev/null @@ -1,306 +0,0 @@ -package lib.enonic.libStatic.etag; - -import com.enonic.xp.resource.Resource; -import com.enonic.xp.testing.ScriptTestSupport; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; - -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - - -/* Tests: shouldReCache - - Prod: always return false - - Dev, and etagOverride is default (0) or -1: always return false - - Dev, and resource NOT read before (not in prevLastmodifiedDates): return true - - Dev, and resource IS read before and DOES match date: return false - - Dev, and resource IS read before and does NOT match date: return true - - Dev, and resource.getTimestamp fails: return true, then returns true again on first call on that path afterwards - */ - - -public class ForceRecacheDeciderTest extends ScriptTestSupport { - private ForceRecacheDecider decider; - private Resource resourceMock; - //private HashMap prevLastmodifiedDatesMock; - - private final String APATH = "any/path"; - private final String BPATH = "different/path/b"; - - - @Override - protected void initialize() throws Exception { - super.initialize(); - } - - @Before - public void setUp() { - decider = new ForceRecacheDecider(true); - resourceMock = mock(Resource.class); - //prevLastmodifiedDatesMock = mock(HashMap.class); - } - - /////////////////////////////////////////////////// Prod - - @Test - public void testShouldReCache_prod_noOverride_shouldAlwaysFalse() { - decider = new ForceRecacheDecider(false); - boolean forceRecacheA = decider.shouldReCache(APATH, 0, resourceMock); - boolean secondA = decider.shouldReCache(APATH, 0, resourceMock); - boolean thirdA = decider.shouldReCache(APATH, 0, resourceMock); - boolean forceRecacheB = decider.shouldReCache(BPATH, 0, resourceMock); - boolean secondB = decider.shouldReCache(BPATH, 0, resourceMock); - boolean thirdB = decider.shouldReCache(BPATH, 0, resourceMock); - Assert.assertFalse(forceRecacheA); - Assert.assertFalse(secondA); - Assert.assertFalse(thirdA); - Assert.assertFalse(forceRecacheB); - Assert.assertFalse(secondB); - Assert.assertFalse(thirdB); - } - @Test - public void testShouldReCache_prod_override1_shouldAlwaysFalse() { - decider = new ForceRecacheDecider(false); - boolean forceRecacheA = decider.shouldReCache(APATH, 1, resourceMock); - boolean secondA = decider.shouldReCache(APATH, 1, resourceMock); - boolean thirdA = decider.shouldReCache(APATH, 1, resourceMock); - boolean forceRecacheB = decider.shouldReCache(BPATH, 1, resourceMock); - boolean secondB = decider.shouldReCache(BPATH, 1, resourceMock); - boolean thirdB = decider.shouldReCache(BPATH, 1, resourceMock); - Assert.assertFalse(forceRecacheA); - Assert.assertFalse(secondA); - Assert.assertFalse(thirdA); - Assert.assertFalse(forceRecacheB); - Assert.assertFalse(secondB); - Assert.assertFalse(thirdB); - } - @Test - public void testShouldReCache_prod_overrideMinus1_shouldAlwaysFalse() { - decider = new ForceRecacheDecider(false); - boolean forceRecacheA = decider.shouldReCache(APATH, -1, resourceMock); - boolean secondA = decider.shouldReCache(APATH, -1, resourceMock); - boolean thirdA = decider.shouldReCache(APATH, -1, resourceMock); - boolean forceRecacheB = decider.shouldReCache(BPATH, -1, resourceMock); - boolean secondB = decider.shouldReCache(BPATH, -1, resourceMock); - boolean thirdB = decider.shouldReCache(BPATH, -1, resourceMock); - Assert.assertFalse(forceRecacheA); - Assert.assertFalse(secondA); - Assert.assertFalse(thirdA); - Assert.assertFalse(forceRecacheB); - Assert.assertFalse(secondB); - Assert.assertFalse(thirdB); - } - - - /////////////////////////////////////////////////// Dev - - @Test - public void testShouldReCache_dev_noOverride_shouldAlwaysFalse() { - //decider.prevLastmodifiedDates = prevLastmodifiedDatesMock; - when(resourceMock.getTimestamp()).thenReturn(12345678L); - - boolean firstA = decider.shouldReCache(APATH, 0, resourceMock); - boolean secondA = decider.shouldReCache(APATH, 0, resourceMock); - boolean thirdA = decider.shouldReCache(APATH, 0, resourceMock); - // Other path, independent results - boolean firstB = decider.shouldReCache(BPATH, 0, resourceMock); - boolean secondB = decider.shouldReCache(BPATH, 0, resourceMock); - boolean thirdB = decider.shouldReCache(BPATH, 0, resourceMock); - - // Simulate updated file timestamp (doesn't matter, since should not be called) - when(resourceMock.getTimestamp()).thenReturn(87654321L); - - boolean fourthA = decider.shouldReCache(APATH, 0, resourceMock); - - // Simulate timestamp failure (doesn't matter, since should not be called) - when(resourceMock.getTimestamp()).thenThrow(new RuntimeException("Oh no")); - boolean fourthB = decider.shouldReCache(BPATH, 0, resourceMock); - - Assert.assertFalse(firstA); - Assert.assertFalse(secondA); - Assert.assertFalse(thirdA); - Assert.assertFalse(fourthA); - Assert.assertFalse(firstB); - Assert.assertFalse(secondB); - Assert.assertFalse(thirdB); - Assert.assertFalse(fourthB); - } - @Test - public void testShouldReCache_dev_overrideMinus1_shouldAlwaysFalse() { - //decider.prevLastmodifiedDates = prevLastmodifiedDatesMock; - when(resourceMock.getTimestamp()).thenReturn(12345678L); - - boolean firstA = decider.shouldReCache(APATH, -1, resourceMock); - boolean secondA = decider.shouldReCache(APATH, -1, resourceMock); - boolean thirdA = decider.shouldReCache(APATH, -1, resourceMock); - // Other path, independent results - boolean firstB = decider.shouldReCache(BPATH, -1, resourceMock); - boolean secondB = decider.shouldReCache(BPATH, -1, resourceMock); - boolean thirdB = decider.shouldReCache(BPATH, -1, resourceMock); - - // Simulate updated file timestamp (doesn't matter, since should not be called) - when(resourceMock.getTimestamp()).thenReturn(87654321L); - - boolean fourthA = decider.shouldReCache(APATH, -1, resourceMock); - - // Simulate timestamp failure (doesn't matter, since should not be called) - when(resourceMock.getTimestamp()).thenThrow(new RuntimeException("Oh no")); - boolean fourthB = decider.shouldReCache(BPATH, -1, resourceMock); - - Assert.assertFalse(firstA); - Assert.assertFalse(secondA); - Assert.assertFalse(thirdA); - Assert.assertFalse(fourthA); - Assert.assertFalse(firstB); - Assert.assertFalse(secondB); - Assert.assertFalse(thirdB); - Assert.assertFalse(fourthB); - } - - @Test - public void testShouldReCache_dev_override1Unchanged_shouldTrueWhenNewPath() { - //decider.prevLastmodifiedDates = prevLastmodifiedDatesMock; - when(resourceMock.getTimestamp()).thenReturn(12345678L); - - boolean firstA = decider.shouldReCache(APATH, 1, resourceMock); - boolean secondA = decider.shouldReCache(APATH, 1, resourceMock); - boolean thirdA = decider.shouldReCache(APATH, 1, resourceMock); - boolean fourthA = decider.shouldReCache(APATH, 1, resourceMock); - - // Different path, should yield independent results from APATH: - boolean firstB = decider.shouldReCache(BPATH, 1, resourceMock); - boolean secondB = decider.shouldReCache(BPATH, 1, resourceMock); - boolean thirdB = decider.shouldReCache(BPATH, 1, resourceMock); - boolean fourthB = decider.shouldReCache(BPATH, 1, resourceMock); - - Assert.assertTrue(firstA); // true - Assert.assertFalse(secondA); - Assert.assertFalse(thirdA); - Assert.assertFalse(fourthA); - - Assert.assertTrue(firstB); // true - Assert.assertFalse(secondB); - Assert.assertFalse(thirdB); - Assert.assertFalse(fourthB); - } - - @Test - public void testShouldReCache_dev_override1Updated_shouldTrueWhenNewPathAndAfterUpdates() { - //decider.prevLastmodifiedDates = prevLastmodifiedDatesMock; - when(resourceMock.getTimestamp()).thenReturn(12345678L); - boolean firstA = decider.shouldReCache(APATH, 1, resourceMock); - boolean secondA = decider.shouldReCache(APATH, 1, resourceMock); - - // Timestamp changes: "file was updated", so the next call on that path should return true - specifically for each path: - when(resourceMock.getTimestamp()).thenReturn(87654321L); - boolean thirdA = decider.shouldReCache(APATH, 1, resourceMock); - boolean fourthA = decider.shouldReCache(APATH, 1, resourceMock); - - // Different path, should yield independent results from APATH: - when(resourceMock.getTimestamp()).thenReturn(12345678L); - boolean firstB = decider.shouldReCache(BPATH, 1, resourceMock); - boolean secondB = decider.shouldReCache(BPATH, 1, resourceMock); - boolean thirdB = decider.shouldReCache(BPATH, 1, resourceMock); - when(resourceMock.getTimestamp()).thenReturn(87654321L); - boolean fourthB = decider.shouldReCache(BPATH, 1, resourceMock); - boolean fifthB = decider.shouldReCache(BPATH, 1, resourceMock); - - Assert.assertTrue(firstA); // true, since first check on that path - Assert.assertFalse(secondA); - Assert.assertTrue(thirdA); // true, since timestamp has changed - Assert.assertFalse(fourthA); - - Assert.assertTrue(firstB); // true, since first check on that path - Assert.assertFalse(secondB); - Assert.assertFalse(thirdB); - Assert.assertTrue(fourthB); // true, since timestamp has changed - Assert.assertFalse(fifthB); - } - - - - @Test - public void testShouldReCache_dev_override1TimestampFails_shouldTrueWhenFailsAndFirsttimeAfter() { - //decider.prevLastmodifiedDates = prevLastmodifiedDatesMock; - when(resourceMock.getTimestamp()).thenReturn(12345678L); - boolean firstA = decider.shouldReCache(APATH, 1, resourceMock); - boolean secondA = decider.shouldReCache(APATH, 1, resourceMock); - - // Timestamp changes: "file was updated", so the next call on that path should return true - specifically for each path: - when(resourceMock.getTimestamp()).thenThrow(new RuntimeException("Oh no")); - boolean thirdA = decider.shouldReCache(APATH, 1, resourceMock); - - Resource resourceMock2 = mock(Resource.class); - when(resourceMock2.getTimestamp()).thenReturn(12345678L); - boolean fourthA = decider.shouldReCache(APATH, 1, resourceMock2); - boolean fifthA = decider.shouldReCache(APATH, 1, resourceMock2); - boolean sixthA = decider.shouldReCache(APATH, 1, resourceMock2); - - // Different path, should yield independent results from APATH: - boolean firstB = decider.shouldReCache(BPATH, 1, resourceMock2); - boolean secondB = decider.shouldReCache(BPATH, 1, resourceMock2); - boolean thirdB = decider.shouldReCache(BPATH, 1, resourceMock2); - when(resourceMock2.getTimestamp()).thenThrow(new RuntimeException("Oh no")); - boolean fourthB = decider.shouldReCache(BPATH, 1, resourceMock2); - Resource resourceMock3 = mock(Resource.class); - when(resourceMock3.getTimestamp()).thenReturn(12345678L); - boolean fifthB = decider.shouldReCache(BPATH, 1, resourceMock3); - boolean sixthB = decider.shouldReCache(BPATH, 1, resourceMock3); - - Assert.assertTrue(firstA); // true, since first check on that path - Assert.assertFalse(secondA); - Assert.assertTrue(thirdA); // true, since getTimestamp failed - Assert.assertTrue(fourthA); // true, since first call after the failure removed the path from prevLastmodifiedDates - Assert.assertFalse(fifthA); - Assert.assertFalse(sixthA); - - Assert.assertTrue(firstB); // true, since first check on that path - Assert.assertFalse(secondB); - Assert.assertFalse(thirdB); - Assert.assertTrue(fourthB); // true, since getTimestamp failed - Assert.assertTrue(fifthB); // true, since first call after the failure removed the path from prevLastmodifiedDates - Assert.assertFalse(sixthB); - } - - - - @Test - public void testShouldReCache_dev_override1TimestampFails_shouldNotBeEffectedByCallOrder() { - // Same as above, but mixing up the call order between path A and B. Only the point in their individual sequence where each of them get a failure, should matter - - when(resourceMock.getTimestamp()).thenReturn(12345678L); - boolean firstA = decider.shouldReCache(APATH, 1, resourceMock); - boolean firstB = decider.shouldReCache(BPATH, 1, resourceMock); - boolean secondB = decider.shouldReCache(BPATH, 1, resourceMock); - boolean secondA = decider.shouldReCache(APATH, 1, resourceMock); - boolean thirdB = decider.shouldReCache(BPATH, 1, resourceMock); - - // Timestamp changes: "file was updated", so the next call on that path should return true - specifically for each path: - when(resourceMock.getTimestamp()).thenThrow(new RuntimeException("Oh no")); - boolean fourthB = decider.shouldReCache(BPATH, 1, resourceMock); - boolean thirdA = decider.shouldReCache(APATH, 1, resourceMock); - - Resource resourceMock2 = mock(Resource.class); - when(resourceMock2.getTimestamp()).thenReturn(12345678L); - boolean fifthB = decider.shouldReCache(BPATH, 1, resourceMock2); - boolean fourthA = decider.shouldReCache(APATH, 1, resourceMock2); - boolean fifthA = decider.shouldReCache(APATH, 1, resourceMock2); - boolean sixthB = decider.shouldReCache(BPATH, 1, resourceMock2); - boolean sixthA = decider.shouldReCache(APATH, 1, resourceMock2); - - Assert.assertTrue(firstA); // true, since first check on that path - Assert.assertFalse(secondA); - Assert.assertTrue(thirdA); // true, since getTimestamp failed - Assert.assertTrue(fourthA); // true, since first call after the failure removed the path from prevLastmodifiedDates - Assert.assertFalse(fifthA); - Assert.assertFalse(sixthA); - - Assert.assertTrue(firstB); // true, since first check on that path - Assert.assertFalse(secondB); - Assert.assertFalse(thirdB); - Assert.assertTrue(fourthB); // true, since getTimestamp failed - Assert.assertTrue(fifthB); // true, since first call after the failure removed the path from prevLastmodifiedDates - Assert.assertFalse(sixthB); - } -} From 10117fff0609f7bcfd782f39b84f204bdb4598e2 Mon Sep 17 00:00:00 2001 From: Espen Norderud Date: Thu, 4 Mar 2021 15:30:55 +0100 Subject: [PATCH 04/17] Refactored the EtagService, moving some checks out of it to clean the responsibility and dispense with the STATUS and handling - simplifying a lot. Now tests fail, rewriting those. --- .../enonic/libStatic/etag/EtagService.java | 43 ++-- .../lib/enonic/static/etagReader.es6 | 15 +- .../resources/lib/enonic/static/index.es6 | 126 +++++----- .../libStatic/etag/EtagServiceTest.java | 237 ++++++------------ 4 files changed, 162 insertions(+), 259 deletions(-) diff --git a/src/main/java/lib/enonic/libStatic/etag/EtagService.java b/src/main/java/lib/enonic/libStatic/etag/EtagService.java index 2cbfb59..0494bef 100644 --- a/src/main/java/lib/enonic/libStatic/etag/EtagService.java +++ b/src/main/java/lib/enonic/libStatic/etag/EtagService.java @@ -1,11 +1,5 @@ package lib.enonic.libStatic.etag; -import java.util.Map; -import java.util.function.Supplier; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import com.enonic.xp.resource.Resource; import com.enonic.xp.resource.ResourceKey; import com.enonic.xp.resource.ResourceProcessor; @@ -13,6 +7,12 @@ import com.enonic.xp.script.bean.BeanContext; import com.enonic.xp.script.bean.ScriptBean; import com.enonic.xp.server.RunMode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Supplier; public class EtagService implements ScriptBean @@ -23,23 +23,22 @@ public class EtagService protected static boolean isDev = RunMode.get() != RunMode.PROD; - private Supplier resourceServiceSupplier; + protected Supplier resourceServiceSupplier; - public static final String STATUS_KEY = "status"; public static final String ERROR_KEY = "error"; - public static final String ETAG_KEY = "etag"; + private static final Map NO_ETAG = new HashMap<>(); public Map getEtag( String path ) { return getEtag( path, 0 ); } - /** Gets a content string and contenthash etag string. + /** Gets a contenthash etag string or an error, at the keys "etag" or "error" in the returned map. * * - * @param path (string) Absolute (i.e. JAR-root-relative) path, name and extension to the file + * @param path (string) Absolute (i.e. JAR-root-relative) path, name and extension to the file. Must be already checked and verified. * @param etagOverrideMode (int) if 0 (or null), default handling: in XP prod mode do cached processing without lastModified-checking, and in dev mode skip all etag processing * Setting to -1 or 1 overrides this: * * If 1 (actually, > 0) : process and cache etags, even in dev mode. @@ -50,11 +49,6 @@ public Map getEtag( String path ) */ public Map getEtag( String path, Integer etagOverrideMode ) { - path = path.trim(); - if ( path.endsWith( ":" ) || path.endsWith( ":/" ) ) - { - return Map.of( STATUS_KEY, "400", ERROR_KEY, "Empty (root) path not allowed." ); - } if ( etagOverrideMode == null ) { etagOverrideMode = 0; @@ -63,33 +57,28 @@ public Map getEtag( String path, Integer etagOverrideMode ) final ResourceService resourceService = resourceServiceSupplier.get(); try { - Resource resource = resourceService.getResource( ResourceKey.from( path ) ); - if ( !resource.exists() ) - { - return Map.of( STATUS_KEY, "404", ERROR_KEY, "Resource not found: '" + path + "'" ); - } - boolean doProcessEtag = etagOverrideMode > ( isDev ? 0 : -1 ); // 0: true in prod, false in dev. 1 forces true in dev, -1 forces false in prod. if ( doProcessEtag ) { + Resource resource = resourceService.getResource( ResourceKey.from( path ) ); final String etag = resourceService.processResource( createEtagProcessor( resource.getKey() ) ); - return Map.of( STATUS_KEY, "200", ETAG_KEY, etag ); + return Map.of( ETAG_KEY, etag ); } else { - return Map.of( STATUS_KEY, "200" ); + return NO_ETAG; } } catch ( Exception e ) { long errorRnd = (long) ( Math.random() * Long.MAX_VALUE ); - String errorId = Long.toString( errorRnd, 36 ); - LOG.error( "Couldn't process etag: '" + path + "' (error ID: " + errorId + ")", e ); - return Map.of( STATUS_KEY, "500", ERROR_KEY, "Couldn't process etag: '" + path + "' (error ID: " + errorId + ")" ); + String errorMsg = "Couldn't process etag from resource '" + path + "' (error ID: " + Long.toString( errorRnd, 36 ) + ")"; + LOG.error(errorMsg, e ); + return Map.of( ERROR_KEY, errorMsg ); } } diff --git a/src/main/resources/lib/enonic/static/etagReader.es6 b/src/main/resources/lib/enonic/static/etagReader.es6 index c95d4d3..a6883d0 100644 --- a/src/main/resources/lib/enonic/static/etagReader.es6 +++ b/src/main/resources/lib/enonic/static/etagReader.es6 @@ -5,7 +5,8 @@ const etagService = __.newBean('lib.enonic.libStatic.etag.EtagService'); * In XP prod mode, cache the etag by file path only. * In dev mode, check file's last-modified date. If newer than cached version, re-hash the etag and replace it in the cache. * - * @param path (string) Absolute (i.e. JAR-root-relative) path, name and extension to the file + * @param path (string) Absolute (i.e. JAR-root-relative) path, name and extension to the file. No checking is done here + * for fine-grained 400, 404 errors etc, so path should be already checked, trimmed, verified for existing resource etc. * @param isProd (boolean) true for XP prod mode, false for dev mode * @return (object) {content, etag} */ @@ -17,13 +18,11 @@ exports.read = (path, etagOverrideOption) => { ? -1 : 0; - const { status, error, etag } = __.toNativeObject(etagService.getEtag(`${app.name}:${path}`, etagOverride)); + const { error, etag } = __.toNativeObject(etagService.getEtag(`${app.name}:${path}`, etagOverride)); - - - return { - status: parseInt(status), - error, - etagValue: etag || undefined + if (error) { + throw Error(error); } + + return etag || undefined }; diff --git a/src/main/resources/lib/enonic/static/index.es6 b/src/main/resources/lib/enonic/static/index.es6 index 5eb7017..7bdd68c 100644 --- a/src/main/resources/lib/enonic/static/index.es6 +++ b/src/main/resources/lib/enonic/static/index.es6 @@ -3,75 +3,55 @@ const optionsParser = require('/lib/enonic/static/options'); const ioLib = require('/lib/enonic/static/io'); -const makeResponse200 = (status, path, contentTypeFunc, cacheControlFunc, etagValue) => { - const resource = ioLib.getResource(path); - - const contentType = contentTypeFunc(path, resource); - - const headers = { - 'Cache-Control': cacheControlFunc(path, resource, contentType), - 'ETag': etagValue - }; - +const makeResponse200 = (path, resource, contentType, cacheControlFunc, etagValue) => { return { - status, + status: 200, body: resource.getStream(), contentType, - headers + headers: { + 'Cache-Control': cacheControlFunc(path, resource, contentType), + 'ETag': etagValue + } }; } // Attempt graceful handling: the status and etagError so far comes from the etagReader trying to resolve the etag. // In case it's still possible to return the main data with a must-revalidate header - much better than nothing - try to read it: -const makeFallbackResponse = (status, path, contentTypeFunc, etagError) => { +const makeFallbackResponse = (path, resource, contentType, etagError) => { try { - const resource = ioLib.getResource(path); - - const contentType = contentTypeFunc(path, resource); - - const headers = { - 'Cache-Control': 'must-revalidate' - }; - - log.warn(`Successful fallback to reading resource '${path}', although ETag processing failed (${etagError})`); - return { - status, + const response = { + status: 200, body: resource.getStream(), contentType, - headers + headers: { + 'Cache-Control': 'must-revalidate' + } }; + log.warn(`Handled: serving fallback resource (non-caching headers) after ETag processing error: ${etagError}`); + + return response; + } catch (e) { - log.error(`Tried reading resource '${path}' after failing ETag processing (${etagError}) - but this failed too.`, e); + log.error(`Tried serving non-caching fallback resource after failed ETag processing - but that failed too, see below. ETag error was: ${etagError}`); throw e; } } -const makeResponse = (status, path, contentTypeFunc, cacheControlFunc, etagValue, etagError) => { - if (status < 300) { - return makeResponse200(status, path, contentTypeFunc, cacheControlFunc, etagValue); - - } else if (status >= 500) { - // Attempt graceful handling: the status and etagError so far comes from the etagReader trying to resolve the etag. - // In case it's still possible to return the main data with a must-revalidate header - much better than nothing - try to read it: - return makeFallbackResponse(status, path, contentTypeFunc, etagError); +const getResponse = (path, resource, contentTypeFunc, cacheControlFunc, etagValue, etagError) => { + const contentType = contentTypeFunc(path, resource); - } else { - // An error in the 400-area from the etagReader is bound to yield the same error here, so don't even try a fallback. Just return it. - return { - status, - body: etagError, - contentType: "text/plain", - } - } + return (!etagError) + ? makeResponse200(path, resource, contentType, cacheControlFunc, etagValue) + : makeFallbackResponse(path, resource, contentType, etagError); } /** Creates an easy-readable and trackable error message in the log, * and returns a generic error message with a tracking ID in the response */ -const makeErrorLogAndResponse = (e, throwErrors, stringOrOptions, options, methodLabel, rootOrPathLabel) => { +const errorLogAndResponse500 = (e, throwErrors, stringOrOptions, options, methodLabel, rootOrPathLabel) => { if (!throwErrors) { const errorID = Math.floor(Math.random() * 1000000000000000).toString(36); @@ -102,6 +82,31 @@ const makeErrorLogAndResponse = (e, throwErrors, stringOrOptions, options, metho +const getResource = (path, pathError) => { + if (pathError) { + return { + response400: { + status: 400, + body: pathError, + contentType: 'text/plain' + } + }; + } + + const resource = ioLib.getResource(path); + if (!resource.exists()) { + return { + response400: { + status: 404, + body: `Not found: ${path}`, + contentType: 'text/plain' + } + } + } + + return { resource }; +}; + ///////////////////////////////////////////////////////////////////////////// .get @@ -125,22 +130,20 @@ exports.get = (pathOrOptions, options) => { path = path.replace(/^\/+/, ''); const pathError = getPathError(path); - if (pathError) { - return { - status: 400, - body: `Resource path '${path}' ${pathError}`, - contentType: 'text/plain' - } + + const { resource, response400 } = getResource(path, pathError ? `Resource path '${path}' ${pathError}` : undefined); + if (response400) { + return response400; } - path = `/${path}`; + path = `/${path}`; - const { status, error, etagValue } = etagReader.read(path, etagOverride); + const { etagValue, etagError } = etagReader.read(path, etagOverride); - return makeResponse(status, path, contentTypeFunc, cacheControlFunc, etagValue, error); + return getResponse(path, resource, contentTypeFunc, cacheControlFunc, etagValue, etagError); } catch (e) { - return makeErrorLogAndResponse(e, throwErrors, pathOrOptions, options, "get", "Path"); + return errorLogAndResponse500(e, throwErrors, pathOrOptions, options, "get", "Path"); } }; @@ -236,15 +239,14 @@ exports.static = (rootOrOptions, options) => { return function getStatic(request) { try { const { path, pathError } = getPathFromRequest(request, root, contextPathOverride); - if (pathError) { - return { - status: 400, - body: pathError, - contentType: 'text/plain' - } + + + const { resource, response400 } = getResource(path, pathError); + if (response400) { + return response400; } - const { etagValue, status, error } = etagReader.read(path, etagOverride); + const { etagValue, etagError } = etagReader.read(path, etagOverride); let ifNoneMatch = (request.headers || {})['If-None-Match']; if (ifNoneMatch && ifNoneMatch === etagValue) { @@ -253,10 +255,10 @@ exports.static = (rootOrOptions, options) => { }; } - return makeResponse(status, path, contentTypeFunc, cacheControlFunc, etagValue, error); + return getResponse(path, resource, contentTypeFunc, cacheControlFunc, etagValue, etagError); } catch (e) { - return makeErrorLogAndResponse(e, throwErrors, rootOrOptions, options, "static", "Root"); + return errorLogAndResponse500(e, throwErrors, rootOrOptions, options, "static", "Root"); } } }; diff --git a/src/test/java/lib/enonic/libStatic/etag/EtagServiceTest.java b/src/test/java/lib/enonic/libStatic/etag/EtagServiceTest.java index 3ac28b4..6003561 100644 --- a/src/test/java/lib/enonic/libStatic/etag/EtagServiceTest.java +++ b/src/test/java/lib/enonic/libStatic/etag/EtagServiceTest.java @@ -1,17 +1,17 @@ package lib.enonic.libStatic.etag; import com.enonic.xp.resource.ResourceKey; +import com.enonic.xp.resource.ResourceService; import com.enonic.xp.testing.ScriptTestSupport; import org.junit.Before; import org.junit.Test; +import org.mockito.Mockito; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.Map; +import java.util.function.Supplier; -import static lib.enonic.libStatic.etag.EtagService.ERROR_KEY; -import static lib.enonic.libStatic.etag.EtagService.ETAG_KEY; -import static lib.enonic.libStatic.etag.EtagService.STATUS_KEY; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -20,6 +20,17 @@ public class EtagServiceTest extends ScriptTestSupport { private EtagService service; + + private Supplier resourceServiceSupplierMock; + private ResourceService resourceServiceMock; + + private String getETag(Map result) { + return result.get(lib.enonic.libStatic.etag.EtagService.ETAG_KEY); + } + private String getError(Map result) { + return result.get(lib.enonic.libStatic.etag.EtagService.ERROR_KEY); + } + @Override protected void initialize() throws Exception { super.initialize(); @@ -27,35 +38,32 @@ protected void initialize() throws Exception { @Before public void setUp() { + resourceServiceSupplierMock = Mockito.mock(Supplier.class); + resourceServiceMock = Mockito.mock(ResourceService.class); + + Mockito.when(resourceServiceSupplierMock.get()).thenReturn(resourceServiceMock); + Mockito.when(resourceServiceMock.getResource( Mockito.any(ResourceKey.class) )).then + service = new EtagService(); service.initialize( newBeanContext(ResourceKey.from("myapplication:/test"))); - } - - @Test - public void testGetEtag_status() { - service.isDev = false; - Map result = service.getEtag("myapplication:static/static-test-text.txt", 0); - assertEquals(200, Integer.parseInt(result.get(STATUS_KEY))); // , "Status should be 200"); - assertEquals(null, result.get(ERROR_KEY)); + service.resourceServiceSupplier = resourceServiceSupplierMock; } @Test public void testGetEtag_defaultETag_prod_EtagExpected() { service.isDev = false; Map result = service.getEtag("myapplication:static/static-test-text.txt", 0); - assertEquals(200, Integer.parseInt(result.get(STATUS_KEY))); // , "Status should be 200"); - assertTrue(result.get(ETAG_KEY).trim().length() > 0); //, "The returned ETag should not be empty"); - assertEquals(null, result.get(ERROR_KEY)); + assertEquals(null, getError(result)); + assertTrue(getETag(result).trim().length() > 0); //, "The returned ETag should not be empty"); } @Test - public void testGetEtag_defaultETag_dev_onlyStatusExpected() { + public void testGetEtag_defaultETag_dev_noEtagExpected() { service.isDev = true; Map result = service.getEtag("myapplication:static/static-test-text.txt", 0); - assertEquals(200, Integer.parseInt(result.get(STATUS_KEY))); // , "Status should be 200"); - assertEquals(null, result.get(ETAG_KEY)); - assertEquals(null, result.get(ERROR_KEY)); + assertEquals(null, getError(result)); + assertEquals(null, getETag(result)); } @@ -63,41 +71,37 @@ public void testGetEtag_defaultETag_dev_onlyStatusExpected() { public void testGetEtag_positiveETagOverride_prod_EtagExpected() { service.isDev = false; Map result = service.getEtag("myapplication:static/static-test-text.txt", 1); - assertEquals(200, Integer.parseInt(result.get(STATUS_KEY))); // , "Status should be 200"); - assertTrue(result.get(ETAG_KEY).trim().length() > 0); // "Positive etagOverride, so the ETag should be returned"); - assertEquals(null, result.get(ERROR_KEY)); + assertEquals(null, getError(result)); + assertTrue(getETag(result).length() > 0); // "Positive etagOverride, so the ETag should be returned"); } @Test public void testGetEtag_positiveETagOverride_dev_EtagExpected() { service.isDev = true; Map result = service.getEtag("myapplication:static/static-test-text.txt", 1); - assertEquals(200, Integer.parseInt(result.get(STATUS_KEY))); // , "Status should be 200"); - assertTrue(result.get(ETAG_KEY).trim().length() > 0);//, "Positive etagOverride, so EVEN IN DEV the ETag should be returned"); - assertEquals(null, result.get(ERROR_KEY)); + assertEquals(null, getError(result)); + assertTrue(getETag(result).length() > 0);//, "Positive etagOverride, so EVEN IN DEV the ETag should be returned"); } @Test - public void testGetEtag_negativeETagOverride_prod_onlyStatusExpected() { + public void testGetEtag_negativeETagOverride_prod_noEtagOrErrorExpected() { service.isDev = false; Map result = service.getEtag("myapplication:static/static-test-text.txt", -1); - assertEquals(200, Integer.parseInt(result.get(STATUS_KEY))); // , "Status should be 200"); - assertEquals(null, result.get(ETAG_KEY)); //, "Negative etagOverride, so EVEN IN PROD the ETag should be skipped"); - assertEquals(null, result.get(ERROR_KEY)); + assertEquals(null, getError(result)); + assertEquals(null, getETag(result)); //, "Negative etagOverride, so EVEN IN PROD the ETag should be skipped"); } @Test - public void testGetEtag_negativeETagOverride_onlyStatusExpected() { + public void testGetEtag_negativeETagOverride_dev_noEtagOrErrorExpected() { service.isDev = true; Map result = service.getEtag("myapplication:static/static-test-text.txt", -1); - assertEquals(200, Integer.parseInt(result.get(STATUS_KEY))); // , "Status should be 200"); - assertEquals(null, result.get(ETAG_KEY)); //, "Negative etagOverride, so no ETag"); - assertEquals(null, result.get(ERROR_KEY)); + assertEquals(null, getError(result)); + assertEquals(null, getETag(result)); //, "Negative etagOverride, so no ETag"); } @Test - public void testGetEtag_defaultETag_prod_shouldReadFirstCacheSubsequent() { + public void testGetEtag_defaultETag_prod_shouldSaveTimeBecauseReadFirstCacheSubsequent() { service.isDev = false; Long t0 = System.nanoTime(); Map result1 = service.getEtag("myapplication:static/hugh.jazzit.blob", 0); @@ -109,21 +113,21 @@ public void testGetEtag_defaultETag_prod_shouldReadFirstCacheSubsequent() { Map result6 = service.getEtag("myapplication:static/hugh.jazzit.blob", 0); Long t6 = System.nanoTime(); - // LOG.info("etag: " + result1.get(ETAG_KEY)); - - // All results should have an etag (so 3 long) and status 200 - assertTrue(result1.get(ETAG_KEY).trim().length() > 0); - assertTrue(result2.get(ETAG_KEY).trim().length() > 0); - assertTrue(result3.get(ETAG_KEY).trim().length() > 0); - assertTrue(result4.get(ETAG_KEY).trim().length() > 0); - assertTrue(result5.get(ETAG_KEY).trim().length() > 0); - assertTrue(result6.get(ETAG_KEY).trim().length() > 0); - assertEquals(200, Integer.parseInt(result1.get(STATUS_KEY))); - assertEquals(200, Integer.parseInt(result2.get(STATUS_KEY))); - assertEquals(200, Integer.parseInt(result3.get(STATUS_KEY))); - assertEquals(200, Integer.parseInt(result4.get(STATUS_KEY))); - assertEquals(200, Integer.parseInt(result5.get(STATUS_KEY))); - assertEquals(200, Integer.parseInt(result6.get(STATUS_KEY))); + // LOG.info("etag: " + getETag(result1)); + + // All results should have an etag and no error + assertEquals(null, getError(result1)); + assertEquals(null, getError(result2)); + assertEquals(null, getError(result3)); + assertEquals(null, getError(result4)); + assertEquals(null, getError(result5)); + assertEquals(null, getError(result6)); + assertTrue(getETag(result1).length() > 0); + assertTrue(getETag(result2).length() > 0); + assertTrue(getETag(result3).length() > 0); + assertTrue(getETag(result4).length() > 0); + assertTrue(getETag(result5).length() > 0); + assertTrue(getETag(result6).length() > 0); Long delta1 = t1-t0; float avgDelta6 = (t6-t1)/5f; @@ -136,7 +140,7 @@ public void testGetEtag_defaultETag_prod_shouldReadFirstCacheSubsequent() { } @Test - public void testGetEtag_positiveETag_dev_shouldReadFirstCacheSubsequent() { + public void testGetEtag_positiveETag_dev_shouldSaveTimeBecauseReadFirstCacheSubsequent() { service.isDev = true; Long t0 = System.nanoTime(); Map result1 = service.getEtag("myapplication:static/hugh.jazzit.blob", 1); @@ -148,21 +152,21 @@ public void testGetEtag_positiveETag_dev_shouldReadFirstCacheSubsequent() { Map result6 = service.getEtag("myapplication:static/hugh.jazzit.blob", 1); Long t6 = System.nanoTime(); - // LOG.info("etag: " + result1.get(ETAG_KEY)); - - // All results should have an etag and status 200 - assertTrue(result1.get(ETAG_KEY).trim().length() > 0); - assertTrue(result2.get(ETAG_KEY).trim().length() > 0); - assertTrue(result3.get(ETAG_KEY).trim().length() > 0); - assertTrue(result4.get(ETAG_KEY).trim().length() > 0); - assertTrue(result5.get(ETAG_KEY).trim().length() > 0); - assertTrue(result6.get(ETAG_KEY).trim().length() > 0); - assertEquals(200, Integer.parseInt(result1.get(STATUS_KEY))); - assertEquals(200, Integer.parseInt(result2.get(STATUS_KEY))); - assertEquals(200, Integer.parseInt(result3.get(STATUS_KEY))); - assertEquals(200, Integer.parseInt(result4.get(STATUS_KEY))); - assertEquals(200, Integer.parseInt(result5.get(STATUS_KEY))); - assertEquals(200, Integer.parseInt(result6.get(STATUS_KEY))); + // LOG.info("etag: " + getETag(result1)); + + // All results should have an etag and no error + assertEquals(null, getError(result1)); + assertEquals(null, getError(result2)); + assertEquals(null, getError(result3)); + assertEquals(null, getError(result4)); + assertEquals(null, getError(result5)); + assertEquals(null, getError(result6)); + assertTrue(getETag(result1).length() > 0); + assertTrue(getETag(result2).length() > 0); + assertTrue(getETag(result3).length() > 0); + assertTrue(getETag(result4).length() > 0); + assertTrue(getETag(result5).length() > 0); + assertTrue(getETag(result6).length() > 0); Long delta1 = t1-t0; float avgDelta6 = (t6-t1)/5f; @@ -180,108 +184,17 @@ public void testGetEtag_positiveETag_dev_shouldReadFirstCacheSubsequent() { //////////////////////////////////////////// Error handling - // 400 - empty/root path - @Test - public void testGetEtag_prod_emptyPath_should400() { - service.isDev = false; - Map result = service.getEtag("myapplication:", 0); - assertEquals(400, Integer.parseInt(result.get(STATUS_KEY))); - assertTrue(result.get(ERROR_KEY).trim().length() > 0); - LOG.info("Ok: " + result.get(STATUS_KEY) + " - " + result.get(ERROR_KEY)); - } - @Test - public void testGetEtag_dev_emptyPath_should400() { - service.isDev = true; - Map result = service.getEtag("myapplication:", 0); - assertEquals(400, Integer.parseInt(result.get(STATUS_KEY))); - assertTrue(result.get(ERROR_KEY).trim().length() > 0); - LOG.info("Ok: " + result.get(STATUS_KEY) + " - " + result.get(ERROR_KEY)); - } - @Test - public void testGetEtag_prod_rootPath_should400() { - service.isDev = false; - Map result = service.getEtag("myapplication:/", 0); - assertEquals(400, Integer.parseInt(result.get(STATUS_KEY))); - assertTrue(result.get(ERROR_KEY).trim().length() > 0); - LOG.info("Ok: " + result.get(STATUS_KEY) + " - " + result.get(ERROR_KEY)); - } + // 500 @Test - public void testGetEtag_dev_rootPath_should400() { - service.isDev = true; - Map result = service.getEtag("myapplication:/", 0); - assertEquals(400, Integer.parseInt(result.get(STATUS_KEY))); - assertTrue(result.get(ERROR_KEY).trim().length() > 0); - LOG.info("Ok: " + result.get(STATUS_KEY) + " - " + result.get(ERROR_KEY)); - } + public void testGetEtag_exceptions_shouldReturnMessageUnderErrorKey() { + Mockito.when(resourceServiceMock.getResource( Mockito.any(ResourceKey.class) )).thenThrow(new RuntimeException("Catch-and-return")); + service.resourceServiceSupplier = resourceServiceSupplierMock; - // 404 - @Test - public void testGetEtag_prod_noExist_should404() { - service.isDev = false; - Map result = service.getEtag("myapplication:static/no.exist", 0); - assertEquals(404, Integer.parseInt(result.get(STATUS_KEY))); - assertTrue(result.get(ERROR_KEY).trim().length() > 0); - LOG.info("Ok: " + result.get(STATUS_KEY) + " - " + result.get(ERROR_KEY)); - } - @Test - public void testGetEtag_dev_noExist_should404() { - service.isDev = true; - Map result = service.getEtag("myapplication:static/no.exist", 0); - assertEquals(404, Integer.parseInt(result.get(STATUS_KEY))); - assertTrue(result.get(ERROR_KEY).trim().length() > 0); - LOG.info("Ok: " + result.get(STATUS_KEY) + " - " + result.get(ERROR_KEY)); - } - @Test - public void testGetEtag_prod_positiveEtagOverride_noExist_should404() { - service.isDev = false; - Map result = service.getEtag("myapplication:static/no.exist", 1); - assertEquals(404, Integer.parseInt(result.get(STATUS_KEY))); - assertTrue(result.get(ERROR_KEY).trim().length() > 0); - LOG.info("Ok: " + result.get(STATUS_KEY) + " - " + result.get(ERROR_KEY)); - } - @Test - public void testGetEtag_dev_positiveEtagOverride_noExist_should404() { - service.isDev = true; - Map result = service.getEtag("myapplication:static/no.exist", 1); - assertEquals(404, Integer.parseInt(result.get(STATUS_KEY))); - assertTrue(result.get(ERROR_KEY).trim().length() > 0); - LOG.info("Ok: " + result.get(STATUS_KEY) + " - " + result.get(ERROR_KEY)); - } - @Test - public void testGetEtag_prod_negativeEtagOverride_noExist_should404() { - service.isDev = false; - Map result = service.getEtag("myapplication:static/no.exist", -1); - assertEquals(404, Integer.parseInt(result.get(STATUS_KEY))); - assertTrue(result.get(ERROR_KEY).trim().length() > 0); - LOG.info("Ok: " + result.get(STATUS_KEY) + " - " + result.get(ERROR_KEY)); - } - @Test - public void testGetEtag_dev_negativeEtagOverride_noExist_should404() { - service.isDev = true; - Map result = service.getEtag("myapplication:static/no.exist", -1); - assertEquals(404, Integer.parseInt(result.get(STATUS_KEY))); - assertTrue(result.get(ERROR_KEY).trim().length() > 0); - LOG.info("Ok: " + result.get(STATUS_KEY) + " - " + result.get(ERROR_KEY)); - } - + Map result = service.getEtag("myapplication:static/hugh.jazzit.blob", 0); - // 500 - @Test - public void testGetEtag_prod_missingAppPath_should500() { - service.isDev = false; - Map result = service.getEtag("static/no.exist", 1); - assertEquals(500, Integer.parseInt(result.get(STATUS_KEY))); - assertTrue(result.get(ERROR_KEY).trim().length() > 0); - LOG.info("Ok: " + result.get(STATUS_KEY) + " - " + result.get(ERROR_KEY)); - } - @Test - public void testGetEtag_dev_missingAppPath_should500() { - service.isDev = true; - Map result = service.getEtag("static/no.exist", 1); - assertEquals(500, Integer.parseInt(result.get(STATUS_KEY))); - assertTrue(result.get(ERROR_KEY).trim().length() > 0); - LOG.info("Ok: " + result.get(STATUS_KEY) + " - " + result.get(ERROR_KEY)); + assertEquals("Catch-and-return", getError(result)); + assertEquals(null, getETag(result)); } } From 69441968b769ce4200e7075a7ddcf50c5b4a4336 Mon Sep 17 00:00:00 2001 From: Espen Norderud Date: Thu, 4 Mar 2021 17:50:59 +0100 Subject: [PATCH 05/17] CR changes --- .../enonic/libStatic/etag/EtagService.java | 10 +--- .../resources/lib/enonic/static/index.es6 | 49 ++++--------------- 2 files changed, 11 insertions(+), 48 deletions(-) diff --git a/src/main/java/lib/enonic/libStatic/etag/EtagService.java b/src/main/java/lib/enonic/libStatic/etag/EtagService.java index 0494bef..988ec9e 100644 --- a/src/main/java/lib/enonic/libStatic/etag/EtagService.java +++ b/src/main/java/lib/enonic/libStatic/etag/EtagService.java @@ -10,7 +10,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.HashMap; import java.util.Map; import java.util.function.Supplier; @@ -23,12 +22,12 @@ public class EtagService protected static boolean isDev = RunMode.get() != RunMode.PROD; - protected Supplier resourceServiceSupplier; + Supplier resourceServiceSupplier; public static final String ERROR_KEY = "error"; public static final String ETAG_KEY = "etag"; - private static final Map NO_ETAG = new HashMap<>(); + private static final Map NO_ETAG = Map.of(); public Map getEtag( String path ) { @@ -49,11 +48,6 @@ public Map getEtag( String path ) */ public Map getEtag( String path, Integer etagOverrideMode ) { - if ( etagOverrideMode == null ) - { - etagOverrideMode = 0; - } - final ResourceService resourceService = resourceServiceSupplier.get(); try { diff --git a/src/main/resources/lib/enonic/static/index.es6 b/src/main/resources/lib/enonic/static/index.es6 index 7bdd68c..e393ca5 100644 --- a/src/main/resources/lib/enonic/static/index.es6 +++ b/src/main/resources/lib/enonic/static/index.es6 @@ -3,7 +3,8 @@ const optionsParser = require('/lib/enonic/static/options'); const ioLib = require('/lib/enonic/static/io'); -const makeResponse200 = (path, resource, contentType, cacheControlFunc, etagValue) => { +const getResponse200 = (path, resource, contentTypeFunc, cacheControlFunc, etagValue) => { + const contentType = contentTypeFunc(path, resource); return { status: 200, body: resource.getStream(), @@ -13,40 +14,7 @@ const makeResponse200 = (path, resource, contentType, cacheControlFunc, etagValu 'ETag': etagValue } }; -} - - -// Attempt graceful handling: the status and etagError so far comes from the etagReader trying to resolve the etag. -// In case it's still possible to return the main data with a must-revalidate header - much better than nothing - try to read it: -const makeFallbackResponse = (path, resource, contentType, etagError) => { - try { - const response = { - status: 200, - body: resource.getStream(), - contentType, - headers: { - 'Cache-Control': 'must-revalidate' - } - }; - - log.warn(`Handled: serving fallback resource (non-caching headers) after ETag processing error: ${etagError}`); - - return response; - - } catch (e) { - log.error(`Tried serving non-caching fallback resource after failed ETag processing - but that failed too, see below. ETag error was: ${etagError}`); - throw e; - } -} - - -const getResponse = (path, resource, contentTypeFunc, cacheControlFunc, etagValue, etagError) => { - const contentType = contentTypeFunc(path, resource); - - return (!etagError) - ? makeResponse200(path, resource, contentType, cacheControlFunc, etagValue) - : makeFallbackResponse(path, resource, contentType, etagError); -} +}; /** Creates an easy-readable and trackable error message in the log, @@ -138,9 +106,9 @@ exports.get = (pathOrOptions, options) => { path = `/${path}`; - const { etagValue, etagError } = etagReader.read(path, etagOverride); + const etag = etagReader.read(path, etagOverride); - return getResponse(path, resource, contentTypeFunc, cacheControlFunc, etagValue, etagError); + return getResponse200(path, resource, contentTypeFunc, cacheControlFunc, etag); } catch (e) { return errorLogAndResponse500(e, throwErrors, pathOrOptions, options, "get", "Path"); @@ -246,16 +214,17 @@ exports.static = (rootOrOptions, options) => { return response400; } - const { etagValue, etagError } = etagReader.read(path, etagOverride); + const etag = etagReader.read(path, etagOverride); + let ifNoneMatch = (request.headers || {})['If-None-Match']; - if (ifNoneMatch && ifNoneMatch === etagValue) { + if (ifNoneMatch && ifNoneMatch === etag) { return { status: 304 }; } - return getResponse(path, resource, contentTypeFunc, cacheControlFunc, etagValue, etagError); + return getResponse200(path, resource, contentTypeFunc, cacheControlFunc, etag); } catch (e) { return errorLogAndResponse500(e, throwErrors, rootOrOptions, options, "static", "Root"); From f607307cdeb64bd87f599bc89c761d124bf4ae50 Mon Sep 17 00:00:00 2001 From: Espen Norderud Date: Thu, 4 Mar 2021 18:14:24 +0100 Subject: [PATCH 06/17] Etag doublequotes handling --- .../resources/lib/enonic/static/index.es6 | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/src/main/resources/lib/enonic/static/index.es6 b/src/main/resources/lib/enonic/static/index.es6 index e393ca5..e1b815b 100644 --- a/src/main/resources/lib/enonic/static/index.es6 +++ b/src/main/resources/lib/enonic/static/index.es6 @@ -17,6 +17,23 @@ const getResponse200 = (path, resource, contentTypeFunc, cacheControlFunc, etagV }; +const getEtagOr304 = (path, request, etagOverride) => { + let etag = etagReader.read(path, etagOverride); + + const ifNoneMatch = (request.headers || {})['If-None-Match']; + if (ifNoneMatch) { + etag = (ifNoneMatch[0] === '"') ? `"${etag}"` : etag; + if (ifNoneMatch === etag) { + return { + response304: { + status: 304 + } + }; + } + } + return { etag }; +} + /** Creates an easy-readable and trackable error message in the log, * and returns a generic error message with a tracking ID in the response */ const errorLogAndResponse500 = (e, throwErrors, stringOrOptions, options, methodLabel, rootOrPathLabel) => { @@ -50,7 +67,7 @@ const errorLogAndResponse500 = (e, throwErrors, stringOrOptions, options, method -const getResource = (path, pathError) => { +const getResourceOr400 = (path, pathError) => { if (pathError) { return { response400: { @@ -99,7 +116,7 @@ exports.get = (pathOrOptions, options) => { path = path.replace(/^\/+/, ''); const pathError = getPathError(path); - const { resource, response400 } = getResource(path, pathError ? `Resource path '${path}' ${pathError}` : undefined); + const { resource, response400 } = getResourceOr400(path, pathError ? `Resource path '${path}' ${pathError}` : undefined); if (response400) { return response400; } @@ -209,19 +226,14 @@ exports.static = (rootOrOptions, options) => { const { path, pathError } = getPathFromRequest(request, root, contextPathOverride); - const { resource, response400 } = getResource(path, pathError); + const { resource, response400 } = getResourceOr400(path, pathError); if (response400) { return response400; } - const etag = etagReader.read(path, etagOverride); - - - let ifNoneMatch = (request.headers || {})['If-None-Match']; - if (ifNoneMatch && ifNoneMatch === etag) { - return { - status: 304 - }; + const { etag, response304 } = getEtagOr304(path, request, etagOverride); + if (response304) { + return response304 } return getResponse200(path, resource, contentTypeFunc, cacheControlFunc, etag); From 65d69d0f37af16d1f28b7e9af225e3786b738e58 Mon Sep 17 00:00:00 2001 From: Espen Norderud Date: Thu, 4 Mar 2021 18:51:28 +0100 Subject: [PATCH 07/17] #38: decodeURI solves URI encoding issue. Improving double-quoted etag handling, and charset on error responses. --- .../lib/enonic/static/etagReader.es6 | 6 ++++-- .../resources/lib/enonic/static/index.es6 | 20 +++++++++---------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/main/resources/lib/enonic/static/etagReader.es6 b/src/main/resources/lib/enonic/static/etagReader.es6 index a6883d0..0171d54 100644 --- a/src/main/resources/lib/enonic/static/etagReader.es6 +++ b/src/main/resources/lib/enonic/static/etagReader.es6 @@ -18,11 +18,13 @@ exports.read = (path, etagOverrideOption) => { ? -1 : 0; - const { error, etag } = __.toNativeObject(etagService.getEtag(`${app.name}:${path}`, etagOverride)); + let { error, etag } = __.toNativeObject(etagService.getEtag(`${app.name}:${path}`, etagOverride)); if (error) { throw Error(error); } - return etag || undefined + return (etag && etag[0] !== '"') + ? `"${etag}"` + : etag || undefined }; diff --git a/src/main/resources/lib/enonic/static/index.es6 b/src/main/resources/lib/enonic/static/index.es6 index e1b815b..2dcd0e0 100644 --- a/src/main/resources/lib/enonic/static/index.es6 +++ b/src/main/resources/lib/enonic/static/index.es6 @@ -3,7 +3,7 @@ const optionsParser = require('/lib/enonic/static/options'); const ioLib = require('/lib/enonic/static/io'); -const getResponse200 = (path, resource, contentTypeFunc, cacheControlFunc, etagValue) => { +const getResponse200 = (path, resource, contentTypeFunc, cacheControlFunc, etag) => { const contentType = contentTypeFunc(path, resource); return { status: 200, @@ -11,7 +11,7 @@ const getResponse200 = (path, resource, contentTypeFunc, cacheControlFunc, etagV contentType, headers: { 'Cache-Control': cacheControlFunc(path, resource, contentType), - 'ETag': etagValue + 'ETag': etag } }; }; @@ -20,9 +20,11 @@ const getResponse200 = (path, resource, contentTypeFunc, cacheControlFunc, etagV const getEtagOr304 = (path, request, etagOverride) => { let etag = etagReader.read(path, etagOverride); - const ifNoneMatch = (request.headers || {})['If-None-Match']; + let ifNoneMatch = (request.headers || {})['If-None-Match']; if (ifNoneMatch) { - etag = (ifNoneMatch[0] === '"') ? `"${etag}"` : etag; + ifNoneMatch = (ifNoneMatch[0] !== '"') + ? `"${ifNoneMatch}"` + : ifNoneMatch; if (ifNoneMatch === etag) { return { response304: { @@ -56,7 +58,7 @@ const errorLogAndResponse500 = (e, throwErrors, stringOrOptions, options, method return { status: 500, - contentType: "text/plain", + contentType: "text/plain; charset=utf-8", body: `Server error, logged with error ID: ${errorID}` } @@ -73,7 +75,7 @@ const getResourceOr400 = (path, pathError) => { response400: { status: 400, body: pathError, - contentType: 'text/plain' + contentType: 'text/plain; charset=utf-8' } }; } @@ -84,7 +86,7 @@ const getResourceOr400 = (path, pathError) => { response400: { status: 404, body: `Not found: ${path}`, - contentType: 'text/plain' + contentType: 'text/plain; charset=utf-8' } } } @@ -151,7 +153,7 @@ const getPathFromRequest = (request, root, contextPathOverride) => { const removePrefix = contextPathOverride || request.contextPath || '** contextPath (contextPathOverride) IS MISSING IN BOTH REQUEST AND OPTIONS **'; if (request.path.startsWith(removePrefix)) { - const relativePath = request.path + const relativePath = decodeURI(request.path) .trim() .substring(removePrefix.length) .replace(/^\/+/, ''); @@ -216,7 +218,6 @@ exports.static = (rootOrOptions, options) => { errorMessage = `Illegal root argument (or .root option attribute) '${root}': ${errorMessage}`; } - if (errorMessage) { throw Error(errorMessage); } @@ -225,7 +226,6 @@ exports.static = (rootOrOptions, options) => { try { const { path, pathError } = getPathFromRequest(request, root, contextPathOverride); - const { resource, response400 } = getResourceOr400(path, pathError); if (response400) { return response400; From 18b64bd3f60e2aeafebfe93933f52fffed630059 Mon Sep 17 00:00:00 2001 From: Espen Norderud Date: Thu, 4 Mar 2021 19:05:32 +0100 Subject: [PATCH 08/17] Static root resolution tweak --- src/main/resources/lib/enonic/static/index.es6 | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/resources/lib/enonic/static/index.es6 b/src/main/resources/lib/enonic/static/index.es6 index 2dcd0e0..8bb66c7 100644 --- a/src/main/resources/lib/enonic/static/index.es6 +++ b/src/main/resources/lib/enonic/static/index.es6 @@ -202,18 +202,16 @@ exports.static = (rootOrOptions, options) => { } = optionsParser.parseRootAndOptions(rootOrOptions, options); if (!errorMessage) { - root = root.replace(/^\/+/, ''); + root = resolvePath(root.replace(/^\/+/, '')); errorMessage = getPathError(root); + root = "/" + root; } if (!errorMessage) { - root = resolvePath(root); // TODO: verify that root exists and is a directory? if (!root) { errorMessage = "is empty or all-spaces"; } - root = "/" + root; } - if (errorMessage) { errorMessage = `Illegal root argument (or .root option attribute) '${root}': ${errorMessage}`; } @@ -222,6 +220,7 @@ exports.static = (rootOrOptions, options) => { throw Error(errorMessage); } + return function getStatic(request) { try { const { path, pathError } = getPathFromRequest(request, root, contextPathOverride); From cea5d59f15212634ec27199f0f300552c899a334 Mon Sep 17 00:00:00 2001 From: Espen Norderud Date: Fri, 5 Mar 2021 11:17:57 +0100 Subject: [PATCH 09/17] Remove double quotation handling --- .../resources/lib/enonic/static/etagReader.es6 | 4 +--- src/main/resources/lib/enonic/static/index.es6 | 17 ++++++----------- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/src/main/resources/lib/enonic/static/etagReader.es6 b/src/main/resources/lib/enonic/static/etagReader.es6 index 0171d54..e93a1d5 100644 --- a/src/main/resources/lib/enonic/static/etagReader.es6 +++ b/src/main/resources/lib/enonic/static/etagReader.es6 @@ -24,7 +24,5 @@ exports.read = (path, etagOverrideOption) => { throw Error(error); } - return (etag && etag[0] !== '"') - ? `"${etag}"` - : etag || undefined + return etag || undefined }; diff --git a/src/main/resources/lib/enonic/static/index.es6 b/src/main/resources/lib/enonic/static/index.es6 index 8bb66c7..0806f0e 100644 --- a/src/main/resources/lib/enonic/static/index.es6 +++ b/src/main/resources/lib/enonic/static/index.es6 @@ -21,17 +21,12 @@ const getEtagOr304 = (path, request, etagOverride) => { let etag = etagReader.read(path, etagOverride); let ifNoneMatch = (request.headers || {})['If-None-Match']; - if (ifNoneMatch) { - ifNoneMatch = (ifNoneMatch[0] !== '"') - ? `"${ifNoneMatch}"` - : ifNoneMatch; - if (ifNoneMatch === etag) { - return { - response304: { - status: 304 - } - }; - } + if (ifNoneMatch && ifNoneMatch === etag) { + return { + response304: { + status: 304 + } + }; } return { etag }; } From 3fc12fda9de8f153689a273b6cd5aef24086292d Mon Sep 17 00:00:00 2001 From: Espen Norderud Date: Fri, 5 Mar 2021 11:25:30 +0100 Subject: [PATCH 10/17] Commenting out syntax error in test (which is incomplete anyway) --- src/test/java/lib/enonic/libStatic/etag/EtagServiceTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/lib/enonic/libStatic/etag/EtagServiceTest.java b/src/test/java/lib/enonic/libStatic/etag/EtagServiceTest.java index 6003561..3a46bcf 100644 --- a/src/test/java/lib/enonic/libStatic/etag/EtagServiceTest.java +++ b/src/test/java/lib/enonic/libStatic/etag/EtagServiceTest.java @@ -42,7 +42,7 @@ public void setUp() { resourceServiceMock = Mockito.mock(ResourceService.class); Mockito.when(resourceServiceSupplierMock.get()).thenReturn(resourceServiceMock); - Mockito.when(resourceServiceMock.getResource( Mockito.any(ResourceKey.class) )).then + //Mockito.when(resourceServiceMock.getResource( Mockito.any(ResourceKey.class) )).then service = new EtagService(); service.initialize( newBeanContext(ResourceKey.from("myapplication:/test"))); From a49bc4c8826068fe359ec85e6984e89c6ada7224 Mon Sep 17 00:00:00 2001 From: rymsha Date: Fri, 5 Mar 2021 14:56:06 +0100 Subject: [PATCH 11/17] Fixed some tests Removed tests that don't verify lib-static functionality (caching ResourceService now does it) Commented out tests that won't run on existing XP Testing Tool (need to wait for XP 7.7 Testing Tool) --- src/test/java/EtagReaderTest.java | 3 + src/test/java/StaticTest.java | 3 + .../etag/ClassLoaderResourceService.java | 45 ++++++ .../libStatic/etag/EtagServiceTest.java | 133 +++--------------- .../lib/enonic/static/etagReader-test.es6 | 2 +- .../lib/enonic/static/options-test.es6 | 2 +- .../lib/enonic/static/static-test.es6 | 2 +- 7 files changed, 77 insertions(+), 113 deletions(-) create mode 100644 src/test/java/lib/enonic/libStatic/etag/ClassLoaderResourceService.java diff --git a/src/test/java/EtagReaderTest.java b/src/test/java/EtagReaderTest.java index 82df6ad..935fa5a 100644 --- a/src/test/java/EtagReaderTest.java +++ b/src/test/java/EtagReaderTest.java @@ -1,5 +1,8 @@ +import org.junit.Ignore; + import com.enonic.xp.testing.ScriptRunnerSupport; +@Ignore("Until XP 7.7 it does not work") public class EtagReaderTest extends ScriptRunnerSupport { diff --git a/src/test/java/StaticTest.java b/src/test/java/StaticTest.java index 7b50d84..f79dc00 100644 --- a/src/test/java/StaticTest.java +++ b/src/test/java/StaticTest.java @@ -1,5 +1,8 @@ +import org.junit.Ignore; + import com.enonic.xp.testing.ScriptRunnerSupport; +@Ignore("Until XP 7.7 it does not work") public class StaticTest extends ScriptRunnerSupport { diff --git a/src/test/java/lib/enonic/libStatic/etag/ClassLoaderResourceService.java b/src/test/java/lib/enonic/libStatic/etag/ClassLoaderResourceService.java new file mode 100644 index 0000000..b133a5d --- /dev/null +++ b/src/test/java/lib/enonic/libStatic/etag/ClassLoaderResourceService.java @@ -0,0 +1,45 @@ +package lib.enonic.libStatic.etag; + +import java.net.URL; + +import com.enonic.xp.app.ApplicationKey; +import com.enonic.xp.resource.Resource; +import com.enonic.xp.resource.ResourceKey; +import com.enonic.xp.resource.ResourceKeys; +import com.enonic.xp.resource.ResourceProcessor; +import com.enonic.xp.resource.ResourceService; +import com.enonic.xp.resource.UrlResource; + +/** + * Won't be needed when migrated to XP 7.7 Testing Tool + * ClassLoaderResourceService will support processResource method there + */ +public final class ClassLoaderResourceService + implements ResourceService +{ + private final ClassLoader loader; + + public ClassLoaderResourceService( final ClassLoader loader ) + { + this.loader = loader; + } + + @Override + public Resource getResource( final ResourceKey key ) + { + final URL url = this.loader.getResource( key.getPath().substring( 1 ) ); + return new UrlResource( key, url ); + } + + @Override + public ResourceKeys findFiles( final ApplicationKey key, final String pattern ) + { + throw new IllegalStateException( "Not implemented" ); + } + + @Override + public V processResource( final ResourceProcessor processor ) + { + return processor.process( getResource( processor.toResourceKey() ) ); + } +} diff --git a/src/test/java/lib/enonic/libStatic/etag/EtagServiceTest.java b/src/test/java/lib/enonic/libStatic/etag/EtagServiceTest.java index 3a46bcf..c0a0dee 100644 --- a/src/test/java/lib/enonic/libStatic/etag/EtagServiceTest.java +++ b/src/test/java/lib/enonic/libStatic/etag/EtagServiceTest.java @@ -1,28 +1,28 @@ package lib.enonic.libStatic.etag; -import com.enonic.xp.resource.ResourceKey; -import com.enonic.xp.resource.ResourceService; -import com.enonic.xp.testing.ScriptTestSupport; +import java.util.Map; +import java.util.function.Supplier; + import org.junit.Before; import org.junit.Test; import org.mockito.Mockito; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.Map; -import java.util.function.Supplier; +import com.enonic.xp.resource.ResourceKey; +import com.enonic.xp.resource.ResourceService; +import com.enonic.xp.testing.ScriptTestSupport; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; +import static org.junit.Assert.*; public class EtagServiceTest extends ScriptTestSupport { private final static Logger LOG = LoggerFactory.getLogger( EtagServiceTest.class ); private EtagService service; + private final Supplier resourceServiceSupplierMock = + () -> new ClassLoaderResourceService( EtagService.class.getClassLoader() ); - private Supplier resourceServiceSupplierMock; - private ResourceService resourceServiceMock; private String getETag(Map result) { return result.get(lib.enonic.libStatic.etag.EtagService.ETAG_KEY); @@ -38,12 +38,6 @@ protected void initialize() throws Exception { @Before public void setUp() { - resourceServiceSupplierMock = Mockito.mock(Supplier.class); - resourceServiceMock = Mockito.mock(ResourceService.class); - - Mockito.when(resourceServiceSupplierMock.get()).thenReturn(resourceServiceMock); - //Mockito.when(resourceServiceMock.getResource( Mockito.any(ResourceKey.class) )).then - service = new EtagService(); service.initialize( newBeanContext(ResourceKey.from("myapplication:/test"))); @@ -54,7 +48,7 @@ public void setUp() { public void testGetEtag_defaultETag_prod_EtagExpected() { service.isDev = false; Map result = service.getEtag("myapplication:static/static-test-text.txt", 0); - assertEquals(null, getError(result)); + assertNull( getError( result ) ); assertTrue(getETag(result).trim().length() > 0); //, "The returned ETag should not be empty"); } @@ -62,8 +56,8 @@ public void testGetEtag_defaultETag_prod_EtagExpected() { public void testGetEtag_defaultETag_dev_noEtagExpected() { service.isDev = true; Map result = service.getEtag("myapplication:static/static-test-text.txt", 0); - assertEquals(null, getError(result)); - assertEquals(null, getETag(result)); + assertNull( getError( result ) ); + assertNull( getETag( result ) ); } @@ -71,7 +65,7 @@ public void testGetEtag_defaultETag_dev_noEtagExpected() { public void testGetEtag_positiveETagOverride_prod_EtagExpected() { service.isDev = false; Map result = service.getEtag("myapplication:static/static-test-text.txt", 1); - assertEquals(null, getError(result)); + assertNull( getError( result ) ); assertTrue(getETag(result).length() > 0); // "Positive etagOverride, so the ETag should be returned"); } @@ -79,7 +73,7 @@ public void testGetEtag_positiveETagOverride_prod_EtagExpected() { public void testGetEtag_positiveETagOverride_dev_EtagExpected() { service.isDev = true; Map result = service.getEtag("myapplication:static/static-test-text.txt", 1); - assertEquals(null, getError(result)); + assertNull( getError( result ) ); assertTrue(getETag(result).length() > 0);//, "Positive etagOverride, so EVEN IN DEV the ETag should be returned"); } @@ -88,113 +82,32 @@ public void testGetEtag_positiveETagOverride_dev_EtagExpected() { public void testGetEtag_negativeETagOverride_prod_noEtagOrErrorExpected() { service.isDev = false; Map result = service.getEtag("myapplication:static/static-test-text.txt", -1); - assertEquals(null, getError(result)); - assertEquals(null, getETag(result)); //, "Negative etagOverride, so EVEN IN PROD the ETag should be skipped"); + assertNull( getError( result ) ); + assertNull( getETag( result ) ); //, "Negative etagOverride, so EVEN IN PROD the ETag should be skipped"); } @Test public void testGetEtag_negativeETagOverride_dev_noEtagOrErrorExpected() { service.isDev = true; Map result = service.getEtag("myapplication:static/static-test-text.txt", -1); - assertEquals(null, getError(result)); - assertEquals(null, getETag(result)); //, "Negative etagOverride, so no ETag"); + assertNull( getError( result ) ); + assertNull( getETag( result ) ); //, "Negative etagOverride, so no ETag"); } - @Test - public void testGetEtag_defaultETag_prod_shouldSaveTimeBecauseReadFirstCacheSubsequent() { - service.isDev = false; - Long t0 = System.nanoTime(); - Map result1 = service.getEtag("myapplication:static/hugh.jazzit.blob", 0); - Long t1 = System.nanoTime(); - Map result2 = service.getEtag("myapplication:static/hugh.jazzit.blob", 0); - Map result3 = service.getEtag("myapplication:static/hugh.jazzit.blob", 0); - Map result4 = service.getEtag("myapplication:static/hugh.jazzit.blob", 0); - Map result5 = service.getEtag("myapplication:static/hugh.jazzit.blob", 0); - Map result6 = service.getEtag("myapplication:static/hugh.jazzit.blob", 0); - Long t6 = System.nanoTime(); - - // LOG.info("etag: " + getETag(result1)); - - // All results should have an etag and no error - assertEquals(null, getError(result1)); - assertEquals(null, getError(result2)); - assertEquals(null, getError(result3)); - assertEquals(null, getError(result4)); - assertEquals(null, getError(result5)); - assertEquals(null, getError(result6)); - assertTrue(getETag(result1).length() > 0); - assertTrue(getETag(result2).length() > 0); - assertTrue(getETag(result3).length() > 0); - assertTrue(getETag(result4).length() > 0); - assertTrue(getETag(result5).length() > 0); - assertTrue(getETag(result6).length() > 0); - - Long delta1 = t1-t0; - float avgDelta6 = (t6-t1)/5f; - - // LOG.info("delta1: " + delta1); - // LOG.info("avgDelta6: " + avgDelta6); - - // Expecting the subsequent calls to be on average much much faster - assertTrue(delta1 / avgDelta6 > 100); - } - - @Test - public void testGetEtag_positiveETag_dev_shouldSaveTimeBecauseReadFirstCacheSubsequent() { - service.isDev = true; - Long t0 = System.nanoTime(); - Map result1 = service.getEtag("myapplication:static/hugh.jazzit.blob", 1); - Long t1 = System.nanoTime(); - Map result2 = service.getEtag("myapplication:static/hugh.jazzit.blob", 1); - Map result3 = service.getEtag("myapplication:static/hugh.jazzit.blob", 1); - Map result4 = service.getEtag("myapplication:static/hugh.jazzit.blob", 1); - Map result5 = service.getEtag("myapplication:static/hugh.jazzit.blob", 1); - Map result6 = service.getEtag("myapplication:static/hugh.jazzit.blob", 1); - Long t6 = System.nanoTime(); - - // LOG.info("etag: " + getETag(result1)); - - // All results should have an etag and no error - assertEquals(null, getError(result1)); - assertEquals(null, getError(result2)); - assertEquals(null, getError(result3)); - assertEquals(null, getError(result4)); - assertEquals(null, getError(result5)); - assertEquals(null, getError(result6)); - assertTrue(getETag(result1).length() > 0); - assertTrue(getETag(result2).length() > 0); - assertTrue(getETag(result3).length() > 0); - assertTrue(getETag(result4).length() > 0); - assertTrue(getETag(result5).length() > 0); - assertTrue(getETag(result6).length() > 0); - - Long delta1 = t1-t0; - float avgDelta6 = (t6-t1)/5f; - - // LOG.info("delta1: " + delta1); - // LOG.info("avgDelta6: " + avgDelta6); - - // Expecting the subsequent calls to be on average much much faster - assertTrue(delta1 / avgDelta6 > 100); - } - - - - //////////////////////////////////////////// Error handling // 500 @Test public void testGetEtag_exceptions_shouldReturnMessageUnderErrorKey() { - Mockito.when(resourceServiceMock.getResource( Mockito.any(ResourceKey.class) )).thenThrow(new RuntimeException("Catch-and-return")); - - service.resourceServiceSupplier = resourceServiceSupplierMock; + service.resourceServiceSupplier = () -> Mockito.mock( ResourceService.class, invocation -> { + throw new RuntimeException( "Catch-and-return" ); + } ); Map result = service.getEtag("myapplication:static/hugh.jazzit.blob", 0); - assertEquals("Catch-and-return", getError(result)); - assertEquals(null, getETag(result)); + assertNotNull( getError( result ) ); + assertNull( getETag( result ) ); } } diff --git a/src/test/resources/lib/enonic/static/etagReader-test.es6 b/src/test/resources/lib/enonic/static/etagReader-test.es6 index c323754..f8acd17 100644 --- a/src/test/resources/lib/enonic/static/etagReader-test.es6 +++ b/src/test/resources/lib/enonic/static/etagReader-test.es6 @@ -1,7 +1,7 @@ const lib = require('/lib/enonic/static/etagReader'); const t = require('/lib/xp/testing'); -const ioLib = require('/lib/xp/io'); +const ioLib = require('/lib/enonic/static/io'); // HELPERS diff --git a/src/test/resources/lib/enonic/static/options-test.es6 b/src/test/resources/lib/enonic/static/options-test.es6 index 68f90f4..fbc2cc2 100644 --- a/src/test/resources/lib/enonic/static/options-test.es6 +++ b/src/test/resources/lib/enonic/static/options-test.es6 @@ -1,4 +1,4 @@ -const ioLib = require('/lib/xp/io'); +const ioLib = require('/lib/enonic/static/io'); const lib = require('/lib/enonic/static/options'); const t = require('/lib/xp/testing'); diff --git a/src/test/resources/lib/enonic/static/static-test.es6 b/src/test/resources/lib/enonic/static/static-test.es6 index 5f1c6dd..10ce490 100644 --- a/src/test/resources/lib/enonic/static/static-test.es6 +++ b/src/test/resources/lib/enonic/static/static-test.es6 @@ -1,4 +1,4 @@ -const ioLib = require('/lib/xp/io'); +const ioLib = require('/lib/enonic/static/io'); const lib = require('./index'); const t = require('/lib/xp/testing'); From 04b1c80daf504a80c3af36a98ad760db6bc51864 Mon Sep 17 00:00:00 2001 From: Espen Norderud Date: Mon, 8 Mar 2021 11:16:23 +0100 Subject: [PATCH 12/17] #44: Preventing keys with undefined values in response header. --- .../resources/lib/enonic/static/index.es6 | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/main/resources/lib/enonic/static/index.es6 b/src/main/resources/lib/enonic/static/index.es6 index 0806f0e..8ac352a 100644 --- a/src/main/resources/lib/enonic/static/index.es6 +++ b/src/main/resources/lib/enonic/static/index.es6 @@ -5,14 +5,24 @@ const ioLib = require('/lib/enonic/static/io'); const getResponse200 = (path, resource, contentTypeFunc, cacheControlFunc, etag) => { const contentType = contentTypeFunc(path, resource); + const cacheControlHeader = cacheControlFunc(path, resource, contentType); + + // Preventing any keys at all with null/undefined values in header (since those cause NPE): + const headers = {}; + if (cacheControlHeader) { + headers['Cache-Control'] = cacheControlHeader; + } + if (etag) { + headers.ETag = etag; + } + + + return { status: 200, body: resource.getStream(), contentType, - headers: { - 'Cache-Control': cacheControlFunc(path, resource, contentType), - 'ETag': etag - } + headers }; }; @@ -147,8 +157,8 @@ const resolvePath = (path) => { const getPathFromRequest = (request, root, contextPathOverride) => { const removePrefix = contextPathOverride || request.contextPath || '** contextPath (contextPathOverride) IS MISSING IN BOTH REQUEST AND OPTIONS **'; - if (request.path.startsWith(removePrefix)) { - const relativePath = decodeURI(request.path) + if (request.rawPath.startsWith(removePrefix)) { + const relativePath = request.rawPath .trim() .substring(removePrefix.length) .replace(/^\/+/, ''); @@ -165,7 +175,7 @@ const getPathFromRequest = (request, root, contextPathOverride) => { } // 500-type error - throw Error(`options.contextPathOverride || request.contextPath = '${removePrefix}'. Expected that to be the prefix of request.path '${request.path}'. Add or correct options.contextPathOverride so that it matches the request.path URI root (which is removed from request.path to create the relative asset path).`); + throw Error(`options.contextPathOverride || request.contextPath = '${removePrefix}'. Expected that to be the prefix of request.rawPath '${request.rawPath}'. Add or correct options.contextPathOverride so that it matches the request.rawPath URI root (which is removed from request.rawPath to create the relative asset path).`); } From a9b7bd47f68038148875ebf8afbc2d70934cd832 Mon Sep 17 00:00:00 2001 From: Espen Norderud Date: Thu, 11 Mar 2021 19:48:16 +0100 Subject: [PATCH 13/17] #45: In prod mode, hide (swallow) 400 and 404 error messages. Only output them in XP dev mode. --- .../resources/lib/enonic/static/index.es6 | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/src/main/resources/lib/enonic/static/index.es6 b/src/main/resources/lib/enonic/static/index.es6 index 8ac352a..df79ed4 100644 --- a/src/main/resources/lib/enonic/static/index.es6 +++ b/src/main/resources/lib/enonic/static/index.es6 @@ -2,6 +2,7 @@ const etagReader = require('/lib/enonic/static/etagReader'); const optionsParser = require('/lib/enonic/static/options'); const ioLib = require('/lib/enonic/static/io'); +var IS_DEV = Java.type('com.enonic.xp.server.RunMode').get().toString() !== 'PROD'; const getResponse200 = (path, resource, contentTypeFunc, cacheControlFunc, etag) => { const contentType = contentTypeFunc(path, resource); @@ -16,8 +17,6 @@ const getResponse200 = (path, resource, contentTypeFunc, cacheControlFunc, etag) headers.ETag = etag; } - - return { status: 200, body: resource.getStream(), @@ -76,23 +75,32 @@ const errorLogAndResponse500 = (e, throwErrors, stringOrOptions, options, method const getResourceOr400 = (path, pathError) => { if (pathError) { + // TODO: In prod mode, the pathError will just be swallowed. Log it? return { - response400: { - status: 400, - body: pathError, - contentType: 'text/plain; charset=utf-8' - } + response400: (IS_DEV) + ? { + status: 400, + body: pathError, + contentType: 'text/plain; charset=utf-8' + } + : { + status: 400, + } }; } const resource = ioLib.getResource(path); if (!resource.exists()) { return { - response400: { - status: 404, - body: `Not found: ${path}`, - contentType: 'text/plain; charset=utf-8' - } + response400: (IS_DEV) + ? { + status: 404, + body: `Not found: ${path}`, + contentType: 'text/plain; charset=utf-8' + } + : { + status: 404 + } } } @@ -196,6 +204,8 @@ exports.getPathError = getPathError; exports.static = (rootOrOptions, options) => { + + let { root, cacheControlFunc, From 2822143dc5c9125d2fde915604847a9e8e7fc228 Mon Sep 17 00:00:00 2001 From: Espen Norderud Date: Fri, 12 Mar 2021 13:43:38 +0100 Subject: [PATCH 14/17] #43: getCleanPath instead of contextPathOverride and prefix --- README.md | 32 ++++-- .../resources/lib/enonic/static/index.es6 | 99 +++++++++++-------- .../resources/lib/enonic/static/options.es6 | 12 ++- 3 files changed, 90 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 81f797d..76d1ce1 100644 --- a/README.md +++ b/README.md @@ -113,14 +113,7 @@ exports.get = (request) => { The path to the actual asset is determined by the URL path (`request.path`). This relative to the access URL of the controller itself. - -NOTE: It's recommended to use `.static` in an XP [service controller](https://developer.enonic.com/docs/xp/stable/runtime/engines/http-service). Here, routing is included, the endpoint's rootpath is already in `request.contextPath`, and relative asset path is automatically determined. However, if you use `.static` in a non-service controller, you must supply the endpoint's rootpath in options, as `contextPathOverride`! - -For example, if _getAnyStatic.es6_ is accessed with a [controller mapping](https://developer.enonic.com/docs/xp/stable/cms/mappings) at `https://someDomain.com/resources/public`, then that's an endpoint with the rootpath `resources/public`. Since this is not a service, we add the contextPathOverride option: - -`const getStatic = libStatic.static('my-resources', {contextPathOverride: 'resources/public'});` - -Now the URL `https://someDomain.com/resources/public/subfolder/target-resource.xml` will return the static resource _/my-resources/subfolder/target-resource.xml_ from the JAR (a.k.a. _build/resources/main/my-resources/subfolder/target-resource.xml_ in dev mode). +NOTE: It's recommended to use `.static` in an [XP service controller](https://developer.enonic.com/docs/xp/stable/runtime/engines/http-service). Here, routing is included and easy to handle: the endpoint's standard root path is already in `request.contextPath`, and the asset path is automatically determined relative to that. If you use `.static` in a context where the asset path (relative to `root`) can't be determined by simply subtracting `request.contextPath` from the beginning of `request.rawPath`, you should add a `getCleanPath` [option parameter](#options). Same example as above, but simplified and without options: ``` @@ -229,7 +222,7 @@ NOTE: mutable assets should not be served with this header! See [below](#mutable As described above, an object can be added with optional attributes to **override** the [default behaviour](#behaviour): ``` -{ cacheControl, contentType, etag, throwErrors, contextPathOverride } +{ cacheControl, contentType, etag, getCleanPath, throwErrors } ``` ### Params: @@ -246,10 +239,29 @@ As described above, an object can be added with optional attributes to **overrid - `etag` (boolean, optional): The default behavior of lib-static is to generate/handle ETag in prod, while skipping it entirely in dev mode. - Setting the etag parameter to `false` will turn **off** etag processing (runtime content processing, headers and handling) in **prod** too. - Setting it to `true` will turn it **on in dev mode** too. +- `getCleanPath` (function, optional): Only used in [.static](#api-static). The default behavior of the returned `getStatic` function is to take a request object, and compare the beginning of the current requested path (`request.rawPath`) to the endpoint's own root path (`request.contextPath`) and get a relative asset path below `root` (so that later, prefixing the `root` value to that relative path will give the absolute full path to the resource in the JAR). In cases where this default behavior is not enough, you can override it by adding a `getCleanPath` param: `(request) => 'resource/path/below/root'`. Emphasis: the returned 'clean' path from this function should be _relative to the `root` folder_, not an absolute path in the JAR. + - **For example:** if _getAnyStatic.es6_ is accessed with a [controller mapping](https://developer.enonic.com/docs/xp/stable/cms/mappings) at `https://someDomain.com/resources/public`, then that's an endpoint with the path `resources/public` - but that can't be determined from the request. So the automatic extraction of a relative path needs a `getCleanPath` override. Very simplified here: + ``` + const getStatic = libStatic.static( + 'my-resources', + { + getCleanPath: (request) => { + if (!resource.rawPath.startsWith('resources/public') { throw Error('Ooops'); } + return resource.rawPath.substring('resources/public'.length); + } + } + ); + ``` + - Now, since `request.rawPath` doesn't include the protocol or domain, the URL `https://someDomain.com/resources/public/subfolder/target-resource.xml` will make `getCleanPath` return `/subfolder/target-resource.xml`, which together with `root` will look up the resource _/my-resources/subfolder/target-resource.xml_ in the JAR (a.k.a. _build/resources/main/my-resources/subfolder/target-resource.xml_ in dev mode). - `throwErrors` (boolean, default is `false`): by default, the `.get` method should not throw errors when used correctly. Instead, it internally server-logs (and hash-ID-tags) errors and automatically outputs a 500 error response. - Setting `throwErrors` to `true` overrides this: the 500-response generation is skipped, and the error is re-thrown down to the calling context, to be handled there. - This does not apply to 404-not-found type "errors", they will always generate a 404-response either way. -- `contextPathOverride`: Only used in [.static](#api-static). The default behavior of the returned `getStatic` function is to take a request object, and compare the current path to the endpoint's rootpath to get a relative asset path (below `root` in the JAR). This is done by looking at `request.contextPath` and removing that from `request.path`. However, contextPath only works well in an XP service - if you want to use `static` elsewhere, for example with a controller mapping, you need to supply the endpoint's root path to `contextPathOverride`. + + + + + + In addition, you may supply a `path` or `root` param ([.get](#api-get) or [.static](#api-static), respectively). If a positional `path` or `root` argument is used and the options object is the second argument, then `path` or `root` parameters will be ignored in the options object. diff --git a/src/main/resources/lib/enonic/static/index.es6 b/src/main/resources/lib/enonic/static/index.es6 index df79ed4..86de590 100644 --- a/src/main/resources/lib/enonic/static/index.es6 +++ b/src/main/resources/lib/enonic/static/index.es6 @@ -63,7 +63,7 @@ const errorLogAndResponse500 = (e, throwErrors, stringOrOptions, options, method return { status: 500, contentType: "text/plain; charset=utf-8", - body: `Server error, logged with error ID: ${errorID}` + body: `Server error (logged with error ID: ${errorID})` } } else { @@ -89,6 +89,13 @@ const getResourceOr400 = (path, pathError) => { }; } + log.info("Looking for resourc path... (" + + (Array.isArray(path) ? + ("array[" + path.length + "]") : + (typeof path + (path && typeof path === 'object' ? (" with keys: " + JSON.stringify(Object.keys(path))) : "")) + ) + "): " + JSON.stringify(path, null, 2) + ); + const resource = ioLib.getResource(path); if (!resource.exists()) { return { @@ -161,29 +168,24 @@ const resolvePath = (path) => { return rootArr.join('/').trim(); } -/* .static helper: creates a path from the request, and prefixes the root */ -const getPathFromRequest = (request, root, contextPathOverride) => { - const removePrefix = contextPathOverride || request.contextPath || '** contextPath (contextPathOverride) IS MISSING IN BOTH REQUEST AND OPTIONS **'; - - if (request.rawPath.startsWith(removePrefix)) { - const relativePath = request.rawPath - .trim() - .substring(removePrefix.length) - .replace(/^\/+/, ''); +/* .static helper: creates a resource path from the request, relative to the root folder (which will be prefixed later). +* Overridable with the getCleanPath option param. */ +const getRelativeResourcePath = (request) => { + if (!(request || {}).rawPath) { + throw Error(`Can't resolve relative asset path - request doesn't have a .rawPath attribute: ` + JSON.stringify(request)); + } - const error = getPathError(relativePath); + const removePrefix = request.contextPath.trim() || '** missing or falsy **'; - return (error) - ? { - pathError: `Illegal relative resource path '${relativePath}': ${error}` // 400-type error - } - : { - path: `${root}/${relativePath}` - }; + if (!request.rawPath.startsWith(removePrefix)) { + // Gives 500-type error + throw Error(`Can't resolve relative asset path: request.contextPath (${JSON.stringify(request.contextPath)}) was expected to be a non-empty prefix of request.rawPath (${JSON.stringify(request.rawPath)}). You may need to supply a getCleanPath(request) function parameter to extract a relative asset path from the request.`); } - // 500-type error - throw Error(`options.contextPathOverride || request.contextPath = '${removePrefix}'. Expected that to be the prefix of request.rawPath '${request.rawPath}'. Add or correct options.contextPathOverride so that it matches the request.rawPath URI root (which is removed from request.rawPath to create the relative asset path).`); + return request.rawPath + .trim() + .substring(removePrefix.length) + .replace(/^\/+/, ''); } @@ -203,54 +205,69 @@ const getPathError = (trimmedPathString) => { exports.getPathError = getPathError; -exports.static = (rootOrOptions, options) => { +const resolveRoot = (root) => { + let resolvedRoot = resolvePath(root.replace(/^\/+/, '').replace(/\/+$/, '')); + let errorMessage = getPathError(resolvedRoot); + resolvedRoot = "/" + resolvedRoot; + + if (!errorMessage) { + // TODO: verify that root exists and is a directory? + if (!resolvedRoot) { + errorMessage = "is empty or all-spaces"; + } + } + + if (errorMessage) { + throw Error(`Illegal root argument (or .root option attribute) ${JSON.stringify(root)}: ${errorMessage}`); + } + + return resolvedRoot; +}; +exports.static = (rootOrOptions, options) => { let { root, cacheControlFunc, contentTypeFunc, etagOverride, - contextPathOverride, + getCleanPath, throwErrors, errorMessage } = optionsParser.parseRootAndOptions(rootOrOptions, options); - if (!errorMessage) { - root = resolvePath(root.replace(/^\/+/, '')); - errorMessage = getPathError(root); - root = "/" + root; - } - if (!errorMessage) { - // TODO: verify that root exists and is a directory? - if (!root) { - errorMessage = "is empty or all-spaces"; - } - } - if (errorMessage) { - errorMessage = `Illegal root argument (or .root option attribute) '${root}': ${errorMessage}`; - } - if (errorMessage) { throw Error(errorMessage); } + root = resolveRoot(root, errorMessage); + + // Allow option override of the function that gets the relative resource path from the request + const getRelativePathFunc = getCleanPath || getRelativeResourcePath; return function getStatic(request) { try { - const { path, pathError } = getPathFromRequest(request, root, contextPathOverride); + const relativePath = getRelativePathFunc(request); + + const error = getPathError(relativePath); + const pathError = (error) + ? `Illegal relative resource path '${relativePath}': ${error}` // 400-type error + : error; + + + const absolutePath = `${root}/${relativePath}`; - const { resource, response400 } = getResourceOr400(path, pathError); + const { resource, response400 } = getResourceOr400(absolutePath, pathError); if (response400) { return response400; } - const { etag, response304 } = getEtagOr304(path, request, etagOverride); + const { etag, response304 } = getEtagOr304(absolutePath, request, etagOverride); if (response304) { return response304 } - return getResponse200(path, resource, contentTypeFunc, cacheControlFunc, etag); + return getResponse200(absolutePath, resource, contentTypeFunc, cacheControlFunc, etag); } catch (e) { return errorLogAndResponse500(e, throwErrors, rootOrOptions, options, "static", "Root"); diff --git a/src/main/resources/lib/enonic/static/options.es6 b/src/main/resources/lib/enonic/static/options.es6 index 4ac1a0c..9e4fb86 100644 --- a/src/main/resources/lib/enonic/static/options.es6 +++ b/src/main/resources/lib/enonic/static/options.es6 @@ -179,19 +179,23 @@ const parseStringAndOptions = (stringOrOptions, options, attributeKey) => { cacheControl, contentType, etag, - contextPathOverride + getCleanPath } = useOptions; const cacheControlFunc = getCacheControlFunc(cacheControl); const contentTypeFunc = getContentTypeFunc(contentType); + if (getCleanPath !== undefined && typeof getCleanPath !== 'function') { + throw Error(`Unexpected type of 'getCleanPath' parameter: '${typeof getCleanPath}'. Expected a function. getCleanPath is: ${JSON.stringify(getCleanPath)}`); + } + verifyEtagOption(etag); const output = { cacheControlFunc, contentTypeFunc, throwErrors, - contextPathOverride, + getCleanPath, etagOverride: etag }; output[attributeKey] = pathOrRoot; @@ -228,6 +232,7 @@ const parseStringAndOptions = (stringOrOptions, options, attributeKey) => { * @param options {{ * contentType: (string|boolean|object|function(path, resource): string)?, * cacheControl: (string|boolean|function(path, resource, mimeType): string)?, + * getCleanPath: (function: string)?, * etag: (boolean?), * throwErrors: (boolean?) * }} Options object (only applies if pathOrOptions is a string). Any path string here will be ignored. @@ -237,6 +242,7 @@ const parseStringAndOptions = (stringOrOptions, options, attributeKey) => { * etagOverride: (boolean?), * cacheControlFunc: (function(path, resource, mimeType): string), * contentTypeFunc: (function(path, resource): string), + * getCleanPath: (function: string)?, * throwErrors: (boolean) * } | { * errorMessage: string, @@ -263,6 +269,7 @@ exports.parsePathAndOptions = (pathOrOptions, options) => * @param options {{ * contentType: (string|boolean|object|function(path, resource): string)?, * cacheControl: (string|boolean|function(path, resource, mimeType): string)?, + * getCleanPath: (function: string)?, * etag: (boolean?), * throwErrors: (boolean?) * }} Options object (only applies if rootOrOptions is a string). Any root string here will be ignored. NOTE: the contentType and cacheControl functions should take full path params for each individual resource, not just root. The same applies to the returned cacheControlFunc and contentTypeFunc - exactly like with index.js.get. @@ -272,6 +279,7 @@ exports.parsePathAndOptions = (pathOrOptions, options) => * etagOverride: (boolean?), * cacheControlFunc: (function(path, resource, mimeType): string), * contentTypeFunc: (function(path, resource): string), + * getCleanPath: (function: string)?, * throwErrors: (boolean) * } | { * errorMessage: string, From 389462090a565e40d57adc93b85b3eca64847d10 Mon Sep 17 00:00:00 2001 From: Espen Norderud Date: Fri, 12 Mar 2021 13:45:39 +0100 Subject: [PATCH 15/17] #43: Cleanup, removing redundant logs and comments --- src/main/resources/lib/enonic/static/index.es6 | 7 ------- src/main/resources/lib/enonic/static/options.es6 | 11 ----------- 2 files changed, 18 deletions(-) diff --git a/src/main/resources/lib/enonic/static/index.es6 b/src/main/resources/lib/enonic/static/index.es6 index 86de590..8698c9a 100644 --- a/src/main/resources/lib/enonic/static/index.es6 +++ b/src/main/resources/lib/enonic/static/index.es6 @@ -89,13 +89,6 @@ const getResourceOr400 = (path, pathError) => { }; } - log.info("Looking for resourc path... (" + - (Array.isArray(path) ? - ("array[" + path.length + "]") : - (typeof path + (path && typeof path === 'object' ? (" with keys: " + JSON.stringify(Object.keys(path))) : "")) - ) + "): " + JSON.stringify(path, null, 2) - ); - const resource = ioLib.getResource(path); if (!resource.exists()) { return { diff --git a/src/main/resources/lib/enonic/static/options.es6 b/src/main/resources/lib/enonic/static/options.es6 index 9e4fb86..0c74397 100644 --- a/src/main/resources/lib/enonic/static/options.es6 +++ b/src/main/resources/lib/enonic/static/options.es6 @@ -42,17 +42,6 @@ const verifyAndTrimPathOrRoot = (pathOrRoot, label) => { * @param cacheControl (string, boolean or function). See README for how the cacheControl option works. * */ const getCacheControlFunc = (cacheControl) => { - /* - if (cacheControl || cacheControl === false || cacheControl === '') { - log.info("cacheControl (" + - (Array.isArray(cacheControl) ? - ("array[" + cacheControl.length + "]") : - (typeof cacheControl + (cacheControl && typeof cacheControl === 'object' ? (" with keys: " + JSON.stringify(Object.keys(cacheControl))) : "")) - ) + "): " + JSON.stringify(cacheControl, null, 2) - ); - } - //*/ - if (cacheControl === false || cacheControl === '') { // Override: explicitly switch off with false or empty string return () => undefined; From 8acaf994ad6eed3bb339da01391ae21893713134 Mon Sep 17 00:00:00 2001 From: Espen Norderud Date: Mon, 15 Mar 2021 20:22:35 +0100 Subject: [PATCH 16/17] Fixed bug in the path parsing of .get --- README.md | 2 +- src/main/resources/lib/enonic/static/index.es6 | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 76d1ce1..4c492ea 100644 --- a/README.md +++ b/README.md @@ -255,7 +255,7 @@ As described above, an object can be added with optional attributes to **overrid - Now, since `request.rawPath` doesn't include the protocol or domain, the URL `https://someDomain.com/resources/public/subfolder/target-resource.xml` will make `getCleanPath` return `/subfolder/target-resource.xml`, which together with `root` will look up the resource _/my-resources/subfolder/target-resource.xml_ in the JAR (a.k.a. _build/resources/main/my-resources/subfolder/target-resource.xml_ in dev mode). - `throwErrors` (boolean, default is `false`): by default, the `.get` method should not throw errors when used correctly. Instead, it internally server-logs (and hash-ID-tags) errors and automatically outputs a 500 error response. - Setting `throwErrors` to `true` overrides this: the 500-response generation is skipped, and the error is re-thrown down to the calling context, to be handled there. - - This does not apply to 404-not-found type "errors", they will always generate a 404-response either way. + - This does not apply to 404-not-found type "errors", they will always generate a 404-response either way. diff --git a/src/main/resources/lib/enonic/static/index.es6 b/src/main/resources/lib/enonic/static/index.es6 index 8698c9a..19a285c 100644 --- a/src/main/resources/lib/enonic/static/index.es6 +++ b/src/main/resources/lib/enonic/static/index.es6 @@ -75,7 +75,9 @@ const errorLogAndResponse500 = (e, throwErrors, stringOrOptions, options, method const getResourceOr400 = (path, pathError) => { if (pathError) { - // TODO: In prod mode, the pathError will just be swallowed. Log it? + if (!IS_DEV) { + log.warning(pathError); + } return { response400: (IS_DEV) ? { @@ -91,6 +93,9 @@ const getResourceOr400 = (path, pathError) => { const resource = ioLib.getResource(path); if (!resource.exists()) { + if (!IS_DEV) { + log.warning(`Not found: ${path}`); + } return { response400: (IS_DEV) ? { @@ -131,12 +136,13 @@ exports.get = (pathOrOptions, options) => { path = path.replace(/^\/+/, ''); const pathError = getPathError(path); + path = `/${path}`; + const { resource, response400 } = getResourceOr400(path, pathError ? `Resource path '${path}' ${pathError}` : undefined); if (response400) { return response400; } - path = `/${path}`; const etag = etagReader.read(path, etagOverride); From 11155f8063906d343e9624fe3cc41e5dbc2f1399 Mon Sep 17 00:00:00 2001 From: rymsha Date: Tue, 16 Mar 2021 14:37:05 +0100 Subject: [PATCH 17/17] Restore use of testing tool #50 --- build.gradle | 2 +- gradle.properties | 2 +- src/test/java/EtagReaderTest.java | 3 -- src/test/java/StaticTest.java | 3 -- .../etag/ClassLoaderResourceService.java | 45 ------------------- .../libStatic/etag/EtagServiceTest.java | 1 + 6 files changed, 3 insertions(+), 53 deletions(-) delete mode 100644 src/test/java/lib/enonic/libStatic/etag/ClassLoaderResourceService.java diff --git a/build.gradle b/build.gradle index fa700ee..0191013 100644 --- a/build.gradle +++ b/build.gradle @@ -20,7 +20,7 @@ dependencies { repositories { jcenter() - xp.enonicRepo() + xp.enonicRepo('dev') } // This task takes care of es6 under src/main/resources/site. You can replace it with build steps of your own if you want. diff --git a/gradle.properties b/gradle.properties index 87b4744..09997f5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ group = com.enonic.lib projectName = lib-static -xpVersion = 7.5.0 +xpVersion = 7.7.0-SNAPSHOT version=0.0.1-SNAPSHOT diff --git a/src/test/java/EtagReaderTest.java b/src/test/java/EtagReaderTest.java index 935fa5a..82df6ad 100644 --- a/src/test/java/EtagReaderTest.java +++ b/src/test/java/EtagReaderTest.java @@ -1,8 +1,5 @@ -import org.junit.Ignore; - import com.enonic.xp.testing.ScriptRunnerSupport; -@Ignore("Until XP 7.7 it does not work") public class EtagReaderTest extends ScriptRunnerSupport { diff --git a/src/test/java/StaticTest.java b/src/test/java/StaticTest.java index f79dc00..7b50d84 100644 --- a/src/test/java/StaticTest.java +++ b/src/test/java/StaticTest.java @@ -1,8 +1,5 @@ -import org.junit.Ignore; - import com.enonic.xp.testing.ScriptRunnerSupport; -@Ignore("Until XP 7.7 it does not work") public class StaticTest extends ScriptRunnerSupport { diff --git a/src/test/java/lib/enonic/libStatic/etag/ClassLoaderResourceService.java b/src/test/java/lib/enonic/libStatic/etag/ClassLoaderResourceService.java deleted file mode 100644 index b133a5d..0000000 --- a/src/test/java/lib/enonic/libStatic/etag/ClassLoaderResourceService.java +++ /dev/null @@ -1,45 +0,0 @@ -package lib.enonic.libStatic.etag; - -import java.net.URL; - -import com.enonic.xp.app.ApplicationKey; -import com.enonic.xp.resource.Resource; -import com.enonic.xp.resource.ResourceKey; -import com.enonic.xp.resource.ResourceKeys; -import com.enonic.xp.resource.ResourceProcessor; -import com.enonic.xp.resource.ResourceService; -import com.enonic.xp.resource.UrlResource; - -/** - * Won't be needed when migrated to XP 7.7 Testing Tool - * ClassLoaderResourceService will support processResource method there - */ -public final class ClassLoaderResourceService - implements ResourceService -{ - private final ClassLoader loader; - - public ClassLoaderResourceService( final ClassLoader loader ) - { - this.loader = loader; - } - - @Override - public Resource getResource( final ResourceKey key ) - { - final URL url = this.loader.getResource( key.getPath().substring( 1 ) ); - return new UrlResource( key, url ); - } - - @Override - public ResourceKeys findFiles( final ApplicationKey key, final String pattern ) - { - throw new IllegalStateException( "Not implemented" ); - } - - @Override - public V processResource( final ResourceProcessor processor ) - { - return processor.process( getResource( processor.toResourceKey() ) ); - } -} diff --git a/src/test/java/lib/enonic/libStatic/etag/EtagServiceTest.java b/src/test/java/lib/enonic/libStatic/etag/EtagServiceTest.java index c0a0dee..9216f50 100644 --- a/src/test/java/lib/enonic/libStatic/etag/EtagServiceTest.java +++ b/src/test/java/lib/enonic/libStatic/etag/EtagServiceTest.java @@ -12,6 +12,7 @@ import com.enonic.xp.resource.ResourceKey; import com.enonic.xp.resource.ResourceService; import com.enonic.xp.testing.ScriptTestSupport; +import com.enonic.xp.testing.resource.ClassLoaderResourceService; import static org.junit.Assert.*;