From dc0ec47543a70ee82fd674b1c939e004d4b969ba Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Sat, 11 Nov 2023 15:37:14 -1000 Subject: [PATCH] Finish rewrite of everything with Prisma --- README.md | 13 +- bun.lockb | Bin 316704 -> 329664 bytes database/entities/Emoji.ts | 1 + database/entities/Relationship.ts | 8 +- database/entities/Status.ts | 138 +++++++++++++----- database/entities/User.ts | 12 +- package.json | 2 + prisma/schema.prisma | 4 + server/api/api/v1/accounts/[id]/block.ts | 52 +++++-- server/api/api/v1/accounts/[id]/follow.ts | 57 ++++++-- server/api/api/v1/accounts/[id]/index.ts | 22 +-- server/api/api/v1/accounts/[id]/mute.ts | 55 +++++-- server/api/api/v1/accounts/[id]/note.ts | 52 +++++-- server/api/api/v1/accounts/[id]/pin.ts | 52 +++++-- .../v1/accounts/[id]/remove_from_followers.ts | 71 +++++++-- server/api/api/v1/accounts/[id]/statuses.ts | 91 +++--------- server/api/api/v1/accounts/[id]/unblock.ts | 52 +++++-- server/api/api/v1/accounts/[id]/unfollow.ts | 52 +++++-- server/api/api/v1/accounts/[id]/unmute.ts | 52 +++++-- server/api/api/v1/accounts/[id]/unpin.ts | 52 +++++-- .../v1/accounts/familiar_followers/index.ts | 79 +++++----- server/api/api/v1/accounts/index.ts | 27 +++- .../api/v1/accounts/relationships/index.ts | 57 ++++---- .../v1/accounts/update_credentials/index.ts | 74 +++++++--- .../v1/accounts/verify_credentials/index.ts | 6 +- server/api/api/v1/apps/index.ts | 26 ++-- .../api/v1/apps/verify_credentials/index.ts | 8 +- server/api/api/v1/custom_emojis/index.ts | 12 +- server/api/api/v1/instance/index.ts | 15 +- server/api/api/v1/statuses/[id]/context.ts | 39 ++--- server/api/api/v1/statuses/[id]/favourite.ts | 64 ++++---- .../api/api/v1/statuses/[id]/favourited_by.ts | 105 ++++++------- server/api/api/v1/statuses/[id]/index.ts | 41 +++--- .../api/api/v1/statuses/[id]/reblogged_by.ts | 105 ++++++------- .../api/api/v1/statuses/[id]/unfavourite.ts | 49 +++---- server/api/api/v1/statuses/index.ts | 33 +++-- server/api/api/v1/timelines/home.ts | 118 ++++++--------- server/api/api/v1/timelines/public.ts | 100 +++++-------- server/api/auth/login/index.ts | 44 +++--- server/api/oauth/token/index.ts | 21 ++- server/api/users/[uuid]/inbox/index.ts | 116 +++++++++------ server/api/users/[uuid]/index.ts | 9 +- server/api/users/[uuid]/outbox/index.ts | 29 ++-- tests/api.test.ts | 104 +++++++------ tests/api/accounts.test.ts | 78 +++++----- tests/api/statuses.test.ts | 83 +++++------ tests/oauth.test.ts | 39 +---- 47 files changed, 1283 insertions(+), 1036 deletions(-) diff --git a/README.md b/README.md index 9d624485..f275fb45 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ## What is this? -This is a project to create a federated social network based on the [ActivityPub](https://www.w3.org/TR/activitypub/) standard. It is currently in early alpha phase, with very basic federation and API support. +This is a project to create a federated social network based on the [Lysand](https://lysand.org) protocol. It is currently in alpha phase, with basic federation and API support. This project aims to be a fully featured social network, with a focus on privacy and security. It will implement the Mastodon API for support with clients that already support Mastodon or Pleroma. @@ -15,7 +15,7 @@ This project aims to be a fully featured social network, with a focus on privacy ### Requirements -- The [Bun Runtime](https://bun.sh), version 0.8 or later (use of the latest version is recommended) +- The [Bun Runtime](https://bun.sh), version 1.0.5 or later (usage of the latest version is recommended) - A PostgreSQL database - (Optional but recommended) A Linux-based operating system @@ -60,11 +60,8 @@ Contributions are welcome! Please see the [CONTRIBUTING.md](CONTRIBUTING.md) fil > **Warning**: Federation has not been tested outside of automated tests. It is not recommended to use this software in production. -Lysand is currently able to federate basic `Note` objects with `Create`, `Update` and `Delete` activities supported. (as well as `Accept` and `Reject`, but with no tests) - -Planned federation features are: -- Activities: `Follow`, `Block`, `Undo`, `Announce`, `Like`, `Dislike`, `Flag`, `Ignore` and more -- Objects: `Emoji` and more +The following extensions are currently supported or being worked on: +- `org.lysand:custom_emojis`: Custom emojis ## API @@ -186,6 +183,8 @@ Configuration can be found inside the `config.toml` file. The following values a ### ActivityPub +> **Note**: These options do nothing and date back to when Lysand had ActivityPub support. They will be removed in a future version. + - `use_tombstones`: Whether to use ActivityPub Tombstones instead of deleting objects. Example: `true` - `fetch_all_collection_members`: Whether to fetch all members of collections (followers, following, etc) when receiving them. Example: `false` - `reject_activities`: An array of instance domain names without "https" or glob patterns. Rejects all activities from these instances, simply doesn't save them at all. Example: `[ "mastodon.social" ]` diff --git a/bun.lockb b/bun.lockb index 50befd6c108eb6bd4dfaa58583855fd52970a3dd..c97db9f8f22ef7279c7ae927a9ee9d4d145cc146 100755 GIT binary patch delta 67941 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

DUQEBWC0`XS=b(5|0)9a&tz z<>J&kms~!%)hcMkZnuotx}#?Lz1}l&+IR3-YdJcjLaPH6C$=3H@#PzDpOTL%X2fLQ zODy1CSV8yR>o_2;kVWOq)Oy0>7eWpCV^%K`mIH_=bcDzo9X-fhuuwrVfZtcNzPD&QVR z(s{Y&MEl1bAK88Rg~npNJ+IH48>bteo>Qsz`-96|tJ3nujy^*=ZtnbOY~e~T*S##A zv#I~;OqZo5&sQsM^bCqgsIq(5)w`lc502jWW;Gx5zEbwR`Oo&8S=ZXjq@CZGv2k4B z*^k1tvcp%+Y4+qBU!T>@2Tl5+fA7IpdmcSJsa3U{uoGYGy%;|ElK=LCXWAI2Z&23{ zmQ-2a>Z|#qqWdR~Jmx#E&2g8u>u*PAxPJNMkCD3q&xUQ@Ki8vHe0Zg4tI95J-+T6= z&u|Z@II!=L51L5;TMpxCh>wKq8G@p^XBe7 zq6;?ORdWr@2yYpYyR^>MW9Ke=B>j4(`i~3mx9%8lFtgR3%|2~^8}s1!L(hG)-gNP7 zb)sd)k%?1U_R0BpRQji$H+Ia_qJ(>I42|EpxqVh{|IxN@&?J{XOqYI|SY`K>LHkeC zs1RM^<1u>&6?VCN|7c{#6*VUuU0-A1?msT?_w971*`+4Gb`GyI>7%fsi)~}i=2Tb~+cSGi{DLp$?v8uCGx+JQgKHkROdd1T+~HY!bMYaS%&z->a+JRs z8{KEw)ArS8JlxfB=R2bgd4A|GcF?_H@BiN5$n`7to~OO&IrQsAOHP&ARmb+`_w;F3 zu0Fdqf6mW4u3z@7n);^Zk>)jSd7pgHzEha{yFCVVTN;1CZP_OYt#%f0kAD>Dyj(-x z{k-z+FL!?W$?(G6Ds``Ek6iqAtqNoQJlAt;NUvvWV`{CaxTATK_s3USx##+&JF7EC zb$ij|^I0WNy@~sy5qWjrn~S$_VV42NZUturO>PVsN>{fzx7{4@Cc(c>0%UaqG% zUE12IR>jDN^J+{#`+`TQI}ZeI7`m`tz43*7eBOT6Wce@E=05pt^vId}&o|mp=__q<C>R5xgBb-5W0)9dW10 zcpF>gSV!D?Qbnas0DgS|CUpXMM;s=&Mc~bw(HEPUI3ZG z(F@=#!7&0MD)k1)7!EM0H^4%1n80rYfOiPMB9R&baEss~!4l!w2VlWSfZ2TjmWgu& z!Jz<8dB8sHVdI?=U1 zz!riH{Q=gC=L8XUfMEjwHi&ft04jz9xDN#QQVbdhaEM?BLAKBa0i=uph#my+wb)9~ zAOfJ;V1P}+F&N-1!7+l(qS6q63V$gj_8pa|HGP)=E6G? z$9N9vHE$OlL($N!NHjEkD8LSJl3>ADfR@7mc8STu0D_|catZbbpWy({2r`EQ>=RcB z)C_qFUfOQnWF%dEf zpkh40CW7O_90qWRz#ay0Qe+dPBmk5f4e*l)9SzVR5#RtpjwodZI7^Uh2RI}45M(3) z)C~tXC*s2a{E`832+j+SF#xv+rjG%*C{7YA7zfZY0^qWk903qK9w3+Cs_=0DJR`_- z09+SW3D%|nbczJX6>}p22Brc$Cb%hr#saua09ZX1;I_yk*h0`R3gE6-5d{$O4uCZp z;GPJH2B7@K}_J12{{N90%}J z>>A*GSe*1?iLwVl|6-$shx#fjlNDY!>aufw)ZvSv?L!GmD2HmLg`+ zZ9KH7S*)ZMGm95gceCh|0xfP9S=16{VM&FSG>gI1QfBcLwX|6jnE)+g7Ne+T&0-t1 zoLQ892U^}NBB>S3VlTC#SyY|~tz;Gn)XHY@1GS1-)S3jXY8Dfy)y(1)wYph&y$h{j z7E_=ics6>F`!0Imfssf9c}9|%22vX%L9+HkkWT3!bukj@AOq)sJSM4+k(dnP_7TYH z$snE>36d=&{ia~h8;TWEFz69;0jyI2yhX@VfQs_~HW4%t=4k+j2<+1Ud_*=uiVje2 zIzV#~I^8_L+(K-nv=pV@gR~M3im%v1@e`HahqM;)ls4ip#b0=2KmtT6rL8zgu?f!^ zkU%k+(oUSC1PPy+koIB*rGvOi=_p#yf^-sdDV@b_N*59I0VG%~q;wT|5V2(uThINLqzZ@G?M!< z8VMCXnE=lSGBW|f#8rZ|p8|B64`3H_=K~B}4e*#?j0h3{Zl3|H761;BN3eyU-vWTK zV#NZ0h&2G#g#ghaWFbJswE&w4Vukq=fI|fKPXOXYHbKfdfO3lf5=H1DfCgCr2MCfy zsl@_9Q;+FvUeGZUA@Q(0U3UG^H`ci;N;v|6iUE#S5k|rio z(#1K-WZ|lG8!6twM1ev1L z8i2C|$!h?F*h7%WD}(90w|Xa z@U;ld257Jw-~hoUQR*vzvjoXs0c;j~2r~8n)cqP@tBC&^z;7==4#9Te@eRN&g6ZD? z><}jj7VHCPxd~vGn7j!fct1cc!5-oBExNXWI3iYT1&BBVVBH3AOoVI$sCXD)6Txv|-VShxz`h;e zq{t>n`2nEZcK|<$(C+{m9052$kRwX%060sKyaV8j*h7$U6rk=-fO8^#CxG8EfE*6ZG+7kes_5$RJ zxqATyo&7PK(k|h5GQo$njl4M*1srxfXC5uS-8N}~8NDfIAi>Q?Ya*Jeo4oEeNI7PDH zSCE#cL26jUl+z%=xgfbD9vGi9AkRoL&w$jnh&E?I*4_YFau%enMcg47coU?@Igt7m z@yR(5w_6~a&ST&`h4}&oehY#90zgBNO%QP#pxi|OZxMPCpyC~X0|ZS(sY?Kd2$C-W z_=xz+04a9?axMcj7amst8vF(@{R%)!agyLHLCdQEzGCuKfQ)+pxdg3+&ouzQ-vKhO z0r-on1h)t}T?c3@=3WO_a3A0?L7)iw6(INl!0KNCfU+j{xj90lJE8f-MB)ZUJ-`p|=1c{s1^Y&_k5E4N&nhK=N&X zUSbcyA%ePh0769k9e|W4067G23y-@14W0r_zYEY$oFq6)(DFBc0b=rR_>u7pAeUf} z@VN)z_Z%Se9>5TBmEaaZr{4jFiMhW6EO-I%m|%nmx(^WiC&23a0HGp};2A-`2LNGW z#RGt~F9EE10Co|Q2Qcszz$St*!u$}x?KOb?A%H_<6Ko+U_XuFD2z>+)@dn@kL9{6K zhj~C|MGJ=G57@C{5A8#+>pq4ZFXA7gK#Cb4hagdSJOOB60hs;-AX%IwI7`s-DZqF! z`6)n#3qUSGs_=OR;O7dE`3&G4ah2c}L8s>clf>NT01K=Dj|tL5&c*0tb)Bs1QE(I^Hp2c|AO%&;4{BmZUkQLr7l1|LD#0y+PObn;#9UW^1tkF<6D$)!R)FAA z0IRJ4D?}c_GlG7F09J_=g#gx;2Cx;d0}Lz!u!&%eFuMV`l?AZ70jv|*1X~Eo zX#nd*s0I*G4&VU622rXAK*jO^$wdIZ6nh8`5!5XTkS*ei0;E&`$RYSzcoYL@z~wZ( z7{DfRlHe>sOLu_HVzN6xMkRn;f~~@*IDlVefXw0m+r?FaTLhg-0PGNRO8_jW0`Qn% zmk25e5L^{tbxD9dB9Gu1LBCP}`^1V;0BfrOSW5#O5Fw=j237~yL~u};%K*650I-(< zI4rUWwh)vn3vfh)mIa8Y32=bmm?%{aprQvrayfwGVh_O~g1Y4aPKx;Q04cQqatMAB z9u)u@)CQPd0U$@5BsfdZvLe74F}Wf@Mje1$f^)*B5`bS_fXqq&=fzcmTLhgd16&ky zD+4U32k@BSvIwdI5L_Q%brpcCB9Gu1LBFa1*Tsse0BainSgQf#ijZmm13dvY5!@8! z>HuzD0QTwtw?#I=7J_m$0Pc#=8UPUu0S*w{6QybbRBQy0Tod5F*h6rLpsoi%o{0AV zNbv^9A$TM_Y5_E83^2VGz+-We;4DGQ+5k_*K3$VZk;4#4)5mXN#xEa9edKNs6xQK`KEV$j0^s5iz;v!bo2U*)3#M%JF z>LU6y02$Zg#8)I+NXmJE6m=1!yg(vafgB)lcM+uiVc zNp1*I!bR*QIYd&o5lAT)kPKGtka8~K6vWYx&ka}VrrM@t?fHV+;D4rsl;w7|}kcJ|Z(nxHjc#Bf4 zAdQ8C(nRc`G!>P6AwD9W(o7ttG#4IzkQO49(o&qHv=W}JA--ZV#ZR20v=%;XAZ^49 zivQ-TZ7_q@N%)(2ci|IcsUlo$mVxHGn}^x(#*F*=lE$CIrhfx<-|P`-*=3n;vsjDS zqsQ6tjg;Lhvl#ySSpMI#u5K~-RxD&p7sc^kcZs+l%LL-hHNz|o%#0O@OK>DcSRwGmF$QbZTcvDbM;6|PZt)f9vT*#5Q9W(lI$Ea zMmmxld^bU{{VlTjkdC&Mt5r%Mcr2*Gp05D{!@5v@j#}wnAbw7|S&Ge&!{CnymOu=Dj(ND88s!pDaK!^Dy!H%$%|&*hHmxnd0w&sOY(VvWGQQtVHDmmlZJ8^QU_ z6E78O47N*Ue5F_uuziZX2BTk15%w!)!AO$%Abg-$Auwd+x3rt$6mtV(yJ^kwBT+$3 z!4_c2iWO0;C0KxBMZp+th0sK?k}97sm>G=gsgz=Vh+9;?(qMF^H3Ggy3geH9i-NEX z!k22O%d5ivU|)lA;Z;y90P)Q#Uq!{*f^Ai-l43S6epL}KexW3LjJuXeQ>=>fy9C<- zI=_dbs>&Dy#xL{WTvt=9J>o~eI1Sao@Xyo%;i#(6L*?rT_M^&IOR-L1Csn@MVEAY1 z%>JKJ8SANxUBLJ~E1cK*Dq}F>hZOTt`MQGrpjbo2x`E9B34VSD?z+7x75N+9}o#Y%??A3R0{;;?6(2Xs_4+u;Or# z^WOms|Mi`V~ql5ec^V49Rpxsoyp@^SRth>rL z46Fngr?7|0HyrU&iuF`%1lT&TV2*z;1xF&z!kok2iiIN1!ek+ejY9lmFwScq#ljF5 zioLDaXt2eK^#x-`> zz?2Iq7J);^sfkf64sm|t5T_Y#8T1Ucp2_et#0@CPA?zm2o6A zQ8E684jbk)Bq_$5%L*_Kd9q^T5kG*D;i!#MECuoJ6&nv`{5?b};CMg|59FJI6KEto2(euUVlRNXo`aAi07bT zdOB6H$%vm-%=xvfQ^42=o#I!svf&hLXkH(67lW&~)DzRHxJ0G#;h;JKzubr41k?b* z6Tu6iAwnYrZ-mAOO%R$Q_#l)=sDN+>b>2nz4S^4mza!j7-~(hH!b5~d2!9|vMtFk2 zTc8EOg*O;iM63vf5DFu>ArwI%2z>hG(=MNG`7rB`5P;B@8{CEvh|mrp z2%$Yf2ZW9Yoe(-BbU_G4=!(z{p*zA`2t5#bBJ@J&jS%v`syh#`s*bKv!#UU+8lxgd z#a=-`z}^kUUZPQBrGp|UU_&F=doN>R@5X|?_o%TeHtdR;*kX%DjmC1{wTqy^eEk8!mkX-f=$+E-t?Uh$QW`Vlph5C2^7qLAK*tABh%Vg z4#vYom;{rdAIJcg0o@nG<+Z^OzJq2U<7hZE;aN7ZZ+$WG-)+1AMRWDdyoV;;y;U3(F-{Ao~gh%ig zp1>dQ6rRDKcB*LsYYC5Y94EnfSORCE0W<`e^kwnZ1ipqNa8x~Xu@;S+&*1`C2s2R%sap+jz>UKj&Bm)0QqU*iXgkLM9$p_JwSc| zFFj;{jPRVwy@XeAR&4$p!bxx*F2E(Y438054QpU6$o6YJY=Dih6=e6-0A$Ct2Yv?G zY01t?c2W@#39^Ig2~iLYvQg>{)#N7?Yd}q?1$Ce<)PpNjLVm`xDpUY3@CF~K1oD+^ zd5~QayCQ89w3cukY=Dih2{wala<)M*1dwkaREM}<_~$Sjfupbuw!;nxf?%ixwV@8w zg?jK6_(FY<1#bgr2#ugIG=Zkj44Q+iLuK75>&#Zr8d&nj*~KtudV&1)f}$XQDx(zS zfxM6pa={mn4KhF`$PDs_O6=hxhIa`rfUG_J;W*5J1Mmy1hegm8et;igCgs}=;b15X z16lXUa(ocAlcn2esD;W-5z3x06J&-gAX`8A<;z8|1Y|pR98SV1kRJ{m0V82341&Qh z1ilC9`dIm=5j2J-Als|v&=OifYxox0fc%7OW$*+KaDXRN?isv*U*R{n4f2t_?3iSO z6b7;>>PjO*AQZwNKbnhk;-4GF^iT7MO;!F4zRn_(+#huJV329rTAs7c1B31v4` zAC|&ZxCO<*1_hu$ghL2~LRaW0Kj_<;1AizBPVkTn@4@d70g=!XZcyk=*a^GfOJwtc z{I+KaC=Bw0zU`qX6oV6_i-+%FAl&8JNkW-vGC>wuX$|3^2pNu}75(8I=c5oDMd-=7 z6D-Ft7eEpkT8nHH$O2fF>!ENT`A3idd*J}|fxgfkYJ(ftgB9!`f&AA?O@d$*naiKU zNC$Qxe{Sdyhnrw9$jW*k$nv~0@_&%Q6IcxLbF2^H9vp=ekO-&YARL0ja0Dj6L>LAG zAg&+(G=yf*0=|K^;0`4r9b|w%DD)}Zgln(^wnKK}vclIO@|WQXT!&xb2AqQ=I1gtb z2RK4b>HnWNSorZp?I&`la%~zY_yK0YZ0H2vL21Yb`QZ^2Nrqjp7kWT<*n_t65TB|* zEnPC#Zo?hfN=XGSz(x21a>4Rc$7TiRacw@#g)3xUhHyXO0r&+D!eKZ9N8uP8hZyJu zeL!|+vGAcnGXBj%B|}k<6fT7=fZVXs2<5t5yGjOU;2b2ud61pf0O${9er8%pe~IfC zL9Xo)AI*Qqm;_V@XKS&z{g`zXE>?z0Agd(V9PHw{Y!qa}aFFA&gvFr%*7*zP2SC!6 z23b>x#U2E46x%?YhAbvxp&yV;8#1y`mh}k9WNor}b7|{F`#4!{hy#|v)d|{zEHuTV z%gUuV$P%L!Gy_?7$nry$A`;4SM3y0C4B8o?EL6V(+2D(Fmoj8IBTI6b4J6M9hJyB| z+$3`u(Nb_{kb-5sV;08DRjSKS)J`jcXDo%doE@9Ap4XpcMtDzhS;?danRaC*bBAm4+oTqF&G9982`}I|{0YzCDf|JC;St<|YmfvN z;U=7b-^5VPaqtixziU~h$8U{e z>1&Y_U;dup9hhalm;RUaxm*+>Ga_}Sa$OQi%glJqcNpiUwnRn@*OZqQ%RazNBb5`w zlyj++XyyXte`uvyKMhMxQ%Q zUAU-K{)(YwF0C=+%&tnUW|1-D%|fL@(oo}ioMw>{S)7PsP!we0FYA8EKt@C|T!+*Z zlxyaBVPxeegGFAhrQWQi1oh)~v8} z;pe(OgI%1vQ}tREKIH_uQ*MWvB!d!3VrS z?h}rM0ni%)!CziKk^AP-Ub(j}O_b_OwVQxkZwgW=xzFALTERCU_u#*UwjgqT&;>d} zJLm)*K<>kT2OUAK%e4UL1(EV+Ai_E520;)4!4L{z&=tBv1Vlj(=n0ZI8e*V7NIJ=< zABcQk=mVl_$uAbRMNU^onep)dpn!yt$Uksk;XU>uAGY0X3< zlxw1mPf^CT8AkY{5ys8pU?zx)B#{V;Rhh>U&IdEWVva@Co|yC^!iBH^R>Bfk220^5 zSPm;-Gi-v5umRS?T39XppTNN?SOe<}wh?ZDt*{-W5>3D`8qJj37}v)Vic$RxVpw}& z516vj-@Cz-m!Tv1O@oHg|6**;7M80ZW91;{2f)I)J^an_YxoOZLL|I^r%(?qNb5u! ze-J)_NALiChkI}r?!a{r4PApPa2YO1e_n*{Z~>Cw92|wSa0(LPFyx_-LxjiSB%FX_ zAhJh5uFJWEr@;;RGlb{iDo7d0a2tMw8xVJsf7}r)4<$efEJJvU@He;*GL#;Iv`UU2 zgIpKP);;I=Pk08CN&kvav?BS5rsP;OA}zlJZ-K_fX##IJc@OVEHr7&*bb|xOGGmB} zr9Y(wa#zWNP_Ad;I5T8~^pFAM!G-)4*)Jg*WCcmT3FN`X7a#_hlQ4(;u~sp_?3_44 zF31b==V9|oFcL<9Jid`XS=*k%J4gs^pb*HT z9I4dTgi--tIR}0tUi&+_H3^G@+@X$>3<`7L0!5%0lz`Gu3QB@p4@FL>!ErUH0G{9h z?ob}wz!l0t87K>_NF&#)g8Z@G%1{yHS(CT?v0g6@B(XerGKH&iTnA)C)Fu21qF zp%?Ul-o~+n-@^dt4+BBc4gqnuLkWk$a1bXmj&LlD2Dv^4#={h_gUK)n;zSh_IS^H4 zfb=jQ=E5A94YObdnD#K8V+B9JO!yJz!3J0dt6>$afaM@IB7N@zKfy9s42z`y7jm!w zBytHX1rbVZFygHwekG7d``gNLtt_lH&gFOw#}ck5+yqDAAnb>Iup4$lXV?Z?W&Cg9 zpfXyKR&0kIunR=pGCHJ7IuQO$xEJ=oFK_^kz+pH9VqnJ!Z^KFG1RX*8Ing-(%{ae7 zm<*TIu$tB!ap#TbGlZu=lAVSmI1A@M%;-8?gDY?qrc>li!doEr|0|)Gu~g+g;XSwv zci|%30kbSId6`itD^AP&cOu2zNJiq{(nC6M04qEp{t-L`X`8f5+VL1J!3%f`Z{Tlu z4bR~j`~eT(DTodv-Jc*0J}aFdiC=-V@-L7CFF_K>wK~+)LM7gDY)|-}P-N^l&H$Mp z8;HlqPM8ZEAtww*PJF6#qBG~sIG33uKgS~PBu-c+LwR^EHeHa=1~TQyT-D8pE5NnF zAahp$a&qnhrNLBtDUOd3SAtMf+?L~#9FHMQ8A6%XDiFGZ2b706nH(iTF3Q{|p+v}a zsj<{nOi>=;>`xVMPv(+YMeqhQjkdHvPF4>> zcwOrt`{t@yJ*!i*=up3?9wbe=zVz7m_cLEfJ@o%#`t=BkjOuQuo<-EEwRzMIa&UJe zkBZuY;S2I^v1Rx-4|=70LyU);r<*r+pk~*zHnGp5ey?YpX%wO6ePs=;pO1V|eEiwL z9gErha-m9Y`%d0&9+jw&D>075Y#CENq3F@zam0AKd3d^cdN&~^J28JeFSmTS!~9pd z`FypRFLiIv?+RM0@)c#a`c^0V7*(>qwJcvEepBDt+ItF#3XbUz+YOo2?S;kO(@l%8`(0g?jQ&zD>su?>XH{jtwl;B=g@cGM-}I*0>KbL= zI!G(|AH!2qL?FF`UcLPz&z|`1&qODS{Xiqb-0JVItxnEG5Re>xYx?M?({pZc*L5I0 z;O2!zsPYYHM@6-^0p3OtKS9fT66ljFpUF4+h{ymFTuazZ7C8-5+w7#Nh(a2iCxiEE<$`rm_zEjCgh3_EX ztqb_8xs5P_?)-8gL2gyGG1-Qx){U(pd?dD0&Kjumjja_d4mnlUCf3IGj{M>kk)NtV zs;{y)wdQwDODFD_oTuG~MoO*uQ4wjRnn)Tx3tZ8ZhFr>}H>BmLq=bTP8)h~dOhfLd z>rJg?Gr42RqHkTEuRL9wkyDVW-wazQs`?WI2SkQPMlmdAU3D4JVqLnGS|*-u6uLi_B)ZAF#&=aGy3o&B7`R=}RYc!$PFU0uhBhdWRX~%-yWv5U1tr{^E#j;e7 zD^^?FK+>W7_^e6OW6ro=y{?s|X+Fj;$~8C|cYnw?%z0kuAz~_^a_ng&X`~IdqR&n& z^7uBC7p8w| z{##@JLV{MixvMo>ta+>Atf&(rV#vpC*($cOHqz^)4VqWPNC$0yvm$3ruSFpwHeBS` z{OVmZYd+Pmxix>|&-5_$YvRoIfedUfhn?<_=}oNGD10P^5+n7xIIPB+)g`-5u-H=~ zja64R@hig!d`9`iM3U5zq+M<{4X>G_!{C%gM??k&x%&BseOt-%*ScjB#F8txRZv~F zSPQCLo2|LxG%s9{RcP84M2*w1Aj4&S-hu)`Bl|>P{521p?^(<0x7fX*IOL0Q9J~p4;TP5um`NnRn@l-3!OZxFKdJz*J z-KAu!mq}jPwHWE@#75TKE}C;9{oh8H9DSIsZS4}feUOqq_0XguOsVEJqvucG_Fopg zLCV)XtX(G~W1=Pw-820-Dx&bv?b*j(9^G@Ez+(J9nTb<$nRlv-!KX!tuexyUo#}Wy&d`M=_`>vjI>m#Oi?}UjHRpkx9pw&nvs^Kck8EH zPw=I~m#+QY@&{tD9fpFPwU~-;ZuPX)E2a<3ZCASvEV!ZPG$af&KUGYfXl~7CyMctL zV&aR%^R^YR$|$QqZ+f`f{a#F2T98L*ah9t{RNOme)AYJGP9kAUl(EHC^A_YW6p5Tj zWOMVXbK`lv7KQ|Q=n`M1V-~QLP;(IW`JDoDBiwG;?+5G6Kh{SpP&4`VCG~6^&y*_E zy;z%E#K=U73euNS4@hgtQ%a?8X$`itETtk^l2`judgWuI)*r5Oak0!S9!vu$Acz=I z`1X#+=bV}~-_fWNdG#!%c97NZMhe*7)l2t*rTNhe#vc8XBGO{&kKOVJ!ZnJnDYBwA$aw>S?i- zR)0y@v8*abGTZvHy2f5*E3)y+TJxS*>=$yykv#N_8(T(|Qwd1=Oe?2PZgYGBnyuX* z5<`8M4#jC*Bt~1OJ}JI?K?R@V#E7G#)H~(Wo7QN|#a%W0*4o&XKu?HnyHxo*{mj`h zGgyM^wrrQIXFdk*bua~s%hhKN+6UTuaO4GLzBQ+awxb2&wMXNp2l%jRtL2nh_)*5W9 z=c9Ml`sNXLa^GKgSu0m_RDb!X9_=vv^c7Xs@90~Xc2+Oj=QI37++v=aA=zALN1yB2 zc{Zk-)9S z*LYhwN8MR76H_F9uA&^VawC%Ykt|=ZWaaQZ?!)zZ zNUKg(Rmmc89|>u7)BTx#Z*!+Wo0PmP)l}9&#C~+smHPE>I9OOOVyR* zU{d@|3TIM`UsdRJ=cZffByHH^5c1SewoXX8)li*~wD{Ig&E>pP4dob07+XV4^dp?Y z^=^dAYN%w9+gU^H=|Xs<#`J*%S87ZTBzRmyxpXGXQByUQ%jIgS0YeCz)>IL4y?afy zM$U)SRE@h5POGVQ$n|A4Riemmt*M^K`LUWRa~GV@@>+UJSGDi%7urI}2E%A6qcK2*R{bqTZPH}m?J(oJnF zsjH3!kld_=SxUZo$~_=e?b4Pv)aGgF{FQnamPR&aCz#n!@KwpuDa(A-2~nSU{nOAi z$FucS^I_I6(|J^~Gqd`T#Yg?<`Jvy<78pIFb_AmkGyhLBF@<#_P(`{R>)k+&4@skL zW?CbS?O6j>9GK+Xfj=D|T5N#7(Y3g#91T^cP`VOHt&7vHaz0fq%o_TSs+pSpSfyB` zmZ@oZjX&7CS*?#%`oXTvx|rFTnlM|g8?IJYhJzWJ2Ay(l+M6M7E7nXOkgvxVzaJFl zBr9KI*;lifdJ>*Wf11G>hS|5dN{CDqXKA4l#bFt>vPHDe=j>d5Kkt0|(&>sgDBbg$ z^)>8WmGPHuZJ{PckQ}!+hNu1pBNwe{(s-bm|;WkZG8e(cd z5ArkOjPj+=yv%EcGf@5kR%d(N^O|bz(^}PvNoknHXsGRFZGAet6&t^xU->psDQf$q zfgk!?ABia)@^K~e{YNvbVARZ<6U-W#y7;Jlo@Uc@ll?Ls)@&yDxJjl#8rj>%wb31& zcck+`k8_Q+c~+lV%=D=oZ)&BR(f?kbPhvkws}n=9su}H-OFw4mE$w+) zi3`e{c*rNyo}k+LLO_--SBP;TriAn2c(;H?0a}c9JW0Hi z@`=tYX1rxw_@b+m38@S%y`R8_tGaQluL}OxPQ{H80;L>NOmu^Xki40JWB<&0&WMjW- zL7T}tC(Ij?BC(Yi*`fB{U$<$-;q$hq#GDUM)yAQjzXDYD1=dnobK5%v>Wg@nKs9=t zHQv%SP-PvDE_(&4TH`t28mN9A&pv2tkaC0MZ{<8zRN|9JcjNHCCHQ%wo{!;gvrNkT#Q3FV8yK1Bj z@3P>1(xk1mQzV{+s5O#%hETm`cc#ZYonQ0C_7n*xV&wMCNQaf7opV;{n-WtlRK1h5 zwT!fTf19-Ii@fz;rbx6TMp|{~MSP(Fi&{@fi3tu>jVDpFenwiK#>Y0zpS&S9MIv*g zibujyC{q16iQe{%R43%TYNT?RM_4~njm3h^5oK!~sc%bGt~l|~Zq(H#DHatRshUql za%+IVXLT)kCG9V)e{jv!{MDZos6y-(}e2GTUPB z(Ni^^VvR79SuRDXq$$=PEqS6<)Kn@sB3dn(%I$=bz4Y1oRnDMp@4Sx7?RXyY@Nm!W zWG|I{8f9GXrMfI+$IP9P$GudYX(WE#OZAqpR&UjG271}tTQ9!)vps8%IC{$iOt~G* zeZ;rD)iueaSs(S6q^QzYm7C7_?!L-*I>n!)ct>oB0NF*O4DYvWV4BuXN+1u<(xL3+62r^D)#42}1-b-RtphB`1wUN7;t-CyU|9YYy zZwj!H#?_7QA3V*R z|44BMd%4p}hIW4pP>E8e zH@{c&B=>iQ#K@?bC9jTbkS#^RWr#kQXMVZ)e*R%QhgfBVlBzQND3Jh+A&8buKMgI#JI%^Sw3&YR((`+K?n~Q=#|g zqR@|>Aj^u6ouDO^(_Ao*=AWFTp3FmmAE$FtZRT5@Y!@c!o?=Ivr^f>WLgabBF(g0D z)>ti={bc+6nn0hm%Vaj^?8j47odr}N{Z!R^0qtx(RUeYkJ6f+uu;loIGQISt#?e#N zwFQ{>SR@?La?rqgeWKdkSc-%^JVIx)h>?d`dD68j(6qpi!xsBWZr<9^STa?WTSyP> zHFB@w(Wq7aLD!`ZrA7ED-R0tXr>fY6DCX!`b%@aRf)u&QbNu{ut>1osvb9zp%`Ih~ zrra0NmwBeChKqPQQ+k^I?8)iSq)bPWVxIeQG!eFhHIj7H{3&vyIWTzh$^-E{RxBm$6-KIpskdBt>98c17Z67ezf$Y*PZ zgjA!_bl0hc_vNB52Fl3$P)a_Z+&t`(XLap%KT0?2+Cv_@&;$=z(S{KtPr}-?INPM- z(+^L7|{b^3|sJ)EPvjTuvG?LWJvZ9dZS+)~s} zXRrLpS|;QhJdJagrsmO@i1dFEbj_4N6Sc~I=>d3s($ zYo2v(n0Tl@X+@bd_rW|hU^&KkZh<;N9=sv;cDZ$?W%EMy;|lFmY{BQBuRdsoN6sx4 z=}u!s;kFB24|^|f#u)>+(;{VCNxdH+ArtrSSzVm63@+}8gg79|rlrkS($XV~_0@{= z5;b%sy7{~t#D|<-No`qXyR1TT{1Ux`CuA&mcYgHZ^W<$j%bvYNMXX}ztXiT*B5C<^ ziQ2)r&HP#BzIPW+E%feLg_PPfS*D&yinhyC<^0^Qn}|#3Dhh5IF)@hrQ(cBt;QK7F4y(x+9+Un z%#{NzNNEh6tIO3yByIjHblduKMdp1sop!L*LgT#Zo2{*1%Z%AQzF;(SR%lV#Fs-c8u zR;ht&$>}CJ$#N~E$Lf3Sr|l_cw2P7daFtqzr0o?FUm|g-@}$YxZdCkBD@(KM>vkqUz86S+ck@pg|^bGnxF=YME7iZ z9)&D7JGFT9sE1JvFTO}fP>JiT@wN^|DT_SUzdaKj+64*YrGuaZ6~3M(MI@+s>#e~) z{gBK_X>Gd)R=R!1Bh{=onivX--{lqA+wQBnW=&C>N>J4{V1^G8)aVW5^)f;2lJM0k z^_0+NS*{|tn~=DvX2xUfzOD^yiUE7w0YL)+q}fbZ+%;Ld=X}( zr6Hw=5rwaMRXi|LKi5=mm-Jbu8gCH=JE;>}sNruL^zv=ro}aV$eAzFJJZK#?bUBD( zpKVaKt)zIfRyE&BsTnrvdGnhwD-S+uBQI329}=_6O^i&06<*%&u-cwKnxeObyhuBT zw3a%v)fUOiEWO)i-S4|w$Hhzv_ftj+4?b?#qFlC-g2}eZHb!9kZEC?bIxl#eUi#@a z=WbqqI&mr~jP8xzrk;?(r!*!bMqm5PWxIS`B35bj)4XI0CR2$ipChAxp-JoS6Jzwo ziEXOJcG7-kiAM`AQ?4v6YJ8t9&#kD<%tM;r_U5&8J*{QmcC}lixB1T0E4VjT)#sfXPd&^$cquw+N<1mA(3Vt+Dp58_Nu^LOtPoQqX6}&I(oqs&%&?vYI$g5`w=lY ziHW^j$njVJzv!owoi{64%lij@Pd{+(H z&5O?!)l33Q^Z~VLH_4|R&>MPp+2|ZeN$JJ?8QR28JM6L6D>mg9-9i@<%KmiblP^~6 zyYfReBxUYJC4~;E@I5rR>_Od6R9W4)?1_v-hqeF5^K3q?2CiIEv|$eN&&Whyt@N{n~~7Gn7ht7>~0TP=^M9($=z^D(-$ zf7{|{Q$NQI`YK1WLw);HR-N2yII>u=>Z<_@7b9&b4}jL#othdN%sa*A!r= znw9oDq3Z1;&R86%iTik`>GKoRrIRX|ENpj9>aAJP;U}L= zTYFs43e#3%f1Fgg_hTgg=B#x?PpVa`MBW|~){<6kc%bjsC)EJbTFxh`HTx;0+$miX z8AsL4aDCx|>PQ+sqv9!b?J!N*bXvVb(kG3^8V&Zx!*D5b#}U2%hECab4q zuKk@-%FVSZ9toc`-sd*nh5Xlf;sj|;?`unAR=_f()yxXEUubpijjH)Cl$OTqV6-L8 zp{c!;Wcv6ZCT#Ig%dS7W^T(DaGVRLth*Bn=)nk&M7u)UB|5ykyVt>To(;raoe|bhJ zo5@h1QK&7AiN#n98cnkFOHw-y(nRhVKVfR}$$DO2?ha|5sYu^Bec0KQVPhcw^-D5b z{n^nkVl$qVIbIsz#aEGfT3bodenAtj-n>%2@pE_Yl!h1*mTsHX*h4fZO?7E+0C&Eq z_8{RCaZw+x;ST$ft|#n}`(^C^WQj0<7}+;%Jay;oFJ11+8{4vYKwV=ls_dAa&ksmA zB2jIL-KjNqd?#7B@udmarQu`6<&aiiaXG18hw)`UT~re#qcp3jeS`G5?@KD!8TRiJ za!DUc!PoYUTDNX_s-4ApV#MEsXWiaw_1B5A;x+mk4_WRAmT7lb`5wV>eBMKv9~`Va z!m{e}WpxN~i}O|WMnWb<_oIZDuc|-^vs_m*BwTe}9XrYm*(tkJ=rPXQC#zY<=#8Lc z-2n4UZI?BAM9eWNWSDhCvPwM0itzhnRq!|!8=1`C+hpg}Ms+)m>#w7R9p?k2ks0*{ zeP=)GvhT~ey35xTUY3}=G<9X9$A<7n0drDvuA?53gDs7uMu!-ajjD2j7V`4#w;v<&YEp+|L>)s>3S&`C9H1WM%2J>{qpmw6d9bDj}_(ae=(r(`xAkyTQAb z9rBHmm!gdUnT9@0E79~(R&70LEi6j8a#B~yi<6pCQhg7lIwVqWqY)PBFgcM9{rqBx z?ND+eCRh88z8LfU-EKsj$E&lNacH`1N{rOky8NKO=g?4Dg&1y%yK8QzNZS^PFOm3S zeA1ux<9Bp6(lViTH)1+RRVspaKiyT0Pt&rE zcUACdR8L^_QmqUw_y2U%PY7`VK2*K9=Vv+P74E zQnvjpAC9>_(6u!3!H6cm-gjSCK@$h{gldRRsXg;g@vFBJTzj~re%-Da{mL_s9PL|eGh9dVTVLQg6z;fZ4@;%Q8<3>pA zdF}{0|Ec$4!jGJ zvU)0?OV(VOr;*H&qD>+FR#`rad9Dk`TV}sjrv3Z$DE+8TO1+JG8h2hjEi%Ff_Lrl**{If2)fFmjQ!n-X@u7QOD|@ZE_JI0nkKH`&O1x2y z=;Xhwy?P%MW@>ZY-|CmEA8M6P<+_$itJB^pw`;cY%SC5w3nc!g@pnX@4HhH7p`J#xFcPI;?w- z@F3TapqQ9m5fMS*u6`jw5vql~gRM}i3o#Ut`jnD<)ZTa2BBix&J0(^-aFs#sB^Bu2 zEif#qhigE%UvzX>0I_OMdWRxqL`?sv(nzD9kWOyUkuKOTI>t3PjEBKFZ&_`{_3tmG zf%=gv!_r$Xp=Yn4UO}!gVFBGl)H!d;IpQfN>f~1r#ivI(q{}V267``3R~`$fLE#QX zvU>S(o$o`r;-l=t9SW-_qa5PY#0S>&1FeN9D@BP&rJT5mpYhPU zBOtOns*jF#<)0pr5z#@0SU_Y%fS;!C0;#Wv!aq9KnxS^Ba43^UyA`GBIIMeEfSI8h z^w?@sedj4YB?)XN3B1?qk6dp_yvUOVsS66J=%S03KaS2C0FeOUnwhy zKT{YH=o%A*x%kBdg@na~_VRZPj*9H=+QToPn^+Cj<*M11dYtG`B>Tsfq4o@QDDM5a z?$=zo*8S10XaviQh>7OY+dx16@F2}{>X~OL=JUxFY)IDT)D>F^iwNl)7!<&#r%}CP z!opnxgJ@cSD*Vz~uu!TCw1ifFd_lFy?@(Oz*zAzr*~~&Z`VQ&ktC z)ThcmgG2H3{^4BqSMR$!l=wpb;8M=DFE3qrZ-~E9Z7rNr|5TL-{eaBhcXKGJ&JJ_1 zslvk@zEH3GIDDnNhdUg~Yko|Nuz5(kz158)4n>Nj^nj~0DO!X+bc%LKHP7!*qHG$S zhiDpCUES5G0S?6rf7JDeeqs!+o@&xshoZJrP6we>XN+3qchsRs;g5Zh)KB|3*mO>= zK5FeSGSa?VCsqp?MLhMAe$`G@&dCl%J>|`0gPeZK7_O`jI%E7q_X&%Q4cF|x z=tq~N>p#B0koM%g0*9}y+-urA?YY&>ULAX3UBCJ9OKV8Z+|iM`114RVd90f6bSS>L zbtZ?&5t_$R*Y8-|J!^Z6DEXt|G8&Kr`kJPOJ15?s+cNQg0fw({!Tq|NqZ9<1mMWN=Az;*(yRb!;E9!rpO2($`}V@nPC=7+l)$~r0GrP#0 zhJ-e$6iPKow5TMdMOji>)bIJauj@?n?a}A+{d~WV-|zAG{n2@N&HH*kuYJAmbI+XP z#kr>}e&poan>X#;X=3NrY1jXn{B*(B1M8lY)#U39!%tpWeeFk^uJ6!y&uO)8f44$F zuhU8{J1f+xM#~yg8A*I?{Hpj>uqR@VCs;dibhGT7f|Ni4 zeibj_YJB$SF$IIYV5o7+ABYm|SUnIp z85{Z+U5dA)k~cVXR?Cl1vNaZriREOEnihD~`K4I3>sLzCKxB={96yfs5A_}nomF&N zP1~ZQL7VC`tm>SYmz_T$GZ47^RNK-CnR(-4%+J{T+??^)iy11FHs9GUSoLgL=J@g1 z`2~Sw$Irx`1~1GRnVrS3)vs;K+mqzyFDf24O|udRY#@RWEFM8>4f`Oh{4H?J?=Oyb z$I_$XC*bs`xa}D>{RK2!>Bi=d%$-1ar`NIRrf3oHs)4Wa_MB;XL0;<%1A(GIU}9m; zw5;5bu@DI~Cr3yin_AaqD8Z_yZ^PBFSFrN)>)9S}!fJfiVl``{v5KFVKVJ0)){|Z< zAwP3!Ol_GuVLVfim;bo9Hz$00(QSn4lHyvdYWV$ZTk`~bb@A4QmJh;bx{5EsYOD(z zxpdhRV$DX52#ms4%iA`#Ey>KEmXkFuyPyl{)uOgo4M7W+KXXLp2pUu;kd>dGUodTa z><7kD1%HlJ#lxC1re5!g4T|=kW3!HrWoD5jFb-dxVyp`bvd8B?a;}ZP2dhCCM!al) ztV&G5QfS%YJ27bEKWSz+-EWksS)JJ0b|JqYFQ<@c9EQ(wEGo{tz>adCHh}<3r}$zb zG*%ZdiSqBpveb&(+6=`#k~vE7i^)d;#dWaq(^FU)*ypfHpA6TS{?gK})5hdiLlW8r z0!%}34g6DCCdDBVss|URYQ(4*s{#+h&k}b9ecj47Xlk0B)Eu}9e80V|;A$#T#jB&% z8m!8Fn{@TDBXXzY$MOP!)%eU)v4^e8GA(|`75t2|g@xJd!$9CZeD&x$EK9F=B345` z5X)XEj$$=J&9EA=Q?P3I-Y&Mn%~<9Ch=!?SXLJh$8eo@UmF}eOjK4A#O-)3K?`145c6~o-|kB8O}B& zUg?evu;qN*+*VYWlgkzi1SV$}#2DbfiG%I#8iv)z9aWScn;gq2$j{3iKfZ7xXI5^0 zfpUx{@l%|C&mi05 zVOXt!V*`Ev(qekstROEIn=mq`*~rX-%*ymqL;Z7>UDf&7d6PAC1-S*8x&Teco4~0U z2<$}~nR~E0h(;Dp93Pt+%ljtFYgeVtNzaV7cw#1(O|qXm#v5CuPSJH>4g4pRF8g}6 zov`=tRm#0%ZKfLDi)%FHjAq^nY1tT0~}@hr|jjoaBdwjmR-)$sFOhO9BUxtn%gN#0s=zS+_;mSA3`GF4js`sxq!+qx1axWlW{XP-f5IZ)%*|f|F;{)8#PvpL-C>9_> zyX)vI+wknsIk|cC^jmzjS6eG{e172Txz>;Gx{rvjPW)51+lunCC*)<03IzPENW-rv zvGI(xf0lfKuK^u3KC_^Jo8N8Zucmu}SiXPL!!bLlFqSthP|LFg#_}g}Lj;|hFbEowe;%kYG%*`su%g%{qj`iyc6y{|&8=D^p+&<67PsVDdZXkUvYz$jR znfz}5wgH!8RdLM))+S=LWFEjqu(e39srh-P-No*PG4Rep z+w^1YZ?<^h85-@e-TiP3Bd{K+Z%0y!t#~(|w9n zz8%iy$0khHZCBh*ES#9B^yBZh>0Tq9*4%AN8GjAwfu+{wWhohp$PM{~>>RdxegVek zv1PV^4@j>O8=YI=^RdhARIJCUyg^tUCl@7Ddj) z34y?q43SRGSx?xW#(PeDmc}3}n|JP`as!XMp&pZ|4V{XT06Eer~HYYnF?~Ik+FDKRMT>B|oUv;boppvu4SKH0diSkv>@HKW|`eQZH@nfOI z1KyC6>l8I~&4^=NN}Ej_H#+VI*V*Epz}6u99oRFmvAn$8yk@bf53aY(nC{}s-e1!f zjib8D2{Jmq>HqS)ps2Wc6yH*4)ZRPOV3v;TzEcu#o2roRHK zrT-0F{fjT*ycnnWhSZ<7HdS#AYL7qu5iqQ~U)V=(fqGOUA1H zk+}uUCg$Z%j4!PCVZ)+omXkRl_UfzlaMGpjVzUXETKW&XW-Cb8Y}YOFfC466mOoqma5zr_wvd<({BIKK7bJ7!`t|86@F@9@yqm7jC}uY2>aTQ0uU zYHqbN(`}n=*l{XOq5((nwMBPfPsjeNEq0s+pGthZffHhkA?Hrn6|L}X+q1mbNZu9% z0vD9po)ux$^Z1g9&uEPF+PmX)uaY||@X$LpJvWZJ%M4sbyr!flRx5yUZk9V@Y~ai7 zw!C)mA|-g}T|1QV1o1t0uFH6smTM|@?y!6IOXp8}&tA#0b0!sL=f(2e)3>sRZ-03z zSH|NH<@)cW|C+;IJ8cEyb4O<8j|l`uWsfdop$FQ*)$#&+I0n)_unkSEYkQzaE+bdh@|`BoJS$eLl8x$(xoKH+A`K z@wI#ZIs&(UVjC2HtQa53vg1TIrEc3c%go8oj-Mm(T@Y{BbPCc)6k>H8#D_LMl*9Jg zwe!no);@{VNSy5&7QY0ahA&_YxH=3>dS`srLSy(x{n*dYNjy% z?bVlG+qr%as|IGe3jDiC%31b}?NC3sMy%`o-on~-PP_hq#htO$!H>+e1CaHd_fG9b zojV`2$7w~ZHgz4Wh9mos?cNvP+ouR!sG%>K1i-R`DjD+rnFXW)pAmxjn0QU$rrO z{lYbwje`$%ziLhN@ap~NE=~-t@Mg8{5M1tU;pgdIq)mt5Fs~OsOT1ZaIz%=l1Oh44 z8uCioBuA1f1On-JLEoE>*9xzKSMp7YH|xR<;iHhQUe$rAk+xw%e!dz5lY`fKy^=cw zS9`PgdC1$s&&FOPr9*g1#Xz95pMJeU@${izD|%Z~qLERR0)bAznqG}I$-#%bUTr%B zcY3qhc8GLI3;8AttbarbLFVXh~C|*0_Dyfj*5-*b0A@VKEwzzZ`y@yO^k+dLH#49OHseseU ztD2G;-0Ssf-yzt*o5jyeZwo&cc#&v_;8Cv^Kbv~9_&LGb673LK#Qe6VfS{LgVREDt zPnFWEXmaEkK*(;Ni78fnRqhwwCT75r>pd68}% zBIj^8*`-vYQ*vZ1o_bfwUuKWvX<|sv;D6yox_5{);0RRtEX4MHyI~$ok#=mWH=*}c>>!s1qfh4bT|7fHo?}*wH7gD}ZPHk^?|7d7; zZEq`al~1$lHo+_HnHnS@9cuIzou&d{|_4qbk_S9xGGvJ`l+U%S@( zA-tY`60Pv`Gwho7SAXcvGrY=|MI*<7n%Gml5*AOhI`Qmk(zH6>R+78`R69@bm-w$P ziNC}nJWy!0W_&^7hE*+Zh?cj_@={_a1ee*Co12n@p0;084P;rV#Yy{ea`hF-1jZM{4i zsnEc#6+15%;<>hwH3v^CjjW8*qj;KU`qU{o^l1aH@~~(q>1;23STua`*@3_quXl24 zCD4>}s(cEo$VYUl|QO*T|cF zWi)bxIE|RUTEaaV(^10E|c@-m*fZA(aK;%yxsjdW~MuGq*`csgMiY$oCn zJY}~H_{^omBkqi*_UKITG8mRgc)jBJ!q4DwP%t~k2&u}5mvL!Ig>wRdw%(lnsi7y% z@n&a5BZmQL#0Il61vfn=|8b~G|P*)9c_1W#4^Ug%h| zw>3K&jxj#;j?%XiDk~vUudQ8uOjO(C$T+>$M0(esl2$&tz( z`SrQ=SQR7jhWnoKyn^QscDN>2_zbW2gw)6=LR#I4IvgW+;SKcDXADUWecQ>~Ix!l) zglTE-xA0a%ehb5U2-%`qbqNHz__2kAE>$QJW&lUVtBqvi*+SKb4S2)+^c}kK1jY|- zAY^kkU=Fp!{p}T+)ZN>f9}PbUZ144+l9ot7i`>R0_wZ&HM8miC2n6!|V*VhMrBG-@ zPcOYN8d}lQn_UUPIht?Cy^0tji4R2w=0O@FGH~e&{Ab*?x836SCFy9pHv9ypoX3 zaWWI?k4I!IA?*oU^V4{3ys8V*DzGzJcvXj`Mj8&b=hP`W(L&jSz4W4J=#jzRY|Np- z-d0SbAztO;XrzFavaam*3O|j<5i~G06u8{mS{w~^xZJBeJsLiLm^Mt+>8arYLOuM@ zvxIv2p$b>TV|x*@><&UU-TP&+b*_xZjwaOG&-+vvJ5J~lpIyxMv9SvX*-}0wWJ_+E z8LusekY#HL*&G!{#B*Fk=pw(oy9n8IpAfR?&dZ9kLPBl5s!P)}|$xcxtJ=-MVnR%@_1aFXi~gi~1>bGrSz{LOd>DjNpDemC1N=Z*=yAK%j%yyH9Fp z@&qsa_Gsi`fD%&>_wJwIb@LOK^h=Jk$gzW9-@Fy!afE5$xbAGi>)@x<@m?V}K24>) zxE(9=bWe3Vp1NUo@w?8W`yBC2C)#Tn9;M{txyk05v;t4{(R~_z&LmBJ)wI;`hCEIM zKU6!Pi;5qbM98w&3H9^YnFacjqd9k`RUk0ZN4p7K;fGQSYvGcqS^`1uxaV=f94ST1gMM`Tk*wH`_!bL#D!+vcZL@B@lmMJU@(cKS8`*?Lb} zB7xSlin*fBm3UkhG{-EKU-4S`EmlvOU8_St#pDsff|RdOFW; z;G6KY0Bp(^@RX1DH7u1sTuO%V*_6cTW%qkj@HSo_651BkxjudsE@4Xz#dDRjHSWMu z1>C=Vmy(Fn5r?M{2U8Mnh_5|0xig-knAKx`R6F0vNcNr1Hikz1PP#Na`(C%*jkadC zC>w7HUfDQDwwHMtweXDis*yJcPi5Mx&_X=!QyBB_33c#8X*UJ9aTL3iP*4B8z`KOp z@bHeZ^3C4t2cw~GH+x$jjD`ztcIzebEFrZp>~UuosWh{!m$a}0UVlH1!!`8EOt12? zXk^4}n}Km|lN`Etwl^DC>lXX2h7sn_yb@1GG;_{+nv16z?0b^eoyW>!KUBKa+xk#6 zl5wkTDrIteeluQoZ(B4qvW<{NmX0$ymFBRt@i>b+GbnhrSQ;0(6VJ|7jg;i@yLfs2 zTe1GP*^*gJJ5v&I?CdJbOL$#v;q@6Cz#lv_HZgcg^9cc~S6=HH;@uo^1+Kd{;^$j#?7& zN*RNNc-k3Uh=y}1z|(NkYi^g*?zBTnD>IVAGw|B^C*1}@eMx0EXtjA|D~1OFJ?42^ zpNvKx1ga9=g|Ps3;VFY%4o&VVdn>^Pxe@OY;`~cUcst$z|N7m;cJ~iLLrQrcPutp_h}G`3`D`gY z@U#n^wjbts3 zFH9BoB%ZqBAIsq%@wg`P$acUITabN9Hy3XZshDaS_XFN}csvvAk{lWKKv@aDr6l4+ ziL&)KeXy)5=C=S(!_MPEZWEV3=vBTb8uifiZp!jo$o@%C5EXDA{47fi=8HB|BQ3;peb=49L?}&#YwI8wDmpT}?0eI@G zzlcJ2JmSsn6^*f~Xl*(4M zA5VodR2x(H6-DLE(a4pnEu)TkDTz4t=ukUf$7@A$ddmo%{ZzbvYUc<%cdT&peUJ0F zz-&%Q#L=Kr%VQ~tYs#9#4CLeWvCUyLH{xl=IoWtX9DLf_x-}X}d%CQbtj3%0TKNa# zIzn#HE=dl3N8;C`kveN_oBVejp`mNN^f!2C1ynEXq*qy2wg8!yZg_t#h!^p+0J*;L z1gi4-KwyO5I5u#2JYI^ot!HZF2|}76mN54P2iAM(Z$=}XpNY??4&VYjtrA9x&ASdy z4e(cF_#j?i|AxEcvt=X5m|cgbiDG*#OOCvVr-t!}i??RKJ?o{v9gU2A&bHk@Zo&`Z zjrMxKof@g~eEi9os=EbmxL=*#2c7VOU6l61d@Wut+mxK-$g6ld4OtBg{|OuH@NwJM-Fk!Y+Lh(Iqs-F?e28bu)R}w6D`g9nWjhehPK_4vZFm}wfCfGMF5ZRy9;&=4 z5NPJ}8n-5g``~eZ!6?im#M5jZuDn4=<=8u_3a^&6hBX$&Ye5{BNR~zp-k&Ky!5c!H zJ*?WkX5;LEGzm}rwQ;NPy8E75T79z}V7n6f;YGdPL(?h{uua=ahza8Tf5k23l16&s zX`t-savNU9GVe{i^YJSC7rKU9?VBT(`BTY};dmF9rF<06=HqM&e~WjaUswItZRs3B zywQ!x^9S%gLN>jo;1j&AWo0#Z!=3?lA}8Uc6K8(`_bgs#JbOhrj@Q%ArxUgBHr`0! zQJ3z-@pu;3o<VKgUbFfWic#ZM;u}0$T*+rCiEqWng%*Y<;Pv-g zzR_`eNBAqArjlQ%vaYXx+e`l}8hQS0+eo{^{=m~lu(Q^oG`^>FEuDpz=AT}h2=(_< zW%Nx+e8)~LjeN~_)GE6JRuiRDuHwAXpdY1Cv=`u}+o{MeY#1Rso;9|lRKV%32S1@h z+r4yt9ohI@Dz&*7`z$=AWkhuwfM>Uj=J9Jh7Ces#>h7?dBa@?V2wn%TYLC>&0zx|a z7(i}*aQfn~KvL*)IpZR`SQ!eDUi0^x|zm7&qfET(9DTz2`n<-TF z126rXXr$W*wh;ff3ZbG8ysf~^KxJe8x%vCuC1LlloKilt?X!F7dOSA^Tq|G2v(;&> z{)U%ADa;(bZ2eJuQOGO6Qyj+w4>%sh(+G0}@rLVbJeA__r%2u1wt;qO4Z%yZaSZ=m zc&eS0yes%%x0n8XG&1tzvJ+ED@cR2jbCn2v{jpd1U^LR;llVQ0E*%T;T$#gC5}m^k z)6A1TjdxbpiavN6a61_@@ziMh`S+=WMGpJ`F+7nTzVb%fhx_KfrVio=!my4(5iSVHdolR|zu8C=wG)IBD% zJ%xTw*ZoJN*AP-~n4WJ_!%9Jm*dB-BHgk>VQWE#YZw@qT*WnHKi(-dFKE~_ndm5bj zU)XNZlin#6aO`h9A0*VzPo(#(Ke|MW1Z962f0A2ToE({fr#_N@ogbw!vXg$|ejDW< zmf=2l_TasdkWHm??j^kLejY8N#INiDYu{oI#QU?nJMo&6l6M(&Y8#$r&ervZ^X&F( z`E}V`kun#r4JoVmd;bYMEn)IKoRWy6!L-@V`6iyN^k_<=b6Dyu(zV}s>Ayw8`+;4& z-WR4u&i&Rl+kZb3nS^J@w4{4-XwA1?`tfMwTi`$nw-<|k2kdcdtMc&DZ7W#cyAOEj zzehujzw>5e3cvHV{vM5N`p#~Fi2vsawZHe$|AAi3;KI%qRk zZxf!z)p}J9@!+-0E5y^J*=zS2JhjuMJY3#;6tAq5&>x4*)?hFiX?Mh)rhbml>?0;U z1br3SgK~HV!n{`fA-;n%c%W7MgQ=VlY*%zrdtYwB)2qyYJYHJ9_AZ5%(ddUXO91=DS5u6~#tGre69G_YnG}}mCR1q{8so&SS zXj!$h7%AIyq*r;X_#2VpXE=KkRlJZ*CvC6N&Ha{4OrO8&(}l$Ev*EF1}BZOVHOP7~m2N zatQ`Ie<)Tx9*$K5MmReXtDQQ|#pgOc39AYxV^!fa$FFw0*!e}*6X0Lq1{X0?4lh{+ zXE{3?tBP;Ms)u(f(pfd&ZhTdAud_?AO8bJpL71k|IwJ_rQ%J9_}D z1|7tzhd*H}Vvl3_7xf<1;+ML%QpDsRPqfvaK1UHso!m3Oid{d;To|9_D1zboi}8E@0- zTwT`#S^4#yU*3wJ4OdiS7yoxwBiF>m%PQYFI)61c&7E!K638mp#`*sftBNjk`DC>g zQn9MAz2mYA& zKj+J;oc;+cSq;b_KvnZtRqk}2tID0u|LRBeB9)o#>X21%yz^z%h6&E*IGgM2M66!2 zDksm`0vBKC{3%%e1+LPM|7wf;h-+O!O|~j5#wzU%{LqOqOU}Qrs`wV-W#_nbvMT2` zXXiS5yNj2N5WgV7E$;;`LRJ|UI$u`)-Om3{tdidA^4;h1$*P>i&X<+HL_JmyAHr6} zKIIbpJFD4W=hBt8TE@@A8)5glcv+SImGfmaQr}?JfTC|*#J{sjc)(@+-lhLLt8@ol z{C{FeUlcgxGRUfc!_FSTD(Xk)|AbXfjte@g^uIg*-&v*m!=;l|Fr;DB?TrFTkbu?D zgq>f}Pe7&4uY?uS*DWdi33C1$tBR_*bmgrUUy``ip8~4*G?!3T6`bz;@>c22bm`B+ zYD5}hm9!~8lDxPB zR({mk4$gM0pyw^T2>5f-1=|>ZEcQg~RIFaIDqx!PW#wNbYaTn9Gg&dk4(ro8@@MGB ze`8h5O{CLBtpqFo4wvsgu}be%U>r37^IQRUxdLRBz+iPBaj*05bMfV^(k*tpycJ*K z^4;(311{a4)}3R20=uM^x(v%)23ZyOkn{h}YDqmtyppVR=~ub*vdX{O`Lgn#^2c8R z2iCX*vbwxI@AwOjm$xcl16(7x(b<<>`d3`~@>c0LIW8-|=v4yRZChM~tb*H|FRNgw z^UGTm@Q&lMYKXprP37&xDt?!XFK@-)xAEr7Q-W1>mB?Kh zf9bfa%KO^+<*h3I1}^)pi^tkUP!9MNe&-T?@9aTm54nu8D(JBDWmWM}tQz!_MHx8LDBc z0qQuvyj8-p;Ieg{t>@DHomIN}#B2LDb?Ic)z;m&RKM$)u)@Q)S^m#p%Q4)df}5o27$e`1wBoAe20TN1ab-zEjC`^zK;rnp>J_$24&Vf89+ z)u{N~$|_&6OD8LTI#!Lm0jtGztBaRaCvSH?*8g~PU;#iG7rF>pjlv?V3cAn5FUImO zuv9;sRla4;FK?A^x#P0(AHk~J$FR!xc-U=|$6dnz#46(xF1@V$C!POyRu!*u=~laR zvf^vvL-@3dkX3MY;rxUBNMa}xt%_wKJH2!%|U;cr; z9ya{*p#Gl+_5VDmug4Wt=RjjPpoH-J3xyc?0SUwQ2O<aHZKIkT?j?Y!o2VOdADQC$L)}%QPJg7&90!XEY#Y-WNz30%$)5Fxt!- z1K2EZKp@++%?3=p9Iz-GFwT4>&}1l}_gKILvtTS>m%wp>T+?eDVCFEuW8(mm%+CTT zR{(~M2jrWF#{>2YRGk1QG=nDq=3fbTUSNuepErb|Rq2Y!OJz0yLWhm~N&`0<06*EpUTrnguk;_6Tei=r$X$#gxnjOuYthRN!^f=@vkfYXM7d0cJZ96+hbm;*>D0<00(Zi2S~_6x*r1MDy>1?Cq6YR(N_6xtauBjyGN znx4}kn?&9Zn3HaY91|(L9r9toyd<*xdPu_($nJp2D}fBU0kT8nlYpsz2PE-E$c#H6 zdjjSik#!<%JjmXFx!!|}nE}}+vM*p--3dv$2{P|a$d>{0naF05Zu1~t1=6P8_3LAknnuSp@8W(ACfW~vPR@cz=Re+ z_KU<8K#m5?Dv|lOKx!_8{KWVyg!H@>vPtA;#^-LxF_FT%A-^&{BFpDM8r}mr&iLE| z8FU+DhsYm{&mu_TT*!wN&XG#Uu3AA|_(7;T87%=8;z&?S7rsX4mq%d|B$R$|(jY?)NEQ6|mQUWI9ICdow27MTvF z-fB!oGfk$GDV6DLnm&c;Vy4S-tJrsYjot zsTY|}YXMCj1}t3*=xq)O>=GET4seNCvJNow5kPo7Aj4#=2c)b3tP!}>1fQYo{${Yu z0JBnNpou(-xy)qB3^MCw2AdksVTPD&naj;anW3ik^O#{~lFSumi_Dd#-V0<-e3D9L zyg((Hrc_{^K${JKEHixrV9ZLuK7p8N`63``6=2?rfYD};z-EDN8v)s-WFuheYQRx} zai-HtfF@4?mc9g-U=9iF5*Y9@AlEE;88CAVAp8nolF4`lkn%KOjX=H$ZUXEVh;0HC znw0|c*8*z33YcOtUj_7B2iPPq&D3}ea7>`^HNe$oqrmd@fQFj^*P2P28HMZ27MUVb zZwsc_Op}>zN@cD$O}AohFwPPhhEO`5qu?Ghp6(fQQT;fz6vazq;=X zrt5ZLCt&IpQXJh$iWR2QEzd-Cmz#6ksVE#5h&5rije+$s? zW5Dxf(#L>7Zv%D+Y%ujc0VI|JW_$wJXi5dv3AFhX@UofyDPYVyfPDg+Ov^ohr0sxt zdjPMQJp!8rx_t)NVoE*(Onnz{RN!^fX)mD34#3jAfNkcGz%GFSp99`9OFjq8d=C)b z2Pice`v56B0c!-do8T9K{Q|Ks06WY|f%&@tHNWICwlio(e93Xq^L@xBk@th%W1F*$vqzvM*>_eG5tY7&7l$$d^I$naF05ZU-P=135I=L33DSm&kzcA>Rkh{og}o?tz34LJkE@zk`sJ&me0=jsy*q+Xa!>A;{67StT-m zFQn#S$WM&VVMx!grksJ0V>nkM0SfLgv>cZA!GKlN#6T`O%gWsjxt?IUy)?SQRc6bDHYf( z(B?-##7zGYF!gJ|K7lHxFfS%t2!oLD)n~Yxp#{||0oNj`@ z0hS*G#C`*uX;umhIs~YB9B`J&JPt@a4A>-4&(!!Guuh=xcR&NPQDDpwK*K)(4b7xK z07*Xpb_g_{S1-hP;Ln>8;)Y^gDelyxM7IgzpKGQE0Zo1c>=S5aT803-1m=YR=bJqO zGk*efO8~SqB?*9(V}PRq7nn{J0Q&`&RsggyhXm&T3>XjwB%38+K+j(Q;fjE^CZi(Y zn7|r=R1>TOSpF*@RteDFtP~jZ8=z()po7Uw1SB2@Y!c{XYD56*1PUX7E@q>^nBM^n zD+9WjNtFRfe*ks}^f2|R05%KEr~>F^N&)kxh8T_$@GmmcPar{)AYh+BZ_~0WV3)wW zs(?$(9)X!5K(`YC8K&e!KuQAOsKBMBQ#HVTfu+>|1I!_T`4s>Issk=FOR58Uh5_Lk zfWaoC2H=>$8iC7A@Fc+Uih$TjfMI5(z@SQinkNIUG?^y@5)%QN1TsyHQvmA(3QqxK znT-NtB7lZ90WmYFCLpOYV28kHQ}0y3W`P-}0`cJ5W~IQOlK?gA0E$dz9YEsAfK39^O^vev z>jVnV0^DFW3XC}g(6BCGhM80skW>?}L*Qmp&)+NpGwK0mnNoqNrvlp42i#(&*9SDI z1=uGr$Fyt!*d;Kp0bs7#BQP@w(CutMi77c7kWw3PRKPQx8UpqUENuvwXATL>KMgRT z5x|%wjQ~AQ2ZS2~7MP62fMWt{1nxG$CV=H<0Afu5i_A)aL1zMLHU->gGMfSt>i{+h zEHO3C0jv`!JO}WA*(fmPEI`9^0ZYxKa{)muo0ZL6tJ3z|$fTIH2O{Y}A zeu1T_fF0(L!2A|~0cn7pW=R^LXG=i1J>Y$l(H?M2V2!|sCKv@QZv}`&0lUpgfk77l zYIX>5KN2z{I)u31wuWpH*%LA+b%d-FDeMT@8!|76jA;XD*a@;PWb!&ek}ibo5cx7> z>UV~07Mal*@>R&ZBQiA^(xwaKn~=G_3#3U3WS_`^kZIKwvP)!MSIGAv^O?xZwvcY! zAcsQcj&6{Yc95eYM?$7^cgTK`rQIP%L*}r^{8Y$*9+00x=Kda#o@tP9Psq<9)2}Dw zn8+HDUqdF;3$naDB-RUZJY-gh42nW(rbGToym7RifRlYH%@QK9W2 zzl@6qh7yAXYbT5f-CQBOoo}+KqOv;pacSeo@EPA`Q|(W7E3Ee6A^s;9i}eep_hii zqd9SMXmYsuO|GY-&B4i`y5XO0=Ko_W>$zz$h1RZ}$r!Gs2WJJ%(5Zm#x4BHOtIV}2 z(?Z8W=fwY)+N|-}&GrB5s(JDM!L~eGarfFG*MuGkhQD0mrp6A;i$mz!=m-6ePxyln z|1UOuW@=0hja&Q5<)J1)jsETa=&${uICNfx+81xIeNv6_|BrJv%pZ^`{%oaHH01hJ z&8<4(OZ(p@PR3T#@SNq3*4vci{b(ijRShi#O8)Ky_()^z3;F~U?KIEfs z3DN5<$LbI+bxd(;AS*2Jj$?`wtBdp%D0(SQtRAZB*mlS2!wURQ4H0+Lx zjDLZ%(I}*;(KnW;Kuyt6x8Qa;)(EB#gK28scdRktr`&@3z_BK<$&P&(DzXJO1x|7B zBbV?T*o}_ucI;f(OvgTkY2eO7vmE=(v1YIg$G(8^PhU|NXye#^fI4zM^#@WM{K~-= zuy&4p?O01#&@t@{b-oq)!A;$FF5Lw%eP~!~;(N!q0t8-j=?=p9r|-SHys6#2uD z2Gr;EG>7_@84Zm-U8avWYRG?usmD=N;xhm4(sh7&F5MrFb%fpJ(kY|zc0$IbO8}^j z&f5R_ET!hKf=k$i@Epe~xrAL|a~(@`tQ$-pGSys1T;A@42RL>DOs8NEbTUjY{T;h1 z?ukwl)A*m_U@yY)zYVMjqci$0K>xGQn%7z`-9?1AGSr&aB$w`D!VkOQKh34<4SN8l zDLmb=K7_A!>CS-pAHBT<*w8`!WxZP17d3Y5EXOin@lTZ3b*vw3JLxpn^=E{ExGD#-8ia zaqALzkBY_4bBtS;Kn{7kV4FF{t&5+xo5o+?!J`%qMatX_d%k1C2oEN+=Cy@mR}dcR zSWCyQgk?I`3Z{+>MAQV`n*R|x+BTm@IS+O!Jit(^Y#U zY6jDEb%d#$82S*VsnM76@XtRj_4k9C8vT90${UT;Ik8@jjUim5iZx&94rUX6l#-d7 zz(tOYC9EOT)LiV?IKui+v8JZCW8(?m>sTMhCcyOV0-CN%9Lph`OFE{esO*ymxh|pR zMxR1Zt0y8=tZC3!1&K{UqhK2HOC8H2d^43~Vf#ClPk5$d17Kv)1t-uNHWE9;PsjM{ zg44#q%U$NlFb!c0JJhi$gf)a>!yKDRSiKvCy~43+gw;EZ+Lex7<(N7&+_9?(FVvln zTAJzLHG~&AHo~!MVX8!p%5tn&8=6=3rU^W}3pX}bwNJ$0xHypwL#C81H#?~MB&8hn;x5QR_zs(`|%BC3QEQ3O>+RnQ5jDmoEWL)B3YbP_rl zoq}qjQ&BCHgml+>8af@FfzAvv%5?~wh3cYus6J|d&PEMUBh(l*K~2#)=v;IjYKD%` z_K(pg=u@-@eTJ5x`_TjFRx}6QhUOw&j&)3T(2?1ZKqu50bwORxE$B>C2c3oLq59}- z)DSg7y7xK<>6-ovv-B&{0kIDoW0poC?O*NJf#@=%6HF(T4kH~jeTw*@lSD^lThtEe zEY-#06sEiuIstXY?}8EuM^I&S6P4YJ?uOrk7NG=q1@s4D-OwGz-hysL2T>>VJ^BiL zjr=c{`kcT%v>*9L8$Ear>DbgUxsigppsuJJ>VbNqUg#opG3t%_pi7Y6Oz6!+KXfVT zj|QND=rW`?40?aS`+=gsFalSgE0Nv|WTFu$3+bkR6dH}jAl>kHK)T0YLSr99#b`Rx zje^b*eF5Mhr2Bf^$Lqdbx8%A7UxDhQ2Iy?mP`AvD2sA-W(K+Z0R2S*SUpM;I(2*eL z3@SzXa>fj#Z-HEa9zwUFZb)BM*#YT0I1e#$-=TwOKiZ3SqX*GavuwT8Gx7XV4wULwBMQbUV^rneM`L)1?cqZmPPYS!g!WWk#13T{3jN z%0eSi3~@0n@~_%`aC9~9i!#uqs6Tp%mGClp4P`UHqtRfbYtH3JmlRz>bji>qVmQh~ zBalw`k*Gb=b?_W?E;3vT-l#2A;CIMAIVN?kvq6k_*Bm8gMA$*L^{)~!#;m5D& zIMR3f{D59VW0Bs&AsqXtM{ zc&WD?yv<ZB_lR5O@{6hBl%X&|S!&c}Q;wrlaf84M;awx7(zhw;8<+GoO+TV7Xbmb_$B$>x!{`yT0xd&V zBfXbtgc?iq>TJ{yH9|Y-?R#i5+Jas}i_t=KH@XMih&qtA75WX$MS9;;icX+G7cK>YD7%e8vbaVqch(1QE(BtSyq>sp*hnk~%X~cc# z1HvC6edEqI*e}pt?SGFzUEqG~SLk-s9_f4RQqg0?m0;7*tMuS){Emd{QfNAU3VIp8 z54<~8j|<*IqtTVfV-ZdyZ9XbQqtFwiU5)ZJ|2Lu8C=Inotx!dDL=~dVXcHQTCZhUC zUtXJyUZU{l(W!)sDR?r{y|3PW{7A!|rkpisEK*&MquY^(=Ak0JmA@6uLARl~s6QHj z(orX*Z=|Y*PDN)TeWd7Hv=zO9o8NS{|4nafF6(|#pKn>7Y=z8+rh`vL+(I;p%T8i{eun6hh z-#O?*&42kym1!x3tU!+ECXZDJ_66v9Y4s6*g&@hyVZbP~~&qBKM*6q4(TXg#} z6J3v{vuJdo@W0Prn@jIF_3l*fTD9S{t94b-{SEgtfpKUo(oT<|k;wf9!8c>@l}?*f zR}$U2D~;}v#B@^V#L#_{?w_WpcD?nz4z+SBF6XPlo6!t(1G*92gj9)o6>p3hJQv-A zbV%#4z6;%fJfzFrooE4?k8}xCy$jX*IFyZt6{&P3R;hzLXV;+&^q)WdKRrfFQb>xi)bxcjaH(EHUG;9Jb;#<2IzjY6sZ9DQat>S z^Ou*4QzKQe(x@_}c?7B4c>Z`km8%9mhHROPzXFe=c;a|vTTthP4m2HRn|1!bM&R#G zviPv8viR`+cPHB4jna?gR|Ait_mCR(1M(N9=3m3}u2Z}>$MFAmJ^I%j(v1z5jZj0RhN(e%d@HU;xT;Z) zYa1gyy453GJ<3&Q^ps5LlF@~z4LTpSMy*gw)B;_g=fsK-Y>O^J-B4%L38kX;C=ErC z9>H})T~Jrl6Lm*Dkn;9I=}3?9luzYcf|R}w>WwstDz7iw$wb4E!j~c?x)NQ1 zhM}S8ax?@DM*1&sE<*#+0MsAFP!<}A)R|GvifJ_dDy@t9Mf}i$QRGBt^RSbUhDclq zwP505*(oTV{%XP+>Q>CsRoH20D!LwBgRVo@BGpxlrlUFNR#bEgKW3v@=w>tn-H2{L zH=&tMw_|TZb5RLWBO0Linh6tb=J*J#*3^8Yb!E_9D4tf+Jr9w-$S+9SLIsXPbx{Bv zLhmE(f<@T7(Jr(Tm7=%Nn`j%-nt20lMqSCS-f1*m!@i1MK`)_=XajlyJ&)FC{xw8T zp;c%ldJ;W>x}e9=qi6+Mf*wH+p=IbkbSjnHi+unsMGvC;k+MLrRf2cW4)iX152+$e!v{#GqlQ@X*#&)wDk1Sb*iX^N=p(cn zeS*G6-=MwdGo<{tYW}|_@D(S;9gue)} zYzPOCxSoURZ$b1oB3c8=bA)h1q`xK6?-CB^_XMvH&~MK48?cw z!{(zIg!8ZykRC&gLNU}HrJC8|t7l(QhRD z1*w3a(2wXC>PN<3vBy!EbQRF=F76L(0)7Yuk>2f#>6aa+p&F<<(l0yoi;pU(GK!!? zR8)~4m5_cTaw5_~la#17N_!68J&bwke-VvpVI1oj;f2aW9nh`tC;4f89EQ0 zgBl}sO1n$Hm0_BT%6>(2EQCHMO^WT|3H`E99Ko_BO)D!hW7o*;&FH-y^=n6ClU55Ih zf#^~+0QGla*~`%oG#CvKEqQGr9f+g5H(Zgsdx*sh;i_qPu zJ(`aUx(l^sj@63=Xd$`>X}Gm}G$qhIq*cEe`x;VZuOd~p3B@bZ>et0bm8tM8NC%V3`GWB0XfOH{S^BV z`ULGpO7pRPMYWH>m*^X$!{b}*LG&H^9t}r2el?%}r@QYCi|Y9L-@SJQf(;cTWx<+| zSSY)=iXvi7Vh~F-sHhPE3nC(DR5S>-2sW^ARIourzKV#kfQpK}5rf!b15qOuBntLe zexJE>uS*h4^2hi0KF|C5JUHAtpL6D%GiT16DZ9MD@2&8|CY|Rf8U7hcHfugbISD_n z@beNsY;t0gS|_>gcf5OxA2!K#Ldl-ru<>t-38M`jnAzF6*hc)$&dHdG4fvg%m-&H+ zot3RZ$sBp1otUBrBEoOkgG~b4qrWwz5(^OJwRy$IJTosaJ_eNkUkX zp%Ai7Vb%!CfS4eb_^B)i!?AjHXvcUlp;&|Loi)n0c#i9YU+`~OAur9{eRXs_Kx~wy z2^8H08Z=Uj#0TJ7j25k_#wgLomd(@kFx2~G&-to!VzpO-dZ>$|&apM-H0m@;?1|4` zE*d2!a++wYC^mz%RWs;p0jlhIP8m&4jaXo*9;$P6(K+f`cSTJt)I569a^oD$x)+xC zhJlLFVzjz1O$-v-tB2FpAki8hDLWM;wopgVvmmj*I+l8l5v^^RKh*?n@sks(nek(u z3TmCBvm;BBZcsD;>fb1FjHt&)E`A;Z$!d{ZFkTL)Ho;;~TXu5D8a_Pt-04A&^Q{GS zq)c6%)&OT~t>N5%^)CBQ!Rlfa04`wfiW!^E28+?Qj;63JIBXfc>3m+jeILRUi~~&3 zfyRwROWx#iTePHAW5tH}dH|OSu^#1*6-{g#m|?C2VQIji{@2`dyMnMS7rY5QVuWsV zAVmDo-UVY3P;)>BsgtgBf3_wR(AI#WhBG%oi->y?UW2z^k!=_lB+!{K(b7aRhAhQ5 zx5a9lv9-F0u7m*PNKZmUy{%OZE>7bsPD34rHZC!?F_3;OfZ+ltejJMGR5T7$!>ITS zykWvbu>pM=Dz;HSCcjX;_oS(zqF(sjf-*xzFMNvSb*MP;b1HarhjcJORGS0y zb|!Qw{uv0SKYU?;4J`x@!*C$#08!J?wR7>4u6<+#cnm-d1~Fo! z4P9mJpFsPTKnzT~f2`}eyh#$m*-;Nq(~`wBAZ}^-fUe!r+bn5e_r6BNV=Z-^CXNuC z?I@ig-RyX45nDo&qkHoZgc`d(FHXKxlwbv z=T7}zAI!X8&$S$DM~>ft`*%PvEq@63=DTFCabE!;kB^0RH2gc!OUSUJ%opS@*+hWZ7Sf*;G@Db-oC88X*8mr?5vsgBuocf zZh&w{%*N2Z5r?1H;;}oRpL-)g?d)h^##hCohB?`bnoV}p_gwo>P)Fm91>W%Xyo8aB zsTA#M!y9v(t~U7h+Io281W4!t-v*Pc#?&YZx*Tat?V@0xH_hl_l&GWKQIK^0N8Fsl z+Wl^lxN5?3L{v1({>d=K1mqqK_y{aTnH_pfYQCoRnJsUmR-7H%BCq_I!lGeyHwPYN zEhk6XHd}i=Ne-g$Q?9b^A8q-e##zPt&`LYxQ|^~(;ovpl;5kQ?+eQgJ(eR zet5xrsq2M9D{k$#+9LI#o(nKbFi&Ksg&UG;Vg77${r{!i&|mf zgf`tWx^%G+<}VV|TUt@vEU}Tc5W4_Ys5$uK^7zzGR^JfRvZJe>I#SenXlCKW$3jK; z-GnWHcb6IwA2}t=hPZ8=C_NLvZUCb9qdZ=_)$Wp%n9qQ&u8>eQ!il_rG{gYW6o_;e z@9D>WDT$O2dNim?M-BQk>QVcZ`R>&|J!`BfaH8vs`y(LGr%_KwergfX{ku<$2rFmG zXL9JA$$1ly17S);nkXlG2e;r{=Q&&s7Nf$Q$$k#xSO^4D+WYVa_kBw2e2lzOQG-Df zwcyc?Uy`@@HsCdwBvprHg zjeuNOakKOG&YDfeR(iNl8RIn)2o~-<1CRXd>vutFO^+buteWaVtHDE9>_YC}1Gx=I z_+V7Q7rtw1wu-%vu4kd!MW?#zLZLtiFI^JS0kp)pK@g3y-M6Pt*Q=cpjKEep+Q(RV z0Kt?W+4Rbcc!%OTM#R@TDrJp^>ZrzCl#x2}22Wv)jy&ZW@qvT&WC5!NHZTgHr2;xLOYsL9MJm zKYh95e-w%~Dlb>+Js-#+uH+btGSrnmj}^VN-pHGnJvRG}^&8cPiWCZ+3Q}eQ!K6j0 z6Z8gJJs)MFfkw>3dv5Y~N-9bz%5b=q@L3xgoP-9s0A324ziC)Dm$pY+>}5c-3X}0m z;>`SzCn&v+w({2?p^)vE)0X1U1ofCF+ECOQ(H0ew$p4Cy)D>K6oglU&_oa~bPc0}t zir3s0?a6)#Q>s1HU_EGr%W_F5#f>Ms)Abawel?f2Dut0=VPJF#i9~TBFqGtSbEO+` zFuKz4pXn8x>`IgwkBsM12g-~8gH#f4g;t><53)!BY?}v#LT5U;TCp5@>96uVTq%0d z+68|yvBFgG@k$1i6p)!K9?BZAp`4|VQW-T0hf22m!|13ap3S?V1RW}S0Cz6QOz(Lu zu~d9ghHH;^;U4yK;YarZM_X@|Jq$7KW*2fKZbHd+vVTbW3)*f}x}s8LYB#FITwLxX zZCz}F)q&&I^*gxt^)u_MEc8i9A{$eZAo8G)JXY}QPN$cH91*lP*e0xiab#A?JHhTV zYPnJT%v73FmC8TL;l;pG2v#f#9J2=PSt(k-tHevzlkcA85JI7eXjb8%^rmeO%1tzC zCdis<6AgSuy%`aeJjKZ;%xE~UudJcsT!K7C2v>zaL6>3Pr{lTRaCN5qHYbw!H3fAClw3ov3&u#wG^PlJp?{|_F-h7eKD>bdL z-(}r?qx;CB{*C!7wIXTyZeUd5V~XOHX#QYD*&vEHRf0*Zd5`G?ZEvcv1%`9)O+yh* zw1azNQyt!Ko`2l%L2h6t-nEZEs3vfkzR5$44s`bfj)WBzU+1^?x?0QZK(Z ztm?yOyV9NJb=6;v#a4lma%G~1&1wOQI|x@_5BZhLz!DjyO@-H=+xq9cPfp2%KPO)Q zm^{7H_g|+-f--~1bFG}j2uiB*-%C*xb10pxn5EJvkUIUpp0d0P2vtSFEqy8OKgd}B znuIG0(i459PY*>c+SVg(Sy7TL0+AeR|HMyw2S}DptpDk5_dHteW<>JX8YTR5*UP!S z@PE<;-gUh(CHeo{<+5{8NJgcpNAX0(1C<~khnfGm@bd5d;cwP{@9`Ajd4F=;iV!ES z$+Z8ggUA20=P0Wey_&CR-vw61SLC>%{qIEwopAqaO5K4>{Q1}9d;~qG@u5goe&R#5 zb|OZ#@!{)kPtB);ZaNLX8Xnt_tsUFoxDz#OotshjxSv~}ZEcM;fj;DY6jSD8AlQbR zLp8@yeZ1ekH6j-HP#m}msXp}P6p-0KvWYFBLt*1b0j_S2{ zWDt4pg1{pOae-T$PYF9S=EuPZ%xnu6tL;g?+;GQDdsFv2V-IRzQkS-lI%m~9UrGY4 zcAbo{dim0&?@2?T5wXLU@)`G&K(H0DbLW-cWSP_!jEHNf!A^8k%?4fXuFm-On6ak9 zm+W_g)^spW#76IOPjfXHd)tVxMh)xID>FxQcS*Q>z*y62Fa?8F>j?zozN!1@Az>XI zl8lJ22h&={eXPu*dEHO+>UlTU84=MkZSb(GT6t%r)$BD8t4)fEF^O?0S@&Bi0qt2GF@2v5`Vfwf15^ z^gSMgR+9p$>t5^t-VEe>htuK@3^L7av`SE)52V<=Vu->>h#X0l`^1&P?va$a56;?R z6y4efFFiGyh8-3^)U;NG2a(r)*l%VK-}~L)GVyZz{#HFCf7P*l$R$A(wI8&rgJ?S| zZw1l(gP`yo!&~oAk-PPzMe9+Zz>XQ)9bGquw7Jl+VhkB_v2R-#%#r4+iZ9PHy{QKh z=MN0&KbB_YV!tL_ZhcQ^m4gk3XNVfC7dd{9MX|HRg zVh^}Acwt<*kE4(Skgvx$N(Pe2`S1Yx-71v#MZo->Uyr`J8k>8{2}zG}d_Q@b$&kbj z^Vf%j51Nk3h@9j>k|T=iF~*Y#)h@Vl1WK$TbJ2dl!!@+A~z<3OTranwwQ0 z&yzTE%*Hke78VunVIQwzpOH3cc(v|_i zCXnJcKV^L4S`!BdlJ#8_1l!G*xxMpvWg>fDDNj!C@XCZI<0N*laZo&t;;!$s!6Wzf?BU-@6q3QlMN{Z;AS0qF^B1vxL)dUuH22)~ z39C!1H5!KXsx;s~oGr#L&*KzIqmtZCj6liO462yOriAXK}7!!p*#!@dKd=5FDs*2Q_ma#B9Z4PB3 zIrs}7IcZP~uYG@Nz$fqK=E~H)WG+SJp~a1JX?q^jdNG%etMHuun=*vjkD!(-t{=cg z4Pr?96MWP;hDXJ~$h(um1{QCStq2o&#!zn{3_XBgFe%(Kyf_GN@^b=M&X-|<$DPFvpBXN8qa(9$f7}w54n4^d)qw^;+&2k2jegx?!DzbqzevfMZeBy0s)+ z23xOnWVnnG+9;V;Uj{|jWS(dYKck!faly`$5>KQpxOA3G1&qQknM|(0q`^Qkr<=DX zLa*O?eq1HdW2hex!lY!H%gWef%DDpGD`bk1pHqWKj^=z;oNhXi0;BgiRX0XF<)lbyDu#1g$mY_lBtH~606+BAhR*Eg^IgOkj zR9)f$46ZsSQ!YaUAWvdE8VWat_9-hjKfUyB>Zf-0A)rBy<%l%GC3Ea?Oou& zu3!0o{C&a0;n!Vc4I%%r6pAbY<@prKD*~tMDRdhY%8i?u$Ak4W<2rZ;rjllg=w_pDS{Z6s|J1gA;MjV(Pg~TmNQV9q)5yLUW7!fPlB)+q&G8Gn*xa1YdQi`kDF@Ur zt52R;5ZtBLzPX$$yNH5ZAQ-e-XCRnuj@X1=eBxov?uW_KkVhJ=W!$}hU>8CBE)*A- zOeWME9^=mvm3DI?2S}wB5c8VN*PG%Cf>02 z>x-+`Uu;odCWcxNE%XM6w{N0YM(mtM`|xZy4hoiS<8%D!$I5&%K!IygOpx=aVIu0E z-5b0`on~s}1=<=n!RvNBeS8zVDhbdsgInBLTreR%*pFl)_-xD;TF8hnEvDVXIMr?? zi(BYxhpk+I0-y6Yipt}bfjm}))R}iQaWhmU}GgDZKdn%S$RL}N3#4Ck>~X`K3(i_+uzl1{GR6EAcrl} z?KJIIC}**qGJnO~%WvVC;>sgFSs+bsR)Y0*dc*4EOM4RJ-K{}(Ur*a_2RCB75gWso zI1CMvWS7S9upJb28~P8n93F|Izr-RAXCl6TyfxySY*2)-)dj@k2ragCX=3Yd<-9g&p2rx@!&J5U? z|Hb1qIALKU5wTR&F^8Jn1xLReF87_Z*xDB^n6NM>hXVxUu)AVc^KbTYw;Q*Ya__=Q z_wmepH^Q^U4SlPAEOg4<`V2K};J4f2HKe3xrwPWIq5G)TJ-Eo&eKZ)Q_Wei&k++|B zT}sfm6Q{iKwF58c$;@?mKgBU#xAxP2@i+ z`u%fD>QVB30714LZKV zI-cJ|7LUNYis&p$UghAD?jXrLwOYh6HgBXARg^r+e#mA_mOnzS!KcXcA-H?U^TwW6 zo9c9Q{b8h0w|^VhTYMmo45iU3;=61}StX&{Pt^PoRK(`$b*ADkKk-C#X73N0OkO={ zlktvTH`FlibvhUOG{Wp0o0gdZ2)8n=wgLz?XX-(l?#RTzp29&Ps zw#X;Pa!A%LpO3d-&7liL898k42@Y1&GUS-F>(tU7iL3VAe{tS;e zBMJ}2{P^R>N(uh-GZY2q*Ty29V=@kMIADm5>sJ67Szp1W^Y+vB3iN;+y_7@=e=7-F zAsY(F@EDaPXDNu4n3qF?)VCBX0Wby>%lnd1E3AP>k?Vt;IHaJ}5BV09OU)u5R zVeHy2f)n#L7t}C6{BY^O8sXnh$dhWM@zL%gc|1i%@x?`&{}d+acac(`Vgck$k6vNt z60~kq@f0`CtGLxJo3jaZdWO5?ZsdzXTLn&z!+b8#3GExv;b#!n{W2Hol7FrAnWu+u zm+zUPZ{r(r%Np;@<^ap0`-Cgh@;OxJD+(P`-9*q-sk%JSs!&~)hpXO#9A1cxwO_zO z%+XscN>pubvg{DsN|tWLs$h|T7m`J!PX@?$^0&Ugy?J?d6DC|qc==8!E4Ci-ehKHT zS;+I8?)O#SPj`87&gh~xs9_VJxbfI<-KJMy-(hg^IF~1@ zmAJn4&xgu^kWEo8uRDZ@nmc2gSWJ{!la5j1ipc$yv=7n$6(;cMbl^2Qxr!V|<^*Z> z0dM;i)MFmk|7>Zqh;bGJ<+dP=P+XO{Dv(3vJ+$M8NzwdeLo~@T$1HW%V zL*7Vk&4keoxyf-a=ElyRF#2O(BhER6($PlMt4~-(%7d>1!SdjjZ!lLMDdE1hE~w3p zV=p4u%2u{s{S`F)EoS2Puk!e37;tfBE8SwqgLgSE+xf0=5{3YqY3Y9vNF>&a`91f17&tVVktvczOYTDAFY)vy-a7JT6W14C{po@hX+pLmo%}_PveXOyiFiZT`tB%Hm^3nn9 z=%6`HU3Y7ildg%T?neO=LxTe+2Zrf`$4v?h^Y;r6Y&9luO8D5(V@Lb9!k=&n9TyTF zm{q=8(@dbAjWyHBX{yF7%YLtBb1nL+xu$cLkf(XvuKHZ|ZFu+>cTU5;pu4Hrg`y34 O => { shortcode: { in: matches.map(match => match.replace(/:/g, "")), }, + instanceId: null, }, include: { instance: true, diff --git a/database/entities/Relationship.ts b/database/entities/Relationship.ts index 7b3162c3..fb56fa85 100644 --- a/database/entities/Relationship.ts +++ b/database/entities/Relationship.ts @@ -12,7 +12,7 @@ import { client } from "~database/datasource"; * @param other The user who is the subject of the relationship. * @returns The newly created relationship. */ -export const createNew = async ( +export const createNewRelationship = async ( owner: User, other: User ): Promise => { @@ -41,8 +41,10 @@ export const createNew = async ( * Converts the relationship to an API-friendly format. * @returns The API-friendly relationship. */ -// eslint-disable-next-line @typescript-eslint/require-await -export const toAPI = async (rel: Relationship): Promise => { +export const relationshipToAPI = async ( + rel: Relationship + // eslint-disable-next-line @typescript-eslint/require-await +): Promise => { return { blocked_by: rel.blockedBy, blocking: rel.blocking, diff --git a/database/entities/Status.ts b/database/entities/Status.ts index 2cecbe3c..8ead79e8 100644 --- a/database/entities/Status.ts +++ b/database/entities/Status.ts @@ -47,9 +47,9 @@ export const statusAndUserRelations = { instance: true, mentions: true, pinnedBy: true, - replies: { - include: { - _count: true, + _count: { + select: { + replies: true, }, }, }, @@ -57,9 +57,10 @@ export const statusAndUserRelations = { instance: true, mentions: true, pinnedBy: true, - replies: { - include: { - _count: true, + _count: { + select: { + replies: true, + likes: true, }, }, reblog: { @@ -77,9 +78,9 @@ export const statusAndUserRelations = { instance: true, mentions: true, pinnedBy: true, - replies: { - include: { - _count: true, + _count: { + select: { + replies: true, }, }, }, @@ -99,9 +100,9 @@ export const statusAndUserRelations = { instance: true, mentions: true, pinnedBy: true, - replies: { - include: { - _count: true, + _count: { + select: { + replies: true, }, }, }, @@ -113,7 +114,7 @@ export const statusAndUserRelations = { }, }; -type StatusWithRelations = Status & { +export type StatusWithRelations = Status & { author: UserWithRelations; application: Application | null; emojis: Emoji[]; @@ -126,16 +127,17 @@ type StatusWithRelations = Status & { instance: Instance | null; mentions: User[]; pinnedBy: User[]; - replies: Status[] & { - _count: number; + _count: { + replies: number; }; }) | null; instance: Instance | null; mentions: User[]; pinnedBy: User[]; - replies: Status[] & { - _count: number; + _count: { + replies: number; + likes: number; }; reblog: | (Status & { @@ -146,8 +148,8 @@ type StatusWithRelations = Status & { instance: Instance | null; mentions: User[]; pinnedBy: User[]; - replies: Status[] & { - _count: number; + _count: { + replies: number; }; }) | null; @@ -160,8 +162,8 @@ type StatusWithRelations = Status & { instance: Instance | null; mentions: User[]; pinnedBy: User[]; - replies: Status[] & { - _count: number; + _count: { + replies: number; }; }) | null; @@ -196,12 +198,13 @@ export const isViewableByUser = (status: Status, user: User | null) => { export const fetchFromRemote = async (uri: string): Promise => { // Check if already in database - const existingStatus = await client.status.findFirst({ - where: { - uri: uri, - }, - include: statusAndUserRelations, - }); + const existingStatus: StatusWithRelations | null = + await client.status.findFirst({ + where: { + uri: uri, + }, + include: statusAndUserRelations, + }); if (existingStatus) return existingStatus; @@ -228,7 +231,7 @@ export const fetchFromRemote = async (uri: string): Promise => { quotingStatus = await fetchFromRemote(body.quotes[0]); } - return await createNew({ + return await createNewStatus({ account: author, content: content?.content || "", content_type: content?.content_type, @@ -254,18 +257,79 @@ export const fetchFromRemote = async (uri: string): Promise => { * Return all the ancestors of this post, */ // eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-unused-vars -export const getAncestors = async (fetcher: UserWithRelations | null) => { - // TODO: Implement - return []; +export const getAncestors = async ( + status: StatusWithRelations, + fetcher: UserWithRelations | null +) => { + const ancestors: StatusWithRelations[] = []; + + let currentStatus = status; + + while (currentStatus.inReplyToPostId) { + const parent = await client.status.findFirst({ + where: { + id: currentStatus.inReplyToPostId, + }, + include: statusAndUserRelations, + }); + + if (!parent) break; + + ancestors.push(parent); + + currentStatus = parent; + } + + // Filter for posts that are viewable by the user + + const viewableAncestors = ancestors.filter(ancestor => + isViewableByUser(ancestor, fetcher) + ); + return viewableAncestors; }; /** - * Return all the descendants of this post, + * Return all the descendants of this post (recursive) + * Temporary implementation, will be replaced with a recursive SQL query when Prisma adds support for it */ // eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-unused-vars -export const getDescendants = async (fetcher: UserWithRelations | null) => { - // TODO: Implement - return []; +export const getDescendants = async ( + status: StatusWithRelations, + fetcher: UserWithRelations | null, + depth = 0 +) => { + const descendants: StatusWithRelations[] = []; + + const currentStatus = status; + + // Fetch all children of children of children recursively calling getDescendants + + const children = await client.status.findMany({ + where: { + inReplyToPostId: currentStatus.id, + }, + include: statusAndUserRelations, + }); + + for (const child of children) { + descendants.push(child); + + if (depth < 20) { + const childDescendants = await getDescendants( + child, + fetcher, + depth + 1 + ); + descendants.push(...childDescendants); + } + } + + // Filter for posts that are viewable by the user + + const viewableDescendants = descendants.filter(descendant => + isViewableByUser(descendant, fetcher) + ); + return viewableDescendants; }; /** @@ -273,7 +337,7 @@ export const getDescendants = async (fetcher: UserWithRelations | null) => { * @param data The data for the new status. * @returns A promise that resolves with the new status. */ -const createNew = async (data: { +export const createNewStatus = async (data: { account: User; application: Application | null; content: string; @@ -408,7 +472,7 @@ export const statusToAPI = async ( reblogId: status.id, }, }), - replies_count: status.replies._count, + replies_count: status._count.replies, sensitive: status.sensitive, spoiler_text: status.spoilerText, tags: [], diff --git a/database/entities/User.ts b/database/entities/User.ts index 13c31bf9..8a57b2c9 100644 --- a/database/entities/User.ts +++ b/database/entities/User.ts @@ -32,9 +32,10 @@ export const userRelations = { relationships: true, relationshipSubjects: true, pinnedNotes: true, - statuses: { + _count: { select: { - _count: true, + statuses: true, + likes: true, }, }, }; @@ -46,8 +47,9 @@ export type UserWithRelations = User & { relationships: Relationship[]; relationshipSubjects: Relationship[]; pinnedNotes: Status[]; - statuses: { - length: number; + _count: { + statuses: number; + likes: number; }; }; @@ -353,7 +355,7 @@ export const userToAPI = async ( followers_count: user.relationshipSubjects.filter(r => r.following) .length, following_count: user.relationships.filter(r => r.following).length, - statuses_count: user.statuses.length, + statuses_count: user._count.statuses, emojis: await Promise.all(user.emojis.map(emoji => emojiToAPI(emoji))), // TODO: Add fields fields: [], diff --git a/package.json b/package.json index e854ea14..7e024233 100644 --- a/package.json +++ b/package.json @@ -66,12 +66,14 @@ "chalk": "^5.3.0", "html-to-text": "^9.0.5", "ip-matching": "^2.1.2", + "iso-639-1": "^3.1.0", "isomorphic-dompurify": "^1.9.0", "jsonld": "^8.3.1", "marked": "^9.1.2", "pg": "^8.11.3", "prisma": "^5.5.2", "reflect-metadata": "^0.1.13", + "sharp": "^0.32.6", "typeorm": "^0.3.17" } } \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a3b2bc0d..dcf058ff 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -151,6 +151,10 @@ model User { header String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + isBot Boolean @default(false) + isLocked Boolean @default(false) + isDiscoverable Boolean @default(false) + sanctions String[] @default([]) publicKey String privateKey String? // Nullable relationships Relationship[] @relation("OwnerToRelationship") // One to many relation with Relationship diff --git a/server/api/api/v1/accounts/[id]/block.ts b/server/api/api/v1/accounts/[id]/block.ts index 583ce511..291866a7 100644 --- a/server/api/api/v1/accounts/[id]/block.ts +++ b/server/api/api/v1/accounts/[id]/block.ts @@ -1,8 +1,15 @@ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; -import { Relationship } from "~database/entities/Relationship"; -import { UserAction, userRelations } from "~database/entities/User"; +import { + createNewRelationship, + relationshipToAPI, +} from "~database/entities/Relationship"; +import { + getFromRequest, + getRelationshipToOtherUser, +} from "~database/entities/User"; import { applyConfig } from "@api"; +import { client } from "~database/datasource"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -25,29 +32,42 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - const { user: self } = await UserAction.getFromRequest(req); + const { user: self } = await getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); - const user = await UserAction.findOne({ - where: { - id, + const user = await client.user.findUnique({ + where: { id }, + include: { + relationships: { + include: { + owner: true, + subject: true, + }, + }, }, - relations: userRelations, }); if (!user) return errorResponse("User not found", 404); // Check if already following - let relationship = await self.getRelationshipToOtherUser(user); + let relationship = await getRelationshipToOtherUser(self, user); if (!relationship) { // Create new relationship - const newRelationship = await Relationship.createNew(self, user); + const newRelationship = await createNewRelationship(self, user); - self.relationships.push(newRelationship); - await self.save(); + await client.user.update({ + where: { id: self.id }, + data: { + relationships: { + connect: { + id: newRelationship.id, + }, + }, + }, + }); relationship = newRelationship; } @@ -56,6 +76,12 @@ export default async ( relationship.blocking = true; } - await relationship.save(); - return jsonResponse(await relationship.toAPI()); + await client.relationship.update({ + where: { id: relationship.id }, + data: { + blocking: true, + }, + }); + + return jsonResponse(await relationshipToAPI(relationship)); }; diff --git a/server/api/api/v1/accounts/[id]/follow.ts b/server/api/api/v1/accounts/[id]/follow.ts index bc5f1e70..62da3cf8 100644 --- a/server/api/api/v1/accounts/[id]/follow.ts +++ b/server/api/api/v1/accounts/[id]/follow.ts @@ -1,9 +1,16 @@ import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; -import { Relationship } from "~database/entities/Relationship"; -import { UserAction, userRelations } from "~database/entities/User"; +import { + createNewRelationship, + relationshipToAPI, +} from "~database/entities/Relationship"; +import { + getFromRequest, + getRelationshipToOtherUser, +} from "~database/entities/User"; import { applyConfig } from "@api"; +import { client } from "~database/datasource"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -26,7 +33,7 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - const { user: self } = await UserAction.getFromRequest(req); + const { user: self } = await getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); @@ -36,25 +43,38 @@ export default async ( languages?: string[]; }>(req); - const user = await UserAction.findOne({ - where: { - id, + const user = await client.user.findUnique({ + where: { id }, + include: { + relationships: { + include: { + owner: true, + subject: true, + }, + }, }, - relations: userRelations, }); if (!user) return errorResponse("User not found", 404); // Check if already following - let relationship = await self.getRelationshipToOtherUser(user); + let relationship = await getRelationshipToOtherUser(self, user); if (!relationship) { // Create new relationship - const newRelationship = await Relationship.createNew(self, user); + const newRelationship = await createNewRelationship(self, user); - self.relationships.push(newRelationship); - await self.save(); + await client.user.update({ + where: { id: self.id }, + data: { + relationships: { + connect: { + id: newRelationship.id, + }, + }, + }, + }); relationship = newRelationship; } @@ -63,7 +83,7 @@ export default async ( relationship.following = true; } if (reblogs) { - relationship.showing_reblogs = true; + relationship.showingReblogs = true; } if (notify) { relationship.notifying = true; @@ -72,6 +92,15 @@ export default async ( relationship.languages = languages; } - await relationship.save(); - return jsonResponse(await relationship.toAPI()); + await client.relationship.update({ + where: { id: relationship.id }, + data: { + following: true, + showingReblogs: reblogs ?? false, + notifying: notify ?? false, + languages: languages ?? [], + }, + }); + + return jsonResponse(relationshipToAPI(relationship)); }; diff --git a/server/api/api/v1/accounts/[id]/index.ts b/server/api/api/v1/accounts/[id]/index.ts index f9d52e20..f6eadb3c 100644 --- a/server/api/api/v1/accounts/[id]/index.ts +++ b/server/api/api/v1/accounts/[id]/index.ts @@ -1,7 +1,13 @@ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; -import { UserAction, userRelations } from "~database/entities/User"; +import { + UserWithRelations, + getFromRequest, + userRelations, + userToAPI, +} from "~database/entities/User"; import { applyConfig } from "@api"; +import { client } from "~database/datasource"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -24,15 +30,13 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - const { user } = await UserAction.getFromRequest(req); + const { user } = await getFromRequest(req); - let foundUser: UserAction | null; + let foundUser: UserWithRelations | null; try { - foundUser = await UserAction.findOne({ - where: { - id, - }, - relations: userRelations, + foundUser = await client.user.findUnique({ + where: { id }, + include: userRelations, }); } catch (e) { return errorResponse("Invalid ID", 404); @@ -40,5 +44,5 @@ export default async ( if (!foundUser) return errorResponse("User not found", 404); - return jsonResponse(await foundUser.toAPI(user?.id === foundUser.id)); + return jsonResponse(await userToAPI(foundUser, user?.id === foundUser.id)); }; diff --git a/server/api/api/v1/accounts/[id]/mute.ts b/server/api/api/v1/accounts/[id]/mute.ts index eabacd52..b1b85f06 100644 --- a/server/api/api/v1/accounts/[id]/mute.ts +++ b/server/api/api/v1/accounts/[id]/mute.ts @@ -1,9 +1,16 @@ import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; -import { Relationship } from "~database/entities/Relationship"; -import { UserAction, userRelations } from "~database/entities/User"; +import { + createNewRelationship, + relationshipToAPI, +} from "~database/entities/Relationship"; +import { + getFromRequest, + getRelationshipToOtherUser, +} from "~database/entities/User"; import { applyConfig } from "@api"; +import { client } from "~database/datasource"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -26,7 +33,7 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - const { user: self } = await UserAction.getFromRequest(req); + const { user: self } = await getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); @@ -36,25 +43,38 @@ export default async ( duration: number; }>(req); - const user = await UserAction.findOne({ - where: { - id, + const user = await client.user.findUnique({ + where: { id }, + include: { + relationships: { + include: { + owner: true, + subject: true, + }, + }, }, - relations: userRelations, }); if (!user) return errorResponse("User not found", 404); // Check if already following - let relationship = await self.getRelationshipToOtherUser(user); + let relationship = await getRelationshipToOtherUser(self, user); if (!relationship) { // Create new relationship - const newRelationship = await Relationship.createNew(self, user); + const newRelationship = await createNewRelationship(self, user); - self.relationships.push(newRelationship); - await self.save(); + await client.user.update({ + where: { id: self.id }, + data: { + relationships: { + connect: { + id: newRelationship.id, + }, + }, + }, + }); relationship = newRelationship; } @@ -63,11 +83,18 @@ export default async ( relationship.muting = true; } if (notifications ?? true) { - relationship.muting_notifications = true; + relationship.mutingNotifications = true; } + await client.relationship.update({ + where: { id: relationship.id }, + data: { + muting: true, + mutingNotifications: notifications ?? true, + }, + }); + // TODO: Implement duration - await relationship.save(); - return jsonResponse(await relationship.toAPI()); + return jsonResponse(await relationshipToAPI(relationship)); }; diff --git a/server/api/api/v1/accounts/[id]/note.ts b/server/api/api/v1/accounts/[id]/note.ts index 54f8378d..5aa005af 100644 --- a/server/api/api/v1/accounts/[id]/note.ts +++ b/server/api/api/v1/accounts/[id]/note.ts @@ -1,9 +1,16 @@ import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; -import { Relationship } from "~database/entities/Relationship"; -import { UserAction, userRelations } from "~database/entities/User"; +import { + createNewRelationship, + relationshipToAPI, +} from "~database/entities/Relationship"; +import { + getFromRequest, + getRelationshipToOtherUser, +} from "~database/entities/User"; import { applyConfig } from "@api"; +import { client } from "~database/datasource"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -26,7 +33,7 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - const { user: self } = await UserAction.getFromRequest(req); + const { user: self } = await getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); @@ -34,31 +41,50 @@ export default async ( comment: string; }>(req); - const user = await UserAction.findOne({ - where: { - id, + const user = await client.user.findUnique({ + where: { id }, + include: { + relationships: { + include: { + owner: true, + subject: true, + }, + }, }, - relations: userRelations, }); if (!user) return errorResponse("User not found", 404); // Check if already following - let relationship = await self.getRelationshipToOtherUser(user); + let relationship = await getRelationshipToOtherUser(self, user); if (!relationship) { // Create new relationship - const newRelationship = await Relationship.createNew(self, user); + const newRelationship = await createNewRelationship(self, user); - self.relationships.push(newRelationship); - await self.save(); + await client.user.update({ + where: { id: self.id }, + data: { + relationships: { + connect: { + id: newRelationship.id, + }, + }, + }, + }); relationship = newRelationship; } relationship.note = comment ?? ""; - await relationship.save(); - return jsonResponse(await relationship.toAPI()); + await client.relationship.update({ + where: { id: relationship.id }, + data: { + note: relationship.note, + }, + }); + + return jsonResponse(await relationshipToAPI(relationship)); }; diff --git a/server/api/api/v1/accounts/[id]/pin.ts b/server/api/api/v1/accounts/[id]/pin.ts index 82ee42cb..84b76611 100644 --- a/server/api/api/v1/accounts/[id]/pin.ts +++ b/server/api/api/v1/accounts/[id]/pin.ts @@ -1,8 +1,15 @@ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; -import { Relationship } from "~database/entities/Relationship"; -import { UserAction, userRelations } from "~database/entities/User"; +import { + createNewRelationship, + relationshipToAPI, +} from "~database/entities/Relationship"; +import { + getFromRequest, + getRelationshipToOtherUser, +} from "~database/entities/User"; import { applyConfig } from "@api"; +import { client } from "~database/datasource"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -25,29 +32,42 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - const { user: self } = await UserAction.getFromRequest(req); + const { user: self } = await getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); - const user = await UserAction.findOne({ - where: { - id, + const user = await client.user.findUnique({ + where: { id }, + include: { + relationships: { + include: { + owner: true, + subject: true, + }, + }, }, - relations: userRelations, }); if (!user) return errorResponse("User not found", 404); // Check if already following - let relationship = await self.getRelationshipToOtherUser(user); + let relationship = await getRelationshipToOtherUser(self, user); if (!relationship) { // Create new relationship - const newRelationship = await Relationship.createNew(self, user); + const newRelationship = await createNewRelationship(self, user); - self.relationships.push(newRelationship); - await self.save(); + await client.user.update({ + where: { id: self.id }, + data: { + relationships: { + connect: { + id: newRelationship.id, + }, + }, + }, + }); relationship = newRelationship; } @@ -56,6 +76,12 @@ export default async ( relationship.endorsed = true; } - await relationship.save(); - return jsonResponse(await relationship.toAPI()); + await client.relationship.update({ + where: { id: relationship.id }, + data: { + endorsed: true, + }, + }); + + return jsonResponse(await relationshipToAPI(relationship)); }; diff --git a/server/api/api/v1/accounts/[id]/remove_from_followers.ts b/server/api/api/v1/accounts/[id]/remove_from_followers.ts index 9bf94a9c..ce321933 100644 --- a/server/api/api/v1/accounts/[id]/remove_from_followers.ts +++ b/server/api/api/v1/accounts/[id]/remove_from_followers.ts @@ -1,8 +1,15 @@ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; -import { Relationship } from "~database/entities/Relationship"; -import { UserAction, userRelations } from "~database/entities/User"; +import { + createNewRelationship, + relationshipToAPI, +} from "~database/entities/Relationship"; +import { + getFromRequest, + getRelationshipToOtherUser, +} from "~database/entities/User"; import { applyConfig } from "@api"; +import { client } from "~database/datasource"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -25,37 +32,71 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - const { user: self } = await UserAction.getFromRequest(req); + const { user: self } = await getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); - const user = await UserAction.findOne({ - where: { - id, + const user = await client.user.findUnique({ + where: { id }, + include: { + relationships: { + include: { + owner: true, + subject: true, + }, + }, }, - relations: userRelations, }); if (!user) return errorResponse("User not found", 404); // Check if already following - let relationship = await self.getRelationshipToOtherUser(user); + let relationship = await getRelationshipToOtherUser(self, user); if (!relationship) { // Create new relationship - const newRelationship = await Relationship.createNew(self, user); + const newRelationship = await createNewRelationship(self, user); - self.relationships.push(newRelationship); - await self.save(); + await client.user.update({ + where: { id: self.id }, + data: { + relationships: { + connect: { + id: newRelationship.id, + }, + }, + }, + }); relationship = newRelationship; } - if (relationship.followed_by) { - relationship.followed_by = false; + if (relationship.followedBy) { + relationship.followedBy = false; } - await relationship.save(); - return jsonResponse(await relationship.toAPI()); + await client.relationship.update({ + where: { id: relationship.id }, + data: { + followedBy: false, + }, + }); + + if (user.instanceId === null) { + // Also remove from followers list + await client.relationship.update({ + // @ts-expect-error Idk why there's this error + where: { + ownerId: user.id, + subjectId: self.id, + following: true, + }, + data: { + following: false, + }, + }); + } + + return jsonResponse(await relationshipToAPI(relationship)); }; diff --git a/server/api/api/v1/accounts/[id]/statuses.ts b/server/api/api/v1/accounts/[id]/statuses.ts index 8b663b5a..220ef2f7 100644 --- a/server/api/api/v1/accounts/[id]/statuses.ts +++ b/server/api/api/v1/accounts/[id]/statuses.ts @@ -1,10 +1,10 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; -import { Status, statusAndUserRelations } from "~database/entities/Status"; -import { UserAction, userRelations } from "~database/entities/User"; +import { statusAndUserRelations, statusToAPI } from "~database/entities/Status"; +import { userRelations } from "~database/entities/User"; import { applyConfig } from "@api"; -import { FindManyOptions } from "typeorm"; +import { client } from "~database/datasource"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -47,79 +47,29 @@ export default async ( tagged?: string; } = matchedRoute.query; - const user = await UserAction.findOne({ - where: { - id, - }, - relations: userRelations, + const user = await client.user.findUnique({ + where: { id }, + include: userRelations, }); if (!user) return errorResponse("User not found", 404); - // Get list of boosts for this status - let query: FindManyOptions = { + const objects = await client.status.findMany({ where: { - account: { - id: user.id, - }, + authorId: id, isReblog: exclude_reblogs ? true : undefined, + id: { + lt: max_id, + gt: min_id, + gte: since_id, + }, }, - relations: statusAndUserRelations, + include: statusAndUserRelations, take: limit ?? 20, - order: { - id: "DESC", + orderBy: { + id: "desc", }, - }; - - if (max_id) { - const maxStatus = await Status.findOneBy({ id: max_id }); - if (maxStatus) { - query = { - ...query, - where: { - ...query.where, - created_at: { - ...(query.where as any)?.created_at, - $lt: maxStatus.created_at, - }, - }, - }; - } - } - - if (since_id) { - const sinceStatus = await Status.findOneBy({ id: since_id }); - if (sinceStatus) { - query = { - ...query, - where: { - ...query.where, - created_at: { - ...(query.where as any)?.created_at, - $gt: sinceStatus.created_at, - }, - }, - }; - } - } - - if (min_id) { - const minStatus = await Status.findOneBy({ id: min_id }); - if (minStatus) { - query = { - ...query, - where: { - ...query.where, - created_at: { - ...(query.where as any)?.created_at, - $gte: minStatus.created_at, - }, - }, - }; - } - } - - const objects = await Status.find(query); + }); // Constuct HTTP Link header (next and prev) const linkHeader = []; @@ -129,14 +79,13 @@ export default async ( `<${urlWithoutQuery}?max_id=${objects[0].id}&limit=${limit}>; rel="next"` ); linkHeader.push( - `<${urlWithoutQuery}?since_id=${ - objects[objects.length - 1].id - }&limit=${limit}>; rel="prev"` + `<${urlWithoutQuery}?since_id=${objects.at(-1) + ?.id}&limit=${limit}>; rel="prev"` ); } return jsonResponse( - await Promise.all(objects.map(async status => await status.toAPI())), + await Promise.all(objects.map(status => statusToAPI(status))), 200, { Link: linkHeader.join(", "), diff --git a/server/api/api/v1/accounts/[id]/unblock.ts b/server/api/api/v1/accounts/[id]/unblock.ts index d28f16a4..0aeb007d 100644 --- a/server/api/api/v1/accounts/[id]/unblock.ts +++ b/server/api/api/v1/accounts/[id]/unblock.ts @@ -1,8 +1,15 @@ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; -import { Relationship } from "~database/entities/Relationship"; -import { UserAction, userRelations } from "~database/entities/User"; +import { + createNewRelationship, + relationshipToAPI, +} from "~database/entities/Relationship"; +import { + getFromRequest, + getRelationshipToOtherUser, +} from "~database/entities/User"; import { applyConfig } from "@api"; +import { client } from "~database/datasource"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -25,29 +32,42 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - const { user: self } = await UserAction.getFromRequest(req); + const { user: self } = await getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); - const user = await UserAction.findOne({ - where: { - id, + const user = await client.user.findUnique({ + where: { id }, + include: { + relationships: { + include: { + owner: true, + subject: true, + }, + }, }, - relations: userRelations, }); if (!user) return errorResponse("User not found", 404); // Check if already following - let relationship = await self.getRelationshipToOtherUser(user); + let relationship = await getRelationshipToOtherUser(self, user); if (!relationship) { // Create new relationship - const newRelationship = await Relationship.createNew(self, user); + const newRelationship = await createNewRelationship(self, user); - self.relationships.push(newRelationship); - await self.save(); + await client.user.update({ + where: { id: self.id }, + data: { + relationships: { + connect: { + id: newRelationship.id, + }, + }, + }, + }); relationship = newRelationship; } @@ -56,6 +76,12 @@ export default async ( relationship.blocking = false; } - await relationship.save(); - return jsonResponse(await relationship.toAPI()); + await client.relationship.update({ + where: { id: relationship.id }, + data: { + blocking: false, + }, + }); + + return jsonResponse(await relationshipToAPI(relationship)); }; diff --git a/server/api/api/v1/accounts/[id]/unfollow.ts b/server/api/api/v1/accounts/[id]/unfollow.ts index 0d189824..1ca9ea20 100644 --- a/server/api/api/v1/accounts/[id]/unfollow.ts +++ b/server/api/api/v1/accounts/[id]/unfollow.ts @@ -1,8 +1,15 @@ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; -import { Relationship } from "~database/entities/Relationship"; -import { UserAction, userRelations } from "~database/entities/User"; +import { + createNewRelationship, + relationshipToAPI, +} from "~database/entities/Relationship"; +import { + getFromRequest, + getRelationshipToOtherUser, +} from "~database/entities/User"; import { applyConfig } from "@api"; +import { client } from "~database/datasource"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -25,29 +32,42 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - const { user: self } = await UserAction.getFromRequest(req); + const { user: self } = await getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); - const user = await UserAction.findOne({ - where: { - id, + const user = await client.user.findUnique({ + where: { id }, + include: { + relationships: { + include: { + owner: true, + subject: true, + }, + }, }, - relations: userRelations, }); if (!user) return errorResponse("User not found", 404); // Check if already following - let relationship = await self.getRelationshipToOtherUser(user); + let relationship = await getRelationshipToOtherUser(self, user); if (!relationship) { // Create new relationship - const newRelationship = await Relationship.createNew(self, user); + const newRelationship = await createNewRelationship(self, user); - self.relationships.push(newRelationship); - await self.save(); + await client.user.update({ + where: { id: self.id }, + data: { + relationships: { + connect: { + id: newRelationship.id, + }, + }, + }, + }); relationship = newRelationship; } @@ -56,6 +76,12 @@ export default async ( relationship.following = false; } - await relationship.save(); - return jsonResponse(await relationship.toAPI()); + await client.relationship.update({ + where: { id: relationship.id }, + data: { + following: false, + }, + }); + + return jsonResponse(await relationshipToAPI(relationship)); }; diff --git a/server/api/api/v1/accounts/[id]/unmute.ts b/server/api/api/v1/accounts/[id]/unmute.ts index 7a79bae0..ed46b8ad 100644 --- a/server/api/api/v1/accounts/[id]/unmute.ts +++ b/server/api/api/v1/accounts/[id]/unmute.ts @@ -1,8 +1,15 @@ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; -import { Relationship } from "~database/entities/Relationship"; -import { UserAction, userRelations } from "~database/entities/User"; +import { + createNewRelationship, + relationshipToAPI, +} from "~database/entities/Relationship"; +import { + getFromRequest, + getRelationshipToOtherUser, +} from "~database/entities/User"; import { applyConfig } from "@api"; +import { client } from "~database/datasource"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -25,29 +32,42 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - const { user: self } = await UserAction.getFromRequest(req); + const { user: self } = await getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); - const user = await UserAction.findOne({ - where: { - id, + const user = await client.user.findUnique({ + where: { id }, + include: { + relationships: { + include: { + owner: true, + subject: true, + }, + }, }, - relations: userRelations, }); if (!user) return errorResponse("User not found", 404); // Check if already following - let relationship = await self.getRelationshipToOtherUser(user); + let relationship = await getRelationshipToOtherUser(self, user); if (!relationship) { // Create new relationship - const newRelationship = await Relationship.createNew(self, user); + const newRelationship = await createNewRelationship(self, user); - self.relationships.push(newRelationship); - await self.save(); + await client.user.update({ + where: { id: self.id }, + data: { + relationships: { + connect: { + id: newRelationship.id, + }, + }, + }, + }); relationship = newRelationship; } @@ -58,6 +78,12 @@ export default async ( // TODO: Implement duration - await relationship.save(); - return jsonResponse(await relationship.toAPI()); + await client.relationship.update({ + where: { id: relationship.id }, + data: { + muting: false, + }, + }); + + return jsonResponse(await relationshipToAPI(relationship)); }; diff --git a/server/api/api/v1/accounts/[id]/unpin.ts b/server/api/api/v1/accounts/[id]/unpin.ts index 1462f145..c941ba0f 100644 --- a/server/api/api/v1/accounts/[id]/unpin.ts +++ b/server/api/api/v1/accounts/[id]/unpin.ts @@ -1,8 +1,15 @@ import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; -import { Relationship } from "~database/entities/Relationship"; -import { UserAction, userRelations } from "~database/entities/User"; +import { + createNewRelationship, + relationshipToAPI, +} from "~database/entities/Relationship"; +import { + getFromRequest, + getRelationshipToOtherUser, +} from "~database/entities/User"; import { applyConfig } from "@api"; +import { client } from "~database/datasource"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -25,29 +32,42 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - const { user: self } = await UserAction.getFromRequest(req); + const { user: self } = await getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); - const user = await UserAction.findOne({ - where: { - id, + const user = await client.user.findUnique({ + where: { id }, + include: { + relationships: { + include: { + owner: true, + subject: true, + }, + }, }, - relations: userRelations, }); if (!user) return errorResponse("User not found", 404); // Check if already following - let relationship = await self.getRelationshipToOtherUser(user); + let relationship = await getRelationshipToOtherUser(self, user); if (!relationship) { // Create new relationship - const newRelationship = await Relationship.createNew(self, user); + const newRelationship = await createNewRelationship(self, user); - self.relationships.push(newRelationship); - await self.save(); + await client.user.update({ + where: { id: self.id }, + data: { + relationships: { + connect: { + id: newRelationship.id, + }, + }, + }, + }); relationship = newRelationship; } @@ -56,6 +76,12 @@ export default async ( relationship.endorsed = false; } - await relationship.save(); - return jsonResponse(await relationship.toAPI()); + await client.relationship.update({ + where: { id: relationship.id }, + data: { + endorsed: false, + }, + }); + + return jsonResponse(await relationshipToAPI(relationship)); }; diff --git a/server/api/api/v1/accounts/familiar_followers/index.ts b/server/api/api/v1/accounts/familiar_followers/index.ts index c012d85f..92c2d50a 100644 --- a/server/api/api/v1/accounts/familiar_followers/index.ts +++ b/server/api/api/v1/accounts/familiar_followers/index.ts @@ -1,14 +1,18 @@ import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; -import { UserAction } from "~database/entities/User"; -import { APIAccount } from "~types/entities/account"; +import { + getFromRequest, + userRelations, + userToAPI, +} from "~database/entities/User"; import { applyConfig } from "@api"; +import { client } from "~database/datasource"; export const meta = applyConfig({ allowedMethods: ["GET"], route: "/api/v1/accounts/familiar_followers", ratelimits: { - max: 30, + max: 5, duration: 60, }, auth: { @@ -20,7 +24,7 @@ export const meta = applyConfig({ * Find familiar followers (followers of a user that you also follow) */ export default async (req: Request): Promise => { - const { user: self } = await UserAction.getFromRequest(req); + const { user: self } = await getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); @@ -33,47 +37,34 @@ export default async (req: Request): Promise => { return errorResponse("Number of ids must be between 1 and 10", 422); } - const response = ( - await Promise.all( - ids.map(async id => { - // Find followers of user that you also follow - - // Get user - const user = await UserAction.findOne({ - where: { id }, - relations: { - relationships: { - subject: { - relationships: true, - }, - }, + const followersOfIds = await client.user.findMany({ + where: { + relationships: { + some: { + subjectId: { + in: ids, }, - }); + following: true, + }, + }, + }, + }); - if (!user) return null; + // Find users that you follow in followersOfIds + const output = await client.user.findMany({ + where: { + relationships: { + some: { + ownerId: self.id, + subjectId: { + in: followersOfIds.map(u => u.id), + }, + following: true, + }, + }, + }, + include: userRelations, + }); - // Map to user response - const response = user.relationships - .filter(r => r.following) - .map(r => r.subject) - .filter(u => - u.relationships.some( - r => r.following && r.subject.id === self.id - ) - ); - - return { - id: id, - accounts: await Promise.all( - response.map(async u => await u.toAPI()) - ), - }; - }) - ) - ).filter(r => r !== null) as { - id: string; - accounts: APIAccount[]; - }[]; - - return jsonResponse(response); + return jsonResponse(output.map(o => userToAPI(o))); }; diff --git a/server/api/api/v1/accounts/index.ts b/server/api/api/v1/accounts/index.ts index 69d997cf..678384db 100644 --- a/server/api/api/v1/accounts/index.ts +++ b/server/api/api/v1/accounts/index.ts @@ -2,8 +2,10 @@ import { getConfig } from "@config"; import { parseRequest } from "@request"; import { jsonResponse } from "@response"; import { tempmailDomains } from "@tempmail"; -import { UserAction } from "~database/entities/User"; import { applyConfig } from "@api"; +import { client } from "~database/datasource"; +import { createNewLocalUser } from "~database/entities/User"; +import ISO6391 from "iso-639-1"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -115,7 +117,7 @@ export default async (req: Request): Promise => { }); // Check if username is taken - if (await UserAction.findOne({ where: { username: body.username } })) + if (await client.user.findFirst({ where: { username: body.username } })) errors.details.username.push({ error: "ERR_TAKEN", description: `is already taken`, @@ -150,6 +152,18 @@ export default async (req: Request): Promise => { description: `must be accepted`, }); + if (!body.locale) + errors.details.locale.push({ + error: "ERR_BLANK", + description: `can't be blank`, + }); + + if (!ISO6391.validate(body.locale ?? "")) + errors.details.locale.push({ + error: "ERR_INVALID", + description: `must be a valid ISO 639-1 code`, + }); + // If any errors are present, return them if (Object.values(errors.details).some(value => value.length > 0)) { // Error is something like "Validation failed: Password can't be blank, Username must contain only letters, numbers and underscores, Agreement must be accepted" @@ -168,14 +182,13 @@ export default async (req: Request): Promise => { }); } - // TODO: Check if locale is valid - - await UserAction.createNewLocal({ + await createNewLocalUser({ username: body.username ?? "", password: body.password ?? "", email: body.email ?? "", }); - // TODO: Return access token - return new Response(); + return new Response("", { + status: 200, + }); }; diff --git a/server/api/api/v1/accounts/relationships/index.ts b/server/api/api/v1/accounts/relationships/index.ts index b9dde030..b32628a5 100644 --- a/server/api/api/v1/accounts/relationships/index.ts +++ b/server/api/api/v1/accounts/relationships/index.ts @@ -1,8 +1,12 @@ import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; -import { Relationship } from "~database/entities/Relationship"; -import { UserAction } from "~database/entities/User"; +import { + createNewRelationship, + relationshipToAPI, +} from "~database/entities/Relationship"; +import { getFromRequest } from "~database/entities/User"; import { applyConfig } from "@api"; +import { client } from "~database/datasource"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -20,7 +24,7 @@ export const meta = applyConfig({ * Find relationships */ export default async (req: Request): Promise => { - const { user: self } = await UserAction.getFromRequest(req); + const { user: self } = await getFromRequest(req); if (!self) return errorResponse("Unauthorized", 401); @@ -33,34 +37,35 @@ export default async (req: Request): Promise => { return errorResponse("Number of ids must be between 1 and 10", 422); } - // Check if already following - // TODO: Limit ID amount - const relationships = ( - await Promise.all( - ids.map(async id => { - const user = await UserAction.findOneBy({ id }); - if (!user) return null; - let relationship = await self.getRelationshipToOtherUser(user); + const relationships = await client.relationship.findMany({ + where: { + ownerId: self.id, + subjectId: { + in: ids, + }, + }, + }); - if (!relationship) { - // Create new relationship + // Find IDs that dont have a relationship + const missingIds = ids.filter( + id => !relationships.some(r => r.subjectId === id) + ); - const newRelationship = await Relationship.createNew( - self, - user - ); + // Create the missing relationships + for (const id of missingIds) { + const relationship = await createNewRelationship(self, { id } as any); - self.relationships.push(newRelationship); - await self.save(); + relationships.push(relationship); + } - relationship = newRelationship; - } - return relationship; - }) - ) - ).filter(relationship => relationship !== null) as Relationship[]; + // Order in the same order as ids + relationships.sort( + (a, b) => ids.indexOf(a.subjectId) - ids.indexOf(b.subjectId) + ); return jsonResponse( - await Promise.all(relationships.map(async r => await r.toAPI())) + await Promise.all( + relationships.map(async r => await relationshipToAPI(r)) + ) ); }; diff --git a/server/api/api/v1/accounts/update_credentials/index.ts b/server/api/api/v1/accounts/update_credentials/index.ts index 8ac804c6..4b85924b 100644 --- a/server/api/api/v1/accounts/update_credentials/index.ts +++ b/server/api/api/v1/accounts/update_credentials/index.ts @@ -1,12 +1,14 @@ import { getConfig } from "@config"; import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; -import { UserAction } from "~database/entities/User"; +import { getFromRequest, userToAPI } from "~database/entities/User"; import { applyConfig } from "@api"; import { sanitize } from "isomorphic-dompurify"; import { sanitizeHtml } from "@sanitization"; import { uploadFile } from "~classes/media"; -import { EmojiAction } from "~database/entities/Emoji"; +import ISO6391 from "iso-639-1"; +import { parseEmojis } from "~database/entities/Emoji"; +import { client } from "~database/datasource"; export const meta = applyConfig({ allowedMethods: ["PATCH"], @@ -24,7 +26,7 @@ export const meta = applyConfig({ * Patches a user */ export default async (req: Request): Promise => { - const { user } = await UserAction.getFromRequest(req); + const { user } = await getFromRequest(req); if (!user) return errorResponse("Unauthorized", 401); @@ -85,7 +87,7 @@ export default async (req: Request): Promise => { // Remove emojis user.emojis = []; - user.display_name = sanitizedDisplayName; + user.displayName = sanitizedDisplayName; } if (note) { @@ -112,7 +114,7 @@ export default async (req: Request): Promise => { user.note = sanitizedNote; } - if (source_privacy) { + if (source_privacy && user.source) { // Check if within allowed privacy values if ( !["public", "unlisted", "private", "direct"].includes( @@ -125,21 +127,30 @@ export default async (req: Request): Promise => { ); } - user.source.privacy = source_privacy; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (user.source as any).privacy = source_privacy; } - if (source_sensitive) { + if (source_sensitive && user.source) { // Check if within allowed sensitive values if (source_sensitive !== "true" && source_sensitive !== "false") { return errorResponse("Sensitive must be a boolean", 422); } - user.source.sensitive = source_sensitive === "true"; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (user.source as any).sensitive = source_sensitive === "true"; } - if (source_language) { - // TODO: Check if proper ISO code - user.source.language = source_language; + if (source_language && user.source) { + if (!ISO6391.validate(source_language)) { + return errorResponse( + "Language must be a valid ISO 639-1 code", + 422 + ); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (user.source as any).language = source_language; } if (avatar) { @@ -176,8 +187,7 @@ export default async (req: Request): Promise => { return errorResponse("Locked must be a boolean", 422); } - // TODO: Add a user value for Locked - // user.locked = locked === "true"; + user.isLocked = locked === "true"; } if (bot) { @@ -186,8 +196,7 @@ export default async (req: Request): Promise => { return errorResponse("Bot must be a boolean", 422); } - // TODO: Add a user value for bot - // user.bot = bot === "true"; + user.isBot = bot === "true"; } if (discoverable) { @@ -196,14 +205,13 @@ export default async (req: Request): Promise => { return errorResponse("Discoverable must be a boolean", 422); } - // TODO: Add a user value for discoverable - // user.discoverable = discoverable === "true"; + user.isDiscoverable = discoverable === "true"; } // Parse emojis - const displaynameEmojis = await EmojiAction.parseEmojis(sanitizedDisplayName); - const noteEmojis = await EmojiAction.parseEmojis(sanitizedNote); + const displaynameEmojis = await parseEmojis(sanitizedDisplayName); + const noteEmojis = await parseEmojis(sanitizedNote); user.emojis = [...displaynameEmojis, ...noteEmojis]; @@ -212,7 +220,31 @@ export default async (req: Request): Promise => { (emoji, index, self) => self.findIndex(e => e.id === emoji.id) === index ); - await user.save(); + await client.user.update({ + where: { id: user.id }, + data: { + displayName: user.displayName, + note: user.note, + avatar: user.avatar, + header: user.header, + isLocked: user.isLocked, + isBot: user.isBot, + isDiscoverable: user.isDiscoverable, + emojis: { + disconnect: user.emojis.map(e => ({ + id: e.id, + })), + connect: user.emojis.map(e => ({ + id: e.id, + })), + }, + source: user.source + ? { + update: user.source, + } + : undefined, + }, + }); - return jsonResponse(await user.toAPI()); + return jsonResponse(await userToAPI(user)); }; diff --git a/server/api/api/v1/accounts/verify_credentials/index.ts b/server/api/api/v1/accounts/verify_credentials/index.ts index 102d49fd..2fd46dc2 100644 --- a/server/api/api/v1/accounts/verify_credentials/index.ts +++ b/server/api/api/v1/accounts/verify_credentials/index.ts @@ -1,5 +1,5 @@ import { errorResponse, jsonResponse } from "@response"; -import { UserAction } from "~database/entities/User"; +import { getFromRequest, userToAPI } from "~database/entities/User"; import { applyConfig } from "@api"; export const meta = applyConfig({ @@ -17,12 +17,12 @@ export const meta = applyConfig({ export default async (req: Request): Promise => { // TODO: Add checks for disabled or not email verified accounts - const { user } = await UserAction.getFromRequest(req); + const { user } = await getFromRequest(req); if (!user) return errorResponse("Unauthorized", 401); return jsonResponse({ - ...(await user.toAPI()), + ...(await userToAPI(user)), source: user.source, // TODO: Add role support role: { diff --git a/server/api/api/v1/apps/index.ts b/server/api/api/v1/apps/index.ts index c1b06dec..175599f8 100644 --- a/server/api/api/v1/apps/index.ts +++ b/server/api/api/v1/apps/index.ts @@ -2,7 +2,7 @@ import { applyConfig } from "@api"; import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; import { randomBytes } from "crypto"; -import { ApplicationAction } from "~database/entities/Application"; +import { client } from "~database/datasource"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -27,10 +27,6 @@ export default async (req: Request): Promise => { website: string; }>(req); - const application = new ApplicationAction(); - - application.name = client_name || ""; - // Check if redirect URI is a valid URI, and also an absolute URI if (redirect_uris) { try { @@ -42,20 +38,20 @@ export default async (req: Request): Promise => { 422 ); } - - application.redirect_uris = redirect_uris; } catch { return errorResponse("Redirect URI must be a valid URI", 422); } } - - application.scopes = scopes || "read"; - application.website = website || null; - - application.client_id = randomBytes(32).toString("base64url"); - application.secret = randomBytes(64).toString("base64url"); - - await application.save(); + const application = await client.application.create({ + data: { + name: client_name || "", + redirect_uris: redirect_uris || "", + scopes: scopes || "read", + website: website || null, + client_id: randomBytes(32).toString("base64url"), + secret: randomBytes(64).toString("base64url"), + }, + }); return jsonResponse({ id: application.id, diff --git a/server/api/api/v1/apps/verify_credentials/index.ts b/server/api/api/v1/apps/verify_credentials/index.ts index bd606cfb..a281be66 100644 --- a/server/api/api/v1/apps/verify_credentials/index.ts +++ b/server/api/api/v1/apps/verify_credentials/index.ts @@ -1,7 +1,7 @@ import { applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import { ApplicationAction } from "~database/entities/Application"; -import { UserAction } from "~database/entities/User"; +import { getFromToken } from "~database/entities/Application"; +import { getFromRequest } from "~database/entities/User"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -19,8 +19,8 @@ export const meta = applyConfig({ * Returns OAuth2 credentials */ export default async (req: Request): Promise => { - const { user, token } = await UserAction.getFromRequest(req); - const application = await ApplicationAction.getFromToken(token); + const { user, token } = await getFromRequest(req); + const application = await getFromToken(token); if (!user) return errorResponse("Unauthorized", 401); if (!application) return errorResponse("Unauthorized", 401); diff --git a/server/api/api/v1/custom_emojis/index.ts b/server/api/api/v1/custom_emojis/index.ts index b19e24dc..1590bbc0 100644 --- a/server/api/api/v1/custom_emojis/index.ts +++ b/server/api/api/v1/custom_emojis/index.ts @@ -1,7 +1,7 @@ import { applyConfig } from "@api"; import { jsonResponse } from "@response"; -import { IsNull } from "typeorm"; -import { EmojiAction } from "~database/entities/Emoji"; +import { client } from "~database/datasource"; +import { emojiToAPI } from "~database/entities/Emoji"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -20,11 +20,13 @@ export const meta = applyConfig({ */ // eslint-disable-next-line @typescript-eslint/require-await export default async (): Promise => { - const emojis = await EmojiAction.findBy({ - instance: IsNull(), + const emojis = await client.emoji.findMany({ + where: { + instanceId: null, + }, }); return jsonResponse( - await Promise.all(emojis.map(async emoji => await emoji.toAPI())) + await Promise.all(emojis.map(emoji => emojiToAPI(emoji))) ); }; diff --git a/server/api/api/v1/instance/index.ts b/server/api/api/v1/instance/index.ts index 75237801..1904aa31 100644 --- a/server/api/api/v1/instance/index.ts +++ b/server/api/api/v1/instance/index.ts @@ -1,8 +1,7 @@ import { applyConfig } from "@api"; import { getConfig } from "@config"; import { jsonResponse } from "@response"; -import { Status } from "~database/entities/Status"; -import { UserAction } from "~database/entities/User"; +import { client } from "~database/datasource"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -23,8 +22,16 @@ export const meta = applyConfig({ export default async (): Promise => { const config = getConfig(); - const statusCount = await Status.count(); - const userCount = await UserAction.count(); + const statusCount = await client.status.count({ + where: { + instanceId: null, + }, + }); + const userCount = await client.user.count({ + where: { + instanceId: null, + }, + }); // TODO: fill in more values return jsonResponse({ diff --git a/server/api/api/v1/statuses/[id]/context.ts b/server/api/api/v1/statuses/[id]/context.ts index f62d30b0..d780de2a 100644 --- a/server/api/api/v1/statuses/[id]/context.ts +++ b/server/api/api/v1/statuses/[id]/context.ts @@ -1,14 +1,20 @@ import { applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; -import { Status, statusAndUserRelations } from "~database/entities/Status"; -import { UserAction } from "~database/entities/User"; +import { client } from "~database/datasource"; +import { + getAncestors, + getDescendants, + statusAndUserRelations, + statusToAPI, +} from "~database/entities/Status"; +import { getFromRequest } from "~database/entities/User"; import { APIRouteMeta } from "~types/api"; export const meta: APIRouteMeta = applyConfig({ allowedMethods: ["GET"], ratelimits: { - max: 100, + max: 8, duration: 60, }, route: "/api/v1/statuses/:id/context", @@ -28,30 +34,25 @@ export default async ( // User token + read:statuses for up to 4,096 ancestors, 4,096 descendants, unlimited depth, and private statuses. const id = matchedRoute.params.id; - const { user } = await UserAction.getFromRequest(req); + const { user } = await getFromRequest(req); - let foundStatus: Status | null; - try { - foundStatus = await Status.findOne({ - where: { - id, - }, - relations: statusAndUserRelations, - }); - } catch (e) { - return errorResponse("Invalid ID", 404); - } + const foundStatus = await client.status.findUnique({ + where: { id }, + include: statusAndUserRelations, + }); if (!foundStatus) return errorResponse("Record not found", 404); // Get all ancestors - const ancestors = await foundStatus.getAncestors(user); - const descendants = await foundStatus.getDescendants(user); + const ancestors = await getAncestors(foundStatus, user); + const descendants = await getDescendants(foundStatus, user); return jsonResponse({ - ancestors: await Promise.all(ancestors.map(status => status.toAPI())), + ancestors: await Promise.all( + ancestors.map(status => statusToAPI(status)) + ), descendants: await Promise.all( - descendants.map(status => status.toAPI()) + descendants.map(status => statusToAPI(status)) ), }); }; diff --git a/server/api/api/v1/statuses/[id]/favourite.ts b/server/api/api/v1/statuses/[id]/favourite.ts index 348a57c1..e8e60162 100644 --- a/server/api/api/v1/statuses/[id]/favourite.ts +++ b/server/api/api/v1/statuses/[id]/favourite.ts @@ -2,10 +2,15 @@ import { applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; -import { Like } from "~database/entities/Like"; -import { Status, statusAndUserRelations } from "~database/entities/Status"; -import { UserAction, userRelations } from "~database/entities/User"; +import { client } from "~database/datasource"; +import { + isViewableByUser, + statusAndUserRelations, + statusToAPI, +} from "~database/entities/Status"; +import { getFromRequest } from "~database/entities/User"; import { APIRouteMeta } from "~types/api"; +import { APIStatus } from "~types/entities/status"; export const meta: APIRouteMeta = applyConfig({ allowedMethods: ["POST"], @@ -28,51 +33,38 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - const { user } = await UserAction.getFromRequest(req); + const { user } = await getFromRequest(req); if (!user) return errorResponse("Unauthorized", 401); - let foundStatus: Status | null; - try { - foundStatus = await Status.findOne({ - where: { - id, - }, - relations: statusAndUserRelations, - }); - } catch (e) { - return errorResponse("Invalid ID", 404); - } - - if (!foundStatus) return errorResponse("Record not found", 404); + const status = await client.status.findUnique({ + where: { id }, + include: statusAndUserRelations, + }); // Check if user is authorized to view this status (if it's private) - if (!foundStatus.isViewableByUser(user)) { + if (!status || !isViewableByUser(status, user)) return errorResponse("Record not found", 404); - } - // Check if user has already favourited this status - const existingLike = await Like.findOne({ + const existingLike = await client.like.findFirst({ where: { - liked: { - id: foundStatus.id, - }, - liker: { - id: user.id, - }, + likedId: status.id, + likerId: user.id, }, - relations: [ - ...userRelations.map(r => `liker.${r}`), - ...statusAndUserRelations.map(r => `liked.${r}`), - ], }); if (!existingLike) { - const like = new Like(); - like.liker = user; - like.liked = foundStatus; - await like.save(); + await client.like.create({ + data: { + likedId: status.id, + likerId: user.id, + }, + }); } - return jsonResponse(await foundStatus.toAPI()); + return jsonResponse({ + ...(await statusToAPI(status, user)), + favourited: true, + favourites_count: status._count.likes + 1, + } as APIStatus); }; diff --git a/server/api/api/v1/statuses/[id]/favourited_by.ts b/server/api/api/v1/statuses/[id]/favourited_by.ts index c64b5de9..ba24be4f 100644 --- a/server/api/api/v1/statuses/[id]/favourited_by.ts +++ b/server/api/api/v1/statuses/[id]/favourited_by.ts @@ -3,10 +3,16 @@ import { applyConfig } from "@api"; import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; -import { FindManyOptions } from "typeorm"; -import { Like } from "~database/entities/Like"; -import { Status, statusAndUserRelations } from "~database/entities/Status"; -import { UserAction, userRelations } from "~database/entities/User"; +import { client } from "~database/datasource"; +import { + isViewableByUser, + statusAndUserRelations, +} from "~database/entities/Status"; +import { + getFromRequest, + userRelations, + userToAPI, +} from "~database/entities/User"; import { APIRouteMeta } from "~types/api"; export const meta: APIRouteMeta = applyConfig({ @@ -30,33 +36,25 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - const { user } = await UserAction.getFromRequest(req); + const { user } = await getFromRequest(req); - let foundStatus: Status | null; - try { - foundStatus = await Status.findOne({ - where: { - id, - }, - relations: statusAndUserRelations, - }); - } catch (e) { - return errorResponse("Invalid ID", 404); - } - - if (!foundStatus) return errorResponse("Record not found", 404); + const status = await client.status.findUnique({ + where: { id }, + include: statusAndUserRelations, + }); // Check if user is authorized to view this status (if it's private) - if (!foundStatus.isViewableByUser(user)) { + if (!status || !isViewableByUser(status, user)) return errorResponse("Record not found", 404); - } const { max_id = null, + min_id = null, since_id = null, limit = 40, } = await parseRequest<{ max_id?: string; + min_id?: string; since_id?: string; limit?: number; }>(req); @@ -65,53 +63,32 @@ export default async ( if (limit > 80) return errorResponse("Invalid limit (maximum is 80)", 400); if (limit < 1) return errorResponse("Invalid limit", 400); - // Get list of boosts for this status - let query: FindManyOptions = { + const objects = await client.user.findMany({ where: { - liked: { - id, + likes: { + some: { + likedId: status.id, + }, + }, + id: { + lt: max_id ?? undefined, + gte: since_id ?? undefined, + gt: min_id ?? undefined, }, }, - relations: userRelations.map(r => `liker.${r}`), - take: limit, - order: { - id: "DESC", + include: { + ...userRelations, + likes: { + where: { + likedId: status.id, + }, + }, }, - }; - - if (max_id) { - const maxLike = await Like.findOneBy({ id: max_id }); - if (maxLike) { - query = { - ...query, - where: { - ...query.where, - created_at: { - ...(query.where as any)?.created_at, - $lt: maxLike.created_at, - }, - }, - }; - } - } - - if (since_id) { - const sinceLike = await Like.findOneBy({ id: since_id }); - if (sinceLike) { - query = { - ...query, - where: { - ...query.where, - created_at: { - ...(query.where as any)?.created_at, - $gt: sinceLike.created_at, - }, - }, - }; - } - } - - const objects = await Like.find(query); + take: limit, + orderBy: { + id: "desc", + }, + }); // Constuct HTTP Link header (next and prev) const linkHeader = []; @@ -128,7 +105,7 @@ export default async ( } return jsonResponse( - await Promise.all(objects.map(async like => await like.liker.toAPI())), + await Promise.all(objects.map(async user => userToAPI(user))), 200, { Link: linkHeader.join(", "), diff --git a/server/api/api/v1/statuses/[id]/index.ts b/server/api/api/v1/statuses/[id]/index.ts index ae0058e4..f91d4a1f 100644 --- a/server/api/api/v1/statuses/[id]/index.ts +++ b/server/api/api/v1/statuses/[id]/index.ts @@ -1,8 +1,13 @@ import { applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; -import { Status, statusAndUserRelations } from "~database/entities/Status"; -import { UserAction } from "~database/entities/User"; +import { client } from "~database/datasource"; +import { + isViewableByUser, + statusAndUserRelations, + statusToAPI, +} from "~database/entities/Status"; +import { getFromRequest } from "~database/entities/User"; import { APIRouteMeta } from "~types/api"; export const meta: APIRouteMeta = applyConfig({ @@ -27,31 +32,21 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - const { user } = await UserAction.getFromRequest(req); + const { user } = await getFromRequest(req); - let foundStatus: Status | null; - try { - foundStatus = await Status.findOne({ - where: { - id, - }, - relations: statusAndUserRelations, - }); - } catch (e) { - return errorResponse("Invalid ID", 404); - } - - if (!foundStatus) return errorResponse("Record not found", 404); + const status = await client.status.findUnique({ + where: { id }, + include: statusAndUserRelations, + }); // Check if user is authorized to view this status (if it's private) - if (!foundStatus.isViewableByUser(user)) { + if (!status || isViewableByUser(status, user)) return errorResponse("Record not found", 404); - } if (req.method === "GET") { - return jsonResponse(await foundStatus.toAPI()); + return jsonResponse(await statusToAPI(status)); } else if (req.method === "DELETE") { - if (foundStatus.account.id !== user?.id) { + if (status.authorId !== user?.id) { return errorResponse("Unauthorized", 401); } @@ -60,11 +55,13 @@ export default async ( // Get associated Status object // Delete status and all associated objects - await foundStatus.remove(); + await client.status.delete({ + where: { id }, + }); return jsonResponse( { - ...(await foundStatus.toAPI()), + ...(await statusToAPI(status)), // TODO: Add // text: Add source text // poll: Add source poll diff --git a/server/api/api/v1/statuses/[id]/reblogged_by.ts b/server/api/api/v1/statuses/[id]/reblogged_by.ts index cf30e4ff..0754a276 100644 --- a/server/api/api/v1/statuses/[id]/reblogged_by.ts +++ b/server/api/api/v1/statuses/[id]/reblogged_by.ts @@ -3,9 +3,16 @@ import { applyConfig } from "@api"; import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; -import { FindManyOptions } from "typeorm"; -import { Status, statusAndUserRelations } from "~database/entities/Status"; -import { UserAction } from "~database/entities/User"; +import { client } from "~database/datasource"; +import { + isViewableByUser, + statusAndUserRelations, +} from "~database/entities/Status"; +import { + getFromRequest, + userRelations, + userToAPI, +} from "~database/entities/User"; import { APIRouteMeta } from "~types/api"; export const meta: APIRouteMeta = applyConfig({ @@ -29,33 +36,25 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - const { user } = await UserAction.getFromRequest(req); + const { user } = await getFromRequest(req); - let foundStatus: Status | null; - try { - foundStatus = await Status.findOne({ - where: { - id, - }, - relations: statusAndUserRelations, - }); - } catch (e) { - return errorResponse("Invalid ID", 404); - } - - if (!foundStatus) return errorResponse("Record not found", 404); + const status = await client.status.findUnique({ + where: { id }, + include: statusAndUserRelations, + }); // Check if user is authorized to view this status (if it's private) - if (!foundStatus.isViewableByUser(user)) { + if (!status || !isViewableByUser(status, user)) return errorResponse("Record not found", 404); - } const { max_id = null, + min_id = null, since_id = null, limit = 40, } = await parseRequest<{ max_id?: string; + min_id?: string; since_id?: string; limit?: number; }>(req); @@ -64,53 +63,33 @@ export default async ( if (limit > 80) return errorResponse("Invalid limit (maximum is 80)", 400); if (limit < 1) return errorResponse("Invalid limit", 400); - // Get list of boosts for this status - let query: FindManyOptions = { + const objects = await client.user.findMany({ where: { - reblog: { - id, + statuses: { + some: { + reblogId: status.id, + }, + }, + id: { + lt: max_id ?? undefined, + gte: since_id ?? undefined, + gt: min_id ?? undefined, }, }, - relations: statusAndUserRelations, - take: limit, - order: { - id: "DESC", + include: { + ...userRelations, + statuses: { + where: { + reblogId: status.id, + }, + include: statusAndUserRelations, + }, }, - }; - - if (max_id) { - const maxPost = await Status.findOneBy({ id: max_id }); - if (maxPost) { - query = { - ...query, - where: { - ...query.where, - created_at: { - ...(query.where as any)?.created_at, - $lt: maxPost.created_at, - }, - }, - }; - } - } - - if (since_id) { - const sincePost = await Status.findOneBy({ id: since_id }); - if (sincePost) { - query = { - ...query, - where: { - ...query.where, - created_at: { - ...(query.where as any)?.created_at, - $gt: sincePost.created_at, - }, - }, - }; - } - } - - const objects = await Status.find(query); + take: limit, + orderBy: { + id: "desc", + }, + }); // Constuct HTTP Link header (next and prev) const linkHeader = []; @@ -127,7 +106,7 @@ export default async ( } return jsonResponse( - await Promise.all(objects.map(async object => await object.toAPI())), + await Promise.all(objects.map(async user => userToAPI(user))), 200, { Link: linkHeader.join(", "), diff --git a/server/api/api/v1/statuses/[id]/unfavourite.ts b/server/api/api/v1/statuses/[id]/unfavourite.ts index 7d349d94..01038312 100644 --- a/server/api/api/v1/statuses/[id]/unfavourite.ts +++ b/server/api/api/v1/statuses/[id]/unfavourite.ts @@ -2,10 +2,15 @@ import { applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; -import { Like } from "~database/entities/Like"; -import { Status, statusAndUserRelations } from "~database/entities/Status"; -import { UserAction } from "~database/entities/User"; +import { client } from "~database/datasource"; +import { + isViewableByUser, + statusAndUserRelations, + statusToAPI, +} from "~database/entities/Status"; +import { getFromRequest } from "~database/entities/User"; import { APIRouteMeta } from "~types/api"; +import { APIStatus } from "~types/entities/status"; export const meta: APIRouteMeta = applyConfig({ allowedMethods: ["POST"], @@ -28,37 +33,29 @@ export default async ( ): Promise => { const id = matchedRoute.params.id; - const { user } = await UserAction.getFromRequest(req); + const { user } = await getFromRequest(req); if (!user) return errorResponse("Unauthorized", 401); - let foundStatus: Status | null; - try { - foundStatus = await Status.findOne({ - where: { - id, - }, - relations: statusAndUserRelations, - }); - } catch (e) { - return errorResponse("Invalid ID", 404); - } - - if (!foundStatus) return errorResponse("Record not found", 404); + const status = await client.status.findUnique({ + where: { id }, + include: statusAndUserRelations, + }); // Check if user is authorized to view this status (if it's private) - if (!foundStatus.isViewableByUser(user)) { + if (!status || !isViewableByUser(status, user)) return errorResponse("Record not found", 404); - } - await Like.delete({ - liked: { - id: foundStatus.id, - }, - liker: { - id: user.id, + await client.like.deleteMany({ + where: { + likedId: status.id, + likerId: user.id, }, }); - return jsonResponse(await foundStatus.toAPI()); + return jsonResponse({ + ...(await statusToAPI(status, user)), + favourited: false, + favourites_count: status._count.likes - 1, + } as APIStatus); }; diff --git a/server/api/api/v1/statuses/index.ts b/server/api/api/v1/statuses/index.ts index 0c9865d6..5c9f81ff 100644 --- a/server/api/api/v1/statuses/index.ts +++ b/server/api/api/v1/statuses/index.ts @@ -8,9 +8,15 @@ import { errorResponse, jsonResponse } from "@response"; import { sanitizeHtml } from "@sanitization"; import { MatchedRoute } from "bun"; import { parse } from "marked"; -import { ApplicationAction } from "~database/entities/Application"; -import { Status, statusRelations } from "~database/entities/Status"; -import { AuthData, UserAction } from "~database/entities/User"; +import { client } from "~database/datasource"; +import { getFromToken } from "~database/entities/Application"; +import { + StatusWithRelations, + createNewStatus, + statusAndUserRelations, + statusToAPI, +} from "~database/entities/Status"; +import { AuthData, UserWithRelations } from "~database/entities/User"; import { APIRouteMeta } from "~types/api"; export const meta: APIRouteMeta = applyConfig({ @@ -34,7 +40,7 @@ export default async ( authData: AuthData ): Promise => { const { user, token } = authData; - const application = await ApplicationAction.getFromToken(token); + const application = await getFromToken(token); if (!user) return errorResponse("Unauthorized", 401); @@ -126,18 +132,16 @@ export default async ( } // Get reply account and status if exists - let replyStatus: Status | null = null; - let replyUser: UserAction | null = null; + let replyStatus: StatusWithRelations | null = null; + let replyUser: UserWithRelations | null = null; if (in_reply_to_id) { - replyStatus = await Status.findOne({ - where: { - id: in_reply_to_id, - }, - relations: statusRelations, + replyStatus = await client.status.findUnique({ + where: { id: in_reply_to_id }, + include: statusAndUserRelations, }); - replyUser = replyStatus?.account || null; + replyUser = replyStatus?.author || null; } // Check if status body doesnt match filters @@ -145,8 +149,7 @@ export default async ( return errorResponse("Status contains blocked words", 422); } - // Create status - const newStatus = await Status.createNew({ + const newStatus = await createNewStatus({ account: user, application, content: sanitizedStatus, @@ -171,5 +174,5 @@ export default async ( // TODO: add database jobs to deliver the post - return jsonResponse(await newStatus.toAPI()); + return jsonResponse(await statusToAPI(newStatus, user)); }; diff --git a/server/api/api/v1/timelines/home.ts b/server/api/api/v1/timelines/home.ts index 6a571189..fa9408e5 100644 --- a/server/api/api/v1/timelines/home.ts +++ b/server/api/api/v1/timelines/home.ts @@ -2,10 +2,9 @@ import { applyConfig } from "@api"; import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; -import { MatchedRoute } from "bun"; -import { FindManyOptions } from "typeorm"; -import { Status, statusAndUserRelations } from "~database/entities/Status"; -import { AuthData } from "~database/entities/User"; +import { client } from "~database/datasource"; +import { statusAndUserRelations, statusToAPI } from "~database/entities/Status"; +import { getFromRequest } from "~database/entities/User"; import { APIRouteMeta } from "~types/api"; export const meta: APIRouteMeta = applyConfig({ @@ -23,11 +22,9 @@ export const meta: APIRouteMeta = applyConfig({ /** * Fetch home timeline statuses */ -export default async ( - req: Request, - matchedRoute: MatchedRoute, - authData: AuthData -): Promise => { +export default async (req: Request): Promise => { + const { user } = await getFromRequest(req); + const { limit = 20, max_id, @@ -40,85 +37,54 @@ export default async ( limit?: number; }>(req); - const { user } = authData; - if (limit < 1 || limit > 40) { return errorResponse("Limit must be between 1 and 40", 400); } - let query: FindManyOptions = { + if (!user) return errorResponse("Unauthorized", 401); + + const objects = await client.status.findMany({ where: { - visibility: "public", - account: [ - { - relationships: { - id: user?.id, - followed_by: true, + id: { + lt: max_id ?? undefined, + gte: since_id ?? undefined, + gt: min_id ?? undefined, + }, + author: { + relationships: { + some: { + subjectId: user.id, + following: true, }, }, - { - id: user?.id, - }, - ], - }, - order: { - created_at: "DESC", + }, }, + include: statusAndUserRelations, take: limit, - relations: statusAndUserRelations, - }; + orderBy: { + id: "desc", + }, + }); - if (max_id) { - const maxPost = await Status.findOneBy({ id: max_id }); - if (maxPost) { - query = { - ...query, - where: { - ...query.where, - created_at: { - ...(query.where as any)?.created_at, - $lt: maxPost.created_at, - }, - }, - }; - } + // Constuct HTTP Link header (next and prev) + const linkHeader = []; + if (objects.length > 0) { + const urlWithoutQuery = req.url.split("?")[0]; + linkHeader.push( + `<${urlWithoutQuery}?max_id=${objects[0].id}&limit=${limit}>; rel="next"` + ); + linkHeader.push( + `<${urlWithoutQuery}?since_id=${ + objects[objects.length - 1].id + }&limit=${limit}>; rel="prev"` + ); } - if (min_id) { - const minPost = await Status.findOneBy({ id: min_id }); - if (minPost) { - query = { - ...query, - where: { - ...query.where, - created_at: { - ...(query.where as any)?.created_at, - $gt: minPost.created_at, - }, - }, - }; - } - } - - if (since_id) { - const sincePost = await Status.findOneBy({ id: since_id }); - if (sincePost) { - query = { - ...query, - where: { - ...query.where, - created_at: { - ...(query.where as any)?.created_at, - $gte: sincePost.created_at, - }, - }, - }; - } - } - - const objects = await Status.find(query); - return jsonResponse( - await Promise.all(objects.map(async object => await object.toAPI())) + await Promise.all(objects.map(async status => statusToAPI(status))), + 200, + { + Link: linkHeader.join(", "), + } ); }; diff --git a/server/api/api/v1/timelines/public.ts b/server/api/api/v1/timelines/public.ts index ad69380a..b1bf33bb 100644 --- a/server/api/api/v1/timelines/public.ts +++ b/server/api/api/v1/timelines/public.ts @@ -1,8 +1,8 @@ import { applyConfig } from "@api"; import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; -import { FindManyOptions, IsNull, Not } from "typeorm"; -import { Status, statusAndUserRelations } from "~database/entities/Status"; +import { client } from "~database/datasource"; +import { statusAndUserRelations, statusToAPI } from "~database/entities/Status"; import { APIRouteMeta } from "~types/api"; export const meta: APIRouteMeta = applyConfig({ @@ -17,36 +17,13 @@ export const meta: APIRouteMeta = applyConfig({ }, }); -const updateQuery = async ( - id: string | undefined, - operator: string, - query: FindManyOptions -) => { - if (!id) return query; - const post = await Status.findOneBy({ id }); - if (post) { - query = { - ...query, - where: { - ...query.where, - created_at: { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - ...(query.where as any)?.created_at, - [operator]: post.created_at, - }, - }, - }; - } - return query; -}; - export default async (req: Request): Promise => { const { local, limit = 20, max_id, min_id, - only_media, + // only_media, remote, since_id, } = await parseRequest<{ @@ -67,48 +44,47 @@ export default async (req: Request): Promise => { return errorResponse("Cannot use both local and remote", 400); } - let query: FindManyOptions = { + const objects = await client.status.findMany({ where: { - visibility: "public", - }, - order: { - created_at: "DESC", + id: { + lt: max_id ?? undefined, + gte: since_id ?? undefined, + gt: min_id ?? undefined, + }, + instanceId: remote + ? { + not: null, + } + : local + ? null + : undefined, }, + include: statusAndUserRelations, take: limit, - relations: statusAndUserRelations, - }; + orderBy: { + id: "desc", + }, + }); - query = await updateQuery(max_id, "$lt", query); - query = await updateQuery(min_id, "$gt", query); - query = await updateQuery(since_id, "$gte", query); - - if (only_media) { - // TODO: add + // Constuct HTTP Link header (next and prev) + const linkHeader = []; + if (objects.length > 0) { + const urlWithoutQuery = req.url.split("?")[0]; + linkHeader.push( + `<${urlWithoutQuery}?max_id=${objects[0].id}&limit=${limit}>; rel="next"` + ); + linkHeader.push( + `<${urlWithoutQuery}?since_id=${ + objects[objects.length - 1].id + }&limit=${limit}>; rel="prev"` + ); } - if (local) { - query = { - ...query, - where: { - ...query.where, - instance: IsNull(), - }, - }; - } - - if (remote) { - query = { - ...query, - where: { - ...query.where, - instance: Not(IsNull()), - }, - }; - } - - const objects = await Status.find(query); - return jsonResponse( - await Promise.all(objects.map(async object => await object.toAPI())) + await Promise.all(objects.map(async status => statusToAPI(status))), + 200, + { + Link: linkHeader.join(", "), + } ); }; diff --git a/server/api/auth/login/index.ts b/server/api/auth/login/index.ts index 3fe279e7..ee58c44e 100644 --- a/server/api/auth/login/index.ts +++ b/server/api/auth/login/index.ts @@ -2,9 +2,8 @@ import { applyConfig } from "@api"; import { errorResponse } from "@response"; import { MatchedRoute } from "bun"; import { randomBytes } from "crypto"; -import { ApplicationAction } from "~database/entities/Application"; -import { Token } from "~database/entities/Token"; -import { UserAction, userRelations } from "~database/entities/User"; +import { client } from "~database/datasource"; +import { userRelations } from "~database/entities/User"; import { APIRouteMeta } from "~types/api"; export const meta: APIRouteMeta = applyConfig({ @@ -45,33 +44,44 @@ export default async ( return errorResponse("Missing username or password", 400); // Get user - const user = await UserAction.findOne({ + const user = await client.user.findFirst({ where: { email, }, - relations: userRelations, + include: userRelations, }); if (!user || !(await Bun.password.verify(password, user.password || ""))) return errorResponse("Invalid username or password", 401); // Get application - const application = await ApplicationAction.findOneBy({ - client_id, + const application = await client.application.findFirst({ + where: { + client_id, + }, }); if (!application) return errorResponse("Invalid client_id", 404); - const token = new Token(); - - token.access_token = randomBytes(64).toString("base64url"); - token.code = randomBytes(32).toString("hex"); - token.application = application; - token.scope = scopes.join(" "); - token.user = user; - - await token.save(); + const token = await client.application.update({ + where: { id: application.id }, + data: { + tokens: { + create: { + access_token: randomBytes(64).toString("base64url"), + code: randomBytes(32).toString("hex"), + scope: scopes.join(" "), + token_type: "bearer", + user: { + connect: { + id: user.id, + }, + }, + }, + }, + }, + }); // Redirect back to application - return Response.redirect(`${redirect_uri}?code=${token.code}`, 302); + return Response.redirect(`${redirect_uri}?code=${token.secret}`, 302); }; diff --git a/server/api/oauth/token/index.ts b/server/api/oauth/token/index.ts index 7e3fa9f0..a0c9d8ae 100644 --- a/server/api/oauth/token/index.ts +++ b/server/api/oauth/token/index.ts @@ -1,7 +1,7 @@ import { applyConfig } from "@api"; import { parseRequest } from "@request"; import { errorResponse, jsonResponse } from "@response"; -import { Token } from "~database/entities/Token"; +import { client } from "~database/datasource"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -36,14 +36,19 @@ export default async (req: Request): Promise => { ); // Get associated token - const token = await Token.findOneBy({ - code, - application: { - client_id, - secret: client_secret, - redirect_uris: redirect_uri, + const token = await client.token.findFirst({ + where: { + code, + application: { + client_id, + secret: client_secret, + redirect_uris: redirect_uri, + }, + scope: scope?.replaceAll("+", " "), + }, + include: { + application: true, }, - scope: scope?.replaceAll("+", " "), }); if (!token) diff --git a/server/api/users/[uuid]/inbox/index.ts b/server/api/users/[uuid]/inbox/index.ts index 1a143b7c..062cbae9 100644 --- a/server/api/users/[uuid]/inbox/index.ts +++ b/server/api/users/[uuid]/inbox/index.ts @@ -5,17 +5,16 @@ import { getConfig } from "@config"; import { getBestContentType } from "@content_types"; import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; -import { EmojiAction } from "~database/entities/Emoji"; -import { LysandObject } from "~database/entities/Object"; -import { Status } from "~database/entities/Status"; -import { UserAction, userRelations } from "~database/entities/User"; +import { client } from "~database/datasource"; +import { parseEmojis } from "~database/entities/Emoji"; +import { createFromObject } from "~database/entities/Object"; import { - ContentFormat, - LysandAction, - LysandObjectType, - LysandPublication, - Patch, -} from "~types/lysand/Object"; + createNewStatus, + fetchFromRemote, + statusAndUserRelations, +} from "~database/entities/Status"; +import { parseMentionsUris, userRelations } from "~database/entities/User"; +import { LysandAction, LysandPublication, Patch } from "~types/lysand/Object"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -61,11 +60,11 @@ export default async ( // Process request body const body = (await req.json()) as LysandPublication | LysandAction; - const author = await UserAction.findOne({ + const author = await client.user.findUnique({ where: { - uri: body.author, + username, }, - relations: userRelations, + include: userRelations, }); if (!author) { @@ -116,7 +115,7 @@ export default async ( // author.public_key is base64 encoded raw public key const publicKey = await crypto.subtle.importKey( "spki", - Buffer.from(author.public_key, "base64"), + Buffer.from(author.publicKey, "base64"), "Ed25519", false, ["verify"] @@ -141,13 +140,13 @@ export default async ( switch (type) { case "Note": { // Store the object in the LysandObject table - await LysandObject.createFromObject(body); + await createFromObject(body); const content = getBestContentType(body.contents); - const emojis = await EmojiAction.parseEmojis(content?.content || ""); + const emojis = await parseEmojis(content?.content || ""); - const newStatus = await Status.createNew({ + const newStatus = await createNewStatus({ account: author, content: content?.content || "", content_type: content?.content_type, @@ -158,39 +157,49 @@ export default async ( sensitive: body.is_sensitive, uri: body.uri, emojis: emojis, - mentions: await UserAction.parseMentions(body.mentions), + mentions: await parseMentionsUris(body.mentions), }); // If there is a reply, fetch all the reply parents and add them to the database if (body.replies_to.length > 0) { - newStatus.in_reply_to_post = await Status.fetchFromRemote( - body.replies_to[0] - ); + newStatus.inReplyToPostId = + (await fetchFromRemote(body.replies_to[0]))?.id || null; } // Same for quotes if (body.quotes.length > 0) { - newStatus.quoting_post = await Status.fetchFromRemote( - body.quotes[0] - ); + newStatus.quotingPostId = + (await fetchFromRemote(body.quotes[0]))?.id || null; } - await newStatus.save(); + await client.status.update({ + where: { + id: newStatus.id, + }, + data: { + inReplyToPostId: newStatus.inReplyToPostId, + quotingPostId: newStatus.quotingPostId, + }, + }); + break; } case "Patch": { const patch = body as Patch; // Store the object in the LysandObject table - await LysandObject.createFromObject(patch); + await createFromObject(patch); // Edit the status const content = getBestContentType(patch.contents); - const emojis = await EmojiAction.parseEmojis(content?.content || ""); + const emojis = await parseEmojis(content?.content || ""); - const status = await Status.findOneBy({ - id: patch.patched_id, + const status = await client.status.findUnique({ + where: { + uri: patch.patched_id, + }, + include: statusAndUserRelations, }); if (!status) { @@ -198,64 +207,81 @@ export default async ( } status.content = content?.content || ""; - status.content_type = content?.content_type || "text/plain"; - status.spoiler_text = patch.subject || ""; + status.contentType = content?.content_type || "text/plain"; + status.spoilerText = patch.subject || ""; status.sensitive = patch.is_sensitive; status.emojis = emojis; // If there is a reply, fetch all the reply parents and add them to the database if (body.replies_to.length > 0) { - status.in_reply_to_post = await Status.fetchFromRemote( - body.replies_to[0] - ); + status.inReplyToPostId = + (await fetchFromRemote(body.replies_to[0]))?.id || null; } // Same for quotes if (body.quotes.length > 0) { - status.quoting_post = await Status.fetchFromRemote( - body.quotes[0] - ); + status.quotingPostId = + (await fetchFromRemote(body.quotes[0]))?.id || null; } + + await client.status.update({ + where: { + id: status.id, + }, + data: { + content: status.content, + contentType: status.contentType, + spoilerText: status.spoilerText, + sensitive: status.sensitive, + emojis: { + connect: status.emojis.map(emoji => ({ + id: emoji.id, + })), + }, + inReplyToPostId: status.inReplyToPostId, + quotingPostId: status.quotingPostId, + }, + }); break; } case "Like": { // Store the object in the LysandObject table - await LysandObject.createFromObject(body); + await createFromObject(body); break; } case "Dislike": { // Store the object in the LysandObject table - await LysandObject.createFromObject(body); + await createFromObject(body); break; } case "Follow": { // Store the object in the LysandObject table - await LysandObject.createFromObject(body); + await createFromObject(body); break; } case "FollowAccept": { // Store the object in the LysandObject table - await LysandObject.createFromObject(body); + await createFromObject(body); break; } case "FollowReject": { // Store the object in the LysandObject table - await LysandObject.createFromObject(body); + await createFromObject(body); break; } case "Announce": { // Store the object in the LysandObject table - await LysandObject.createFromObject(body); + await createFromObject(body); break; } case "Undo": { // Store the object in the LysandObject table - await LysandObject.createFromObject(body); + await createFromObject(body); break; } case "Extension": { // Store the object in the LysandObject table - await LysandObject.createFromObject(body); + await createFromObject(body); break; } default: { diff --git a/server/api/users/[uuid]/index.ts b/server/api/users/[uuid]/index.ts index f80eca54..8d37794b 100644 --- a/server/api/users/[uuid]/index.ts +++ b/server/api/users/[uuid]/index.ts @@ -4,7 +4,8 @@ import { applyConfig } from "@api"; import { getConfig } from "@config"; import { errorResponse, jsonResponse } from "@response"; import { MatchedRoute } from "bun"; -import { UserAction, userRelations } from "~database/entities/User"; +import { client } from "~database/datasource"; +import { userRelations, userToLysand } from "~database/entities/User"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -29,16 +30,16 @@ export default async ( const config = getConfig(); - const user = await UserAction.findOne({ + const user = await client.user.findUnique({ where: { id: uuid, }, - relations: userRelations, + include: userRelations, }); if (!user) { return errorResponse("User not found", 404); } - return jsonResponse(user.toLysand()); + return jsonResponse(userToLysand(user)); }; diff --git a/server/api/users/[uuid]/outbox/index.ts b/server/api/users/[uuid]/outbox/index.ts index 813bbfc5..bc3d3559 100644 --- a/server/api/users/[uuid]/outbox/index.ts +++ b/server/api/users/[uuid]/outbox/index.ts @@ -1,10 +1,12 @@ import { jsonResponse } from "@response"; import { MatchedRoute } from "bun"; -import { userRelations } from "~database/entities/User"; import { getConfig, getHost } from "@config"; import { applyConfig } from "@api"; -import { Status } from "~database/entities/Status"; -import { In } from "typeorm"; +import { + statusAndUserRelations, + statusToLysand, +} from "~database/entities/Status"; +import { client } from "~database/datasource"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -29,26 +31,25 @@ export default async ( const pageNumber = Number(matchedRoute.query.page) || 1; const config = getConfig(); - const statuses = await Status.find({ + const statuses = await client.status.findMany({ where: { - account: { - id: uuid, + authorId: uuid, + visibility: { + in: ["public", "unlisted"], }, - visibility: In(["public", "unlisted"]), }, - relations: userRelations, take: 20, skip: 20 * (pageNumber - 1), + include: statusAndUserRelations, }); - const totalStatuses = await Status.count({ + const totalStatuses = await client.status.count({ where: { - account: { - id: uuid, + authorId: uuid, + visibility: { + in: ["public", "unlisted"], }, - visibility: In(["public", "unlisted"]), }, - relations: userRelations, }); return jsonResponse({ @@ -65,6 +66,6 @@ export default async ( pageNumber > 1 ? `${getHost()}/users/${uuid}/outbox?page=${pageNumber - 1}` : undefined, - items: statuses.map(s => s.toLysand()), + items: statuses.map(s => statusToLysand(s)), }); }; diff --git a/tests/api.test.ts b/tests/api.test.ts index a7f939f8..56e9e21e 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -1,71 +1,63 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { getConfig } from "@config"; +import { Token } from "@prisma/client"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { AppDataSource } from "~database/datasource"; -import { ApplicationAction } from "~database/entities/Application"; -import { EmojiAction } from "~database/entities/Emoji"; -import { Token, TokenType } from "~database/entities/Token"; -import { UserAction } from "~database/entities/User"; +import { client } from "~database/datasource"; +import { TokenType } from "~database/entities/Token"; +import { UserWithRelations, createNewLocalUser } from "~database/entities/User"; import { APIEmoji } from "~types/entities/emoji"; import { APIInstance } from "~types/entities/instance"; const config = getConfig(); let token: Token; -let user: UserAction; -let user2: UserAction; +let user: UserWithRelations; describe("API Tests", () => { beforeAll(async () => { - if (!AppDataSource.isInitialized) await AppDataSource.initialize(); - // Initialize test user - user = await UserAction.createNewLocal({ + user = await createNewLocalUser({ email: "test@test.com", username: "test", password: "test", display_name: "", }); - // Initialize second test user - user2 = await UserAction.createNewLocal({ - email: "test2@test.com", - username: "test2", - password: "test2", - display_name: "", + token = await client.token.create({ + data: { + access_token: "test", + application: { + create: { + client_id: "test", + name: "Test Application", + redirect_uris: "https://example.com", + scopes: "read write", + secret: "test", + website: "https://example.com", + vapid_key: null, + }, + }, + code: "test", + scope: "read write", + token_type: TokenType.BEARER, + user: { + connect: { + id: user.id, + }, + }, + }, }); - - const app = new ApplicationAction(); - - app.name = "Test Application"; - app.website = "https://example.com"; - app.client_id = "test"; - app.redirect_uris = "https://example.com"; - app.scopes = "read write"; - app.secret = "test"; - app.vapid_key = null; - - await app.save(); - - // Initialize test token - token = new Token(); - - token.access_token = "test"; - token.application = app; - token.code = "test"; - token.scope = "read write"; - token.token_type = TokenType.BEARER; - token.user = user; - - token = await token.save(); }); afterAll(async () => { - await user.remove(); - await user2.remove(); - - await AppDataSource.destroy(); + await client.user.deleteMany({ + where: { + username: { + in: ["test", "test2"], + }, + }, + }); }); describe("GET /api/v1/instance", () => { @@ -106,15 +98,15 @@ describe("API Tests", () => { describe("GET /api/v1/custom_emojis", () => { beforeAll(async () => { - const emoji = new EmojiAction(); - - emoji.instance = null; - emoji.url = "https://example.com/test.png"; - emoji.content_type = "image/png"; - emoji.shortcode = "test"; - emoji.visible_in_picker = true; - - await emoji.save(); + await client.emoji.create({ + data: { + instanceId: null, + url: "https://example.com/test.png", + content_type: "image/png", + shortcode: "test", + visible_in_picker: true, + }, + }); }); test("should return an array of at least one custom emoji", async () => { const response = await fetch( @@ -139,7 +131,11 @@ describe("API Tests", () => { expect(emojis[0].url).toBe("https://example.com/test.png"); }); afterAll(async () => { - await EmojiAction.delete({ shortcode: "test" }); + await client.emoji.deleteMany({ + where: { + shortcode: "test", + }, + }); }); }); }); diff --git a/tests/api/accounts.test.ts b/tests/api/accounts.test.ts index 44af9feb..fae15c4d 100644 --- a/tests/api/accounts.test.ts +++ b/tests/api/accounts.test.ts @@ -1,11 +1,11 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { getConfig } from "@config"; +import { Token } from "@prisma/client"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { AppDataSource } from "~database/datasource"; -import { ApplicationAction } from "~database/entities/Application"; -import { Token, TokenType } from "~database/entities/Token"; -import { UserAction } from "~database/entities/User"; +import { client } from "~database/datasource"; +import { TokenType } from "~database/entities/Token"; +import { UserWithRelations, createNewLocalUser } from "~database/entities/User"; import { APIAccount } from "~types/entities/account"; import { APIRelationship } from "~types/entities/relationship"; import { APIStatus } from "~types/entities/status"; @@ -13,59 +13,59 @@ import { APIStatus } from "~types/entities/status"; const config = getConfig(); let token: Token; -let user: UserAction; -let user2: UserAction; +let user: UserWithRelations; +let user2: UserWithRelations; describe("API Tests", () => { beforeAll(async () => { - if (!AppDataSource.isInitialized) await AppDataSource.initialize(); - - // Initialize test user - user = await UserAction.createNewLocal({ + user = await createNewLocalUser({ email: "test@test.com", username: "test", password: "test", display_name: "", }); - // Initialize second test user - user2 = await UserAction.createNewLocal({ + user2 = await createNewLocalUser({ email: "test2@test.com", username: "test2", password: "test2", display_name: "", }); - const app = new ApplicationAction(); - - app.name = "Test Application"; - app.website = "https://example.com"; - app.client_id = "test"; - app.redirect_uris = "https://example.com"; - app.scopes = "read write"; - app.secret = "test"; - app.vapid_key = null; - - await app.save(); - - // Initialize test token - token = new Token(); - - token.access_token = "test"; - token.application = app; - token.code = "test"; - token.scope = "read write"; - token.token_type = TokenType.BEARER; - token.user = user; - - token = await token.save(); + token = await client.token.create({ + data: { + access_token: "test", + application: { + create: { + client_id: "test", + name: "Test Application", + redirect_uris: "https://example.com", + scopes: "read write", + secret: "test", + website: "https://example.com", + vapid_key: null, + }, + }, + code: "test", + scope: "read write", + token_type: TokenType.BEARER, + user: { + connect: { + id: user.id, + }, + }, + }, + }); }); afterAll(async () => { - await user.remove(); - await user2.remove(); - - await AppDataSource.destroy(); + await client.user.deleteMany({ + where: { + username: { + in: ["test", "test2"], + }, + }, + }); }); describe("POST /api/v1/accounts/:id", () => { diff --git a/tests/api/statuses.test.ts b/tests/api/statuses.test.ts index 168a9b27..3c82a160 100644 --- a/tests/api/statuses.test.ts +++ b/tests/api/statuses.test.ts @@ -1,72 +1,65 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { getConfig } from "@config"; +import { Token } from "@prisma/client"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { AppDataSource } from "~database/datasource"; -import { ApplicationAction } from "~database/entities/Application"; -import { Token, TokenType } from "~database/entities/Token"; -import { UserAction } from "~database/entities/User"; +import { client } from "~database/datasource"; +import { TokenType } from "~database/entities/Token"; +import { UserWithRelations, createNewLocalUser } from "~database/entities/User"; +import { APIAccount } from "~types/entities/account"; import { APIContext } from "~types/entities/context"; import { APIStatus } from "~types/entities/status"; const config = getConfig(); let token: Token; -let user: UserAction; -let user2: UserAction; +let user: UserWithRelations; let status: APIStatus | null = null; let status2: APIStatus | null = null; describe("API Tests", () => { beforeAll(async () => { - if (!AppDataSource.isInitialized) await AppDataSource.initialize(); - - // Initialize test user - user = await UserAction.createNewLocal({ + user = await createNewLocalUser({ email: "test@test.com", username: "test", password: "test", display_name: "", }); - // Initialize second test user - user2 = await UserAction.createNewLocal({ - email: "test2@test.com", - username: "test2", - password: "test2", - display_name: "", + token = await client.token.create({ + data: { + access_token: "test", + application: { + create: { + client_id: "test", + name: "Test Application", + redirect_uris: "https://example.com", + scopes: "read write", + secret: "test", + website: "https://example.com", + vapid_key: null, + }, + }, + code: "test", + scope: "read write", + token_type: TokenType.BEARER, + user: { + connect: { + id: user.id, + }, + }, + }, }); - - const app = new ApplicationAction(); - - app.name = "Test Application"; - app.website = "https://example.com"; - app.client_id = "test"; - app.redirect_uris = "https://example.com"; - app.scopes = "read write"; - app.secret = "test"; - app.vapid_key = null; - - await app.save(); - - // Initialize test token - token = new Token(); - - token.access_token = "test"; - token.application = app; - token.code = "test"; - token.scope = "read write"; - token.token_type = TokenType.BEARER; - token.user = user; - - token = await token.save(); }); afterAll(async () => { - await user.remove(); - await user2.remove(); - - await AppDataSource.destroy(); + await client.user.deleteMany({ + where: { + username: { + in: ["test", "test2"], + }, + }, + }); }); describe("POST /api/v1/statuses", () => { @@ -322,7 +315,7 @@ describe("API Tests", () => { "application/json" ); - const users = (await response.json()) as UserAction[]; + const users = (await response.json()) as APIAccount[]; expect(users.length).toBe(1); expect(users[0].id).toBe(user.id); diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts index a0d132d9..13adc8d9 100644 --- a/tests/oauth.test.ts +++ b/tests/oauth.test.ts @@ -1,9 +1,8 @@ import { getConfig } from "@config"; +import { Application, Token } from "@prisma/client"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { AppDataSource } from "~database/datasource"; -import { ApplicationAction } from "~database/entities/Application"; -import { Token } from "~database/entities/Token"; -import { UserAction, userRelations } from "~database/entities/User"; +import { client } from "~database/datasource"; +import { createNewLocalUser } from "~database/entities/User"; const config = getConfig(); @@ -13,10 +12,8 @@ let code: string; let token: Token; beforeAll(async () => { - if (!AppDataSource.isInitialized) await AppDataSource.initialize(); - - // Initialize test user - await UserAction.createNewLocal({ + // Init test user + await createNewLocalUser({ email: "test@test.com", username: "test", password: "test", @@ -139,7 +136,7 @@ describe("GET /api/v1/apps/verify_credentials", () => { expect(response.status).toBe(200); expect(response.headers.get("content-type")).toBe("application/json"); - const credentials = (await response.json()) as Partial; + const credentials = (await response.json()) as Partial; expect(credentials.name).toBe("Test Application"); expect(credentials.website).toBe("https://example.com"); @@ -150,31 +147,9 @@ describe("GET /api/v1/apps/verify_credentials", () => { afterAll(async () => { // Clean up user - const user = await UserAction.findOne({ + await client.user.delete({ where: { username: "test", }, - relations: userRelations, }); - - // Clean up tokens - const tokens = await Token.findBy({ - user: { - username: "test", - }, - }); - - const applications = await ApplicationAction.findBy({ - client_id, - secret: client_secret, - }); - - await Promise.all(tokens.map(async token => await token.remove())); - await Promise.all( - applications.map(async application => await application.remove()) - ); - - if (user) await user.remove(); - - await AppDataSource.destroy(); });