From d304e6e69de056e9c73ac8b295487cd59625c390 Mon Sep 17 00:00:00 2001 From: Saimon8420 Date: Sat, 1 Mar 2025 14:13:26 +0600 Subject: [PATCH] all requirement added --- .env | 29 ++ .gitignore | 42 +++ bun.lockb | Bin 0 -> 80047 bytes drizzle.config.ts | 11 + drizzle/0000_workable_iron_man.sql | 52 +++ drizzle/meta/0000_snapshot.json | 354 ++++++++++++++++++ drizzle/meta/_journal.json | 13 + env.example.js | 18 + env.examples | 28 ++ package.json | 32 ++ src/api/auth/auth.controller.ts | 150 ++++++++ src/api/auth/auth.route.ts | 178 +++++++++ src/api/category/category.controller.ts | 45 +++ src/api/category/category.route.ts | 53 +++ src/api/design/design.controller.ts | 29 ++ src/api/design/design.route.ts | 28 ++ src/api/index.ts | 24 ++ .../photoLibrary/photo.library.controller.ts | 21 ++ src/api/photoLibrary/photo.library.route.ts | 31 ++ src/api/project/project.controller.ts | 191 ++++++++++ src/api/project/project.route.ts | 86 +++++ src/api/upload/upload.controller.ts | 106 ++++++ src/api/upload/upload.route.ts | 60 +++ .../uploadShapes/upload.shapes.controller.ts | 42 +++ src/api/uploadShapes/upload.shapes.route.ts | 53 +++ src/app.ts | 59 +++ src/config/env.ts | 22 ++ src/config/minioClient.ts | 10 + src/db/index.ts | 5 + src/db/schema.ts | 49 +++ src/helper/auth/auth.helper.ts | 177 +++++++++ src/helper/projects/createProject.ts | 24 ++ src/helper/upload/createBucket.ts | 34 ++ src/helper/upload/removeBucket.ts | 30 ++ src/helper/upload/removeFromMinio.ts | 16 + src/helper/upload/uploadToMinio.ts | 18 + src/html/alreadyVerify.html | 71 ++++ src/html/error.html | 70 ++++ src/html/resetPassword.html | 129 +++++++ src/html/success.html | 69 ++++ src/middlewares/auth.middlewares.ts | 76 ++++ tsconfig.json | 103 +++++ 42 files changed, 2638 insertions(+) create mode 100644 .env create mode 100644 .gitignore create mode 100644 bun.lockb create mode 100644 drizzle.config.ts create mode 100644 drizzle/0000_workable_iron_man.sql create mode 100644 drizzle/meta/0000_snapshot.json create mode 100644 drizzle/meta/_journal.json create mode 100644 env.example.js create mode 100644 env.examples create mode 100644 package.json create mode 100644 src/api/auth/auth.controller.ts create mode 100644 src/api/auth/auth.route.ts create mode 100644 src/api/category/category.controller.ts create mode 100644 src/api/category/category.route.ts create mode 100644 src/api/design/design.controller.ts create mode 100644 src/api/design/design.route.ts create mode 100644 src/api/index.ts create mode 100644 src/api/photoLibrary/photo.library.controller.ts create mode 100644 src/api/photoLibrary/photo.library.route.ts create mode 100644 src/api/project/project.controller.ts create mode 100644 src/api/project/project.route.ts create mode 100644 src/api/upload/upload.controller.ts create mode 100644 src/api/upload/upload.route.ts create mode 100644 src/api/uploadShapes/upload.shapes.controller.ts create mode 100644 src/api/uploadShapes/upload.shapes.route.ts create mode 100644 src/app.ts create mode 100644 src/config/env.ts create mode 100644 src/config/minioClient.ts create mode 100644 src/db/index.ts create mode 100644 src/db/schema.ts create mode 100644 src/helper/auth/auth.helper.ts create mode 100644 src/helper/projects/createProject.ts create mode 100644 src/helper/upload/createBucket.ts create mode 100644 src/helper/upload/removeBucket.ts create mode 100644 src/helper/upload/removeFromMinio.ts create mode 100644 src/helper/upload/uploadToMinio.ts create mode 100644 src/html/alreadyVerify.html create mode 100644 src/html/error.html create mode 100644 src/html/resetPassword.html create mode 100644 src/html/success.html create mode 100644 src/middlewares/auth.middlewares.ts create mode 100644 tsconfig.json diff --git a/.env b/.env new file mode 100644 index 0000000..aaeba75 --- /dev/null +++ b/.env @@ -0,0 +1,29 @@ +SERVER_URL=http://localhost +SERVER_PORT=3000 + +DATABASE_URL=postgres://postgres:saimon%40567@localhost:5432/planpost_canvas_dev + +MINIO_ACCESS_KEY=rEiuiqB8JCSmWt7AswOM +MINIO_SECRET_KEY=en3ut7Zp71uAfGrhvMkH6Pk7ZM1qZb9mFxj7KzD5 +MINIO_ENDPOINT=localhost +MINIO_PORT=9000 + +JWT_ACCESS_TOKEN_SECRET=planpostai%^$_%43%65576canvas_dev_2025_planpostai!@43223_canvas$%%$$ + +JWT_REFRESH_TOKEN_SECRET=planpostai!@!@@**&$$43223_canvas_dev_2025_planpostai@45342356$%^$349332$$ + +JWT_EMAIL_TOKEN_SECRET=planpostAi!!@@!!!3242d2345ffdddf4^$367sss744!!@canvas + +JWT_EMAIL_RESET_PASSWORD_SECRET=planpostAi!!!3ddssdeersssdffd^$367744!!!!@@!!!3242234ff54^$3677ff44!!@canvas + + +USER_CANVAS_JWT_ACCESS_TOKEN_SECRET=planpostai%^$_%43%65576canvas%%$$ + +MAIL_HOST=mail.adspillar.com +MAIL_PORT=465 +MAIL_USER=canvas@adspillar.com +MAIL_PASS=Adspillar2025!!canvas!! + +PEXELS_URL=https://api.pexels.com/v1 +PEXELS_ACCESS_KEY=6PK7hLvOuG6nFsmC8c9EV0P8hGkyHeIhYpiRxhkEfh2ePK0GhiQypBhI + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..87e5610 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel + +**/*.trace +**/*.zip +**/*.tar.gz +**/*.tgz +**/*.log +package-lock.json +**/*.bun \ No newline at end of file diff --git a/bun.lockb b/bun.lockb new file mode 100644 index 0000000000000000000000000000000000000000..61569aa8076c2e1a8393893886c1586aaefbd889 GIT binary patch literal 80047 zcmeFa1ymMY+xHE4(t=2f(%sz+iULXth=`C#zI(mTec{FTeQVuo-N$k`&e^m7za6t@*Et{~J+G_t4PH}gdtM8BH#$>S zdpvM(+d7zCzh-S~!F|o)hMkEEw;LZG1{xaLZ7~P&y9w>RcR6#6aze)lW}6f4IqG9$ z`6LAr+lr!-(2AjhSZHW{yFby;aQ?;n?4M)z?0?vGglK3!jt;Ojq z!sme}1^y%O_)t7cZkL;m7S2i_JIBGfhl_>1Ik+Yc&Y}36z(YLheGlSW+q;ON0q^MG zU<A!9Sn${p<1j=N&){<%15KQve?bJTdUTfD1nQID?BMz|R0r z34A~BQ2p70>*T<1gG!7IlJ7jceMj83!FpudmFWv&R5`} z>mPxK!cClS+M9v;w*Uu}k6BQ7DE(pJL6&{m_k9iUkS>4UC++)4;GuFjTiCf-+(1LK zaR-e8n2o#1uFpELSI?oqL-lF5cNxvX_NKEnh%yWUp>lQt59Ox`c&Hp^4i49?L8c4< z1>&1KfClV_hNcY8q4=*Uzfte$JZSdrj}7opeR*10m_QB73UEwJZ7n82JShBM z<4b}#P&g;>Pnfdv3(DUyL%rdxc15+&%Ni< zfrsMZ^X#=xG~l88^{?grPbqH~C?8bcb-+XI!Q8^s)e@BbKeasY{R3@y_qpnk+Vh~z z@1DcsM1b_(a{!MQ|2j@Mn7UpA5;ScK8P0!FoUFeX zL`ja~w7|6gqs+oQ=GM-bIY9^(irb zQd@k|n=*l>+5J>%esCSuUnQ#{y{B65Fg{lkIV%Nax76x zbjIy-IGxtZL^Hg}f+r*%2wm1MFkGg$)#}$aBOK&RGJ1xc6xqq>_c`O2tY&+aRa^oZ z!)!oUeRnMW@Jft+CB9R6?2}U?y;lPkilx&@D=xCdGPhZ{5^Fb|lMTZSQTf>YDB(4g zlU)_Qqaz{tn3Uje9;qganNV$A<_q*`uVhC^o)%+8_wy@-(Gz^n*uOoMunN%+KOzbi2jSz1a}<>0{gx(oah38b zbM!mDV(QDYG>Bvf4yvVQZ;ICTem2c@E)Syrz-z>6ME_-&hBDEl@Wgwc6%lVt<+88% zRlUB#4i7{}@1_WT6!99;^t;ibl6!gJwp+fS@`cUpq$9VZPhGAQ;6;y)GwLu|X=={; z{;MV1zH!n`I$2z;|4aTSZTqJQq!w9zy6OqxJ`)|kRa-+Se&&RHwO}z}JNvx7-(td& zPgeSRw%=VLhpzL*w^B8WN*BqivZ&^mQPd`oTsd4Nfv=7fG2=fh^(Brkj z{Fy2;>zdD=P{PhDhA!!O;gT9r5yM*1{tpCT|)n-#>a=9m3_Z^C}Z>mN4dXV|q;Ak25V1d7nN= z6TPAS*Z3Nz!U}YClPy?MKNwG{X}^2)ob#E?%~AlOgHB#>f<>n zC;4jnX3Y%R)_iEavo1zQOD-_l+)oTPCEvz9MoYTJLChwI9yXQ!!S9+9f9(4gFVPmy zvdm)0QEqMVYrPh+Xv5ZsQ<|}RiE~_rvkLuTXE3V3>gsU`kBld`Z5DBud-)U+uRg;% zh9PpkvobxZ-1Eh!$XpVNv+G}CU6#{j*J`b+rE-O^>^_f;H6-T?)QL0pl-PypTAC8( zbY^SDjhd>}Kb3X6^P4@E$VXoAO3|6=g&L|xx?es1I~6HEDaq{rwcP(H<-K|?t<#^P ziPoB0MWpQAPla$lG@%Kq*De34<$>>?*MenNF!4M?W=H(ZRGVJwT7pOHdTo=6@HipR zkW+rHljX1R;$O!J^{!_hUnOv*z~e=Dx0!FI2 z*-!tU=sy8HRDTc;W*#IHa9lm`L1jOEIP6dK=|Cg^^w7P7?4S%dULW|Nu%AApKWH7m zdNR;(P9yXX;~)m>FYW8$^Mh!iIB@)Apcg@;54E9#7_9Hx*8_LpvO|1Wj|(bZ2$BAu zK0ng?dg%VaOo;z?2FG;*LNHbJ0n2n~zItGT^+iArwI6tg2C9dH7_7Ge1%}EGX`np7 z;n4ZNGdNx@&_nqLYpPxQ)BE=c=)sa~xBr0Z@K5z5Ak%XDdPw`H_s%qGFkJG0I53DL8|6r|pAcND-0(zx=JtTy~{|*AjTLpT!|2$~jBhwKC z4Uca>eVF-oX(RP!Ko9l5P#Dy9|Fr!+26{OEQ2Kw`|4jouSaR;RKR6GN|2u=rdKxqu z1w{G(H2ndfhwtA%ssEFp(}12|I6tsIX#UlK9?n0M{-5e2fnEYpe+MlCoX!x?Bg_A% zdTP+|!08{f?Lc({r>_U}a)-!&9MJ0?LjM)$&mTfB4musU{7|_-b3Ty40(z+bh3Xz+{^|1<3-odb z{hy{kxSu{;o`dPoP&fBsb83-odzeJE|{_|y3pF_`c`dg!@@(mrS% zKxsi10rVO`4}FI70Db;DgZ1M;ud<*2ztbSE!+Lh`6f1oKq-w7SaV7)fbL*o~84u>5C z0LOg<^w9GQr4O~egVrIep8$HH{qjTU{%QLq0|iz9dRX_T>FWW#93p)v4+k+g|8YPM zl^;5X(*D!*NB8p&>kpDP6bDY97JOMK0Mdt^TPO_bLl0uGUK8l$fF53Z!Qp?$faCoF zdg%Fw?muLIn*Igw#nU;29_H-P{`a47`Z4=@xZi^P|E}D-0_#VC9&W$Tl|Q|IjFkT~ zenI*_P2YN750(3%S|}Yj|HVKLjX#k7pR~U%pogA6sQ-uV-9hsJr_Tevx90(RNC$<% z;o#%HH8`H(z8=auT!w!u+`kCxp8>r(NFTm$U^;&wgY{Tc|Ihg+%=tSAtTzREE|5NS z4r%|i{=$J?ct8IT11!f5WN`W`K(7Gwkca^s&>r$Z4AzUBL_<>qdaw-nNBO@A^b-5& z!^}TPzYOS+{r^E3aJ(--FT0;U3rHAhLkBTfZ%4gX|6m%ptNqjUO9s%(f%Kt!4>SKH z{a-*2_1}>GPZ)4oyfkQNl0Xlq3Ejto7_7e!^w9hZ(!hE66a5g-L;Vk=g`PbyojZ`h z>66j!l^;5Xjz7JBXMtV}qz{e%(DA3w-yNWb=5Ii{JO2iCc_4%H{~YL%^$)tE0~xGe z2YO`v!}FNGgTQ)uy8qdKfW!Vy3tflfdI3Fj|Ddpg?p;7}Vf|OSz404vyKp!Z=ieC| zPndr1`G?{jwCqq^SZ@vVaQ_eI0lNNo2J4%E9_qiqG!%Mo3wqb-Jv9G=H2*|T4kk}< z{rwZY*&+2Mht&T%q+W$@@BN2=T7NM>54Hb)svkR~o|XS_={p`$UwueDhQQ(SuX;%R z!$ayvf&T0v?mv^@;q=!JsegJ%{U*?#IYjwoz~so+0PFEZ{%8F8 zCwc>*hsU3PqR%;`e&vvQd9e6CO#Z`xUKQLwsM`NY`GZ z{jWpnmBbHM{)j{B-yKp<4>s@M@#ml1f2%|4iw~(^JEUG3Y#zen$3H2*?;-WAhtwYh zi;u(PU-OXqSfGdYU!k=Nbb#gPfeh~d-vd1uLU-T)#so@&1F8RKoFoKaK7b);SC0?0 z&=`6UgVR?8de8;rW<^yO#yXixkgX*EU$Mz5S zGdB1@4xIq|zj>&Ao&*O}&!@lv*+D!MpBfyHrvV3KNKXjP!8XWl-obKw*N`466R6`| zgFKWE(B^mNd*IpI<$^lcHKd2q1I=sKAP;dsb?rVQV9ee%$V2f!8{9R>L-Edo0}2Oo zlU;*6)J8PG0bSPu2V{_k+Pp3}p!ugEI3V2>a6s{m!2z9{fCDnfL+7U8fOOZu0mZZ4 zKeq!O;yZu?GRQ;m9l-&`a|Q=wkcYxupc7z_ht6H06JU^s_&1>wVE@iTI?vr$|91~v z_u9Yy?>rRGdp{oJp}r_^|NJl>s*hlBK;;hu2b9h|a6pFiP`ilS4gL526o^x}9}n`7 z?&nd3P=Oy%yQKym8b_F*!2iWVmzejj|Eq^I{~ech*HpXp`rmO0 zY7ze(m;O60?X`pdKgPTNj!XZU;}O(Cpz-won{ny)vETb+)?Q2ef8i(Kp`-0_k!X~o za9Vn<>xqS51*5G~M%CG$rJrn_&Ybp{Hy~;#QX74xLt>yt?5q1?$ni#o|93b2`<0)> z67&hR1}150X-dfm5nQM)U_*(Y%kL-Plp3_Sx-+t<%24}^v*emKJ7XNv2Db=(3xh@- zt+Luica6CSQz;v|NyOIx=JaGH`G!v6Sl0VO#w5ZBE;QD|hH5r@@3XUkon}nBhrN)X`sMXBtb&-SpG!-tcIp=-Ump12e;@GB{Y{UiA|rc`IeQer zg`PdwP*V3}*RF4lRkX_~KKenTxvcqFJ6 zw{hKsC=py}4h0)(HC~VH?R?X17f}P=YLgEqPqYNB+-D79edTel_xb4wZI2;*x^Fvz z26s#v))qN09G49!ez=hTi%2o_(T!%^X$u4w2ZjQV;(AOvNJqi!N{4>)mnP+8?G$+s zGX+h@cusXvbx55nGaje<%kcO8jT-#Nx*Fn{AC=c)-P59SKacY?$m30U<8cHR7s-A4 z@$}?!P*@vh|6Xmy0!%ScNdGdLW^`i!ZZ) z-9@A*WQE5>_QUI62rjgSgbmfoJsV&9;0e(sOvip+p4WD{g{B!Jk0{fcjEFwBZOf6V z=ANgxweW~8TQ@X~LQRj?ZEfY}3Bq!bak-|{NuB5+1Q*&XgAJt{dtEDA{7A8pB6Fql z1D`=j)tik4q875YGBKeB%>*{pKVxu?Yu6?h^p5hMqWNcNUP+k zUCOrck8>WD#Dju7>`Hzl{R-4+KcA`9}@%(8Z!$kFJ=^B2-BuzB6k%!7woWxgKg6^xIoj(thOr$Y!(%?yZR@P(KE9p}iN_P-Fvj z#T(t%NK-OS3R`;N3~`;2>v7<3|9pF9L!X3Hoh1JVL#MSUY9Q?>7Sku&CIh2y3JRZ0 zduS%yJCX?)f7T$l&>S8%lxEuq)2FPIGINT|j;mdBj?NNn9%O98Wb@agu0M(=xcsZV z=Ybv?HeX(lOZ?b?&fsJHmY!?uIAYgvy9teNlNcblL@*S1lrq6Lb9(;!(~0G!vZu)7 zR3FW0Of$_~mv`~SYoWiAXeS&$ed6kmH!OyezW z@e?j(7cR&Nd4uVUJjb|W?u<^6ktzbP2+{9VQ%;h$)-ZTE)%}>;oXzT{m-C1^=`l>* z{v5$2g`vQsHnYRuK95m;ft8kd<>NQu?n>{c7tnTOZYR57ea3kxnK)Z`$LwwxiCfsO zTvqyPjBOSRAI$G$D$RQ*ZBdhAZm=M@WJoTx@0bp&a^I}H$(4ZTgV!3I+TVR}kq?-= z7Cb#Veuims`lVNT=UcmkC+XkETBY6+Nf!XWn2Q3My-6Mzk+{hLZB z`SGj!R7ZQIv1m~mssJu%Vm2y0^ua zble}8*rkW28xdS+Z4MhMGH!=6_cTLA5M%b7*hPlmmina;)V3_qM)!SmOXg@cQHLP6 z=vpB;%O@gYKA3M4&pNwG9S!2G@zi|zZl*dv3BiT-ZeT;@o~7K#(2ubVqy5lj|9WdD zescwL`m;y1^%L4UCaz>--rwkFO@O!0$v#o4<<8{*QwZPLyg+Hd{N%@@lp{*Tz`}LX1h7y@~V?_5qrZA@Zy&aghVXO$n-5$c3q%FO? zwi0fzLMt*Hb^Y#|l0QjN#bn*UiAM-7H4FtF)lXE8scE9R<&rld;pyq^wM2OA3@4`w zCace z9z9z``@72AxyAT?D;Hfwl>qNc?;1=6h>}zc4rBnwKJ&Rx`QD)bAJA1 ziF(Phn+7-Tk5h>J(jxPF(LSQ(BxkgOk3_zx{#lnOjY*1~m_-y}7XG_IVV&*L&sMV2 za*N_0+%i~ZvaYV6u?NS_)8YnAD|<>C#9G?nAh>i$?smXe2Xv-sm$z-_1=w$=Qz=or zdTVJfQ-wW~t&{Nb7tP(?$jy@h?hYy!ZR?w`WVoY<5<%C zVx3jGhimNq;4 zDg65}H1w~}KB|b5@*}tmNbXmsu2;9`u8t^uHsxzMXP+R!zy0#Ka8nxLbKQ~-w=BH1 zQIpx3*F6u%nlb6E@e+ThYnM~hi-nv!{=>SR|9j;*1eX!XEfq?|lAyp!m1bGSqyIA3 zR3U=H`l$5HMih(0b)3KqjKXyKN4_GcZHM1+A23In1Ehwe>0cA#`}ujNkH5O~q!+!hmY zM&rTdm{eNn%iLIYyjgeR_;5APbae*=S@hy)>u~cVl_9t+NG_Y-og@Kyc?yaqMSNZI z(`!uF@%GPiG0$NgQ^>QR3^&IK#K~M$J5Rnf7pbsGR(qpE#9n-=uus(J%p$4TGwuxp zmlesyB1df+zwbad9_G_E9%v+4Lhna?Q6rIm;`H{j3s0|bi0kEd?@vzW(7ET%7RHN4 zgz(%!2}w1g{PoSqPuqDcBe>vo_rDC~b+hL-cE4q47iHbE_n357)gFj)rMy+cbK}2G zG1;f&7nfL;Mw<5VBT@2G;pfCITdQ-)#@3NpNet4u1O+UyV+bz$UsUiMr#;akZ9V!j zhHm(aWfZB{mwU7Z%(69CUOtFxkdhxcpPz9x%i3;6Cs>=J_DZXbW@PpDAhuH%Ik8|7h8H0&!Lvj7`l<^z)q|$?7h|nCSzj ze)I7fkuRcGjrYHZf35IYcJSA{U}@)V1Q+=|HR?;$1&&Gbt*t8duk!b%rv$H^s=(mI zm1&{i|CX?(P8IH$QpFT9N~tj17&D=jH=FP%ux#wsUDYr74m_q=amGamE*CPt1th$T z;?_qxwn(N!u7%2MI8x@loR1>xR9g-GfdptJt7&BF8qQ7zy@*11YafN}p z>F%m8W2XlJA>E@7H%?;IhRk~3f8f|GUt)rO>aka2`CtNq3tmJ0%TTR5zHb9LL(Tc+ zTXF9sgdTrN)ju+ZOLA`dEc35fmYBfA#JCGwYuS1Xr*NY$&^q%brrF;$(&i7;Yfv_O zdXf1$f-CSB71XbQ2p5}kwPdyLQ*)(eOMC;0fZTCK)?d+y9=!LmCj%B!Zs|un>8hQY zaM}ra7qPW6K0*APLff#AY_40h2)hh9UI-$&Un?$$owJYF=A=7P=Flu`)A@1jtm#PR z9eift6{fbuZZ?a%C(;^j;0Vv!Ip41O$z$NWGTd3@r_bz}SARi09yz`VA-T~%TdZ%( zn(98gEOifWrCZ#MhR$nFEZ;Jh!oG`t_RYf+o%l>_ZI|8^`zKLG368X=_+omz*5f3v zat%tL$4hyEs0Zl#YS>W0b5SW))SX4#V z{AccDN5W3w#Tuz=Pv`?~A4w9K9{%wVw{cJdxh@oep}?comKIXz^35DKFMU9r_HHG) zTWQQZmu7$Fyek6^4>5hs)|HpTnD@ooi%)Zi&_wrDzxv2`&!)aVP6>soRqRhnL*!Q! z$tC)>nSVL1%}_auqa%q`IO*ox*pb8;5&85+oZ$P>)^t8!H9`gy6!~*8IzuQ#nkl`d zuUHlDaPt%WpbWMz{pyV1f_F{+GF0Gcz3&%v??qnuR(pii_Q4KjY;ztvaU;6+#qkGk z(YB;zeniPpCE5+;a1K}*&t#l*2xZdF9KFKb(T2ha{nf5)JP|>|fU39{4w(5(iT};(aUn3iP z(@ozi2rhWN>Mui8TQjnLz5i_a&1!0U+vCdu^Z4Jy>DOx?VoI)~J(Qf{mW4g?o^Zvi&cfWS3unnY!rgvk*vYYBOR8|A;g zrUx`>UAQWPTdG_x_Q7FNI3bmphSP=Wx%3Ir-!rjoMJ1!(=UH!{nSM{xqeO6}U?}jY zKPIQ6LZJfm%JqaGk7IzCe9JBhH=0- zCvJ*Q!$R!C`!>FcJOo!7$tBOGR9s>kO2uk3pXbxMR`x!9gd>{c8;5T`Ns2<-^2?&W z3(qrZ?go*ZCzm%$J>T-0FeMAkZ`{UASIs*G167FNg4bXEGL#}t+%-H+$0zh^(or{V z{{HFry5c%^uZpu}Vc0ND(sl)z>F)_$H=7#-^(7tAvo!5B9ToK(q1!KPYo8D>uVy3n zqh$Z0f_j*8vn|0e5SZs-H{CADMwfRQlt_H;o|Y>5vmz&$v}@U1P)%X^xpqS6G~LRO zO{}#zkpz#jpp$Ag>kFGB6+G^U{K_G@%M~975{8yeEx+sW)0Mkub+kT)U0oC_dC1Z3 zF1u zRG|P8`*M)wldPf!;z`Y|dG-`VJtA3JW)A6S6pBVw#rX=Od=;h+dwu(5k*EGZ4_|_0fViCzr zMzA&p+p1R_7q*b_t zBDutQ*X=^lD;X}G7HY$b_nY6A8Fd6yUOe$dj%A0!^0Grjq=O2Adm72bAozxDBf5mo zY{7jiAThU4c|KJrE9=vZjo^%<=c02zJUSk#U*#HnC!^|d8^Lf)C<$p*3zoTKbA}i%|x+HK7^-H+0SBQp5 zH#O9>8wX{m{1N#D@5=vWD1tgIdo-w9HQ|%K zLzCM!vldaEJ)*cz?Up}2f6(c!vdS&Kv2lSUEUgkWvcKBdc_dew%5!en zd;Vcm?yT_>UiuoLO>G}SpVo=gDWRxm3f_nkUO=tdLZTkf0#k3T6*Yo34Qtm4pjpB(v=EXLM^d(RFwg1ehbX!*x z!ZL0MM_rm+$DI*s43t<<4yG=3Xx@0ChC*=FkldrExR0B;Ii+{6Z#)hj@;{PP(4Tm` zg5uri*uXk2N5%7`B;4pa(rdOi{PY6Lj|WLCy&VlHWGou0subbVAy3diaMh7qY?*5{ zNtMdFZ>dkquQ$s%A4{a6;XPw?PU_t`Zn*+Up=+h$QHn-W40E|z*f|w8G=Zv4C&rC_ zleZZad-wg)K<*<#@87|O>ceolbA0vXk!Mtui?Uy|)zoHk7Q?1=Fs_sa3n}|;c{iL~ z_szBa-o{D4WOPe)Htnb=n@#nLrp%Z9E7dQ47^>l~WmHCQ=IfL36jdigS3T@Bh9OhH@?I+!2i{2XI$_U?##%z^dl9GO=sZosJ zY9YCo9i8SZ&Cl=`K8zxkSXKP3J9l4PU0SYB;HIAWrGb~c7uZq?gK;Ie^(+FSS%xYu zg}zQ_ZxhT~)O|aujyHC>55d(&a+T|)zGl6_Yq(N~sk$7+FDYzd{Ug(STt_>FK6w0? zwwF%`vsWDT4deHhMJ!9-E!Vg*SP2V-XsYt57rAyE6%{~mb&%Y2y)0Qhyz&U&^OCKX z?&>&p*mC|7dTETh!x8E|B&X!SGj!x)02|fS5-Ilp9)^oJpO(ae&l3i|8J70H)}^j9 zgWz67a*c0Z!lB9L7q`%t)ROYvv2a-kdsW^$kJ8Iut(16P{^`V9!lrDx!HXV4gwJYj zjynF1^H`7e%W~6K9zL$tQjXl`(nWIf7>0HzPZMc9L`w+>COPTZ^=$?9La;bR64;@)H^36&?F-GP%X>r(mGqyH`Mz2%7R2<{~$cjl6Fb$N%mUHfoxmku4X$2qe%MOH27%T~vrK)q8>i3oyp-b%NJXB5R zD8ujv1otwM>-0$_pU&Thdvm%#sO%Sa`tCU5F)?Cy1)_kW3~;sZn=Ww9woRqq#axG zBt9Gq`9qe zMC7gL^~n7`BP4evZzsa9V#dGt2cNuuBEv+*8{__I(z3jBb^S-FS?_x*7!H-)Ux+D4 z3KYL?eEaR(d*up0G(+z@97FV2X)Qd+?-{Nlxn&KTSW&MfKZTt3Vy{n(ZXY?5-oP-p zB~ozSBRf3$M0oePj)%7_c)A`79SzmPJBKZC|0ia;!X|@;!}t|~Gkv3o`ZY#!r)nZ@ zkSe@ZQs{FsiS(f_+9sVMdO;ezDz}wrQ?-@QCmV<<=>2`hPC4=Vs)mqPG@}b~t@Af3g}O+M(oK--8^|VPF`s() zg-Sx?<+GPcUUrX@5nOX5SE%!u+_1#!A&j(DET&uBAN#J4XT};~nH=%tOswmxCtDQ~ zW1|>zq~M@Fp}E~cKc>yTwryy4T>QBP@dxar(}@VK1(JK+rEz;w;mptnatgE&&&tUQ zm$VI}%fyn7bA0eD*+UO18v*42NonJsxM{utp zx!rj!X{KD#+jGlxN@dAe5rR-U=!d7Jp zgBO3C!!Bz{ZndBv`*~TQ<($t=_UyC^nc8L67jzUPE+V*ANbc(=v|rCM>8>Zp_B^3g zY@g&Y=6j04YZ^em7|yHFq9-?()TR{M=pt)RBoMyT!cCm4#$LOjIy6&atl8Q&WrbXK zL+|gyhAOdsL+(DQa+~dg=X8wvRM9bE|D~F7r3KQUvwlq{b_nh}XRv9b9Y11q*(=C{ z{Fq;Nn*G%2_o>Uj^|6i^oo-h^l&w9r{<>bISTe>dwt#!!t}sgt(nyG52ot8!%)FGa*P6dEt=~yyZ9;MWwYz%ECd|UdX426o0})(%B-bRlMVoym zvyGN5!7}Fd$0e#NKgJqy&UDJpnT#T@|n{?P_FD zq&l+w@iB7V=YZtCo4IarB}~^9%}7~?0zY0;g2_hzj;qpkQNCCAry6WV;^t6;s~MvPh)6K|b={H2?ihP=Vv zPowD9CmK}AuFZL7A@X|z$z75iJDd6Z%R2#mAI6$*x)g#sN|tMB->$Lf4H%rK2~e}_ zyqDQ{k|e0&Uc5Zn$4E<>X;Cj4wsy&JPEi47jTgxI4)psku%WU(G0z%^aH}-fu{>8z ztaTVQ^y$yMG5o6ruVJHOrfJ;fa={(VSiZNX8b*fm(q(Eq+-PwxH$O-7{nCssrMCuu zKZF867Z?gW>I>D8TX#}HG$UJF zQk9u!qTOhGO?Ljgj?VALCy7ruW#xQuFT~(*m!|ot=6|ZEV9Za#;+E|P{_n`|yWLCZqe-+ESxHfMQ_st#2y{PTH)1TN=D}GMlYpVOtJYt2T-vU1Sq2l5Q*H@xK zJx{NZzt!Eo?4GiA>b4vQ<4=8A9HB*bD(oggQnP6Gv&6ggu>1dAqIn>>DW%SxuOu%A zL~;!fYR6gV<0Z~B8!;;W{B9bk75>T3q&Kj5x%Cre8M+r`Cg~g>@!fvPBD%(Hz$tObb5-Yb_N~hoJ?|=G z5)()zkM|UBabFdD%+8U1PArJ|Tki|`XAkl>ck5vCR8!Hk(B zdD`+9)37yXo;TWZA5*h1ZF-_B#cyyD(^)GX!*u6cK~TP-*|S<6?@Ag1J7R4{Is_N~ zJt_!~(xaB<e98O^@7i$o_Km<(0r%=a4jo|nAi37}(*#T(%bdEZ{T1creB1DYlD%lRAC}qFO{tUa%LRh6nuCr_1=la1 z@QYel^I$Oy%r-)!9GIpujp@tREYZ&0YY!OEJ8gH7+;ujtB`yqhx8`H*JwVy%Co+&(Hr$0)0=`;5Gr6c1Q=WZ3S+`P2?F+u;UBFnbfvgGRv zYosogBl(t!Exw8Pk6Ww7jrZ0=s9mlflDjkd;f`&or#mI#4Q~E`I}KMnyVkd~nWi%D zav#U($DGK}jb^#^txKEkm&RzB-Y0i%GWXvh8_k1mGreW0MVpg*^?(Dto9d6`J}fk1 zUdS4V+166FAsb&mQb<~zx}~YtqHelKb$>7?;>Xu%?K6QgeI!NiRTLiX6!VMKHD;z7 zaTniLQ5&F=oZII@?Kc3)P0_xEU99!2+fmEscKG>+eWNFfmhii~McXbnx>rlNd?2H3 znOrSCrGZI&`~~W+gxg#9%S8MkCw^>QRGwD2sp+}P-Rsl>k=$CDhVt{3beHPR_&t68 z#QTS+v&A;b`|-8pI|>mRXYWr3icEx6HLga|3Vfqo^v#A`1x$`NkU)xxOUb(#Y{Tt41dk8^t zFA91PPX)bjV!A54nJhVA7A8ruod32Y9KX~2_TUG0aJst4zX0G z5pvBC?5;VDT-m>GD8LLwa;?~H+)zzAEB;h?7^<(1oV?vq+nKbc(<+2wE|b)l zQ8X$~C)`=v?znYc;A?`_+Ly^QwsdIDIu-ADhyp$3{qpy@K7bj9CS@lSHfi=m5n>@7||wJ;w)e{=U%Ky(%6D_=6z){BzPS%DH}!mOqr`~y;b*XSuC zPP~7*&y@qrdr0nSQtkJ6{&5O;a%kEUE>lY#*3ln+dQ#m^DERWut^a0%t8uFVC5j^; z&+G(W%Ho1#$Ju#89y(Rkpf@LH$tQ(N_PNk=d>_d@Zd9MKvT)on0wcxS&r)&z<@dCm zZ=8vm^+(HDzkio9cIkW1Li%&vWZs2_C9nP|r_aiZDxMOYTdL8u$#)16HV&5DiL-2D4Z zR}6VrVzW>?T1z<@au@ob{IM+9aNF;o~UaQ~k zqsnZ;FG&P963L}Few0B$B_-&_5$!}=6;xg2@u)xx?iA`@pPFlXOun(az&f%?e9ftF zrKfF_Biqg6`TSgxn!YQAVUY!?(I*)f1UCxFEm@IEJ*MEwc|2a#^68b>E0#C%=IC@E zxVI;d`e9u4>YSGpTF`rjTW`zBu3O)Gtr{yhkoBWgo+9sctoLo%Tu%_(Xe77lc>hS< z$eWh}CO6`Kw2l_o@HY4Rrt|xqNr^J?(mG2#tj+&%dS`3K%d;kcnP%zqwV>Oft`i*h z=M;`oXv!`l--~~U5# zX0x;Hc~YwuHI;EOiBCf2FFq$1Hxr<6@>oRVHwMWi`P7YjirDUP2Hr`BIW@nR*fI{Z zcgyk@*&sSPII*|W_cf;V?_+w{=`u_t2s%U!x(4V2DP-mqnk^jZ{$u_ zo=YH}!k?wOik^j-r^X?$(n%>LkCDUi%*Vd$osS+DXEJuwL{{ zh|YKZ0>be5rihn;Un0hE_cr|q zZUT~dCGUT-B-v2&on&TdGT7x zQHRXXygqF1{rp1XMG}(hKzjCa0=D)YK~-z@^FN-}GJW3UO-V27PZl(HiKImz()T&b z9Cy!Lz468BXn9}b*9+BNRk3OBy!@vJ<*wm&5$$t*05ciMbsfY!o55|0N0lG8!D3Ei zg&|&6J^N;8;3JNNNR_Fz-SHylxZD$TFQR5{>3qu9Gq@;35ZEi8!5UqETK!hyn9MGB zZ$6oV2cA$5}?n7M!GODDd*)m+sEsFIC07Uhc`F##)hkY|aj+ z=JWjrDg)M%jA=WJ&c-qF;VpaI-T7oHlKZpkS-%&W#yoLp1E6j(~w+!=jEHM z$A#N{L~e9P75Us@?jf&NoEa_Pz{SiovGJYS_&6fsMfb^0^wNuQB~x{8g2+0#uyY~e zXSi;hjUZVk-LGG0d`m}ihXWTJuRi20e}JyVo_zgXA z0&L&U=n!Y5`LgJS9-od7O%^Trq!?dx;UiuA(V=$s8nCHmLA2UJb)5 zBXxOUHdnEpvYgHrGqiQ@jZ!_y-;&VFl&yF@KUrLU&ZT6HJEPR5>Al3c4%hpLi{*^pwJnv*b5;e%O_1uYI-qrnf|Q zbGJR9;m84I9+LY&r1`Xgi|NDeiw1L=V`nae#Ab#&pU58Txr6F==wQ<*Z|HQXvzZV# z52F{wNG<%75XJ93MN!WBHEhL6^cNqz4uGDwd?Z&hG~>za6aP4Z5>2kiH|zHaU#yAo z-M8tz&Smg|=b?_Sl0gYt<@u>*j-2#%g{x$RcQ8h~)Qi>P2u!Igr@SKe<_~-G$pR#I zC${xLb56b{9Y@{8&A=aHT_%@2nHG(kHC~B1+PR>wK5MKKk!CUdEN^X`H(M@A=V0N( z6xQP?UY{E_bZ6}e@_nO1B=_yDwz%q>F1h_a7WisuwsNF=H{>U#vD#`Wuaw+VBxI56 zB*`~l%v%dSrO#6xd13n<_Hwf5N0HeW$6m&&u*CV@`rVsP79qK`4!sMT&#!lf4bE7v z%ul^6F`54Pz55P_&ccP-<`nlq$-Lj5d_QeAOzwRrw$9IGHct3u@cR1lf@EBZ{cHBF zy>;Opw;0KtH6Px-r(m|Sg~FIIck`d(JL#PjeGl0}OknW$*y{SJf|_rR z5`lFj&gfiE?_CJyCuPPNmrm_5M&!2y$+be+_!e`fX_)n;;ML)6-noofE4MZ6@Hph5 zLRC^ox1GD?QLj4k{7>6;E=KGt}pWIi|1m1u!mtsZ-XvxrE_WH@9nXS(Sf3P6Jf2} zvlKZYB1*=+Jj!WECOF0``#}qq{k+0j2pu3U|TZ88~w(J?ht%Yc= zhof4;L4+^6Nkg&wl50^ROFi0n`AYmYAv6eX8IsGxJZeb5H4+-BBwkZ!6lZNrqWsdw zNI>kY?+oGPH%ki-a`h~VH4It{3(n$_)e&>P@OG%Ui+w7b`cX0?`b{3FKSu|DEeYCx zC`WQ9vK&YQJ~v_?!9FHe{jqyEq~(!}>RjO1XXOa><|{vUO1GoQ*x0C@PGQd+nYWMD zE7|_dS>S>$lkqR!R+!9Ew_S0K5vXUK$189Y6P8{9YF6dsXvwtIJ1b}Zq{o6k|} z`ICX)R;b5PVymJqF%j4BhSD;4gQX8mZM zMh?D$y?yY#{j+CCt|le-Sv});`&H=f5O z(SC>LsGK+XHHxla{b6gs3Bq0O-h8J9$xZf^`>=8>)VN!M`Z~L1ve1`jC)TB;ANMb~ ztcSQ33M=(=xzufKpm*!P&uiE*931^&?fc4mGTJ7$a#Cuwb#iZhwzq#)i{ujfPqp6* ztJWTOSzb7C@gjapd5*>z@}Jgne0PjX>8-s}cVb7n(o;l@G$LukCwNpnQr*<@xa$@t z2gTPv7w{tIJI|5aZpBok&i2g=+|hX< zZ6_d0eVI8ydNTi6#Vy+Qmqs`7@iM+i`R>*4?tG^X$sJQ0PCrwl_?RQ$-8q}$LCex_ zSP9Ko7wh^W4JO}k=qF_e&5#WAD zBe{9UIwCF=jj(Dw!`9{6-PvCjLG%)nYu$o z=^2-87BC~Vu6kC+G?(wam$^6JX+Ux}qy6V%nD{2IK00yNT={||&6-f&x$EDX#a@)v zw$627+>FBBvgXfsj14Gx7sGcOmm``XhoOG)0_*UZuCK+E>~K8*W+Re2cG*m^dNmWn zn5@=!UOV&Wr-^6cPZNDI{CkYm-;iRMTE2?=JlTQ~!q9i+(doW3x~9>cCT|lIj6Ivn zlXa?{@^PLw+uG&eeDVurOs}Jd}#l8(+Z13qs&5&q$qc{Ga_O1iEiDPTq27`gn z4F&?iG*cy4j0w#^FeM2c!UG;4TWedOh9nzfnnMdE^xjP91PB2_Cj>(8H4s_|J@n8* zAoc(5%u2g5D+%$=`_Icc@_S#jGk5RZJ9qBP+$pRG#hej{eHOk-Mae8b5<5FG3r9avDdrT z+%&zif3*&a|8=0{mT4VFjjKC1J!_C%-+0K}BbQrt`N40?oPBchi+uS`)N9nc?}xYA z&Fs*wz_p(i*MIBT;acT~^~qnc@wxknUoLvl;Qq4-B}$&S_o8Q$@qve`scU_8yaivM z^b4YRXRlmt-tE24>5Se>ZF)WnPi(j|v8qFdX@+dDSuMe6d~6jQvpmi#Drt$m2Zv(`#B3%HD1L zas9kD%MXl?>$ARIg%6Gx`-OjSx8jt`F-%_`mmHAG?e;;yhIW6M{B;lKogaF%PX1Nz z6%rm7d%3jj@x8g1m#p}AhbM1kFT8l*(~E&8OU7GV~>1 zPvvqC%H=NH-RR?99qKR7`_bMTt?&GlnfqkL!+Y<38a8BZ!>|+O>lLbZ{(>RhP;P3U z7kg@EzMZ?MSv8~My`SG}^Gu(=ht+~#)Iy`h^;2CqB^BYgL>YUnZdiLeTOMj_3rq}8w zk26c%oxHj5h{s#welAdUOZncPzr8fNUG~QH-|u}Cu-y{hX{1?ZhvRa&LEmSLufF}g z4mawqJvFY~&CXw^b_#s@aOIkquGR-7YcFj2;jD)Ze%osqUGH?l$ZM(|8IJ}I8L_?b z+qZ{5ET8df+E*bmxhLdu8w@gUix2ZZeK&4;NfV1MFtnexJ>`AA_TRJZ zKS{FB5B^ED<)v4C&6;yKR+~nzSL;nleUf#0LEX_J2n|D{B0{aYOu>{~D>Nol zqt~SvP09IXMg8k<{zqB#y)inEn2Qqlv#04iZa$X7za3`3r=XZe-~FOI`qsv`;x&D< zD+CAx?BAuK?|aaFd4Qq-J%_%{L40|EYXJSx_ax{(`ql{1Nd|ojg7Qd@>j2$P-*G4j zv=9w3@eC69%U?e&I;tVc>!#!+i^@eh(DUgTq!-Dh=hAb?PB;Va_ZC29Ed;y`MtT>& z3_$PP{)oIQzz@J>U^lP}pfZroq$7P7f^0zF(V=hN(D!NR+mGXbK|mHT3>Xf442%GV z0`z^}fxuwk9iTiA3RD0p0+oQuKouYis0vgAsslBEngCwS@+%I|`~8K1oj^J0P2Y(r z4}=00fQmpR;2P*;=zKsifZpS$e={x+pl>J_0Q>>^_r>!7!9YoX{@sTX0DTuE0LTl_ zH+FLY^o_V6AUE(Q#9svV0Q7yDb-+mA6JP+43D9?{=?#`NARVCZY}IaR6seP zGGyMsFMX4S{A(028ZZNW0laeM7YRfG(LfCFHgFTT1sDJ$kOT-oZ6Fqi1L^_|fQCRl zpcU{wkOHIvy?_otC!jvi4rmUv06GKRfNz0M0rK~~z%k%^;5cv=I0T#n=$-9-z)9dR za2~h>kZWH6P6PV^dUyUi-~ez2I0_sDHUiY%N&%&TGQf3UGw?I;0(b=c3fu!;0*`^8 zfG5Cx;5Xn8;3@Ds@C>*OJOF+H?f}n$hrnH6Cn1Pzy|=?s|XMPpo&@BD~R7bKp>DC$OTaE^%i~$0QrG@KwcmSAYStM_kmVGOP~cn zb<_-a4`>QB2B`kt1!@B&fMP%}P#hq;mITOmqJap2e35Le0;&VlKFb2+TXy@Ce^v#k z-O_J0pbAhKu(w~jzYI_T2n8rV44`}HUgD!)x{rQ|ZkI)JYXG$XJB{+_-kJbC&;Crx zqqajjldhEj(lb&_6F;>PqS3uXBb_Ks*RcSVKMtq^)CKAR@jxS>AwX@V36KEL{r>_w z0Ud!3KzpDa&=zO|v<5x|Is+uH3-FP6P3a!Mzk%*RH$WV!{$GDm@!TX)heZ66e+qyW zNCA=o9YA@#0is&~6JP`k0Ogs1zCb@94X^>}zyx4CFb?<>7z>O6MgyaOPk@oY2w*rc z3>XT03}gXAfWg2ZU?4C6$OQTW8NeD~HSh&637}_B2Brg3fN8)~U=^?uSOI(vEC-eW zOMxZ8Vqg)l5Lf_w2FwStfqB4OU=A=Fm<7xPW&mFTROWvH`+6{Yy>s{>w&L;b--HSAV4-I92S2A@k@QfDc~e<95@CX1&#nG zfKeClAN;j?!GH7D?-mvN@3c5XPO*;oA<{W=Dui3}!Tj3_&Z+*fCvr07|4fiYP6?QxueU ze@yP#rQPw%5=CpW2}ZcwfVJHx4;gUgAyH!0G3q+ZKS{R>fc<7dzrb>#iYam!Bq{Nn?9 z4s=$dY|tx+R_;>S;A zJax!ApxFAG1#8&6HJ>lLTcbB9QR+ydTmc`mLG7PQKCwRe6qHCc$4N31N;Z0MoG7tBBkYtviZ6XI6D{=VgjWu zD5Sy7)(<~Fm3VRLyE?dQTm^6*GzmfXKlAGIqQE}z@Fi@i5iO}FmZm!!0 zUk;kzZuMI71JTwGKq(B}8uVE*?Bf{IQIP^8`~eDi)z=5V6RxI2Gy+8|X%HGSm2}zY z2SS*pT60ipbHkMYg`^3KhjnfTRwPN~_NFG^nkC6c#OTdKvBmXaE!`8Z#~%8kpqu;;K=(pr>uUnrTUPKtje z#uG_?ong>N(F?aR32JHtry; zfBiWBiAF^lPb5F!hD)>Q^r#-c@Ojat-<=rK4bOoEqM&w%JlwDUXc2To_oT?}-2M@2 zG-2GUGo}g_oy}Tw_T7+DKYo`29`Zz#^eTAB21T9@D-sfBs?D^HpxS-_N(s^+@4@t5 z8&*+CNr5_u!t!#zUU8<&ihX0(jARsMw<4gBk9Jwr{YeFVd#VL$Kkzd>C?!EL%^H4t z@jE-OO45dcLUr1x$CLEmn$9Orr02w8tvHfC1{dT{VLuW zG4`7i(?LOLs5~hqGc^UjKO40>xH)ykEXKq7(P@xIUezI~(Ak=Edu$S=LASf0P;KX( zJLyQJVYe1a&zTd%DFx0yo7QDxfxe8wN*W54F*amXDEN5c%RJ>~iahYqI-rnW-~Y0~ zg5V9EsxeBGIu;{_OP-OS6a!DG`2%K+Ti!8Bk~R|*{6DM8hZR2C)96xhMnT7eF&o)b z9(DcQ!tF5~I*UA@tO12Im^158nZAb21sH|Z!gf)b*<7gP<@(gaGFyYkwcQl0gBrq& zNn?SG&bMpR##3y8hI5gx=++?dX)o0TConhMGf5Qb{JJnp1AJIJX5}lOc*Qb z28+5bscY-FwzX>>*EVp?W!!CdF5yqvDC43>ii~yBySQ#8uKSRDXo}Pt2P|sSO0)V%s#puCQy0A$ ztmC?*I;+YeqzDESZ~Y@RYLMHu4^p^l+5k|f&HeCkcJ!6l0pEjyevik>20^7w z^xM66@#8=DEiqCwSew;?)n+lK88G4={_FkyqeFfdp99@qwHu3wp@G55?YDUSo--T1 zpU{G7z~a%~{W$6EWea|q|7#tJgeXdjlDF z9sz|$=fXeExgI&bS3lyRFgn`rJSgOE<>v)li7B=oeoavg0^jJnpim?cH$!Y2wsC;as>3M{xRSFpR^&FZrfg8gf-e5__5bh6=k;!o!;m!7z1D)B@z zrat|xm>@#G4?=IG?YdN4;xY8M_SM6?VtP*4`{Le>62yZf{*bSx=(gY0Ki=OuESH5z zvr}|7A=+=j_-~uqRMRIA1@4Z&oO3{;L^BFnBD0%)CN61^b0#iP!pd^LRu_Ko$=Mq> zW-twyXGelU{Xv-$Rf>LGslyRQVI^$<3e`fIZuRNr%@UqS(prH+{XxaHAd4JpQHDijlb&yd)k3%J{Q^LM?0ikqrq!vl6;VJi|bt zR+8SaR$!H5BgXUR(ClmqDCl6ax~&Q7BIt(egB>2f1Qhb#u;RJTWv@(GBk8so6ly=s znzi_JU~ukKP9cXo0t&_PSv|W?$h+u76{5i1@po0E6#1~{jK}S>>q)vj7by#07$b5Y zC}WT)1KZ6i3lmn{yx5?~v2v^})J0e7_XrfKxycvX^sGIyBX3zW z19H={%oCXUuF;xf0hnj8PT4ON{edP?FWlO>VA|=Y&Gvz(Hu_!|C^J>i(8R>{_nt-h z?-ZH_3XM!K!>i?itEr>#97PRiRVq?h1F*cZz9W@qODMP7y@osIR~@|NJ~W7cZg7Qt zpwL|AohN6O4$uGXC{|K7HY)UQ#DiY!2k?;h&iT6LriT63zRP&fjk(Z(t#WOyz}xAh z@UG*+HZ(xZ*?XivL7{e<&s2Zk-FCCL@ev(sS&lE26{*yqX}`QrH-Q4<(9pEABIhYl zsAbor)bB|HEPYY^BwK}a!DzE?o;WmQ#KTdSbJMyDn;pJaiEI5%bb{t~=Cq1TYc`@^ z28G((<;y$ft&G}Um*&oR49fFJr0BNPUmlaka7&^DROUQCO}9N+(CGO#i4rVQzP&kV zdGV0uze4(B4y#{gL^*=s&Y!A^bjed za<5A5S@hiuiPBf31Ql&qbF=@{BNAmeDAZFN$lG>C!fnGYi8529l=|(r$o7X~1&Ojs zr0nv0*gWh`K)giRDN-&DOHPQKe&Jh*@|{SjQLI|D|JdsbB+6}(l34POe1*#gKbI(f zh?FVy?pNwPZuTgNQlyHg_4BOq0~dFiB2g-XLaj0T?TD?X?<94VD0M+WSdrDf;fb1M z%N1QFQQC-)B2lrjnaqw zRh>NtF(g{B_;o-@H?+CPICWIS0`O2PnO-ETarD1eH)9$g_C_;N^R_+w?+w|%mnwm{ z*S@k)02GQ{ZrtBmW@qT16m{|?F2$C`9^S{A1$ zwJc6iYFV73)Ur55sbz7BQp@5LrIy7hN-c|1lv)<2D77q3QEFM7qSUfDMX62S_;Oy@x%OLu9i7OBW(=&c#Nkxy|;+}Otb@v#a+y%Fgh#s{%DMd zDU^DDPEqtx#-r$?jH2kHjH2kHjH2kHjH2kHjH2kHjH2kHjH2kHjH2kHjH2kHjH2kH zjH2kHjH2kHjH2kHjH2kHjH2kHjH2kHjH2kHjH2kHjH2kHjB>I*_q#=hXZD}p`;-o` zOB_X95q_^mLW85w#YU;)UZvP$B!!(Wn&Y7Bb}T;UFs1!0qF|I!*v}I8j1=~>M4l1QfMWls9n03YI1)yCdK9gr zq^88QWGLd-=IEv?duG+jhDC`2<)*%%eCgNu2p#`cv$`2}$}C2WzJtx8Go~~Qjg&To z;~X2pJ4Hz$asEsq&cI;rI8t%zI7wqn*I0WdYLfb33of4RM24GF64Q))S5j-yWoGIH zRUe%#p3gLTTf=!!El*plQp(y-laeA>?3^Zxp|{m!>?b7JOn8RTq!kPr90s(c+H7X) zyW!zjC)8nSslVE2#+_=DB_-TGyAD@5E>&!cRc%YjbjO!M94RVneIxRvn~^YEOg2-J zNsr4UlhG(7*>onO3QvRuDNk=o(Xb+&)3Zz#k?FP_at(sN<_@h=Ouz zr;4KOUeEIECFEl4DQNRr+`P`2-Pk3bidi=^p19Gi(pW5-{`N{yCHA)o8b?Zt(*%wL zTBKk}*XZ3@&@R+|FKL}K54wvwxRJzmjls=+Rn(P4d9s!LIi$4wxs=8;u{rp<5||O> zB3K5K;jzZ-S}?Q9Wv~p`pXSe=Je}>?6EokvXHRA>@5W-dmrfyYF1kXidL193Qw@xX zF4&W5d(e;X&Vz?vpmsdhr_?ooPfDoxfea51WJf%}$9~ZP&~7SBm`G=jsj&+hLsX*0 z)DQnQfC_uBBpzLHGEzyFr06I%-xdry8~isqS!1=Su;;Df|CWHxtkG&MSRyp&ur$uv zNZ&NU(qHUDxNw!&$*BZml1YoM1#<+fGlUKt7|Lb~QEsY*Z?8jnexah-1#@Mj*=Nq( zc9zmjT!P4Nlt!d7exHfU6YP+j~3VD@~ufYA+N$G&9(f(lxEaoQ%hw- zCqW~WO_SnAg&cQ6fgCxkvGQ&aLaqG`i6*_(gFx{vQJ9!w-(!x1-)6Y zSW^TW^}nbfEBwWEAmel&Sw_w$X((7#S|M3y6ew7Md|Q9y2o{S8|CEVUWlHRgphX3* zHCXLl%RGjpvC-au)nQ2=clDd&PNJ=K#0 zM{z|%vZAx9V*QpDEeG8>%XL#FISdL}IdYgTo_az@{cwI>SKy=xfP34?)daIVDV4>I z{w_QcqFhj3ODf8f!vlH2a%8@iEQcOWI+0hgZqN#^byU7yKo^(lrnm}sL##qBRHMKZ z#us%)Os8?QC+ZgMZ3`u}CRuc5i~{bOiYpd8-W7?N$CDc?sRx-#*^o!WvU`V89#$Yx znZR^p{UV}nt1icIgc*=QaAy_QOn9PeB73T*rr|skL!NU6vqq$+$P+xT4)R>qtNBbM zQ_HGJ7Wk1B6`hWD=aJ5LLUayZNvInsP?H^y;hM2a@X^YG``QMdBc9>BjwIvvXk+mp zCGm=vto!!lW%gsE;Im&!Q!Yf;S{B&wSlCUmbKD7OIdU)~5rbbI|Mb)fh^GY$3+uFk z*&-xiuIA&0 zu&JL4Yc0t-i`7OZL==-|(eYVU0D^x+n9 z*73FG;?CtHF?2NDQE*g~=ZH$O8u+EeYw=3cQt(O%=ng;iRHy8IZK9o1n}|o!aW4K_Z-&HCVlSD!bFV2+5GI zlQ0RM^k8pUfRp{A*Tw;H4<(l`1A|eXRIwLzh@bnD+GI=;#7mtqNuP$0&7QQP(h>!W zBS{U}nBqt#Vp&Vj7#(R^X2f!|BW>2BljKt>Hy);RJ9JjcX2YPTvO5?>b>yc7E>-q%tBp=i(ju(K5jj^7@otl@#IAgZFrT)rSKfDbP?-WCPmDFE}rLAqAuh( zk_1YDn|fgQ7FJm>bD)3p(tY>B?|~G4fw3)?FG;vDD2}s&0sm!9!P6W7M?%@%8Z>!v z*-h*SguZDSJ?#+S(+NhK%7!IJAxUf1sI0ma6&6V_UB~e1MiGU(@hF8{*4to_(0zyJ zS}jO*P2|Eo$TpD43|2l+dAOHvK2b^7`+ty+;f?iC2Eky`S+IqKl@p`Jtm7-R@mA_L zG&)N-t?nh7GT6E;oA$Xe-YZ;qpvX@PQ>ZGoD&$6jlM>)|N~`!eSr0{Z6o{;2FC2?$ zJoU+ZV-9Tmf>nYiSCsA3fl-#C0^@~CQdYUy&{wBTWLtD-%@#aLFIcUj;yKjPpz&I@ zeMGfX7A@99ASK=?n2dIDav{>jUwA*0r4N<`Mb|T#5#pv`IS;R_VI>?%ZaX(_!{3CZ z3Sh6qs`zWL9vtPBJy-CHyRWQ$Fn5V3duY%MQejZ5BSo*Ay3vQ7H-$L%3t`mU+;=Z1R*e{D*J&oA*6SZKnUm{M#k=oa_C#yGb^9n>6afG+->oQX{6EFR;%82q5 z;l945JxY=t7_g8QNmy``9Mg@JCCAR$qp@SSRCuuoi$@Vy`Ru(OMoR4Cr(valz1%<} zCr`V1V$NHcw)AR*#LYDK)CykJm*xZ>T8^^z$|{HrQ7k@fW4bAo>@G;7n^pXjP9*bM zEST$(#PQON6!~osBTvGUqhdWynw;uNDi+PQm>yaT-qh==asI=N9{4w;!HoY>M2C+N zkXdO@06o6Rn5=WRPi8+*4SxG28n3v~2?mdiF1a`TiiIALz9g2x|cq3V&WgW7_w3o7+>JGA;(q77P@(##yN_#0w?tTy@PkJdy=G+h@ zOL-~BUi)aAkRwZZDTjLkYm|^9OTCdC*5Dm-Sn7@Bu$JeL!%|+#v3m};k1XY-9J|+W zIkMCn%0XFBF1sA0yp$vL-4G)syp$ph3Xmctyp+Pdg~ky_RZ+rADbg?oDN@2qDclsi zmK@h!%5V(2P{eWVr3`Kpu7~6Ljbw07;WE7U6GzyAvN*0^PY?S-1P&fkjY!9k!A{?( zqVUEZ@bDAFf+gO58_uU1XjvRFrG4b06U>N^3@W`zgYCrE80&IyKnsZ-!J`=VI?2Zb zM^Mpzqq`W;1qb9y>{vvk6?K%+YD&ZE2*s5uYns_?vbftLd-Y~WeKiv%q*tCuytsmw zY{A(g$CKO`N4m*bG96ib&80+o7MNvErn$Gr^#yyFDvxv7n>17mHyYc`og?mn2HdOd zkr9mY2I9nM#UV|KUfw{M>+r=ZVfJwb?UjwoPHAt8DH|l1FsHP)!sHURcC*>f0WiYw7+HiC5x^i55 zE5s4QKo!Tew?epGxF(M4Hxt4=#?csFSBOGrfdcUugJgIDc{7H3FV4*v>b)>GW2iU6>^+{n z^K?p6eS0HJ);%eYq236S51Lf@eEp5->8)P8P1}noOL;3uHflm8S;|{Myji*!L%p#Q z7vy3L^azvj%#m=c2vc^rhaZ`;C#c4o!G&iKYOL!<900PF-vb4W76tr zyv6&LoJ~cqX|hX<(_*NA92}Cu5rHJf>#r7#F~!|}p7V{cfO7^ngNL!)!9vH*=v`g8 z7<(Q&x8<>P_V-wEIO5f}Y3cOoYfEtPHZ7g{@+8BAg#;9VeqL)G!BKt}Z{N~0J;g&( U)sgWW^@!(-yzIUE|H1$KAHVYeTmS$7 literal 0 HcmV?d00001 diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..5f6e03a --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'drizzle-kit'; +import { ENV } from './src/config/env'; + +export default defineConfig({ + out: './drizzle', + schema: './src/db/schema.ts', + dialect: 'postgresql', + dbCredentials: { + url: ENV.DATABASE_URL!, + }, +}); diff --git a/drizzle/0000_workable_iron_man.sql b/drizzle/0000_workable_iron_man.sql new file mode 100644 index 0000000..5e49d87 --- /dev/null +++ b/drizzle/0000_workable_iron_man.sql @@ -0,0 +1,52 @@ +CREATE TABLE "project_category" ( + "category_id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid, + "category" text NOT NULL, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now() +); +--> statement-breakpoint +CREATE TABLE "projects" ( + "project_id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "userId" uuid, + "category" uuid, + "object" json, + "name" text, + "description" text, + "is_public" boolean DEFAULT true NOT NULL, + "preview_url" text, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now() +); +--> statement-breakpoint +CREATE TABLE "shapes" ( + "shape_id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "shapes" text NOT NULL, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now() +); +--> statement-breakpoint +CREATE TABLE "uploads" ( + "upload_id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "filename" text NOT NULL, + "url" text NOT NULL, + "projectId" uuid, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now() +); +--> statement-breakpoint +CREATE TABLE "users" ( + "user_id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "email" text NOT NULL, + "name" text, + "password" text NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "is_verified" boolean DEFAULT false NOT NULL, + "is_admin" boolean DEFAULT false NOT NULL, + "refresh_token" text +); +--> statement-breakpoint +ALTER TABLE "project_category" ADD CONSTRAINT "project_category_user_id_users_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("user_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "projects" ADD CONSTRAINT "projects_userId_users_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."users"("user_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "projects" ADD CONSTRAINT "projects_category_project_category_category_id_fk" FOREIGN KEY ("category") REFERENCES "public"."project_category"("category_id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "uploads" ADD CONSTRAINT "uploads_projectId_projects_project_id_fk" FOREIGN KEY ("projectId") REFERENCES "public"."projects"("project_id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..60a359e --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,354 @@ +{ + "id": "90741dda-2291-48fc-a730-1d0c20ef1991", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.project_category": { + "name": "project_category", + "schema": "", + "columns": { + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_category_user_id_users_user_id_fk": { + "name": "project_category_user_id_users_user_id_fk", + "tableFrom": "project_category", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "object": { + "name": "object", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "preview_url": { + "name": "preview_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "projects_userId_users_user_id_fk": { + "name": "projects_userId_users_user_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "user_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "projects_category_project_category_category_id_fk": { + "name": "projects_category_project_category_category_id_fk", + "tableFrom": "projects", + "tableTo": "project_category", + "columnsFrom": [ + "category" + ], + "columnsTo": [ + "category_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shapes": { + "name": "shapes", + "schema": "", + "columns": { + "shape_id": { + "name": "shape_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "shapes": { + "name": "shapes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.uploads": { + "name": "uploads", + "schema": "", + "columns": { + "upload_id": { + "name": "upload_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "projectId": { + "name": "projectId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "uploads_projectId_projects_project_id_fk": { + "name": "uploads_projectId_projects_project_id_fk", + "tableFrom": "uploads", + "tableTo": "projects", + "columnsFrom": [ + "projectId" + ], + "columnsTo": [ + "project_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_verified": { + "name": "is_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..43dae21 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1739942538665, + "tag": "0000_workable_iron_man", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/env.example.js b/env.example.js new file mode 100644 index 0000000..13e0dc3 --- /dev/null +++ b/env.example.js @@ -0,0 +1,18 @@ +// all of these are required to run the project + + +// SERVER_URL +// SERVER_PORT + +// DATABASE_URL + +// MINIO_ACCESS_KEY +// MINIO_SECRET_KEY +// MINIO_ENDPOINT +// MINIO_PORT + +// CLERK_SECRET_KEY + +// JWT_ACCESS_TOKEN_SECRET + +// JWT_REFRESH_TOKEN_SECRET \ No newline at end of file diff --git a/env.examples b/env.examples new file mode 100644 index 0000000..798e1ca --- /dev/null +++ b/env.examples @@ -0,0 +1,28 @@ +SERVER_URL= +SERVER_PORT= + +DATABASE_URL= + +MINIO_ACCESS_KEY= +MINIO_SECRET_KEY= +MINIO_ENDPOINT= +MINIO_PORT= + +JWT_ACCESS_TOKEN_SECRET= + +JWT_REFRESH_TOKEN_SECRET= + +JWT_EMAIL_TOKEN_SECRET= + +JWT_EMAIL_RESET_PASSWORD_SECRET= + + +USER_CANVAS_JWT_ACCESS_TOKEN_SECRET= + +MAIL_HOST= +MAIL_PORT= +MAIL_USER= +MAIL_PASS= + +PEXELS_URL= +PEXELS_ACCESS_KEY= \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..a34c745 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "canvas_backend", + "version": "1.0.50", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "db:studio": "drizzle-kit studio", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:push": "drizzle-kit push:pg", + "dev": "bun run --watch src/app.ts" + }, + "dependencies": { + "@elysiajs/cookie": "^0.8.0", + "@elysiajs/cors": "^1.2.0", + "@elysiajs/swagger": "^1.2.0", + "dotenv": "^16.4.7", + "drizzle-orm": "^0.38.4", + "elysia": "latest", + "jsonwebtoken": "^9.0.2", + "minio": "^8.0.3", + "nodemailer": "^6.10.0", + "pg": "^8.13.1", + "postgres": "^3.4.5" + }, + "devDependencies": { + "@types/pg": "^8.11.10", + "bun-types": "latest", + "drizzle-kit": "^0.30.2", + "tsx": "^4.19.2" + }, + "module": "src/app.js" +} \ No newline at end of file diff --git a/src/api/auth/auth.controller.ts b/src/api/auth/auth.controller.ts new file mode 100644 index 0000000..9ca4ca2 --- /dev/null +++ b/src/api/auth/auth.controller.ts @@ -0,0 +1,150 @@ +import { ENV } from "../../config/env" +// @ts-ignore +import jwt from "jsonwebtoken"; +import { sendResetPasswordEmail, sendVerificationEmail, storeRefreshToken } from "../../helper/auth/auth.helper"; +import { db } from "../../db"; +import { users } from "../../db/schema"; +// @ts-ignore +import { eq } from "drizzle-orm"; +import path from "path"; + +export const registerUser = async (email: string, password: string, name: string, set: any) => { + const bcryptHash = await Bun.password.hash(password, { + algorithm: "bcrypt", + cost: 10, // number between 4-31 + }); + + const saveUser = await db.insert(users).values({ email, password: bcryptHash, name }).returning({ id: users.id }); + + if (saveUser.length > 0 && saveUser[0].id) { + const token = jwt.sign({ id: saveUser[0].id }, ENV.JWT_EMAIL_TOKEN_SECRET, { expiresIn: '10m' }); + const response = await sendVerificationEmail(email, token, set); + return response; + } else { + set.status = 500; + return { status: 500, message: "Failed to register user" }; + } +}; + +export const loginUser = async (email: string, cookie: any, set: any) => { + try { + // generate access token + const accessToken = jwt.sign({ email }, ENV.JWT_ACCESS_TOKEN_SECRET, { expiresIn: '3h' }); + + // generate refresh token + const refreshToken = jwt.sign({ email }, ENV.JWT_REFRESH_TOKEN_SECRET, { expiresIn: '7d' }); + + // store refresh token in db + const storeRToken = await storeRefreshToken(email, refreshToken); + + if (storeRToken.success === true) { + cookie.access_token.set({ + value: accessToken, + httpOnly: true, + secure: true, // Set to true in production + sameSite: 'none', // Adjust based on your needs + path: "/", + maxAge: 3 * 60 * 60, // 3 hours in seconds + }); + cookie.refresh_token.set({ + value: refreshToken, + httpOnly: true, + secure: true, // Set to true in production + sameSite: 'none', // Adjust based on your needs + path: "/", + maxAge: 7 * 24 * 60 * 60, // 7 days in seconds + }); + set.status = 200; + return { status: 200, message: "User log-in successful", token: accessToken }; + } + else { + set.status = 500; + return { status: 500, message: storeRToken.message }; + } + } catch (error) { + console.error(error); + set.status = 500; + return { status: 500, message: "Internal server error" }; + } +} + +// for resetting password, this will send the verification e-mail +export const resetPassword = async (email: string, set: any) => { + try { + const user = await db.select({ id: users.id }).from(users).where(eq(users.email, email)); + if (user.length === 0) { + set.status = 404; + return { status: 404, message: "User not found" }; + } + else { + const token = jwt.sign({ id: user[0].id }, ENV.JWT_EMAIL_RESET_PASSWORD_SECRET, { expiresIn: '10m' }); + const sendEmail = await sendResetPasswordEmail(email, token, set); + return sendEmail; + } + } + catch (error) { + console.error(error); + set.status = 500; + return { status: 500, message: "Internal server error" }; + } +} + +// for resending verification e-mail this is only for users who have not verified their e-mail yet +export const resendVerificationEmail = async (email: string, set: any) => { + try { + const user = await db.select({ id: users.id, isVerified: users.is_verified, isActive: users.is_active }).from(users).where(eq(users.email, email)); + if (user.length === 0) { + set.status = 404; + return { status: 404, message: "User not found" }; + } + else { + if (user[0].isVerified) { + set.status = 400; + return { status: 400, message: "User already verified" }; + } + else if (!user[0].isActive) { + set.status = 400; + return { status: 400, message: "User is not active" }; + } + else { + const token = jwt.sign({ id: user[0].id }, ENV.JWT_EMAIL_TOKEN_SECRET, { expiresIn: '10m' }); + const sendEmail = await sendVerificationEmail(email, token, set); + return sendEmail; + } + } + } catch (error) { + console.error(error); + set.status = 500; + return { status: 500, message: "Internal server error" }; + } +} + +// this controller is for e-mail verification +export const verifyUser = async (id: string, set: any) => { + try { + const user = await db.select({ isVerified: users.is_verified }).from(users).where(eq(users.id, id)); + + if (user.length === 0) { + set.status = 404; + return Bun.file(path.resolve('./src/html/error.html')); + } + + if (user[0].isVerified) { + set.status = 200; + return Bun.file(path.resolve('./src/html/alreadyVerify.html')); + } + + await db.update(users).set({ is_verified: true }).where(eq(users.id, id)); + set.status = 200; + return Bun.file(path.resolve('./src/html/success.html')); + } catch (error) { + console.log(error); + set.status = 500; + return { status: 500, message: "Internal server error" }; + } +} + + + + + diff --git a/src/api/auth/auth.route.ts b/src/api/auth/auth.route.ts new file mode 100644 index 0000000..49ade50 --- /dev/null +++ b/src/api/auth/auth.route.ts @@ -0,0 +1,178 @@ +import Elysia, { t } from "elysia"; +import { verifyAuth } from "../../middlewares/auth.middlewares"; +import { loginUser, registerUser, resendVerificationEmail, resetPassword, verifyUser } from "./auth.controller"; +import { checkUserInDb } from "../../helper/auth/auth.helper"; +// @ts-ignore +import jwt from "jsonwebtoken"; +import { ENV } from "../../config/env"; +import path from "path"; +import { db } from "../../db"; +import { users } from "../../db/schema"; +import { eq } from "drizzle-orm"; + +export const authRoute = new Elysia({ + prefix: "/auth", + tags: ["Auth"], + detail: { + description: "Routes for managing users", + } +}); + +authRoute.post("/login", async ({ body, cookie, set }) => { + const { email, password } = body; + const findUser = await checkUserInDb(email, password); + if (findUser.success === true && findUser.can_login === true) { + const response = await loginUser(findUser.email as string, cookie, set); + return response; + } + else if (findUser.message === "User not verified") { + set.status = 401; + return { status: 401, message: "User not verified, please verify your email" }; + } + else { + set.status = 404; + return { status: 404, message: findUser.message }; + } +}, { + body: t.Object({ + email: t.String(), + password: t.String() + }) +}); + +authRoute.post("/register", async ({ body, set }) => { + const { email, password, name } = body; + const findUser = await checkUserInDb(email, password); + if (findUser.success === true && findUser.can_register === true) { + const response = await registerUser(email, password, name, set); + return response; + } + else { + set.status = 409; + return { status: 409, message: "User already exists, please login" }; + } +}, { + body: t.Object({ + email: t.String(), + password: t.String(), + name: t.String(), + }) +}) + +authRoute.get("/isAuthenticated", async ({ cookie }) => { + const response = await verifyAuth(cookie); + return response; +}) + +// for resetting password +authRoute.post('/reset-password/verify', async ({ body, set }) => { + const { email } = body; + const response = await resetPassword(email, set); + return response; +}, { + body: t.Object({ + email: t.String(), + }) +}) + +// for e-mail verification +authRoute.get('/verify', async ({ query, set }) => { + const { token } = query; + try { + const decoded = jwt.verify(token, ENV.JWT_EMAIL_TOKEN_SECRET); + const response = await verifyUser(decoded.id as string, set); + return response; + } catch (error: any) { + set.status = 400; + if (error.name === 'TokenExpiredError') { + return Bun.file(path.resolve('./src/html/error.html')); + } + return Bun.file(path.resolve('./src/html/error.html')); + } +}); + +// for resend verification email, if user did not receive the verification email or the verification email expired +authRoute.post('/resend-verification', async ({ body, set }) => { + const { email } = body; + const response = await resendVerificationEmail(email, set); + return response; +}, { + body: t.Object({ + email: t.String(), + }) +}); + +authRoute.get("/reset-password", async ({ query, set }) => { + const { token } = query; + try { + const decoded = jwt.verify(token, ENV.JWT_EMAIL_RESET_PASSWORD_SECRET) as { id: string }; + + if (!decoded?.id) { + set.status = 400; + return { status: 400, message: "Invalid token" }; + } + + // Serve the reset password page where the user can enter a new password + return Bun.file(path.resolve('./src/html/resetPassword.html')); + + } catch (error: any) { + set.status = 400; + if (error.name === 'TokenExpiredError') { + return Bun.file(path.resolve('./src/html/error.html')); + } + return Bun.file(path.resolve('./src/html/error.html')); + } +}, { + query: t.Object({ + token: t.String(), + }) +}); + +authRoute.post("/reset-password", async ({ query, set, body }) => { + const { token } = query; + const { password } = body; + + try { + const decoded = jwt.verify(token, ENV.JWT_EMAIL_RESET_PASSWORD_SECRET) as { id: string }; + + if (!decoded?.id) { + set.status = 400; + return { status: 400, message: "Invalid token" }; + } + + // Hash the new password + const bcryptHash = await Bun.password.hash(password, { + algorithm: "bcrypt", + cost: 10, + }); + + // Update password in the database + const updatePassword = await db + .update(users) + .set({ password: bcryptHash }) + .where(eq(users.id, decoded.id)); + + if (updatePassword?.rowCount !== 0) { + return { status: 200, message: "Password updated successfully" }; + } else { + set.status = 400; + return { status: 400, message: "Password update failed" }; + } + + } catch (error: any) { + set.status = 400; + if (error.name === "TokenExpiredError") { + return { status: 400, message: "Token has expired" }; + } + return { status: 400, message: "Invalid request" }; + } +}, { + body: t.Object({ + password: t.String(), + }), + query: t.Object({ + token: t.String(), + }) +}); + + diff --git a/src/api/category/category.controller.ts b/src/api/category/category.controller.ts new file mode 100644 index 0000000..ed9919c --- /dev/null +++ b/src/api/category/category.controller.ts @@ -0,0 +1,45 @@ +import { eq } from "drizzle-orm"; +import { db } from "../../db"; +import { category } from "../../db/schema"; + +export const getAllCategory = async (token: string) => { + try { + const allCategory = await db.select({ id: category?.id, name: category?.category }).from(category); + + if (allCategory.length === 0) { + return { status: 404, message: "No categories found", token } + } + + return { status: 200, message: "Categories fetched successfully", data: allCategory, token }; + + } catch (error: any) { + console.error('Error fetching categories:', error); + return { status: 500, message: "An error occurred while fetching the categories", token }; + } +} + +export const createCategory = async (token: string, name: string, user_id: string) => { + try { + const newCategory = await db.insert(category).values({ category: name, user_id: user_id }); + + return { status: 200, message: "Category created successfully", data: newCategory, token }; + + } catch (error: any) { + console.error('Error creating category:', error); + return { status: 500, message: "An error occurred while creating the category", token }; + } +} + +export const deleteCategory = async (token: string, id: string) => { + try { + const deleted = await db.delete(category).where(eq(category?.id, id)).returning({ id: category?.id, name: category?.category }); + if (deleted.length === 0) { + return { status: 404, message: "Category not found", token }; + } + return { status: 200, message: "Category deleted successfully", data: deleted, token }; + } + catch (error: any) { + console.error('Error deleting category:', error); + return { status: 500, message: "An error occurred while deleting the category", token }; + } +} \ No newline at end of file diff --git a/src/api/category/category.route.ts b/src/api/category/category.route.ts new file mode 100644 index 0000000..c1e553e --- /dev/null +++ b/src/api/category/category.route.ts @@ -0,0 +1,53 @@ +import Elysia, { t } from "elysia"; +import { verifyAuth } from "../../middlewares/auth.middlewares"; +import { createCategory, deleteCategory, getAllCategory } from "./category.controller"; + +export const categoryRoutes = new Elysia({ + prefix: "/category", + tags: ["Category routes"], + detail: { + description: "Routes for managing project category", + } +}).derive(async ({ cookie }) => { + const authData = await verifyAuth(cookie); + return { authData }; // Inject into context +}); + +categoryRoutes.get("/get-all", async ({ authData }) => { + if (authData?.status !== 200) + return authData; + else { + const token = authData?.token; + const data = await getAllCategory(token); + return data; + } +}) + +categoryRoutes.post("/create", async ({ authData, body: { name } }) => { + if (authData?.status !== 200) + return authData; + else { + const token = authData?.token; + const user_id = authData?.userId; + const data = await createCategory(token, name, user_id); + return data; + } +}, { + body: t.Object({ + name: t.String(), + }) +}) + +categoryRoutes.delete("/delete/:id", async ({ authData, params: { id } }) => { + if (authData?.status !== 200) + return authData; + else { + const token = authData?.token; + const data = await deleteCategory(token, id); + return data; + } +}, { + params: t.Object({ + id: t.String(), + }) +}) \ No newline at end of file diff --git a/src/api/design/design.controller.ts b/src/api/design/design.controller.ts new file mode 100644 index 0000000..697a164 --- /dev/null +++ b/src/api/design/design.controller.ts @@ -0,0 +1,29 @@ +import { eq } from "drizzle-orm"; +import { db } from "../../db"; +import { projects } from "../../db/schema"; + +export const getAllDesign = async (token: string) => { + try { + const getDesign = await db.select({ + object: projects.object, + name: projects.name, + description: projects.description, + preview_url: projects.preview_url, + }).from(projects).where(eq(projects.is_public, true)); + + // Filter out projects where the object field is an empty object + const filteredDesigns = getDesign?.filter(project => + project.object && Object.keys(project.object).length > 0 + ); + + if (filteredDesigns.length === 0) { + return { status: 404, message: "No designs found", token }; + } else { + return { status: 200, message: "Designs fetched successfully", data: filteredDesigns, token }; + } + + } catch (error: any) { + console.error('Error fetching designs:', error); + return { status: 500, message: "Internal Server Error" }; + } +}; diff --git a/src/api/design/design.route.ts b/src/api/design/design.route.ts new file mode 100644 index 0000000..5d66136 --- /dev/null +++ b/src/api/design/design.route.ts @@ -0,0 +1,28 @@ +import { Elysia, t } from "elysia"; +import { ENV } from "../../config/env"; +// @ts-ignore +import jwt from "jsonwebtoken"; +import { getAllDesign } from "./design.controller"; + +export const designRoutes = new Elysia({ + prefix: "/design", + tags: ["Design"], + detail: { + description: "Routes for managing design", + } +}).derive(({ headers }) => { + const token = headers?.authorization?.split(" ")[1]; + return { token }; +}); + +designRoutes.get("/", async ({ token }) => { + const verifyToken = await jwt.verify(token, ENV.USER_CANVAS_JWT_ACCESS_TOKEN_SECRET); + if (!verifyToken) { + return { status: 401, message: "Unauthorized" } + } + else { + const data = await getAllDesign(token as string); + return data; + } +}); + diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..212e676 --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,24 @@ +import Elysia from "elysia"; +import { projectRoutes } from "./project/project.route"; +import { uploadRoutes } from "./upload/upload.route"; +import { authRoute } from "./auth/auth.route"; +import { uploadShapesRoutes } from "./uploadShapes/upload.shapes.route"; +import { photoLibraryRoutes } from "./photoLibrary/photo.library.route"; +import { categoryRoutes } from "./category/category.route"; +import { designRoutes } from "./design/design.route"; + +export const api = new Elysia({ + prefix: "/api", +}); + +api.get("/", () => { + return "Hello from PlanPostAI Canvas API" +}) + +api.use(authRoute); +api.use(projectRoutes); +api.use(uploadRoutes); +api.use(photoLibraryRoutes); +api.use(uploadShapesRoutes); +api.use(categoryRoutes); +api.use(designRoutes); \ No newline at end of file diff --git a/src/api/photoLibrary/photo.library.controller.ts b/src/api/photoLibrary/photo.library.controller.ts new file mode 100644 index 0000000..82f9b0c --- /dev/null +++ b/src/api/photoLibrary/photo.library.controller.ts @@ -0,0 +1,21 @@ +import { ENV } from "../../config/env"; + +export const getPhotos = async (keyword: string, pre_page: number, token: string) => { + try { + const url = `${ENV.PEXELS_URL}/search?query=${keyword}&per_page=${pre_page}`; + const response = await fetch(url, { + headers: { + Authorization: process.env.PEXELS_ACCESS_KEY as string, + }, + }) + + if (!response.ok) { + return { status: 500, message: "An error occurred while getting the photos", token } + } + const data = await response.json(); + return { data, token } + } catch (error: any) { + console.log("Error in getting photos:", error.message || error.toString()); + return { status: 500, message: "An error occurred while getting the photos", token }; + } +} \ No newline at end of file diff --git a/src/api/photoLibrary/photo.library.route.ts b/src/api/photoLibrary/photo.library.route.ts new file mode 100644 index 0000000..643d775 --- /dev/null +++ b/src/api/photoLibrary/photo.library.route.ts @@ -0,0 +1,31 @@ +import Elysia, { t } from "elysia"; +import { verifyAuth } from "../../middlewares/auth.middlewares"; +import { getPhotos } from "./photo.library.controller"; + +export const photoLibraryRoutes = new Elysia({ + prefix: "/photos", + tags: ["Photo library"], + detail: { + description: "Routes for managing photo library", + } +}).derive(async ({ cookie }) => { + const authData = await verifyAuth(cookie); + return { authData }; // Inject into context +}); + +photoLibraryRoutes.get("/", async ({ query, authData +}) => { + if (authData?.status !== 200) + return authData; + else { + const { keyword, per_page } = query; + const token = authData?.token; + const data = await getPhotos(keyword, per_page, token); + return { data }; + } +}, { + query: t.Object({ + keyword: t.String(), + per_page: t.Number(), + }) +}) \ No newline at end of file diff --git a/src/api/project/project.controller.ts b/src/api/project/project.controller.ts new file mode 100644 index 0000000..7730a0c --- /dev/null +++ b/src/api/project/project.controller.ts @@ -0,0 +1,191 @@ +import { eq } from "drizzle-orm"; +import { db } from "../../db"; +import { category, projects, uploads } from "../../db/schema"; +import { createEmptyProject } from "../../helper/projects/createProject"; +import { createBucket } from "../../helper/upload/createBucket"; +import { removeBucket } from "../../helper/upload/removeBucket"; + +export const getAllProjects = async (userId: string, token: string) => { + try { + // Fetch all projects for the given user + const allProjects = await db.select({ + id: projects.id, + name: projects.name, + description: projects.description, + preview_url: projects.preview_url, + object: projects.object, + category_name: category.category, + category: category.id, + }).from(projects).leftJoin(category, eq(projects.category, category.id)).where(eq(projects.userId, userId)); + + // Identify projects where 'object' is empty or 'object.objects' is empty + const projectsToDelete = allProjects?.filter(proj => + (proj.object && typeof proj.object === "object" && Object.keys(proj.object).length === 0) || + (proj.object?.objects && Array.isArray(proj.object.objects) && proj.object.objects.length === 0) + ); + + // Delete projects with empty 'object' or empty 'object.objects' + await Promise.all( + projectsToDelete?.map(async (proj) => { + // Step 1: Delete associated uploads first + await db.delete(uploads).where(eq(uploads.projectId, proj.id)); + + // Step 2: Delete the project itself + await db.delete(projects).where(eq(projects.id, proj.id)); + + // Step 3: Delete the associated bucket + await removeBucket(proj.id); + }) + ); + + // Get remaining projects + const remainingProjects = allProjects?.filter(proj => + !( + (proj.object && typeof proj.object === "object" && Object.keys(proj.object).length === 0) || + (proj.object?.objects && Array.isArray(proj.object.objects) && proj.object.objects.length === 0) + ) + ); + + if (remainingProjects?.length === 0) { + return { status: 404, message: "No projects found", token }; + } + + return { status: 200, message: "Projects fetched successfully", data: remainingProjects, token }; + } catch (error: any) { + console.log(error.message); + return { status: 500, message: "An error occurred while fetching projects", token }; + } +}; + +export const getEachProjects = async (id: string, token: string) => { + try { + const project = await db.select({ + id: projects.id, + name: projects.name, + description: projects.description, + preview_url: projects.preview_url, + object: projects.object, + category_name: category.category, + category_id: projects.category, + }).from(projects).leftJoin(category, eq(projects.category, category.id)).where(eq(projects.id, id)); + + if (project.length === 0) { + return { status: 404, message: "Project not found", token }; + } + return { status: 200, message: "Project fetched successfully", data: project[0], token }; + } catch (error: any) { + console.log(error.message); + return { status: 500, message: "An error occurred while fetching projects", token }; + } +}; + +export const createProject = async (userId: string, token: string) => { + try { + const { id } = await createEmptyProject(userId); + const bucket = await createBucket(id); + return { status: 200, message: "New project created successfully", data: { id, bucketName: bucket }, token }; + + } catch (error: any) { + console.log(error.message); + return { status: 500, message: "An error occurred while creating projects", token } + } +}; + +export const updateProject = async (id: string, body: any, token: string) => { + try { + // 1. Validate if project exists + const existingProject = await db.select().from(projects).where(eq(projects.id, id)); + if (existingProject.length === 0) { + return { status: 404, message: "Project not found", token }; + } + + const { object, name, description, preview_url, category } = body; + + // The preview_url will come from client-side as well, where before updating the project a project capture will be taken and uploaded to the bucket. than the url will be sent to the server.And rest of them are normal process + + // 2. Validate if the category exists + if (category === "" || category === undefined || category === null) { + const updatedProject = await db.update(projects).set({ + object, + name, + description, + preview_url, + }).where(eq(projects.id, id)).returning({ + id: projects.id, + object: projects.object, + name: projects.name, + description: projects.description, + preview_url: projects.preview_url, + category: projects.category + }); + + if (updatedProject.length === 0) { + return { status: 500, message: "Failed to update the project", token }; + } + return { status: 200, message: "Project updated successfully", data: updatedProject[0], token }; + } + else { + const updatedProject = await db.update(projects).set({ + object, + name, + description, + preview_url, + category + }).where(eq(projects.id, id)).returning({ + id: projects.id, + object: projects.object, + name: projects.name, + description: projects.description, + preview_url: projects.preview_url, + category: projects.category + }); + + if (updatedProject.length === 0) { + return { status: 500, message: "Failed to update the project", token }; + } + return { status: 200, message: "Project updated successfully", data: updatedProject[0], token }; + } + } catch (error: any) { + console.log("Error updating project:", error.message || error.toString()); + return { status: 500, message: "An error occurred while updating the project", token }; + } +}; + +export const deleteProject = async (id: string, token: string) => { + try { + const deletedUploads = await db + .delete(uploads) + .where(eq(uploads.projectId, id)) + .returning({ id: uploads.id }); + + if (deletedUploads.length >= 0) { + // Step 4: Delete the project + const deletedProject = await db + .delete(projects) + .where(eq(projects.id, id)) + .returning({ id: projects.id }); + + if (deletedProject.length === 0) { + return { status: 404, message: "Project not found", token }; + } + + // Step 5: Delete the associated bucket + const bucketDeletionResult = await removeBucket(id); + + if (bucketDeletionResult.status !== 200) { + return { + status: bucketDeletionResult.status, + message: `Error deleting bucket: ${bucketDeletionResult.message}`, + token + }; + } + return { status: 200, message: "Project and associated bucket deleted successfully", token }; + } + } catch (error: any) { + console.log("Error in deleteProject:", error.message || error.toString()); + return { status: 500, message: "An error occurred while deleting the project", token }; + } +}; + + + diff --git a/src/api/project/project.route.ts b/src/api/project/project.route.ts new file mode 100644 index 0000000..2ad5b20 --- /dev/null +++ b/src/api/project/project.route.ts @@ -0,0 +1,86 @@ +import { Elysia, t } from "elysia"; +import { createProject, deleteProject, getAllProjects, getEachProjects, updateProject } from "./project.controller"; +import { verifyAuth } from "../../middlewares/auth.middlewares"; + +export const projectRoutes = new Elysia({ + prefix: "/projects", + tags: ["Projects"], + detail: { + description: "Routes for managing projects", + } +}).derive(async ({ cookie }) => { + const authData = await verifyAuth(cookie); + return { authData }; // Inject into context +}); + +projectRoutes.get("/each/:project_id", async ({ params: { project_id }, authData }) => { + if (authData?.status !== 200) + return authData; + else { + const token = authData?.token; + const response = await getEachProjects(project_id, token); + return response; + } +}, { + params: t.Object({ + project_id: t.String() + }) +}); + +projectRoutes.get("/", async ({ authData }: any) => { + if (authData?.status !== 200) + return authData; + else { + const userId = authData.userId; + const token = authData.token; + const response = await getAllProjects(userId, token); + return response; + } +}); + +projectRoutes.post("/create", async ({ authData }: any) => { + if (authData?.status !== 200) + return authData; + else { + const userId = authData.userId; + const token = authData.token; + const response = await createProject(userId, token); + return response; + } +}); + +projectRoutes.put("/update/:project_id", async ({ body, params: { project_id }, authData }) => { + if (authData?.status !== 200) + return authData; + else { + const token = authData?.token; + const response = await updateProject(project_id, body, token); + return response; + } +}, { + params: t.Object({ + project_id: t.String() + }), + body: t.Object({ + object: t.Record(t.String(), t.Any()), // Allows any JSON object + name: t.String(), + description: t.String(), + preview_url: t.String(), + category: t.String(), + }) +}); + +projectRoutes.delete("/delete/:project_id", async ({ params: { project_id }, authData }) => { + if (authData?.status !== 200) + return authData; + else { + const token = authData?.token; + const response = await deleteProject(project_id, token); + return response; + } +}, { + params: t.Object({ + project_id: t.String() + }) +}); + diff --git a/src/api/upload/upload.controller.ts b/src/api/upload/upload.controller.ts new file mode 100644 index 0000000..4d7fab5 --- /dev/null +++ b/src/api/upload/upload.controller.ts @@ -0,0 +1,106 @@ +import { eq } from "drizzle-orm"; +import { db } from "../../db"; +import { projects, uploads } from "../../db/schema"; +import { uploadToMinio } from "../../helper/upload/uploadToMinio"; +import { removeFromMinio } from "../../helper/upload/removeFromMinio"; + +export const uploadPhoto = async (file: File, project_id: string, userId: string, token: string) => { + try { + // Validate userId + if (!userId || typeof userId !== "string") { + return { status: 400, message: "Invalid user ID", token }; + } + + // Validate projectId + if (!project_id || typeof project_id !== "string") { + return { status: 400, message: "Invalid project ID", token }; + } + + // Validate file input + if (!file || !(file instanceof File) || !file.name) { + return { status: 400, message: "Invalid or missing file", token }; + } + const findProject = await db.select().from(projects).where(eq(projects.id, project_id)); + + if (findProject.length > 0) { + // Extract file extension (e.g., ".jpg", ".png") + const fileExtension = file.name.substring(file.name.lastIndexOf(".")); + + // Generate a unique filename using the timestamp + const timestamp = Date.now(); // Current timestamp in milliseconds + const uniqueFileName = `${file.name.split(".")[0]}-${timestamp}${fileExtension}`; + + // Upload file to MinIO with the unique filename + const urlLink = await uploadToMinio(file, project_id, uniqueFileName); + if (!urlLink || !urlLink.url) { + return { status: 500, message: "File upload failed", token }; + } + + // Save file info in DB with modified filename + const saveFile = await db.insert(uploads).values({ + filename: uniqueFileName, + url: urlLink.url, + projectId: project_id, + }).returning(); + + return { status: 200, message: "File uploaded successfully", data: saveFile, token }; + } + else { + return { status: 404, message: "No projects found with this project id", token } + } + } catch (error: any) { + console.error("Error processing file:", error); + return { status: 500, message: "An error occurred while uploading the photo", token }; + } +}; + +export const deletePhoto = async (url: string, token: string) => { + try { + if (!url) { + return { status: 404, message: "File url is missing", token } + } + + const deleteFile = await db + .delete(uploads) + .where(eq(uploads.url, url)) + .returning(); + + // Ensure there's a file to delete + if (!deleteFile || deleteFile.length === 0) { + return { status: 404, message: "File not found", token }; + } + + const { projectId, filename } = deleteFile[0]; + + // Ensure projectId and filename are valid + if (!projectId || !filename) { + return { status: 400, message: "Invalid project ID or filename", token }; + } + + const minioRemove = await removeFromMinio(projectId, filename); + + return { status: 200, message: minioRemove.msg, token }; + + } catch (error: any) { + console.error("Error processing file:", error); + return { status: 500, message: `An error occurred while deleting the photo: ${error.message}`, token }; + } +}; + +export const getAllPhoto = async (id: string, token: string) => { + try { + // project id + if (!id) { + return { status: 404, message: "Project ID is missing", token } + } + const getAllPhoto = await db.select().from(uploads).where(eq(uploads.projectId, id)); + if (getAllPhoto.length === 0) { + return { status: 200, message: "No photos found for the given project ID", data: [], token } + } + return { status: 200, message: "All photos retrieved successfully", data: getAllPhoto, token }; + + } catch (error: any) { + console.log(`Error getting photos: ${error.message}`); + return { status: 500, message: "An error occurred while getting the photos", token } + } +} diff --git a/src/api/upload/upload.route.ts b/src/api/upload/upload.route.ts new file mode 100644 index 0000000..1ed2699 --- /dev/null +++ b/src/api/upload/upload.route.ts @@ -0,0 +1,60 @@ +import { Elysia, t } from "elysia"; +import { deletePhoto, getAllPhoto, uploadPhoto } from "./upload.controller"; +import { verifyAuth } from "../../middlewares/auth.middlewares"; + +export const uploadRoutes = new Elysia({ + prefix: "/uploads", + tags: ["Uploads"], + detail: { + description: "Routes for uploading and managing photos", + } +}).derive(async ({ cookie }) => { + const authData = await verifyAuth(cookie); + return { authData }; // Inject into context +}); + +uploadRoutes.post("/add", async ({ body, authData }) => { + if (authData?.status !== 200) + return authData; + else { + const token = authData?.token; + const user_id: String | any = authData?.userId; + const { id: project_id, file } = body; + const response = await uploadPhoto(file, project_id, user_id, token); + return response; + } +}, { + body: t.Object({ + file: t.File(), + id: t.String(), + }) +}); + +uploadRoutes.delete("/delete", async ({ query, authData }) => { + if (authData?.status !== 200) + return authData; + else { + const token = authData?.token; + const { url } = query; + const response = await deletePhoto(url, token); + return response; + } +}, { + query: t.Object({ + url: t.String(), + }) +}); + +uploadRoutes.get("/getAll/:id", async ({ params: { id }, authData }) => { + if (authData?.status !== 200) + return authData; + else { + const token = authData?.token; + const response = await getAllPhoto(id, token); + return response; + } +}, { + params: t.Object({ + id: t.String() + }) +}); \ No newline at end of file diff --git a/src/api/uploadShapes/upload.shapes.controller.ts b/src/api/uploadShapes/upload.shapes.controller.ts new file mode 100644 index 0000000..ef2bced --- /dev/null +++ b/src/api/uploadShapes/upload.shapes.controller.ts @@ -0,0 +1,42 @@ +import { eq } from "drizzle-orm"; +import { db } from "../../db"; +import { shapes } from "../../db/schema"; + +export const uploadShapes = async (shape: string, token: string) => { + // This function is responsible for uploading shapes to the server. + try { + const shapeUpload = await db.insert(shapes).values({ shapes: shape }).returning(); + return { status: 200, message: "Shape uploaded successfully", token, shapeUpload }; + } catch (error: any) { + console.error("Error processing upload:", error); + return { status: 500, message: "An error occurred while uploading the shape", token }; + } +}; + +export const deleteShape = async (shapeId: string, token: string) => { + try { + const shapeDelete = await db.delete(shapes).where(eq(shapes.id, shapeId)).returning(); + return { status: 200, message: "Shape deleted successfully", token, shapeDelete }; + } catch (error: any) { + console.error("Error deleting shape:", error); + return { status: 500, message: "An error occurred while deleting the shape", token }; + } +}; + +export const getShapes = async (token: string) => { + try { + const allShapes = await db.select().from(shapes); + + if (allShapes.length === 0) { + return { status: 404, message: "No shapes found", token }; + } + + else { + return { status: 200, message: "Shapes retrieved successfully", token, allShapes }; + } + + } catch (error: any) { + console.error("Error getting shapes:", error); + return { status: 500, message: "An error occurred while getting the shapes", token }; + } +}; \ No newline at end of file diff --git a/src/api/uploadShapes/upload.shapes.route.ts b/src/api/uploadShapes/upload.shapes.route.ts new file mode 100644 index 0000000..f91b19a --- /dev/null +++ b/src/api/uploadShapes/upload.shapes.route.ts @@ -0,0 +1,53 @@ +import Elysia, { t } from "elysia"; +import { verifyAuth } from "../../middlewares/auth.middlewares"; +import { deleteShape, getShapes, uploadShapes } from "./upload.shapes.controller"; + +export const uploadShapesRoutes = new Elysia({ + prefix: "/upload-shapes", + tags: ["Shape uploads"], + detail: { + description: "Routes for uploading and managing shapes", + } +}).derive(async ({ cookie }) => { + const authData = await verifyAuth(cookie); + return { authData }; // Inject into context +}); + +uploadShapesRoutes.post("/add", async ({ body, authData }) => { + if (authData?.status !== 200) + return authData; + else { + const token = authData?.token; + const { shape } = body; + const response = await uploadShapes(shape, token); + return response; + } +}, { + body: t.Object({ + shape: t.String(), + }) +}); + +uploadShapesRoutes.delete("/delete/:id", async ({ authData, params: { id } }) => { + if (authData?.status !== 200) + return authData; + else { + const token = authData?.token; + const response = await deleteShape(id, token); + return response; + } +}, { + params: t.Object({ + id: t.String() + }), +}); + +uploadShapesRoutes.get("/get", async ({ authData }) => { + if (authData?.status !== 200) + return authData; + else { + const token = authData?.token; + const response = await getShapes(token); + return response; + } +}); \ No newline at end of file diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..4b7955b --- /dev/null +++ b/src/app.ts @@ -0,0 +1,59 @@ +import { Elysia } from "elysia"; +import swagger from '@elysiajs/swagger'; + +import { ENV } from "./config/env"; +import cors from "@elysiajs/cors"; +import { api } from "./api"; + +const allowedOrigins = [ + "http://localhost:5175", + "http://localhost:5173", + // allowed canvas backend (user) origins(for user) + "https://localhost:3001", +]; + +const app = new Elysia({ + prefix: "", + tags: ["Default"], +}) + .use(cors({ + origin: allowedOrigins, + methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"], + allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With", "Accept", "Origin", "Access-Control-Allow-Origin"], + credentials: true, + })) + .use(swagger({ + path: "/api/docs", + documentation: { + info: { + title: "Canvas API", + version: "1.0.0", + description: "Canvas API Documentation", + }, + tags: [ + { + name: "Projects", + description: "All APIs related to Projects", + }, + { + name: "Uploads", + description: "All APIs related to Uploads" + } + ], + } + })) + .onError(({ code, error }) => { + if (code === 'NOT_FOUND') + return 'Not Found :('; + console.log("hello from app.ts under error"); + console.error(error) + }); + +// all routes here +app.use(api); + +app.listen(ENV.SERVER_PORT, () => { + console.log(`🦊 Elysia is running at ${ENV.SERVER_URL}:${ENV.SERVER_PORT}`) +}) + + diff --git a/src/config/env.ts b/src/config/env.ts new file mode 100644 index 0000000..1f41a62 --- /dev/null +++ b/src/config/env.ts @@ -0,0 +1,22 @@ +import 'dotenv/config' + +export const ENV = { + SERVER_URL: process.env.SERVER_URL, + SERVER_PORT: process.env.SERVER_PORT || 5000, + DATABASE_URL: process.env.DATABASE_URL, + MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY, + MINIO_SECRET_KEY: process.env.MINIO_SECRET_KEY, + MINIO_ENDPOINT: process.env.MINIO_ENDPOINT, + MINIO_PORT: process.env.MINIO_PORT, + JWT_ACCESS_TOKEN_SECRET: process.env.JWT_ACCESS_TOKEN_SECRET, + JWT_REFRESH_TOKEN_SECRET: process.env.JWT_REFRESH_TOKEN_SECRET, + JWT_EMAIL_TOKEN_SECRET: process.env.JWT_EMAIL_TOKEN_SECRET, + JWT_EMAIL_RESET_PASSWORD_SECRET: process.env.JWT_EMAIL_RESET_PASSWORD_SECRET, + USER_CANVAS_JWT_ACCESS_TOKEN_SECRET: process.env.USER_CANVAS_JWT_ACCESS_TOKEN_SECRET, + MAIL_HOST: process.env.MAIL_HOST, + MAIL_PORT: process.env.MAIL_PORT, + MAIL_USER: process.env.MAIL_USER, + MAIL_PASS: process.env.MAIL_PASS, + PEXELS_URL: process.env.PEXELS_URL, + PEXELS_ACCESS_KEY: process.env.PEXELS_ACCESS_KEY, +} \ No newline at end of file diff --git a/src/config/minioClient.ts b/src/config/minioClient.ts new file mode 100644 index 0000000..798b1a1 --- /dev/null +++ b/src/config/minioClient.ts @@ -0,0 +1,10 @@ +import { Client } from "minio"; +import { ENV } from "../config/env"; + +export const minioClient = new Client({ + endPoint: ENV.MINIO_ENDPOINT!, + port: ENV.MINIO_PORT, + useSSL: false, + accessKey: ENV.MINIO_ACCESS_KEY, + secretKey: ENV.MINIO_SECRET_KEY, +}) \ No newline at end of file diff --git a/src/db/index.ts b/src/db/index.ts new file mode 100644 index 0000000..eb8b284 --- /dev/null +++ b/src/db/index.ts @@ -0,0 +1,5 @@ +import { drizzle } from "drizzle-orm/node-postgres"; + +import { ENV } from "../config/env"; + +export const db = drizzle(ENV.DATABASE_URL!); \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts new file mode 100644 index 0000000..296bfe1 --- /dev/null +++ b/src/db/schema.ts @@ -0,0 +1,49 @@ +import { boolean, json, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; + +export const users = pgTable("users", { + id: uuid("user_id").defaultRandom().primaryKey(), + email: text("email").notNull(), + name: text("name"), + password: text("password").notNull(), + is_active: boolean("is_active").notNull().default(true), + is_verified: boolean("is_verified").notNull().default(false), + is_admin: boolean("is_admin").notNull().default(false), + refresh_token: text("refresh_token"), +}); + +export const projects = pgTable("projects", { + id: uuid("project_id").defaultRandom().primaryKey(), + userId: uuid().references(() => users.id), + category: uuid().references(() => category.id), + object: json(), + name: text("name"), + description: text("description"), + is_public: boolean("is_public").notNull().default(true), + preview_url: text("preview_url"), + created_at: timestamp("created_at").defaultNow(), + updated_at: timestamp("updated_at").defaultNow(), +}); + +export const uploads = pgTable("uploads", { + id: uuid("upload_id").defaultRandom().primaryKey(), + filename: text("filename").notNull(), + url: text("url").notNull(), + projectId: uuid().references(() => projects.id), + created_at: timestamp("created_at").defaultNow(), + updated_at: timestamp("updated_at").defaultNow(), +}); + +export const shapes = pgTable("shapes", { + id: uuid("shape_id").defaultRandom().primaryKey(), + shapes: text("shapes").notNull(), + created_at: timestamp("created_at").defaultNow(), + updated_at: timestamp("updated_at").defaultNow(), +}); + +export const category = pgTable("project_category", { + id: uuid("category_id").defaultRandom().primaryKey(), + user_id: uuid().references(() => users.id), + category: text("category").notNull(), + created_at: timestamp("created_at").defaultNow(), + updated_at: timestamp("updated_at").defaultNow(), +}); diff --git a/src/helper/auth/auth.helper.ts b/src/helper/auth/auth.helper.ts new file mode 100644 index 0000000..a0dbe5a --- /dev/null +++ b/src/helper/auth/auth.helper.ts @@ -0,0 +1,177 @@ +import { eq } from "drizzle-orm"; +import { db } from "../../db"; +import { users } from "../../db/schema"; +import { ENV } from "../../config/env"; +// @ts-ignore +import nodemailer from 'nodemailer'; + +export const checkUserInDb = async (email: string, password: string): Promise<{ + success: boolean; + message: string; + can_register?: boolean; + can_login?: boolean; + email?: string; +}> => { + try { + // function isAdspillarEmail(email: string) { + // const regex = /^[a-zA-Z0-9._%+-]+@adspillar\.com$/; + // return regex.test(email); + // } + + // if (!isAdspillarEmail(email)) { + // return { success: false, message: "Invalid email domain", can_register: false, can_login: false }; + // } + + // else { + // const findUser = await db.select({ + // email: users.email, + // password: users.password, + // is_active: users.is_active, + // is_verified: users.is_verified, + // refresh_token: users.refresh_token, + // }).from(users).where(eq(users.email, email)); + + // if (!findUser[0]) { + // return { success: true, message: "User not found", can_register: true }; + // } + + // const hash = findUser[0].password; + // const isMatch = await Bun.password.verify(password, hash); + + // if (isMatch && findUser[0].is_verified && findUser[0].is_active) { + // return { + // success: true, + // message: "User verified successfully", + // can_login: true, + // email: findUser[0].email // Ensure email is included + // }; + // } + // else if (isMatch && findUser[0].is_verified === false && findUser[0].is_active) { + // return { success: false, message: "User not verified", can_login: false }; + // } + // else if (isMatch && findUser[0].is_active === false && findUser[0].is_verified) { + // return { success: false, message: "User not active", can_login: false }; + // } + // else { + // return { success: false, message: "Invalid password", can_login: false }; + // } + // } + + const findUser = await db.select({ + email: users.email, + password: users.password, + is_active: users.is_active, + is_verified: users.is_verified, + refresh_token: users.refresh_token, + }).from(users).where(eq(users.email, email)); + + if (!findUser[0]) { + return { success: true, message: "Wrong credentials", can_register: true }; + } + + const hash = findUser[0].password; + const isMatch = await Bun.password.verify(password, hash); + + if (isMatch && findUser[0].is_verified && findUser[0].is_active) { + return { + success: true, + message: "User verified successfully", + can_login: true, + email: findUser[0].email, // Ensure email is included + can_register: false + }; + } + else if (isMatch && findUser[0].is_verified === false && findUser[0].is_active) { + return { success: false, message: "User not verified", can_login: false, can_register: false }; + } + else if (isMatch && findUser[0].is_active === false && findUser[0].is_verified) { + return { success: false, message: "User not active", can_login: false, can_register: false }; + } + else { + return { success: false, message: "Invalid credentials", can_login: false, can_register: false }; + } + + } catch (error: any) { + console.log("Error verifying user:", error); + return { success: false, message: "Error verifying user" }; + } +}; + + +export const storeRefreshToken = async (email: string, refreshToken: string): Promise<{ success: boolean; message: string }> => { + try { + await db.update(users).set({ refresh_token: refreshToken }).where(eq(users.email, email)); + return { success: true, message: "Refresh token stored successfully" }; + } catch (error) { + console.log("Error storing refresh token:", error); + return { success: false, message: "Error storing refresh token" }; + } +} + +export const sendVerificationEmail = async (email: string, token: string, set: any) => { + const sendEmail = async (email: string, token: string) => { + try { + const transporter = nodemailer.createTransport({ + host: ENV.MAIL_HOST, + port: ENV.MAIL_PORT, + auth: { + user: ENV.MAIL_USER, + pass: ENV.MAIL_PASS, + }, + }); + + const url = `${ENV.SERVER_URL}:${ENV.SERVER_PORT}/api/auth/verify?token=${token}`; + const mailOptions = { + from: ENV.MAIL_USER, + to: email, + subject: 'Verify Your Email Address', + html: `

Please verify your email by clicking the following link:

+

Verify email

+

This link will be valid for the next 10 minutes.

`, + }; + + await transporter.sendMail(mailOptions); + return { status: 200, message: "Verification email sent, link will valid till next 10 minutes" }; + } catch (error) { + console.error("Error sending email:", error); + return { status: 500, message: "Internal server error, unable to send email" }; + } + }; + const emailResponse = await sendEmail(email, token); + set.status = emailResponse.status; + return emailResponse; +} + +export const sendResetPasswordEmail = async (email: string, token: string, set: any) => { + const sendEmail = async (email: string, token: string) => { + try { + const transporter = nodemailer.createTransport({ + host: ENV.MAIL_HOST, + port: ENV.MAIL_PORT, + auth: { + user: ENV.MAIL_USER, + pass: ENV.MAIL_PASS, + }, + }); + + const url = `${ENV.SERVER_URL}:${ENV.SERVER_PORT}/api/auth/reset-password?token=${token}`; + const mailOptions = { + from: ENV.MAIL_USER, + to: email, + subject: 'Reset Your Password', + html: `

Please reset your password by clicking the following link:

+

Reset password

+

This link will be valid for the next 10 minutes.

`, + }; + await transporter.sendMail(mailOptions); + return { status: 200, message: "Reset password email sent, link will valid till next 10 minutes" }; + } catch (error) { + console.error("Error sending email:", error); + return { status: 500, message: "Internal server error, unable to send email" }; + } + }; + const emailResponse = await sendEmail(email, token); + set.status = emailResponse.status; + return emailResponse; +} + diff --git a/src/helper/projects/createProject.ts b/src/helper/projects/createProject.ts new file mode 100644 index 0000000..912f284 --- /dev/null +++ b/src/helper/projects/createProject.ts @@ -0,0 +1,24 @@ +import { db } from "../../db"; +import { projects } from "../../db/schema"; + +export const createEmptyProject = async (userId: string): Promise<{ id: string }> => { + try { + // Insert a new row with default values + const [newProject] = await db + .insert(projects) + .values({ + userId: userId, + object: {}, // Empty object as default + name: "", // Empty name + description: "", // Empty description + preview_url: "", // Empty preview URL + is_public: true, // Add default value for is_public + }) + .returning({ id: projects.id }); // Returning the ID of the created project + // Return the newly created project's ID + return { id: newProject.id }; + } catch (error) { + console.error("Error creating an empty project:", error); + throw new Error("Failed to create an empty project"); + } +}; diff --git a/src/helper/upload/createBucket.ts b/src/helper/upload/createBucket.ts new file mode 100644 index 0000000..b546eb4 --- /dev/null +++ b/src/helper/upload/createBucket.ts @@ -0,0 +1,34 @@ +import { minioClient } from "../../config/minioClient"; + +export const createBucket = async (bucketName: string) => { + try { + const bucketExists = await minioClient.bucketExists(bucketName); + if (!bucketExists) { + // Create the bucket + await minioClient.makeBucket(bucketName, "us-east-1"); + + // Set the bucket policy to make it public + const bucketPolicy = JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Principal: "*", + Action: ["s3:GetObject"], + Resource: [`arn:aws:s3:::${bucketName}/*`], + }, + ], + }); + + await minioClient.setBucketPolicy(bucketName, bucketPolicy); + + return bucketName; // Return the bucket name if created successfully + } else { + return bucketName; // Return the bucket name if it already exists + } + } catch (error: any) { + console.error("Error creating or configuring bucket:", error); + // Optionally rethrow the error with additional context + throw new Error(`Error creating bucket "${bucketName}": ${error.message}`); + } +}; diff --git a/src/helper/upload/removeBucket.ts b/src/helper/upload/removeBucket.ts new file mode 100644 index 0000000..4b875b4 --- /dev/null +++ b/src/helper/upload/removeBucket.ts @@ -0,0 +1,30 @@ +import { minioClient } from "../../config/minioClient"; + +export const removeBucket = async (bucketName: string) => { + try { + // Check if the bucket exists before proceeding + const bucketExists = await minioClient.bucketExists(bucketName); + if (!bucketExists) { + return { status: 404, message: `Bucket ${bucketName} does not exist` }; + } + + // List objects in the bucket, which returns a stream + const objects = minioClient.listObjects(bucketName); + + // Iterate over the stream of objects using 'for await...of' + for await (const obj of objects) { + await minioClient.removeObject(bucketName, obj.name); + console.log(`Removed object: ${obj.name}`); + } + + // Now remove the bucket after clearing all objects + await minioClient.removeBucket(bucketName); + + return { status: 200, message: `Bucket ${bucketName} and its data removed successfully` }; + } catch (error: any) { + console.log(`Error removing bucket ${bucketName}: ${error.message}`); + return { status: 500, message: `Error removing bucket ${bucketName}: ${error.message}` }; + } +}; + + diff --git a/src/helper/upload/removeFromMinio.ts b/src/helper/upload/removeFromMinio.ts new file mode 100644 index 0000000..615a6f9 --- /dev/null +++ b/src/helper/upload/removeFromMinio.ts @@ -0,0 +1,16 @@ +import { minioClient } from "../../config/minioClient"; + +interface RemoveFromMinioResponse { + msg: string; +} + +export const removeFromMinio = async (bucketName: string, objectName: string): Promise => { + try { + // Remove the object from MinIO + await minioClient.removeObject(bucketName, objectName); + return { msg: `Successfully removed ${objectName}` }; + } catch (error: any) { + console.error("Error removing object from MinIO:", error); + throw new Error(`Failed to remove ${objectName} from bucket ${bucketName}: ${error.message}`); + } +}; \ No newline at end of file diff --git a/src/helper/upload/uploadToMinio.ts b/src/helper/upload/uploadToMinio.ts new file mode 100644 index 0000000..662d4ed --- /dev/null +++ b/src/helper/upload/uploadToMinio.ts @@ -0,0 +1,18 @@ +import { minioClient } from "../../config/minioClient"; + +export const uploadToMinio = async (file: File, bucketName: string, objectName: string) => { + const buffer = Buffer.from(await file.arrayBuffer()); // Convert file to buffer + try { + // Ensure the file is uploaded to MinIO + await minioClient.putObject(bucketName, objectName, buffer); + + // Construct the public URL to access the uploaded file + const publicUrl = `${minioClient.protocol}//${minioClient.host}:${minioClient.port}/${bucketName}/${objectName}`; + + return { url: publicUrl }; + + } catch (error: any) { + console.error("Error uploading file to MinIO:", error); + throw new Error(`Error uploading file: ${error.message}`); + } +}; \ No newline at end of file diff --git a/src/html/alreadyVerify.html b/src/html/alreadyVerify.html new file mode 100644 index 0000000..53a4aca --- /dev/null +++ b/src/html/alreadyVerify.html @@ -0,0 +1,71 @@ + + + + + + + Already Verified + + + + + +
+

✅ Verified User!

+

You are already verified. Please log in to continue.

+ Log In +
+ + + + \ No newline at end of file diff --git a/src/html/error.html b/src/html/error.html new file mode 100644 index 0000000..56b84b6 --- /dev/null +++ b/src/html/error.html @@ -0,0 +1,70 @@ + + + + + + + Verification Failed + + + + + +
+

❌ Verification Failed!

+

Something went wrong. Please try again later or contact support.

+ Go Back +
+ + + diff --git a/src/html/resetPassword.html b/src/html/resetPassword.html new file mode 100644 index 0000000..81a8074 --- /dev/null +++ b/src/html/resetPassword.html @@ -0,0 +1,129 @@ + + + + + + + Reset Password + + + + + +
+

Reset Your Password

+

+
+
+ +
+
+ +

Passwords do not match!

+
+ +
+
+ + + + + + \ No newline at end of file diff --git a/src/html/success.html b/src/html/success.html new file mode 100644 index 0000000..8cf2a19 --- /dev/null +++ b/src/html/success.html @@ -0,0 +1,69 @@ + + + + + + + Verification Successful + + + + +
+

✅ Verification Successful!

+

Your email has been verified. You can now log in.

+ Log In +
+ + + \ No newline at end of file diff --git a/src/middlewares/auth.middlewares.ts b/src/middlewares/auth.middlewares.ts new file mode 100644 index 0000000..25acc85 --- /dev/null +++ b/src/middlewares/auth.middlewares.ts @@ -0,0 +1,76 @@ +import { ENV } from "../config/env"; +// @ts-ignore +import jwt from "jsonwebtoken"; +import { users } from "../db/schema"; +import { db } from "../db"; +import { eq } from "drizzle-orm"; + +export const verifyAuth = async (cookie: any) => { + const accessToken = cookie?.access_token?.value; + const refreshToken = cookie?.refresh_token?.value; + + if (accessToken !== undefined) { + try { + // Verify and decode access token + const decoded = jwt.verify(accessToken, ENV.JWT_ACCESS_TOKEN_SECRET) as { email: string }; + + if (!decoded) { + return { status: 401, message: "Unauthorized" }; + } + + // Find user in the database + const findUser = await db.select().from(users).where(eq(users.email, decoded.email)); + if (!findUser.length) { + return { status: 401, message: "Unauthorized" }; + } + + return { status: 200, message: "Token verified", token: accessToken, userId: findUser[0].id }; + } catch (error) { + // Handle verification errors + console.error("Access token verification error:", error); + // Continue to refresh token logic + } + } + + // If access token is missing or invalid, verify refresh token + else if (accessToken === undefined && refreshToken !== undefined) { + try { + const decoded = jwt.verify(refreshToken, ENV.JWT_REFRESH_TOKEN_SECRET) as { email: string }; + + if (!decoded) { + return { status: 401, message: "Unauthorized" }; + } + + // Find user and check refresh token + const findUser = await db.select().from(users).where(eq(users.email, decoded.email)); + if (!findUser.length || findUser[0].refresh_token !== refreshToken) { + return { status: 401, message: "Unauthorized" }; + } + + // Generate a new access token - only include userId as requested + const newAccessToken = jwt.sign( + { email: findUser[0].email }, + ENV.JWT_ACCESS_TOKEN_SECRET, + { expiresIn: "3h" } + ); + + // Update access token in cookies + cookie.access_token.set({ + value: newAccessToken, + httpOnly: true, + secure: true, + sameSite: "none", + path: "/", + maxAge: 3 * 60 * 60, // 3 hours in seconds + }); + + return { status: 200, message: "Token refreshed", token: newAccessToken, userId: findUser[0].id }; + } catch (error) { + console.error("Refresh token verification error:", error); + return { status: 401, message: "Unauthorized" }; + } + } + else { + return { status: 401, message: "Unauthorized - No valid tokens" }; + } +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ca2350 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,103 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "ES2021", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "ES2022", /* Specify what module code is generated. */ + // "rootDir": "./", /* Specify the root folder within your source files. */ + "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + "types": ["bun-types"], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + // "outDir": "./", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +}