From 826a260e90aa7a46d7329f9f3b39df5ec2617750 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Mon, 6 May 2024 07:16:33 +0000 Subject: [PATCH] refactor(api): :recycle: Move to Hono for HTTP --- bun.lockb | Bin 144100 -> 144460 bytes database/entities/User.ts | 9 + index.ts | 43 +- package.json | 4 +- packages/database-interface/note.ts | 15 + packages/database-interface/user.ts | 44 +- packages/lysand-utils/index.ts | 20 + packages/server-handler/index.ts | 11 +- routes.ts | 5 +- server/api/api/_fe/config/index.ts | 29 +- server/api/api/auth/login/index.ts | 219 +++---- server/api/api/auth/mastodon-login/index.ts | 101 ++-- server/api/api/auth/mastodon-logout/index.ts | 25 +- server/api/api/auth/redirect/index.ts | 72 ++- .../v1/accounts/{[id] => :id}/block.test.ts | 0 server/api/api/v1/accounts/:id/block.ts | 66 +++ .../v1/accounts/{[id] => :id}/follow.test.ts | 10 + server/api/api/v1/accounts/:id/follow.ts | 79 +++ .../accounts/{[id] => :id}/followers.test.ts | 2 + server/api/api/v1/accounts/:id/followers.ts | 74 +++ .../accounts/{[id] => :id}/following.test.ts | 2 + server/api/api/v1/accounts/:id/following.ts | 73 +++ .../v1/accounts/{[id] => :id}/index.test.ts | 26 +- server/api/api/v1/accounts/:id/index.ts | 43 ++ .../v1/accounts/{[id] => :id}/mute.test.ts | 10 + server/api/api/v1/accounts/:id/mute.ts | 83 +++ server/api/api/v1/accounts/:id/note.ts | 69 +++ server/api/api/v1/accounts/:id/pin.ts | 66 +++ .../v1/accounts/:id/remove_from_followers.ts | 80 +++ .../accounts/{[id] => :id}/statuses.test.ts | 11 +- server/api/api/v1/accounts/:id/statuses.ts | 108 ++++ server/api/api/v1/accounts/:id/unblock.ts | 66 +++ server/api/api/v1/accounts/:id/unfollow.ts | 67 +++ .../v1/accounts/{[id] => :id}/unmute.test.ts | 0 server/api/api/v1/accounts/:id/unmute.ts | 68 +++ server/api/api/v1/accounts/:id/unpin.ts | 66 +++ server/api/api/v1/accounts/[id]/block.ts | 54 -- server/api/api/v1/accounts/[id]/follow.ts | 69 --- server/api/api/v1/accounts/[id]/followers.ts | 64 -- server/api/api/v1/accounts/[id]/following.ts | 64 -- server/api/api/v1/accounts/[id]/index.ts | 34 -- server/api/api/v1/accounts/[id]/mute.ts | 76 --- server/api/api/v1/accounts/[id]/note.ts | 65 --- server/api/api/v1/accounts/[id]/pin.ts | 55 -- .../v1/accounts/[id]/remove_from_followers.ts | 70 --- server/api/api/v1/accounts/[id]/statuses.ts | 86 --- server/api/api/v1/accounts/[id]/unblock.ts | 51 -- server/api/api/v1/accounts/[id]/unfollow.ts | 56 -- server/api/api/v1/accounts/[id]/unmute.ts | 57 -- server/api/api/v1/accounts/[id]/unpin.ts | 55 -- .../v1/accounts/familiar_followers/index.ts | 118 ++-- server/api/api/v1/accounts/index.ts | 396 +++++++------ server/api/api/v1/accounts/lookup/index.ts | 120 ++-- .../api/v1/accounts/relationships/index.ts | 80 +-- server/api/api/v1/accounts/search/index.ts | 149 ++--- .../v1/accounts/update_credentials/index.ts | 551 +++++++++--------- .../v1/accounts/verify_credentials/index.ts | 22 +- server/api/api/v1/apps/index.ts | 81 +-- .../api/v1/apps/verify_credentials/index.ts | 40 +- server/api/api/v1/blocks/index.ts | 72 ++- server/api/api/v1/custom_emojis/index.ts | 26 +- server/api/api/v1/favourites/index.ts | 75 ++- .../follow_requests/:account_id/authorize.ts | 100 ++++ .../v1/follow_requests/:account_id/reject.ts | 100 ++++ .../follow_requests/[account_id]/authorize.ts | 80 --- .../v1/follow_requests/[account_id]/reject.ts | 80 --- server/api/api/v1/follow_requests/index.ts | 73 ++- .../api/v1/instance/extended_description.ts | 53 +- server/api/api/v1/instance/index.ts | 301 +++++----- server/api/api/v1/instance/rules.ts | 28 +- server/api/api/v1/markers/index.test.ts | 22 +- server/api/api/v1/markers/index.ts | 325 ++++++----- server/api/api/v1/media/:id/index.ts | 123 ++++ server/api/api/v1/media/[id]/index.ts | 119 ---- server/api/api/v1/media/index.ts | 215 ++++--- .../v1/moderation/accounts/search/index.ts | 0 server/api/api/v1/mutes/index.test.ts | 4 + server/api/api/v1/mutes/index.ts | 66 ++- .../{[id] => :id}/dismiss.test.ts | 40 +- .../api/api/v1/notifications/:id/dismiss.ts | 50 ++ .../notifications/{[id] => :id}/index.test.ts | 42 +- server/api/api/v1/notifications/:id/index.ts | 51 ++ .../api/api/v1/notifications/[id]/dismiss.ts | 37 -- server/api/api/v1/notifications/[id]/index.ts | 37 -- .../api/v1/notifications/clear/index.test.ts | 42 +- .../api/api/v1/notifications/clear/index.ts | 31 +- .../destroy_multiple/index.test.ts | 58 +- .../notifications/destroy_multiple/index.ts | 53 +- server/api/api/v1/notifications/index.test.ts | 126 ++-- server/api/api/v1/notifications/index.ts | 294 +++++----- server/api/api/v1/profile/avatar.ts | 33 +- server/api/api/v1/profile/header.ts | 34 +- server/api/api/v1/statuses/:id/context.ts | 54 ++ server/api/api/v1/statuses/:id/favourite.ts | 65 +++ .../{[id] => :id}/favourited_by.test.ts | 22 +- .../api/api/v1/statuses/:id/favourited_by.ts | 75 +++ server/api/api/v1/statuses/:id/index.ts | 160 +++++ server/api/api/v1/statuses/:id/pin.ts | 65 +++ server/api/api/v1/statuses/:id/reblog.ts | 92 +++ .../{[id] => :id}/reblogged_by.test.ts | 22 +- .../api/api/v1/statuses/:id/reblogged_by.ts | 74 +++ server/api/api/v1/statuses/:id/source.ts | 51 ++ server/api/api/v1/statuses/:id/unfavourite.ts | 53 ++ server/api/api/v1/statuses/:id/unpin.ts | 51 ++ server/api/api/v1/statuses/:id/unreblog.ts | 68 +++ server/api/api/v1/statuses/[id]/context.ts | 79 --- server/api/api/v1/statuses/[id]/favourite.ts | 56 -- .../api/api/v1/statuses/[id]/favourited_by.ts | 67 --- server/api/api/v1/statuses/[id]/index.ts | 159 ----- server/api/api/v1/statuses/[id]/pin.ts | 57 -- server/api/api/v1/statuses/[id]/reblog.ts | 89 --- .../api/api/v1/statuses/[id]/reblogged_by.ts | 67 --- server/api/api/v1/statuses/[id]/source.ts | 37 -- .../api/api/v1/statuses/[id]/unfavourite.ts | 45 -- server/api/api/v1/statuses/[id]/unpin.ts | 44 -- server/api/api/v1/statuses/[id]/unreblog.ts | 57 -- server/api/api/v1/statuses/index.test.ts | 70 +-- server/api/api/v1/statuses/index.ts | 293 +++++----- server/api/api/v1/timelines/home.test.ts | 21 +- server/api/api/v1/timelines/home.ts | 92 +-- server/api/api/v1/timelines/public.test.ts | 21 +- server/api/api/v1/timelines/public.ts | 123 ++-- .../v2/filters/{[id] => :id}/index.test.ts | 62 +- server/api/api/v2/filters/:id/index.ts | 215 +++++++ server/api/api/v2/filters/[id]/index.ts | 178 ------ server/api/api/v2/filters/index.test.ts | 10 +- server/api/api/v2/filters/index.ts | 284 +++++---- server/api/api/v2/instance/index.ts | 171 +++--- server/api/api/v2/media/index.ts | 263 +++++---- server/api/api/v2/search/index.ts | 301 +++++----- server/api/media/[id]/index.ts | 65 ++- server/api/media/proxy/index.ts | 49 +- server/api/oauth/authorize-external/index.ts | 166 +++--- server/api/oauth/authorize/index.ts | 494 ++++++++-------- server/api/oauth/callback/:issuer/index.ts | 244 ++++++++ server/api/oauth/callback/[issuer]/index.ts | 188 ------ server/api/oauth/providers/index.ts | 32 +- server/api/oauth/token/index.ts | 224 +++---- server/api/objects/[uuid]/index.ts | 75 ++- server/api/users/:uuid/inbox/index.ts | 264 +++++++++ server/api/users/:uuid/index.ts | 42 ++ server/api/users/:uuid/outbox/index.ts | 93 +++ server/api/users/[uuid]/inbox/index.ts | 244 -------- server/api/users/[uuid]/index.ts | 27 - server/api/users/[uuid]/outbox/index.ts | 70 --- server/api/well-known/host-meta/index.ts | 23 +- server/api/well-known/jwks/index.ts | 48 +- server/api/well-known/lysand.ts | 33 +- server/api/well-known/nodeinfo/2.0/index.ts | 34 +- server/api/well-known/nodeinfo/index.ts | 19 +- .../well-known/openid-configuration/index.ts | 34 +- server/api/well-known/webfinger/index.ts | 119 ++-- server2.ts | 21 + tests/utils.ts | 5 +- utils/api.ts | 108 +++- 155 files changed, 7226 insertions(+), 6077 deletions(-) rename server/api/api/v1/accounts/{[id] => :id}/block.test.ts (100%) create mode 100644 server/api/api/v1/accounts/:id/block.ts rename server/api/api/v1/accounts/{[id] => :id}/follow.test.ts (86%) create mode 100644 server/api/api/v1/accounts/:id/follow.ts rename server/api/api/v1/accounts/{[id] => :id}/followers.test.ts (96%) create mode 100644 server/api/api/v1/accounts/:id/followers.ts rename server/api/api/v1/accounts/{[id] => :id}/following.test.ts (96%) create mode 100644 server/api/api/v1/accounts/:id/following.ts rename server/api/api/v1/accounts/{[id] => :id}/index.test.ts (81%) create mode 100644 server/api/api/v1/accounts/:id/index.ts rename server/api/api/v1/accounts/{[id] => :id}/mute.test.ts (86%) create mode 100644 server/api/api/v1/accounts/:id/mute.ts create mode 100644 server/api/api/v1/accounts/:id/note.ts create mode 100644 server/api/api/v1/accounts/:id/pin.ts create mode 100644 server/api/api/v1/accounts/:id/remove_from_followers.ts rename server/api/api/v1/accounts/{[id] => :id}/statuses.test.ts (95%) create mode 100644 server/api/api/v1/accounts/:id/statuses.ts create mode 100644 server/api/api/v1/accounts/:id/unblock.ts create mode 100644 server/api/api/v1/accounts/:id/unfollow.ts rename server/api/api/v1/accounts/{[id] => :id}/unmute.test.ts (100%) create mode 100644 server/api/api/v1/accounts/:id/unmute.ts create mode 100644 server/api/api/v1/accounts/:id/unpin.ts delete mode 100644 server/api/api/v1/accounts/[id]/block.ts delete mode 100644 server/api/api/v1/accounts/[id]/follow.ts delete mode 100644 server/api/api/v1/accounts/[id]/followers.ts delete mode 100644 server/api/api/v1/accounts/[id]/following.ts delete mode 100644 server/api/api/v1/accounts/[id]/index.ts delete mode 100644 server/api/api/v1/accounts/[id]/mute.ts delete mode 100644 server/api/api/v1/accounts/[id]/note.ts delete mode 100644 server/api/api/v1/accounts/[id]/pin.ts delete mode 100644 server/api/api/v1/accounts/[id]/remove_from_followers.ts delete mode 100644 server/api/api/v1/accounts/[id]/statuses.ts delete mode 100644 server/api/api/v1/accounts/[id]/unblock.ts delete mode 100644 server/api/api/v1/accounts/[id]/unfollow.ts delete mode 100644 server/api/api/v1/accounts/[id]/unmute.ts delete mode 100644 server/api/api/v1/accounts/[id]/unpin.ts create mode 100644 server/api/api/v1/follow_requests/:account_id/authorize.ts create mode 100644 server/api/api/v1/follow_requests/:account_id/reject.ts delete mode 100644 server/api/api/v1/follow_requests/[account_id]/authorize.ts delete mode 100644 server/api/api/v1/follow_requests/[account_id]/reject.ts create mode 100644 server/api/api/v1/media/:id/index.ts delete mode 100644 server/api/api/v1/media/[id]/index.ts delete mode 100644 server/api/api/v1/moderation/accounts/search/index.ts rename server/api/api/v1/notifications/{[id] => :id}/dismiss.test.ts (72%) create mode 100644 server/api/api/v1/notifications/:id/dismiss.ts rename server/api/api/v1/notifications/{[id] => :id}/index.test.ts (75%) create mode 100644 server/api/api/v1/notifications/:id/index.ts delete mode 100644 server/api/api/v1/notifications/[id]/dismiss.ts delete mode 100644 server/api/api/v1/notifications/[id]/index.ts create mode 100644 server/api/api/v1/statuses/:id/context.ts create mode 100644 server/api/api/v1/statuses/:id/favourite.ts rename server/api/api/v1/statuses/{[id] => :id}/favourited_by.test.ts (83%) create mode 100644 server/api/api/v1/statuses/:id/favourited_by.ts create mode 100644 server/api/api/v1/statuses/:id/index.ts create mode 100644 server/api/api/v1/statuses/:id/pin.ts create mode 100644 server/api/api/v1/statuses/:id/reblog.ts rename server/api/api/v1/statuses/{[id] => :id}/reblogged_by.test.ts (83%) create mode 100644 server/api/api/v1/statuses/:id/reblogged_by.ts create mode 100644 server/api/api/v1/statuses/:id/source.ts create mode 100644 server/api/api/v1/statuses/:id/unfavourite.ts create mode 100644 server/api/api/v1/statuses/:id/unpin.ts create mode 100644 server/api/api/v1/statuses/:id/unreblog.ts delete mode 100644 server/api/api/v1/statuses/[id]/context.ts delete mode 100644 server/api/api/v1/statuses/[id]/favourite.ts delete mode 100644 server/api/api/v1/statuses/[id]/favourited_by.ts delete mode 100644 server/api/api/v1/statuses/[id]/index.ts delete mode 100644 server/api/api/v1/statuses/[id]/pin.ts delete mode 100644 server/api/api/v1/statuses/[id]/reblog.ts delete mode 100644 server/api/api/v1/statuses/[id]/reblogged_by.ts delete mode 100644 server/api/api/v1/statuses/[id]/source.ts delete mode 100644 server/api/api/v1/statuses/[id]/unfavourite.ts delete mode 100644 server/api/api/v1/statuses/[id]/unpin.ts delete mode 100644 server/api/api/v1/statuses/[id]/unreblog.ts rename server/api/api/v2/filters/{[id] => :id}/index.test.ts (78%) create mode 100644 server/api/api/v2/filters/:id/index.ts delete mode 100644 server/api/api/v2/filters/[id]/index.ts create mode 100644 server/api/oauth/callback/:issuer/index.ts delete mode 100644 server/api/oauth/callback/[issuer]/index.ts create mode 100644 server/api/users/:uuid/inbox/index.ts create mode 100644 server/api/users/:uuid/index.ts create mode 100644 server/api/users/:uuid/outbox/index.ts delete mode 100644 server/api/users/[uuid]/inbox/index.ts delete mode 100644 server/api/users/[uuid]/index.ts delete mode 100644 server/api/users/[uuid]/outbox/index.ts create mode 100644 server2.ts diff --git a/bun.lockb b/bun.lockb index c9f645569a53508912c92856003f4cfe53bedeaa..be22e73f1d643e84cd2d146541a2800ed6e39999 100755 GIT binary patch delta 14067 zcmeHOd3;UR_CM>&B}W7yBoQQ{hG<0waYb-L5nK^LU2{y4q{KW05uUlKq`PcGm9$Sw z%|#=KR!g7p^?ByGW+7(PrWN^p*BM0L&+Gg3{XXyg<$Urz-?i3Wd!N1bba&2h^_uIU zo38VN0>@NsUFUpP*KJ|ndk+f8d@y&2-@$c1wfoDo3vJdOzxn>^MR=#Zgovm(h&Q6@(l{ze8WTqHiq8>?+S;DX6};thWudo?R+KluRSmx1N>PHK?*X%2g6o2l!1chb!T#W5BEq#+ zVAXbdsUwq;2PGqn_0YNEqfs#r_Ks1Ma^U)6wyR%gXsllS(O~vU9$9y6Qr}TZpkWX1 zpeSWve+OI|>>n%6xYqQj8K-A+bkrlt5FV;uv$mb|LI&Wne3XLD@g|HLrRjEfmx7*> zkTNuRd_qEAXT6lmVD>v9hO1ubmAmMgJD4w>ICyA3&TdvIc}P;ih~!ZtlZOl(oSc|6 z>JJV5d4t_SJ@E)VI={T42st-bg1f7ksCyexZ|K&c6rDejC8t-%E}3=|=i+LyrfSQ__{m zC5*<8cG27CS$mytgSntHVD8Fq!CawOv=6uIQZTnbLonML_tz`*DVW=LDVSU8cUveO z4*#Nw{-Z5aV<_NOiSNg*GBOoqMcn`0b3*3NKC0@Q_n!-@}PO!)RHI+MNYPAS}SOrR!r>nx6xFQ z8e_L^hONHHsT@f+L_YLDyP`yiwC0iO_$ag7-8LKLi0t-udLZ)K+pVuR<2+4jMXK?T z8lprOvAeR(x&c}XkroiCK7!QNyppv^b46(`(kex&>5#DW=ryo@3oTfry$1gxJIb+P zv1+W{ovyOvFT7aIOp^S4<~ zLW~w^F_G3VEU9fYX(FUhP1*q|f+h7aB=j0LQFR-27ZF|U>f#uM!_;l+MQA;>a;IRp zMPnB0!=qV49uN`T>}n)d03J>j(JaKK{s|iThNG!vqdg+Oo1K0Y5#8--iw=6Z+EBGF zg4Tk&%$f(Ok;rKsX|0Eqx}hkDigZn638l!4G88O^dAq91VQB!1BIq?69T)jM?5Y+0 zqhFaS*{p9tYb*-tMXE~-yEee-n8@#GS1WeH5Y%eP6U8Y~d)aBG$nIsg?ng=;u8H~x z5=U1|aIE#P(g#5!L2uZsy`V*jCeYF`G+q;l-zoU@!yxtfRdXB!3Vzj*LpWA+d6sy@<^)gchB)ya~ko3IOdz?iwyPWQ zYt>jQh3eSUJZMd!k=Wg$Aq^1Y2Q{_!8=%!~P^9%UNQev4T}X%qk}bhZod`*{?S-WK zl^STK#zE3kmq0@N$a~|tEjUqYci7S)>8Xbx>7|s%vZCkh2?^~4zs%>hTafe|HtfXo z9FriS-Ql+%lAgER+ooSUB+aI-g2b(jnG$TH%OZ8S-Qqh)Y#-iKjUA*YScduc2~+BQ zXc%Fv`Pe8=q>iwwwFfH-eu8*(``gq6Xjn(F24i%qYoXzXfhT>mO??6_5*ns;FPr)! zeju@lF|_trT`{!`?GtENhYal@G+r9@d`*Tb3f2-sdk-2`I72%N?Rh>+onf2Dj~-7d zCOc1fg~Xjd)eE#hLg#A7%eW#^uqG&qNEz!T22AZB^2WMWV*_T7LI7rmcKn9L;i+Ef zoQiR+dG*nJ6y-(cYh&~&%osbGA4Z9GyvSS*Mup~we%1WYgNh4)X44L4D=-3J|B(u| zb9_M1j+ZfK7zY#wCIOt0b1_dgcnY`}Fb&{?xdbqW6&%d$|1K*yn7P~xMFdPQ=fkG= z`Gn&)EMiiFOMQDNqSJ}`7<))yGO5X>#L$lxVlK3-%_{}A8`F9-NwE(NRsIDH+! z2UxqlX5b7PHM7p#v|9}QkFYE9>;$+)zcKPN^M&^S9ME2XkG;HeWW#rwsxvp;K|mF0 zTx5Lne`Kf{(s?O;{1582+noZ$n`Sj^m9h~i|i^2X7~xunPojV@jAesH&_ud z6ZG+Y%dp;ND-LE3_6{pJm^s&7fbI7IKA745fE64sWA=XpaAW2Je7wLVpBqRVp6OMM z-Jfc<-!hNZVz`!zh?(do8qX?An9LJvY16YL>|6~uKK&C`VfQrBnOUr0=)cG8Qqk~x z38!n*#1{_iQyC`?psJDKMdra606P!T8en#*Y4|a-Jqo*B~|AJ%6KCKGeeXz0wm$}9!* zRhJodW*#Y-2CrnhNSou;m%Y|NW5ZgU_$nK~yx3(K_HCSqgPBX&Y3R(Hf0v=Z$XttW zVJA^ANB=GO&hXv`_Co4KBiDa{v61;tT~OS4R}b%!5#ALrM|RZ+kC_|ky1_RM`-{vQ zk!Obe-(Y>V{~H-`+5gvtwZIe$uFsVxgvrClg~K(N?R;mH4`$Yj8~TgP!>zPo|HIt) zg^Vng#fiC`VgE-)lD12X0KeKPY754zog!}?dGx;8DWVZx?G(`nzuh^$+9~qd9gY*P zs;_p6FWX6S3%zWI2(xDA_2t!05smO_r-(*)wNw1B?-XkrJ3`)KviMJTikla|ceQ2I zH#K{DiBY#sHn^A&ek|fvk5#ev!;%gx%G=;uBXI1!MxRX1u=UMb7hU)L=)&{wrr%z( zx5amJE-XJ;I^-_}9X@GPXO74@<}K2WIq0z1f6OVy9d{RXk2~mlk$T)IYM*cy=b;@D zfhU~e6tuY~9CTEihBoV@yJ&LKfrlrvPdY`zQ|{spw38y@lvCVb`MFF()qRknn_!-)|GY+~a@}aFd>n?hobU^3bQgy&IOw+U zx#$%8pru`O&>gWK+PEJOahpv}#5P4)PQE z&{jP_d=DH{O{{)^_#Ps@hYqSPx;;dEj}RZU8bW=9_#TxOLmsuIS|S@-(qqK;*g=6J z`LUDgh}}59E_{A;(i>tp&UM9poP$KQCr+v-QgIFzM{o`ifj>DZR7}A+Oq|BKz6kl* zN#SBP&JDyRoNXc^-$@Nc2F?*87w1Nz*)L9t6pL|gEFR$8M6~(UNlis2&UTTHbCigC z>cnVT{S|T4X~@DnuNG4r(Kk3!MwR(I%S@(tna|Wm#+3l|m8+ThNpc1C zm))2K$W2TMQdM!8Z7R9T6czPOl-X=ZDhWfCk}xF8(^)gqQ zz|YKL|0@;Oa?w(U)g@QU zox!rbH#J9=pR?{Ypj9Ox36`E#U+6(0x=kQ6@j-*uSGnSgl zo@2>Boqs48!JLAF?{=~q@8%k?0f*DD@$Iofy$q$RVdGn2(+wNnA!U(odj}b|?uL!; zQ_ckVT0LOnvT$D{UEy0xe8d}${I3Y#R^po`eZYMCh;Nwh1NazY__;yO(JYEG*0Ax@ zlCKP#xfOMX{x`#B+`v>8v4CT8x_oaE1;`J_(YfL_7#b>aQYuvnEQM;i1NOKOkOaI93zSWuo@QvX20RhYddZVj~uw}C;xV4x4s7wF6~e6In#+1(2)1SGHsSPU!y_%1fz z*`80B0`bW7p3Iy`Ud2biHj?D7iBu{5GR#*1eqgs1$O5(jJAkzS|6|IJIVuBH0DdNQ z4_F9f03(6Xz*qo}j`^h?WjK%k^auF%>aSOIJTz96NGvKhh_AP>$rIRkJF z*az$cc%tnBcyfIW+y@o`i-B>#L?8_q0DO*ea={1XrZn=3`#Z#=z%Aeea1uBL90Se( zr-7Ns@-Dy&L?fUxt@C|Sr*q9C@j-O|0 zQ@aYwJQaCL@;KsUfv2PkFhuU1M1Bzo5V_~sR18o6o*RDwU=4noMVy%FA<8 z$y08eN-pVV;cu4391UCmS^?ZYR{ISiN}cpM$Ay{~G@Zn$zAd;l&<1D^IDi-Hfph-Qlf9N z9j%T_SQFb0y6E=$B~NPFBup!6iahcu`Op%18|1qlex>0T(823*!7fX0&*vB1Dg8bp zpYUUF@PR{Mi*KVm_O8n3fhx*HWUTYCGw&jzWFSck6GhT+v|d*`{6=;;z7`Q0mLLb!e;BauCSZd{Mjf zXNOkC$@BdXZfH<=P&j@y&e>^7%NY~PNC9m1A1z-v@?av9b0hqcjVj+7!Wn%7-vruU?2;vY8qf)iy6w+59Zv`$x7t6T`=}KwgY~9y*n~NdGP5O{ZkU7Bm@} zX4V!|U2BX~wR~4S)OgusE2?0=bic1e2ifXo$~P{A;fKW_RGBV^!-1B{P22F(wR|gC zea$!bKk^OUel)i~hN0=aM}7t82Ii~&?p`f&KUzQLFGUXK#{l0bccW|OTB3?l@IPzm zFUdRH{Cwc-)Pb!+7M|H%mk#|ZK2^*3c|IvhSt-vWtFQTO#4MM!N%0SelZDah)m@IyqISOK2MXmxGW}+595L z)uZX_+vtNsMOo27E4E__X-j=Ym(ZYw`f88D_;*lW(_>`k9aKL&Bt`p&cJkUY6~lKw zX@A58i=U1AP%*!=IMQHq^RNE;uy;{Pyv*5wu+0xMa@MxCS9UgBUgR)OK7a$Qlx{oW zXMWhReMy3M^apQ$TjY02+IP~FlCAmQFB~^_$pbs7Io4Z`9P$gP*cLlA{Gz?~#Ir*G z23POep}RrPIGeqs@`tlM+d{7aeIIRDQsw=R7Y>zubI4vPvc0~1O=}o_h{lzE=FR`u z;h0-5YkLlrZ!E`@x0I$ZnemhYvi_P+iSzUT)nu^{%Uf=)#^-5W!z}XPPO2o+LM#*9<{MUgye8*OqH6NBP|Mf<1Kbmx Ar2qf` delta 14076 zcmeHOd0Z67w(c4jp}`S}D4UA76BQW*M}jbtD2OAt#!(|GZfxR?;>KWNRANX%%2?_( ziCGo*HHeB=qc;(wNw_!e`!0#QMn$9E@AM+vJagZBznAxW?+@$8*WWp(PF2^bs;+6O zyZ2poJ%7RVz2KnL`+gdF=FO!?OX`fR@%ZMAS(1I-BH2BzNzMa4Yxk7(b9#pwBuY~Y z>;VRkN$au)S2G)7a+jpsn(Za2I=By*^hKIJIXP>}NR(+5rRMu_PnXs#mdChw%QHJj zl1ycIvWqtx%O@izg%`R6IM%-+Np8rwukp~-4n< zZtN~e9D zOPk;rpEhd5$h4Hy3CsGZqj?%pP`?fzKO`${Mj!t%M?&B}K2_-ljIDhZ>!ESzU|K0j(p? zZx|`>g49JnlhO3DB)!b@{UYUVkg!^)EiiIuAw0h^{44FK#{~;md!5TuZR5Xa4dFKKY2G|&m7a!pNgDGsZydA4s zBQzRbbXRAKdY_##_9D;gYn9UyBnb<(YLnMM8=%x%(9j}#<9b!6M~`+iA@cfJ zWp}K_G@T5*huI?cgod%90c&Pq^LTuJEBlV;^|#7i-PL-PscKAx){%zHxDC=ve1GRi z;}b~jxG5&m)zU+aP2BWgAw1W|RUQpXThuFY&&C$^1&<$Kl@GwCp4rpSVr+uZYR^p} zk#ahurxTog&f}A;@q^`>c_5Ev6e`SI|dJ_^R2&C^JskyaY(`^GFVIm>- zGe~Og<)^u3%!~vjHwThh%eRoaDYiOEdhSq2YAwqlseZR1sr9uRsM}^hQtSI3l3Jqn zAWUqfzQK^xvW1XTzq?Ou*1=kR@=Qq7Wy-20Z`U-Lmsu_HeNDrJ$0`t-tag$_Q3NRi>5Wo z@L(w4n-Ogc9i}uaBhokt5`u!X9TGx;=IJN`5`aD<+l4hg}dY|&Gj zl%jMxY~3KKxr-sGwVZ>b*4z?H3pyQsVS|mgAr`itmyWd>eo5zUjW&G*) zntm~qRF$bT))J)_tPe^eCOcX#Nqo~JJCpdMN!|{!;Q~_faDga{OF>Ch*#OI6fEZI! z@x#PZQdOqs&e7Nb zb^=}pC=t5=d88l_Q@OWDL3#`4S6cEe6v}vytVsVIrg8;Jsefg%zpvSeN#`2R15;1V zrxug20H9Qr$-W4nh&}`;5myHa0m@$iP^!x0zgn@XOkKZT(|^y7-y5pxt|+txpf2A5 zP}%{|iN6LYqFn$bV$ye$f<#P1Rtm_#-vLTh8U7CGAF5qps^BM0|F1AbdI+F$hqZFV zq#x1rXEEg;Rq}a_snyIRkxKENB?XC?+|H4LRFxS|m>M8c?nQv{{HfkFj#4>5ee(+` zNW|oI1t9x1fD$p;uakoGEGGY(0QLPnfYP&=>bbA{&!P(CM+V8jTTSzhq1n&iLvz4L zzW)i6OEt}pnDNYM&G_PJ4IDIxFK9W$B-YaOf5+tFrTIOJX_ELNpIrQKpup>D{#Dqa zgwOyPH0X`M%;dX--kWE^~SCorHOcig@bYiM-o2FM~YS>QLDeG%3pV+`NbG;qj zu%6JY{|DS3r~lt+fPp8?Q13*}YT*%6C!N>$qGqqkbocX{X8#jdS;7Cz3aPiASsw-V z7!9YkKf!^fkHMgxAA*|UL|#b5;(k&u>qkbz4e3M0UEop5LB6>volT=K1aE z^V`$^aJ%~a_EcSPpWmKhg+Tf*YJVLM-N z*u)<}JH+FU*m>t8Ccf&3jUC|?(C$M^I%;Fbc;QhyUv|{QO~-8P1n+mu&ifuS@e*jK zxP08sU5}f1=5ZT4!;7J9hF0%{jh*9ZC+s}+go*Emc7gkzv~!=6CO-Y7ja?F-c(Sg% z-be0y&&le%<*BZ$oKHSw=h>%B{KP36`-KOcw)5tv5#(tbyULG1I}9!AjE!CAd1vhG z20w%2O&)R9&TjDn9B=b-9PjX$b9VMCUx?#feiO%gJpMd_JCESb+gJs!fOa2R(ghoP z$O|tZxC;pGqK!S`{VpQ7iwN$b&G1CxZkPIS*Gq`*l8rH5d^XBo_5#V(Mc-_W) zc?Gol(2{Q0m_IMPfdFqHz?(K!kN3NY0B<5dXaQWlh0(i(_-@%)ATNft8Ct#DHr9xz z-9~)35g)WB-1iRRyHkVb-04Ek_n_^$gZO^6u^>MAS390=mf_ff2i>)^mV5?|t@sff zgSq*hogR(j7{brsXyy_3?JSfR;Mj(j;~2(cD(ozrFT}Aezlo!T$3L*McKj0@BX|Xl zFYz7^?JSZP;@F7x6|=U-1c1KT$!{U-YO3 zdQB7(4G_!)lqC8Q4HWB$1_{{}G*~1PC5vLBA;MDz4Haob!^BRa;lkGqr}1$^!>7BU z;VGh&4137X${mI@G1(o4Y7L6WcAUJDxE+7v1q>b3BUP3uH?SY_~8s zW^Lrfoz(X-ii$EDvt|bQMxy%u#>%1(n=-q>c*TwvQi{szvt0&pr8#re44(dMw1Mgr z?So+~!SgG*2u2Gl%5BXoE$JBPTJ)oZahJ;DVHl+43ZjHn-l;Y<)F9+TCuc+4^ZV+Kqe@ zpmX(yjq1WqheJB0IntgfiL@h48%4AYG6+l?LTM!S+!c8Ru4IWtGRCsKU<8l~qyfnQ#k&Y+_zSQY zppEVqfffLDs25Nhr~`NdK7b6k0o4H$Py_H_cw?+41W(`rDtai1fm2zXczX753%Cv3 z0e%JU0tuW~ z(BRWxzXl8dl7LgdY2X{+ThVD6vpcpyECIFyI{?~D-3q)5yayBjv>!YG)zdyP4J~a@ z)2{arfF7TH1bhrE0X_jf1wIG90KNp40?U9}@DBy(89@kuX9^DGfkS;Lz5x9rO8=zN zW`+~k8K6f#^pGzP7y-~D)ImT`l97K7I0NhgXe;^yU_Ky#1psYV(-!tSKoZK(CO2)- zj|4KA_;NaXp&D%s9FxTM>C8`ge76DmCg3Ze7+4Opfj>RW@B{n-dIWV9m=UbUn0LR45T^iBfy)3r6+8eO1n5!X zVc-z(7K+oNLA#MXA^#O<1BcO& z+#A-WtClWDx-4lf(emLWN*yd9Vg$rt0F640z5#fGq7Q)w0L^fk4b)Q<*ae_Ba99N9 zvR*~mx$Lr&na=qGun*V|d=HcXKLQjUg-hY-As-M0Gg&isoXDFU5oORE$KXJ7La%^0 z4mbhC0{Rf0fqWV`1)K!V0vJQ3h19p@z&&6iK*M$w2n1>ZG=HuGcY$AlJHTz=7A?%Q zR@?yY0}Fviz;7Bq1~YUGt){U+C!izn3P4@+GB^gH1*|#H2xtHV0PcV*5C}iI&87N%xcn;Ia^xv(aiuDDFx{Y*_>1x+y z%BS+WPyN17sBk@?EtqHB_ z)EN|fJU9;M3?u+ufUbZ|^q9@;4HF>_2WT?TOd?sI2uq-El*HcItbYAbuscBqJ>>?o zNyC+}t;jHk`8XAY&1KhEQTu$hz}H`Y3%o)7VRho)nD>q|8yFfK5p0&EK4RT+=F1j| z??C>3Q3wOSYaI*fmAu$~!F&VDF$bH2Lolo&YAH)#v10C0mf{~_RbM(argX_T<=*0q z7mJQ+qny5nFfBtx>2UBuMV&fzetVjib1FH6peD1FBVypd-WIQs-{_c*pxH?0tz!2w7V2LCkLvL7tKGzNMx$Q) z6c1WPB%|;w#Hn5oEkOPaJE$*Lm+adV>$))ZH81ueIU$+FphDJ$Sp_d-<+!VzPCYVH zcx(gxOaEw^c)Xk?;vO_%1)G6;#~)U(lyLpc_<9Y`G}<9IEHE77NGMDa?Nf*uK6g%kRSNRYq&2K{baK4Sy@_HseZEWzMx&v3(WuVSeJ!DwblnW)mG&qX+dj z=zsoVN!yjjqP-2w92`c2E;)<*)o6_VN_~wsv(}$~iG79;LZ~$%5*It+z?O?+rsEQn7a{_C7KRz5(^6pnOu!txavt4!f*I({;TK?^j(3z>}ibE(3)iyB}^=YS2n_(6QH?p=` zBiicU5B&UP*sO0{-@S~YZIp{d(!T`w;kIS>wD5@?krSroTzORt*n}}$CbBl6N6}Ba zHz5wSXGG1f*x;yhcJ(UOU#>rS@~(&)rU&@>-T$Hx{KJ$nO|4tyed|G zg^|?X*Y|nwz}$0h+u@ZC4siw!Y@P_*!n}k>5i|PhFa4XmJC=X6dg8a0&LyIC5$3A? zl|Wi?`(G_R%lRV*fBNKLL2*o_bFz2>=WzXlhfDJ(-1@|NcWb4C{z=3f=as30 zJYUML%vmH7Hls`QZzy(NC|Fj@JMLbk!wxY64u+rliN%|lkKs~3@zrL`9Q~7o#O>>? z6CIC(l+kTN6C*&>D@MyRgryi0v%1KD=C6OLF!RZ#j~@Ee9tC%8Qcn~g!y){cgVv6& zLj9|Xp3y(Hs9Vo-snRqhCqrDNGbw}SRZTG|Bj?MOipVXvl=KfbzLu`VH7z%Es4QC6 zSB%-he8QhOL`qHi#~_~;=A>V9ZSwcZvTmXTl{008w9_iXo3RIl+4`EzAqg!ld^Y&Fb zW0a0>#r)Dgj4Ei|Kfmdws0jSzh2k;^4Q{9X0;oTYRhIrR@!mET=Fc*f|5$TZ9<39; z^KN39GgeKDvO;@{Q~2o!*T2B_lM@%XfMo+iOXx3;rd?Tf-6veYoQ!NGJ>gVP6#E}@1%v8T16yW4%ucsNAt4K?@| nMTHog3~sJw)wQpgTqn9M(exW;@|y3~T>FNtcc@5jV>tUyIo3r$ diff --git a/database/entities/User.ts b/database/entities/User.ts index 2b0b52dc..61e75107 100644 --- a/database/entities/User.ts +++ b/database/entities/User.ts @@ -109,6 +109,15 @@ export const getFromRequest = async (req: Request): Promise => { return { user, token, application }; }; +export const getFromHeader = async (value: string): Promise => { + const token = value.split(" ")[1]; + + const { user, application } = + await retrieveUserAndApplicationFromToken(token); + + return { user, token, application }; +}; + export const followRequestUser = async ( follower: User, followee: User, diff --git a/index.ts b/index.ts index 421fab01..323035fa 100644 --- a/index.ts +++ b/index.ts @@ -1,11 +1,13 @@ import { dualLogger } from "@loggers"; import { connectMeili } from "@meilisearch"; import { config } from "config-manager"; -import { count } from "drizzle-orm"; +import { Hono } from "hono"; import { LogLevel, LogManager, type MultiLogManager } from "log-manager"; -import { db, setupDatabase } from "~drizzle/db"; -import { Notes } from "~drizzle/schema"; -import { createServer } from "~server"; +import { setupDatabase } from "~drizzle/db"; +import { Note } from "~packages/database-interface/note"; +import type { APIRouteExports } from "~packages/server-handler"; +import { routes } from "~routes"; +import { createServer } from "~server2"; const timeAtStart = performance.now(); @@ -28,20 +30,7 @@ if (config.meilisearch.enabled) { } // Check if database is reachable -let postCount = 0; -try { - postCount = ( - await db - .select({ - count: count(), - }) - .from(Notes) - )[0].count; -} catch (e) { - const error = e as Error; - await dualServerLogger.logError(LogLevel.CRITICAL, "Database", error); - process.exit(1); -} +const postCount = await Note.getCount(); if (isEntry) { // Check if JWT private key is set in config @@ -110,7 +99,21 @@ if (isEntry) { } } -const server = createServer(config, dualServerLogger, true); +const app = new Hono(); + +// Inject own filesystem router +for (const [route, path] of Object.entries(routes)) { + // use app.get(path, handler) to add routes + const route: APIRouteExports = await import(path); + + if (!route.meta || !route.default) { + throw new Error(`Route ${path} does not have the correct exports.`); + } + + route.default(app); +} + +createServer(config, app); await dualServerLogger.log( LogLevel.INFO, @@ -161,4 +164,4 @@ if (config.frontend.enabled) { ); } -export { config, server }; +export { app }; diff --git a/package.json b/package.json index c592d619..baab85e6 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ }, "private": true, "scripts": { - "dev": "bun run --watch index.ts", + "dev": "bun run --hot index.ts", "start": "NODE_ENV=production bun run dist/index.js --prod", "lint": "bunx @biomejs/biome check .", "prod-build": "bun run build.ts", @@ -80,6 +80,7 @@ "config-manager": "workspace:*", "drizzle-orm": "^0.30.7", "extract-zip": "^2.0.1", + "hono": "^4.3.2", "html-to-text": "^9.0.5", "ioredis": "^5.3.2", "ip-matching": "^2.1.2", @@ -99,6 +100,7 @@ "mime-types": "^2.1.35", "oauth4webapi": "^2.4.0", "pg": "^8.11.5", + "qs": "^6.12.1", "request-parser": "workspace:*", "sharp": "^0.33.3", "string-comparison": "^1.3.0", diff --git a/packages/database-interface/note.ts b/packages/database-interface/note.ts index 2ca98bca..94e56321 100644 --- a/packages/database-interface/note.ts +++ b/packages/database-interface/note.ts @@ -3,10 +3,12 @@ import { type InferInsertModel, type SQL, and, + count, desc, eq, inArray, isNotNull, + sql, } from "drizzle-orm"; import { htmlToText } from "html-to-text"; import type * as Lysand from "lysand-types"; @@ -161,6 +163,19 @@ export class Note { return new User(this.status.author); } + static async getCount() { + return ( + await db + .select({ + count: count(), + }) + .from(Notes) + .where( + sql`EXISTS (SELECT 1 FROM "Users" WHERE "Users"."id" = ${Notes.authorId} AND "Users"."instanceId" IS NULL)`, + ) + )[0].count; + } + async getReplyChildren() { return await Note.manyFromSql(eq(Notes.replyId, this.status.id)); } diff --git a/packages/database-interface/user.ts b/packages/database-interface/user.ts index 01db5418..264c857c 100644 --- a/packages/database-interface/user.ts +++ b/packages/database-interface/user.ts @@ -2,7 +2,17 @@ import { idValidator } from "@api"; import { getBestContentType, urlToContentFormat } from "@content_types"; import { addUserToMeilisearch } from "@meilisearch"; import { proxyUrl } from "@response"; -import { type SQL, and, desc, eq, inArray } from "drizzle-orm"; +import { + type SQL, + and, + count, + countDistinct, + desc, + eq, + gte, + inArray, + isNull, +} from "drizzle-orm"; import { htmlToText } from "html-to-text"; import type * as Lysand from "lysand-types"; import { @@ -20,6 +30,7 @@ import { db } from "~drizzle/db"; import { EmojiToUser, NoteToMentions, + Notes, UserToPinnedNotes, Users, } from "~drizzle/schema"; @@ -102,6 +113,37 @@ export class User { return uri || new URL(`/users/${id}`, baseUrl).toString(); } + static async getCount() { + return ( + await db + .select({ + count: count(), + }) + .from(Users) + .where(isNull(Users.instanceId)) + )[0].count; + } + + static async getActiveInPeriod(milliseconds: number) { + return ( + await db + .select({ + count: countDistinct(Users), + }) + .from(Users) + .leftJoin(Notes, eq(Users.id, Notes.authorId)) + .where( + and( + isNull(Users.instanceId), + gte( + Notes.createdAt, + new Date(Date.now() - milliseconds).toISOString(), + ), + ), + ) + )[0].count; + } + async pin(note: Note) { return ( await db diff --git a/packages/lysand-utils/index.ts b/packages/lysand-utils/index.ts index ceb0720f..b41329ab 100644 --- a/packages/lysand-utils/index.ts +++ b/packages/lysand-utils/index.ts @@ -260,3 +260,23 @@ export const signedFetch = async ( }, }); }; + +// Export all schemas as a single object +export default { + Note: schemas.Note, + User: schemas.User, + Reaction: schemas.Reaction, + Poll: schemas.Poll, + Vote: schemas.Vote, + VoteResult: schemas.VoteResult, + Report: schemas.Report, + ServerMetadata: schemas.ServerMetadata, + Like: schemas.Like, + Dislike: schemas.Dislike, + Follow: schemas.Follow, + FollowAccept: schemas.FollowAccept, + FollowReject: schemas.FollowReject, + Announce: schemas.Announce, + Undo: schemas.Undo, + Entity: schemas.Entity, +}; diff --git a/packages/server-handler/index.ts b/packages/server-handler/index.ts index 998ea68f..10fa5b6f 100644 --- a/packages/server-handler/index.ts +++ b/packages/server-handler/index.ts @@ -2,6 +2,8 @@ import { dualLogger } from "@loggers"; import { errorResponse, jsonResponse, response } from "@response"; import type { MatchedRoute } from "bun"; import { type Config, config } from "config-manager"; +import type { Hono } from "hono"; +import type { RouterRoute } from "hono/types"; import { LogLevel, type LogManager, type MultiLogManager } from "log-manager"; import { RequestParser } from "request-parser"; import type { ZodType, z } from "zod"; @@ -11,7 +13,7 @@ import { type AuthData, getFromRequest } from "~database/entities/User"; import type { User } from "~packages/database-interface/user"; type MaybePromise = T | Promise; -type HttpVerb = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS"; +export type HttpVerb = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS"; export type RouteHandler< RouteMeta extends APIRouteMetadata, @@ -54,8 +56,11 @@ export interface APIRouteMetadata { export interface APIRouteExports { meta: APIRouteMetadata; - schema: z.AnyZodObject; - default: RouteHandler; + schemas?: { + query?: z.AnyZodObject; + body?: z.AnyZodObject; + }; + default: (app: Hono) => RouterRoute; } export const processRoute = async ( diff --git a/routes.ts b/routes.ts index b3582b96..7e6f5a60 100644 --- a/routes.ts +++ b/routes.ts @@ -7,7 +7,7 @@ export const routeMatcher = new Bun.FileSystemRouter({ }); // Transform routes to be relative to the server/api directory -const routes = routeMatcher.routes; +let routes = routeMatcher.routes; for (const [route, path] of Object.entries(routes)) { routes[route] = path.replace(join(process.cwd()), "."); @@ -17,6 +17,9 @@ for (const [route, path] of Object.entries(routes)) { } } +// Prevent catch-all routes from being first by reversinbg the order +routes = Object.fromEntries(Object.entries(routes).reverse()); + export { routes }; export const matchRoute = (request: Request) => { diff --git a/server/api/api/_fe/config/index.ts b/server/api/api/_fe/config/index.ts index 534d6e5e..2767e901 100644 --- a/server/api/api/_fe/config/index.ts +++ b/server/api/api/_fe/config/index.ts @@ -1,5 +1,7 @@ -import { apiRoute, applyConfig } from "@api"; +import { applyConfig } from "@api"; import { jsonResponse } from "@response"; +import type { Hono } from "hono"; +import { config } from "~packages/config-manager"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -13,17 +15,16 @@ export const meta = applyConfig({ }, }); -export default apiRoute(async (req, matchedRoute, extraData) => { - const config = await extraData.configManager.getConfig(); - - return jsonResponse({ - http: { - bind: config.http.bind, - bind_port: config.http.bind_port, - base_url: config.http.base_url, - url: config.http.bind.includes("http") - ? `${config.http.bind}:${config.http.bind_port}` - : `http://${config.http.bind}:${config.http.bind_port}`, - }, +export default (app: Hono) => + app.on(meta.allowedMethods, meta.route, async (context) => { + return jsonResponse({ + http: { + bind: config.http.bind, + bind_port: config.http.bind_port, + base_url: config.http.base_url, + url: config.http.bind.includes("http") + ? `${config.http.bind}:${config.http.bind_port}` + : `http://${config.http.bind}:${config.http.bind_port}`, + }, + }); }); -}); diff --git a/server/api/api/auth/login/index.ts b/server/api/api/auth/login/index.ts index ace5a04b..e3096ec3 100644 --- a/server/api/api/auth/login/index.ts +++ b/server/api/api/auth/login/index.ts @@ -1,6 +1,8 @@ -import { apiRoute, applyConfig } from "@api"; +import { applyConfig, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; import { errorResponse, response } from "@response"; import { eq } from "drizzle-orm"; +import type { Hono } from "hono"; import { SignJWT } from "jose"; import { z } from "zod"; import { db } from "~drizzle/db"; @@ -20,35 +22,39 @@ export const meta = applyConfig({ }, }); -export const schema = z.object({ - email: z.string().email().toLowerCase(), - password: z.string().min(2).max(100), - scope: z.string().optional(), - redirect_uri: z.string().url().optional(), - response_type: z.enum([ - "code", - "token", - "none", - "id_token", - "code id_token", - "code token", - "token id_token", - "code token id_token", - ]), - client_id: z.string(), - state: z.string().optional(), - code_challenge: z.string().optional(), - code_challenge_method: z.enum(["plain", "S256"]).optional(), - prompt: z - .enum(["none", "login", "consent", "select_account"]) - .optional() - .default("none"), - max_age: z - .number() - .int() - .optional() - .default(60 * 60 * 24 * 7), -}); +export const schemas = { + form: z.object({ + email: z.string().email().toLowerCase(), + password: z.string().min(2).max(100), + }), + query: z.object({ + scope: z.string().optional(), + redirect_uri: z.string().url().optional(), + response_type: z.enum([ + "code", + "token", + "none", + "id_token", + "code id_token", + "code token", + "token id_token", + "code token id_token", + ]), + client_id: z.string(), + state: z.string().optional(), + code_challenge: z.string().optional(), + code_challenge_method: z.enum(["plain", "S256"]).optional(), + prompt: z + .enum(["none", "login", "consent", "select_account"]) + .optional() + .default("none"), + max_age: z + .number() + .int() + .optional() + .default(60 * 60 * 24 * 7), + }), +}; const returnError = (query: object, error: string, description: string) => { const searchParams = new URLSearchParams(); @@ -66,91 +72,92 @@ const returnError = (query: object, error: string, description: string) => { Location: `/oauth/authorize?${searchParams.toString()}`, }); }; -/** - * Login flow - */ -export default apiRoute( - async (req, matchedRoute, extraData) => { - const { email, password } = extraData.parsedRequest; - if (!email || !password) - return returnError( - extraData.parsedRequest, - "invalid_request", - "Missing email or password", +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("form", schemas.form, handleZodError), + zValidator("query", schemas.query, handleZodError), + async (context) => { + const { email, password } = context.req.valid("form"); + const { client_id } = context.req.valid("query"); + + // Find user + const user = await User.fromSql( + eq(Users.email, email.toLowerCase()), ); - // Find user - const user = await User.fromSql(eq(Users.email, email.toLowerCase())); + if ( + !user || + !(await Bun.password.verify( + password, + user.getUser().password || "", + )) + ) + return returnError( + context.req.query(), + "invalid_request", + "Invalid email or password", + ); - if ( - !user || - !(await Bun.password.verify( - password, - user.getUser().password || "", - )) - ) - return returnError( - extraData.parsedRequest, - "invalid_request", - "Invalid email or password", + // Try and import the key + const privateKey = await crypto.subtle.importKey( + "pkcs8", + Buffer.from(config.oidc.jwt_key.split(";")[0], "base64"), + "Ed25519", + false, + ["sign"], ); - const { client_id } = extraData.parsedRequest; + // Generate JWT + const jwt = await new SignJWT({ + sub: user.id, + iss: new URL(config.http.base_url).origin, + aud: client_id, + exp: Math.floor(Date.now() / 1000) + 60 * 60, + iat: Math.floor(Date.now() / 1000), + nbf: Math.floor(Date.now() / 1000), + }) + .setProtectedHeader({ alg: "EdDSA" }) + .sign(privateKey); - // Try and import the key - const privateKey = await crypto.subtle.importKey( - "pkcs8", - Buffer.from(config.oidc.jwt_key.split(";")[0], "base64"), - "Ed25519", - false, - ["sign"], - ); + const application = await db.query.Applications.findFirst({ + where: (app, { eq }) => eq(app.clientId, client_id), + }); - // Generate JWT - const jwt = await new SignJWT({ - sub: user.id, - iss: new URL(config.http.base_url).origin, - aud: client_id, - exp: Math.floor(Date.now() / 1000) + 60 * 60, - iat: Math.floor(Date.now() / 1000), - nbf: Math.floor(Date.now() / 1000), - }) - .setProtectedHeader({ alg: "EdDSA" }) - .sign(privateKey); + if (!application) { + return errorResponse("Invalid application", 400); + } - const application = await db.query.Applications.findFirst({ - where: (app, { eq }) => eq(app.clientId, client_id), - }); + const searchParams = new URLSearchParams({ + application: application.name, + client_secret: application.secret, + }); - if (!application) { - return errorResponse("Invalid application", 400); - } + if (application.website) + searchParams.append("website", application.website); - const searchParams = new URLSearchParams({ - application: application.name, - client_secret: application.secret, - }); + // Add all data that is not undefined except email and password + for (const [key, value] of Object.entries(context.req.query())) { + if ( + key !== "email" && + key !== "password" && + value !== undefined + ) + searchParams.append(key, String(value)); + } - if (application.website) - searchParams.append("website", application.website); - - // Add all data that is not undefined except email and password - for (const [key, value] of Object.entries(extraData.parsedRequest)) { - if (key !== "email" && key !== "password" && value !== undefined) - searchParams.append(key, String(value)); - } - - // Redirect to OAuth authorize with JWT - return response(null, 302, { - Location: new URL( - `/oauth/consent?${searchParams.toString()}`, - config.http.base_url, - ).toString(), - // Set cookie with JWT - "Set-Cookie": `jwt=${jwt}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${ - 60 * 60 - }`, - }); - }, -); + // Redirect to OAuth authorize with JWT + return response(null, 302, { + Location: new URL( + `/oauth/consent?${searchParams.toString()}`, + config.http.base_url, + ).toString(), + // Set cookie with JWT + "Set-Cookie": `jwt=${jwt}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${ + 60 * 60 + }`, + }); + }, + ); diff --git a/server/api/api/auth/mastodon-login/index.ts b/server/api/api/auth/mastodon-login/index.ts index 6e078ffd..60748f2d 100644 --- a/server/api/api/auth/mastodon-login/index.ts +++ b/server/api/api/auth/mastodon-login/index.ts @@ -1,6 +1,9 @@ import { randomBytes } from "node:crypto"; -import { apiRoute, applyConfig } from "@api"; +import { applyConfig, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { response } from "@response"; import { eq } from "drizzle-orm"; +import type { Hono } from "hono"; import { z } from "zod"; import { TokenType } from "~database/entities/Token"; import { db } from "~drizzle/db"; @@ -20,66 +23,68 @@ export const meta = applyConfig({ }, }); -export const schema = z.object({ - user: z.object({ - email: z.string().email().toLowerCase(), - password: z.string().max(100).min(3), +export const schemas = { + form: z.object({ + user: z.object({ + email: z.string().email().toLowerCase(), + password: z.string().min(2).max(100), + }), }), -}); +}; /** * Mastodon-FE login route */ -export default apiRoute( - async (req, matchedRoute, extraData) => { - const { - user: { email, password }, - } = extraData.parsedRequest; +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("form", schemas.form, handleZodError), + async (context) => { + const { + user: { email, password }, + } = context.req.valid("form"); - const redirectToLogin = (error: string) => - Response.redirect( - `/auth/sign_in?${new URLSearchParams({ - ...matchedRoute.query, - error: encodeURIComponent(error), - }).toString()}`, - 302, - ); + const redirectToLogin = (error: string) => + response(null, 302, { + Location: `/auth/sign_in?${new URLSearchParams({ + ...context.req.query, + error: encodeURIComponent(error), + }).toString()}`, + }); - const user = await User.fromSql(eq(Users.email, email)); + const user = await User.fromSql(eq(Users.email, email)); - if ( - !user || - !(await Bun.password.verify( - password, - user.getUser().password || "", - )) - ) - return redirectToLogin("Invalid email or password"); + if ( + !user || + !(await Bun.password.verify( + password, + user.getUser().password || "", + )) + ) + return redirectToLogin("Invalid email or password"); - const code = randomBytes(32).toString("hex"); - const accessToken = randomBytes(64).toString("base64url"); + const code = randomBytes(32).toString("hex"); + const accessToken = randomBytes(64).toString("base64url"); - await db.insert(Tokens).values({ - accessToken, - code: code, - scope: "read write follow push", - tokenType: TokenType.BEARER, - applicationId: null, - userId: user.id, - }); + await db.insert(Tokens).values({ + accessToken, + code: code, + scope: "read write follow push", + tokenType: TokenType.BEARER, + applicationId: null, + userId: user.id, + }); - // One week from now - const maxAge = String(60 * 60 * 24 * 7); + // One week from now + const maxAge = String(60 * 60 * 24 * 7); - // Redirect to home - return new Response(null, { - headers: { + // Redirect to home + return response(null, 303, { Location: "/", "Set-Cookie": `_session_id=${accessToken}; Domain=${ new URL(config.http.base_url).hostname }; SameSite=Lax; Path=/; HttpOnly; Max-Age=${maxAge}`, - }, - status: 303, - }); - }, -); + }); + }, + ); diff --git a/server/api/api/auth/mastodon-logout/index.ts b/server/api/api/auth/mastodon-logout/index.ts index 23a4c055..ddc6f3f5 100644 --- a/server/api/api/auth/mastodon-logout/index.ts +++ b/server/api/api/auth/mastodon-logout/index.ts @@ -1,4 +1,5 @@ -import { apiRoute, applyConfig } from "@api"; +import { applyConfig } from "@api"; +import type { Hono } from "hono"; import { config } from "~packages/config-manager"; export const meta = applyConfig({ @@ -16,15 +17,15 @@ export const meta = applyConfig({ /** * Mastodon-FE logout route */ -export default apiRoute(async (req, matchedRoute, extraData) => { - // Redirect to home - return new Response(null, { - headers: { - Location: "/", - "Set-Cookie": `_session_id=; Domain=${ - new URL(config.http.base_url).hostname - }; SameSite=Lax; Path=/; HttpOnly; Max-Age=0; Expires=${new Date().toUTCString()}`, - }, - status: 303, +export default (app: Hono) => + app.on(meta.allowedMethods, meta.route, async () => { + return new Response(null, { + headers: { + Location: "/", + "Set-Cookie": `_session_id=; Domain=${ + new URL(config.http.base_url).hostname + }; SameSite=Lax; Path=/; HttpOnly; Max-Age=0; Expires=${new Date().toUTCString()}`, + }, + status: 303, + }); }); -}); diff --git a/server/api/api/auth/redirect/index.ts b/server/api/api/auth/redirect/index.ts index 7a992b88..f9093757 100644 --- a/server/api/api/auth/redirect/index.ts +++ b/server/api/api/auth/redirect/index.ts @@ -1,5 +1,8 @@ -import { apiRoute, applyConfig } from "@api"; +import { applyConfig, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; import { and, eq } from "drizzle-orm"; +import type { Hono } from "hono"; +import { z } from "zod"; import { db } from "~drizzle/db"; import { Applications, Tokens } from "~drizzle/schema"; @@ -15,33 +18,54 @@ export const meta = applyConfig({ }, }); +export const schemas = { + query: z.object({ + redirect_uri: z.string().url(), + client_id: z.string(), + code: z.string(), + }), +}; + /** * OAuth Code flow */ -export default apiRoute(async (req, matchedRoute) => { - const redirect_uri = decodeURIComponent(matchedRoute.query.redirect_uri); - const client_id = matchedRoute.query.client_id; - const code = matchedRoute.query.code; +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("query", schemas.query, handleZodError), + async (context) => { + const { redirect_uri, client_id, code } = + context.req.valid("query"); - const redirectToLogin = (error: string) => - Response.redirect( - `/oauth/authorize?${new URLSearchParams({ - ...matchedRoute.query, - error: encodeURIComponent(error), - }).toString()}`, - 302, - ); + const redirectToLogin = (error: string) => + Response.redirect( + `/oauth/authorize?${new URLSearchParams({ + ...context.req.query, + error: encodeURIComponent(error), + }).toString()}`, + 302, + ); - const foundToken = await db - .select() - .from(Tokens) - .leftJoin(Applications, eq(Tokens.applicationId, Applications.id)) - .where(and(eq(Tokens.code, code), eq(Applications.clientId, client_id))) - .limit(1); + const foundToken = await db + .select() + .from(Tokens) + .leftJoin( + Applications, + eq(Tokens.applicationId, Applications.id), + ) + .where( + and( + eq(Tokens.code, code), + eq(Applications.clientId, client_id), + ), + ) + .limit(1); - if (!foundToken || foundToken.length <= 0) - return redirectToLogin("Invalid code"); + if (!foundToken || foundToken.length <= 0) + return redirectToLogin("Invalid code"); - // Redirect back to application - return Response.redirect(`${redirect_uri}?code=${code}`, 302); -}); + // Redirect back to application + return Response.redirect(`${redirect_uri}?code=${code}`, 302); + }, + ); diff --git a/server/api/api/v1/accounts/[id]/block.test.ts b/server/api/api/v1/accounts/:id/block.test.ts similarity index 100% rename from server/api/api/v1/accounts/[id]/block.test.ts rename to server/api/api/v1/accounts/:id/block.test.ts diff --git a/server/api/api/v1/accounts/:id/block.ts b/server/api/api/v1/accounts/:id/block.ts new file mode 100644 index 00000000..5193d8db --- /dev/null +++ b/server/api/api/v1/accounts/:id/block.ts @@ -0,0 +1,66 @@ +import { applyConfig, auth, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse } from "@response"; +import { eq } from "drizzle-orm"; +import type { Hono } from "hono"; +import { z } from "zod"; +import { relationshipToAPI } from "~database/entities/Relationship"; +import { getRelationshipToOtherUser } from "~database/entities/User"; +import { db } from "~drizzle/db"; +import { Relationships } from "~drizzle/schema"; +import { User } from "~packages/database-interface/user"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/api/v1/accounts/:id/block", + auth: { + required: true, + oauthPermissions: ["write:blocks"], + }, +}); + +export const schemas = { + param: z.object({ + id: z.string().uuid(), + }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("param", schemas.param, handleZodError), + auth(meta.auth), + async (context) => { + const { id } = context.req.valid("param"); + const { user } = context.req.valid("header"); + + if (!user) return errorResponse("Unauthorized", 401); + + const otherUser = await User.fromId(id); + + if (!otherUser) return errorResponse("User not found", 404); + + const foundRelationship = await getRelationshipToOtherUser( + user, + otherUser, + ); + + if (!foundRelationship.blocking) { + foundRelationship.blocking = true; + } + + await db + .update(Relationships) + .set({ + blocking: true, + }) + .where(eq(Relationships.id, foundRelationship.id)); + + return jsonResponse(relationshipToAPI(foundRelationship)); + }, + ); diff --git a/server/api/api/v1/accounts/[id]/follow.test.ts b/server/api/api/v1/accounts/:id/follow.test.ts similarity index 86% rename from server/api/api/v1/accounts/[id]/follow.test.ts rename to server/api/api/v1/accounts/:id/follow.test.ts index 6ac2807a..170f80e1 100644 --- a/server/api/api/v1/accounts/[id]/follow.test.ts +++ b/server/api/api/v1/accounts/:id/follow.test.ts @@ -27,6 +27,10 @@ describe(meta.route, () => { ), { method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({}), }, ), ); @@ -47,7 +51,9 @@ describe(meta.route, () => { method: "POST", headers: { Authorization: `Bearer ${tokens[0].accessToken}`, + "Content-Type": "application/json", }, + body: JSON.stringify({}), }, ), ); @@ -65,7 +71,9 @@ describe(meta.route, () => { method: "POST", headers: { Authorization: `Bearer ${tokens[0].accessToken}`, + "Content-Type": "application/json", }, + body: JSON.stringify({}), }, ), ); @@ -86,7 +94,9 @@ describe(meta.route, () => { method: "POST", headers: { Authorization: `Bearer ${tokens[0].accessToken}`, + "Content-Type": "application/json", }, + body: JSON.stringify({}), }, ), ); diff --git a/server/api/api/v1/accounts/:id/follow.ts b/server/api/api/v1/accounts/:id/follow.ts new file mode 100644 index 00000000..b179e354 --- /dev/null +++ b/server/api/api/v1/accounts/:id/follow.ts @@ -0,0 +1,79 @@ +import { applyConfig, auth, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse } from "@response"; +import type { Hono } from "hono"; +import ISO6391 from "iso-639-1"; +import { z } from "zod"; +import { relationshipToAPI } from "~database/entities/Relationship"; +import { + followRequestUser, + getRelationshipToOtherUser, +} from "~database/entities/User"; +import { User } from "~packages/database-interface/user"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/api/v1/accounts/:id/follow", + auth: { + required: true, + oauthPermissions: ["write:follows"], + }, +}); + +export const schemas = { + param: z.object({ + id: z.string().uuid(), + }), + json: z + .object({ + reblogs: z.coerce.boolean().optional(), + notify: z.coerce.boolean().optional(), + languages: z + .array(z.enum(ISO6391.getAllCodes() as [string, ...string[]])) + .optional(), + }) + .optional() + .default({ reblogs: true, notify: false, languages: [] }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("param", schemas.param, handleZodError), + zValidator("json", schemas.json, handleZodError), + auth(meta.auth), + async (context) => { + const { id } = context.req.valid("param"); + const { user } = context.req.valid("header"); + const { reblogs, notify, languages } = context.req.valid("json"); + + if (!user) return errorResponse("Unauthorized", 401); + + const otherUser = await User.fromId(id); + + if (!otherUser) return errorResponse("User not found", 404); + + let relationship = await getRelationshipToOtherUser( + user, + otherUser, + ); + + if (!relationship.following) { + relationship = await followRequestUser( + user, + otherUser, + relationship.id, + reblogs, + notify, + languages, + ); + } + + return jsonResponse(relationshipToAPI(relationship)); + }, + ); diff --git a/server/api/api/v1/accounts/[id]/followers.test.ts b/server/api/api/v1/accounts/:id/followers.test.ts similarity index 96% rename from server/api/api/v1/accounts/[id]/followers.test.ts rename to server/api/api/v1/accounts/:id/followers.test.ts index a3338d21..ef126241 100644 --- a/server/api/api/v1/accounts/[id]/followers.test.ts +++ b/server/api/api/v1/accounts/:id/followers.test.ts @@ -28,7 +28,9 @@ beforeAll(async () => { method: "POST", headers: { Authorization: `Bearer ${tokens[0].accessToken}`, + "Content-Type": "application/json", }, + body: JSON.stringify({}), }, ), ); diff --git a/server/api/api/v1/accounts/:id/followers.ts b/server/api/api/v1/accounts/:id/followers.ts new file mode 100644 index 00000000..6b351d22 --- /dev/null +++ b/server/api/api/v1/accounts/:id/followers.ts @@ -0,0 +1,74 @@ +import { applyConfig, auth, handleZodError, idValidator } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse } from "@response"; +import { and, gt, gte, lt, sql } from "drizzle-orm"; +import type { Hono } from "hono"; +import { z } from "zod"; +import { Users } from "~drizzle/schema"; +import { Timeline } from "~packages/database-interface/timeline"; +import { User } from "~packages/database-interface/user"; + +export const meta = applyConfig({ + allowedMethods: ["GET"], + ratelimits: { + max: 60, + duration: 60, + }, + route: "/api/v1/accounts/:id/followers", + auth: { + required: false, + oauthPermissions: ["read:accounts"], + }, +}); + +export const schemas = { + query: z.object({ + max_id: z.string().regex(idValidator).optional(), + since_id: z.string().regex(idValidator).optional(), + min_id: z.string().regex(idValidator).optional(), + limit: z.coerce.number().int().min(1).max(40).optional().default(20), + }), + param: z.object({ + id: z.string().uuid(), + }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("query", schemas.query, handleZodError), + zValidator("param", schemas.param, handleZodError), + auth(meta.auth), + async (context) => { + const { id } = context.req.valid("param"); + const { user } = context.req.valid("header"); + const { max_id, since_id, min_id, limit } = + context.req.valid("query"); + + const otherUser = await User.fromId(id); + + // TODO: Add follower/following privacy settings + + if (!otherUser) return errorResponse("User not found", 404); + + const { objects, link } = await Timeline.getUserTimeline( + and( + max_id ? lt(Users.id, max_id) : undefined, + since_id ? gte(Users.id, since_id) : undefined, + min_id ? gt(Users.id, min_id) : undefined, + sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${otherUser.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."following" = true)`, + ), + limit, + context.req.url, + ); + + return jsonResponse( + await Promise.all(objects.map((object) => object.toAPI())), + 200, + { + Link: link, + }, + ); + }, + ); diff --git a/server/api/api/v1/accounts/[id]/following.test.ts b/server/api/api/v1/accounts/:id/following.test.ts similarity index 96% rename from server/api/api/v1/accounts/[id]/following.test.ts rename to server/api/api/v1/accounts/:id/following.test.ts index 5441b949..684c6936 100644 --- a/server/api/api/v1/accounts/[id]/following.test.ts +++ b/server/api/api/v1/accounts/:id/following.test.ts @@ -28,7 +28,9 @@ beforeAll(async () => { method: "POST", headers: { Authorization: `Bearer ${tokens[0].accessToken}`, + "Content-Type": "application/json", }, + body: JSON.stringify({}), }, ), ); diff --git a/server/api/api/v1/accounts/:id/following.ts b/server/api/api/v1/accounts/:id/following.ts new file mode 100644 index 00000000..d49427aa --- /dev/null +++ b/server/api/api/v1/accounts/:id/following.ts @@ -0,0 +1,73 @@ +import { applyConfig, auth, handleZodError, idValidator } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse } from "@response"; +import { and, gt, gte, lt, sql } from "drizzle-orm"; +import type { Hono } from "hono"; +import { z } from "zod"; +import { Users } from "~drizzle/schema"; +import { Timeline } from "~packages/database-interface/timeline"; +import { User } from "~packages/database-interface/user"; + +export const meta = applyConfig({ + allowedMethods: ["GET"], + ratelimits: { + max: 60, + duration: 60, + }, + route: "/api/v1/accounts/:id/following", + auth: { + required: false, + oauthPermissions: ["read:accounts"], + }, +}); + +export const schemas = { + query: z.object({ + max_id: z.string().regex(idValidator).optional(), + since_id: z.string().regex(idValidator).optional(), + min_id: z.string().regex(idValidator).optional(), + limit: z.coerce.number().int().min(1).max(40).optional().default(20), + }), + param: z.object({ + id: z.string().uuid(), + }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("query", schemas.query, handleZodError), + zValidator("param", schemas.param, handleZodError), + auth(meta.auth), + async (context) => { + const { id } = context.req.valid("param"); + const { user } = context.req.valid("header"); + const { max_id, since_id, min_id } = context.req.valid("query"); + + const otherUser = await User.fromId(id); + + if (!otherUser) return errorResponse("User not found", 404); + + // TODO: Add follower/following privacy settings + + const { objects, link } = await Timeline.getUserTimeline( + and( + max_id ? lt(Users.id, max_id) : undefined, + since_id ? gte(Users.id, since_id) : undefined, + min_id ? gt(Users.id, min_id) : undefined, + sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${otherUser.id} AND "Relationships"."following" = true)`, + ), + context.req.valid("query").limit, + context.req.url, + ); + + return jsonResponse( + await Promise.all(objects.map((object) => object.toAPI())), + 200, + { + Link: link, + }, + ); + }, + ); diff --git a/server/api/api/v1/accounts/[id]/index.test.ts b/server/api/api/v1/accounts/:id/index.test.ts similarity index 81% rename from server/api/api/v1/accounts/[id]/index.test.ts rename to server/api/api/v1/accounts/:id/index.test.ts index 7517aeb8..c1985176 100644 --- a/server/api/api/v1/accounts/[id]/index.test.ts +++ b/server/api/api/v1/accounts/:id/index.test.ts @@ -20,17 +20,21 @@ afterAll(async () => { beforeAll(async () => { for (const status of timeline) { - await fetch( - new URL( - `/api/v1/statuses/${status.id}/favourite`, - config.http.base_url, - ), - { - method: "POST", - headers: { - Authorization: `Bearer ${tokens[1].accessToken}`, + await sendTestRequest( + new Request( + new URL( + `/api/v1/statuses/${status.id}/favourite`, + config.http.base_url, + ), + { + method: "POST", + headers: { + Authorization: `Bearer ${tokens[1].accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), }, - }, + ), ); } }); @@ -46,7 +50,7 @@ describe(meta.route, () => { ), ), ); - expect(response.status).toBe(404); + expect(response.status).toBe(422); }); test("should return user", async () => { diff --git a/server/api/api/v1/accounts/:id/index.ts b/server/api/api/v1/accounts/:id/index.ts new file mode 100644 index 00000000..2f2682f1 --- /dev/null +++ b/server/api/api/v1/accounts/:id/index.ts @@ -0,0 +1,43 @@ +import { apiRoute, applyConfig, auth, handleZodError, idValidator } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse } from "@response"; +import type { Hono } from "hono"; +import { z } from "zod"; +import { User } from "~packages/database-interface/user"; + +export const meta = applyConfig({ + allowedMethods: ["GET"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/api/v1/accounts/:id", + auth: { + required: false, + oauthPermissions: [], + }, +}); + +export const schemas = { + param: z.object({ + id: z.string().uuid(), + }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("param", schemas.param, handleZodError), + auth(meta.auth), + async (context) => { + const { id } = context.req.valid("param"); + const { user } = context.req.valid("header"); + + const foundUser = await User.fromId(id); + + if (!foundUser) return errorResponse("User not found", 404); + + return jsonResponse(foundUser.toAPI(user?.id === foundUser.id)); + }, + ); diff --git a/server/api/api/v1/accounts/[id]/mute.test.ts b/server/api/api/v1/accounts/:id/mute.test.ts similarity index 86% rename from server/api/api/v1/accounts/[id]/mute.test.ts rename to server/api/api/v1/accounts/:id/mute.test.ts index 673cefcb..c685e0fc 100644 --- a/server/api/api/v1/accounts/[id]/mute.test.ts +++ b/server/api/api/v1/accounts/:id/mute.test.ts @@ -27,6 +27,10 @@ describe(meta.route, () => { ), { method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({}), }, ), ); @@ -47,7 +51,9 @@ describe(meta.route, () => { method: "POST", headers: { Authorization: `Bearer ${tokens[0].accessToken}`, + "Content-Type": "application/json", }, + body: JSON.stringify({}), }, ), ); @@ -65,7 +71,9 @@ describe(meta.route, () => { method: "POST", headers: { Authorization: `Bearer ${tokens[0].accessToken}`, + "Content-Type": "application/json", }, + body: JSON.stringify({}), }, ), ); @@ -86,7 +94,9 @@ describe(meta.route, () => { method: "POST", headers: { Authorization: `Bearer ${tokens[0].accessToken}`, + "Content-Type": "application/json", }, + body: JSON.stringify({}), }, ), ); diff --git a/server/api/api/v1/accounts/:id/mute.ts b/server/api/api/v1/accounts/:id/mute.ts new file mode 100644 index 00000000..a2e45a8a --- /dev/null +++ b/server/api/api/v1/accounts/:id/mute.ts @@ -0,0 +1,83 @@ +import { applyConfig, auth, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse } from "@response"; +import { eq } from "drizzle-orm"; +import type { Hono } from "hono"; +import { z } from "zod"; +import { relationshipToAPI } from "~database/entities/Relationship"; +import { getRelationshipToOtherUser } from "~database/entities/User"; +import { db } from "~drizzle/db"; +import { Relationships } from "~drizzle/schema"; +import { User } from "~packages/database-interface/user"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/api/v1/accounts/:id/mute", + auth: { + required: true, + oauthPermissions: ["write:mutes"], + }, +}); + +export const schemas = { + param: z.object({ + id: z.string().uuid(), + }), + json: z.object({ + notifications: z.boolean().optional(), + duration: z + .number() + .int() + .min(60) + .max(60 * 60 * 24 * 365 * 5) + .optional(), + }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("param", schemas.param, handleZodError), + zValidator("json", schemas.json, handleZodError), + auth(meta.auth), + async (context) => { + const { id } = context.req.valid("param"); + const { user } = context.req.valid("header"); + const { notifications, duration } = context.req.valid("json"); + + if (!user) return errorResponse("Unauthorized", 401); + + const otherUser = await User.fromId(id); + + if (!otherUser) return errorResponse("User not found", 404); + + const foundRelationship = await getRelationshipToOtherUser( + user, + otherUser, + ); + + if (!foundRelationship.muting) { + foundRelationship.muting = true; + } + if (notifications ?? true) { + foundRelationship.mutingNotifications = true; + } + + await db + .update(Relationships) + .set({ + muting: true, + mutingNotifications: notifications ?? true, + }) + .where(eq(Relationships.id, foundRelationship.id)); + + // TODO: Implement duration + + return jsonResponse(relationshipToAPI(foundRelationship)); + }, + ); diff --git a/server/api/api/v1/accounts/:id/note.ts b/server/api/api/v1/accounts/:id/note.ts new file mode 100644 index 00000000..d3da7f09 --- /dev/null +++ b/server/api/api/v1/accounts/:id/note.ts @@ -0,0 +1,69 @@ +import { applyConfig, auth, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse } from "@response"; +import { eq } from "drizzle-orm"; +import type { Hono } from "hono"; +import { z } from "zod"; +import { relationshipToAPI } from "~database/entities/Relationship"; +import { getRelationshipToOtherUser } from "~database/entities/User"; +import { db } from "~drizzle/db"; +import { Relationships } from "~drizzle/schema"; +import { User } from "~packages/database-interface/user"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/api/v1/accounts/:id/note", + auth: { + required: true, + oauthPermissions: ["write:accounts"], + }, +}); + +export const schemas = { + param: z.object({ + id: z.string().uuid(), + }), + json: z.object({ + comment: z.string().min(0).max(5000).trim().optional(), + }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("param", schemas.param, handleZodError), + zValidator("json", schemas.json, handleZodError), + auth(meta.auth), + async (context) => { + const { id } = context.req.valid("param"); + const { user } = context.req.valid("header"); + const { comment } = context.req.valid("json"); + + if (!user) return errorResponse("Unauthorized", 401); + + const otherUser = await User.fromId(id); + + if (!otherUser) return errorResponse("User not found", 404); + + const foundRelationship = await getRelationshipToOtherUser( + user, + otherUser, + ); + + foundRelationship.note = comment ?? ""; + + await db + .update(Relationships) + .set({ + note: foundRelationship.note, + }) + .where(eq(Relationships.id, foundRelationship.id)); + + return jsonResponse(relationshipToAPI(foundRelationship)); + }, + ); diff --git a/server/api/api/v1/accounts/:id/pin.ts b/server/api/api/v1/accounts/:id/pin.ts new file mode 100644 index 00000000..da5df876 --- /dev/null +++ b/server/api/api/v1/accounts/:id/pin.ts @@ -0,0 +1,66 @@ +import { applyConfig, auth, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse } from "@response"; +import { eq } from "drizzle-orm"; +import type { Hono } from "hono"; +import { z } from "zod"; +import { relationshipToAPI } from "~database/entities/Relationship"; +import { getRelationshipToOtherUser } from "~database/entities/User"; +import { db } from "~drizzle/db"; +import { Relationships } from "~drizzle/schema"; +import { User } from "~packages/database-interface/user"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/api/v1/accounts/:id/pin", + auth: { + required: true, + oauthPermissions: ["write:accounts"], + }, +}); + +export const schemas = { + param: z.object({ + id: z.string().uuid(), + }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("param", schemas.param, handleZodError), + auth(meta.auth), + async (context) => { + const { id } = context.req.valid("param"); + const { user } = context.req.valid("header"); + + if (!user) return errorResponse("Unauthorized", 401); + + const otherUser = await User.fromId(id); + + if (!otherUser) return errorResponse("User not found", 404); + + const foundRelationship = await getRelationshipToOtherUser( + user, + otherUser, + ); + + if (!foundRelationship.endorsed) { + foundRelationship.endorsed = true; + } + + await db + .update(Relationships) + .set({ + endorsed: true, + }) + .where(eq(Relationships.id, foundRelationship.id)); + + return jsonResponse(relationshipToAPI(foundRelationship)); + }, + ); diff --git a/server/api/api/v1/accounts/:id/remove_from_followers.ts b/server/api/api/v1/accounts/:id/remove_from_followers.ts new file mode 100644 index 00000000..a3bd9102 --- /dev/null +++ b/server/api/api/v1/accounts/:id/remove_from_followers.ts @@ -0,0 +1,80 @@ +import { apiRoute, applyConfig, auth, handleZodError, idValidator } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse } from "@response"; +import { and, eq } from "drizzle-orm"; +import type { Hono } from "hono"; +import { z } from "zod"; +import { relationshipToAPI } from "~database/entities/Relationship"; +import { getRelationshipToOtherUser } from "~database/entities/User"; +import { db } from "~drizzle/db"; +import { Relationships } from "~drizzle/schema"; +import { User } from "~packages/database-interface/user"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/api/v1/accounts/:id/remove_from_followers", + auth: { + required: true, + oauthPermissions: ["write:follows"], + }, +}); + +export const schemas = { + param: z.object({ + id: z.string().uuid(), + }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("param", schemas.param, handleZodError), + auth(meta.auth), + async (context) => { + const { id } = context.req.valid("param"); + const { user: self } = context.req.valid("header"); + + if (!self) return errorResponse("Unauthorized", 401); + + const otherUser = await User.fromId(id); + + if (!otherUser) return errorResponse("User not found", 404); + + const foundRelationship = await getRelationshipToOtherUser( + self, + otherUser, + ); + + if (foundRelationship.followedBy) { + foundRelationship.followedBy = false; + + await db + .update(Relationships) + .set({ + followedBy: false, + }) + .where(eq(Relationships.id, foundRelationship.id)); + + if (otherUser.isLocal()) { + await db + .update(Relationships) + .set({ + following: false, + }) + .where( + and( + eq(Relationships.ownerId, otherUser.id), + eq(Relationships.subjectId, self.id), + ), + ); + } + } + + return jsonResponse(relationshipToAPI(foundRelationship)); + }, + ); diff --git a/server/api/api/v1/accounts/[id]/statuses.test.ts b/server/api/api/v1/accounts/:id/statuses.test.ts similarity index 95% rename from server/api/api/v1/accounts/[id]/statuses.test.ts rename to server/api/api/v1/accounts/:id/statuses.test.ts index 6f10dcf8..3ffa6b18 100644 --- a/server/api/api/v1/accounts/[id]/statuses.test.ts +++ b/server/api/api/v1/accounts/:id/statuses.test.ts @@ -1,13 +1,11 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { config } from "config-manager"; -import { db } from "~drizzle/db"; import { deleteOldTestUsers, getTestStatuses, getTestUsers, sendTestRequest, } from "~tests/utils"; -import type { Account as APIAccount } from "~types/mastodon/account"; import type { Status as APIStatus } from "~types/mastodon/status"; import { meta } from "./statuses"; @@ -21,6 +19,12 @@ afterAll(async () => { await deleteUsers(); }); +const getFormData = (object: Record) => + Object.keys(object).reduce((formData, key) => { + formData.append(key, String(object[key])); + return formData; + }, new FormData()); + beforeAll(async () => { const response = await sendTestRequest( new Request( @@ -100,9 +104,8 @@ describe(meta.route, () => { method: "POST", headers: { Authorization: `Bearer ${tokens[1].accessToken}`, - "Content-Type": "application/json", }, - body: JSON.stringify({ + body: getFormData({ status: "Reply", in_reply_to_id: timeline[0].id, federate: false, diff --git a/server/api/api/v1/accounts/:id/statuses.ts b/server/api/api/v1/accounts/:id/statuses.ts new file mode 100644 index 00000000..a47d2433 --- /dev/null +++ b/server/api/api/v1/accounts/:id/statuses.ts @@ -0,0 +1,108 @@ +import { applyConfig, auth, handleZodError, idValidator } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse } from "@response"; +import { and, eq, gt, gte, isNull, lt, sql } from "drizzle-orm"; +import type { Hono } from "hono"; +import { z } from "zod"; +import { Notes } from "~drizzle/schema"; +import { Timeline } from "~packages/database-interface/timeline"; +import { User } from "~packages/database-interface/user"; + +export const meta = applyConfig({ + allowedMethods: ["GET"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/api/v1/accounts/:id/statuses", + auth: { + required: false, + oauthPermissions: ["read:statuses"], + }, +}); + +export const schemas = { + param: z.object({ + id: z.string().uuid(), + }), + query: z.object({ + max_id: z.string().regex(idValidator).optional(), + since_id: z.string().regex(idValidator).optional(), + min_id: z.string().regex(idValidator).optional(), + limit: z.number().int().min(1).max(40).optional().default(20), + only_media: z + .string() + .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) + .optional(), + exclude_replies: z + .string() + .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) + .optional(), + exclude_reblogs: z + .string() + .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) + .optional(), + pinned: z + .string() + .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) + .optional(), + tagged: z + .string() + .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) + .optional(), + }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("param", schemas.param, handleZodError), + zValidator("query", schemas.query, handleZodError), + auth(meta.auth), + async (context) => { + const { id } = context.req.valid("param"); + + const otherUser = await User.fromId(id); + + if (!otherUser) return errorResponse("User not found", 404); + + const { + max_id, + min_id, + since_id, + limit, + exclude_reblogs, + only_media, + exclude_replies, + pinned, + } = context.req.valid("query"); + + const { objects, link } = await Timeline.getNoteTimeline( + and( + max_id ? lt(Notes.id, max_id) : undefined, + since_id ? gte(Notes.id, since_id) : undefined, + min_id ? gt(Notes.id, min_id) : undefined, + eq(Notes.authorId, id), + only_media + ? sql`EXISTS (SELECT 1 FROM "Attachments" WHERE "Attachments"."noteId" = ${Notes.id})` + : undefined, + pinned + ? sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."noteId" = ${Notes.id} AND "UserToPinnedNotes"."userId" = ${otherUser.id})` + : undefined, + exclude_reblogs ? isNull(Notes.reblogId) : undefined, + exclude_replies ? isNull(Notes.replyId) : undefined, + ), + limit, + context.req.url, + ); + + return jsonResponse( + await Promise.all(objects.map((note) => note.toAPI(otherUser))), + 200, + { + Link: link, + }, + ); + }, + ); diff --git a/server/api/api/v1/accounts/:id/unblock.ts b/server/api/api/v1/accounts/:id/unblock.ts new file mode 100644 index 00000000..a699d0c4 --- /dev/null +++ b/server/api/api/v1/accounts/:id/unblock.ts @@ -0,0 +1,66 @@ +import { applyConfig, auth, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse } from "@response"; +import { eq } from "drizzle-orm"; +import type { Hono } from "hono"; +import { z } from "zod"; +import { relationshipToAPI } from "~database/entities/Relationship"; +import { getRelationshipToOtherUser } from "~database/entities/User"; +import { db } from "~drizzle/db"; +import { Relationships } from "~drizzle/schema"; +import { User } from "~packages/database-interface/user"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/api/v1/accounts/:id/unblock", + auth: { + required: true, + oauthPermissions: ["write:blocks"], + }, +}); + +export const schemas = { + param: z.object({ + id: z.string().uuid(), + }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("param", schemas.param, handleZodError), + auth(meta.auth), + async (context) => { + const { id } = context.req.valid("param"); + const { user } = context.req.valid("header"); + + if (!user) return errorResponse("Unauthorized", 401); + + const otherUser = await User.fromId(id); + + if (!otherUser) return errorResponse("User not found", 404); + + const foundRelationship = await getRelationshipToOtherUser( + user, + otherUser, + ); + + if (foundRelationship.blocking) { + foundRelationship.blocking = false; + + await db + .update(Relationships) + .set({ + blocking: false, + }) + .where(eq(Relationships.id, foundRelationship.id)); + } + + return jsonResponse(relationshipToAPI(foundRelationship)); + }, + ); diff --git a/server/api/api/v1/accounts/:id/unfollow.ts b/server/api/api/v1/accounts/:id/unfollow.ts new file mode 100644 index 00000000..aaa558f1 --- /dev/null +++ b/server/api/api/v1/accounts/:id/unfollow.ts @@ -0,0 +1,67 @@ +import { applyConfig, auth, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse } from "@response"; +import { eq } from "drizzle-orm"; +import type { Hono } from "hono"; +import { z } from "zod"; +import { relationshipToAPI } from "~database/entities/Relationship"; +import { getRelationshipToOtherUser } from "~database/entities/User"; +import { db } from "~drizzle/db"; +import { Relationships } from "~drizzle/schema"; +import { User } from "~packages/database-interface/user"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/api/v1/accounts/:id/unfollow", + auth: { + required: true, + oauthPermissions: ["write:follows"], + }, +}); + +export const schemas = { + param: z.object({ + id: z.string().uuid(), + }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("param", schemas.param, handleZodError), + auth(meta.auth), + async (context) => { + const { id } = context.req.valid("param"); + const { user: self } = context.req.valid("header"); + + if (!self) return errorResponse("Unauthorized", 401); + + const otherUser = await User.fromId(id); + + if (!otherUser) return errorResponse("User not found", 404); + + const foundRelationship = await getRelationshipToOtherUser( + self, + otherUser, + ); + + if (foundRelationship.following) { + foundRelationship.following = false; + + await db + .update(Relationships) + .set({ + following: false, + requested: false, + }) + .where(eq(Relationships.id, foundRelationship.id)); + } + + return jsonResponse(relationshipToAPI(foundRelationship)); + }, + ); diff --git a/server/api/api/v1/accounts/[id]/unmute.test.ts b/server/api/api/v1/accounts/:id/unmute.test.ts similarity index 100% rename from server/api/api/v1/accounts/[id]/unmute.test.ts rename to server/api/api/v1/accounts/:id/unmute.test.ts diff --git a/server/api/api/v1/accounts/:id/unmute.ts b/server/api/api/v1/accounts/:id/unmute.ts new file mode 100644 index 00000000..dd82c887 --- /dev/null +++ b/server/api/api/v1/accounts/:id/unmute.ts @@ -0,0 +1,68 @@ +import { applyConfig, auth, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse } from "@response"; +import { eq } from "drizzle-orm"; +import type { Hono } from "hono"; +import { z } from "zod"; +import { relationshipToAPI } from "~database/entities/Relationship"; +import { getRelationshipToOtherUser } from "~database/entities/User"; +import { db } from "~drizzle/db"; +import { Relationships } from "~drizzle/schema"; +import { User } from "~packages/database-interface/user"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/api/v1/accounts/:id/unmute", + auth: { + required: true, + oauthPermissions: ["write:mutes"], + }, +}); + +export const schemas = { + param: z.object({ + id: z.string().uuid(), + }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("param", schemas.param, handleZodError), + auth(meta.auth), + async (context) => { + const { id } = context.req.valid("param"); + const { user: self } = context.req.valid("header"); + + if (!self) return errorResponse("Unauthorized", 401); + + const user = await User.fromId(id); + + if (!user) return errorResponse("User not found", 404); + + const foundRelationship = await getRelationshipToOtherUser( + self, + user, + ); + + if (foundRelationship.muting) { + foundRelationship.muting = false; + foundRelationship.mutingNotifications = false; + + await db + .update(Relationships) + .set({ + muting: false, + mutingNotifications: false, + }) + .where(eq(Relationships.id, foundRelationship.id)); + } + + return jsonResponse(relationshipToAPI(foundRelationship)); + }, + ); diff --git a/server/api/api/v1/accounts/:id/unpin.ts b/server/api/api/v1/accounts/:id/unpin.ts new file mode 100644 index 00000000..ea365fee --- /dev/null +++ b/server/api/api/v1/accounts/:id/unpin.ts @@ -0,0 +1,66 @@ +import { applyConfig, auth, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse } from "@response"; +import { eq } from "drizzle-orm"; +import type { Hono } from "hono"; +import { z } from "zod"; +import { relationshipToAPI } from "~database/entities/Relationship"; +import { getRelationshipToOtherUser } from "~database/entities/User"; +import { db } from "~drizzle/db"; +import { Relationships } from "~drizzle/schema"; +import { User } from "~packages/database-interface/user"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 30, + duration: 60, + }, + route: "/api/v1/accounts/:id/unpin", + auth: { + required: true, + oauthPermissions: ["write:accounts"], + }, +}); + +export const schemas = { + param: z.object({ + id: z.string().uuid(), + }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("param", schemas.param, handleZodError), + auth(meta.auth), + async (context) => { + const { id } = context.req.valid("param"); + const { user: self } = context.req.valid("header"); + + if (!self) return errorResponse("Unauthorized", 401); + + const otherUser = await User.fromId(id); + + if (!otherUser) return errorResponse("User not found", 404); + + const foundRelationship = await getRelationshipToOtherUser( + self, + otherUser, + ); + + if (foundRelationship.endorsed) { + foundRelationship.endorsed = false; + + await db + .update(Relationships) + .set({ + endorsed: false, + }) + .where(eq(Relationships.id, foundRelationship.id)); + } + + return jsonResponse(relationshipToAPI(foundRelationship)); + }, + ); diff --git a/server/api/api/v1/accounts/[id]/block.ts b/server/api/api/v1/accounts/[id]/block.ts deleted file mode 100644 index f5bd2fdb..00000000 --- a/server/api/api/v1/accounts/[id]/block.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; -import { errorResponse, jsonResponse } from "@response"; -import { eq } from "drizzle-orm"; -import { relationshipToAPI } from "~database/entities/Relationship"; -import { getRelationshipToOtherUser } from "~database/entities/User"; -import { db } from "~drizzle/db"; -import { Relationships } from "~drizzle/schema"; -import { User } from "~packages/database-interface/user"; - -export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 30, - duration: 60, - }, - route: "/api/v1/accounts/:id/block", - auth: { - required: true, - oauthPermissions: ["write:blocks"], - }, -}); - -/** - * Blocks a user - */ -export default apiRoute(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; - if (!id.match(idValidator)) { - return errorResponse("Invalid ID, must be of type UUIDv7", 404); - } - - const { user: self } = extraData.auth; - - if (!self) return errorResponse("Unauthorized", 401); - - const otherUser = await User.fromId(id); - - if (!otherUser) return errorResponse("User not found", 404); - - const foundRelationship = await getRelationshipToOtherUser(self, otherUser); - - if (!foundRelationship.blocking) { - foundRelationship.blocking = true; - } - - await db - .update(Relationships) - .set({ - blocking: true, - }) - .where(eq(Relationships.id, foundRelationship.id)); - - return jsonResponse(relationshipToAPI(foundRelationship)); -}); diff --git a/server/api/api/v1/accounts/[id]/follow.ts b/server/api/api/v1/accounts/[id]/follow.ts deleted file mode 100644 index e9c44ac0..00000000 --- a/server/api/api/v1/accounts/[id]/follow.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; -import { errorResponse, jsonResponse } from "@response"; -import ISO6391 from "iso-639-1"; -import { z } from "zod"; -import { relationshipToAPI } from "~database/entities/Relationship"; -import { - followRequestUser, - getRelationshipToOtherUser, -} from "~database/entities/User"; -import { User } from "~packages/database-interface/user"; - -export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 30, - duration: 60, - }, - route: "/api/v1/accounts/:id/follow", - auth: { - required: true, - oauthPermissions: ["write:follows"], - }, -}); - -export const schema = z.object({ - reblogs: z.coerce.boolean().optional(), - notify: z.coerce.boolean().optional(), - languages: z - .array(z.enum(ISO6391.getAllCodes() as [string, ...string[]])) - .optional(), -}); - -/** - * Follow a user - */ -export default apiRoute( - async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; - if (!id.match(idValidator)) { - return errorResponse("Invalid ID, must be of type UUIDv7", 404); - } - - const { user: self } = extraData.auth; - - if (!self) return errorResponse("Unauthorized", 401); - - const { languages, notify, reblogs } = extraData.parsedRequest; - - const otherUser = await User.fromId(id); - - if (!otherUser) return errorResponse("User not found", 404); - - // Check if already following - let relationship = await getRelationshipToOtherUser(self, otherUser); - - if (!relationship.following) { - relationship = await followRequestUser( - self, - otherUser, - relationship.id, - reblogs, - notify, - languages, - ); - } - - return jsonResponse(relationshipToAPI(relationship)); - }, -); diff --git a/server/api/api/v1/accounts/[id]/followers.ts b/server/api/api/v1/accounts/[id]/followers.ts deleted file mode 100644 index b6d40f92..00000000 --- a/server/api/api/v1/accounts/[id]/followers.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; -import { errorResponse, jsonResponse } from "@response"; -import { and, gt, gte, lt, sql } from "drizzle-orm"; -import { z } from "zod"; -import { Users } from "~drizzle/schema"; -import { Timeline } from "~packages/database-interface/timeline"; -import { User } from "~packages/database-interface/user"; - -export const meta = applyConfig({ - allowedMethods: ["GET"], - ratelimits: { - max: 60, - duration: 60, - }, - route: "/api/v1/accounts/:id/followers", - auth: { - required: false, - oauthPermissions: ["read:accounts"], - }, -}); - -const schema = z.object({ - max_id: z.string().regex(idValidator).optional(), - since_id: z.string().regex(idValidator).optional(), - min_id: z.string().regex(idValidator).optional(), - limit: z.coerce.number().int().min(1).max(40).optional().default(20), -}); - -/** - * Fetch all statuses for a user - */ -export default apiRoute( - async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; - if (!id.match(idValidator)) { - return errorResponse("Invalid ID, must be of type UUIDv7", 404); - } - - const { max_id, min_id, since_id, limit } = extraData.parsedRequest; - - const otherUser = await User.fromId(id); - - if (!otherUser) return errorResponse("User not found", 404); - - const { objects, link } = await Timeline.getUserTimeline( - and( - max_id ? lt(Users.id, max_id) : undefined, - since_id ? gte(Users.id, since_id) : undefined, - min_id ? gt(Users.id, min_id) : undefined, - sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${otherUser.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."following" = true)`, - ), - limit, - req.url, - ); - - return jsonResponse( - await Promise.all(objects.map((object) => object.toAPI())), - 200, - { - Link: link, - }, - ); - }, -); diff --git a/server/api/api/v1/accounts/[id]/following.ts b/server/api/api/v1/accounts/[id]/following.ts deleted file mode 100644 index e3a6e49a..00000000 --- a/server/api/api/v1/accounts/[id]/following.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; -import { errorResponse, jsonResponse } from "@response"; -import { and, gt, gte, lt, sql } from "drizzle-orm"; -import { z } from "zod"; -import { Users } from "~drizzle/schema"; -import { Timeline } from "~packages/database-interface/timeline"; -import { User } from "~packages/database-interface/user"; - -export const meta = applyConfig({ - allowedMethods: ["GET"], - ratelimits: { - max: 60, - duration: 60, - }, - route: "/api/v1/accounts/:id/following", - auth: { - required: false, - oauthPermissions: ["read:accounts"], - }, -}); - -export const schema = z.object({ - max_id: z.string().regex(idValidator).optional(), - since_id: z.string().regex(idValidator).optional(), - min_id: z.string().regex(idValidator).optional(), - limit: z.coerce.number().int().min(1).max(40).optional().default(20), -}); - -/** - * Fetch all statuses for a user - */ -export default apiRoute( - async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; - if (!id.match(idValidator)) { - return errorResponse("Invalid ID, must be of type UUIDv7", 404); - } - - const { max_id, min_id, since_id, limit } = extraData.parsedRequest; - - const otherUser = await User.fromId(id); - - if (!otherUser) return errorResponse("User not found", 404); - - const { objects, link } = await Timeline.getUserTimeline( - and( - max_id ? lt(Users.id, max_id) : undefined, - since_id ? gte(Users.id, since_id) : undefined, - min_id ? gt(Users.id, min_id) : undefined, - sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${otherUser.id} AND "Relationships"."following" = true)`, - ), - limit, - req.url, - ); - - return jsonResponse( - await Promise.all(objects.map((object) => object.toAPI())), - 200, - { - Link: link, - }, - ); - }, -); diff --git a/server/api/api/v1/accounts/[id]/index.ts b/server/api/api/v1/accounts/[id]/index.ts deleted file mode 100644 index a8fbab97..00000000 --- a/server/api/api/v1/accounts/[id]/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; -import { errorResponse, jsonResponse } from "@response"; -import { User } from "~packages/database-interface/user"; - -export const meta = applyConfig({ - allowedMethods: ["GET"], - ratelimits: { - max: 30, - duration: 60, - }, - route: "/api/v1/accounts/:id", - auth: { - required: false, - oauthPermissions: [], - }, -}); - -/** - * Fetch a user - */ -export default apiRoute(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; - if (!id.match(idValidator)) { - return errorResponse("Invalid ID, must be of type UUIDv7", 404); - } - - const { user } = extraData.auth; - - const foundUser = await User.fromId(id); - - if (!foundUser) return errorResponse("User not found", 404); - - return jsonResponse(foundUser.toAPI(user?.id === foundUser.id)); -}); diff --git a/server/api/api/v1/accounts/[id]/mute.ts b/server/api/api/v1/accounts/[id]/mute.ts deleted file mode 100644 index 75b2f115..00000000 --- a/server/api/api/v1/accounts/[id]/mute.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; -import { errorResponse, jsonResponse } from "@response"; -import { eq } from "drizzle-orm"; -import { z } from "zod"; -import { relationshipToAPI } from "~database/entities/Relationship"; -import { getRelationshipToOtherUser } from "~database/entities/User"; -import { db } from "~drizzle/db"; -import { Relationships } from "~drizzle/schema"; -import { User } from "~packages/database-interface/user"; - -export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 30, - duration: 60, - }, - route: "/api/v1/accounts/:id/mute", - auth: { - required: true, - oauthPermissions: ["write:mutes"], - }, -}); - -export const schema = z.object({ - notifications: z.coerce.boolean().optional(), - duration: z - .number() - .int() - .min(60) - .max(60 * 60 * 24 * 365 * 5) - .optional(), -}); - -/** - * Mute a user - */ -export default apiRoute( - async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; - if (!id.match(idValidator)) { - return errorResponse("Invalid ID, must be of type UUIDv7", 404); - } - - const { user: self } = extraData.auth; - - if (!self) return errorResponse("Unauthorized", 401); - - const { notifications, duration } = extraData.parsedRequest; - - const user = await User.fromId(id); - - if (!user) return errorResponse("User not found", 404); - - // Check if already following - const foundRelationship = await getRelationshipToOtherUser(self, user); - - if (!foundRelationship.muting) { - foundRelationship.muting = true; - } - if (notifications ?? true) { - foundRelationship.mutingNotifications = true; - } - - await db - .update(Relationships) - .set({ - muting: true, - mutingNotifications: notifications ?? true, - }) - .where(eq(Relationships.id, foundRelationship.id)); - - // TODO: Implement duration - - return jsonResponse(relationshipToAPI(foundRelationship)); - }, -); diff --git a/server/api/api/v1/accounts/[id]/note.ts b/server/api/api/v1/accounts/[id]/note.ts deleted file mode 100644 index e5f942b2..00000000 --- a/server/api/api/v1/accounts/[id]/note.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; -import { errorResponse, jsonResponse } from "@response"; -import { eq } from "drizzle-orm"; -import { z } from "zod"; -import { relationshipToAPI } from "~database/entities/Relationship"; -import { getRelationshipToOtherUser } from "~database/entities/User"; -import { db } from "~drizzle/db"; -import { Relationships } from "~drizzle/schema"; -import { User } from "~packages/database-interface/user"; - -export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 30, - duration: 60, - }, - route: "/api/v1/accounts/:id/note", - auth: { - required: true, - oauthPermissions: ["write:accounts"], - }, -}); - -export const schema = z.object({ - comment: z.string().min(0).max(5000).trim().optional(), -}); - -/** - * Sets a user note - */ -export default apiRoute( - async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; - if (!id.match(idValidator)) { - return errorResponse("Invalid ID, must be of type UUIDv7", 404); - } - - const { user: self } = extraData.auth; - - if (!self) return errorResponse("Unauthorized", 401); - - const { comment } = extraData.parsedRequest; - - const otherUser = await User.fromId(id); - - if (!otherUser) return errorResponse("User not found", 404); - - // Check if already following - const foundRelationship = await getRelationshipToOtherUser( - self, - otherUser, - ); - - foundRelationship.note = comment ?? ""; - - await db - .update(Relationships) - .set({ - note: foundRelationship.note, - }) - .where(eq(Relationships.id, foundRelationship.id)); - - return jsonResponse(relationshipToAPI(foundRelationship)); - }, -); diff --git a/server/api/api/v1/accounts/[id]/pin.ts b/server/api/api/v1/accounts/[id]/pin.ts deleted file mode 100644 index 3c15d21f..00000000 --- a/server/api/api/v1/accounts/[id]/pin.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; -import { errorResponse, jsonResponse } from "@response"; -import { eq } from "drizzle-orm"; -import { relationshipToAPI } from "~database/entities/Relationship"; -import { getRelationshipToOtherUser } from "~database/entities/User"; -import { db } from "~drizzle/db"; -import { Relationships } from "~drizzle/schema"; -import { User } from "~packages/database-interface/user"; - -export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 30, - duration: 60, - }, - route: "/api/v1/accounts/:id/pin", - auth: { - required: true, - oauthPermissions: ["write:accounts"], - }, -}); - -/** - * Pin a user - */ -export default apiRoute(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; - if (!id.match(idValidator)) { - return errorResponse("Invalid ID, must be of type UUIDv7", 404); - } - - const { user: self } = extraData.auth; - - if (!self) return errorResponse("Unauthorized", 401); - - const otherUser = await User.fromId(id); - - if (!otherUser) return errorResponse("User not found", 404); - - // Check if already following - const foundRelationship = await getRelationshipToOtherUser(self, otherUser); - - if (!foundRelationship.endorsed) { - foundRelationship.endorsed = true; - - await db - .update(Relationships) - .set({ - endorsed: true, - }) - .where(eq(Relationships.id, foundRelationship.id)); - } - - return jsonResponse(relationshipToAPI(foundRelationship)); -}); diff --git a/server/api/api/v1/accounts/[id]/remove_from_followers.ts b/server/api/api/v1/accounts/[id]/remove_from_followers.ts deleted file mode 100644 index 884d4dca..00000000 --- a/server/api/api/v1/accounts/[id]/remove_from_followers.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; -import { errorResponse, jsonResponse } from "@response"; -import { and, eq } from "drizzle-orm"; -import { relationshipToAPI } from "~database/entities/Relationship"; -import { getRelationshipToOtherUser } from "~database/entities/User"; -import { db } from "~drizzle/db"; -import { Relationships } from "~drizzle/schema"; -import { User } from "~packages/database-interface/user"; - -export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 30, - duration: 60, - }, - route: "/api/v1/accounts/:id/remove_from_followers", - auth: { - required: true, - oauthPermissions: ["write:follows"], - }, -}); - -/** - * Removes an account from your followers list - */ -export default apiRoute(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; - if (!id.match(idValidator)) { - return errorResponse("Invalid ID, must be of type UUIDv7", 404); - } - - const { user: self } = extraData.auth; - - if (!self) return errorResponse("Unauthorized", 401); - - const otherUser = await User.fromId(id); - - if (!otherUser) return errorResponse("User not found", 404); - - // Check if already following - const foundRelationship = await getRelationshipToOtherUser(self, otherUser); - - if (foundRelationship.followedBy) { - foundRelationship.followedBy = false; - - await db - .update(Relationships) - .set({ - followedBy: false, - }) - .where(eq(Relationships.id, foundRelationship.id)); - - if (otherUser.isLocal()) { - // Also remove from followers list - await db - .update(Relationships) - .set({ - following: false, - }) - .where( - and( - eq(Relationships.ownerId, otherUser.id), - eq(Relationships.subjectId, self.id), - ), - ); - } - } - - return jsonResponse(relationshipToAPI(foundRelationship)); -}); diff --git a/server/api/api/v1/accounts/[id]/statuses.ts b/server/api/api/v1/accounts/[id]/statuses.ts deleted file mode 100644 index 52c6fed6..00000000 --- a/server/api/api/v1/accounts/[id]/statuses.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; -import { errorResponse, jsonResponse } from "@response"; -import { and, eq, gt, gte, isNull, lt, sql } from "drizzle-orm"; -import { z } from "zod"; -import { Notes } from "~drizzle/schema"; -import { Timeline } from "~packages/database-interface/timeline"; -import { User } from "~packages/database-interface/user"; - -export const meta = applyConfig({ - allowedMethods: ["GET"], - ratelimits: { - max: 30, - duration: 60, - }, - route: "/api/v1/accounts/:id/statuses", - auth: { - required: false, - oauthPermissions: ["read:statuses"], - }, -}); - -export const schema = z.object({ - max_id: z.string().regex(idValidator).optional(), - since_id: z.string().regex(idValidator).optional(), - min_id: z.string().regex(idValidator).optional(), - limit: z.coerce.number().int().min(1).max(40).optional().default(20), - only_media: z.coerce.boolean().optional(), - exclude_replies: z.coerce.boolean().optional(), - exclude_reblogs: z.coerce.boolean().optional(), - pinned: z.coerce.boolean().optional(), - tagged: z.string().optional(), -}); - -/** - * Fetch all statuses for a user - */ -export default apiRoute( - async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; - if (!id.match(idValidator)) { - return errorResponse("Invalid ID, must be of type UUIDv7", 404); - } - - const { - max_id, - min_id, - since_id, - limit, - exclude_reblogs, - only_media, - exclude_replies, - pinned, - } = extraData.parsedRequest; - - const user = await User.fromId(id); - - if (!user) return errorResponse("User not found", 404); - - const { objects, link } = await Timeline.getNoteTimeline( - and( - max_id ? lt(Notes.id, max_id) : undefined, - since_id ? gte(Notes.id, since_id) : undefined, - min_id ? gt(Notes.id, min_id) : undefined, - eq(Notes.authorId, id), - only_media - ? sql`EXISTS (SELECT 1 FROM "Attachments" WHERE "Attachments"."noteId" = ${Notes.id})` - : undefined, - pinned - ? sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."noteId" = ${Notes.id} AND "UserToPinnedNotes"."userId" = ${user.id})` - : undefined, - exclude_reblogs ? isNull(Notes.reblogId) : undefined, - exclude_replies ? isNull(Notes.replyId) : undefined, - ), - limit, - req.url, - ); - - return jsonResponse( - await Promise.all(objects.map((note) => note.toAPI(user))), - 200, - { - Link: link, - }, - ); - }, -); diff --git a/server/api/api/v1/accounts/[id]/unblock.ts b/server/api/api/v1/accounts/[id]/unblock.ts deleted file mode 100644 index 9a21ae3a..00000000 --- a/server/api/api/v1/accounts/[id]/unblock.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; -import { errorResponse, jsonResponse } from "@response"; -import { eq } from "drizzle-orm"; -import { relationshipToAPI } from "~database/entities/Relationship"; -import { getRelationshipToOtherUser } from "~database/entities/User"; -import { db } from "~drizzle/db"; -import { Relationships } from "~drizzle/schema"; -import { User } from "~packages/database-interface/user"; - -export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 30, - duration: 60, - }, - route: "/api/v1/accounts/:id/unblock", - auth: { - required: true, - oauthPermissions: ["write:blocks"], - }, -}); - -export default apiRoute(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; - if (!id.match(idValidator)) { - return errorResponse("Invalid ID, must be of type UUIDv7", 404); - } - - const { user: self } = extraData.auth; - - if (!self) return errorResponse("Unauthorized", 401); - - const otherUser = await User.fromId(id); - - if (!otherUser) return errorResponse("User not found", 404); - - const foundRelationship = await getRelationshipToOtherUser(self, otherUser); - - if (foundRelationship.blocking) { - foundRelationship.blocking = false; - - await db - .update(Relationships) - .set({ - blocking: false, - }) - .where(eq(Relationships.id, foundRelationship.id)); - } - - return jsonResponse(relationshipToAPI(foundRelationship)); -}); diff --git a/server/api/api/v1/accounts/[id]/unfollow.ts b/server/api/api/v1/accounts/[id]/unfollow.ts deleted file mode 100644 index 83c9999c..00000000 --- a/server/api/api/v1/accounts/[id]/unfollow.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; -import { errorResponse, jsonResponse } from "@response"; -import { eq } from "drizzle-orm"; -import { relationshipToAPI } from "~database/entities/Relationship"; -import { getRelationshipToOtherUser } from "~database/entities/User"; -import { db } from "~drizzle/db"; -import { Relationships } from "~drizzle/schema"; -import { User } from "~packages/database-interface/user"; - -export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 30, - duration: 60, - }, - route: "/api/v1/accounts/:id/unfollow", - auth: { - required: true, - oauthPermissions: ["write:follows"], - }, -}); - -/** - * Unfollows a user - */ -export default apiRoute(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; - if (!id.match(idValidator)) { - return errorResponse("Invalid ID, must be of type UUIDv7", 404); - } - - const { user: self } = extraData.auth; - - if (!self) return errorResponse("Unauthorized", 401); - - const otherUser = await User.fromId(id); - - if (!otherUser) return errorResponse("User not found", 404); - - // Check if already following - const foundRelationship = await getRelationshipToOtherUser(self, otherUser); - - if (foundRelationship.following) { - foundRelationship.following = false; - - await db - .update(Relationships) - .set({ - following: false, - requested: false, - }) - .where(eq(Relationships.id, foundRelationship.id)); - } - - return jsonResponse(relationshipToAPI(foundRelationship)); -}); diff --git a/server/api/api/v1/accounts/[id]/unmute.ts b/server/api/api/v1/accounts/[id]/unmute.ts deleted file mode 100644 index 47b625d0..00000000 --- a/server/api/api/v1/accounts/[id]/unmute.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; -import { errorResponse, jsonResponse } from "@response"; -import { eq } from "drizzle-orm"; -import { relationshipToAPI } from "~database/entities/Relationship"; -import { getRelationshipToOtherUser } from "~database/entities/User"; -import { db } from "~drizzle/db"; -import { Relationships } from "~drizzle/schema"; -import { User } from "~packages/database-interface/user"; - -export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 30, - duration: 60, - }, - route: "/api/v1/accounts/:id/unmute", - auth: { - required: true, - oauthPermissions: ["write:mutes"], - }, -}); - -/** - * Unmute a user - */ -export default apiRoute(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; - if (!id.match(idValidator)) { - return errorResponse("Invalid ID, must be of type UUIDv7", 404); - } - - const { user: self } = extraData.auth; - - if (!self) return errorResponse("Unauthorized", 401); - - const user = await User.fromId(id); - - if (!user) return errorResponse("User not found", 404); - - // Check if already following - const foundRelationship = await getRelationshipToOtherUser(self, user); - - if (foundRelationship.muting) { - foundRelationship.muting = false; - foundRelationship.mutingNotifications = false; - - await db - .update(Relationships) - .set({ - muting: false, - mutingNotifications: false, - }) - .where(eq(Relationships.id, foundRelationship.id)); - } - - return jsonResponse(relationshipToAPI(foundRelationship)); -}); diff --git a/server/api/api/v1/accounts/[id]/unpin.ts b/server/api/api/v1/accounts/[id]/unpin.ts deleted file mode 100644 index b0794f92..00000000 --- a/server/api/api/v1/accounts/[id]/unpin.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; -import { errorResponse, jsonResponse } from "@response"; -import { eq } from "drizzle-orm"; -import { relationshipToAPI } from "~database/entities/Relationship"; -import { getRelationshipToOtherUser } from "~database/entities/User"; -import { db } from "~drizzle/db"; -import { Relationships } from "~drizzle/schema"; -import { User } from "~packages/database-interface/user"; - -export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 30, - duration: 60, - }, - route: "/api/v1/accounts/:id/unpin", - auth: { - required: true, - oauthPermissions: ["write:accounts"], - }, -}); - -/** - * Unpin a user - */ -export default apiRoute(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; - if (!id.match(idValidator)) { - return errorResponse("Invalid ID, must be of type UUIDv7", 404); - } - - const { user: self } = extraData.auth; - - if (!self) return errorResponse("Unauthorized", 401); - - const otherUser = await User.fromId(id); - - if (!otherUser) return errorResponse("User not found", 404); - - // Check if already following - const foundRelationship = await getRelationshipToOtherUser(self, otherUser); - - if (foundRelationship.endorsed) { - foundRelationship.endorsed = false; - - await db - .update(Relationships) - .set({ - endorsed: false, - }) - .where(eq(Relationships.id, foundRelationship.id)); - } - - return jsonResponse(relationshipToAPI(foundRelationship)); -}); diff --git a/server/api/api/v1/accounts/familiar_followers/index.ts b/server/api/api/v1/accounts/familiar_followers/index.ts index 6bc65dcd..3dec4c43 100644 --- a/server/api/api/v1/accounts/familiar_followers/index.ts +++ b/server/api/api/v1/accounts/familiar_followers/index.ts @@ -1,6 +1,8 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; +import { applyConfig, auth, handleZodError, idValidator } from "@api"; +import { zValidator } from "@hono/zod-validator"; import { errorResponse, jsonResponse } from "@response"; import { inArray } from "drizzle-orm"; +import type { Hono } from "hono"; import { z } from "zod"; import { db } from "~drizzle/db"; import { Users } from "~drizzle/schema"; @@ -19,63 +21,69 @@ export const meta = applyConfig({ }, }); -export const schema = z.object({ - id: z.array(z.string().regex(idValidator)).min(1).max(10), -}); +export const schemas = { + query: z.object({ + "id[]": z.array(z.string().uuid()).min(1).max(10), + }), +}; -/** - * Find familiar followers (followers of a user that you also follow) - */ -export default apiRoute( - async (req, matchedRoute, extraData) => { - const { user: self } = extraData.auth; +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("query", schemas.query, handleZodError), + auth(meta.auth), + async (context) => { + const { user: self } = context.req.valid("header"); + const { "id[]": ids } = context.req.valid("query"); - if (!self) return errorResponse("Unauthorized", 401); + if (!self) return errorResponse("Unauthorized", 401); - const { id: ids } = extraData.parsedRequest; + const idFollowerRelationships = + await db.query.Relationships.findMany({ + columns: { + ownerId: true, + }, + where: (relationship, { inArray, and, eq }) => + and( + inArray(relationship.subjectId, ids), + eq(relationship.following, true), + ), + }); - const idFollowerRelationships = await db.query.Relationships.findMany({ - columns: { - ownerId: true, - }, - where: (relationship, { inArray, and, eq }) => - and( - inArray(relationship.subjectId, ids), - eq(relationship.following, true), + if (idFollowerRelationships.length === 0) { + return jsonResponse([]); + } + + // Find users that you follow in idFollowerRelationships + const relevantRelationships = await db.query.Relationships.findMany( + { + columns: { + subjectId: true, + }, + where: (relationship, { inArray, and, eq }) => + and( + eq(relationship.ownerId, self.id), + inArray( + relationship.subjectId, + idFollowerRelationships.map((f) => f.ownerId), + ), + eq(relationship.following, true), + ), + }, + ); + + if (relevantRelationships.length === 0) { + return jsonResponse([]); + } + + const finalUsers = await User.manyFromSql( + inArray( + Users.id, + relevantRelationships.map((r) => r.subjectId), ), - }); + ); - if (idFollowerRelationships.length === 0) { - return jsonResponse([]); - } - - // Find users that you follow in idFollowerRelationships - const relevantRelationships = await db.query.Relationships.findMany({ - columns: { - subjectId: true, - }, - where: (relationship, { inArray, and, eq }) => - and( - eq(relationship.ownerId, self.id), - inArray( - relationship.subjectId, - idFollowerRelationships.map((f) => f.ownerId), - ), - eq(relationship.following, true), - ), - }); - - if (relevantRelationships.length === 0) { - return jsonResponse([]); - } - - const finalUsers = await User.manyFromSql( - inArray( - Users.id, - relevantRelationships.map((r) => r.subjectId), - ), - ); - - return jsonResponse(finalUsers.map((o) => o.toAPI())); - }, -); + return jsonResponse(finalUsers.map((o) => o.toAPI())); + }, + ); diff --git a/server/api/api/v1/accounts/index.ts b/server/api/api/v1/accounts/index.ts index 7977cfe8..c8506393 100644 --- a/server/api/api/v1/accounts/index.ts +++ b/server/api/api/v1/accounts/index.ts @@ -1,10 +1,13 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, auth, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; import { jsonResponse, response } from "@response"; import { tempmailDomains } from "@tempmail"; import { eq } from "drizzle-orm"; +import type { Hono } from "hono"; import ISO6391 from "iso-639-1"; import { z } from "zod"; import { Users } from "~drizzle/schema"; +import { config } from "~packages/config-manager"; import { User } from "~packages/database-interface/user"; export const meta = applyConfig({ @@ -20,210 +23,217 @@ export const meta = applyConfig({ }, }); -// No validation on the Zod side as we need to do custom validation -export const schema = z.object({ - username: z.string().toLowerCase(), - email: z.string().toLowerCase(), - password: z.string(), - agreement: z.boolean(), - locale: z.string(), - reason: z.string(), -}); +export const schemas = { + form: z.object({ + username: z.string(), + email: z.string(), + password: z.string(), + agreement: z + .string() + .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())), + locale: z.string(), + reason: z.string(), + }), +}; -export default apiRoute( - async (req, matchedRoute, extraData) => { - // TODO: Add Authorization check +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("form", schemas.form, handleZodError), + auth(meta.auth), + async (context) => { + const { username, email, password, agreement, locale, reason } = + context.req.valid("form"); - const body = extraData.parsedRequest; + if (!config.signups.registration) { + return jsonResponse( + { + error: "Registration is disabled", + }, + 422, + ); + } - const config = await extraData.configManager.getConfig(); - - if (!config.signups.registration) { - return jsonResponse( - { - error: "Registration is disabled", + const errors: { + details: Record< + string, + { + error: + | "ERR_BLANK" + | "ERR_INVALID" + | "ERR_TOO_LONG" + | "ERR_TOO_SHORT" + | "ERR_BLOCKED" + | "ERR_TAKEN" + | "ERR_RESERVED" + | "ERR_ACCEPTED" + | "ERR_INCLUSION"; + description: string; + }[] + >; + } = { + details: { + password: [], + username: [], + email: [], + agreement: [], + locale: [], + reason: [], }, - 422, - ); - } + }; - const errors: { - details: Record< - string, - { - error: - | "ERR_BLANK" - | "ERR_INVALID" - | "ERR_TOO_LONG" - | "ERR_TOO_SHORT" - | "ERR_BLOCKED" - | "ERR_TAKEN" - | "ERR_RESERVED" - | "ERR_ACCEPTED" - | "ERR_INCLUSION"; - description: string; - }[] - >; - } = { - details: { - password: [], - username: [], - email: [], - agreement: [], - locale: [], - reason: [], - }, - }; + // Check if fields are blank + for (const value of [ + "username", + "email", + "password", + "agreement", + "locale", + "reason", + ]) { + // @ts-expect-error We don't care about typing here + if (!parsedRequest[value]) { + errors.details[value].push({ + error: "ERR_BLANK", + description: `can't be blank`, + }); + } + } - // Check if fields are blank - for (const value of [ - "username", - "email", - "password", - "agreement", - "locale", - "reason", - ]) { - // @ts-expect-error We don't care about typing here - if (!body[value]) { - errors.details[value].push({ + // Check if username is valid + if (!username?.match(/^[a-z0-9_]+$/)) + errors.details.username.push({ + error: "ERR_INVALID", + description: + "must only contain lowercase letters, numbers, and underscores", + }); + + // Check if username doesnt match filters + if ( + config.filters.username.some((filter) => + username?.match(filter), + ) + ) { + errors.details.username.push({ + error: "ERR_INVALID", + description: "contains blocked words", + }); + } + + // Check if username is too long + if ((username?.length ?? 0) > config.validation.max_username_size) + errors.details.username.push({ + error: "ERR_TOO_LONG", + description: `is too long (maximum is ${config.validation.max_username_size} characters)`, + }); + + // Check if username is too short + if ((username?.length ?? 0) < 3) + errors.details.username.push({ + error: "ERR_TOO_SHORT", + description: "is too short (minimum is 3 characters)", + }); + + // Check if username is reserved + if (config.validation.username_blacklist.includes(username ?? "")) + errors.details.username.push({ + error: "ERR_RESERVED", + description: "is reserved", + }); + + // Check if username is taken + if (await User.fromSql(eq(Users.username, username))) { + errors.details.username.push({ + error: "ERR_TAKEN", + description: "is already taken", + }); + } + + // Check if email is valid + if ( + !email?.match( + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, + ) + ) + errors.details.email.push({ + error: "ERR_INVALID", + description: "must be a valid email address", + }); + + // Check if email is blocked + if ( + config.validation.email_blacklist.includes(email) || + (config.validation.blacklist_tempmail && + tempmailDomains.domains.includes( + (email ?? "").split("@")[1], + )) + ) + errors.details.email.push({ + error: "ERR_BLOCKED", + description: "is from a blocked email provider", + }); + + // Check if email is taken + if (await User.fromSql(eq(Users.email, email))) + errors.details.email.push({ + error: "ERR_TAKEN", + description: "is already taken", + }); + + // Check if agreement is accepted + if (!agreement) + errors.details.agreement.push({ + error: "ERR_ACCEPTED", + description: "must be accepted", + }); + + if (!locale) + errors.details.locale.push({ error: "ERR_BLANK", description: `can't be blank`, }); - } - } - // Check if username is valid - if (!body.username?.match(/^[a-z0-9_]+$/)) - errors.details.username.push({ - error: "ERR_INVALID", - description: - "must only contain lowercase letters, numbers, and underscores", - }); + if (!ISO6391.validate(locale ?? "")) + errors.details.locale.push({ + error: "ERR_INVALID", + description: "must be a valid ISO 639-1 code", + }); - // Check if username doesnt match filters - if ( - config.filters.username.some((filter) => - body.username?.match(filter), - ) - ) { - errors.details.username.push({ - error: "ERR_INVALID", - description: "contains blocked words", - }); - } + // 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" - // Check if username is too long - if ((body.username?.length ?? 0) > config.validation.max_username_size) - errors.details.username.push({ - error: "ERR_TOO_LONG", - description: `is too long (maximum is ${config.validation.max_username_size} characters)`, - }); - - // Check if username is too short - if ((body.username?.length ?? 0) < 3) - errors.details.username.push({ - error: "ERR_TOO_SHORT", - description: "is too short (minimum is 3 characters)", - }); - - // Check if username is reserved - if (config.validation.username_blacklist.includes(body.username ?? "")) - errors.details.username.push({ - error: "ERR_RESERVED", - description: "is reserved", - }); - - // Check if username is taken - if (await User.fromSql(eq(Users.username, body.username))) { - errors.details.username.push({ - error: "ERR_TAKEN", - description: "is already taken", - }); - } - - // Check if email is valid - if ( - !body.email?.match( - /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, - ) - ) - errors.details.email.push({ - error: "ERR_INVALID", - description: "must be a valid email address", - }); - - // Check if email is blocked - if ( - config.validation.email_blacklist.includes(body.email ?? "") || - (config.validation.blacklist_tempmail && - tempmailDomains.domains.includes( - (body.email ?? "").split("@")[1], - )) - ) - errors.details.email.push({ - error: "ERR_BLOCKED", - description: "is from a blocked email provider", - }); - - // Check if email is taken - if (await User.fromSql(eq(Users.email, body.email))) - errors.details.email.push({ - error: "ERR_TAKEN", - description: "is already taken", - }); - - // Check if agreement is accepted - if (!body.agreement) - errors.details.agreement.push({ - error: "ERR_ACCEPTED", - 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" - - const errorsText = Object.entries(errors.details) - .filter(([_, errors]) => errors.length > 0) - .map( - ([name, errors]) => - `${name} ${errors - .map((error) => error.description) - .join(", ")}`, - ) - .join(", "); - return jsonResponse( - { - error: `Validation failed: ${errorsText}`, - details: Object.fromEntries( - Object.entries(errors.details).filter( - ([_, errors]) => errors.length > 0, + const errorsText = Object.entries(errors.details) + .filter(([_, errors]) => errors.length > 0) + .map( + ([name, errors]) => + `${name} ${errors + .map((error) => error.description) + .join(", ")}`, + ) + .join(", "); + return jsonResponse( + { + error: `Validation failed: ${errorsText}`, + details: Object.fromEntries( + Object.entries(errors.details).filter( + ([_, errors]) => errors.length > 0, + ), ), - ), - }, - 422, - ); - } + }, + 422, + ); + } - await User.fromDataLocal({ - username: body.username ?? "", - password: body.password ?? "", - email: body.email ?? "", - }); + await User.fromDataLocal({ + username: username ?? "", + password: password ?? "", + email: email ?? "", + }); - return response(null, 200); - }, -); + return response(null, 200); + }, + ); diff --git a/server/api/api/v1/accounts/lookup/index.ts b/server/api/api/v1/accounts/lookup/index.ts index 58660562..00e8b40d 100644 --- a/server/api/api/v1/accounts/lookup/index.ts +++ b/server/api/api/v1/accounts/lookup/index.ts @@ -1,7 +1,9 @@ -import { apiRoute, applyConfig } from "@api"; +import { applyConfig, auth, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; import { dualLogger } from "@loggers"; import { errorResponse, jsonResponse } from "@response"; import { eq } from "drizzle-orm"; +import type { Hono } from "hono"; import { anyOf, charIn, @@ -32,73 +34,81 @@ export const meta = applyConfig({ }, }); -export const schema = z.object({ - acct: z.string().min(1).max(512), -}); +export const schemas = { + query: z.object({ + acct: z.string().min(1).max(512), + }), +}; -export default apiRoute( - async (req, matchedRoute, extraData) => { - const { acct } = extraData.parsedRequest; +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("query", schemas.query, handleZodError), + auth(meta.auth), + async (context) => { + const { acct } = context.req.valid("query"); - if (!acct) { - return errorResponse("Invalid acct parameter", 400); - } - - // Check if acct is matching format username@domain.com or @username@domain.com - const accountMatches = acct?.trim().match( - createRegExp( - maybe("@"), - oneOrMore( - anyOf(letter.lowercase, digit, charIn("-")), - ).groupedAs("username"), - exactly("@"), - oneOrMore(anyOf(letter, digit, charIn("_-.:"))).groupedAs( - "domain", - ), - - [global], - ), - ); - - if (accountMatches) { - // Remove leading @ if it exists - if (accountMatches[0].startsWith("@")) { - accountMatches[0] = accountMatches[0].slice(1); + if (!acct) { + return errorResponse("Invalid acct parameter", 400); } - const [username, domain] = accountMatches[0].split("@"); - const foundAccount = await resolveWebFinger(username, domain).catch( - (e) => { + // Check if acct is matching format username@domain.com or @username@domain.com + const accountMatches = acct?.trim().match( + createRegExp( + maybe("@"), + oneOrMore( + anyOf(letter.lowercase, digit, charIn("-")), + ).groupedAs("username"), + exactly("@"), + oneOrMore(anyOf(letter, digit, charIn("_-.:"))).groupedAs( + "domain", + ), + + [global], + ), + ); + + if (accountMatches) { + // Remove leading @ if it exists + if (accountMatches[0].startsWith("@")) { + accountMatches[0] = accountMatches[0].slice(1); + } + + const [username, domain] = accountMatches[0].split("@"); + const foundAccount = await resolveWebFinger( + username, + domain, + ).catch((e) => { dualLogger.logError( LogLevel.ERROR, "WebFinger.Resolve", e as Error, ); return null; - }, - ); + }); - if (foundAccount) { - return jsonResponse(foundAccount.toAPI()); + if (foundAccount) { + return jsonResponse(foundAccount.toAPI()); + } + + return errorResponse("Account not found", 404); } - return errorResponse("Account not found", 404); - } + let username = acct; + if (username.startsWith("@")) { + username = username.slice(1); + } - let username = acct; - if (username.startsWith("@")) { - username = username.slice(1); - } + const account = await User.fromSql(eq(Users.username, username)); - const account = await User.fromSql(eq(Users.username, username)); + if (account) { + return jsonResponse(account.toAPI()); + } - if (account) { - return jsonResponse(account.toAPI()); - } - - return errorResponse( - `Account with username ${username} not found`, - 404, - ); - }, -); + return errorResponse( + `Account with username ${username} not found`, + 404, + ); + }, + ); diff --git a/server/api/api/v1/accounts/relationships/index.ts b/server/api/api/v1/accounts/relationships/index.ts index 760910a4..71a06029 100644 --- a/server/api/api/v1/accounts/relationships/index.ts +++ b/server/api/api/v1/accounts/relationships/index.ts @@ -1,12 +1,14 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; +import { applyConfig, auth, handleZodError, idValidator } from "@api"; +import { zValidator } from "@hono/zod-validator"; import { errorResponse, jsonResponse } from "@response"; +import type { Hono } from "hono"; import { z } from "zod"; import { createNewRelationship, relationshipToAPI, } from "~database/entities/Relationship"; -import type { UserType } from "~database/entities/User"; import { db } from "~drizzle/db"; +import { User } from "~packages/database-interface/user"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -21,48 +23,48 @@ export const meta = applyConfig({ }, }); -export const schema = z.object({ - id: z.array(z.string().regex(idValidator)).min(1).max(10), -}); +export const schemas = { + query: z.object({ + "id[]": z.array(z.string().uuid()).min(1).max(10), + }), +}; -/** - * Find relationships - */ -export default apiRoute( - async (req, matchedRoute, extraData) => { - const { user: self } = extraData.auth; +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("query", schemas.query, handleZodError), + auth(meta.auth), + async (context) => { + const { user: self } = context.req.valid("header"); + const { "id[]": ids } = context.req.valid("query"); - if (!self) return errorResponse("Unauthorized", 401); + if (!self) return errorResponse("Unauthorized", 401); - const { id: ids } = extraData.parsedRequest; + const relationships = await db.query.Relationships.findMany({ + where: (relationship, { inArray, and, eq }) => + and( + inArray(relationship.subjectId, ids), + eq(relationship.ownerId, self.id), + ), + }); - const relationships = await db.query.Relationships.findMany({ - where: (relationship, { inArray, and, eq }) => - and( - inArray(relationship.subjectId, ids), - eq(relationship.ownerId, self.id), - ), - }); + const missingIds = ids.filter( + (id) => !relationships.some((r) => r.subjectId === id), + ); - // Find IDs that dont have a relationship - const missingIds = ids.filter( - (id) => !relationships.some((r) => r.subjectId === id), - ); + for (const id of missingIds) { + const user = await User.fromId(id); + if (!user) continue; + const relationship = await createNewRelationship(self, user); - // Create the missing relationships - for (const id of missingIds) { - const relationship = await createNewRelationship(self, { - id, - } as UserType); + relationships.push(relationship); + } - relationships.push(relationship); - } + relationships.sort( + (a, b) => ids.indexOf(a.subjectId) - ids.indexOf(b.subjectId), + ); - // Order in the same order as ids - relationships.sort( - (a, b) => ids.indexOf(a.subjectId) - ids.indexOf(b.subjectId), - ); - - return jsonResponse(relationships.map((r) => relationshipToAPI(r))); - }, -); + return jsonResponse(relationships.map((r) => relationshipToAPI(r))); + }, + ); diff --git a/server/api/api/v1/accounts/search/index.ts b/server/api/api/v1/accounts/search/index.ts index b0ca3614..34da5973 100644 --- a/server/api/api/v1/accounts/search/index.ts +++ b/server/api/api/v1/accounts/search/index.ts @@ -1,6 +1,8 @@ -import { apiRoute, applyConfig } from "@api"; +import { applyConfig, auth, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; import { errorResponse, jsonResponse } from "@response"; import { eq, like, not, or, sql } from "drizzle-orm"; +import type { Hono } from "hono"; import { anyOf, charIn, @@ -31,87 +33,90 @@ export const meta = applyConfig({ }, }); -export const schema = z.object({ - q: z - .string() - .min(1) - .max(512) - .regex( - createRegExp( - maybe("@"), - oneOrMore( - anyOf(letter.lowercase, digit, charIn("-")), - ).groupedAs("username"), - maybe( - exactly("@"), - oneOrMore(anyOf(letter, digit, charIn("_-.:"))).groupedAs( - "domain", +export const schemas = { + query: z.object({ + q: z + .string() + .min(1) + .max(512) + .regex( + createRegExp( + maybe("@"), + oneOrMore( + anyOf(letter.lowercase, digit, charIn("-")), + ).groupedAs("username"), + maybe( + exactly("@"), + oneOrMore( + anyOf(letter, digit, charIn("_-.:")), + ).groupedAs("domain"), ), + [global], ), - [global], ), - ), - limit: z.coerce.number().int().min(1).max(80).default(40), - offset: z.coerce.number().int().optional(), - resolve: z.coerce.boolean().optional(), - following: z.coerce.boolean().optional(), -}); + limit: z.coerce.number().int().min(1).max(80).default(40), + offset: z.coerce.number().int().optional(), + resolve: z + .string() + .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) + .optional(), + following: z + .string() + .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) + .optional(), + }), +}; -export default apiRoute( - async (req, matchedRoute, extraData) => { - // TODO: Add checks for disabled or not email verified accounts - const { - following = false, - limit, - offset, - resolve, - q, - } = extraData.parsedRequest; +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("query", schemas.query, handleZodError), + auth(meta.auth), + async (context) => { + const { q, limit, offset, resolve, following } = + context.req.valid("query"); + const { user: self } = context.req.valid("header"); - const { user: self } = extraData.auth; + if (!self && following) return errorResponse("Unauthorized", 401); - if (!self && following) return errorResponse("Unauthorized", 401); + const [username, host] = q.replace(/^@/, "").split("@"); - // Remove any leading @ - const [username, host] = q.replace(/^@/, "").split("@"); + const accounts: User[] = []; - const accounts: User[] = []; + if (resolve && username && host) { + const resolvedUser = await resolveWebFinger(username, host); - if (resolve && username && host) { - const resolvedUser = await resolveWebFinger(username, host); - - if (resolvedUser) { - accounts.push(resolvedUser); + if (resolvedUser) { + accounts.push(resolvedUser); + } + } else { + accounts.push( + ...(await User.manyFromSql( + or( + like(Users.displayName, `%${q}%`), + like(Users.username, `%${q}%`), + following && self + ? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${self.id} AND "Relationships"."following" = true)` + : undefined, + self ? not(eq(Users.id, self.id)) : undefined, + ), + undefined, + limit, + offset, + )), + ); } - } else { - accounts.push( - ...(await User.manyFromSql( - or( - like(Users.displayName, `%${q}%`), - like(Users.username, `%${q}%`), - following && self - ? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${self.id} AND "Relationships"."following" = true)` - : undefined, - self ? not(eq(Users.id, self.id)) : undefined, - ), - undefined, - limit, - offset, - )), - ); - } - // Sort accounts by closest match - // Returns array of numbers (indexes of accounts array) - const indexOfCorrectSort = stringComparison.jaccardIndex - .sortMatch( - q, - accounts.map((acct) => acct.getAcct()), - ) - .map((sort) => sort.index); + const indexOfCorrectSort = stringComparison.jaccardIndex + .sortMatch( + q, + accounts.map((acct) => acct.getAcct()), + ) + .map((sort) => sort.index); - const result = indexOfCorrectSort.map((index) => accounts[index]); + const result = indexOfCorrectSort.map((index) => accounts[index]); - return jsonResponse(result.map((acct) => acct.toAPI())); - }, -); + return jsonResponse(result.map((acct) => acct.toAPI())); + }, + ); diff --git a/server/api/api/v1/accounts/update_credentials/index.ts b/server/api/api/v1/accounts/update_credentials/index.ts index cdb000ed..4053b11c 100644 --- a/server/api/api/v1/accounts/update_credentials/index.ts +++ b/server/api/api/v1/accounts/update_credentials/index.ts @@ -1,8 +1,10 @@ -import { apiRoute, applyConfig } from "@api"; +import { applyConfig, auth, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; import { errorResponse, jsonResponse } from "@response"; import { sanitizeHtml, sanitizedHtmlStrip } from "@sanitization"; import { config } from "config-manager"; import { and, eq } from "drizzle-orm"; +import type { Hono } from "hono"; import ISO6391 from "iso-639-1"; import { MediaBackendType } from "media-manager"; import type { MediaBackend } from "media-manager"; @@ -28,274 +30,295 @@ export const meta = applyConfig({ }, }); -export const schema = z.object({ - display_name: z - .string() - .min(3) - .trim() - .max(config.validation.max_displayname_size) - .optional(), - note: z - .string() - .min(0) - .max(config.validation.max_bio_size) - .trim() - .optional(), - avatar: z.instanceof(File).optional(), - header: z.instanceof(File).optional(), - locked: z.boolean().optional(), - bot: z.boolean().optional(), - discoverable: z.boolean().optional(), - source: z - .object({ - privacy: z - .enum(["public", "unlisted", "private", "direct"]) - .optional(), - sensitive: z.boolean().optional(), - language: z - .enum(ISO6391.getAllCodes() as [string, ...string[]]) - .optional(), - }) - .optional(), - fields_attributes: z - .array( - z.object({ - name: z +export const schemas = { + form: z.object({ + display_name: z + .string() + .min(3) + .trim() + .max(config.validation.max_displayname_size) + .optional(), + note: z + .string() + .min(0) + .max(config.validation.max_bio_size) + .trim() + .optional(), + avatar: z.instanceof(File).optional(), + header: z.instanceof(File).optional(), + locked: z + .string() + .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) + .optional(), + bot: z + .string() + .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) + .optional(), + discoverable: z + .string() + .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) + .optional(), + source: z + .object({ + privacy: z + .enum(["public", "unlisted", "private", "direct"]) + .optional(), + sensitive: z .string() - .trim() - .max(config.validation.max_field_name_size), - value: z - .string() - .trim() - .max(config.validation.max_field_value_size), - }), - ) - .max(config.validation.max_field_count) - .optional(), -}); - -export default apiRoute( - async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; - - if (!user) return errorResponse("Unauthorized", 401); - - const config = await extraData.configManager.getConfig(); - const self = user.getUser(); - - const { - display_name, - note, - avatar, - header, - locked, - bot, - discoverable, - source, - fields_attributes, - } = extraData.parsedRequest; - - const sanitizedNote = await sanitizeHtml(note ?? ""); - - const sanitizedDisplayName = await sanitizedHtmlStrip( - display_name ?? "", - ); - - let mediaManager: MediaBackend; - - switch (config.media.backend as MediaBackendType) { - case MediaBackendType.LOCAL: - mediaManager = new LocalMediaBackend(config); - break; - case MediaBackendType.S3: - mediaManager = new S3MediaBackend(config); - break; - default: - // TODO: Replace with logger - throw new Error("Invalid media backend"); - } - - if (display_name) { - // Check if display name doesnt match filters - if ( - config.filters.displayname.some((filter) => - sanitizedDisplayName.match(filter), - ) - ) { - return errorResponse( - "Display name contains blocked words", - 422, - ); - } - - self.displayName = sanitizedDisplayName; - } - - if (note && self.source) { - // Check if bio doesnt match filters - if ( - config.filters.bio.some((filter) => sanitizedNote.match(filter)) - ) { - return errorResponse("Bio contains blocked words", 422); - } - - self.source.note = sanitizedNote; - self.note = await contentToHtml({ - "text/markdown": { - content: sanitizedNote, - }, - }); - } - - if (source?.privacy) { - self.source.privacy = source.privacy; - } - - if (source?.sensitive) { - self.source.sensitive = source.sensitive; - } - - if (source?.language) { - self.source.language = source.language; - } - - if (avatar) { - // Check if within allowed avatar length (avatar is an image) - if (avatar.size > config.validation.max_avatar_size) { - return errorResponse( - `Avatar must be less than ${config.validation.max_avatar_size} bytes`, - 422, - ); - } - - const { path } = await mediaManager.addFile(avatar); - - self.avatar = getUrl(path, config); - } - - if (header) { - // Check if within allowed header length (header is an image) - if (header.size > config.validation.max_header_size) { - return errorResponse( - `Header must be less than ${config.validation.max_avatar_size} bytes`, - 422, - ); - } - - const { path } = await mediaManager.addFile(header); - - self.header = getUrl(path, config); - } - - if (locked) { - self.isLocked = locked; - } - - if (bot) { - self.isBot = bot; - } - - if (discoverable) { - self.isDiscoverable = discoverable; - } - - const fieldEmojis: EmojiWithInstance[] = []; - - if (fields_attributes) { - self.fields = []; - self.source.fields = []; - for (const field of fields_attributes) { - // Can be Markdown or plaintext, also has emojis - const parsedName = await contentToHtml({ - "text/markdown": { - content: field.name, - }, - }); - - const parsedValue = await contentToHtml({ - "text/markdown": { - content: field.value, - }, - }); - - // Parse emojis - const nameEmojis = await parseEmojis(parsedName); - const valueEmojis = await parseEmojis(parsedValue); - - fieldEmojis.push(...nameEmojis, ...valueEmojis); - - // Replace fields - self.fields.push({ - key: { - "text/html": { - content: parsedName, - }, - }, - value: { - "text/html": { - content: parsedValue, - }, - }, - }); - - self.source.fields.push({ - name: field.name, - value: field.value, - }); - } - } - - // Parse emojis - const displaynameEmojis = await parseEmojis(sanitizedDisplayName); - const noteEmojis = await parseEmojis(sanitizedNote); - - self.emojis = [...displaynameEmojis, ...noteEmojis, ...fieldEmojis]; - - // Deduplicate emojis - self.emojis = self.emojis.filter( - (emoji, index, self) => - self.findIndex((e) => e.id === emoji.id) === index, - ); - - await db - .update(Users) - .set({ - displayName: self.displayName, - note: self.note, - avatar: self.avatar, - header: self.header, - fields: self.fields, - isLocked: self.isLocked, - isBot: self.isBot, - isDiscoverable: self.isDiscoverable, - source: self.source || undefined, + .transform((v) => + ["true", "1", "on"].includes(v.toLowerCase()), + ) + .optional(), + language: z + .enum(ISO6391.getAllCodes() as [string, ...string[]]) + .optional(), }) - .where(eq(Users.id, self.id)); + .optional(), + fields_attributes: z + .array( + z.object({ + name: z + .string() + .trim() + .max(config.validation.max_field_name_size), + value: z + .string() + .trim() + .max(config.validation.max_field_value_size), + }), + ) + .max(config.validation.max_field_count) + .optional(), + }), +}; - // Connect emojis, if any - for (const emoji of self.emojis) { - await db - .delete(EmojiToUser) - .where( - and( - eq(EmojiToUser.emojiId, emoji.id), - eq(EmojiToUser.userId, self.id), - ), - ) - .execute(); +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("form", schemas.form, handleZodError), + auth(meta.auth), + async (context) => { + const { user } = context.req.valid("header"); + const { + display_name, + note, + avatar, + header, + locked, + bot, + discoverable, + source, + fields_attributes, + } = context.req.valid("form"); + + if (!user) return errorResponse("Unauthorized", 401); + + const self = user.getUser(); + + const sanitizedNote = await sanitizeHtml(note ?? ""); + + const sanitizedDisplayName = await sanitizedHtmlStrip( + display_name ?? "", + ); + + let mediaManager: MediaBackend; + + switch (config.media.backend as MediaBackendType) { + case MediaBackendType.LOCAL: + mediaManager = new LocalMediaBackend(config); + break; + case MediaBackendType.S3: + mediaManager = new S3MediaBackend(config); + break; + default: + // TODO: Replace with logger + throw new Error("Invalid media backend"); + } + + if (display_name) { + // Check if display name doesnt match filters + if ( + config.filters.displayname.some((filter) => + sanitizedDisplayName.match(filter), + ) + ) { + return errorResponse( + "Display name contains blocked words", + 422, + ); + } + + self.displayName = sanitizedDisplayName; + } + + if (note && self.source) { + // Check if bio doesnt match filters + if ( + config.filters.bio.some((filter) => + sanitizedNote.match(filter), + ) + ) { + return errorResponse("Bio contains blocked words", 422); + } + + self.source.note = sanitizedNote; + self.note = await contentToHtml({ + "text/markdown": { + content: sanitizedNote, + }, + }); + } + + if (source?.privacy) { + self.source.privacy = source.privacy; + } + + if (source?.sensitive) { + self.source.sensitive = source.sensitive; + } + + if (source?.language) { + self.source.language = source.language; + } + + if (avatar) { + // Check if within allowed avatar length (avatar is an image) + if (avatar.size > config.validation.max_avatar_size) { + return errorResponse( + `Avatar must be less than ${config.validation.max_avatar_size} bytes`, + 422, + ); + } + + const { path } = await mediaManager.addFile(avatar); + + self.avatar = getUrl(path, config); + } + + if (header) { + // Check if within allowed header length (header is an image) + if (header.size > config.validation.max_header_size) { + return errorResponse( + `Header must be less than ${config.validation.max_avatar_size} bytes`, + 422, + ); + } + + const { path } = await mediaManager.addFile(header); + + self.header = getUrl(path, config); + } + + if (locked) { + self.isLocked = locked; + } + + if (bot) { + self.isBot = bot; + } + + if (discoverable) { + self.isDiscoverable = discoverable; + } + + const fieldEmojis: EmojiWithInstance[] = []; + + if (fields_attributes) { + self.fields = []; + self.source.fields = []; + for (const field of fields_attributes) { + // Can be Markdown or plaintext, also has emojis + const parsedName = await contentToHtml({ + "text/markdown": { + content: field.name, + }, + }); + + const parsedValue = await contentToHtml({ + "text/markdown": { + content: field.value, + }, + }); + + // Parse emojis + const nameEmojis = await parseEmojis(parsedName); + const valueEmojis = await parseEmojis(parsedValue); + + fieldEmojis.push(...nameEmojis, ...valueEmojis); + + // Replace fields + self.fields.push({ + key: { + "text/html": { + content: parsedName, + }, + }, + value: { + "text/html": { + content: parsedValue, + }, + }, + }); + + self.source.fields.push({ + name: field.name, + value: field.value, + }); + } + } + + // Parse emojis + const displaynameEmojis = await parseEmojis(sanitizedDisplayName); + const noteEmojis = await parseEmojis(sanitizedNote); + + self.emojis = [...displaynameEmojis, ...noteEmojis, ...fieldEmojis]; + + // Deduplicate emojis + self.emojis = self.emojis.filter( + (emoji, index, self) => + self.findIndex((e) => e.id === emoji.id) === index, + ); await db - .insert(EmojiToUser) - .values({ - emojiId: emoji.id, - userId: self.id, + .update(Users) + .set({ + displayName: self.displayName, + note: self.note, + avatar: self.avatar, + header: self.header, + fields: self.fields, + isLocked: self.isLocked, + isBot: self.isBot, + isDiscoverable: self.isDiscoverable, + source: self.source || undefined, }) - .execute(); - } + .where(eq(Users.id, self.id)); - const output = await User.fromId(self.id); - if (!output) return errorResponse("Couldn't edit user", 500); + // Connect emojis, if any + for (const emoji of self.emojis) { + await db + .delete(EmojiToUser) + .where( + and( + eq(EmojiToUser.emojiId, emoji.id), + eq(EmojiToUser.userId, self.id), + ), + ) + .execute(); - return jsonResponse(output.toAPI()); - }, -); + await db + .insert(EmojiToUser) + .values({ + emojiId: emoji.id, + userId: self.id, + }) + .execute(); + } + + const output = await User.fromId(self.id); + if (!output) return errorResponse("Couldn't edit user", 500); + + return jsonResponse(output.toAPI()); + }, + ); diff --git a/server/api/api/v1/accounts/verify_credentials/index.ts b/server/api/api/v1/accounts/verify_credentials/index.ts index a533742a..016afaa8 100644 --- a/server/api/api/v1/accounts/verify_credentials/index.ts +++ b/server/api/api/v1/accounts/verify_credentials/index.ts @@ -1,5 +1,6 @@ -import { apiRoute, applyConfig } from "@api"; +import { applyConfig, auth } from "@api"; import { errorResponse, jsonResponse } from "@response"; +import type { Hono } from "hono"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -14,12 +15,17 @@ export const meta = applyConfig({ }, }); -export default apiRoute((req, matchedRoute, extraData) => { - // TODO: Add checks for disabled or not email verified accounts +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + auth(meta.auth), + async (context) => { + // TODO: Add checks for disabled/unverified accounts + const { user } = context.req.valid("header"); - const { user } = extraData.auth; + if (!user) return errorResponse("Unauthorized", 401); - if (!user) return errorResponse("Unauthorized", 401); - - return jsonResponse(user.toAPI(true)); -}); + return jsonResponse(user.toAPI(true)); + }, + ); diff --git a/server/api/api/v1/apps/index.ts b/server/api/api/v1/apps/index.ts index 11b3f6e5..bac1335b 100644 --- a/server/api/api/v1/apps/index.ts +++ b/server/api/api/v1/apps/index.ts @@ -1,6 +1,8 @@ import { randomBytes } from "node:crypto"; -import { apiRoute, applyConfig } from "@api"; +import { applyConfig, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; import { jsonResponse } from "@response"; +import type { Hono } from "hono"; import { z } from "zod"; import { db } from "~drizzle/db"; import { Applications } from "~drizzle/schema"; @@ -17,43 +19,46 @@ export const meta = applyConfig({ }, }); -export const schema = z.object({ - client_name: z.string().trim().min(1).max(100), - redirect_uris: z.string().min(0).max(2000).url(), - scopes: z.string().min(1).max(200), - website: z.string().min(0).max(2000).url().optional(), -}); +export const schemas = { + form: z.object({ + client_name: z.string().trim().min(1).max(100), + redirect_uris: z.string().min(0).max(2000).url(), + scopes: z.string().min(1).max(200), + website: z.string().min(0).max(2000).url().optional(), + }), +}; -/** - * Creates a new application to obtain OAuth 2 credentials - */ -export default apiRoute( - async (req, matchedRoute, extraData) => { - const { client_name, redirect_uris, scopes, website } = - extraData.parsedRequest; +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("form", schemas.form, handleZodError), + async (context) => { + const { client_name, redirect_uris, scopes, website } = + context.req.valid("form"); - const app = ( - await db - .insert(Applications) - .values({ - name: client_name || "", - redirectUri: decodeURIComponent(redirect_uris) || "", - scopes: scopes || "read", - website: website || null, - clientId: randomBytes(32).toString("base64url"), - secret: randomBytes(64).toString("base64url"), - }) - .returning() - )[0]; + const app = ( + await db + .insert(Applications) + .values({ + name: client_name || "", + redirectUri: decodeURIComponent(redirect_uris) || "", + scopes: scopes || "read", + website: website || null, + clientId: randomBytes(32).toString("base64url"), + secret: randomBytes(64).toString("base64url"), + }) + .returning() + )[0]; - return jsonResponse({ - id: app.id, - name: app.name, - website: app.website, - client_id: app.clientId, - client_secret: app.secret, - redirect_uri: app.redirectUri, - vapid_link: app.vapidKey, - }); - }, -); + return jsonResponse({ + id: app.id, + name: app.name, + website: app.website, + client_id: app.clientId, + client_secret: app.secret, + redirect_uri: app.redirectUri, + vapid_link: app.vapidKey, + }); + }, + ); diff --git a/server/api/api/v1/apps/verify_credentials/index.ts b/server/api/api/v1/apps/verify_credentials/index.ts index 35ce56ac..15592691 100644 --- a/server/api/api/v1/apps/verify_credentials/index.ts +++ b/server/api/api/v1/apps/verify_credentials/index.ts @@ -1,5 +1,6 @@ -import { apiRoute, applyConfig } from "@api"; +import { applyConfig, auth } from "@api"; import { errorResponse, jsonResponse } from "@response"; +import type { Hono } from "hono"; import { getFromToken } from "~database/entities/Application"; export const meta = applyConfig({ @@ -14,24 +15,27 @@ export const meta = applyConfig({ }, }); -/** - * Returns OAuth2 credentials - */ -export default apiRoute(async (req, matchedRoute, extraData) => { - const { user, token } = extraData.auth; +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + auth(meta.auth), + async (context) => { + const { user, token } = context.req.valid("header"); - if (!token) return errorResponse("Unauthorized", 401); - if (!user) return errorResponse("Unauthorized", 401); + if (!token) return errorResponse("Unauthorized", 401); + if (!user) return errorResponse("Unauthorized", 401); - const application = await getFromToken(token); + const application = await getFromToken(token); - if (!application) return errorResponse("Unauthorized", 401); + if (!application) return errorResponse("Unauthorized", 401); - return jsonResponse({ - name: application.name, - website: application.website, - vapid_key: application.vapidKey, - redirect_uris: application.redirectUri, - scopes: application.scopes, - }); -}); + return jsonResponse({ + name: application.name, + website: application.website, + vapid_key: application.vapidKey, + redirect_uris: application.redirectUri, + scopes: application.scopes, + }); + }, + ); diff --git a/server/api/api/v1/blocks/index.ts b/server/api/api/v1/blocks/index.ts index 3d2f3887..f21fe0f7 100644 --- a/server/api/api/v1/blocks/index.ts +++ b/server/api/api/v1/blocks/index.ts @@ -1,6 +1,8 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; +import { apiRoute, applyConfig, auth, handleZodError, idValidator } from "@api"; +import { zValidator } from "@hono/zod-validator"; import { errorResponse, jsonResponse } from "@response"; import { and, gt, gte, lt, sql } from "drizzle-orm"; +import type { Hono } from "hono"; import { z } from "zod"; import { Users } from "~drizzle/schema"; import { Timeline } from "~packages/database-interface/timeline"; @@ -18,38 +20,46 @@ export const meta = applyConfig({ }, }); -export const schema = z.object({ - max_id: z.string().regex(idValidator).optional(), - since_id: z.string().regex(idValidator).optional(), - min_id: z.string().regex(idValidator).optional(), - limit: z.coerce.number().int().min(1).max(80).default(40), -}); +export const schemas = { + query: z.object({ + max_id: z.string().regex(idValidator).optional(), + since_id: z.string().regex(idValidator).optional(), + min_id: z.string().regex(idValidator).optional(), + limit: z.coerce.number().int().min(1).max(80).default(40), + }), +}; -export default apiRoute( - async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("query", schemas.query, handleZodError), + auth(meta.auth), + async (context) => { + const { max_id, since_id, min_id, limit } = + context.req.valid("query"); - if (!user) return errorResponse("Unauthorized", 401); + const { user } = context.req.valid("header"); - const { max_id, since_id, min_id, limit } = extraData.parsedRequest; + if (!user) return errorResponse("Unauthorized", 401); - const { objects: blocks, link } = await Timeline.getUserTimeline( - and( - max_id ? lt(Users.id, max_id) : undefined, - since_id ? gte(Users.id, since_id) : undefined, - min_id ? gt(Users.id, min_id) : undefined, - sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."blocking" = true)`, - ), - limit, - req.url, - ); + const { objects: blocks, link } = await Timeline.getUserTimeline( + and( + max_id ? lt(Users.id, max_id) : undefined, + since_id ? gte(Users.id, since_id) : undefined, + min_id ? gt(Users.id, min_id) : undefined, + sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."blocking" = true)`, + ), + limit, + context.req.url, + ); - return jsonResponse( - blocks.map((u) => u.toAPI()), - 200, - { - Link: link, - }, - ); - }, -); + return jsonResponse( + blocks.map((u) => u.toAPI()), + 200, + { + Link: link, + }, + ); + }, + ); diff --git a/server/api/api/v1/custom_emojis/index.ts b/server/api/api/v1/custom_emojis/index.ts index f6a2ab7d..1312cd89 100644 --- a/server/api/api/v1/custom_emojis/index.ts +++ b/server/api/api/v1/custom_emojis/index.ts @@ -1,5 +1,6 @@ -import { apiRoute, applyConfig } from "@api"; +import { applyConfig } from "@api"; import { jsonResponse } from "@response"; +import type { Hono } from "hono"; import { emojiToAPI } from "~database/entities/Emoji"; import { db } from "~drizzle/db"; @@ -15,15 +16,16 @@ export const meta = applyConfig({ }, }); -export default apiRoute(async () => { - const emojis = await db.query.Emojis.findMany({ - where: (emoji, { isNull }) => isNull(emoji.instanceId), - with: { - instance: true, - }, - }); +export default (app: Hono) => + app.on(meta.allowedMethods, meta.route, async () => { + const emojis = await db.query.Emojis.findMany({ + where: (emoji, { isNull }) => isNull(emoji.instanceId), + with: { + instance: true, + }, + }); - return jsonResponse( - await Promise.all(emojis.map((emoji) => emojiToAPI(emoji))), - ); -}); + return jsonResponse( + await Promise.all(emojis.map((emoji) => emojiToAPI(emoji))), + ); + }); diff --git a/server/api/api/v1/favourites/index.ts b/server/api/api/v1/favourites/index.ts index 2ca0146d..5b95ed93 100644 --- a/server/api/api/v1/favourites/index.ts +++ b/server/api/api/v1/favourites/index.ts @@ -1,6 +1,8 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; +import { apiRoute, applyConfig, auth, handleZodError, idValidator } from "@api"; +import { zValidator } from "@hono/zod-validator"; import { errorResponse, jsonResponse } from "@response"; import { and, gt, gte, lt, sql } from "drizzle-orm"; +import type { Hono } from "hono"; import { z } from "zod"; import { Notes } from "~drizzle/schema"; import { Timeline } from "~packages/database-interface/timeline"; @@ -17,38 +19,49 @@ export const meta = applyConfig({ }, }); -export const schema = z.object({ - max_id: z.string().regex(idValidator).optional(), - since_id: z.string().regex(idValidator).optional(), - min_id: z.string().regex(idValidator).optional(), - limit: z.coerce.number().int().min(1).max(80).default(40), -}); +export const schemas = { + query: z.object({ + max_id: z.string().regex(idValidator).optional(), + since_id: z.string().regex(idValidator).optional(), + min_id: z.string().regex(idValidator).optional(), + limit: z.coerce.number().int().min(1).max(80).default(40), + }), +}; -export default apiRoute( - async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("query", schemas.query, handleZodError), + auth(meta.auth), + async (context) => { + const { max_id, since_id, min_id, limit } = + context.req.valid("query"); - const { limit, max_id, min_id, since_id } = extraData.parsedRequest; + const { user } = context.req.valid("header"); - if (!user) return errorResponse("Unauthorized", 401); + if (!user) return errorResponse("Unauthorized", 401); - const { objects, link } = await Timeline.getNoteTimeline( - and( - max_id ? lt(Notes.id, max_id) : undefined, - since_id ? gte(Notes.id, since_id) : undefined, - min_id ? gt(Notes.id, min_id) : undefined, - sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = ${Notes.id} AND "Likes"."likerId" = ${user.id})`, - ), - limit, - req.url, - ); + const { objects: favourites, link } = + await Timeline.getNoteTimeline( + and( + max_id ? lt(Notes.id, max_id) : undefined, + since_id ? gte(Notes.id, since_id) : undefined, + min_id ? gt(Notes.id, min_id) : undefined, + sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = ${Notes.id} AND "Likes"."likerId" = ${user.id})`, + ), + limit, + context.req.url, + ); - return jsonResponse( - await Promise.all(objects.map(async (note) => note.toAPI(user))), - 200, - { - Link: link, - }, - ); - }, -); + return jsonResponse( + await Promise.all( + favourites.map(async (note) => note.toAPI(user)), + ), + 200, + { + Link: link, + }, + ); + }, + ); diff --git a/server/api/api/v1/follow_requests/:account_id/authorize.ts b/server/api/api/v1/follow_requests/:account_id/authorize.ts new file mode 100644 index 00000000..cceebfe5 --- /dev/null +++ b/server/api/api/v1/follow_requests/:account_id/authorize.ts @@ -0,0 +1,100 @@ +import { applyConfig, auth, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse } from "@response"; +import { and, eq } from "drizzle-orm"; +import type { Hono } from "hono"; +import { z } from "zod"; +import { + checkForBidirectionalRelationships, + relationshipToAPI, +} from "~database/entities/Relationship"; +import { + getRelationshipToOtherUser, + sendFollowAccept, +} from "~database/entities/User"; +import { db } from "~drizzle/db"; +import { Relationships } from "~drizzle/schema"; +import { User } from "~packages/database-interface/user"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + route: "/api/v1/follow_requests/:account_id/authorize", + ratelimits: { + max: 100, + duration: 60, + }, + auth: { + required: true, + }, +}); + +export const schemas = { + param: z.object({ + account_id: z.string().uuid(), + }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("param", schemas.param, handleZodError), + auth(meta.auth), + async (context) => { + const { user } = context.req.valid("header"); + + if (!user) return errorResponse("Unauthorized", 401); + + const { account_id } = context.req.valid("param"); + + const account = await User.fromId(account_id); + + if (!account) return errorResponse("Account not found", 404); + + // Check if there is a relationship on both sides + await checkForBidirectionalRelationships(user, account); + + // Authorize follow request + await db + .update(Relationships) + .set({ + requested: false, + following: true, + }) + .where( + and( + eq(Relationships.subjectId, user.id), + eq(Relationships.ownerId, account.id), + ), + ); + + // Update followedBy for other user + await db + .update(Relationships) + .set({ + followedBy: true, + }) + .where( + and( + eq(Relationships.subjectId, account.id), + eq(Relationships.ownerId, user.id), + ), + ); + + const foundRelationship = await getRelationshipToOtherUser( + user, + account, + ); + + if (!foundRelationship) + return errorResponse("Relationship not found", 404); + + // Check if accepting remote follow + if (account.isRemote()) { + // Federate follow accept + await sendFollowAccept(account, user); + } + + return jsonResponse(relationshipToAPI(foundRelationship)); + }, + ); diff --git a/server/api/api/v1/follow_requests/:account_id/reject.ts b/server/api/api/v1/follow_requests/:account_id/reject.ts new file mode 100644 index 00000000..4c5386dc --- /dev/null +++ b/server/api/api/v1/follow_requests/:account_id/reject.ts @@ -0,0 +1,100 @@ +import { applyConfig, auth, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse } from "@response"; +import { and, eq } from "drizzle-orm"; +import type { Hono } from "hono"; +import { z } from "zod"; +import { + checkForBidirectionalRelationships, + relationshipToAPI, +} from "~database/entities/Relationship"; +import { + getRelationshipToOtherUser, + sendFollowReject, +} from "~database/entities/User"; +import { db } from "~drizzle/db"; +import { Relationships } from "~drizzle/schema"; +import { User } from "~packages/database-interface/user"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + route: "/api/v1/follow_requests/:account_id/reject", + ratelimits: { + max: 100, + duration: 60, + }, + auth: { + required: true, + }, +}); + +export const schemas = { + param: z.object({ + account_id: z.string().uuid(), + }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("param", schemas.param, handleZodError), + auth(meta.auth), + async (context) => { + const { user } = context.req.valid("header"); + + if (!user) return errorResponse("Unauthorized", 401); + + const { account_id } = context.req.valid("param"); + + const account = await User.fromId(account_id); + + if (!account) return errorResponse("Account not found", 404); + + // Check if there is a relationship on both sides + await checkForBidirectionalRelationships(user, account); + + // Reject follow request + await db + .update(Relationships) + .set({ + requested: false, + following: false, + }) + .where( + and( + eq(Relationships.subjectId, user.id), + eq(Relationships.ownerId, account.id), + ), + ); + + // Update followedBy for other user + await db + .update(Relationships) + .set({ + followedBy: false, + }) + .where( + and( + eq(Relationships.subjectId, account.id), + eq(Relationships.ownerId, user.id), + ), + ); + + const foundRelationship = await getRelationshipToOtherUser( + user, + account, + ); + + if (!foundRelationship) + return errorResponse("Relationship not found", 404); + + // Check if rejecting remote follow + if (account.isRemote()) { + // Federate follow reject + await sendFollowReject(account, user); + } + + return jsonResponse(relationshipToAPI(foundRelationship)); + }, + ); diff --git a/server/api/api/v1/follow_requests/[account_id]/authorize.ts b/server/api/api/v1/follow_requests/[account_id]/authorize.ts deleted file mode 100644 index ba427116..00000000 --- a/server/api/api/v1/follow_requests/[account_id]/authorize.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { apiRoute, applyConfig } from "@api"; -import { errorResponse, jsonResponse } from "@response"; -import { and, eq } from "drizzle-orm"; -import { - checkForBidirectionalRelationships, - relationshipToAPI, -} from "~database/entities/Relationship"; -import { - getRelationshipToOtherUser, - sendFollowAccept, -} from "~database/entities/User"; -import { db } from "~drizzle/db"; -import { Relationships } from "~drizzle/schema"; -import { User } from "~packages/database-interface/user"; - -export const meta = applyConfig({ - allowedMethods: ["POST"], - route: "/api/v1/follow_requests/:account_id/authorize", - ratelimits: { - max: 100, - duration: 60, - }, - auth: { - required: true, - }, -}); - -export default apiRoute(async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; - - if (!user) return errorResponse("Unauthorized", 401); - - const { account_id } = matchedRoute.params; - - const account = await User.fromId(account_id); - - if (!account) return errorResponse("Account not found", 404); - - // Check if there is a relationship on both sides - await checkForBidirectionalRelationships(user, account); - - // Authorize follow request - await db - .update(Relationships) - .set({ - requested: false, - following: true, - }) - .where( - and( - eq(Relationships.subjectId, user.id), - eq(Relationships.ownerId, account.id), - ), - ); - - // Update followedBy for other user - await db - .update(Relationships) - .set({ - followedBy: true, - }) - .where( - and( - eq(Relationships.subjectId, account.id), - eq(Relationships.ownerId, user.id), - ), - ); - - const foundRelationship = await getRelationshipToOtherUser(user, account); - - if (!foundRelationship) return errorResponse("Relationship not found", 404); - - // Check if accepting remote follow - if (account.isRemote()) { - // Federate follow accept - await sendFollowAccept(account, user); - } - - return jsonResponse(relationshipToAPI(foundRelationship)); -}); diff --git a/server/api/api/v1/follow_requests/[account_id]/reject.ts b/server/api/api/v1/follow_requests/[account_id]/reject.ts deleted file mode 100644 index d21ce985..00000000 --- a/server/api/api/v1/follow_requests/[account_id]/reject.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { apiRoute, applyConfig } from "@api"; -import { errorResponse, jsonResponse } from "@response"; -import { and, eq } from "drizzle-orm"; -import { - checkForBidirectionalRelationships, - relationshipToAPI, -} from "~database/entities/Relationship"; -import { - getRelationshipToOtherUser, - sendFollowReject, -} from "~database/entities/User"; -import { db } from "~drizzle/db"; -import { Relationships } from "~drizzle/schema"; -import { User } from "~packages/database-interface/user"; - -export const meta = applyConfig({ - allowedMethods: ["POST"], - route: "/api/v1/follow_requests/:account_id/reject", - ratelimits: { - max: 100, - duration: 60, - }, - auth: { - required: true, - }, -}); - -export default apiRoute(async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; - - if (!user) return errorResponse("Unauthorized", 401); - - const { account_id } = matchedRoute.params; - - const account = await User.fromId(account_id); - - if (!account) return errorResponse("Account not found", 404); - - // Check if there is a relationship on both sides - await checkForBidirectionalRelationships(user, account); - - // Reject follow request - await db - .update(Relationships) - .set({ - requested: false, - following: false, - }) - .where( - and( - eq(Relationships.subjectId, user.id), - eq(Relationships.ownerId, account.id), - ), - ); - - // Update followedBy for other user - await db - .update(Relationships) - .set({ - followedBy: false, - }) - .where( - and( - eq(Relationships.subjectId, account.id), - eq(Relationships.ownerId, user.id), - ), - ); - - const foundRelationship = await getRelationshipToOtherUser(user, account); - - if (!foundRelationship) return errorResponse("Relationship not found", 404); - - // Check if rejecting remote follow - if (account.isRemote()) { - // Federate follow reject - await sendFollowReject(account, user); - } - - return jsonResponse(relationshipToAPI(foundRelationship)); -}); diff --git a/server/api/api/v1/follow_requests/index.ts b/server/api/api/v1/follow_requests/index.ts index 220afde3..950d44c0 100644 --- a/server/api/api/v1/follow_requests/index.ts +++ b/server/api/api/v1/follow_requests/index.ts @@ -1,6 +1,8 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; +import { apiRoute, applyConfig, auth, handleZodError, idValidator } from "@api"; +import { zValidator } from "@hono/zod-validator"; import { errorResponse, jsonResponse } from "@response"; import { and, gt, gte, lt, sql } from "drizzle-orm"; +import type { Hono } from "hono"; import { z } from "zod"; import { Users } from "~drizzle/schema"; import { Timeline } from "~packages/database-interface/timeline"; @@ -17,38 +19,47 @@ export const meta = applyConfig({ }, }); -export const schema = z.object({ - max_id: z.string().regex(idValidator).optional(), - since_id: z.string().regex(idValidator).optional(), - min_id: z.string().regex(idValidator).optional(), - limit: z.coerce.number().int().min(1).max(80).default(20), -}); +export const schemas = { + query: z.object({ + max_id: z.string().regex(idValidator).optional(), + since_id: z.string().regex(idValidator).optional(), + min_id: z.string().regex(idValidator).optional(), + limit: z.coerce.number().int().min(1).max(80).default(40), + }), +}; -export default apiRoute( - async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("query", schemas.query, handleZodError), + auth(meta.auth), + async (context) => { + const { max_id, since_id, min_id, limit } = + context.req.valid("query"); - const { limit, max_id, min_id, since_id } = extraData.parsedRequest; + const { user } = context.req.valid("header"); - if (!user) return errorResponse("Unauthorized", 401); + if (!user) return errorResponse("Unauthorized", 401); - const { objects, link } = await Timeline.getUserTimeline( - and( - max_id ? lt(Users.id, max_id) : undefined, - since_id ? gte(Users.id, since_id) : undefined, - min_id ? gt(Users.id, min_id) : undefined, - sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${user.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."requested" = true)`, - ), - limit, - req.url, - ); + const { objects: followRequests, link } = + await Timeline.getUserTimeline( + and( + max_id ? lt(Users.id, max_id) : undefined, + since_id ? gte(Users.id, since_id) : undefined, + min_id ? gt(Users.id, min_id) : undefined, + sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${user.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."requested" = true)`, + ), + limit, + context.req.url, + ); - return jsonResponse( - objects.map((user) => user.toAPI()), - 200, - { - Link: link, - }, - ); - }, -); + return jsonResponse( + followRequests.map((u) => u.toAPI()), + 200, + { + Link: link, + }, + ); + }, + ); diff --git a/server/api/api/v1/instance/extended_description.ts b/server/api/api/v1/instance/extended_description.ts index e0563929..8e6b4a47 100644 --- a/server/api/api/v1/instance/extended_description.ts +++ b/server/api/api/v1/instance/extended_description.ts @@ -1,7 +1,9 @@ -import { apiRoute, applyConfig } from "@api"; +import { applyConfig, auth } from "@api"; import { dualLogger } from "@loggers"; import { jsonResponse } from "@response"; +import type { Hono } from "hono"; import { getMarkdownRenderer } from "~database/entities/Status"; +import { config } from "~packages/config-manager"; import { LogLevel } from "~packages/log-manager"; export const meta = applyConfig({ @@ -16,32 +18,31 @@ export const meta = applyConfig({ }, }); -export default apiRoute(async (req, matchedRoute, extraData) => { - const config = await extraData.configManager.getConfig(); +export default (app: Hono) => + app.on(meta.allowedMethods, meta.route, auth(meta.auth), async () => { + let extended_description = (await getMarkdownRenderer()).render( + "This is a [Lysand](https://lysand.org) server with the default extended description.", + ); + let lastModified = new Date(2024, 0, 0); - let extended_description = (await getMarkdownRenderer()).render( - "This is a [Lysand](https://lysand.org) server with the default extended description.", - ); - let lastModified = new Date(2024, 0, 0); + const extended_description_file = Bun.file( + config.instance.extended_description_path, + ); - const extended_description_file = Bun.file( - config.instance.extended_description_path, - ); + if (await extended_description_file.exists()) { + extended_description = + (await getMarkdownRenderer()).render( + (await extended_description_file.text().catch(async (e) => { + await dualLogger.logError(LogLevel.ERROR, "Routes", e); + return ""; + })) || + "This is a [Lysand](https://lysand.org) server with the default extended description.", + ) || ""; + lastModified = new Date(extended_description_file.lastModified); + } - if (await extended_description_file.exists()) { - extended_description = - (await getMarkdownRenderer()).render( - (await extended_description_file.text().catch(async (e) => { - await dualLogger.logError(LogLevel.ERROR, "Routes", e); - return ""; - })) || - "This is a [Lysand](https://lysand.org) server with the default extended description.", - ) || ""; - lastModified = new Date(extended_description_file.lastModified); - } - - return jsonResponse({ - updated_at: lastModified.toISOString(), - content: extended_description, + return jsonResponse({ + updated_at: lastModified.toISOString(), + content: extended_description, + }); }); -}); diff --git a/server/api/api/v1/instance/index.ts b/server/api/api/v1/instance/index.ts index 0c72f42c..c8d9c292 100644 --- a/server/api/api/v1/instance/index.ts +++ b/server/api/api/v1/instance/index.ts @@ -1,9 +1,12 @@ -import { apiRoute, applyConfig } from "@api"; +import { applyConfig, auth } from "@api"; import { jsonResponse, proxyUrl } from "@response"; -import { and, count, countDistinct, eq, gte, isNull, sql } from "drizzle-orm"; +import { and, count, eq, isNull } from "drizzle-orm"; +import type { Hono } from "hono"; import { db } from "~drizzle/db"; -import { Instances, Notes, Users } from "~drizzle/schema"; +import { Instances, Users } from "~drizzle/schema"; import manifest from "~package.json"; +import { config } from "~packages/config-manager"; +import { Note } from "~packages/database-interface/note"; import { User } from "~packages/database-interface/user"; import type { Instance as APIInstance } from "~types/mastodon/instance"; @@ -19,177 +22,145 @@ export const meta = applyConfig({ }, }); -export default apiRoute(async (req, matchedRoute, extraData) => { - const config = await extraData.configManager.getConfig(); +export default (app: Hono) => + app.on(meta.allowedMethods, meta.route, auth(meta.auth), async () => { + // Get software version from package.json + const version = manifest.version; - // Get software version from package.json - const version = manifest.version; + const statusCount = await Note.getCount(); - const statusCount = ( - await db - .select({ - count: count(), - }) - .from(Notes) - .where( - sql`EXISTS (SELECT 1 FROM "Users" WHERE "Users"."id" = ${Notes.authorId} AND "Users"."instanceId" IS NULL)`, - ) - )[0].count; + const userCount = await User.getCount(); - const userCount = ( - await db - .select({ - count: count(), - }) - .from(Users) - .where(isNull(Users.instanceId)) - )[0].count; + const contactAccount = await User.fromSql( + and(isNull(Users.instanceId), eq(Users.isAdmin, true)), + ); - const contactAccount = await User.fromSql( - and(isNull(Users.instanceId), eq(Users.isAdmin, true)), - ); + const monthlyActiveUsers = await User.getActiveInPeriod( + 30 * 24 * 60 * 60 * 1000, + ); - const monthlyActiveUsers = ( - await db - .select({ - count: countDistinct(Users), - }) - .from(Users) - .leftJoin(Notes, eq(Users.id, Notes.authorId)) - .where( - and( - isNull(Users.instanceId), - gte( - Notes.createdAt, - new Date( - Date.now() - 30 * 24 * 60 * 60 * 1000, - ).toISOString(), - ), - ), - ) - )[0].count; + const knownDomainsCount = ( + await db + .select({ + count: count(), + }) + .from(Instances) + )[0].count; - const knownDomainsCount = ( - await db - .select({ - count: count(), - }) - .from(Instances) - )[0].count; - - // TODO: fill in more values - return jsonResponse({ - approval_required: false, - configuration: { - polls: { - max_characters_per_option: - config.validation.max_poll_option_size, - max_expiration: config.validation.max_poll_duration, - max_options: config.validation.max_poll_options, - min_expiration: 60, - }, - statuses: { - characters_reserved_per_url: 0, - max_characters: config.validation.max_note_size, - max_media_attachments: config.validation.max_media_attachments, - }, - }, - description: "A test instance", - email: "", - invites_enabled: false, - registrations: config.signups.registration, - languages: ["en"], - rules: config.signups.rules.map((r, index) => ({ - id: String(index), - text: r, - })), - stats: { - domain_count: knownDomainsCount, - status_count: statusCount, - user_count: userCount, - }, - thumbnail: proxyUrl(config.instance.logo), - banner: proxyUrl(config.instance.banner) ?? "", - title: config.instance.name, - uri: config.http.base_url, - urls: { - streaming_api: "", - }, - version: "4.3.0-alpha.3+glitch", - lysand_version: version, - pleroma: { - metadata: { - account_activation_required: false, - features: [ - "pleroma_api", - "akkoma_api", - "mastodon_api", - // "mastodon_api_streaming", - // "polls", - // "v2_suggestions", - // "pleroma_explicit_addressing", - // "shareable_emoji_packs", - // "multifetch", - // "pleroma:api/v1/notifications:include_types_filter", - "quote_posting", - "editing", - // "bubble_timeline", - // "relay", - // "pleroma_emoji_reactions", - // "exposable_reactions", - // "profile_directory", - "custom_emoji_reactions", - // "pleroma:get:main/ostatus", - ], - federation: { - enabled: true, - exclusions: false, - mrf_policies: [], - mrf_simple: { - accept: [], - avatar_removal: [], - background_removal: [], - banner_removal: [], - federated_timeline_removal: [], - followers_only: [], - media_nsfw: [], - media_removal: [], - reject: [], - reject_deletes: [], - report_removal: [], - }, - mrf_simple_info: { - media_nsfw: {}, - reject: {}, - }, - quarantined_instances: [], - quarantined_instances_info: { - quarantined_instances: {}, - }, + // TODO: fill in more values + return jsonResponse({ + approval_required: false, + configuration: { + polls: { + max_characters_per_option: + config.validation.max_poll_option_size, + max_expiration: config.validation.max_poll_duration, + max_options: config.validation.max_poll_options, + min_expiration: config.validation.min_poll_duration, }, - fields_limits: { - max_fields: config.validation.max_field_count, - max_remote_fields: 9999, - name_length: config.validation.max_field_name_size, - value_length: config.validation.max_field_value_size, + statuses: { + characters_reserved_per_url: 0, + max_characters: config.validation.max_note_size, + max_media_attachments: + config.validation.max_media_attachments, }, - post_formats: [ - "text/plain", - "text/html", - "text/markdown", - "text/x.misskeymarkdown", - ], - privileged_staff: false, }, + description: config.instance.description, + email: "", + invites_enabled: false, + registrations: config.signups.registration, + languages: ["en"], + rules: config.signups.rules.map((r, index) => ({ + id: String(index), + text: r, + })), stats: { - mau: monthlyActiveUsers, + domain_count: knownDomainsCount, + status_count: statusCount, + user_count: userCount, }, - vapid_public_key: "", - }, - contact_account: contactAccount?.toAPI() || undefined, - } satisfies APIInstance & { - banner: string; - lysand_version: string; - pleroma: object; + thumbnail: proxyUrl(config.instance.logo), + banner: proxyUrl(config.instance.banner) ?? "", + title: config.instance.name, + uri: config.http.base_url, + urls: { + streaming_api: "", + }, + version: "4.3.0-alpha.3+glitch", + lysand_version: version, + pleroma: { + metadata: { + account_activation_required: false, + features: [ + "pleroma_api", + "akkoma_api", + "mastodon_api", + // "mastodon_api_streaming", + // "polls", + // "v2_suggestions", + // "pleroma_explicit_addressing", + // "shareable_emoji_packs", + // "multifetch", + // "pleroma:api/v1/notifications:include_types_filter", + "quote_posting", + "editing", + // "bubble_timeline", + // "relay", + // "pleroma_emoji_reactions", + // "exposable_reactions", + // "profile_directory", + "custom_emoji_reactions", + // "pleroma:get:main/ostatus", + ], + federation: { + enabled: true, + exclusions: false, + mrf_policies: [], + mrf_simple: { + accept: [], + avatar_removal: [], + background_removal: [], + banner_removal: [], + federated_timeline_removal: [], + followers_only: [], + media_nsfw: [], + media_removal: [], + reject: [], + reject_deletes: [], + report_removal: [], + }, + mrf_simple_info: { + media_nsfw: {}, + reject: {}, + }, + quarantined_instances: [], + quarantined_instances_info: { + quarantined_instances: {}, + }, + }, + fields_limits: { + max_fields: config.validation.max_field_count, + max_remote_fields: 9999, + name_length: config.validation.max_field_name_size, + value_length: config.validation.max_field_value_size, + }, + post_formats: [ + "text/plain", + "text/html", + "text/markdown", + "text/x.misskeymarkdown", + ], + privileged_staff: false, + }, + stats: { + mau: monthlyActiveUsers, + }, + vapid_public_key: "", + }, + contact_account: contactAccount?.toAPI() || undefined, + } satisfies APIInstance & { + banner: string; + lysand_version: string; + pleroma: object; + }); }); -}); diff --git a/server/api/api/v1/instance/rules.ts b/server/api/api/v1/instance/rules.ts index 07089cf7..054700b8 100644 --- a/server/api/api/v1/instance/rules.ts +++ b/server/api/api/v1/instance/rules.ts @@ -1,5 +1,7 @@ -import { apiRoute, applyConfig } from "@api"; +import { applyConfig, auth } from "@api"; import { jsonResponse } from "@response"; +import type { Hono } from "hono"; +import { config } from "~packages/config-manager"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -13,14 +15,18 @@ export const meta = applyConfig({ }, }); -export default apiRoute(async (req, matchedRoute, extraData) => { - const config = await extraData.configManager.getConfig(); - - return jsonResponse( - config.signups.rules.map((rule, index) => ({ - id: String(index), - text: rule, - hint: "", - })), +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + auth(meta.auth), + async (context) => { + return jsonResponse( + config.signups.rules.map((rule, index) => ({ + id: String(index), + text: rule, + hint: "", + })), + ); + }, ); -}); diff --git a/server/api/api/v1/markers/index.test.ts b/server/api/api/v1/markers/index.test.ts index c0c2944e..7b722804 100644 --- a/server/api/api/v1/markers/index.test.ts +++ b/server/api/api/v1/markers/index.test.ts @@ -53,16 +53,20 @@ describe(meta.route, () => { test("should create markers", async () => { const response = await sendTestRequest( - new Request(new URL(meta.route, config.http.base_url), { - method: "POST", - headers: { - Authorization: `Bearer ${tokens[0].accessToken}`, - "Content-Type": "application/json", + new Request( + new URL( + `${meta.route}?${new URLSearchParams({ + "home[last_read_id]": timeline[0].id, + })}`, + config.http.base_url, + ), + { + method: "POST", + headers: { + Authorization: `Bearer ${tokens[0].accessToken}`, + }, }, - body: JSON.stringify({ - "home[last_read_id]": timeline[0].id, - }), - }), + ), ); expect(response.status).toBe(200); diff --git a/server/api/api/v1/markers/index.ts b/server/api/api/v1/markers/index.ts index 75c87799..04272e63 100644 --- a/server/api/api/v1/markers/index.ts +++ b/server/api/api/v1/markers/index.ts @@ -1,6 +1,16 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; +import { + applyConfig, + auth, + handleZodError, + idValidator, + qs, + qsQuery, +} from "@api"; +import { zValidator } from "@hono/zod-validator"; import { errorResponse, jsonResponse } from "@response"; import { and, count, eq } from "drizzle-orm"; +import type { Hono } from "hono"; +import { validator } from "hono/validator"; import { z } from "zod"; import { db } from "~drizzle/db"; import { Markers } from "~drizzle/schema"; @@ -19,175 +29,188 @@ export const meta = applyConfig({ }, }); -export const schema = z.object({ - timeline: z - .array(z.enum(["home", "notifications"])) - .max(2) - .optional(), - "home[last_read_id]": z.string().regex(idValidator).optional(), - "notifications[last_read_id]": z.string().regex(idValidator).optional(), -}); +export const schemas = { + query: z.object({ + "timeline[]": z + .array(z.enum(["home", "notifications"])) + .max(2) + .optional(), + "home[last_read_id]": z.string().regex(idValidator).optional(), + "notifications[last_read_id]": z.string().regex(idValidator).optional(), + }), +}; -export default apiRoute( - async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("query", schemas.query, handleZodError), + auth(meta.auth), + async (context) => { + const { "timeline[]": timeline } = context.req.valid("query"); + const { user } = context.req.valid("header"); - if (!user) return errorResponse("Unauthorized", 401); + if (!user) { + return errorResponse("Unauthorized", 401); + } - switch (req.method) { - case "GET": { - const { timeline } = extraData.parsedRequest; + switch (context.req.method) { + case "GET": { + if (!timeline) { + return jsonResponse({}); + } - if (!timeline) { - return jsonResponse({}); + const markers: APIMarker = { + home: undefined, + notifications: undefined, + }; + + if (timeline.includes("home")) { + const found = await db.query.Markers.findFirst({ + where: (marker, { and, eq }) => + and( + eq(marker.userId, user.id), + eq(marker.timeline, "home"), + ), + }); + + const totalCount = await db + .select({ + count: count(), + }) + .from(Markers) + .where( + and( + eq(Markers.userId, user.id), + eq(Markers.timeline, "home"), + ), + ); + + if (found?.noteId) { + markers.home = { + last_read_id: found.noteId, + version: totalCount[0].count, + updated_at: new Date( + found.createdAt, + ).toISOString(), + }; + } + } + + if (timeline.includes("notifications")) { + const found = await db.query.Markers.findFirst({ + where: (marker, { and, eq }) => + and( + eq(marker.userId, user.id), + eq(marker.timeline, "notifications"), + ), + }); + + const totalCount = await db + .select({ + count: count(), + }) + .from(Markers) + .where( + and( + eq(Markers.userId, user.id), + eq(Markers.timeline, "notifications"), + ), + ); + + if (found?.notificationId) { + markers.notifications = { + last_read_id: found.notificationId, + version: totalCount[0].count, + updated_at: new Date( + found.createdAt, + ).toISOString(), + }; + } + } + + return jsonResponse(markers); } - const markers: APIMarker = { - home: undefined, - notifications: undefined, - }; + case "POST": { + const { + "home[last_read_id]": home_id, + "notifications[last_read_id]": notifications_id, + } = context.req.valid("query"); - if (timeline.includes("home")) { - const found = await db.query.Markers.findFirst({ - where: (marker, { and, eq }) => - and( - eq(marker.userId, user.id), - eq(marker.timeline, "home"), - ), - }); + const markers: APIMarker = { + home: undefined, + notifications: undefined, + }; - const totalCount = await db - .select({ - count: count(), - }) - .from(Markers) - .where( - and( - eq(Markers.userId, user.id), - eq(Markers.timeline, "home"), - ), - ); + if (home_id) { + const insertedMarker = ( + await db + .insert(Markers) + .values({ + userId: user.id, + timeline: "home", + noteId: home_id, + }) + .returning() + )[0]; + + const totalCount = await db + .select({ + count: count(), + }) + .from(Markers) + .where( + and( + eq(Markers.userId, user.id), + eq(Markers.timeline, "home"), + ), + ); - if (found?.noteId) { markers.home = { - last_read_id: found.noteId, + last_read_id: home_id, version: totalCount[0].count, - updated_at: new Date(found.createdAt).toISOString(), + updated_at: new Date( + insertedMarker.createdAt, + ).toISOString(), }; } - } - if (timeline.includes("notifications")) { - const found = await db.query.Markers.findFirst({ - where: (marker, { and, eq }) => - and( - eq(marker.userId, user.id), - eq(marker.timeline, "notifications"), - ), - }); + if (notifications_id) { + const insertedMarker = ( + await db + .insert(Markers) + .values({ + userId: user.id, + timeline: "notifications", + notificationId: notifications_id, + }) + .returning() + )[0]; - const totalCount = await db - .select({ - count: count(), - }) - .from(Markers) - .where( - and( - eq(Markers.userId, user.id), - eq(Markers.timeline, "notifications"), - ), - ); + const totalCount = await db + .select({ + count: count(), + }) + .from(Markers) + .where( + and( + eq(Markers.userId, user.id), + eq(Markers.timeline, "notifications"), + ), + ); - if (found?.notificationId) { markers.notifications = { - last_read_id: found.notificationId, + last_read_id: notifications_id, version: totalCount[0].count, - updated_at: new Date(found.createdAt).toISOString(), + updated_at: new Date( + insertedMarker.createdAt, + ).toISOString(), }; } - } - return jsonResponse(markers); + return jsonResponse(markers); + } } - case "POST": { - const { - "home[last_read_id]": home_id, - "notifications[last_read_id]": notifications_id, - } = extraData.parsedRequest; - - const markers: APIMarker = { - home: undefined, - notifications: undefined, - }; - - if (home_id) { - const insertedMarker = ( - await db - .insert(Markers) - .values({ - userId: user.id, - timeline: "home", - noteId: home_id, - }) - .returning() - )[0]; - - const totalCount = await db - .select({ - count: count(), - }) - .from(Markers) - .where( - and( - eq(Markers.userId, user.id), - eq(Markers.timeline, "home"), - ), - ); - - markers.home = { - last_read_id: home_id, - version: totalCount[0].count, - updated_at: new Date( - insertedMarker.createdAt, - ).toISOString(), - }; - } - - if (notifications_id) { - const insertedMarker = ( - await db - .insert(Markers) - .values({ - userId: user.id, - timeline: "notifications", - notificationId: notifications_id, - }) - .returning() - )[0]; - - const totalCount = await db - .select({ - count: count(), - }) - .from(Markers) - .where( - and( - eq(Markers.userId, user.id), - eq(Markers.timeline, "notifications"), - ), - ); - - markers.notifications = { - last_read_id: notifications_id, - version: totalCount[0].count, - updated_at: new Date( - insertedMarker.createdAt, - ).toISOString(), - }; - } - - return jsonResponse(markers); - } - } - }, -); + }, + ); diff --git a/server/api/api/v1/media/:id/index.ts b/server/api/api/v1/media/:id/index.ts new file mode 100644 index 00000000..94527942 --- /dev/null +++ b/server/api/api/v1/media/:id/index.ts @@ -0,0 +1,123 @@ +import { applyConfig, auth, handleZodError, idValidator } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse, response } from "@response"; +import { config } from "config-manager"; +import { eq } from "drizzle-orm"; +import type { Hono } from "hono"; +import type { MediaBackend } from "media-manager"; +import { MediaBackendType } from "media-manager"; +import { LocalMediaBackend, S3MediaBackend } from "media-manager"; +import { z } from "zod"; +import { attachmentToAPI, getUrl } from "~database/entities/Attachment"; +import { db } from "~drizzle/db"; +import { Attachments } from "~drizzle/schema"; + +export const meta = applyConfig({ + allowedMethods: ["GET", "PUT"], + ratelimits: { + max: 10, + duration: 60, + }, + route: "/api/v1/media/:id", + auth: { + required: true, + oauthPermissions: ["write:media"], + }, +}); + +export const schemas = { + param: z.object({ + id: z.string(), + }), + form: z.object({ + thumbnail: z.instanceof(File).optional(), + description: z + .string() + .max(config.validation.max_media_description_size) + .optional(), + focus: z.string().optional(), + }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("param", schemas.param, handleZodError), + zValidator("form", schemas.form, handleZodError), + auth(meta.auth), + async (context) => { + const { id } = context.req.valid("param"); + + if (!id.match(idValidator)) { + return errorResponse("Invalid ID, must be of type UUIDv7", 404); + } + + const foundAttachment = await db.query.Attachments.findFirst({ + where: (attachment, { eq }) => eq(attachment.id, id), + }); + + if (!foundAttachment) { + return errorResponse("Media not found", 404); + } + + switch (context.req.method) { + case "GET": { + if (foundAttachment.url) { + return jsonResponse(attachmentToAPI(foundAttachment)); + } + return response(null, 206); + } + case "PUT": { + const { description, thumbnail } = + context.req.valid("form"); + + let thumbnailUrl = foundAttachment.thumbnailUrl; + + let mediaManager: MediaBackend; + + switch (config.media.backend as MediaBackendType) { + case MediaBackendType.LOCAL: + mediaManager = new LocalMediaBackend(config); + break; + case MediaBackendType.S3: + mediaManager = new S3MediaBackend(config); + break; + default: + // TODO: Replace with logger + throw new Error("Invalid media backend"); + } + + if (thumbnail) { + const { path } = await mediaManager.addFile(thumbnail); + thumbnailUrl = getUrl(path, config); + } + + const descriptionText = + description || foundAttachment.description; + + if ( + descriptionText !== foundAttachment.description || + thumbnailUrl !== foundAttachment.thumbnailUrl + ) { + const newAttachment = ( + await db + .update(Attachments) + .set({ + description: descriptionText, + thumbnailUrl, + }) + .where(eq(Attachments.id, id)) + .returning() + )[0]; + + return jsonResponse(attachmentToAPI(newAttachment)); + } + + return jsonResponse(attachmentToAPI(foundAttachment)); + } + } + + return errorResponse("Method not allowed", 405); + }, + ); diff --git a/server/api/api/v1/media/[id]/index.ts b/server/api/api/v1/media/[id]/index.ts deleted file mode 100644 index 136faa67..00000000 --- a/server/api/api/v1/media/[id]/index.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; -import { errorResponse, jsonResponse, response } from "@response"; -import { config } from "config-manager"; -import { eq } from "drizzle-orm"; -import type { MediaBackend } from "media-manager"; -import { MediaBackendType } from "media-manager"; -import { LocalMediaBackend, S3MediaBackend } from "media-manager"; -import { z } from "zod"; -import { attachmentToAPI, getUrl } from "~database/entities/Attachment"; -import { db } from "~drizzle/db"; -import { Attachments } from "~drizzle/schema"; - -export const meta = applyConfig({ - allowedMethods: ["GET", "PUT"], - ratelimits: { - max: 10, - duration: 60, - }, - route: "/api/v1/media/:id", - auth: { - required: true, - oauthPermissions: ["write:media"], - }, -}); - -export const schema = z.object({ - thumbnail: z.instanceof(File).optional(), - description: z - .string() - .max(config.validation.max_media_description_size) - .optional(), - focus: z.string().optional(), -}); - -/** - * Get media information - */ -export default apiRoute( - async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; - - if (!user) { - return errorResponse("Unauthorized", 401); - } - - const id = matchedRoute.params.id; - if (!id.match(idValidator)) { - return errorResponse("Invalid ID, must be of type UUIDv7", 404); - } - - const foundAttachment = await db.query.Attachments.findFirst({ - where: (attachment, { eq }) => eq(attachment.id, id), - }); - - if (!foundAttachment) { - return errorResponse("Media not found", 404); - } - - const config = await extraData.configManager.getConfig(); - - switch (req.method) { - case "GET": { - if (foundAttachment.url) { - return jsonResponse(attachmentToAPI(foundAttachment)); - } - return response(null, 206); - } - case "PUT": { - const { description, thumbnail } = extraData.parsedRequest; - - let thumbnailUrl = foundAttachment.thumbnailUrl; - - let mediaManager: MediaBackend; - - switch (config.media.backend as MediaBackendType) { - case MediaBackendType.LOCAL: - mediaManager = new LocalMediaBackend(config); - break; - case MediaBackendType.S3: - mediaManager = new S3MediaBackend(config); - break; - default: - // TODO: Replace with logger - throw new Error("Invalid media backend"); - } - - if (thumbnail) { - const { path } = await mediaManager.addFile(thumbnail); - thumbnailUrl = getUrl(path, config); - } - - const descriptionText = - description || foundAttachment.description; - - if ( - descriptionText !== foundAttachment.description || - thumbnailUrl !== foundAttachment.thumbnailUrl - ) { - const newAttachment = ( - await db - .update(Attachments) - .set({ - description: descriptionText, - thumbnailUrl, - }) - .where(eq(Attachments.id, id)) - .returning() - )[0]; - - return jsonResponse(attachmentToAPI(newAttachment)); - } - - return jsonResponse(attachmentToAPI(foundAttachment)); - } - } - - return errorResponse("Method not allowed", 405); - }, -); diff --git a/server/api/api/v1/media/index.ts b/server/api/api/v1/media/index.ts index 35af055b..e63135b1 100644 --- a/server/api/api/v1/media/index.ts +++ b/server/api/api/v1/media/index.ts @@ -1,7 +1,9 @@ -import { apiRoute, applyConfig } from "@api"; +import { applyConfig, auth, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; import { errorResponse, jsonResponse } from "@response"; import { encode } from "blurhash"; import { config } from "config-manager"; +import type { Hono } from "hono"; import { MediaBackendType } from "media-manager"; import type { MediaBackend } from "media-manager"; import { LocalMediaBackend, S3MediaBackend } from "media-manager"; @@ -24,128 +26,125 @@ export const meta = applyConfig({ }, }); -export const schema = z.object({ - file: z.instanceof(File), - thumbnail: z.instanceof(File).optional(), - description: z - .string() - .max(config.validation.max_media_description_size) - .optional(), - focus: z.string().optional(), -}); +export const schemas = { + form: z.object({ + file: z.instanceof(File), + thumbnail: z.instanceof(File).optional(), + description: z + .string() + .max(config.validation.max_media_description_size) + .optional(), + focus: z.string().optional(), + }), +}; -/** - * Upload new media - */ -export default apiRoute( - async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("form", schemas.form, handleZodError), + auth(meta.auth), + async (context) => { + const { file, thumbnail, description, focus } = + context.req.valid("form"); - if (!user) { - return errorResponse("Unauthorized", 401); - } + if (file.size > config.validation.max_media_size) { + return errorResponse( + `File too large, max size is ${config.validation.max_media_size} bytes`, + 413, + ); + } - const { file, thumbnail, description } = extraData.parsedRequest; + if ( + config.validation.enforce_mime_types && + !config.validation.allowed_mime_types.includes(file.type) + ) { + return errorResponse("Invalid file type", 415); + } - const config = await extraData.configManager.getConfig(); + const sha256 = new Bun.SHA256(); - if (file.size > config.validation.max_media_size) { - return errorResponse( - `File too large, max size is ${config.validation.max_media_size} bytes`, - 413, - ); - } + const isImage = file.type.startsWith("image/"); - if ( - config.validation.enforce_mime_types && - !config.validation.allowed_mime_types.includes(file.type) - ) { - return errorResponse("Invalid file type", 415); - } + const metadata = isImage + ? await sharp(await file.arrayBuffer()).metadata() + : null; - const sha256 = new Bun.SHA256(); + const blurhash = await new Promise((resolve) => { + (async () => + sharp(await file.arrayBuffer()) + .raw() + .ensureAlpha() + .toBuffer((err, buffer) => { + if (err) { + resolve(null); + return; + } - const isImage = file.type.startsWith("image/"); + try { + resolve( + encode( + new Uint8ClampedArray(buffer), + metadata?.width ?? 0, + metadata?.height ?? 0, + 4, + 4, + ) as string, + ); + } catch { + resolve(null); + } + }))(); + }); - const metadata = isImage - ? await sharp(await file.arrayBuffer()).metadata() - : null; + let url = ""; - const blurhash = await new Promise((resolve) => { - (async () => - sharp(await file.arrayBuffer()) - .raw() - .ensureAlpha() - .toBuffer((err, buffer) => { - if (err) { - resolve(null); - return; - } + let mediaManager: MediaBackend; - try { - resolve( - encode( - new Uint8ClampedArray(buffer), - metadata?.width ?? 0, - metadata?.height ?? 0, - 4, - 4, - ) as string, - ); - } catch { - resolve(null); - } - }))(); - }); + switch (config.media.backend as MediaBackendType) { + case MediaBackendType.LOCAL: + mediaManager = new LocalMediaBackend(config); + break; + case MediaBackendType.S3: + mediaManager = new S3MediaBackend(config); + break; + default: + // TODO: Replace with logger + throw new Error("Invalid media backend"); + } - let url = ""; + const { path } = await mediaManager.addFile(file); - let mediaManager: MediaBackend; + url = getUrl(path, config); - switch (config.media.backend as MediaBackendType) { - case MediaBackendType.LOCAL: - mediaManager = new LocalMediaBackend(config); - break; - case MediaBackendType.S3: - mediaManager = new S3MediaBackend(config); - break; - default: - // TODO: Replace with logger - throw new Error("Invalid media backend"); - } + let thumbnailUrl = ""; - const { path } = await mediaManager.addFile(file); + if (thumbnail) { + const { path } = await mediaManager.addFile(thumbnail); - url = getUrl(path, config); + thumbnailUrl = getUrl(path, config); + } - let thumbnailUrl = ""; + const newAttachment = ( + await db + .insert(Attachments) + .values({ + url, + thumbnailUrl, + sha256: sha256 + .update(await file.arrayBuffer()) + .digest("hex"), + mimeType: file.type, + description: description ?? "", + size: file.size, + blurhash: blurhash ?? undefined, + width: metadata?.width ?? undefined, + height: metadata?.height ?? undefined, + }) + .returning() + )[0]; + // TODO: Add job to process videos and other media - if (thumbnail) { - const { path } = await mediaManager.addFile(thumbnail); - - thumbnailUrl = getUrl(path, config); - } - - const newAttachment = ( - await db - .insert(Attachments) - .values({ - url, - thumbnailUrl, - sha256: sha256 - .update(await file.arrayBuffer()) - .digest("hex"), - mimeType: file.type, - description: description ?? "", - size: file.size, - blurhash: blurhash ?? undefined, - width: metadata?.width ?? undefined, - height: metadata?.height ?? undefined, - }) - .returning() - )[0]; - // TODO: Add job to process videos and other media - - return jsonResponse(attachmentToAPI(newAttachment)); - }, -); + return jsonResponse(attachmentToAPI(newAttachment)); + }, + ); diff --git a/server/api/api/v1/moderation/accounts/search/index.ts b/server/api/api/v1/moderation/accounts/search/index.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/server/api/api/v1/mutes/index.test.ts b/server/api/api/v1/mutes/index.test.ts index 77405fca..68c2f3f3 100644 --- a/server/api/api/v1/mutes/index.test.ts +++ b/server/api/api/v1/mutes/index.test.ts @@ -26,7 +26,9 @@ beforeAll(async () => { method: "POST", headers: { Authorization: `Bearer ${tokens[0].accessToken}`, + "Content-Type": "application/json", }, + body: JSON.stringify({}), }, ), ); @@ -81,7 +83,9 @@ describe(meta.route, () => { method: "POST", headers: { Authorization: `Bearer ${tokens[0].accessToken}`, + "Content-Type": "application/json", }, + body: JSON.stringify({}), }, ), ); diff --git a/server/api/api/v1/mutes/index.ts b/server/api/api/v1/mutes/index.ts index ac937b1f..8fdbb009 100644 --- a/server/api/api/v1/mutes/index.ts +++ b/server/api/api/v1/mutes/index.ts @@ -1,6 +1,8 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; +import { applyConfig, auth, handleZodError, idValidator } from "@api"; +import { zValidator } from "@hono/zod-validator"; import { errorResponse, jsonResponse } from "@response"; import { and, gt, gte, lt, sql } from "drizzle-orm"; +import type { Hono } from "hono"; import { z } from "zod"; import { Users } from "~drizzle/schema"; import { Timeline } from "~packages/database-interface/timeline"; @@ -18,31 +20,45 @@ export const meta = applyConfig({ }, }); -export const schema = z.object({ - max_id: z.string().regex(idValidator).optional(), - since_id: z.string().regex(idValidator).optional(), - min_id: z.string().regex(idValidator).optional(), - limit: z.coerce.number().int().min(1).max(80).default(40), -}); +export const schemas = { + query: z.object({ + max_id: z.string().regex(idValidator).optional(), + since_id: z.string().regex(idValidator).optional(), + min_id: z.string().regex(idValidator).optional(), + limit: z.coerce.number().int().min(1).max(80).default(40), + }), +}; -export default apiRoute( - async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; - const { max_id, since_id, limit, min_id } = extraData.parsedRequest; +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("query", schemas.query, handleZodError), + auth(meta.auth), + async (context) => { + const { max_id, since_id, limit, min_id } = + context.req.valid("query"); + const { user } = context.req.valid("header"); - if (!user) return errorResponse("Unauthorized", 401); + if (!user) return errorResponse("Unauthorized", 401); - const { objects: mutes, link } = await Timeline.getUserTimeline( - and( - max_id ? lt(Users.id, max_id) : undefined, - since_id ? gte(Users.id, since_id) : undefined, - min_id ? gt(Users.id, min_id) : undefined, - sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."muting" = true)`, - ), - limit, - req.url, - ); + const { objects: mutes, link } = await Timeline.getUserTimeline( + and( + max_id ? lt(Users.id, max_id) : undefined, + since_id ? gte(Users.id, since_id) : undefined, + min_id ? gt(Users.id, min_id) : undefined, + sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."muting" = true)`, + ), + limit, + context.req.url, + ); - return jsonResponse(mutes.map((u) => u.toAPI())); - }, -); + return jsonResponse( + mutes.map((u) => u.toAPI()), + 200, + { + Link: link, + }, + ); + }, + ); diff --git a/server/api/api/v1/notifications/[id]/dismiss.test.ts b/server/api/api/v1/notifications/:id/dismiss.test.ts similarity index 72% rename from server/api/api/v1/notifications/[id]/dismiss.test.ts rename to server/api/api/v1/notifications/:id/dismiss.test.ts index ebb13b2c..be203108 100644 --- a/server/api/api/v1/notifications/[id]/dismiss.test.ts +++ b/server/api/api/v1/notifications/:id/dismiss.test.ts @@ -15,23 +15,29 @@ let notifications: APINotification[] = []; // Create some test notifications: follow, favourite, reblog, mention beforeAll(async () => { - await fetch( - new URL(`/api/v1/accounts/${users[0].id}/follow`, config.http.base_url), - { - method: "POST", - headers: { - Authorization: `Bearer ${tokens[1].accessToken}`, + await sendTestRequest( + new Request( + new URL( + `/api/v1/accounts/${users[0].id}/follow`, + config.http.base_url, + ), + { + method: "POST", + headers: { + Authorization: `Bearer ${tokens[1].accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), }, - }, + ), ); - notifications = await fetch( - new URL("/api/v1/notifications", config.http.base_url), - { + notifications = await sendTestRequest( + new Request(new URL("/api/v1/notifications", config.http.base_url), { headers: { Authorization: `Bearer ${tokens[0].accessToken}`, }, - }, + }), ).then((r) => r.json()); expect(notifications.length).toBe(1); @@ -45,9 +51,15 @@ afterAll(async () => { describe(meta.route, () => { test("should return 401 if not authenticated", async () => { const response = await sendTestRequest( - new Request(new URL(meta.route, config.http.base_url), { - method: "POST", - }), + new Request( + new URL( + meta.route.replace(":id", notifications[0].id), + config.http.base_url, + ), + { + method: "POST", + }, + ), ); expect(response.status).toBe(401); diff --git a/server/api/api/v1/notifications/:id/dismiss.ts b/server/api/api/v1/notifications/:id/dismiss.ts new file mode 100644 index 00000000..fc8b4504 --- /dev/null +++ b/server/api/api/v1/notifications/:id/dismiss.ts @@ -0,0 +1,50 @@ +import { applyConfig, auth, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse } from "@response"; +import { eq } from "drizzle-orm"; +import type { Hono } from "hono"; +import { z } from "zod"; +import { db } from "~drizzle/db"; +import { Notifications } from "~drizzle/schema"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + route: "/api/v1/notifications/:id/dismiss", + ratelimits: { + max: 100, + duration: 60, + }, + auth: { + required: true, + oauthPermissions: ["write:notifications"], + }, +}); + +export const schemas = { + param: z.object({ + id: z.string().uuid(), + }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("param", schemas.param, handleZodError), + auth(meta.auth), + async (context) => { + const { id } = context.req.valid("param"); + + const { user } = context.req.valid("header"); + if (!user) return errorResponse("Unauthorized", 401); + + await db + .update(Notifications) + .set({ + dismissed: true, + }) + .where(eq(Notifications.id, id)); + + return jsonResponse({}); + }, + ); diff --git a/server/api/api/v1/notifications/[id]/index.test.ts b/server/api/api/v1/notifications/:id/index.test.ts similarity index 75% rename from server/api/api/v1/notifications/[id]/index.test.ts rename to server/api/api/v1/notifications/:id/index.test.ts index 112d342d..d284f782 100644 --- a/server/api/api/v1/notifications/[id]/index.test.ts +++ b/server/api/api/v1/notifications/:id/index.test.ts @@ -15,23 +15,29 @@ let notifications: APINotification[] = []; // Create some test notifications: follow, favourite, reblog, mention beforeAll(async () => { - await fetch( - new URL(`/api/v1/accounts/${users[0].id}/follow`, config.http.base_url), - { - method: "POST", - headers: { - Authorization: `Bearer ${tokens[1].accessToken}`, + await sendTestRequest( + new Request( + new URL( + `/api/v1/accounts/${users[0].id}/follow`, + config.http.base_url, + ), + { + method: "POST", + headers: { + Authorization: `Bearer ${tokens[1].accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), }, - }, + ), ); - notifications = await fetch( - new URL("/api/v1/notifications", config.http.base_url), - { + notifications = await sendTestRequest( + new Request(new URL("/api/v1/notifications", config.http.base_url), { headers: { Authorization: `Bearer ${tokens[0].accessToken}`, }, - }, + }), ).then((r) => r.json()); expect(notifications.length).toBe(1); @@ -45,13 +51,21 @@ afterAll(async () => { describe(meta.route, () => { test("should return 401 if not authenticated", async () => { const response = await sendTestRequest( - new Request(new URL(meta.route, config.http.base_url)), + new Request( + new URL( + meta.route.replace( + ":id", + "00000000-0000-0000-0000-000000000000", + ), + config.http.base_url, + ), + ), ); expect(response.status).toBe(401); }); - test("should return 404 if ID is invalid", async () => { + test("should return 422 if ID is invalid", async () => { const response = await sendTestRequest( new Request( new URL( @@ -65,7 +79,7 @@ describe(meta.route, () => { }, ), ); - expect(response.status).toBe(404); + expect(response.status).toBe(422); }); test("should return 404 if notification not found", async () => { diff --git a/server/api/api/v1/notifications/:id/index.ts b/server/api/api/v1/notifications/:id/index.ts new file mode 100644 index 00000000..b64c18ce --- /dev/null +++ b/server/api/api/v1/notifications/:id/index.ts @@ -0,0 +1,51 @@ +import { applyConfig, auth, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse } from "@response"; +import type { Hono } from "hono"; +import { z } from "zod"; +import { findManyNotifications } from "~database/entities/Notification"; + +export const meta = applyConfig({ + allowedMethods: ["GET"], + route: "/api/v1/notifications/:id", + ratelimits: { + max: 100, + duration: 60, + }, + auth: { + required: true, + oauthPermissions: ["read:notifications"], + }, +}); + +export const schemas = { + param: z.object({ + id: z.string().uuid(), + }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("param", schemas.param, handleZodError), + auth(meta.auth), + async (context) => { + const { id } = context.req.valid("param"); + + const { user } = context.req.valid("header"); + if (!user) return errorResponse("Unauthorized", 401); + + const notification = ( + await findManyNotifications({ + where: (notification, { eq }) => eq(notification.id, id), + limit: 1, + }) + )[0]; + + if (!notification) + return errorResponse("Notification not found", 404); + + return jsonResponse(notification); + }, + ); diff --git a/server/api/api/v1/notifications/[id]/dismiss.ts b/server/api/api/v1/notifications/[id]/dismiss.ts deleted file mode 100644 index a4bea9e8..00000000 --- a/server/api/api/v1/notifications/[id]/dismiss.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; -import { errorResponse, jsonResponse } from "@response"; -import { eq } from "drizzle-orm"; -import { db } from "~drizzle/db"; -import { Notifications } from "~drizzle/schema"; - -export const meta = applyConfig({ - allowedMethods: ["POST"], - route: "/api/v1/notifications/:id/dismiss", - ratelimits: { - max: 100, - duration: 60, - }, - auth: { - required: true, - oauthPermissions: ["write:notifications"], - }, -}); - -export default apiRoute(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; - if (!id.match(idValidator)) { - return errorResponse("Invalid ID, must be of type UUIDv7", 404); - } - - const { user } = extraData.auth; - if (!user) return errorResponse("Unauthorized", 401); - - await db - .update(Notifications) - .set({ - dismissed: true, - }) - .where(eq(Notifications.id, id)); - - return jsonResponse({}); -}); diff --git a/server/api/api/v1/notifications/[id]/index.ts b/server/api/api/v1/notifications/[id]/index.ts deleted file mode 100644 index 0b176deb..00000000 --- a/server/api/api/v1/notifications/[id]/index.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; -import { errorResponse, jsonResponse } from "@response"; -import { findManyNotifications } from "~database/entities/Notification"; - -export const meta = applyConfig({ - allowedMethods: ["GET"], - route: "/api/v1/notifications/:id", - ratelimits: { - max: 100, - duration: 60, - }, - auth: { - required: true, - oauthPermissions: ["read:notifications"], - }, -}); - -export default apiRoute(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; - if (!id.match(idValidator)) { - return errorResponse("Invalid ID, must be of type UUIDv7", 404); - } - - const { user } = extraData.auth; - if (!user) return errorResponse("Unauthorized", 401); - - const notification = ( - await findManyNotifications({ - where: (notification, { eq }) => eq(notification.id, id), - limit: 1, - }) - )[0]; - - if (!notification) return errorResponse("Notification not found", 404); - - return jsonResponse(notification); -}); diff --git a/server/api/api/v1/notifications/clear/index.test.ts b/server/api/api/v1/notifications/clear/index.test.ts index 44ed13d0..1e1561c4 100644 --- a/server/api/api/v1/notifications/clear/index.test.ts +++ b/server/api/api/v1/notifications/clear/index.test.ts @@ -15,23 +15,29 @@ let notifications: APINotification[] = []; // Create some test notifications: follow, favourite, reblog, mention beforeAll(async () => { - await fetch( - new URL(`/api/v1/accounts/${users[0].id}/follow`, config.http.base_url), - { - method: "POST", - headers: { - Authorization: `Bearer ${tokens[1].accessToken}`, + await sendTestRequest( + new Request( + new URL( + `/api/v1/accounts/${users[0].id}/follow`, + config.http.base_url, + ), + { + method: "POST", + headers: { + Authorization: `Bearer ${tokens[1].accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), }, - }, + ), ); - notifications = await fetch( - new URL("/api/v1/notifications", config.http.base_url), - { + notifications = await sendTestRequest( + new Request(new URL("/api/v1/notifications", config.http.base_url), { headers: { Authorization: `Bearer ${tokens[0].accessToken}`, }, - }, + }), ).then((r) => r.json()); expect(notifications.length).toBe(1); @@ -65,13 +71,15 @@ describe(meta.route, () => { expect(response.status).toBe(200); - const newNotifications = await fetch( - new URL("/api/v1/notifications", config.http.base_url), - { - headers: { - Authorization: `Bearer ${tokens[0].accessToken}`, + const newNotifications = await sendTestRequest( + new Request( + new URL("/api/v1/notifications", config.http.base_url), + { + headers: { + Authorization: `Bearer ${tokens[0].accessToken}`, + }, }, - }, + ), ).then((r) => r.json()); expect(newNotifications.length).toBe(0); diff --git a/server/api/api/v1/notifications/clear/index.ts b/server/api/api/v1/notifications/clear/index.ts index df186dfd..5d88a2f4 100644 --- a/server/api/api/v1/notifications/clear/index.ts +++ b/server/api/api/v1/notifications/clear/index.ts @@ -1,6 +1,7 @@ -import { apiRoute, applyConfig } from "@api"; +import { applyConfig, auth } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { eq } from "drizzle-orm"; +import type { Hono } from "hono"; import { db } from "~drizzle/db"; import { Notifications } from "~drizzle/schema"; @@ -17,16 +18,22 @@ export const meta = applyConfig({ }, }); -export default apiRoute(async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; - if (!user) return errorResponse("Unauthorized", 401); +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + auth(meta.auth), + async (context) => { + const { user } = context.req.valid("header"); + if (!user) return errorResponse("Unauthorized", 401); - await db - .update(Notifications) - .set({ - dismissed: true, - }) - .where(eq(Notifications.notifiedId, user.id)); + await db + .update(Notifications) + .set({ + dismissed: true, + }) + .where(eq(Notifications.notifiedId, user.id)); - return jsonResponse({}); -}); + return jsonResponse({}); + }, + ); diff --git a/server/api/api/v1/notifications/destroy_multiple/index.test.ts b/server/api/api/v1/notifications/destroy_multiple/index.test.ts index fd89d4a7..a6fe96e3 100644 --- a/server/api/api/v1/notifications/destroy_multiple/index.test.ts +++ b/server/api/api/v1/notifications/destroy_multiple/index.test.ts @@ -17,38 +17,48 @@ let notifications: APINotification[] = []; // Create some test notifications beforeAll(async () => { - await fetch( - new URL(`/api/v1/accounts/${users[0].id}/follow`, config.http.base_url), - { - method: "POST", - headers: { - Authorization: `Bearer ${tokens[1].accessToken}`, - }, - }, - ); - - for (const i of [0, 1, 2, 3]) { - await fetch( + await sendTestRequest( + new Request( new URL( - `/api/v1/statuses/${statuses[i].id}/favourite`, + `/api/v1/accounts/${users[0].id}/follow`, config.http.base_url, ), { method: "POST", headers: { Authorization: `Bearer ${tokens[1].accessToken}`, + "Content-Type": "application/json", }, + body: JSON.stringify({}), }, + ), + ); + + for (const i of [0, 1, 2, 3]) { + await sendTestRequest( + new Request( + new URL( + `/api/v1/statuses/${statuses[i].id}/favourite`, + config.http.base_url, + ), + { + method: "POST", + headers: { + Authorization: `Bearer ${tokens[1].accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }, + ), ); } - notifications = await fetch( - new URL("/api/v1/notifications", config.http.base_url), - { + notifications = await sendTestRequest( + new Request(new URL("/api/v1/notifications", config.http.base_url), { headers: { Authorization: `Bearer ${tokens[0].accessToken}`, }, - }, + }), ).then((r) => r.json()); expect(notifications.length).toBe(5); @@ -62,9 +72,17 @@ afterAll(async () => { describe(meta.route, () => { test("should return 401 if not authenticated", async () => { const response = await sendTestRequest( - new Request(new URL(meta.route, config.http.base_url), { - method: "DELETE", - }), + new Request( + new URL( + `${meta.route}?${new URLSearchParams( + notifications.slice(1).map((n) => ["ids[]", n.id]), + ).toString()}`, + config.http.base_url, + ), + { + method: "DELETE", + }, + ), ); expect(response.status).toBe(401); diff --git a/server/api/api/v1/notifications/destroy_multiple/index.ts b/server/api/api/v1/notifications/destroy_multiple/index.ts index 335efc85..3a9c782d 100644 --- a/server/api/api/v1/notifications/destroy_multiple/index.ts +++ b/server/api/api/v1/notifications/destroy_multiple/index.ts @@ -1,6 +1,8 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; +import { applyConfig, auth, handleZodError, idValidator } from "@api"; +import { zValidator } from "@hono/zod-validator"; import { errorResponse, jsonResponse } from "@response"; -import { inArray } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; +import type { Hono } from "hono"; import { z } from "zod"; import { db } from "~drizzle/db"; import { Notifications } from "~drizzle/schema"; @@ -18,24 +20,37 @@ export const meta = applyConfig({ }, }); -export const schema = z.object({ - ids: z.array(z.string().regex(idValidator)), -}); +export const schemas = { + query: z.object({ + "ids[]": z.array(z.string().uuid()), + }), +}; -export default apiRoute( - async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; - if (!user) return errorResponse("Unauthorized", 401); +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("query", schemas.query, handleZodError), + auth(meta.auth), + async (context) => { + const { user } = context.req.valid("header"); - const { ids } = extraData.parsedRequest; + if (!user) return errorResponse("Unauthorized", 401); - await db - .update(Notifications) - .set({ - dismissed: true, - }) - .where(inArray(Notifications.id, ids)); + const { "ids[]": ids } = context.req.valid("query"); - return jsonResponse({}); - }, -); + await db + .update(Notifications) + .set({ + dismissed: true, + }) + .where( + and( + inArray(Notifications.id, ids), + eq(Notifications.notifiedId, user.id), + ), + ); + + return jsonResponse({}); + }, + ); diff --git a/server/api/api/v1/notifications/index.test.ts b/server/api/api/v1/notifications/index.test.ts index b5664885..93db3f52 100644 --- a/server/api/api/v1/notifications/index.test.ts +++ b/server/api/api/v1/notifications/index.test.ts @@ -11,58 +11,87 @@ import { meta } from "./index"; await deleteOldTestUsers(); +const getFormData = (object: Record) => + Object.keys(object).reduce((formData, key) => { + formData.append(key, String(object[key])); + return formData; + }, new FormData()); + const { users, tokens, deleteUsers } = await getTestUsers(2); const timeline = (await getTestStatuses(40, users[0])).toReversed(); // Create some test notifications: follow, favourite, reblog, mention beforeAll(async () => { - await fetch( - new URL(`/api/v1/accounts/${users[0].id}/follow`, config.http.base_url), - { - method: "POST", - headers: { - Authorization: `Bearer ${tokens[1].accessToken}`, + const res1 = await sendTestRequest( + new Request( + new URL( + `/api/v1/accounts/${users[0].id}/follow`, + config.http.base_url, + ), + { + method: "POST", + headers: { + Authorization: `Bearer ${tokens[1].accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), }, - }, - ); - - await fetch( - new URL( - `/api/v1/statuses/${timeline[0].id}/favourite`, - config.http.base_url, ), - { - method: "POST", - headers: { - Authorization: `Bearer ${tokens[1].accessToken}`, - }, - }, ); - await fetch( - new URL( - `/api/v1/statuses/${timeline[0].id}/reblog`, - config.http.base_url, + expect(res1.status).toBe(200); + + const res2 = await sendTestRequest( + new Request( + new URL( + `/api/v1/statuses/${timeline[0].id}/favourite`, + config.http.base_url, + ), + { + method: "POST", + headers: { + Authorization: `Bearer ${tokens[1].accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }, ), - { + ); + + expect(res2.status).toBe(200); + + const res3 = await sendTestRequest( + new Request( + new URL( + `/api/v1/statuses/${timeline[0].id}/reblog`, + config.http.base_url, + ), + { + method: "POST", + headers: { + Authorization: `Bearer ${tokens[1].accessToken}`, + }, + body: getFormData({}), + }, + ), + ); + + expect(res3.status).toBe(200); + + const res4 = await sendTestRequest( + new Request(new URL("/api/v1/statuses", config.http.base_url), { method: "POST", headers: { Authorization: `Bearer ${tokens[1].accessToken}`, }, - }, - ); - - await fetch(new URL("/api/v1/statuses", config.http.base_url), { - method: "POST", - headers: { - Authorization: `Bearer ${tokens[1].accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - status: `@${users[0].getUser().username} test mention`, - visibility: "direct", - federate: false, + body: getFormData({ + status: `@${users[0].getUser().username} test mention`, + visibility: "direct", + federate: false, + }), }), - }); + ); + + expect(res4.status).toBe(200); }); afterAll(async () => { @@ -109,24 +138,21 @@ describe(meta.route, () => { }); test("should not return notifications with filtered keywords", async () => { - const formData = new FormData(); - - formData.append("title", "Test Filter"); - formData.append("context[]", "notifications"); - formData.append("filter_action", "hide"); - formData.append( - "keywords_attributes[0][keyword]", - timeline[0].content.slice(4, 20), - ); - formData.append("keywords_attributes[0][whole_word]", "false"); - const filterResponse = await sendTestRequest( new Request(new URL("/api/v2/filters", config.http.base_url), { method: "POST", headers: { Authorization: `Bearer ${tokens[0].accessToken}`, + "Content-Type": "application/x-www-form-urlencoded", }, - body: formData, + body: new URLSearchParams({ + title: "Test Filter", + "context[]": "notifications", + filter_action: "hide", + "keywords_attributes[0][keyword]": + timeline[0].content.slice(4, 20), + "keywords_attributes[0][whole_word]": "false", + }), }), ); diff --git a/server/api/api/v1/notifications/index.ts b/server/api/api/v1/notifications/index.ts index b3e44079..1da9c650 100644 --- a/server/api/api/v1/notifications/index.ts +++ b/server/api/api/v1/notifications/index.ts @@ -1,7 +1,9 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; +import { apiRoute, applyConfig, auth, handleZodError, idValidator } from "@api"; +import { zValidator } from "@hono/zod-validator"; import { errorResponse, jsonResponse } from "@response"; import { fetchTimeline } from "@timelines"; import { sql } from "drizzle-orm"; +import type { Hono } from "hono"; import { z } from "zod"; import { findManyNotifications, @@ -22,144 +24,164 @@ export const meta = applyConfig({ }, }); -export const schema = z.object({ - max_id: z.string().regex(idValidator).optional(), - since_id: z.string().regex(idValidator).optional(), - min_id: z.string().regex(idValidator).optional(), - limit: z.coerce.number().int().min(1).max(80).optional().default(15), - exclude_types: z - .enum([ - "mention", - "status", - "follow", - "follow_request", - "reblog", - "poll", - "favourite", - "update", - "admin.sign_up", - "admin.report", - "chat", - "pleroma:chat_mention", - "pleroma:emoji_reaction", - "pleroma:event_reminder", - "pleroma:participation_request", - "pleroma:participation_accepted", - "move", - "group_reblog", - "group_favourite", - "user_approved", - ]) - .array() - .optional(), - types: z - .enum([ - "mention", - "status", - "follow", - "follow_request", - "reblog", - "poll", - "favourite", - "update", - "admin.sign_up", - "admin.report", - "chat", - "pleroma:chat_mention", - "pleroma:emoji_reaction", - "pleroma:event_reminder", - "pleroma:participation_request", - "pleroma:participation_accepted", - "move", - "group_reblog", - "group_favourite", - "user_approved", - ]) - .array() - .optional(), - account_id: z.string().regex(idValidator).optional(), -}); +export const schemas = { + query: z.object({ + max_id: z.string().regex(idValidator).optional(), + since_id: z.string().regex(idValidator).optional(), + min_id: z.string().regex(idValidator).optional(), + limit: z.coerce.number().int().min(1).max(80).default(15), + exclude_types: z + .enum([ + "mention", + "status", + "follow", + "follow_request", + "reblog", + "poll", + "favourite", + "update", + "admin.sign_up", + "admin.report", + "chat", + "pleroma:chat_mention", + "pleroma:emoji_reaction", + "pleroma:event_reminder", + "pleroma:participation_request", + "pleroma:participation_accepted", + "move", + "group_reblog", + "group_favourite", + "user_approved", + ]) + .array() + .optional(), + types: z + .enum([ + "mention", + "status", + "follow", + "follow_request", + "reblog", + "poll", + "favourite", + "update", + "admin.sign_up", + "admin.report", + "chat", + "pleroma:chat_mention", + "pleroma:emoji_reaction", + "pleroma:event_reminder", + "pleroma:participation_request", + "pleroma:participation_accepted", + "move", + "group_reblog", + "group_favourite", + "user_approved", + ]) + .array() + .optional(), + account_id: z.string().regex(idValidator).optional(), + }), +}; -export default apiRoute( - async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("query", schemas.query, handleZodError), + auth(meta.auth), + async (context) => { + const { user } = context.req.valid("header"); + if (!user) return errorResponse("Unauthorized", 401); - if (!user) return errorResponse("Unauthorized", 401); + const { + account_id, + exclude_types, + limit, + max_id, + min_id, + since_id, + types, + } = context.req.valid("query"); - const { - account_id, - exclude_types, - limit, - max_id, - min_id, - since_id, - types, - } = extraData.parsedRequest; + if (types && exclude_types) { + return errorResponse( + "Can't use both types and exclude_types", + 400, + ); + } - if (types && exclude_types) { - return errorResponse("Can't use both types and exclude_types", 400); - } - - const { objects, link } = - await fetchTimeline( - findManyNotifications, - { - where: ( - // @ts-expect-error Yes I KNOW the types are wrong - notification, - // @ts-expect-error Yes I KNOW the types are wrong - { lt, gte, gt, and, eq, not, inArray }, - ) => - and( - max_id ? lt(notification.id, max_id) : undefined, - since_id - ? gte(notification.id, since_id) - : undefined, - min_id ? gt(notification.id, min_id) : undefined, - eq(notification.notifiedId, user.id), - eq(notification.dismissed, false), - account_id - ? eq(notification.accountId, account_id) - : undefined, - not(eq(notification.accountId, user.id)), - types - ? inArray(notification.type, types) - : undefined, - exclude_types - ? not(inArray(notification.type, exclude_types)) - : undefined, - // Don't show notes that have filtered words in them (via Notification.note.content via Notification.noteId) - // Filters in `Filters` table have keyword in `FilterKeywords` table (use LIKE) - // Filters table has a userId and a context which is an array - sql`NOT EXISTS ( - SELECT 1 - FROM "Filters" - WHERE "Filters"."userId" = ${user.id} - AND "Filters"."filter_action" = 'hide' - AND EXISTS ( + const { objects, link } = + await fetchTimeline( + findManyNotifications, + { + where: ( + // @ts-expect-error Yes I KNOW the types are wrong + notification, + // @ts-expect-error Yes I KNOW the types are wrong + { lt, gte, gt, and, eq, not, inArray }, + ) => + and( + max_id + ? lt(notification.id, max_id) + : undefined, + since_id + ? gte(notification.id, since_id) + : undefined, + min_id + ? gt(notification.id, min_id) + : undefined, + eq(notification.notifiedId, user.id), + eq(notification.dismissed, false), + account_id + ? eq(notification.accountId, account_id) + : undefined, + not(eq(notification.accountId, user.id)), + types + ? inArray(notification.type, types) + : undefined, + exclude_types + ? not( + inArray( + notification.type, + exclude_types, + ), + ) + : undefined, + // Don't show notes that have filtered words in them (via Notification.note.content via Notification.noteId) + // Filters in `Filters` table have keyword in `FilterKeywords` table (use LIKE) + // Filters table has a userId and a context which is an array + sql`NOT EXISTS ( SELECT 1 - FROM "FilterKeywords", "Notifications" as "n_inner", "Notes" - WHERE "FilterKeywords"."filterId" = "Filters"."id" - AND "n_inner"."noteId" = "Notes"."id" - AND "Notes"."content" LIKE '%' || "FilterKeywords"."keyword" || '%' - AND "n_inner"."id" = "Notifications"."id" - ) - AND "Filters"."context" @> ARRAY['notifications'] - )`, - ), - limit, - // @ts-expect-error Yes I KNOW the types are wrong - orderBy: (notification, { desc }) => desc(notification.id), - }, - req, - ); + FROM "Filters" + WHERE "Filters"."userId" = ${user.id} + AND "Filters"."filter_action" = 'hide' + AND EXISTS ( + SELECT 1 + FROM "FilterKeywords", "Notifications" as "n_inner", "Notes" + WHERE "FilterKeywords"."filterId" = "Filters"."id" + AND "n_inner"."noteId" = "Notes"."id" + AND "Notes"."content" LIKE + '%' || "FilterKeywords"."keyword" || '%' + AND "n_inner"."id" = "Notifications"."id" + ) + AND "Filters"."context" @> ARRAY['notifications'] + )`, + ), + limit, + // @ts-expect-error Yes I KNOW the types are wrong + orderBy: (notification, { desc }) => + desc(notification.id), + }, + context.req.raw, + ); - return jsonResponse( - await Promise.all(objects.map((n) => notificationToAPI(n))), - 200, - { - Link: link, - }, - ); - }, -); + return jsonResponse( + await Promise.all(objects.map((n) => notificationToAPI(n))), + 200, + { + Link: link, + }, + ); + }, + ); diff --git a/server/api/api/v1/profile/avatar.ts b/server/api/api/v1/profile/avatar.ts index f96c0a81..5fb7d77a 100644 --- a/server/api/api/v1/profile/avatar.ts +++ b/server/api/api/v1/profile/avatar.ts @@ -1,6 +1,7 @@ -import { apiRoute, applyConfig } from "@api"; +import { applyConfig, auth } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { eq } from "drizzle-orm"; +import type { Hono } from "hono"; import { db } from "~drizzle/db"; import { Users } from "~drizzle/schema"; import { User } from "~packages/database-interface/user"; @@ -17,18 +18,24 @@ export const meta = applyConfig({ }, }); -/** - * Deletes a user avatar - */ -export default apiRoute(async (req, matchedRoute, extraData) => { - const { user: self } = extraData.auth; +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + auth(meta.auth), + async (context) => { + const { user: self } = context.req.valid("header"); - if (!self) return errorResponse("Unauthorized", 401); + if (!self) return errorResponse("Unauthorized", 401); - await db.update(Users).set({ avatar: "" }).where(eq(Users.id, self.id)); + await db + .update(Users) + .set({ avatar: "" }) + .where(eq(Users.id, self.id)); - return jsonResponse({ - ...(await User.fromId(self.id))?.toAPI(), - avatar: "", - }); -}); + return jsonResponse({ + ...(await User.fromId(self.id))?.toAPI(), + avatar: "", + }); + }, + ); diff --git a/server/api/api/v1/profile/header.ts b/server/api/api/v1/profile/header.ts index bd2676b3..f5fce412 100644 --- a/server/api/api/v1/profile/header.ts +++ b/server/api/api/v1/profile/header.ts @@ -1,6 +1,7 @@ -import { apiRoute, applyConfig } from "@api"; +import { applyConfig, auth } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { eq } from "drizzle-orm"; +import type { Hono } from "hono"; import { db } from "~drizzle/db"; import { Users } from "~drizzle/schema"; import { User } from "~packages/database-interface/user"; @@ -17,19 +18,24 @@ export const meta = applyConfig({ }, }); -/** - * Deletes a user header - */ -export default apiRoute(async (req, matchedRoute, extraData) => { - const { user: self } = extraData.auth; +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + auth(meta.auth), + async (context) => { + const { user: self } = context.req.valid("header"); - if (!self) return errorResponse("Unauthorized", 401); + if (!self) return errorResponse("Unauthorized", 401); - // Delete user header - await db.update(Users).set({ header: "" }).where(eq(Users.id, self.id)); + await db + .update(Users) + .set({ header: "" }) + .where(eq(Users.id, self.id)); - return jsonResponse({ - ...(await User.fromId(self.id))?.toAPI(), - header: "", - }); -}); + return jsonResponse({ + ...(await User.fromId(self.id))?.toAPI(), + header: "", + }); + }, + ); diff --git a/server/api/api/v1/statuses/:id/context.ts b/server/api/api/v1/statuses/:id/context.ts new file mode 100644 index 00000000..6bd8c355 --- /dev/null +++ b/server/api/api/v1/statuses/:id/context.ts @@ -0,0 +1,54 @@ +import { applyConfig, auth, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse } from "@response"; +import type { Hono } from "hono"; +import { z } from "zod"; +import { Note } from "~packages/database-interface/note"; + +export const meta = applyConfig({ + allowedMethods: ["GET"], + ratelimits: { + max: 8, + duration: 60, + }, + route: "/api/v1/statuses/:id/context", + auth: { + required: false, + }, +}); + +export const schemas = { + param: z.object({ + id: z.string().uuid(), + }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("param", schemas.param, handleZodError), + auth(meta.auth), + async (context) => { + const { id } = context.req.valid("param"); + + const { user } = context.req.valid("header"); + + const foundStatus = await Note.fromId(id); + + if (!foundStatus) return errorResponse("Record not found", 404); + + const ancestors = await foundStatus.getAncestors(user ?? null); + + const descendants = await foundStatus.getDescendants(user ?? null); + + return jsonResponse({ + ancestors: await Promise.all( + ancestors.map((status) => status.toAPI(user)), + ), + descendants: await Promise.all( + descendants.map((status) => status.toAPI(user)), + ), + }); + }, + ); diff --git a/server/api/api/v1/statuses/:id/favourite.ts b/server/api/api/v1/statuses/:id/favourite.ts new file mode 100644 index 00000000..19d9257d --- /dev/null +++ b/server/api/api/v1/statuses/:id/favourite.ts @@ -0,0 +1,65 @@ +import { applyConfig, auth, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse } from "@response"; +import type { Hono } from "hono"; +import { z } from "zod"; +import { createLike } from "~database/entities/Like"; +import { db } from "~drizzle/db"; +import { Note } from "~packages/database-interface/note"; +import type { Status as APIStatus } from "~types/mastodon/status"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 100, + duration: 60, + }, + route: "/api/v1/statuses/:id/favourite", + auth: { + required: true, + }, +}); + +export const schemas = { + param: z.object({ + id: z.string().uuid(), + }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("param", schemas.param, handleZodError), + auth(meta.auth), + async (context) => { + const { id } = context.req.valid("param"); + + const { user } = context.req.valid("header"); + + if (!user) return errorResponse("Unauthorized", 401); + + const note = await Note.fromId(id); + + if (!note?.isViewableByUser(user)) + return errorResponse("Record not found", 404); + + const existingLike = await db.query.Likes.findFirst({ + where: (like, { and, eq }) => + and( + eq(like.likedId, note.getStatus().id), + eq(like.likerId, user.id), + ), + }); + + if (!existingLike) { + await createLike(user, note); + } + + return jsonResponse({ + ...(await note.toAPI(user)), + favourited: true, + favourites_count: note.getStatus().likeCount + 1, + } as APIStatus); + }, + ); diff --git a/server/api/api/v1/statuses/[id]/favourited_by.test.ts b/server/api/api/v1/statuses/:id/favourited_by.test.ts similarity index 83% rename from server/api/api/v1/statuses/[id]/favourited_by.test.ts rename to server/api/api/v1/statuses/:id/favourited_by.test.ts index 9da0af16..34ee0716 100644 --- a/server/api/api/v1/statuses/[id]/favourited_by.test.ts +++ b/server/api/api/v1/statuses/:id/favourited_by.test.ts @@ -20,17 +20,19 @@ afterAll(async () => { beforeAll(async () => { for (const status of timeline) { - const res = await fetch( - new URL( - `/api/v1/statuses/${status.id}/favourite`, - config.http.base_url, - ), - { - method: "POST", - headers: { - Authorization: `Bearer ${tokens[1].accessToken}`, + const res = await sendTestRequest( + new Request( + new URL( + `/api/v1/statuses/${status.id}/favourite`, + config.http.base_url, + ), + { + method: "POST", + headers: { + Authorization: `Bearer ${tokens[1].accessToken}`, + }, }, - }, + ), ); } }); diff --git a/server/api/api/v1/statuses/:id/favourited_by.ts b/server/api/api/v1/statuses/:id/favourited_by.ts new file mode 100644 index 00000000..8dcc403c --- /dev/null +++ b/server/api/api/v1/statuses/:id/favourited_by.ts @@ -0,0 +1,75 @@ +import { applyConfig, auth, handleZodError, idValidator } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse } from "@response"; +import { and, gt, gte, lt, sql } from "drizzle-orm"; +import type { Hono } from "hono"; +import { z } from "zod"; +import { Users } from "~drizzle/schema"; +import { Note } from "~packages/database-interface/note"; +import { Timeline } from "~packages/database-interface/timeline"; + +export const meta = applyConfig({ + allowedMethods: ["GET"], + ratelimits: { + max: 100, + duration: 60, + }, + route: "/api/v1/statuses/:id/favourited_by", + auth: { + required: true, + }, +}); + +export const schemas = { + query: z.object({ + max_id: z.string().regex(idValidator).optional(), + since_id: z.string().regex(idValidator).optional(), + min_id: z.string().regex(idValidator).optional(), + limit: z.coerce.number().int().min(1).max(80).default(40), + }), + param: z.object({ + id: z.string().uuid(), + }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("query", schemas.query, handleZodError), + zValidator("param", schemas.param, handleZodError), + auth(meta.auth), + async (context) => { + const { max_id, since_id, min_id, limit } = + context.req.valid("query"); + const { id } = context.req.valid("param"); + + const { user } = context.req.valid("header"); + + if (!user) return errorResponse("Unauthorized", 401); + + const status = await Note.fromId(id); + + if (!status?.isViewableByUser(user)) + return errorResponse("Record not found", 404); + + const { objects, link } = await Timeline.getUserTimeline( + and( + max_id ? lt(Users.id, max_id) : undefined, + since_id ? gte(Users.id, since_id) : undefined, + min_id ? gt(Users.id, min_id) : undefined, + sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = ${status.id} AND "Likes"."likerId" = ${Users.id})`, + ), + limit, + context.req.url, + ); + + return jsonResponse( + objects.map((user) => user.toAPI()), + 200, + { + Link: link, + }, + ); + }, + ); diff --git a/server/api/api/v1/statuses/:id/index.ts b/server/api/api/v1/statuses/:id/index.ts new file mode 100644 index 00000000..3fc8e231 --- /dev/null +++ b/server/api/api/v1/statuses/:id/index.ts @@ -0,0 +1,160 @@ +import { applyConfig, auth, handleZodError, idValidator } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse } from "@response"; +import { config } from "config-manager"; +import type { Hono } from "hono"; +import ISO6391 from "iso-639-1"; +import { z } from "zod"; +import { db } from "~drizzle/db"; +import { Note } from "~packages/database-interface/note"; + +export const meta = applyConfig({ + allowedMethods: ["GET", "DELETE", "PUT"], + ratelimits: { + max: 100, + duration: 60, + }, + route: "/api/v1/statuses/:id", + auth: { + required: false, + requiredOnMethods: ["DELETE", "PUT"], + }, +}); + +export const schemas = { + param: z.object({ + id: z.string().regex(idValidator), + }), + form: z.object({ + status: z.string().max(config.validation.max_note_size).optional(), + content_type: z.string().optional().default("text/plain"), + media_ids: z + .array(z.string().regex(idValidator)) + .max(config.validation.max_media_attachments) + .optional(), + spoiler_text: z.string().max(255).optional(), + sensitive: z + .string() + .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) + .optional(), + language: z + .enum(ISO6391.getAllCodes() as [string, ...string[]]) + .optional(), + "poll[options]": z + .array(z.string().max(config.validation.max_poll_option_size)) + .max(config.validation.max_poll_options) + .optional(), + "poll[expires_in]": z + .number() + .int() + .min(config.validation.min_poll_duration) + .max(config.validation.max_poll_duration) + .optional(), + "poll[multiple]": z + .string() + .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) + .optional(), + "poll[hide_totals]": z + .string() + .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) + .optional(), + }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("param", schemas.param, handleZodError), + zValidator("form", schemas.form, handleZodError), + auth(meta.auth), + async (context) => { + const { id } = context.req.valid("param"); + const { user } = context.req.valid("header"); + + const foundStatus = await Note.fromId(id); + + if (!foundStatus?.isViewableByUser(user)) + return errorResponse("Record not found", 404); + + if (context.req.method === "GET") { + return jsonResponse(await foundStatus.toAPI(user)); + } + if (context.req.method === "DELETE") { + if (foundStatus.getAuthor().id !== user?.id) { + return errorResponse("Unauthorized", 401); + } + + // TODO: Delete and redraft + + await foundStatus.delete(); + + return jsonResponse(await foundStatus.toAPI(user), 200); + } + + // TODO: Polls + const { + status: statusText, + content_type, + "poll[options]": options, + media_ids, + spoiler_text, + sensitive, + } = context.req.valid("form"); + + if (!statusText && !(media_ids && media_ids.length > 0)) { + return errorResponse( + "Status is required unless media is attached", + 422, + ); + } + + if (media_ids && media_ids.length > 0 && options) { + return errorResponse( + "Cannot attach poll to post with media", + 422, + ); + } + + if ( + config.filters.note_content.some((filter) => + statusText?.match(filter), + ) + ) { + return errorResponse("Status contains blocked words", 422); + } + + if (media_ids && media_ids.length > 0) { + const foundAttachments = await db.query.Attachments.findMany({ + where: (attachment, { inArray }) => + inArray(attachment.id, media_ids), + }); + + if (foundAttachments.length !== (media_ids ?? []).length) { + return errorResponse("Invalid media IDs", 422); + } + } + + const newNote = await foundStatus.updateFromData( + statusText + ? { + [content_type]: { + content: statusText, + }, + } + : undefined, + undefined, + sensitive, + spoiler_text, + undefined, + undefined, + media_ids, + ); + + if (!newNote) { + return errorResponse("Failed to update status", 500); + } + + return jsonResponse(await newNote.toAPI(user)); + }, + ); diff --git a/server/api/api/v1/statuses/:id/pin.ts b/server/api/api/v1/statuses/:id/pin.ts new file mode 100644 index 00000000..0c1e2bc0 --- /dev/null +++ b/server/api/api/v1/statuses/:id/pin.ts @@ -0,0 +1,65 @@ +import { applyConfig, auth, handleZodError, idValidator } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse } from "@response"; +import type { Hono } from "hono"; +import { z } from "zod"; +import { db } from "~drizzle/db"; +import { Note } from "~packages/database-interface/note"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 100, + duration: 60, + }, + route: "/api/v1/statuses/:id/pin", + auth: { + required: true, + }, +}); + +export const schemas = { + param: z.object({ + id: z.string().regex(idValidator), + }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("param", schemas.param, handleZodError), + auth(meta.auth), + async (context) => { + const { id } = context.req.valid("param"); + const { user } = context.req.valid("header"); + + if (!user) return errorResponse("Unauthorized", 401); + + const foundStatus = await Note.fromId(id); + + if (!foundStatus) return errorResponse("Record not found", 404); + + if (foundStatus.getAuthor().id !== user.id) + return errorResponse("Unauthorized", 401); + + if ( + await db.query.UserToPinnedNotes.findFirst({ + where: (userPinnedNote, { and, eq }) => + and( + eq( + userPinnedNote.noteId, + foundStatus.getStatus().id, + ), + eq(userPinnedNote.userId, user.id), + ), + }) + ) { + return errorResponse("Already pinned", 422); + } + + await user.pin(foundStatus); + + return jsonResponse(await foundStatus.toAPI(user)); + }, + ); diff --git a/server/api/api/v1/statuses/:id/reblog.ts b/server/api/api/v1/statuses/:id/reblog.ts new file mode 100644 index 00000000..b1025b06 --- /dev/null +++ b/server/api/api/v1/statuses/:id/reblog.ts @@ -0,0 +1,92 @@ +import { applyConfig, auth, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse } from "@response"; +import { and, eq } from "drizzle-orm"; +import type { Hono } from "hono"; +import { z } from "zod"; +import { db } from "~drizzle/db"; +import { Notes, Notifications } from "~drizzle/schema"; +import { Note } from "~packages/database-interface/note"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 100, + duration: 60, + }, + route: "/api/v1/statuses/:id/reblog", + auth: { + required: true, + }, +}); + +export const schemas = { + param: z.object({ + id: z.string().uuid(), + }), + form: z.object({ + visibility: z.enum(["public", "unlisted", "private"]).default("public"), + }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("param", schemas.param, handleZodError), + zValidator("form", schemas.form, handleZodError), + auth(meta.auth), + async (context) => { + const { id } = context.req.valid("param"); + const { visibility } = context.req.valid("form"); + const { user } = context.req.valid("header"); + + if (!user) return errorResponse("Unauthorized", 401); + + const foundStatus = await Note.fromId(id); + + if (!foundStatus?.isViewableByUser(user)) + return errorResponse("Record not found", 404); + + const existingReblog = await Note.fromSql( + and( + eq(Notes.authorId, user.id), + eq(Notes.reblogId, foundStatus.getStatus().id), + ), + ); + + if (existingReblog) { + return errorResponse("Already reblogged", 422); + } + + const newReblog = await Note.insert({ + authorId: user.id, + reblogId: foundStatus.getStatus().id, + visibility, + sensitive: false, + updatedAt: new Date().toISOString(), + applicationId: null, + }); + + if (!newReblog) { + return errorResponse("Failed to reblog", 500); + } + + const finalNewReblog = await Note.fromId(newReblog.id); + + if (!finalNewReblog) { + return errorResponse("Failed to reblog", 500); + } + + if (foundStatus.getAuthor().isLocal() && user.isLocal()) { + await db.insert(Notifications).values({ + accountId: user.id, + notifiedId: foundStatus.getAuthor().id, + type: "reblog", + noteId: newReblog.reblogId, + }); + } + + return jsonResponse(await finalNewReblog.toAPI(user)); + }, + ); diff --git a/server/api/api/v1/statuses/[id]/reblogged_by.test.ts b/server/api/api/v1/statuses/:id/reblogged_by.test.ts similarity index 83% rename from server/api/api/v1/statuses/[id]/reblogged_by.test.ts rename to server/api/api/v1/statuses/:id/reblogged_by.test.ts index 02e131fd..29d79c2e 100644 --- a/server/api/api/v1/statuses/[id]/reblogged_by.test.ts +++ b/server/api/api/v1/statuses/:id/reblogged_by.test.ts @@ -20,17 +20,19 @@ afterAll(async () => { beforeAll(async () => { for (const status of timeline) { - await fetch( - new URL( - `/api/v1/statuses/${status.id}/reblog`, - config.http.base_url, - ), - { - method: "POST", - headers: { - Authorization: `Bearer ${tokens[1].accessToken}`, + await sendTestRequest( + new Request( + new URL( + `/api/v1/statuses/${status.id}/reblog`, + config.http.base_url, + ), + { + method: "POST", + headers: { + Authorization: `Bearer ${tokens[1].accessToken}`, + }, }, - }, + ), ); } }); diff --git a/server/api/api/v1/statuses/:id/reblogged_by.ts b/server/api/api/v1/statuses/:id/reblogged_by.ts new file mode 100644 index 00000000..77bbff9a --- /dev/null +++ b/server/api/api/v1/statuses/:id/reblogged_by.ts @@ -0,0 +1,74 @@ +import { applyConfig, auth, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse } from "@response"; +import { and, gt, gte, lt, sql } from "drizzle-orm"; +import type { Hono } from "hono"; +import { z } from "zod"; +import { Users } from "~drizzle/schema"; +import { Note } from "~packages/database-interface/note"; +import { Timeline } from "~packages/database-interface/timeline"; + +export const meta = applyConfig({ + allowedMethods: ["GET"], + ratelimits: { + max: 100, + duration: 60, + }, + route: "/api/v1/statuses/:id/reblogged_by", + auth: { + required: true, + }, +}); + +export const schemas = { + param: z.object({ + id: z.string().uuid(), + }), + query: z.object({ + max_id: z.string().uuid().optional(), + since_id: z.string().uuid().optional(), + min_id: z.string().uuid().optional(), + limit: z.coerce.number().int().min(1).max(80).default(40), + }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("param", schemas.param, handleZodError), + zValidator("query", schemas.query, handleZodError), + auth(meta.auth), + async (context) => { + const { id } = context.req.valid("param"); + const { max_id, min_id, since_id, limit } = + context.req.valid("query"); + const { user } = context.req.valid("header"); + + if (!user) return errorResponse("Unauthorized", 401); + + const status = await Note.fromId(id); + + if (!status?.isViewableByUser(user)) + return errorResponse("Record not found", 404); + + const { objects, link } = await Timeline.getUserTimeline( + and( + max_id ? lt(Users.id, max_id) : undefined, + since_id ? gte(Users.id, since_id) : undefined, + min_id ? gt(Users.id, min_id) : undefined, + sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."reblogId" = ${status.id} AND "Notes"."authorId" = ${Users.id})`, + ), + limit, + context.req.url, + ); + + return jsonResponse( + objects.map((user) => user.toAPI()), + 200, + { + Link: link, + }, + ); + }, + ); diff --git a/server/api/api/v1/statuses/:id/source.ts b/server/api/api/v1/statuses/:id/source.ts new file mode 100644 index 00000000..7fcff32d --- /dev/null +++ b/server/api/api/v1/statuses/:id/source.ts @@ -0,0 +1,51 @@ +import { applyConfig, auth, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse } from "@response"; +import type { Hono } from "hono"; +import { z } from "zod"; +import { Note } from "~packages/database-interface/note"; +import type { StatusSource as APIStatusSource } from "~types/mastodon/status_source"; + +export const meta = applyConfig({ + allowedMethods: ["GET"], + ratelimits: { + max: 100, + duration: 60, + }, + route: "/api/v1/statuses/:id/source", + auth: { + required: true, + }, +}); + +export const schemas = { + param: z.object({ + id: z.string().uuid(), + }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("param", schemas.param, handleZodError), + auth(meta.auth), + async (context) => { + const { id } = context.req.valid("param"); + const { user } = context.req.valid("header"); + + if (!user) return errorResponse("Unauthorized", 401); + + const status = await Note.fromId(id); + + if (!status?.isViewableByUser(user)) + return errorResponse("Record not found", 404); + + return jsonResponse({ + id: status.id, + // TODO: Give real source for spoilerText + spoiler_text: status.getStatus().spoilerText, + text: status.getStatus().contentSource, + } as APIStatusSource); + }, + ); diff --git a/server/api/api/v1/statuses/:id/unfavourite.ts b/server/api/api/v1/statuses/:id/unfavourite.ts new file mode 100644 index 00000000..9f183e16 --- /dev/null +++ b/server/api/api/v1/statuses/:id/unfavourite.ts @@ -0,0 +1,53 @@ +import { applyConfig, auth, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse } from "@response"; +import type { Hono } from "hono"; +import { z } from "zod"; +import { deleteLike } from "~database/entities/Like"; +import { Note } from "~packages/database-interface/note"; +import type { Status as APIStatus } from "~types/mastodon/status"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 100, + duration: 60, + }, + route: "/api/v1/statuses/:id/unfavourite", + auth: { + required: true, + }, +}); + +export const schemas = { + param: z.object({ + id: z.string().uuid(), + }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("param", schemas.param, handleZodError), + auth(meta.auth), + async (context) => { + const { id } = context.req.valid("param"); + const { user } = context.req.valid("header"); + + if (!user) return errorResponse("Unauthorized", 401); + + const note = await Note.fromId(id); + + if (!note?.isViewableByUser(user)) + return errorResponse("Record not found", 404); + + await deleteLike(user, note); + + return jsonResponse({ + ...(await note.toAPI(user)), + favourited: false, + favourites_count: note.getStatus().likeCount - 1, + } as APIStatus); + }, + ); diff --git a/server/api/api/v1/statuses/:id/unpin.ts b/server/api/api/v1/statuses/:id/unpin.ts new file mode 100644 index 00000000..4188953b --- /dev/null +++ b/server/api/api/v1/statuses/:id/unpin.ts @@ -0,0 +1,51 @@ +import { applyConfig, auth, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse } from "@response"; +import type { Hono } from "hono"; +import { z } from "zod"; +import { Note } from "~packages/database-interface/note"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 100, + duration: 60, + }, + route: "/api/v1/statuses/:id/unpin", + auth: { + required: true, + }, +}); + +export const schemas = { + param: z.object({ + id: z.string().uuid(), + }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("param", schemas.param, handleZodError), + auth(meta.auth), + async (context) => { + const { id } = context.req.valid("param"); + const { user } = context.req.valid("header"); + + if (!user) return errorResponse("Unauthorized", 401); + + const status = await Note.fromId(id); + + if (!status) return errorResponse("Record not found", 404); + + if (status.getAuthor().id !== user.id) + return errorResponse("Unauthorized", 401); + + await user.unpin(status); + + if (!status) return errorResponse("Record not found", 404); + + return jsonResponse(await status.toAPI(user)); + }, + ); diff --git a/server/api/api/v1/statuses/:id/unreblog.ts b/server/api/api/v1/statuses/:id/unreblog.ts new file mode 100644 index 00000000..efa7ece2 --- /dev/null +++ b/server/api/api/v1/statuses/:id/unreblog.ts @@ -0,0 +1,68 @@ +import { applyConfig, auth, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse } from "@response"; +import { and, eq } from "drizzle-orm"; +import type { Hono } from "hono"; +import { z } from "zod"; +import { Notes } from "~drizzle/schema"; +import { Note } from "~packages/database-interface/note"; +import type { Status as APIStatus } from "~types/mastodon/status"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + ratelimits: { + max: 100, + duration: 60, + }, + route: "/api/v1/statuses/:id/unreblog", + auth: { + required: true, + }, +}); + +export const schemas = { + param: z.object({ + id: z.string().uuid(), + }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("param", schemas.param, handleZodError), + auth(meta.auth), + async (context) => { + const { id } = context.req.valid("param"); + const { user } = context.req.valid("header"); + + if (!user) return errorResponse("Unauthorized", 401); + + if (!user) return errorResponse("Unauthorized", 401); + + const foundStatus = await Note.fromId(id); + + // Check if user is authorized to view this status (if it's private) + if (!foundStatus?.isViewableByUser(user)) + return errorResponse("Record not found", 404); + + const existingReblog = await Note.fromSql( + and( + eq(Notes.authorId, user.id), + eq(Notes.reblogId, foundStatus.getStatus().id), + ), + ); + + if (!existingReblog) { + return errorResponse("Not already reblogged", 422); + } + + await existingReblog.delete(); + + return jsonResponse({ + ...(await foundStatus.toAPI(user)), + reblogged: false, + reblogs_count: foundStatus.getStatus().reblogCount - 1, + } as APIStatus); + }, + ); diff --git a/server/api/api/v1/statuses/[id]/context.ts b/server/api/api/v1/statuses/[id]/context.ts deleted file mode 100644 index 5df74103..00000000 --- a/server/api/api/v1/statuses/[id]/context.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; -import { errorResponse, jsonResponse } from "@response"; -import type { Relationship } from "~database/entities/Relationship"; -import { db } from "~drizzle/db"; -import { Note } from "~packages/database-interface/note"; - -export const meta = applyConfig({ - allowedMethods: ["GET"], - ratelimits: { - max: 8, - duration: 60, - }, - route: "/api/v1/statuses/:id/context", - auth: { - required: false, - }, -}); - -/** - * Fetch a user - */ -export default apiRoute(async (req, matchedRoute, extraData) => { - // Public for public statuses limited to 40 ancestors and 60 descendants with a maximum depth of 20. - // User token + read:statuses for up to 4,096 ancestors, 4,096 descendants, unlimited depth, and private statuses. - const id = matchedRoute.params.id; - if (!id.match(idValidator)) { - return errorResponse("Invalid ID, must be of type UUIDv7", 404); - } - - const { user } = extraData.auth; - - const foundStatus = await Note.fromId(id); - - if (!foundStatus) return errorResponse("Record not found", 404); - - const relations = user - ? await db.query.Relationships.findMany({ - where: (relationship, { eq }) => - eq(relationship.ownerId, user.id), - }) - : null; - - const relationSubjects = user - ? await db.query.Relationships.findMany({ - where: (relationship, { eq }) => - eq(relationship.subjectId, user.id), - }) - : null; - - // Get all ancestors - const ancestors = await foundStatus.getAncestors( - user - ? { - ...user, - relationships: relations as Relationship[], - relationshipSubjects: relationSubjects as Relationship[], - } - : null, - ); - - const descendants = await foundStatus.getDescendants( - user - ? { - ...user, - relationships: relations as Relationship[], - relationshipSubjects: relationSubjects as Relationship[], - } - : null, - ); - - return jsonResponse({ - ancestors: await Promise.all( - ancestors.map((status) => status.toAPI(user)), - ), - descendants: await Promise.all( - descendants.map((status) => status.toAPI(user)), - ), - }); -}); diff --git a/server/api/api/v1/statuses/[id]/favourite.ts b/server/api/api/v1/statuses/[id]/favourite.ts deleted file mode 100644 index 84f914ab..00000000 --- a/server/api/api/v1/statuses/[id]/favourite.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; -import { errorResponse, jsonResponse } from "@response"; -import { createLike } from "~database/entities/Like"; -import { db } from "~drizzle/db"; -import { Note } from "~packages/database-interface/note"; -import type { Status as APIStatus } from "~types/mastodon/status"; - -export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 100, - duration: 60, - }, - route: "/api/v1/statuses/:id/favourite", - auth: { - required: true, - }, -}); - -/** - * Favourite a post - */ -export default apiRoute(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; - if (!id.match(idValidator)) { - return errorResponse("Invalid ID, must be of type UUIDv7", 404); - } - - const { user } = extraData.auth; - - if (!user) return errorResponse("Unauthorized", 401); - - const note = await Note.fromId(id); - - // Check if user is authorized to view this status (if it's private) - if (!note?.isViewableByUser(user)) - return errorResponse("Record not found", 404); - - const existingLike = await db.query.Likes.findFirst({ - where: (like, { and, eq }) => - and( - eq(like.likedId, note.getStatus().id), - eq(like.likerId, user.id), - ), - }); - - if (!existingLike) { - await createLike(user, note); - } - - return jsonResponse({ - ...(await note.toAPI(user)), - favourited: true, - favourites_count: note.getStatus().likeCount + 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 deleted file mode 100644 index f445b624..00000000 --- a/server/api/api/v1/statuses/[id]/favourited_by.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; -import { errorResponse, jsonResponse } from "@response"; -import { and, gt, gte, lt, sql } from "drizzle-orm"; -import { z } from "zod"; -import { Users } from "~drizzle/schema"; -import { Note } from "~packages/database-interface/note"; -import { Timeline } from "~packages/database-interface/timeline"; - -export const meta = applyConfig({ - allowedMethods: ["GET"], - ratelimits: { - max: 100, - duration: 60, - }, - route: "/api/v1/statuses/:id/favourited_by", - auth: { - required: true, - }, -}); - -export const schema = z.object({ - max_id: z.string().regex(idValidator).optional(), - since_id: z.string().regex(idValidator).optional(), - min_id: z.string().regex(idValidator).optional(), - limit: z.coerce.number().int().min(1).max(80).optional().default(40), -}); - -/** - * Fetch users who favourited the post - */ -export default apiRoute( - async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; - if (!id.match(idValidator)) { - return errorResponse("Invalid ID, must be of type UUIDv7", 404); - } - - const { user } = extraData.auth; - - const status = await Note.fromId(id); - - // Check if user is authorized to view this status (if it's private) - if (!status?.isViewableByUser(user)) - return errorResponse("Record not found", 404); - - const { max_id, min_id, since_id, limit } = extraData.parsedRequest; - - const { objects, link } = await Timeline.getUserTimeline( - and( - max_id ? lt(Users.id, max_id) : undefined, - since_id ? gte(Users.id, since_id) : undefined, - min_id ? gt(Users.id, min_id) : undefined, - sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = ${status.id} AND "Likes"."likerId" = ${Users.id})`, - ), - limit, - req.url, - ); - - return jsonResponse( - objects.map((user) => user.toAPI()), - 200, - { - Link: link, - }, - ); - }, -); diff --git a/server/api/api/v1/statuses/[id]/index.ts b/server/api/api/v1/statuses/[id]/index.ts deleted file mode 100644 index 4e311689..00000000 --- a/server/api/api/v1/statuses/[id]/index.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; -import { errorResponse, jsonResponse } from "@response"; -import { config } from "config-manager"; -import ISO6391 from "iso-639-1"; -import { z } from "zod"; -import { db } from "~drizzle/db"; -import { Note } from "~packages/database-interface/note"; - -export const meta = applyConfig({ - allowedMethods: ["GET", "DELETE", "PUT"], - ratelimits: { - max: 100, - duration: 60, - }, - route: "/api/v1/statuses/:id", - auth: { - required: false, - requiredOnMethods: ["DELETE", "PUT"], - }, -}); - -export const schema = z.object({ - status: z.string().trim().max(config.validation.max_note_size).optional(), - // TODO: Add regex to validate - content_type: z.string().optional().default("text/plain"), - media_ids: z - .array(z.string().regex(idValidator)) - .max(config.validation.max_media_attachments) - .optional(), - spoiler_text: z.string().max(255).optional(), - sensitive: z.boolean().optional(), - language: z.enum(ISO6391.getAllCodes() as [string, ...string[]]).optional(), - "poll[options]": z - .array(z.string().max(config.validation.max_poll_option_size)) - .max(config.validation.max_poll_options) - .optional(), - "poll[expires_in]": z - .number() - .int() - .min(config.validation.min_poll_duration) - .max(config.validation.max_poll_duration) - .optional(), - "poll[multiple]": z.boolean().optional(), - "poll[hide_totals]": z.boolean().optional(), -}); - -/** - * Fetch a user - */ -export default apiRoute( - async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; - if (!id.match(idValidator)) { - return errorResponse("Invalid ID, must be of type UUIDv7", 404); - } - - const { user } = extraData.auth; - - const foundStatus = await Note.fromId(id); - - const config = await extraData.configManager.getConfig(); - - // Check if user is authorized to view this status (if it's private) - if (!foundStatus?.isViewableByUser(user)) - return errorResponse("Record not found", 404); - - if (req.method === "GET") { - return jsonResponse(await foundStatus.toAPI(user)); - } - if (req.method === "DELETE") { - if (foundStatus.getAuthor().id !== user?.id) { - return errorResponse("Unauthorized", 401); - } - - // TODO: Implement delete and redraft functionality - - // Delete status and all associated objects - await foundStatus.delete(); - - return jsonResponse(await foundStatus.toAPI(user), 200); - } - if (req.method === "PUT") { - if (foundStatus.getAuthor().id !== user?.id) { - return errorResponse("Unauthorized", 401); - } - - const { - status: statusText, - content_type, - "poll[options]": options, - media_ids, - spoiler_text, - sensitive, - } = extraData.parsedRequest; - - // TODO: Add Poll support - // Validate status - if (!statusText && !(media_ids && media_ids.length > 0)) { - return errorResponse( - "Status is required unless media is attached", - 422, - ); - } - - if (media_ids && media_ids.length > 0 && options) { - // Disallow poll - return errorResponse( - "Cannot attach poll to post with media", - 422, - ); - } - - // Check if status body doesnt match filters - if ( - config.filters.note_content.some((filter) => - statusText?.match(filter), - ) - ) { - return errorResponse("Status contains blocked words", 422); - } - - // Check if media attachments are all valid - if (media_ids && media_ids.length > 0) { - const foundAttachments = await db.query.Attachments.findMany({ - where: (attachment, { inArray }) => - inArray(attachment.id, media_ids), - }); - - if (foundAttachments.length !== (media_ids ?? []).length) { - return errorResponse("Invalid media IDs", 422); - } - } - - const newNote = await foundStatus.updateFromData( - statusText - ? { - [content_type]: { - content: statusText, - }, - } - : undefined, - undefined, - sensitive, - spoiler_text, - undefined, - undefined, - media_ids, - ); - - if (!newNote) { - return errorResponse("Failed to update status", 500); - } - - return jsonResponse(await newNote.toAPI(user)); - } - - return jsonResponse({}); - }, -); diff --git a/server/api/api/v1/statuses/[id]/pin.ts b/server/api/api/v1/statuses/[id]/pin.ts deleted file mode 100644 index 9dfe7571..00000000 --- a/server/api/api/v1/statuses/[id]/pin.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; -import { errorResponse, jsonResponse } from "@response"; -import { db } from "~drizzle/db"; -import { NoteToMentions, UserToPinnedNotes } from "~drizzle/schema"; -import { Note } from "~packages/database-interface/note"; - -export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 100, - duration: 60, - }, - route: "/api/v1/statuses/:id/pin", - auth: { - required: true, - }, -}); - -/** - * Pin a post - */ -export default apiRoute(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; - if (!id.match(idValidator)) { - return errorResponse("Invalid ID, must be of type UUIDv7", 404); - } - - const { user } = extraData.auth; - - if (!user) return errorResponse("Unauthorized", 401); - - const foundStatus = await Note.fromId(id); - - // Check if status exists - if (!foundStatus) return errorResponse("Record not found", 404); - - // Check if status is user's - if (foundStatus.getAuthor().id !== user.id) - return errorResponse("Unauthorized", 401); - - // Check if post is already pinned - if ( - await db.query.UserToPinnedNotes.findFirst({ - where: (userPinnedNote, { and, eq }) => - and( - eq(userPinnedNote.noteId, foundStatus.getStatus().id), - eq(userPinnedNote.userId, user.id), - ), - }) - ) { - return errorResponse("Already pinned", 422); - } - - await user.pin(foundStatus); - - return jsonResponse(await foundStatus.toAPI(user)); -}); diff --git a/server/api/api/v1/statuses/[id]/reblog.ts b/server/api/api/v1/statuses/[id]/reblog.ts deleted file mode 100644 index 2eb1bd71..00000000 --- a/server/api/api/v1/statuses/[id]/reblog.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; -import { errorResponse, jsonResponse } from "@response"; -import { and, eq } from "drizzle-orm"; -import { z } from "zod"; -import { db } from "~drizzle/db"; -import { Notes, Notifications } from "~drizzle/schema"; -import { Note } from "~packages/database-interface/note"; - -export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 100, - duration: 60, - }, - route: "/api/v1/statuses/:id/reblog", - auth: { - required: true, - }, -}); - -export const schema = z.object({ - visibility: z.enum(["public", "unlisted", "private"]).default("public"), -}); - -/** - * Reblogs a post - */ -export default apiRoute( - async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; - if (!id.match(idValidator)) { - return errorResponse("Invalid ID, must be of type UUIDv7", 404); - } - - const { user, application } = extraData.auth; - - if (!user) return errorResponse("Unauthorized", 401); - - const { visibility } = extraData.parsedRequest; - - const foundStatus = await Note.fromId(id); - - // Check if user is authorized to view this status (if it's private) - if (!foundStatus?.isViewableByUser(user)) - return errorResponse("Record not found", 404); - - const existingReblog = await Note.fromSql( - and( - eq(Notes.authorId, user.id), - eq(Notes.reblogId, foundStatus.getStatus().id), - ), - ); - - if (existingReblog) { - return errorResponse("Already reblogged", 422); - } - - const newReblog = await Note.insert({ - authorId: user.id, - reblogId: foundStatus.getStatus().id, - visibility, - sensitive: false, - updatedAt: new Date().toISOString(), - applicationId: application?.id ?? null, - }); - - if (!newReblog) { - return errorResponse("Failed to reblog", 500); - } - - const finalNewReblog = await Note.fromId(newReblog.id); - - if (!finalNewReblog) { - return errorResponse("Failed to reblog", 500); - } - - // Create notification for reblog if reblogged user is on the same instance - if (foundStatus.getAuthor().isLocal() && user.isLocal()) { - await db.insert(Notifications).values({ - accountId: user.id, - notifiedId: foundStatus.getAuthor().id, - type: "reblog", - noteId: newReblog.reblogId, - }); - } - - return jsonResponse(await finalNewReblog.toAPI(user)); - }, -); diff --git a/server/api/api/v1/statuses/[id]/reblogged_by.ts b/server/api/api/v1/statuses/[id]/reblogged_by.ts deleted file mode 100644 index 64ba2ba8..00000000 --- a/server/api/api/v1/statuses/[id]/reblogged_by.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; -import { errorResponse, jsonResponse } from "@response"; -import { and, gt, gte, lt, sql } from "drizzle-orm"; -import { z } from "zod"; -import { Users } from "~drizzle/schema"; -import { Note } from "~packages/database-interface/note"; -import { Timeline } from "~packages/database-interface/timeline"; - -export const meta = applyConfig({ - allowedMethods: ["GET"], - ratelimits: { - max: 100, - duration: 60, - }, - route: "/api/v1/statuses/:id/reblogged_by", - auth: { - required: true, - }, -}); - -export const schema = z.object({ - max_id: z.string().regex(idValidator).optional(), - since_id: z.string().regex(idValidator).optional(), - min_id: z.string().regex(idValidator).optional(), - limit: z.coerce.number().int().min(1).max(80).optional().default(40), -}); - -/** - * Fetch users who reblogged the post - */ -export default apiRoute( - async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; - if (!id.match(idValidator)) { - return errorResponse("Invalid ID, must be of type UUIDv7", 404); - } - - const { user } = extraData.auth; - - const status = await Note.fromId(id); - - // Check if user is authorized to view this status (if it's private) - if (!status?.isViewableByUser(user)) - return errorResponse("Record not found", 404); - - const { max_id, min_id, since_id, limit } = extraData.parsedRequest; - - const { objects, link } = await Timeline.getUserTimeline( - and( - max_id ? lt(Users.id, max_id) : undefined, - since_id ? gte(Users.id, since_id) : undefined, - min_id ? gt(Users.id, min_id) : undefined, - sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."reblogId" = ${status.id} AND "Notes"."authorId" = ${Users.id})`, - ), - limit, - req.url, - ); - - return jsonResponse( - objects.map((user) => user.toAPI()), - 200, - { - Link: link, - }, - ); - }, -); diff --git a/server/api/api/v1/statuses/[id]/source.ts b/server/api/api/v1/statuses/[id]/source.ts deleted file mode 100644 index 5adedf34..00000000 --- a/server/api/api/v1/statuses/[id]/source.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; -import { errorResponse } from "@response"; -import { Note } from "~packages/database-interface/note"; - -export const meta = applyConfig({ - allowedMethods: ["GET"], - ratelimits: { - max: 100, - duration: 60, - }, - route: "/api/v1/statuses/:id/source", - auth: { - required: true, - }, -}); - -/** - * Favourite a post - */ -export default apiRoute(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; - if (!id.match(idValidator)) { - return errorResponse("Invalid ID, must be of type UUIDv7", 404); - } - - const { user } = extraData.auth; - - if (!user) return errorResponse("Unauthorized", 401); - - const status = await Note.fromId(id); - - // Check if user is authorized to view this status (if it's private) - if (!status?.isViewableByUser(user)) - return errorResponse("Record not found", 404); - - return errorResponse("Not implemented yet"); -}); diff --git a/server/api/api/v1/statuses/[id]/unfavourite.ts b/server/api/api/v1/statuses/[id]/unfavourite.ts deleted file mode 100644 index e5908cda..00000000 --- a/server/api/api/v1/statuses/[id]/unfavourite.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; -import { errorResponse, jsonResponse } from "@response"; -import { deleteLike } from "~database/entities/Like"; -import { Note } from "~packages/database-interface/note"; -import type { Status as APIStatus } from "~types/mastodon/status"; - -export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 100, - duration: 60, - }, - route: "/api/v1/statuses/:id/unfavourite", - auth: { - required: true, - }, -}); - -/** - * Unfavourite a post - */ -export default apiRoute(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; - if (!id.match(idValidator)) { - return errorResponse("Invalid ID, must be of type UUIDv7", 404); - } - - const { user } = extraData.auth; - - if (!user) return errorResponse("Unauthorized", 401); - - const note = await Note.fromId(id); - - // Check if user is authorized to view this status (if it's private) - if (!note?.isViewableByUser(user)) - return errorResponse("Record not found", 404); - - await deleteLike(user, note); - - return jsonResponse({ - ...(await note.toAPI(user)), - favourited: false, - favourites_count: note.getStatus().likeCount - 1, - } as APIStatus); -}); diff --git a/server/api/api/v1/statuses/[id]/unpin.ts b/server/api/api/v1/statuses/[id]/unpin.ts deleted file mode 100644 index 7d50e86e..00000000 --- a/server/api/api/v1/statuses/[id]/unpin.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; -import { errorResponse, jsonResponse } from "@response"; -import { Note } from "~packages/database-interface/note"; - -export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 100, - duration: 60, - }, - route: "/api/v1/statuses/:id/unpin", - auth: { - required: true, - }, -}); - -/** - * Unpins a post - */ -export default apiRoute(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; - if (!id.match(idValidator)) { - return errorResponse("Invalid ID, must be of type UUIDv7", 404); - } - - const { user } = extraData.auth; - - if (!user) return errorResponse("Unauthorized", 401); - - const status = await Note.fromId(id); - - // Check if status exists - if (!status) return errorResponse("Record not found", 404); - - // Check if status is user's - if (status.getAuthor().id !== user.id) - return errorResponse("Unauthorized", 401); - - await user.unpin(status); - - if (!status) return errorResponse("Record not found", 404); - - return jsonResponse(await status.toAPI(user)); -}); diff --git a/server/api/api/v1/statuses/[id]/unreblog.ts b/server/api/api/v1/statuses/[id]/unreblog.ts deleted file mode 100644 index 75680c40..00000000 --- a/server/api/api/v1/statuses/[id]/unreblog.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; -import { errorResponse, jsonResponse } from "@response"; -import { and, eq } from "drizzle-orm"; -import { Notes } from "~drizzle/schema"; -import { Note } from "~packages/database-interface/note"; -import type { Status as APIStatus } from "~types/mastodon/status"; - -export const meta = applyConfig({ - allowedMethods: ["POST"], - ratelimits: { - max: 100, - duration: 60, - }, - route: "/api/v1/statuses/:id/unreblog", - auth: { - required: true, - }, -}); - -/** - * Unreblogs a post - */ -export default apiRoute(async (req, matchedRoute, extraData) => { - const id = matchedRoute.params.id; - if (!id.match(idValidator)) { - return errorResponse("Invalid ID, must be of type UUIDv7", 404); - } - - const { user } = extraData.auth; - - if (!user) return errorResponse("Unauthorized", 401); - - const foundStatus = await Note.fromId(id); - - // Check if user is authorized to view this status (if it's private) - if (!foundStatus?.isViewableByUser(user)) - return errorResponse("Record not found", 404); - - const existingReblog = await Note.fromSql( - and( - eq(Notes.authorId, user.id), - eq(Notes.reblogId, foundStatus.getStatus().id), - ), - ); - - if (!existingReblog) { - return errorResponse("Not already reblogged", 422); - } - - await existingReblog.delete(); - - return jsonResponse({ - ...(await foundStatus.toAPI(user)), - reblogged: false, - reblogs_count: foundStatus.getStatus().reblogCount - 1, - } as APIStatus); -}); diff --git a/server/api/api/v1/statuses/index.test.ts b/server/api/api/v1/statuses/index.test.ts index a73c855c..9578b16f 100644 --- a/server/api/api/v1/statuses/index.test.ts +++ b/server/api/api/v1/statuses/index.test.ts @@ -16,21 +16,18 @@ afterAll(async () => { await deleteUsers(); }); +const getFormData = (object: Record) => + Object.keys(object).reduce((formData, key) => { + formData.append(key, String(object[key])); + return formData; + }, new FormData()); + describe(meta.route, () => { - test("should return 405 if method is not allowed", async () => { - const response = await sendTestRequest( - new Request(new URL(meta.route, config.http.base_url), { - method: "GET", - }), - ); - - expect(response.status).toBe(405); - }); - test("should return 401 if not authenticated", async () => { const response = await sendTestRequest( new Request(new URL(meta.route, config.http.base_url), { method: "POST", + body: new FormData(), }), ); @@ -42,10 +39,9 @@ describe(meta.route, () => { new Request(new URL(meta.route, config.http.base_url), { method: "POST", headers: { - "Content-Type": "application/json", Authorization: `Bearer ${tokens[0].accessToken}`, }, - body: JSON.stringify({}), + body: new FormData(), }), ); @@ -57,10 +53,9 @@ describe(meta.route, () => { new Request(new URL(meta.route, config.http.base_url), { method: "POST", headers: { - "Content-Type": "application/json", Authorization: `Bearer ${tokens[0].accessToken}`, }, - body: JSON.stringify({ + body: getFormData({ status: "a".repeat(config.validation.max_note_size + 1), federate: false, }), @@ -75,10 +70,9 @@ describe(meta.route, () => { new Request(new URL(meta.route, config.http.base_url), { method: "POST", headers: { - "Content-Type": "application/json", Authorization: `Bearer ${tokens[0].accessToken}`, }, - body: JSON.stringify({ + body: getFormData({ status: "Hello, world!", visibility: "invalid", federate: false, @@ -94,10 +88,9 @@ describe(meta.route, () => { new Request(new URL(meta.route, config.http.base_url), { method: "POST", headers: { - "Content-Type": "application/json", Authorization: `Bearer ${tokens[0].accessToken}`, }, - body: JSON.stringify({ + body: getFormData({ status: "Hello, world!", scheduled_at: "invalid", federate: false, @@ -113,10 +106,9 @@ describe(meta.route, () => { new Request(new URL(meta.route, config.http.base_url), { method: "POST", headers: { - "Content-Type": "application/json", Authorization: `Bearer ${tokens[0].accessToken}`, }, - body: JSON.stringify({ + body: getFormData({ status: "Hello, world!", in_reply_to_id: "invalid", federate: false, @@ -132,10 +124,9 @@ describe(meta.route, () => { new Request(new URL(meta.route, config.http.base_url), { method: "POST", headers: { - "Content-Type": "application/json", Authorization: `Bearer ${tokens[0].accessToken}`, }, - body: JSON.stringify({ + body: getFormData({ status: "Hello, world!", quote_id: "invalid", federate: false, @@ -151,12 +142,11 @@ describe(meta.route, () => { new Request(new URL(meta.route, config.http.base_url), { method: "POST", headers: { - "Content-Type": "application/json", Authorization: `Bearer ${tokens[0].accessToken}`, }, - body: JSON.stringify({ + body: getFormData({ status: "Hello, world!", - media_ids: ["invalid"], + "media_ids[]": "invalid", federate: false, }), }), @@ -170,10 +160,9 @@ describe(meta.route, () => { new Request(new URL(meta.route, config.http.base_url), { method: "POST", headers: { - "Content-Type": "application/json", Authorization: `Bearer ${tokens[0].accessToken}`, }, - body: JSON.stringify({ + body: getFormData({ status: "Hello, world!", federate: false, }), @@ -193,10 +182,9 @@ describe(meta.route, () => { new Request(new URL(meta.route, config.http.base_url), { method: "POST", headers: { - "Content-Type": "application/json", Authorization: `Bearer ${tokens[0].accessToken}`, }, - body: JSON.stringify({ + body: getFormData({ status: "Hello, world!", visibility: "unlisted", federate: false, @@ -218,10 +206,9 @@ describe(meta.route, () => { new Request(new URL(meta.route, config.http.base_url), { method: "POST", headers: { - "Content-Type": "application/json", Authorization: `Bearer ${tokens[0].accessToken}`, }, - body: JSON.stringify({ + body: getFormData({ status: "Hello, world!", federate: false, }), @@ -234,10 +221,9 @@ describe(meta.route, () => { new Request(new URL(meta.route, config.http.base_url), { method: "POST", headers: { - "Content-Type": "application/json", Authorization: `Bearer ${tokens[0].accessToken}`, }, - body: JSON.stringify({ + body: getFormData({ status: "Hello, world again!", in_reply_to_id: object.id, federate: false, @@ -259,10 +245,9 @@ describe(meta.route, () => { new Request(new URL(meta.route, config.http.base_url), { method: "POST", headers: { - "Content-Type": "application/json", Authorization: `Bearer ${tokens[0].accessToken}`, }, - body: JSON.stringify({ + body: getFormData({ status: "Hello, world!", federate: false, }), @@ -275,10 +260,9 @@ describe(meta.route, () => { new Request(new URL(meta.route, config.http.base_url), { method: "POST", headers: { - "Content-Type": "application/json", Authorization: `Bearer ${tokens[0].accessToken}`, }, - body: JSON.stringify({ + body: getFormData({ status: "Hello, world again!", quote_id: object.id, federate: false, @@ -304,10 +288,9 @@ describe(meta.route, () => { new Request(new URL(meta.route, config.http.base_url), { method: "POST", headers: { - "Content-Type": "application/json", Authorization: `Bearer ${tokens[0].accessToken}`, }, - body: JSON.stringify({ + body: getFormData({ status: `Hello, @${users[1].getUser().username}!`, federate: false, }), @@ -334,10 +317,9 @@ describe(meta.route, () => { new Request(new URL(meta.route, config.http.base_url), { method: "POST", headers: { - "Content-Type": "application/json", Authorization: `Bearer ${tokens[0].accessToken}`, }, - body: JSON.stringify({ + body: getFormData({ status: `Hello, @${users[1].getUser().username}@${ new URL(config.http.base_url).host }!`, @@ -368,10 +350,9 @@ describe(meta.route, () => { new Request(new URL(meta.route, config.http.base_url), { method: "POST", headers: { - "Content-Type": "application/json", Authorization: `Bearer ${tokens[0].accessToken}`, }, - body: JSON.stringify({ + body: getFormData({ status: "Hi! ", federate: false, }), @@ -395,10 +376,9 @@ describe(meta.route, () => { new Request(new URL(meta.route, config.http.base_url), { method: "POST", headers: { - "Content-Type": "application/json", Authorization: `Bearer ${tokens[0].accessToken}`, }, - body: JSON.stringify({ + body: getFormData({ status: "Hello, world!", spoiler_text: "uwu ", diff --git a/server/api/api/v1/statuses/index.ts b/server/api/api/v1/statuses/index.ts index 43efc424..06c48e44 100644 --- a/server/api/api/v1/statuses/index.ts +++ b/server/api/api/v1/statuses/index.ts @@ -1,6 +1,8 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; +import { applyConfig, auth, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; import { errorResponse, jsonResponse } from "@response"; import { config } from "config-manager"; +import type { Hono } from "hono"; import ISO6391 from "iso-639-1"; import { z } from "zod"; import { federateNote, parseTextMentions } from "~database/entities/Status"; @@ -19,153 +21,182 @@ export const meta = applyConfig({ }, }); -export const schema = z.object({ - status: z.string().max(config.validation.max_note_size).trim().optional(), - // TODO: Add regex to validate - content_type: z.string().optional().default("text/plain"), - media_ids: z - .array(z.string().regex(idValidator)) - .max(config.validation.max_media_attachments) - .optional(), - spoiler_text: z.string().max(255).trim().optional(), - sensitive: z.boolean().optional(), - language: z.enum(ISO6391.getAllCodes() as [string, ...string[]]).optional(), - "poll[options]": z - .array(z.string().max(config.validation.max_poll_option_size)) - .max(config.validation.max_poll_options) - .optional(), - "poll[expires_in]": z - .number() - .int() - .min(config.validation.min_poll_duration) - .max(config.validation.max_poll_duration) - .optional(), - "poll[multiple]": z.boolean().optional(), - "poll[hide_totals]": z.boolean().optional(), - in_reply_to_id: z.string().regex(idValidator).optional().nullable(), - quote_id: z.string().regex(idValidator).optional().nullable(), - visibility: z - .enum(["public", "unlisted", "private", "direct"]) - .optional() - .default("public"), - scheduled_at: z.string().optional().nullable(), - local_only: z.boolean().optional(), - federate: z.boolean().optional().default(true), -}); +export const schemas = { + form: z.object({ + status: z + .string() + .max(config.validation.max_note_size) + .trim() + .optional(), + // TODO: Add regex to validate + content_type: z.string().optional().default("text/plain"), + "media_ids[]": z + .array(z.string().uuid()) + .max(config.validation.max_media_attachments) + .optional(), + spoiler_text: z.string().max(255).trim().optional(), + sensitive: z + .string() + .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) + .optional(), + language: z + .enum(ISO6391.getAllCodes() as [string, ...string[]]) + .optional(), + "poll[options]": z + .array(z.string().max(config.validation.max_poll_option_size)) + .max(config.validation.max_poll_options) + .optional(), + "poll[expires_in]": z + .number() + .int() + .min(config.validation.min_poll_duration) + .max(config.validation.max_poll_duration) + .optional(), + "poll[multiple]": z + .string() + .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) + .optional(), + "poll[hide_totals]": z + .string() + .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) + .optional(), + in_reply_to_id: z.string().uuid().optional().nullable(), + quote_id: z.string().uuid().optional().nullable(), + visibility: z + .enum(["public", "unlisted", "private", "direct"]) + .optional() + .default("public"), + scheduled_at: z.string().optional().nullable(), + local_only: z + .string() + .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) + .optional(), + federate: z + .string() + .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) + .optional() + .default("true"), + }), +}; -/** - * Post new status - */ -export default apiRoute( - async (req, matchedRoute, extraData) => { - const { user, application } = extraData.auth; +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("form", schemas.form, handleZodError), + auth(meta.auth), + async (context) => { + const { user, application } = context.req.valid("header"); - if (!user) return errorResponse("Unauthorized", 401); + if (!user) return errorResponse("Unauthorized", 401); - const config = await extraData.configManager.getConfig(); + const { + status, + "media_ids[]": media_ids, + "poll[options]": options, + in_reply_to_id, + quote_id, + scheduled_at, + sensitive, + spoiler_text, + visibility, + content_type, + federate, + } = context.req.valid("form"); - const { - status, - media_ids, - "poll[options]": options, - in_reply_to_id, - quote_id, - scheduled_at, - sensitive, - spoiler_text, - visibility, - content_type, - federate, - } = extraData.parsedRequest; - - // Validate status - if (!status && !(media_ids && media_ids.length > 0)) { - return errorResponse( - "Status is required unless media is attached", - 422, - ); - } - - if (media_ids && media_ids.length > 0 && options) { - // Disallow poll - return errorResponse("Cannot attach poll to media", 422); - } - - if (scheduled_at) { - if ( - Number.isNaN(new Date(scheduled_at).getTime()) || - new Date(scheduled_at).getTime() < Date.now() - ) { + // Validate status + if (!status && !(media_ids && media_ids.length > 0)) { return errorResponse( - "Scheduled time must be in the future", + "Status is required unless media is attached", 422, ); } - } - // Check if status body doesnt match filters - if ( - config.filters.note_content.some((filter) => status?.match(filter)) - ) { - return errorResponse("Status contains blocked words", 422); - } - - // Check if media attachments are all valid - if (media_ids && media_ids.length > 0) { - const foundAttachments = await db.query.Attachments.findMany({ - where: (attachment, { inArray }) => - inArray(attachment.id, media_ids), - }).catch(() => []); - - if (foundAttachments.length !== (media_ids ?? []).length) { - return errorResponse("Invalid media IDs", 422); + if (media_ids && media_ids.length > 0 && options) { + // Disallow poll + return errorResponse("Cannot attach poll to media", 422); } - } - // Check that in_reply_to_id and quote_id are real posts if provided - if (in_reply_to_id) { - const foundReply = await Note.fromId(in_reply_to_id); - if (!foundReply) { - return errorResponse("Invalid in_reply_to_id (not found)", 422); + if (scheduled_at) { + if ( + Number.isNaN(new Date(scheduled_at).getTime()) || + new Date(scheduled_at).getTime() < Date.now() + ) { + return errorResponse( + "Scheduled time must be in the future", + 422, + ); + } } - } - if (quote_id) { - const foundQuote = await Note.fromId(quote_id); - if (!foundQuote) { - return errorResponse("Invalid quote_id (not found)", 422); + // Check if status body doesnt match filters + if ( + config.filters.note_content.some((filter) => + status?.match(filter), + ) + ) { + return errorResponse("Status contains blocked words", 422); } - } - const mentions = await parseTextMentions(status ?? ""); + // Check if media attachments are all valid + if (media_ids && media_ids.length > 0) { + const foundAttachments = await db.query.Attachments.findMany({ + where: (attachment, { inArray }) => + inArray(attachment.id, media_ids), + }).catch(() => []); - const newNote = await Note.fromData( - user, - { - [content_type]: { - content: status ?? "", + if (foundAttachments.length !== (media_ids ?? []).length) { + return errorResponse("Invalid media IDs", 422); + } + } + + // Check that in_reply_to_id and quote_id are real posts if provided + if (in_reply_to_id) { + const foundReply = await Note.fromId(in_reply_to_id); + if (!foundReply) { + return errorResponse( + "Invalid in_reply_to_id (not found)", + 422, + ); + } + } + + if (quote_id) { + const foundQuote = await Note.fromId(quote_id); + if (!foundQuote) { + return errorResponse("Invalid quote_id (not found)", 422); + } + } + + const mentions = await parseTextMentions(status ?? ""); + + const newNote = await Note.fromData( + user, + { + [content_type]: { + content: status ?? "", + }, }, - }, - visibility, - sensitive ?? false, - spoiler_text ?? "", - [], - undefined, - mentions, - media_ids, - in_reply_to_id ?? undefined, - quote_id ?? undefined, - application ?? undefined, - ); + visibility, + sensitive ?? false, + spoiler_text ?? "", + [], + undefined, + mentions, + media_ids, + in_reply_to_id ?? undefined, + quote_id ?? undefined, + application ?? undefined, + ); - if (!newNote) { - return errorResponse("Failed to create status", 500); - } + if (!newNote) { + return errorResponse("Failed to create status", 500); + } - if (federate) { - await federateNote(newNote); - } + if (federate) { + await federateNote(newNote); + } - return jsonResponse(await newNote.toAPI(user)); - }, -); + return jsonResponse(await newNote.toAPI(user)); + }, + ); diff --git a/server/api/api/v1/timelines/home.test.ts b/server/api/api/v1/timelines/home.test.ts index 30fa6320..b38127e7 100644 --- a/server/api/api/v1/timelines/home.test.ts +++ b/server/api/api/v1/timelines/home.test.ts @@ -174,24 +174,21 @@ describe(meta.route, () => { }); test("should not return statuses with filtered keywords", async () => { - const formData = new FormData(); - - formData.append("title", "Test Filter"); - formData.append("context[]", "home"); - formData.append("filter_action", "hide"); - formData.append( - "keywords_attributes[0][keyword]", - timeline[0].content.slice(4, 20), - ); - formData.append("keywords_attributes[0][whole_word]", "false"); - const filterResponse = await sendTestRequest( new Request(new URL("/api/v2/filters", config.http.base_url), { method: "POST", headers: { Authorization: `Bearer ${tokens[0].accessToken}`, + "Content-Type": "application/x-www-form-urlencoded", }, - body: formData, + body: new URLSearchParams({ + title: "Test Filter", + "context[]": "home", + filter_action: "hide", + "keywords_attributes[0][keyword]": + timeline[0].content.slice(4, 20), + "keywords_attributes[0][whole_word]": "false", + }), }), ); diff --git a/server/api/api/v1/timelines/home.ts b/server/api/api/v1/timelines/home.ts index e7f5b623..7a25a2ac 100644 --- a/server/api/api/v1/timelines/home.ts +++ b/server/api/api/v1/timelines/home.ts @@ -1,6 +1,8 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; +import { applyConfig, auth, handleZodError, idValidator } from "@api"; +import { zValidator } from "@hono/zod-validator"; import { errorResponse, jsonResponse } from "@response"; import { and, eq, gt, gte, lt, or, sql } from "drizzle-orm"; +import type { Hono } from "hono"; import { z } from "zod"; import { Notes } from "~drizzle/schema"; import { Timeline } from "~packages/database-interface/timeline"; @@ -17,53 +19,55 @@ export const meta = applyConfig({ }, }); -export const schema = z.object({ - max_id: z.string().regex(idValidator).optional(), - since_id: z.string().regex(idValidator).optional(), - min_id: z.string().regex(idValidator).optional(), - limit: z.coerce.number().int().min(1).max(80).optional().default(20), -}); +export const schemas = { + query: z.object({ + max_id: z.string().regex(idValidator).optional(), + since_id: z.string().regex(idValidator).optional(), + min_id: z.string().regex(idValidator).optional(), + limit: z.coerce.number().int().min(1).max(80).default(20), + }), +}; -/** - * Fetch home timeline statuses - */ -export default apiRoute( - async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("query", schemas.query, handleZodError), + auth(meta.auth), + async (context) => { + const { max_id, since_id, min_id, limit } = + context.req.valid("query"); - const { limit, max_id, min_id, since_id } = extraData.parsedRequest; + const { user } = context.req.valid("header"); - if (!user) return errorResponse("Unauthorized", 401); + if (!user) return errorResponse("Unauthorized", 401); - const { objects, link } = await Timeline.getNoteTimeline( - and( + const { objects, link } = await Timeline.getNoteTimeline( and( - max_id ? lt(Notes.id, max_id) : undefined, - since_id ? gte(Notes.id, since_id) : undefined, - min_id ? gt(Notes.id, min_id) : undefined, + and( + max_id ? lt(Notes.id, max_id) : undefined, + since_id ? gte(Notes.id, since_id) : undefined, + min_id ? gt(Notes.id, min_id) : undefined, + ), + or( + eq(Notes.authorId, user.id), + sql`EXISTS (SELECT 1 FROM "NoteToMentions" WHERE "NoteToMentions"."noteId" = ${Notes.id} AND "NoteToMentions"."userId" = ${user.id})`, + sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Notes.authorId} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."following" = true)`, + ), + sql`NOT EXISTS (SELECT 1 FROM "Filters" WHERE "Filters"."userId" = ${user.id} AND "Filters"."filter_action" = 'hide' AND EXISTS (SELECT 1 FROM "FilterKeywords" WHERE "FilterKeywords"."filterId" = "Filters"."id" AND "Notes"."content" LIKE '%' || "FilterKeywords"."keyword" || '%') AND "Filters"."context" @> ARRAY['home'])`, ), - or( - eq(Notes.authorId, user.id), - sql`EXISTS (SELECT 1 FROM "NoteToMentions" WHERE "NoteToMentions"."noteId" = ${Notes.id} AND "NoteToMentions"."userId" = ${user.id})`, - // All statuses from users that the user is following - // WHERE format (... = ...) - sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Notes.authorId} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."following" = true)`, - ), - // Don't show statuses that have filtered words in them - // Filters in `Filters` table have keyword in `FilterKeywords` table (use LIKE) - // Filters table has a userId and a context which is an array - sql`NOT EXISTS (SELECT 1 FROM "Filters" WHERE "Filters"."userId" = ${user.id} AND "Filters"."filter_action" = 'hide' AND EXISTS (SELECT 1 FROM "FilterKeywords" WHERE "FilterKeywords"."filterId" = "Filters"."id" AND "Notes"."content" LIKE '%' || "FilterKeywords"."keyword" || '%') AND "Filters"."context" @> ARRAY['home'])`, - ), - limit, - req.url, - ); + limit, + context.req.url, + ); - return jsonResponse( - await Promise.all(objects.map(async (note) => note.toAPI(user))), - 200, - { - Link: link, - }, - ); - }, -); + return jsonResponse( + await Promise.all( + objects.map(async (note) => note.toAPI(user)), + ), + 200, + { + Link: link, + }, + ); + }, + ); diff --git a/server/api/api/v1/timelines/public.test.ts b/server/api/api/v1/timelines/public.test.ts index e8814eba..fe5c9eda 100644 --- a/server/api/api/v1/timelines/public.test.ts +++ b/server/api/api/v1/timelines/public.test.ts @@ -220,24 +220,21 @@ describe(meta.route, () => { }); test("should not return statuses with filtered keywords", async () => { - const formData = new FormData(); - - formData.append("title", "Test Filter"); - formData.append("context[]", "public"); - formData.append("filter_action", "hide"); - formData.append( - "keywords_attributes[0][keyword]", - timeline[0].content.slice(4, 20), - ); - formData.append("keywords_attributes[0][whole_word]", "false"); - const filterResponse = await sendTestRequest( new Request(new URL("/api/v2/filters", config.http.base_url), { method: "POST", headers: { Authorization: `Bearer ${tokens[0].accessToken}`, + "Content-Type": "application/x-www-form-urlencoded", }, - body: formData, + body: new URLSearchParams({ + title: "Test Filter", + "context[]": "public", + filter_action: "hide", + "keywords_attributes[0][keyword]": + timeline[0].content.slice(4, 20), + "keywords_attributes[0][whole_word]": "false", + }), }), ); diff --git a/server/api/api/v1/timelines/public.ts b/server/api/api/v1/timelines/public.ts index ed0886ba..febe2024 100644 --- a/server/api/api/v1/timelines/public.ts +++ b/server/api/api/v1/timelines/public.ts @@ -1,6 +1,8 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; -import { errorResponse, jsonResponse } from "@response"; +import { applyConfig, auth, handleZodError, idValidator } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { jsonResponse } from "@response"; import { and, gt, gte, lt, sql } from "drizzle-orm"; +import type { Hono } from "hono"; import { z } from "zod"; import { Notes } from "~drizzle/schema"; import { Timeline } from "~packages/database-interface/timeline"; @@ -17,55 +19,76 @@ export const meta = applyConfig({ }, }); -export const schema = z.object({ - max_id: z.string().regex(idValidator).optional(), - since_id: z.string().regex(idValidator).optional(), - min_id: z.string().regex(idValidator).optional(), - limit: z.coerce.number().int().min(1).max(80).optional().default(20), - local: z.boolean().optional(), - remote: z.boolean().optional(), - only_media: z.boolean().optional(), -}); +export const schemas = { + query: z.object({ + max_id: z.string().regex(idValidator).optional(), + since_id: z.string().regex(idValidator).optional(), + min_id: z.string().regex(idValidator).optional(), + limit: z.coerce.number().int().min(1).max(80).default(20), + local: z + .string() + .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) + .optional(), + remote: z + .string() + .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) + .optional(), + only_media: z + .string() + .transform((v) => ["true", "1", "on"].includes(v.toLowerCase())) + .optional(), + }), +}; -export default apiRoute( - async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; - const { local, limit, max_id, min_id, only_media, remote, since_id } = - extraData.parsedRequest; +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("query", schemas.query, handleZodError), + auth(meta.auth), + async (context) => { + const { + max_id, + since_id, + min_id, + limit, + local, + remote, + only_media, + } = context.req.valid("query"); - if (local && remote) { - return errorResponse("Cannot use both local and remote", 400); - } + const { user } = context.req.valid("header"); - const { objects, link } = await Timeline.getNoteTimeline( - and( - max_id ? lt(Notes.id, max_id) : undefined, - since_id ? gte(Notes.id, since_id) : undefined, - min_id ? gt(Notes.id, min_id) : undefined, - // use authorId to grab user, then use user.instanceId to filter local/remote statuses - remote - ? sql`EXISTS (SELECT 1 FROM "Users" WHERE "Users"."id" = ${Notes.authorId} AND "Users"."instanceId" IS NOT NULL)` - : undefined, - local - ? sql`EXISTS (SELECT 1 FROM "Users" WHERE "Users"."id" = ${Notes.authorId} AND "Users"."instanceId" IS NULL)` - : undefined, - only_media - ? sql`EXISTS (SELECT 1 FROM "Attachments" WHERE "Attachments"."noteId" = ${Notes.id})` - : undefined, - user - ? sql`NOT EXISTS (SELECT 1 FROM "Filters" WHERE "Filters"."userId" = ${user.id} AND "Filters"."filter_action" = 'hide' AND EXISTS (SELECT 1 FROM "FilterKeywords" WHERE "FilterKeywords"."filterId" = "Filters"."id" AND "Notes"."content" LIKE '%' || "FilterKeywords"."keyword" || '%') AND "Filters"."context" @> ARRAY['public'])` - : undefined, - ), - limit, - req.url, - ); + const { objects, link } = await Timeline.getNoteTimeline( + and( + max_id ? lt(Notes.id, max_id) : undefined, + since_id ? gte(Notes.id, since_id) : undefined, + min_id ? gt(Notes.id, min_id) : undefined, + remote + ? sql`EXISTS (SELECT 1 FROM "Users" WHERE "Users"."id" = ${Notes.authorId} AND "Users"."instanceId" IS NOT NULL)` + : undefined, + local + ? sql`EXISTS (SELECT 1 FROM "Users" WHERE "Users"."id" = ${Notes.authorId} AND "Users"."instanceId" IS NULL)` + : undefined, + only_media + ? sql`EXISTS (SELECT 1 FROM "Attachments" WHERE "Attachments"."noteId" = ${Notes.id})` + : undefined, + user + ? sql`NOT EXISTS (SELECT 1 FROM "Filters" WHERE "Filters"."userId" = ${user.id} AND "Filters"."filter_action" = 'hide' AND EXISTS (SELECT 1 FROM "FilterKeywords" WHERE "FilterKeywords"."filterId" = "Filters"."id" AND "Notes"."content" LIKE '%' || "FilterKeywords"."keyword" || '%') AND "Filters"."context" @> ARRAY['public'])` + : undefined, + ), + limit, + context.req.url, + ); - return jsonResponse( - await Promise.all(objects.map(async (note) => note.toAPI(user))), - 200, - { - Link: link, - }, - ); - }, -); + return jsonResponse( + await Promise.all( + objects.map(async (note) => note.toAPI(user)), + ), + 200, + { + Link: link, + }, + ); + }, + ); diff --git a/server/api/api/v2/filters/[id]/index.test.ts b/server/api/api/v2/filters/:id/index.test.ts similarity index 78% rename from server/api/api/v2/filters/[id]/index.test.ts rename to server/api/api/v2/filters/:id/index.test.ts index 6526f4bb..6c50c290 100644 --- a/server/api/api/v2/filters/[id]/index.test.ts +++ b/server/api/api/v2/filters/:id/index.test.ts @@ -5,22 +5,21 @@ import { meta } from "./index"; const { users, tokens, deleteUsers } = await getTestUsers(2); -const formData = new FormData(); - -formData.append("title", "Test Filter"); -formData.append("context[]", "home"); -formData.append("filter_action", "warn"); -formData.append("expires_in", "86400"); -formData.append("keywords_attributes[0][keyword]", "test"); -formData.append("keywords_attributes[0][whole_word]", "true"); - const response = await sendTestRequest( new Request(new URL("/api/v2/filters", config.http.base_url), { method: "POST", headers: { Authorization: `Bearer ${tokens[0].accessToken}`, + "Content-Type": "application/x-www-form-urlencoded", }, - body: formData, + body: new URLSearchParams({ + title: "Test Filter", + "context[]": "home", + filter_action: "warn", + expires_in: "86400", + "keywords_attributes[0][keyword]": "test", + "keywords_attributes[0][whole_word]": "true", + }), }), ); @@ -37,7 +36,17 @@ afterAll(async () => { describe(meta.route, () => { test("should return 401 if not authenticated", async () => { const response = await sendTestRequest( - new Request(new URL(meta.route, config.http.base_url)), + new Request( + new URL( + meta.route.replace(":id", filter.id), + config.http.base_url, + ), + { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }, + ), ); expect(response.status).toBe(401); @@ -74,16 +83,6 @@ describe(meta.route, () => { }); test("should edit that filter", async () => { - const formData = new FormData(); - - formData.append("title", "New Filter"); - formData.append("context[]", "notifications"); - formData.append("filter_action", "hide"); - formData.append("expires_in", "86400"); - formData.append("keywords_attributes[0][keyword]", "new"); - formData.append("keywords_attributes[0][id]", filter.keywords[0].id); - formData.append("keywords_attributes[0][whole_word]", "false"); - const response = await sendTestRequest( new Request( new URL( @@ -94,8 +93,17 @@ describe(meta.route, () => { method: "PUT", headers: { Authorization: `Bearer ${tokens[0].accessToken}`, + "Content-Type": "application/x-www-form-urlencoded", }, - body: formData, + body: new URLSearchParams({ + title: "New Filter", + "context[]": "notifications", + filter_action: "hide", + expires_in: "86400", + "keywords_attributes[0][keyword]": "new", + "keywords_attributes[0][id]": filter.keywords[0].id, + "keywords_attributes[0][whole_word]": "false", + }), }, ), ); @@ -116,11 +124,6 @@ describe(meta.route, () => { }); test("should delete keyword", async () => { - const formData = new FormData(); - - formData.append("keywords_attributes[0][id]", filter.keywords[0].id); - formData.append("keywords_attributes[0][_destroy]", "true"); - const response = await sendTestRequest( new Request( new URL( @@ -132,7 +135,10 @@ describe(meta.route, () => { headers: { Authorization: `Bearer ${tokens[0].accessToken}`, }, - body: formData, + body: new URLSearchParams({ + "keywords_attributes[0][id]": filter.keywords[0].id, + "keywords_attributes[0][_destroy]": "true", + }), }, ), ); diff --git a/server/api/api/v2/filters/:id/index.ts b/server/api/api/v2/filters/:id/index.ts new file mode 100644 index 00000000..e40f6616 --- /dev/null +++ b/server/api/api/v2/filters/:id/index.ts @@ -0,0 +1,215 @@ +import { applyConfig, auth, qs } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse } from "@response"; +import { and, eq, inArray } from "drizzle-orm"; +import type { Hono } from "hono"; +import { z } from "zod"; +import { db } from "~drizzle/db"; +import { FilterKeywords, Filters } from "~drizzle/schema"; + +export const meta = applyConfig({ + allowedMethods: ["GET", "PUT", "DELETE"], + route: "/api/v2/filters/:id", + ratelimits: { + max: 60, + duration: 60, + }, + auth: { + required: true, + }, +}); + +export const schemas = { + param: z.object({ + id: z.string().uuid(), + }), + form: z.object({ + title: z.string().trim().min(1).max(100).optional(), + context: z + .array( + z.enum([ + "home", + "notifications", + "public", + "thread", + "account", + ]), + ) + .optional(), + filter_action: z.enum(["warn", "hide"]).optional().default("warn"), + expires_in: z.coerce + .number() + .int() + .min(60) + .max(60 * 60 * 24 * 365 * 5) + .optional(), + keywords_attributes: z + .array( + z.object({ + keyword: z.string().trim().min(1).max(100).optional(), + id: z.string().uuid().optional(), + whole_word: z + .string() + .transform((v) => + ["true", "1", "on"].includes(v.toLowerCase()), + ) + .optional(), + _destroy: z + .string() + .transform((v) => + ["true", "1", "on"].includes(v.toLowerCase()), + ) + .optional(), + }), + ) + .optional(), + }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + qs(), + zValidator("param", schemas.param), + zValidator("form", schemas.form), + auth(meta.auth), + async (context) => { + const { user } = context.req.valid("header"); + const { id } = context.req.valid("param"); + + if (!user) return errorResponse("Unauthorized", 401); + + const userFilter = await db.query.Filters.findFirst({ + where: (filter, { eq, and }) => + and(eq(filter.userId, user.id), eq(filter.id, id)), + with: { + keywords: true, + }, + }); + + if (!userFilter) return errorResponse("Filter not found", 404); + + switch (context.req.method) { + case "GET": { + return jsonResponse({ + id: userFilter.id, + title: userFilter.title, + context: userFilter.context, + expires_at: userFilter.expireAt + ? new Date(userFilter.expireAt).toISOString() + : null, + filter_action: userFilter.filterAction, + keywords: userFilter.keywords.map((keyword) => ({ + id: keyword.id, + keyword: keyword.keyword, + whole_word: keyword.wholeWord, + })), + statuses: [], + }); + } + case "PUT": { + const { + title, + context: ctx, + filter_action, + expires_in, + keywords_attributes, + } = context.req.valid("form"); + + await db + .update(Filters) + .set({ + title, + context: ctx ?? [], + filterAction: filter_action, + expireAt: new Date( + Date.now() + (expires_in ?? 0), + ).toISOString(), + }) + .where( + and( + eq(Filters.userId, user.id), + eq(Filters.id, id), + ), + ); + + const toUpdate = keywords_attributes + ?.filter((keyword) => keyword.id && !keyword._destroy) + .map((keyword) => ({ + keyword: keyword.keyword, + wholeWord: keyword.whole_word ?? false, + id: keyword.id, + })); + + const toDelete = keywords_attributes + ?.filter((keyword) => keyword._destroy && keyword.id) + .map((keyword) => keyword.id ?? ""); + + if (toUpdate && toUpdate.length > 0) { + for (const keyword of toUpdate) { + await db + .update(FilterKeywords) + .set(keyword) + .where( + and( + eq(FilterKeywords.filterId, id), + eq(FilterKeywords.id, keyword.id ?? ""), + ), + ); + } + } + + if (toDelete && toDelete.length > 0) { + await db + .delete(FilterKeywords) + .where( + and( + eq(FilterKeywords.filterId, id), + inArray(FilterKeywords.id, toDelete), + ), + ); + } + + const updatedFilter = await db.query.Filters.findFirst({ + where: (filter, { eq, and }) => + and(eq(filter.userId, user.id), eq(filter.id, id)), + with: { + keywords: true, + }, + }); + + if (!updatedFilter) + return errorResponse("Failed to update filter", 500); + + return jsonResponse({ + id: updatedFilter.id, + title: updatedFilter.title, + context: updatedFilter.context, + expires_at: updatedFilter.expireAt + ? new Date(updatedFilter.expireAt).toISOString() + : null, + filter_action: updatedFilter.filterAction, + keywords: updatedFilter.keywords.map((keyword) => ({ + id: keyword.id, + keyword: keyword.keyword, + whole_word: keyword.wholeWord, + })), + statuses: [], + }); + } + case "DELETE": { + await db + .delete(Filters) + .where( + and( + eq(Filters.userId, user.id), + eq(Filters.id, id), + ), + ); + + return jsonResponse({}); + } + } + }, + ); diff --git a/server/api/api/v2/filters/[id]/index.ts b/server/api/api/v2/filters/[id]/index.ts deleted file mode 100644 index 16f3d71a..00000000 --- a/server/api/api/v2/filters/[id]/index.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; -import { errorResponse, jsonResponse } from "@response"; -import { type InferSelectModel, and, eq, inArray } from "drizzle-orm"; -import { z } from "zod"; -import { db } from "~drizzle/db"; -import { FilterKeywords, Filters } from "~drizzle/schema"; - -export const meta = applyConfig({ - allowedMethods: ["GET", "PUT", "DELETE"], - route: "/api/v2/filters/:id", - ratelimits: { - max: 60, - duration: 60, - }, - auth: { - required: true, - }, -}); - -export const schema = z.object({ - title: z.string().trim().min(1).max(100).optional(), - context: z - .array(z.enum(["home", "notifications", "public", "thread", "account"])) - .optional(), - filter_action: z.enum(["warn", "hide"]).optional().default("warn"), - expires_in: z.coerce - .number() - .int() - .min(60) - .max(60 * 60 * 24 * 365 * 5) - .optional(), - keywords_attributes: z - .array( - z.object({ - keyword: z.string().trim().min(1).max(100).optional(), - id: z.string().regex(idValidator).optional(), - whole_word: z.boolean().optional(), - _destroy: z.boolean().optional(), - }), - ) - .optional(), -}); - -export default apiRoute( - async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; - const id = matchedRoute.params.id; - if (!id.match(idValidator)) return errorResponse("Invalid ID", 400); - - if (!user) return errorResponse("Unauthorized", 401); - - const userFilter = await db.query.Filters.findFirst({ - where: (filter, { eq, and }) => - and(eq(filter.userId, user.id), eq(filter.id, id)), - with: { - keywords: true, - }, - }); - - if (!userFilter) return errorResponse("Filter not found", 404); - - switch (req.method) { - case "GET": { - return jsonResponse({ - id: userFilter.id, - title: userFilter.title, - context: userFilter.context, - expires_at: userFilter.expireAt - ? new Date(userFilter.expireAt).toISOString() - : null, - filter_action: userFilter.filterAction, - keywords: userFilter.keywords.map((keyword) => ({ - id: keyword.id, - keyword: keyword.keyword, - whole_word: keyword.wholeWord, - })), - statuses: [], - }); - } - case "PUT": { - const { - title, - context, - filter_action, - expires_in, - keywords_attributes, - } = extraData.parsedRequest; - - await db - .update(Filters) - .set({ - title, - context, - filterAction: filter_action, - expireAt: new Date( - Date.now() + (expires_in ?? 0), - ).toISOString(), - }) - .where( - and(eq(Filters.userId, user.id), eq(Filters.id, id)), - ); - - const toUpdate = keywords_attributes - ?.filter((keyword) => keyword.id && !keyword._destroy) - .map((keyword) => ({ - keyword: keyword.keyword, - wholeWord: keyword.whole_word ?? false, - id: keyword.id, - })); - - const toDelete = keywords_attributes - ?.filter((keyword) => keyword._destroy && keyword.id) - .map((keyword) => keyword.id ?? ""); - - if (toUpdate && toUpdate.length > 0) { - for (const keyword of toUpdate) { - await db - .update(FilterKeywords) - .set(keyword) - .where( - and( - eq(FilterKeywords.filterId, id), - eq(FilterKeywords.id, keyword.id ?? ""), - ), - ); - } - } - - if (toDelete && toDelete.length > 0) { - await db - .delete(FilterKeywords) - .where( - and( - eq(FilterKeywords.filterId, id), - inArray(FilterKeywords.id, toDelete), - ), - ); - } - - const updatedFilter = await db.query.Filters.findFirst({ - where: (filter, { eq, and }) => - and(eq(filter.userId, user.id), eq(filter.id, id)), - with: { - keywords: true, - }, - }); - - if (!updatedFilter) - return errorResponse("Failed to update filter", 500); - - return jsonResponse({ - id: updatedFilter.id, - title: updatedFilter.title, - context: updatedFilter.context, - expires_at: updatedFilter.expireAt - ? new Date(updatedFilter.expireAt).toISOString() - : null, - filter_action: updatedFilter.filterAction, - keywords: updatedFilter.keywords.map((keyword) => ({ - id: keyword.id, - keyword: keyword.keyword, - whole_word: keyword.wholeWord, - })), - statuses: [], - }); - } - case "DELETE": { - await db - .delete(Filters) - .where( - and(eq(Filters.userId, user.id), eq(Filters.id, id)), - ); - - return jsonResponse({}); - } - } - }, -); diff --git a/server/api/api/v2/filters/index.test.ts b/server/api/api/v2/filters/index.test.ts index cac5d44c..bb3abbf8 100644 --- a/server/api/api/v2/filters/index.test.ts +++ b/server/api/api/v2/filters/index.test.ts @@ -50,8 +50,16 @@ describe(meta.route, () => { method: "POST", headers: { Authorization: `Bearer ${tokens[0].accessToken}`, + "Content-Type": "application/x-www-form-urlencoded", }, - body: formData, + body: new URLSearchParams({ + title: "Test Filter", + "context[]": "home", + filter_action: "warn", + expires_in: "86400", + "keywords_attributes[0][keyword]": "test", + "keywords_attributes[0][whole_word]": "true", + }), }), ); diff --git a/server/api/api/v2/filters/index.ts b/server/api/api/v2/filters/index.ts index 8cbb5310..edf86a8d 100644 --- a/server/api/api/v2/filters/index.ts +++ b/server/api/api/v2/filters/index.ts @@ -1,10 +1,11 @@ -import { apiRoute, applyConfig } from "@api"; +import { applyConfig, auth, handleZodError, qs } from "@api"; +import { zValidator } from "@hono/zod-validator"; import { errorResponse, jsonResponse } from "@response"; -import type { InferSelectModel } from "drizzle-orm"; +import type { Hono } from "hono"; +import { validator } from "hono/validator"; import { z } from "zod"; import { db } from "~drizzle/db"; import { FilterKeywords, Filters } from "~drizzle/schema"; - export const meta = applyConfig({ allowedMethods: ["GET", "POST"], route: "/api/v2/filters", @@ -17,139 +18,170 @@ export const meta = applyConfig({ }, }); -export const schema = z.object({ - title: z.string().trim().min(1).max(100).optional(), - context: z - .array(z.enum(["home", "notifications", "public", "thread", "account"])) +export const schemas = { + form: z + .object({ + title: z.string().trim().min(1).max(100).optional(), + context: z + .array( + z.enum([ + "home", + "notifications", + "public", + "thread", + "account", + ]), + ) + .optional(), + filter_action: z.enum(["warn", "hide"]).optional().default("warn"), + expires_in: z.coerce + .number() + .int() + .min(60) + .max(60 * 60 * 24 * 365 * 5) + .optional(), + keywords_attributes: z + .array( + z.object({ + keyword: z.string().trim().min(1).max(100), + whole_word: z + .string() + .transform((v) => + ["true", "1", "on"].includes(v.toLowerCase()), + ) + .optional(), + }), + ) + .optional(), + }) .optional(), - filter_action: z.enum(["warn", "hide"]).optional().default("warn"), - expires_in: z.coerce - .number() - .int() - .min(60) - .max(60 * 60 * 24 * 365 * 5) - .optional(), - keywords_attributes: z - .array( - z.object({ - keyword: z.string().trim().min(1).max(100), - whole_word: z.boolean().optional(), - }), - ) - .optional(), -}); +}; -export default apiRoute( - async (req, matchedRoute, extraData) => { - const { user } = extraData.auth; +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + qs(), + zValidator("form", schemas.form, handleZodError), + auth(meta.auth), + async (context) => { + const { user } = context.req.valid("header"); - if (!user) return errorResponse("Unauthorized", 401); + if (!user) return errorResponse("Unauthorized", 401); + switch (context.req.method) { + case "GET": { + const userFilters = await db.query.Filters.findMany({ + where: (filter, { eq }) => eq(filter.userId, user.id), + with: { + keywords: true, + }, + }); - switch (req.method) { - case "GET": { - const userFilters = await db.query.Filters.findMany({ - where: (filter, { eq }) => eq(filter.userId, user.id), - with: { - keywords: true, - }, - }); + return jsonResponse( + userFilters.map((filter) => ({ + id: filter.id, + title: filter.title, + context: filter.context, + expires_at: filter.expireAt + ? new Date( + Date.now() + filter.expireAt, + ).toISOString() + : null, + filter_action: filter.filterAction, + keywords: filter.keywords.map((keyword) => ({ + id: keyword.id, + keyword: keyword.keyword, + whole_word: keyword.wholeWord, + })), + statuses: [], + })), + ); + } + case "POST": { + const form = context.req.valid("form"); + if (!form) { + return errorResponse( + "Missing required Form fields", + 422, + ); + } - return jsonResponse( - userFilters.map((filter) => ({ - id: filter.id, - title: filter.title, - context: filter.context, - expires_at: filter.expireAt - ? new Date( - Date.now() + filter.expireAt, - ).toISOString() + const { + title, + context: ctx, + filter_action, + expires_in, + keywords_attributes, + } = form; + + if (!title || ctx?.length === 0) { + return errorResponse( + "Missing required fields (title and context)", + 422, + ); + } + + const newFilter = ( + await db + .insert(Filters) + .values({ + title: title ?? "", + context: ctx ?? [], + filterAction: filter_action, + expireAt: new Date( + Date.now() + (expires_in ?? 0), + ).toISOString(), + userId: user.id, + }) + .returning() + )[0]; + + if (!newFilter) + return errorResponse("Failed to create filter", 500); + + const insertedKeywords = + keywords_attributes && keywords_attributes.length > 0 + ? await db + .insert(FilterKeywords) + .values( + keywords_attributes?.map((keyword) => ({ + filterId: newFilter.id, + keyword: keyword.keyword, + wholeWord: + keyword.whole_word ?? false, + })) ?? [], + ) + .returning() + : []; + + return jsonResponse({ + id: newFilter.id, + title: newFilter.title, + context: newFilter.context, + expires_at: expires_in + ? new Date(Date.now() + expires_in).toISOString() : null, - filter_action: filter.filterAction, - keywords: filter.keywords.map((keyword) => ({ + filter_action: newFilter.filterAction, + keywords: insertedKeywords.map((keyword) => ({ id: keyword.id, keyword: keyword.keyword, whole_word: keyword.wholeWord, })), statuses: [], - })), - ); - } - case "POST": { - const { - title, - context, - filter_action, - expires_in, - keywords_attributes, - } = extraData.parsedRequest; - - if (!title || context?.length === 0) { - return errorResponse( - "Missing required fields (title and context)", - 422, - ); - } - - const newFilter = ( - await db - .insert(Filters) - .values({ - title: title ?? "", - context: context ?? [], - filterAction: filter_action, - expireAt: new Date( - Date.now() + (expires_in ?? 0), - ).toISOString(), - userId: user.id, - }) - .returning() - )[0]; - - if (!newFilter) - return errorResponse("Failed to create filter", 500); - - const insertedKeywords = - keywords_attributes && keywords_attributes.length > 0 - ? await db - .insert(FilterKeywords) - .values( - keywords_attributes?.map((keyword) => ({ - filterId: newFilter.id, - keyword: keyword.keyword, - wholeWord: keyword.whole_word ?? false, - })) ?? [], - ) - .returning() - : []; - - return jsonResponse({ - id: newFilter.id, - title: newFilter.title, - context: newFilter.context, - expires_at: expires_in - ? new Date(Date.now() + expires_in).toISOString() - : null, - filter_action: newFilter.filterAction, - keywords: insertedKeywords.map((keyword) => ({ - id: keyword.id, - keyword: keyword.keyword, - whole_word: keyword.wholeWord, - })), - statuses: [], - } as { - id: string; - title: string; - context: string[]; - expires_at: string; - filter_action: "warn" | "hide"; - keywords: { + } as { id: string; - keyword: string; - whole_word: boolean; - }[]; - statuses: []; - }); + title: string; + context: string[]; + expires_at: string; + filter_action: "warn" | "hide"; + keywords: { + id: string; + keyword: string; + whole_word: boolean; + }[]; + statuses: []; + }); + } } - } - }, -); + }, + ); diff --git a/server/api/api/v2/instance/index.ts b/server/api/api/v2/instance/index.ts index ffd79fff..7d6197b5 100644 --- a/server/api/api/v2/instance/index.ts +++ b/server/api/api/v2/instance/index.ts @@ -1,9 +1,10 @@ -import { apiRoute, applyConfig } from "@api"; +import { applyConfig } from "@api"; import { jsonResponse, proxyUrl } from "@response"; -import { and, countDistinct, eq, gte, isNull } from "drizzle-orm"; -import { db } from "~drizzle/db"; -import { Notes, Users } from "~drizzle/schema"; +import { and, eq, isNull } from "drizzle-orm"; +import type { Hono } from "hono"; +import { Users } from "~drizzle/schema"; import manifest from "~package.json"; +import { config } from "~packages/config-manager"; import { User } from "~packages/database-interface/user"; export const meta = applyConfig({ @@ -18,102 +19,86 @@ export const meta = applyConfig({ }, }); -export default apiRoute(async (req, matchedRoute, extraData) => { - const config = await extraData.configManager.getConfig(); +export default (app: Hono) => + app.on(meta.allowedMethods, meta.route, async (context) => { + // Get software version from package.json + const version = manifest.version; - // Get software version from package.json - const version = manifest.version; + const contactAccount = await User.fromSql( + and(isNull(Users.instanceId), eq(Users.isAdmin, true)), + ); - const contactAccount = await User.fromSql( - and(isNull(Users.instanceId), eq(Users.isAdmin, true)), - ); + const monthlyActiveUsers = await User.getActiveInPeriod( + 30 * 24 * 60 * 60 * 1000, + ); - const monthlyActiveUsers = ( - await db - .select({ - count: countDistinct(Users), - }) - .from(Users) - .leftJoin(Notes, eq(Users.id, Notes.authorId)) - .where( - and( - isNull(Users.instanceId), - gte( - Notes.createdAt, - new Date( - Date.now() - 30 * 24 * 60 * 60 * 1000, - ).toISOString(), - ), - ), - ) - )[0].count; - - // TODO: fill in more values - return jsonResponse({ - domain: new URL(config.http.base_url).hostname, - title: config.instance.name, - version: "4.3.0-alpha.3+glitch", - lysand_version: version, - source_url: "https://github.com/lysand-org/lysand", - description: config.instance.description, - usage: { - users: { - active_month: monthlyActiveUsers, + // TODO: fill in more values + return jsonResponse({ + domain: new URL(config.http.base_url).hostname, + title: config.instance.name, + version: "4.3.0-alpha.3+glitch", + lysand_version: version, + source_url: "https://github.com/lysand-org/lysand", + description: config.instance.description, + usage: { + users: { + active_month: monthlyActiveUsers, + }, }, - }, - thumbnail: { - url: proxyUrl(config.instance.logo), - }, - banner: { - url: proxyUrl(config.instance.banner), - }, - languages: ["en"], - configuration: { - urls: { - streaming: null, - status: null, + thumbnail: { + url: proxyUrl(config.instance.logo), }, - accounts: { - max_featured_tags: 100, + banner: { + url: proxyUrl(config.instance.banner), }, - statuses: { - max_characters: config.validation.max_note_size, - max_media_attachments: config.validation.max_media_attachments, - characters_reserved_per_url: 0, + languages: ["en"], + configuration: { + urls: { + streaming: null, + status: null, + }, + accounts: { + max_featured_tags: 100, + }, + statuses: { + max_characters: config.validation.max_note_size, + max_media_attachments: + config.validation.max_media_attachments, + characters_reserved_per_url: 0, + }, + media_attachments: { + supported_mime_types: config.validation.allowed_mime_types, + image_size_limit: config.validation.max_media_size, + image_matrix_limit: config.validation.max_media_size, + video_size_limit: config.validation.max_media_size, + video_frame_rate_limit: config.validation.max_media_size, + video_matrix_limit: config.validation.max_media_size, + }, + polls: { + max_characters_per_option: + config.validation.max_poll_option_size, + max_expiration: config.validation.max_poll_duration, + max_options: config.validation.max_poll_options, + min_expiration: config.validation.min_poll_duration, + }, + translation: { + enabled: false, + }, }, - media_attachments: { - supported_mime_types: config.validation.allowed_mime_types, - image_size_limit: config.validation.max_media_size, - image_matrix_limit: config.validation.max_media_size, - video_size_limit: config.validation.max_media_size, - video_frame_rate_limit: config.validation.max_media_size, - video_matrix_limit: config.validation.max_media_size, + registrations: { + enabled: config.signups.registration, + approval_required: false, + message: null, + url: null, }, - polls: { - max_characters_per_option: - config.validation.max_poll_option_size, - max_expiration: config.validation.max_poll_duration, - max_options: config.validation.max_poll_options, - min_expiration: config.validation.min_poll_duration, + contact: { + email: contactAccount?.getUser().email || null, + account: contactAccount?.toAPI() || null, }, - translation: { - enabled: false, - }, - }, - registrations: { - enabled: config.signups.registration, - approval_required: false, - message: null, - url: null, - }, - contact: { - email: contactAccount?.getUser().email || null, - account: contactAccount?.toAPI() || null, - }, - rules: config.signups.rules.map((rule, index) => ({ - id: String(index), - text: rule, - hint: "", - })), + rules: config.signups.rules.map((rule, index) => ({ + id: String(index), + text: rule, + hint: "", + })), + }); }); -}); diff --git a/server/api/api/v2/media/index.ts b/server/api/api/v2/media/index.ts index 18c51ac2..5aa640f9 100644 --- a/server/api/api/v2/media/index.ts +++ b/server/api/api/v2/media/index.ts @@ -1,7 +1,9 @@ -import { apiRoute, applyConfig } from "@api"; +import { applyConfig, auth } from "@api"; +import { zValidator } from "@hono/zod-validator"; import { errorResponse, jsonResponse } from "@response"; import { encode } from "blurhash"; import { config } from "config-manager"; +import type { Hono } from "hono"; import type { MediaBackend } from "media-manager"; import { MediaBackendType } from "media-manager"; import { LocalMediaBackend, S3MediaBackend } from "media-manager"; @@ -24,135 +26,138 @@ export const meta = applyConfig({ }, }); -export const schema = z.object({ - file: z.instanceof(File), - thumbnail: z.instanceof(File).optional(), - description: z - .string() - .max(config.validation.max_media_description_size) - .optional(), - focus: z.string().optional(), -}); +export const schemas = { + form: z.object({ + file: z.instanceof(File), + thumbnail: z.instanceof(File).optional(), + description: z + .string() + .max(config.validation.max_media_description_size) + .optional(), + focus: z.string().optional(), + }), +}; -/** - * Upload new media - */ -export default apiRoute( - async (req, matchedRoute, extraData) => { - const { file, thumbnail, description } = extraData.parsedRequest; +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("form", schemas.form), + auth(meta.auth), + async (context) => { + const { file, thumbnail, description, focus } = + context.req.valid("form"); - const config = await extraData.configManager.getConfig(); + if (file.size > config.validation.max_media_size) { + return errorResponse( + `File too large, max size is ${config.validation.max_media_size} bytes`, + 413, + ); + } - if (file.size > config.validation.max_media_size) { - return errorResponse( - `File too large, max size is ${config.validation.max_media_size} bytes`, - 413, + if ( + config.validation.enforce_mime_types && + !config.validation.allowed_mime_types.includes(file.type) + ) { + return errorResponse("Invalid file type", 415); + } + + const sha256 = new Bun.SHA256(); + + const isImage = file.type.startsWith("image/"); + + const metadata = isImage + ? await sharp(await file.arrayBuffer()).metadata() + : null; + + const blurhash = await new Promise((resolve) => { + (async () => + sharp(await file.arrayBuffer()) + .raw() + .ensureAlpha() + .toBuffer((err, buffer) => { + if (err) { + resolve(null); + return; + } + + try { + resolve( + encode( + new Uint8ClampedArray(buffer), + metadata?.width ?? 0, + metadata?.height ?? 0, + 4, + 4, + ) as string, + ); + } catch { + resolve(null); + } + }))(); + }); + + let url = ""; + + let mediaManager: MediaBackend; + + switch (config.media.backend as MediaBackendType) { + case MediaBackendType.LOCAL: + mediaManager = new LocalMediaBackend(config); + break; + case MediaBackendType.S3: + mediaManager = new S3MediaBackend(config); + break; + default: + // TODO: Replace with logger + throw new Error("Invalid media backend"); + } + + if (isImage) { + const { path } = await mediaManager.addFile(file); + + url = getUrl(path, config); + } + + let thumbnailUrl = ""; + + if (thumbnail) { + const { path } = await mediaManager.addFile(thumbnail); + + thumbnailUrl = getUrl(path, config); + } + + const newAttachment = ( + await db + .insert(Attachments) + .values({ + url, + thumbnailUrl, + sha256: sha256 + .update(await file.arrayBuffer()) + .digest("hex"), + mimeType: file.type, + description: description ?? "", + size: file.size, + blurhash: blurhash ?? undefined, + width: metadata?.width ?? undefined, + height: metadata?.height ?? undefined, + }) + .returning() + )[0]; + + // TODO: Add job to process videos and other media + + if (isImage) { + return jsonResponse(attachmentToAPI(newAttachment)); + } + + return jsonResponse( + { + ...attachmentToAPI(newAttachment), + url: null, + }, + 202, ); - } - - if ( - config.validation.enforce_mime_types && - !config.validation.allowed_mime_types.includes(file.type) - ) { - return errorResponse("Invalid file type", 415); - } - - const sha256 = new Bun.SHA256(); - - const isImage = file.type.startsWith("image/"); - - const metadata = isImage - ? await sharp(await file.arrayBuffer()).metadata() - : null; - - const blurhash = await new Promise((resolve) => { - (async () => - sharp(await file.arrayBuffer()) - .raw() - .ensureAlpha() - .toBuffer((err, buffer) => { - if (err) { - resolve(null); - return; - } - - try { - resolve( - encode( - new Uint8ClampedArray(buffer), - metadata?.width ?? 0, - metadata?.height ?? 0, - 4, - 4, - ) as string, - ); - } catch { - resolve(null); - } - }))(); - }); - - let url = ""; - - let mediaManager: MediaBackend; - - switch (config.media.backend as MediaBackendType) { - case MediaBackendType.LOCAL: - mediaManager = new LocalMediaBackend(config); - break; - case MediaBackendType.S3: - mediaManager = new S3MediaBackend(config); - break; - default: - // TODO: Replace with logger - throw new Error("Invalid media backend"); - } - - if (isImage) { - const { path } = await mediaManager.addFile(file); - - url = getUrl(path, config); - } - - let thumbnailUrl = ""; - - if (thumbnail) { - const { path } = await mediaManager.addFile(thumbnail); - - thumbnailUrl = getUrl(path, config); - } - - const newAttachment = ( - await db - .insert(Attachments) - .values({ - url, - thumbnailUrl, - sha256: sha256 - .update(await file.arrayBuffer()) - .digest("hex"), - mimeType: file.type, - description: description ?? "", - size: file.size, - blurhash: blurhash ?? undefined, - width: metadata?.width ?? undefined, - height: metadata?.height ?? undefined, - }) - .returning() - )[0]; - - // TODO: Add job to process videos and other media - - if (isImage) { - return jsonResponse(attachmentToAPI(newAttachment)); - } - - return jsonResponse( - { - ...attachmentToAPI(newAttachment), - url: null, - }, - 202, - ); - }, -); + }, + ); diff --git a/server/api/api/v2/search/index.ts b/server/api/api/v2/search/index.ts index 16a71f1a..22953abf 100644 --- a/server/api/api/v2/search/index.ts +++ b/server/api/api/v2/search/index.ts @@ -1,12 +1,15 @@ -import { apiRoute, applyConfig } from "@api"; +import { applyConfig, auth } from "@api"; +import { zValidator } from "@hono/zod-validator"; import { dualLogger } from "@loggers"; import { MeiliIndexType, meilisearch } from "@meilisearch"; import { errorResponse, jsonResponse } from "@response"; import { and, eq, inArray, sql } from "drizzle-orm"; +import type { Hono } from "hono"; import { z } from "zod"; import { resolveWebFinger } from "~database/entities/User"; import { db } from "~drizzle/db"; import { Instances, Notes, Users } from "~drizzle/schema"; +import { config } from "~packages/config-manager"; import { Note } from "~packages/database-interface/note"; import { User } from "~packages/database-interface/user"; import { LogLevel } from "~packages/log-manager"; @@ -24,173 +27,173 @@ export const meta = applyConfig({ }, }); -export const schema = z.object({ - q: z.string().trim().optional(), - type: z.string().optional(), - resolve: z.coerce.boolean().optional(), - following: z.coerce.boolean().optional(), - account_id: z.string().optional(), - max_id: z.string().optional(), - min_id: z.string().optional(), - limit: z.coerce.number().int().min(1).max(40).optional(), - offset: z.coerce.number().int().optional(), -}); +export const schemas = { + query: z.object({ + q: z.string().trim().optional(), + type: z.string().optional(), + resolve: z.coerce.boolean().optional(), + following: z.coerce.boolean().optional(), + account_id: z.string().optional(), + max_id: z.string().optional(), + min_id: z.string().optional(), + limit: z.coerce.number().int().min(1).max(40).optional(), + offset: z.coerce.number().int().optional(), + }), +}; -export default apiRoute( - async (req, matchedRoute, extraData) => { - const { user: self } = extraData.auth; +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("query", schemas.query), + auth(meta.auth), + async (context) => { + const { user: self } = context.req.valid("header"); + const { q, type, resolve, following, account_id, limit, offset } = + context.req.valid("query"); - const { - q, - type, - resolve, - following, - account_id, - // max_id, - // min_id, - limit = 20, - offset, - } = extraData.parsedRequest; + if (!self && (resolve || offset)) { + return errorResponse( + "Cannot use resolve or offset without being authenticated", + 401, + ); + } - const config = await extraData.configManager.getConfig(); + if (!config.meilisearch.enabled) { + return errorResponse("Meilisearch is not enabled", 501); + } - if (!config.meilisearch.enabled) { - return errorResponse("Meilisearch is not enabled", 501); - } + let accountResults: { id: string }[] = []; + let statusResults: { id: string }[] = []; - if (!self && (resolve || offset)) { - return errorResponse( - "Cannot use resolve or offset without being authenticated", - 401, - ); - } + if (!type || type === "accounts") { + // Check if q is matching format username@domain.com or @username@domain.com + const accountMatches = q + ?.trim() + .match(/@?[a-zA-Z0-9_]+(@[a-zA-Z0-9_.:]+)/g); + if (accountMatches) { + // Remove leading @ if it exists + if (accountMatches[0].startsWith("@")) { + accountMatches[0] = accountMatches[0].slice(1); + } - let accountResults: { id: string }[] = []; - let statusResults: { id: string }[] = []; + const [username, domain] = accountMatches[0].split("@"); - if (!type || type === "accounts") { - // Check if q is matching format username@domain.com or @username@domain.com - const accountMatches = q - ?.trim() - .match(/@?[a-zA-Z0-9_]+(@[a-zA-Z0-9_.:]+)/g); - if (accountMatches) { - // Remove leading @ if it exists - if (accountMatches[0].startsWith("@")) { - accountMatches[0] = accountMatches[0].slice(1); - } + const accountId = ( + await db + .select({ + id: Users.id, + }) + .from(Users) + .leftJoin( + Instances, + eq(Users.instanceId, Instances.id), + ) + .where( + and( + eq(Users.username, username), + eq(Instances.baseUrl, domain), + ), + ) + )[0]?.id; - const [username, domain] = accountMatches[0].split("@"); + const account = accountId + ? await User.fromId(accountId) + : null; - const accountId = ( - await db - .select({ - id: Users.id, - }) - .from(Users) - .leftJoin(Instances, eq(Users.instanceId, Instances.id)) - .where( - and( - eq(Users.username, username), - eq(Instances.baseUrl, domain), - ), - ) - )[0]?.id; - - const account = accountId ? await User.fromId(accountId) : null; - - if (account) { - return jsonResponse({ - accounts: [account.toAPI()], - statuses: [], - hashtags: [], - }); - } - - if (resolve) { - const newUser = await resolveWebFinger( - username, - domain, - ).catch((e) => { - dualLogger.logError( - LogLevel.ERROR, - "WebFinger.Resolve", - e, - ); - return null; - }); - - if (newUser) { + if (account) { return jsonResponse({ - accounts: [newUser.toAPI()], + accounts: [account.toAPI()], statuses: [], hashtags: [], }); } + + if (resolve) { + const newUser = await resolveWebFinger( + username, + domain, + ).catch((e) => { + dualLogger.logError( + LogLevel.ERROR, + "WebFinger.Resolve", + e, + ); + return null; + }); + + if (newUser) { + return jsonResponse({ + accounts: [newUser.toAPI()], + statuses: [], + hashtags: [], + }); + } + } } + + accountResults = ( + await meilisearch.index(MeiliIndexType.Accounts).search<{ + id: string; + }>(q, { + limit: Number(limit) || 10, + offset: Number(offset) || 0, + sort: ["createdAt:desc"], + }) + ).hits; } - accountResults = ( - await meilisearch.index(MeiliIndexType.Accounts).search<{ - id: string; - }>(q, { - limit: Number(limit) || 10, - offset: Number(offset) || 0, - sort: ["createdAt:desc"], - }) - ).hits; - } + if (!type || type === "statuses") { + statusResults = ( + await meilisearch.index(MeiliIndexType.Statuses).search<{ + id: string; + }>(q, { + limit: Number(limit) || 10, + offset: Number(offset) || 0, + sort: ["createdAt:desc"], + }) + ).hits; + } - if (!type || type === "statuses") { - statusResults = ( - await meilisearch.index(MeiliIndexType.Statuses).search<{ - id: string; - }>(q, { - limit: Number(limit) || 10, - offset: Number(offset) || 0, - sort: ["createdAt:desc"], - }) - ).hits; - } - - const accounts = await User.manyFromSql( - and( - inArray( - Users.id, - accountResults.map((hit) => hit.id), + const accounts = await User.manyFromSql( + and( + inArray( + Users.id, + accountResults.map((hit) => hit.id), + ), + self + ? sql`EXISTS (SELECT 1 FROM Relationships WHERE Relationships.subjectId = ${ + self?.id + } AND Relationships.following = ${!!following} AND Relationships.ownerId = ${ + Users.id + })` + : undefined, ), - self - ? sql`EXISTS (SELECT 1 FROM Relationships WHERE Relationships.subjectId = ${ - self?.id - } AND Relationships.following = ${!!following} AND Relationships.ownerId = ${ - Users.id - })` - : undefined, - ), - ); + ); - const statuses = await Note.manyFromSql( - and( - inArray( - Notes.id, - statusResults.map((hit) => hit.id), + const statuses = await Note.manyFromSql( + and( + inArray( + Notes.id, + statusResults.map((hit) => hit.id), + ), + account_id ? eq(Notes.authorId, account_id) : undefined, + self + ? sql`EXISTS (SELECT 1 FROM Relationships WHERE Relationships.subjectId = ${ + self?.id + } AND Relationships.following = ${!!following} AND Relationships.ownerId = ${ + Notes.authorId + })` + : undefined, ), - account_id ? eq(Notes.authorId, account_id) : undefined, - self - ? sql`EXISTS (SELECT 1 FROM Relationships WHERE Relationships.subjectId = ${ - self?.id - } AND Relationships.following = ${!!following} AND Relationships.ownerId = ${ - Notes.authorId - })` - : undefined, - ), - ); + ); - return jsonResponse({ - accounts: accounts.map((account) => account.toAPI()), - statuses: await Promise.all( - statuses.map((status) => status.toAPI(self)), - ), - hashtags: [], - }); - }, -); + return jsonResponse({ + accounts: accounts.map((account) => account.toAPI()), + statuses: await Promise.all( + statuses.map((status) => status.toAPI(self)), + ), + hashtags: [], + }); + }, + ); diff --git a/server/api/media/[id]/index.ts b/server/api/media/[id]/index.ts index 8c42790b..4a5d9cf9 100644 --- a/server/api/media/[id]/index.ts +++ b/server/api/media/[id]/index.ts @@ -1,5 +1,8 @@ -import { apiRoute, applyConfig } from "@api"; +import { applyConfig, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; import { errorResponse, response } from "@response"; +import type { Hono } from "hono"; +import { z } from "zod"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -13,31 +16,47 @@ export const meta = applyConfig({ }, }); -export default apiRoute(async (req, matchedRoute) => { - // TODO: Add checks for disabled or not email verified accounts +export const schemas = { + param: z.object({ + id: z.string().uuid(), + }), + header: z.object({ + range: z.string().optional().default(""), + }), +}; - const id = matchedRoute.params.id; +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("param", schemas.param, handleZodError), + zValidator("header", schemas.header, handleZodError), + async (context) => { + const { id } = context.req.valid("param"); + const { range } = context.req.valid("header"); - // parse `Range` header - const [start = 0, end = Number.POSITIVE_INFINITY] = ( - (req.headers.get("Range") || "") - .split("=") // ["Range: bytes", "0-100"] - .at(-1) || "" - ) // "0-100" - .split("-") // ["0", "100"] - .map(Number); // [0, 100] + // parse `Range` header + const [start = 0, end = Number.POSITIVE_INFINITY] = ( + range + .split("=") // ["Range: bytes", "0-100"] + .at(-1) || "" + ) // "0-100" + .split("-") // ["0", "100"] + .map(Number); // [0, 100] - // Serve file from filesystem - const file = Bun.file(`./uploads/${id}`); + // Serve file from filesystem + const file = Bun.file(`./uploads/${id}`); - const buffer = await file.arrayBuffer(); + const buffer = await file.arrayBuffer(); - if (!(await file.exists())) return errorResponse("File not found", 404); + if (!(await file.exists())) + return errorResponse("File not found", 404); - // Can't directly copy file into Response because this crashes Bun for now - return response(buffer, 200, { - "Content-Type": file.type || "application/octet-stream", - "Content-Length": `${file.size - start}`, - "Content-Range": `bytes ${start}-${end}/${file.size}`, - }); -}); + // Can't directly copy file into Response because this crashes Bun for now + return response(buffer, 200, { + "Content-Type": file.type || "application/octet-stream", + "Content-Length": `${file.size - start}`, + "Content-Range": `bytes ${start}-${end}/${file.size}`, + }); + }, + ); diff --git a/server/api/media/proxy/index.ts b/server/api/media/proxy/index.ts index dfa19af4..583ffeb8 100644 --- a/server/api/media/proxy/index.ts +++ b/server/api/media/proxy/index.ts @@ -1,5 +1,7 @@ -import { apiRoute, applyConfig } from "@api"; +import { applyConfig, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; import { errorResponse, response } from "@response"; +import type { Hono } from "hono"; import { z } from "zod"; export const meta = applyConfig({ @@ -14,26 +16,31 @@ export const meta = applyConfig({ }, }); -export const schema = z.object({ - // Base64 encoded URL - url: z - .string() - .transform((val) => Buffer.from(val, "base64url").toString()), -}); +export const schemas = { + query: z.object({ + url: z + .string() + .transform((val) => Buffer.from(val, "base64url").toString()), + }), +}; -export default apiRoute( - async (req, matchedRoute, extraData) => { - const { url } = extraData.parsedRequest; +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("query", schemas.query, handleZodError), + async (context) => { + const { url } = context.req.valid("query"); - // Check if URL is valid - if (!URL.canParse(url)) - return errorResponse( - "Invalid URL (it should be encoded as base64url", - 400, - ); + // Check if URL is valid + if (!URL.canParse(url)) + return errorResponse( + "Invalid URL (it should be encoded as base64url", + 400, + ); - return fetch(url).then((res) => { - return response(res.body, res.status, res.headers.toJSON()); - }); - }, -); + return fetch(url).then((res) => { + return response(res.body, res.status, res.headers.toJSON()); + }); + }, + ); diff --git a/server/api/oauth/authorize-external/index.ts b/server/api/oauth/authorize-external/index.ts index dd831b9f..67c5ae13 100644 --- a/server/api/oauth/authorize-external/index.ts +++ b/server/api/oauth/authorize-external/index.ts @@ -1,13 +1,18 @@ -import { apiRoute, applyConfig } from "@api"; +import { applyConfig, handleZodError } from "@api"; import { oauthRedirectUri } from "@constants"; +import { zValidator } from "@hono/zod-validator"; +import { redirect, response } from "@response"; +import type { Hono } from "hono"; import { calculatePKCECodeChallenge, discoveryRequest, generateRandomCodeVerifier, processDiscoveryResponse, } from "oauth4webapi"; +import { z } from "zod"; import { db } from "~drizzle/db"; import { OpenIdLoginFlows } from "~drizzle/schema"; +import { config } from "~packages/config-manager"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -21,78 +26,109 @@ export const meta = applyConfig({ route: "/oauth/authorize-external", }); -/** - * Redirects the user to the external OAuth provider - */ -export default apiRoute(async (req, matchedRoute, extraData) => { - const redirectToLogin = (error: string) => - Response.redirect( - `/oauth/authorize?${new URLSearchParams({ - ...matchedRoute.query, - error: encodeURIComponent(error), - }).toString()}`, - 302, - ); +export const schemas = { + query: z.object({ + issuer: z.string(), + clientId: z.string().optional(), + }), +}; - const issuerId = matchedRoute.query.issuer; +const returnError = (query: object, error: string, description: string) => { + const searchParams = new URLSearchParams(); - // This is the Lysand client's client_id, not the external OAuth provider's client_id - const clientId = matchedRoute.query.clientId; - - if (!clientId || clientId === "undefined") { - return redirectToLogin("Missing client_id"); + // Add all data that is not undefined except email and password + for (const [key, value] of Object.entries(query)) { + if (key !== "email" && key !== "password" && value !== undefined) + searchParams.append(key, value); } - const config = await extraData.configManager.getConfig(); + searchParams.append("error", error); + searchParams.append("error_description", description); - const issuer = config.oidc.providers.find( - (provider) => provider.id === issuerId, - ); - - if (!issuer) { - return redirectToLogin("Invalid issuer"); - } - - const issuerUrl = new URL(issuer.url); - - const authServer = await discoveryRequest(issuerUrl, { - algorithm: "oidc", - }).then((res) => processDiscoveryResponse(issuerUrl, res)); - - const codeVerifier = generateRandomCodeVerifier(); - - const application = await db.query.Applications.findFirst({ - where: (application, { eq }) => eq(application.clientId, clientId), + return response(null, 302, { + Location: `/oauth/authorize?${searchParams.toString()}`, }); +}; - if (!application) { - return redirectToLogin("Invalid client_id"); - } +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("query", schemas.query, handleZodError), + async (context) => { + // This is the Lysand client's client_id, not the external OAuth provider's client_id + const { issuer: issuerId, clientId } = context.req.valid("query"); + const body = await context.req.query(); - // Store into database - const newFlow = ( - await db - .insert(OpenIdLoginFlows) - .values({ - codeVerifier, - applicationId: application.id, - issuerId, - }) - .returning() - )[0]; + if (!clientId || clientId === "undefined") { + return returnError( + body, + "invalid_request", + "client_id is required", + ); + } - const codeChallenge = await calculatePKCECodeChallenge(codeVerifier); + const issuer = config.oidc.providers.find( + (provider) => provider.id === issuerId, + ); - return Response.redirect( - `${authServer.authorization_endpoint}?${new URLSearchParams({ - client_id: issuer.client_id, - redirect_uri: `${oauthRedirectUri(issuerId)}?flow=${newFlow.id}`, - response_type: "code", - scope: "openid profile email", - // PKCE - code_challenge_method: "S256", - code_challenge: codeChallenge, - }).toString()}`, - 302, + if (!issuer) { + return returnError( + body, + "invalid_request", + "issuer is invalid", + ); + } + + const issuerUrl = new URL(issuer.url); + + const authServer = await discoveryRequest(issuerUrl, { + algorithm: "oidc", + }).then((res) => processDiscoveryResponse(issuerUrl, res)); + + const codeVerifier = generateRandomCodeVerifier(); + + const application = await db.query.Applications.findFirst({ + where: (application, { eq }) => + eq(application.clientId, clientId), + }); + + if (!application) { + return returnError( + body, + "invalid_request", + "client_id is invalid", + ); + } + + // Store into database + const newFlow = ( + await db + .insert(OpenIdLoginFlows) + .values({ + codeVerifier, + applicationId: application.id, + issuerId, + }) + .returning() + )[0]; + + const codeChallenge = + await calculatePKCECodeChallenge(codeVerifier); + + return redirect( + `${authServer.authorization_endpoint}?${new URLSearchParams({ + client_id: issuer.client_id, + redirect_uri: `${oauthRedirectUri(issuerId)}?flow=${ + newFlow.id + }`, + response_type: "code", + scope: "openid profile email", + // PKCE + code_challenge_method: "S256", + code_challenge: codeChallenge, + }).toString()}`, + 302, + ); + }, ); -}); diff --git a/server/api/oauth/authorize/index.ts b/server/api/oauth/authorize/index.ts index da34fc51..1707684a 100644 --- a/server/api/oauth/authorize/index.ts +++ b/server/api/oauth/authorize/index.ts @@ -1,6 +1,8 @@ import { randomBytes } from "node:crypto"; -import { apiRoute, applyConfig, idValidator } from "@api"; +import { applyConfig, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; import { response } from "@response"; +import type { Hono } from "hono"; import { SignJWT, jwtVerify } from "jose"; import { z } from "zod"; import { TokenType } from "~database/entities/Token"; @@ -21,36 +23,37 @@ export const meta = applyConfig({ }, }); -export const schema = z.object({ - scope: z.string().optional(), - redirect_uri: z.string().url().optional(), - response_type: z.enum([ - "code", - "token", - "none", - "id_token", - "code id_token", - "code token", - "token id_token", - "code token id_token", - ]), - client_id: z.string(), - state: z.string().optional(), - code_challenge: z.string().optional(), - code_challenge_method: z.enum(["plain", "S256"]).optional(), -}); - -export const querySchema = z.object({ - prompt: z - .enum(["none", "login", "consent", "select_account"]) - .optional() - .default("none"), - max_age: z - .number() - .int() - .optional() - .default(60 * 60 * 24 * 7), -}); +export const schemas = { + query: z.object({ + prompt: z + .enum(["none", "login", "consent", "select_account"]) + .optional() + .default("none"), + max_age: z + .number() + .int() + .optional() + .default(60 * 60 * 24 * 7), + }), + body: z.object({ + scope: z.string().optional(), + redirect_uri: z.string().url().optional(), + response_type: z.enum([ + "code", + "token", + "none", + "id_token", + "code id_token", + "code token", + "token id_token", + "code token id_token", + ]), + client_id: z.string(), + state: z.string().optional(), + code_challenge: z.string().optional(), + code_challenge_method: z.enum(["plain", "S256"]).optional(), + }), +}; const returnError = (query: object, error: string, description: string) => { const searchParams = new URLSearchParams(); @@ -69,254 +72,241 @@ const returnError = (query: object, error: string, description: string) => { }); }; -/** - * OIDC Authorization - */ -export default apiRoute( - async (req, matchedRoute, extraData) => { - const { - scope, - redirect_uri, - response_type, - client_id, - state, - code_challenge, - code_challenge_method, - } = extraData.parsedRequest; +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("query", schemas.query, handleZodError), + zValidator("json", schemas.body, handleZodError), + async (context) => { + const { + scope, + redirect_uri, + response_type, + client_id, + state, + code_challenge, + code_challenge_method, + } = context.req.valid("json"); + const body = context.req.valid("json"); - const cookie = req.headers.get("Cookie"); + const cookie = context.req.header("Cookie"); - if (!cookie) - return returnError( - extraData.parsedRequest, - "invalid_request", - "No cookies were sent with the request", + if (!cookie) + return returnError( + body, + "invalid_request", + "No cookies were sent with the request", + ); + + const jwt = cookie + .split(";") + .find((c) => c.trim().startsWith("jwt=")) + ?.split("=")[1]; + + if (!jwt) + return returnError( + body, + "invalid_request", + "No jwt cookie was sent in the request", + ); + + // Try and import the key + const privateKey = await crypto.subtle.importKey( + "pkcs8", + Buffer.from(config.oidc.jwt_key.split(";")[0], "base64"), + "Ed25519", + true, + ["sign"], ); - const jwt = cookie - .split(";") - .find((c) => c.trim().startsWith("jwt=")) - ?.split("=")[1]; - - if (!jwt) - return returnError( - extraData.parsedRequest, - "invalid_request", - "No jwt cookie was sent in the request", + const publicKey = await crypto.subtle.importKey( + "spki", + Buffer.from(config.oidc.jwt_key.split(";")[1], "base64"), + "Ed25519", + true, + ["verify"], ); - // Try and import the key - const privateKey = await crypto.subtle.importKey( - "pkcs8", - Buffer.from(config.oidc.jwt_key.split(";")[0], "base64"), - "Ed25519", - true, - ["sign"], - ); + const result = await jwtVerify(jwt, publicKey, { + algorithms: ["EdDSA"], + issuer: new URL(config.http.base_url).origin, + audience: client_id, + }).catch((e) => { + console.error(e); + return null; + }); - const publicKey = await crypto.subtle.importKey( - "spki", - Buffer.from(config.oidc.jwt_key.split(";")[1], "base64"), - "Ed25519", - true, - ["verify"], - ); + if (!result) + return returnError( + body, + "invalid_request", + "Invalid JWT, could not verify", + ); - const result = await jwtVerify(jwt, publicKey, { - algorithms: ["EdDSA"], - issuer: new URL(config.http.base_url).origin, - audience: client_id, - }).catch((e) => { - console.error(e); - return null; - }); + const payload = result.payload; - if (!result) - return returnError( - extraData.parsedRequest, - "invalid_request", - "Invalid JWT, could not verify", - ); + if (!payload.sub) + return returnError(body, "invalid_request", "Invalid sub"); + if (!payload.aud) + return returnError(body, "invalid_request", "Invalid aud"); + if (!payload.exp) + return returnError(body, "invalid_request", "Invalid exp"); - const payload = result.payload; + // Check if the user is authenticated + const user = await User.fromId(payload.sub); - if (!payload.sub) - return returnError( - extraData.parsedRequest, - "invalid_request", - "Invalid sub", - ); - if (!payload.aud) - return returnError( - extraData.parsedRequest, - "invalid_request", - "Invalid aud", - ); - if (!payload.exp) - return returnError( - extraData.parsedRequest, - "invalid_request", - "Invalid exp", - ); + if (!user) + return returnError(body, "invalid_request", "Invalid sub"); - // Check if the user is authenticated - const user = await User.fromId(payload.sub); + const responseTypes = response_type.split(" "); - if (!user) - return returnError( - extraData.parsedRequest, - "invalid_request", - "Invalid sub", - ); + const asksCode = responseTypes.includes("code"); + const asksToken = responseTypes.includes("token"); + const asksIdToken = responseTypes.includes("id_token"); - const responseTypes = response_type.split(" "); + if (!asksCode && !asksToken && !asksIdToken) + return returnError( + body, + "invalid_request", + "Invalid response_type, must ask for code, token, or id_token", + ); - const asksCode = responseTypes.includes("code"); - const asksToken = responseTypes.includes("token"); - const asksIdToken = responseTypes.includes("id_token"); + if (asksCode && !redirect_uri) + return returnError( + body, + "invalid_request", + "Redirect URI is required for code flow", + ); - if (!asksCode && !asksToken && !asksIdToken) - return returnError( - extraData.parsedRequest, - "invalid_request", - "Invalid response_type, must ask for code, token, or id_token", - ); + /* if (asksCode && !code_challenge) + return returnError( + "invalid_request", + "Code challenge is required for code flow", + ); + + if (asksCode && !code_challenge_method) + return returnError( + "invalid_request", + "Code challenge method is required for code flow", + ); */ - if (asksCode && !redirect_uri) - return returnError( - extraData.parsedRequest, - "invalid_request", - "Redirect URI is required for code flow", - ); + // Authenticate the user + const application = await db.query.Applications.findFirst({ + where: (app, { eq }) => eq(app.clientId, client_id), + }); - /* if (asksCode && !code_challenge) - return returnError( - "invalid_request", - "Code challenge is required for code flow", - ); + if (!application) + return returnError( + body, + "invalid_client", + "Invalid client_id or client_secret", + ); - if (asksCode && !code_challenge_method) - return returnError( - "invalid_request", - "Code challenge method is required for code flow", - ); */ + if (application.redirectUri !== redirect_uri) + return returnError( + body, + "invalid_request", + "Redirect URI does not match client_id", + ); - // Authenticate the user - const application = await db.query.Applications.findFirst({ - where: (app, { eq }) => eq(app.clientId, client_id), - }); + /* if (application.slate !== slate) + return returnError("invalid_request", "Invalid slate"); */ - if (!application) - return returnError( - extraData.parsedRequest, - "invalid_client", - "Invalid client_id or client_secret", - ); + // Validate scopes, they can either be equal or a subset of the application's scopes + const applicationScopes = application.scopes.split(" "); - if (application.redirectUri !== redirect_uri) - return returnError( - extraData.parsedRequest, - "invalid_request", - "Redirect URI does not match client_id", - ); + if ( + scope && + !scope.split(" ").every((s) => applicationScopes.includes(s)) + ) + return returnError(body, "invalid_scope", "Invalid scope"); - /* if (application.slate !== slate) - return returnError("invalid_request", "Invalid slate"); */ + // Generate tokens + const code = randomBytes(256).toString("base64url"); - // Validate scopes, they can either be equal or a subset of the application's scopes - const applicationScopes = application.scopes.split(" "); - - if ( - scope && - !scope.split(" ").every((s) => applicationScopes.includes(s)) - ) - return returnError( - extraData.parsedRequest, - "invalid_scope", - "Invalid scope", - ); - - // Generate tokens - const code = randomBytes(256).toString("base64url"); - - // Handle the requested scopes - let idTokenPayload = {}; - const scopeIncludesOpenID = scope?.split(" ").includes("openid"); - const scopeIncludesProfile = scope?.split(" ").includes("profile"); - const scopeIncludesEmail = scope?.split(" ").includes("email"); - if (scope) { - const scopes = scope.split(" "); - if (scopeIncludesOpenID) { - // Include the standard OpenID claims - idTokenPayload = { - ...idTokenPayload, - sub: user.id, - aud: client_id, - iss: new URL(config.http.base_url).origin, - iat: Math.floor(Date.now() / 1000), - exp: Math.floor(Date.now() / 1000) + 60 * 60, - }; + // Handle the requested scopes + let idTokenPayload = {}; + const scopeIncludesOpenID = scope?.split(" ").includes("openid"); + const scopeIncludesProfile = scope?.split(" ").includes("profile"); + const scopeIncludesEmail = scope?.split(" ").includes("email"); + if (scope) { + const scopes = scope.split(" "); + if (scopeIncludesOpenID) { + // Include the standard OpenID claims + idTokenPayload = { + ...idTokenPayload, + sub: user.id, + aud: client_id, + iss: new URL(config.http.base_url).origin, + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 60 * 60, + }; + } + if (scopeIncludesProfile) { + // Include the user's profile information + idTokenPayload = { + ...idTokenPayload, + name: user.getUser().displayName, + preferred_username: user.getUser().username, + picture: user.getAvatarUrl(config), + updated_at: new Date( + user.getUser().updatedAt, + ).toISOString(), + }; + } + if (scopeIncludesEmail) { + // Include the user's email address + idTokenPayload = { + ...idTokenPayload, + email: user.getUser().email, + // TODO: Add verification system + email_verified: true, + }; + } } - if (scopeIncludesProfile) { - // Include the user's profile information - idTokenPayload = { - ...idTokenPayload, - name: user.getUser().displayName, - preferred_username: user.getUser().username, - picture: user.getAvatarUrl(config), - updated_at: new Date( - user.getUser().updatedAt, - ).toISOString(), - }; - } - if (scopeIncludesEmail) { - // Include the user's email address - idTokenPayload = { - ...idTokenPayload, - email: user.getUser().email, - // TODO: Add verification system - email_verified: true, - }; - } - } - const idToken = await new SignJWT(idTokenPayload) - .setProtectedHeader({ - alg: "EdDSA", - }) - .sign(privateKey); + const idToken = await new SignJWT(idTokenPayload) + .setProtectedHeader({ + alg: "EdDSA", + }) + .sign(privateKey); - await db.insert(Tokens).values({ - accessToken: randomBytes(64).toString("base64url"), - code: code, - scope: scope ?? application.scopes, - tokenType: TokenType.BEARER, - applicationId: application.id, - redirectUri: redirect_uri ?? application.redirectUri, - expiresAt: new Date(Date.now() + 60 * 60 * 24 * 14).toISOString(), - idToken: - scopeIncludesOpenID || - scopeIncludesEmail || - scopeIncludesProfile - ? idToken - : null, - clientId: client_id, - userId: user.id, - }); + await db.insert(Tokens).values({ + accessToken: randomBytes(64).toString("base64url"), + code: code, + scope: scope ?? application.scopes, + tokenType: TokenType.BEARER, + applicationId: application.id, + redirectUri: redirect_uri ?? application.redirectUri, + expiresAt: new Date( + Date.now() + 60 * 60 * 24 * 14, + ).toISOString(), + idToken: + scopeIncludesOpenID || + scopeIncludesEmail || + scopeIncludesProfile + ? idToken + : null, + clientId: client_id, + userId: user.id, + }); - // Redirect to the client - const redirectUri = new URL(redirect_uri ?? application.redirectUri); + // Redirect to the client + const redirectUri = new URL( + redirect_uri ?? application.redirectUri, + ); - const searchParams = new URLSearchParams({ - code: code, - }); + const searchParams = new URLSearchParams({ + code: code, + }); - return response(null, 302, { - Location: `${redirectUri.origin}${ - redirectUri.pathname - }?${searchParams.toString()}`, - "Cache-Control": "no-store", - Pragma: "no-cache", - }); - }, -); + return response(null, 302, { + Location: `${redirectUri.origin}${ + redirectUri.pathname + }?${searchParams.toString()}`, + "Cache-Control": "no-store", + Pragma: "no-cache", + }); + }, + ); diff --git a/server/api/oauth/callback/:issuer/index.ts b/server/api/oauth/callback/:issuer/index.ts new file mode 100644 index 00000000..f193375d --- /dev/null +++ b/server/api/oauth/callback/:issuer/index.ts @@ -0,0 +1,244 @@ +import { randomBytes } from "node:crypto"; +import { applyConfig, handleZodError } from "@api"; +import { oauthRedirectUri } from "@constants"; +import { zValidator } from "@hono/zod-validator"; +import { response } from "@response"; +import type { Hono } from "hono"; +import { + authorizationCodeGrantRequest, + discoveryRequest, + expectNoState, + getValidatedIdTokenClaims, + isOAuth2Error, + processAuthorizationCodeOpenIDResponse, + processDiscoveryResponse, + processUserInfoResponse, + userInfoRequest, + validateAuthResponse, +} from "oauth4webapi"; +import { z } from "zod"; +import { TokenType } from "~database/entities/Token"; +import { db } from "~drizzle/db"; +import { Tokens } from "~drizzle/schema"; +import { config } from "~packages/config-manager"; +import { User } from "~packages/database-interface/user"; + +export const meta = applyConfig({ + allowedMethods: ["GET"], + auth: { + required: false, + }, + ratelimits: { + duration: 60, + max: 20, + }, + route: "/oauth/callback/:issuer", +}); + +export const schemas = { + query: z.object({ + clientId: z.string().optional(), + flow: z.string(), + }), + param: z.object({ + issuer: z.string(), + }), +}; + +const returnError = (query: object, error: string, description: string) => { + const searchParams = new URLSearchParams(); + + // Add all data that is not undefined except email and password + for (const [key, value] of Object.entries(query)) { + if (key !== "email" && key !== "password" && value !== undefined) + searchParams.append(key, value); + } + + searchParams.append("error", error); + searchParams.append("error_description", description); + + return response(null, 302, { + Location: `/oauth/authorize?${searchParams.toString()}`, + }); +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("query", schemas.query, handleZodError), + zValidator("param", schemas.param, handleZodError), + async (context) => { + const currentUrl = new URL(context.req.url); + + // Remove state query parameter from URL + currentUrl.searchParams.delete("state"); + const { issuer: issuerParam } = context.req.valid("param"); + const { flow: flowId, clientId } = context.req.valid("query"); + + const flow = await db.query.OpenIdLoginFlows.findFirst({ + where: (flow, { eq }) => eq(flow.id, flowId), + with: { + application: true, + }, + }); + + if (!flow) { + return returnError( + context.req.query(), + "invalid_request", + "Invalid flow", + ); + } + + const issuer = config.oidc.providers.find( + (provider) => provider.id === issuerParam, + ); + + if (!issuer) { + return returnError( + context.req.query(), + "invalid_request", + "Invalid issuer", + ); + } + + const issuerUrl = new URL(issuer.url); + + const authServer = await discoveryRequest(issuerUrl, { + algorithm: "oidc", + }).then((res) => processDiscoveryResponse(issuerUrl, res)); + + const parameters = validateAuthResponse( + authServer, + { + client_id: issuer.client_id, + client_secret: issuer.client_secret, + }, + currentUrl, + // Whether to expect state or not + expectNoState, + ); + + if (isOAuth2Error(parameters)) { + return returnError( + context.req.query(), + parameters.error, + parameters.error_description || "", + ); + } + + const response = await authorizationCodeGrantRequest( + authServer, + { + client_id: issuer.client_id, + client_secret: issuer.client_secret, + }, + parameters, + `${oauthRedirectUri(issuerParam)}?flow=${flow.id}`, + flow.codeVerifier, + ); + + const result = await processAuthorizationCodeOpenIDResponse( + authServer, + { + client_id: issuer.client_id, + client_secret: issuer.client_secret, + }, + response, + ); + + if (isOAuth2Error(result)) { + return returnError( + context.req.query(), + result.error, + result.error_description || "", + ); + } + + const { access_token } = result; + + const claims = getValidatedIdTokenClaims(result); + const { sub } = claims; + + // Validate `sub` + // Later, we'll use this to automatically set the user's data + await userInfoRequest( + authServer, + { + client_id: issuer.client_id, + client_secret: issuer.client_secret, + }, + access_token, + ).then((res) => + processUserInfoResponse( + authServer, + { + client_id: issuer.client_id, + client_secret: issuer.client_secret, + }, + sub, + res, + ), + ); + + const userId = ( + await db.query.OpenIdAccounts.findFirst({ + where: (account, { eq, and }) => + and( + eq(account.serverId, sub), + eq(account.issuerId, issuer.id), + ), + }) + )?.userId; + + if (!userId) { + return returnError( + context.req.query(), + "invalid_request", + "No user found with that account", + ); + } + + const user = await User.fromId(userId); + + if (!user) { + return returnError( + context.req.query(), + "invalid_request", + "No user found with that account", + ); + } + + if (!flow.application) + return returnError( + context.req.query(), + "invalid_request", + "No application found", + ); + + const code = randomBytes(32).toString("hex"); + + await db.insert(Tokens).values({ + accessToken: randomBytes(64).toString("base64url"), + code: code, + scope: flow.application.scopes, + tokenType: TokenType.BEARER, + userId: user.id, + applicationId: flow.application.id, + }); + + // Redirect back to application + return Response.redirect( + `/oauth/consent?${new URLSearchParams({ + redirect_uri: flow.application.redirectUri, + code, + client_id: flow.application.clientId, + application: flow.application.name, + website: flow.application.website ?? "", + scope: flow.application.scopes, + }).toString()}`, + 302, + ); + }, + ); diff --git a/server/api/oauth/callback/[issuer]/index.ts b/server/api/oauth/callback/[issuer]/index.ts deleted file mode 100644 index fde3ebe5..00000000 --- a/server/api/oauth/callback/[issuer]/index.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { randomBytes } from "node:crypto"; -import { apiRoute, applyConfig } from "@api"; -import { oauthRedirectUri } from "@constants"; -import { - authorizationCodeGrantRequest, - discoveryRequest, - expectNoState, - getValidatedIdTokenClaims, - isOAuth2Error, - processAuthorizationCodeOpenIDResponse, - processDiscoveryResponse, - processUserInfoResponse, - userInfoRequest, - validateAuthResponse, -} from "oauth4webapi"; -import { TokenType } from "~database/entities/Token"; -import { db } from "~drizzle/db"; -import { Tokens } from "~drizzle/schema"; -import { User } from "~packages/database-interface/user"; - -export const meta = applyConfig({ - allowedMethods: ["GET"], - auth: { - required: false, - }, - ratelimits: { - duration: 60, - max: 20, - }, - route: "/oauth/callback/:issuer", -}); - -/** - * Redirects the user to the external OAuth provider - */ -export default apiRoute(async (req, matchedRoute, extraData) => { - const redirectToLogin = (error: string) => - Response.redirect( - `/oauth/authorize?${new URLSearchParams({ - client_id: matchedRoute.query.clientId, - error: encodeURIComponent(error), - }).toString()}`, - 302, - ); - - const currentUrl = new URL(req.url); - - // Remove state query parameter from URL - currentUrl.searchParams.delete("state"); - const issuerParam = matchedRoute.params.issuer; - - const flow = await db.query.OpenIdLoginFlows.findFirst({ - where: (flow, { eq }) => eq(flow.id, matchedRoute.query.flow), - with: { - application: true, - }, - }); - - if (!flow) { - return redirectToLogin("Invalid flow"); - } - - const config = await extraData.configManager.getConfig(); - - const issuer = config.oidc.providers.find( - (provider) => provider.id === issuerParam, - ); - - if (!issuer) { - return redirectToLogin("Invalid issuer"); - } - - const issuerUrl = new URL(issuer.url); - - const authServer = await discoveryRequest(issuerUrl, { - algorithm: "oidc", - }).then((res) => processDiscoveryResponse(issuerUrl, res)); - - const parameters = validateAuthResponse( - authServer, - { - client_id: issuer.client_id, - client_secret: issuer.client_secret, - }, - currentUrl, - // Whether to expect state or not - expectNoState, - ); - - if (isOAuth2Error(parameters)) { - return redirectToLogin( - parameters.error_description || parameters.error, - ); - } - - const response = await authorizationCodeGrantRequest( - authServer, - { - client_id: issuer.client_id, - client_secret: issuer.client_secret, - }, - parameters, - `${oauthRedirectUri(issuerParam)}?flow=${flow.id}`, - flow.codeVerifier, - ); - - const result = await processAuthorizationCodeOpenIDResponse( - authServer, - { - client_id: issuer.client_id, - client_secret: issuer.client_secret, - }, - response, - ); - - if (isOAuth2Error(result)) { - return redirectToLogin(result.error_description || result.error); - } - - const { access_token } = result; - - const claims = getValidatedIdTokenClaims(result); - const { sub } = claims; - - // Validate `sub` - // Later, we'll use this to automatically set the user's data - await userInfoRequest( - authServer, - { - client_id: issuer.client_id, - client_secret: issuer.client_secret, - }, - access_token, - ).then((res) => - processUserInfoResponse( - authServer, - { - client_id: issuer.client_id, - client_secret: issuer.client_secret, - }, - sub, - res, - ), - ); - - const userId = ( - await db.query.OpenIdAccounts.findFirst({ - where: (account, { eq, and }) => - and(eq(account.serverId, sub), eq(account.issuerId, issuer.id)), - }) - )?.userId; - - if (!userId) { - return redirectToLogin("No user found with that account"); - } - - const user = await User.fromId(userId); - - if (!user) { - return redirectToLogin("No user found with that account"); - } - - if (!flow.application) return redirectToLogin("Invalid client_id"); - - const code = randomBytes(32).toString("hex"); - - await db.insert(Tokens).values({ - accessToken: randomBytes(64).toString("base64url"), - code: code, - scope: flow.application.scopes, - tokenType: TokenType.BEARER, - userId: user.id, - applicationId: flow.application.id, - }); - - // Redirect back to application - return Response.redirect( - `/oauth/consent?${new URLSearchParams({ - redirect_uri: flow.application.redirectUri, - code, - client_id: flow.application.clientId, - application: flow.application.name, - website: flow.application.website ?? "", - scope: flow.application.scopes, - }).toString()}`, - 302, - ); -}); diff --git a/server/api/oauth/providers/index.ts b/server/api/oauth/providers/index.ts index 25a95eb2..f1b56398 100644 --- a/server/api/oauth/providers/index.ts +++ b/server/api/oauth/providers/index.ts @@ -1,5 +1,6 @@ -import { apiRoute, applyConfig } from "@api"; +import { applyConfig } from "@api"; import { jsonResponse } from "@response"; +import type { Hono } from "hono"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -13,17 +14,18 @@ export const meta = applyConfig({ route: "/oauth/providers", }); -/** - * Lists available OAuth providers - */ -export default apiRoute(async (req, matchedRoute, extraData) => { - const config = await extraData.configManager.getConfig(); - - return jsonResponse( - config.oidc.providers.map((p) => ({ - name: p.name, - icon: p.icon, - id: p.id, - })), - ); -}); +export default (app: Hono) => + app.on(meta.allowedMethods, meta.route, async () => { + return jsonResponse([ + { + name: "GitHub", + icon: "github", + id: "github", + }, + { + name: "Google", + icon: "google", + id: "google", + }, + ]); + }); diff --git a/server/api/oauth/token/index.ts b/server/api/oauth/token/index.ts index 16d12760..9cbbe2ec 100644 --- a/server/api/oauth/token/index.ts +++ b/server/api/oauth/token/index.ts @@ -1,6 +1,8 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; +import { applyConfig, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; import { jsonResponse } from "@response"; import { eq } from "drizzle-orm"; +import type { Hono } from "hono"; import { z } from "zod"; import { db } from "~drizzle/db"; import { Tokens } from "~drizzle/schema"; @@ -17,34 +19,36 @@ export const meta = applyConfig({ route: "/oauth/token", }); -export const schema = z.object({ - code: z.string().optional(), - code_verifier: z.string().optional(), - grant_type: z.enum([ - "authorization_code", - "refresh_token", - "client_credentials", - "password", - "urn:ietf:params:oauth:grant-type:device_code", - "urn:ietf:params:oauth:grant-type:token-exchange", - "urn:ietf:params:oauth:grant-type:saml2-bearer", - "urn:openid:params:grant-type:ciba", - ]), - client_id: z.string().optional(), - client_secret: z.string().optional(), - username: z.string().trim().optional(), - password: z.string().trim().optional(), - redirect_uri: z.string().url().optional(), - refresh_token: z.string().optional(), - scope: z.string().optional(), - assertion: z.string().optional(), - audience: z.string().optional(), - subject_token_type: z.string().optional(), - subject_token: z.string().optional(), - actor_token_type: z.string().optional(), - actor_token: z.string().optional(), - auth_req_id: z.string().optional(), -}); +export const schemas = { + json: z.object({ + code: z.string().optional(), + code_verifier: z.string().optional(), + grant_type: z.enum([ + "authorization_code", + "refresh_token", + "client_credentials", + "password", + "urn:ietf:params:oauth:grant-type:device_code", + "urn:ietf:params:oauth:grant-type:token-exchange", + "urn:ietf:params:oauth:grant-type:saml2-bearer", + "urn:openid:params:grant-type:ciba", + ]), + client_id: z.string().optional(), + client_secret: z.string().optional(), + username: z.string().trim().optional(), + password: z.string().trim().optional(), + redirect_uri: z.string().url().optional(), + refresh_token: z.string().optional(), + scope: z.string().optional(), + assertion: z.string().optional(), + audience: z.string().optional(), + subject_token_type: z.string().optional(), + subject_token: z.string().optional(), + actor_token_type: z.string().optional(), + actor_token: z.string().optional(), + auth_req_id: z.string().optional(), + }), +}; const returnError = (error: string, description: string) => jsonResponse( @@ -55,87 +59,89 @@ const returnError = (error: string, description: string) => 401, ); -/** - * Allows getting token from OAuth code - */ -export default apiRoute( - async (req, matchedRoute, extraData) => { - const { - grant_type, - code, - redirect_uri, - scope, - client_id, - client_secret, - } = extraData.parsedRequest; +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("json", schemas.json, handleZodError), + async (context) => { + const { grant_type, code, redirect_uri, client_id, client_secret } = + context.req.valid("json"); - switch (grant_type) { - case "authorization_code": { - if (!code) { - return returnError("invalid_request", "Code is required"); + switch (grant_type) { + case "authorization_code": { + if (!code) { + return returnError( + "invalid_request", + "Code is required", + ); + } + + if (!redirect_uri) { + return returnError( + "invalid_request", + "Redirect URI is required", + ); + } + + if (!client_id) { + return returnError( + "invalid_client", + "Client ID is required", + ); + } + + // Verify the client_secret + const client = await db.query.Applications.findFirst({ + where: (application, { eq }) => + eq(application.clientId, client_id), + }); + + if (!client || client.secret !== client_secret) { + return returnError( + "invalid_client", + "Invalid client credentials", + ); + } + + const token = await db.query.Tokens.findFirst({ + where: (token, { eq, and }) => + and( + eq(token.code, code), + eq(token.redirectUri, redirect_uri), + eq(token.clientId, client_id), + ), + }); + + if (!token) { + return returnError("invalid_grant", "Code not found"); + } + + // Invalidate the code + await db + .update(Tokens) + .set({ code: null }) + .where(eq(Tokens.id, token.id)); + + return jsonResponse({ + access_token: token.accessToken, + token_type: "Bearer", + expires_in: token.expiresAt + ? (new Date(token.expiresAt).getTime() - + Date.now()) / + 1000 + : null, + id_token: token.idToken, + refresh_token: null, + scope: token.scope, + created_at: new Date(token.createdAt).toISOString(), + }); } - - if (!redirect_uri) { - return returnError( - "invalid_request", - "Redirect URI is required", - ); - } - - if (!client_id) { - return returnError( - "invalid_client", - "Client ID is required", - ); - } - - // Verify the client_secret - const client = await db.query.Applications.findFirst({ - where: (application, { eq }) => - eq(application.clientId, client_id), - }); - - if (!client || client.secret !== client_secret) { - return returnError( - "invalid_client", - "Invalid client credentials", - ); - } - - const token = await db.query.Tokens.findFirst({ - where: (token, { eq, and }) => - and( - eq(token.code, code), - eq(token.redirectUri, redirect_uri), - eq(token.clientId, client_id), - ), - }); - - if (!token) { - return returnError("invalid_grant", "Code not found"); - } - - // Invalidate the code - await db - .update(Tokens) - .set({ code: null }) - .where(eq(Tokens.id, token.id)); - - return jsonResponse({ - access_token: token.accessToken, - token_type: "Bearer", - expires_in: token.expiresAt - ? (new Date(token.expiresAt).getTime() - Date.now()) / - 1000 - : null, - id_token: token.idToken, - refresh_token: null, - scope: token.scope, - created_at: new Date(token.createdAt).toISOString(), - }); } - } - return returnError("unsupported_grant_type", "Unsupported grant type"); - }, -); + return returnError( + "unsupported_grant_type", + "Unsupported grant type", + ); + }, + ); diff --git a/server/api/objects/[uuid]/index.ts b/server/api/objects/[uuid]/index.ts index c9dc711c..895b6aa0 100644 --- a/server/api/objects/[uuid]/index.ts +++ b/server/api/objects/[uuid]/index.ts @@ -1,7 +1,10 @@ -import { apiRoute, applyConfig } from "@api"; +import { apiRoute, applyConfig, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; import { errorResponse, jsonResponse } from "@response"; import { and, eq, inArray, sql } from "drizzle-orm"; +import type { Hono } from "hono"; import type * as Lysand from "lysand-types"; +import { z } from "zod"; import { type Like, likeToLysand } from "~database/entities/Like"; import { db } from "~drizzle/db"; import { Notes } from "~drizzle/schema"; @@ -19,35 +22,47 @@ export const meta = applyConfig({ route: "/objects/:id", }); -export default apiRoute(async (req, matchedRoute) => { - const uuid = matchedRoute.params.uuid; +export const schemas = { + param: z.object({ + uuid: z.string().uuid(), + }), +}; - let foundObject: Note | Like | null = null; - let apiObject: Lysand.Entity | null = null; +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("param", schemas.param, handleZodError), + async (context) => { + const { uuid } = context.req.valid("param"); - foundObject = await Note.fromSql( - and( - eq(Notes.id, uuid), - inArray(Notes.visibility, ["public", "unlisted"]), - ), + let foundObject: Note | Like | null = null; + let apiObject: Lysand.Entity | null = null; + + foundObject = await Note.fromSql( + and( + eq(Notes.id, uuid), + inArray(Notes.visibility, ["public", "unlisted"]), + ), + ); + apiObject = foundObject ? foundObject.toLysand() : null; + + if (!foundObject) { + foundObject = + (await db.query.Likes.findFirst({ + where: (like, { eq, and }) => + and( + eq(like.id, uuid), + sql`EXISTS (SELECT 1 FROM statuses WHERE statuses.id = ${like.likedId} AND statuses.visibility IN ('public', 'unlisted'))`, + ), + })) ?? null; + apiObject = foundObject ? likeToLysand(foundObject) : null; + } + + if (!foundObject || !apiObject) { + return errorResponse("Object not found", 404); + } + + return jsonResponse(apiObject); + }, ); - apiObject = foundObject ? foundObject.toLysand() : null; - - if (!foundObject) { - foundObject = - (await db.query.Likes.findFirst({ - where: (like, { eq, and }) => - and( - eq(like.id, uuid), - sql`EXISTS (SELECT 1 FROM statuses WHERE statuses.id = ${like.likedId} AND statuses.visibility IN ('public', 'unlisted'))`, - ), - })) ?? null; - apiObject = foundObject ? likeToLysand(foundObject) : null; - } - - if (!foundObject || !apiObject) { - return errorResponse("Object not found", 404); - } - - return jsonResponse(apiObject); -}); diff --git a/server/api/users/:uuid/inbox/index.ts b/server/api/users/:uuid/inbox/index.ts new file mode 100644 index 00000000..ad6800f4 --- /dev/null +++ b/server/api/users/:uuid/inbox/index.ts @@ -0,0 +1,264 @@ +import { applyConfig, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { dualLogger } from "@loggers"; +import { errorResponse, jsonResponse, response } from "@response"; +import { eq } from "drizzle-orm"; +import type { Hono } from "hono"; +import type * as Lysand from "lysand-types"; +import { z } from "zod"; +import { isValidationError } from "zod-validation-error"; +import { resolveNote } from "~database/entities/Status"; +import { + getRelationshipToOtherUser, + sendFollowAccept, +} from "~database/entities/User"; +import { db } from "~drizzle/db"; +import { Notifications, Relationships } from "~drizzle/schema"; +import { User } from "~packages/database-interface/user"; +import { LogLevel } from "~packages/log-manager"; +import { EntityValidator, SignatureValidator } from "~packages/lysand-utils"; + +export const meta = applyConfig({ + allowedMethods: ["POST"], + auth: { + required: false, + }, + ratelimits: { + duration: 60, + max: 500, + }, + route: "/users/:uuid", +}); + +export const schemas = { + param: z.object({ + uuid: z.string().uuid(), + }), + header: z.object({ + signature: z.string(), + date: z.string(), + }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("param", schemas.param, handleZodError), + zValidator("header", schemas.header, handleZodError), + async (context) => { + const { uuid } = context.req.valid("param"); + const { signature, date } = context.req.valid("header"); + + const user = await User.fromId(uuid); + + if (!user) { + return errorResponse("User not found", 404); + } + + // Verify request signature + // TODO: Check if instance is defederated + // TODO: Reverse DNS lookup with Origin header + // biome-ignore lint/correctness/noConstantCondition: Temporary + if (true) { + if (!signature) { + return errorResponse("Missing Signature header", 400); + } + + if (!date) { + return errorResponse("Missing Date header", 400); + } + + const keyId = signature + .split("keyId=")[1] + .split(",")[0] + .replace(/"/g, ""); + + const sender = await User.resolve(keyId); + + if (!sender) { + return errorResponse("Could not resolve keyId", 400); + } + + const validator = await SignatureValidator.fromStringKey( + sender.getUser().publicKey, + signature, + date, + context.req.method, + new URL(context.req.url), + await context.req.text(), + ); + + const isValid = await validator.validate(); + + if (!isValid) { + return errorResponse("Invalid signature", 400); + } + } + + const validator = new EntityValidator( + (await context.req.json()) as Lysand.Entity, + ); + + try { + // Add sent data to database + switch (validator.getType()) { + case "Note": { + const note = await validator.validate(); + + const account = await User.resolve(note.author); + + if (!account) { + return errorResponse("Author not found", 404); + } + + const newStatus = await resolveNote( + undefined, + note, + ).catch((e) => { + dualLogger.logError( + LogLevel.ERROR, + "Inbox.NoteResolve", + e as Error, + ); + return null; + }); + + if (!newStatus) { + return errorResponse("Failed to add status", 500); + } + + return response("Note created", 201); + } + case "Follow": { + const follow = + await validator.validate(); + + const account = await User.resolve(follow.author); + + if (!account) { + return errorResponse("Author not found", 400); + } + + const foundRelationship = + await getRelationshipToOtherUser(account, user); + + // Check if already following + if (foundRelationship.following) { + return response("Already following", 200); + } + + await db + .update(Relationships) + .set({ + following: !user.getUser().isLocked, + requested: user.getUser().isLocked, + showingReblogs: true, + notifying: true, + languages: [], + }) + .where(eq(Relationships.id, foundRelationship.id)); + + await db.insert(Notifications).values({ + accountId: account.id, + type: user.getUser().isLocked + ? "follow_request" + : "follow", + notifiedId: user.id, + }); + + if (!user.getUser().isLocked) { + // Federate FollowAccept + await sendFollowAccept(account, user); + } + + return response("Follow request sent", 200); + } + case "FollowAccept": { + const followAccept = + await validator.validate(); + + console.log(followAccept); + + const account = await User.resolve(followAccept.author); + + if (!account) { + return errorResponse("Author not found", 400); + } + + console.log(account); + + const foundRelationship = + await getRelationshipToOtherUser(user, account); + + console.log(foundRelationship); + + if (!foundRelationship.requested) { + return response( + "There is no follow request to accept", + 200, + ); + } + + await db + .update(Relationships) + .set({ + following: true, + requested: false, + }) + .where(eq(Relationships.id, foundRelationship.id)); + + return response("Follow request accepted", 200); + } + case "FollowReject": { + const followReject = + await validator.validate(); + + const account = await User.resolve(followReject.author); + + if (!account) { + return errorResponse("Author not found", 400); + } + + const foundRelationship = + await getRelationshipToOtherUser(user, account); + + if (!foundRelationship.requested) { + return response( + "There is no follow request to reject", + 200, + ); + } + + await db + .update(Relationships) + .set({ + requested: false, + following: false, + }) + .where(eq(Relationships.id, foundRelationship.id)); + + return response("Follow request rejected", 200); + } + default: { + return errorResponse( + "Object has not been implemented", + 400, + ); + } + } + } catch (e) { + if (isValidationError(e)) { + return errorResponse(e.message, 400); + } + dualLogger.logError(LogLevel.ERROR, "Inbox", e as Error); + return jsonResponse( + { + error: "Failed to process request", + message: (e as Error).message, + }, + 500, + ); + } + }, + ); diff --git a/server/api/users/:uuid/index.ts b/server/api/users/:uuid/index.ts new file mode 100644 index 00000000..489e112e --- /dev/null +++ b/server/api/users/:uuid/index.ts @@ -0,0 +1,42 @@ +import { applyConfig, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { errorResponse, jsonResponse } from "@response"; +import type { Hono } from "hono"; +import { z } from "zod"; +import { User } from "~packages/database-interface/user"; + +export const meta = applyConfig({ + allowedMethods: ["GET"], + auth: { + required: false, + }, + ratelimits: { + duration: 60, + max: 500, + }, + route: "/users/:uuid", +}); + +export const schemas = { + param: z.object({ + uuid: z.string().uuid(), + }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("param", schemas.param, handleZodError), + async (context) => { + const { uuid } = context.req.valid("param"); + + const user = await User.fromId(uuid); + + if (!user) { + return errorResponse("User not found", 404); + } + + return jsonResponse(user.toLysand()); + }, + ); diff --git a/server/api/users/:uuid/outbox/index.ts b/server/api/users/:uuid/outbox/index.ts new file mode 100644 index 00000000..de7656de --- /dev/null +++ b/server/api/users/:uuid/outbox/index.ts @@ -0,0 +1,93 @@ +import { applyConfig, handleZodError } from "@api"; +import { zValidator } from "@hono/zod-validator"; +import { jsonResponse } from "@response"; +import { and, count, eq, inArray } from "drizzle-orm"; +import type { Hono } from "hono"; +import { z } from "zod"; +import { db } from "~drizzle/db"; +import { Notes } from "~drizzle/schema"; +import { config } from "~packages/config-manager"; +import { Note } from "~packages/database-interface/note"; + +export const meta = applyConfig({ + allowedMethods: ["GET"], + auth: { + required: false, + }, + ratelimits: { + duration: 60, + max: 500, + }, + route: "/users/:uuid/outbox", +}); + +export const schemas = { + param: z.object({ + uuid: z.string().uuid(), + }), + query: z.object({ + page: z.string().optional(), + }), +}; + +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("param", schemas.param, handleZodError), + zValidator("query", schemas.query, handleZodError), + async (context) => { + const { uuid } = context.req.valid("param"); + + const pageNumber = Number(context.req.valid("query").page) || 1; + const host = new URL(config.http.base_url).hostname; + + const notes = await Note.manyFromSql( + and( + eq(Notes.authorId, uuid), + inArray(Notes.visibility, ["public", "unlisted"]), + ), + undefined, + 20, + 20 * (pageNumber - 1), + ); + + const totalNotes = await db + .select({ + count: count(), + }) + .from(Notes) + .where( + and( + eq(Notes.authorId, uuid), + inArray(Notes.visibility, ["public", "unlisted"]), + ), + ); + + return jsonResponse({ + first: `${host}/users/${uuid}/outbox?page=1`, + last: `${host}/users/${uuid}/outbox?page=1`, + total_items: totalNotes, + // Server actor + author: new URL( + "/users/actor", + config.http.base_url, + ).toString(), + next: + notes.length === 20 + ? new URL( + `/users/${uuid}/outbox?page=${pageNumber + 1}`, + config.http.base_url, + ).toString() + : undefined, + prev: + pageNumber > 1 + ? new URL( + `/users/${uuid}/outbox?page=${pageNumber - 1}`, + config.http.base_url, + ).toString() + : undefined, + items: notes.map((note) => note.toLysand()), + }); + }, + ); diff --git a/server/api/users/[uuid]/inbox/index.ts b/server/api/users/[uuid]/inbox/index.ts deleted file mode 100644 index bde3b396..00000000 --- a/server/api/users/[uuid]/inbox/index.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { apiRoute, applyConfig } from "@api"; -import { dualLogger } from "@loggers"; -import { errorResponse, jsonResponse, response } from "@response"; -import { eq } from "drizzle-orm"; -import type * as Lysand from "lysand-types"; -import { isValidationError } from "zod-validation-error"; -import { resolveNote } from "~database/entities/Status"; -import { - getRelationshipToOtherUser, - sendFollowAccept, -} from "~database/entities/User"; -import { db } from "~drizzle/db"; -import { Notifications, Relationships } from "~drizzle/schema"; -import { User } from "~packages/database-interface/user"; -import { LogLevel } from "~packages/log-manager"; -import { EntityValidator, SignatureValidator } from "~packages/lysand-utils"; - -export const meta = applyConfig({ - allowedMethods: ["POST"], - auth: { - required: false, - }, - ratelimits: { - duration: 60, - max: 500, - }, - route: "/users/:uuid", -}); - -export default apiRoute(async (req, matchedRoute, extraData) => { - const uuid = matchedRoute.params.uuid; - - const user = await User.fromId(uuid); - - if (!user) { - return errorResponse("User not found", 404); - } - - // Verify request signature - // TODO: Check if instance is defederated - // TODO: Reverse DNS lookup with Origin header - // biome-ignore lint/correctness/noConstantCondition: Temporary - if (true) { - const Signature = req.headers.get("Signature"); - const DateHeader = req.headers.get("Date"); - - if (!Signature) { - return errorResponse("Missing Signature header", 400); - } - - if (!DateHeader) { - return errorResponse("Missing Date header", 400); - } - - const keyId = Signature.split("keyId=")[1] - .split(",")[0] - .replace(/"/g, ""); - - const sender = await User.resolve(keyId); - - if (!sender) { - return errorResponse("Could not resolve keyId", 400); - } - - const validator = await SignatureValidator.fromStringKey( - sender.getUser().publicKey, - Signature, - DateHeader, - req.method, - new URL(req.url), - await req.text(), - ); - - const isValid = await validator.validate(); - - if (!isValid) { - return errorResponse("Invalid signature", 400); - } - } - - const validator = new EntityValidator( - extraData.parsedRequest as Lysand.Entity, - ); - - try { - // Add sent data to database - switch (validator.getType()) { - case "Note": { - const note = await validator.validate(); - - const account = await User.resolve(note.author); - - if (!account) { - return errorResponse("Author not found", 404); - } - - const newStatus = await resolveNote(undefined, note).catch( - (e) => { - dualLogger.logError( - LogLevel.ERROR, - "Inbox.NoteResolve", - e as Error, - ); - return null; - }, - ); - - if (!newStatus) { - return errorResponse("Failed to add status", 500); - } - - return response("Note created", 201); - } - case "Follow": { - const follow = await validator.validate(); - - const account = await User.resolve(follow.author); - - if (!account) { - return errorResponse("Author not found", 400); - } - - const foundRelationship = await getRelationshipToOtherUser( - account, - user, - ); - - // Check if already following - if (foundRelationship.following) { - return response("Already following", 200); - } - - await db - .update(Relationships) - .set({ - following: !user.getUser().isLocked, - requested: user.getUser().isLocked, - showingReblogs: true, - notifying: true, - languages: [], - }) - .where(eq(Relationships.id, foundRelationship.id)); - - await db.insert(Notifications).values({ - accountId: account.id, - type: user.getUser().isLocked ? "follow_request" : "follow", - notifiedId: user.id, - }); - - if (!user.getUser().isLocked) { - // Federate FollowAccept - await sendFollowAccept(account, user); - } - - return response("Follow request sent", 200); - } - case "FollowAccept": { - const followAccept = - await validator.validate(); - - console.log(followAccept); - - const account = await User.resolve(followAccept.author); - - if (!account) { - return errorResponse("Author not found", 400); - } - - console.log(account); - - const foundRelationship = await getRelationshipToOtherUser( - user, - account, - ); - - console.log(foundRelationship); - - if (!foundRelationship.requested) { - return response( - "There is no follow request to accept", - 200, - ); - } - - await db - .update(Relationships) - .set({ - following: true, - requested: false, - }) - .where(eq(Relationships.id, foundRelationship.id)); - - return response("Follow request accepted", 200); - } - case "FollowReject": { - const followReject = - await validator.validate(); - - const account = await User.resolve(followReject.author); - - if (!account) { - return errorResponse("Author not found", 400); - } - - const foundRelationship = await getRelationshipToOtherUser( - user, - account, - ); - - if (!foundRelationship.requested) { - return response( - "There is no follow request to reject", - 200, - ); - } - - await db - .update(Relationships) - .set({ - requested: false, - following: false, - }) - .where(eq(Relationships.id, foundRelationship.id)); - - return response("Follow request rejected", 200); - } - default: { - return errorResponse("Object has not been implemented", 400); - } - } - } catch (e) { - if (isValidationError(e)) { - return errorResponse(e.message, 400); - } - dualLogger.logError(LogLevel.ERROR, "Inbox", e as Error); - return jsonResponse( - { - error: "Failed to process request", - message: (e as Error).message, - }, - 500, - ); - } -}); diff --git a/server/api/users/[uuid]/index.ts b/server/api/users/[uuid]/index.ts deleted file mode 100644 index 373e4307..00000000 --- a/server/api/users/[uuid]/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { apiRoute, applyConfig } from "@api"; -import { errorResponse, jsonResponse } from "@response"; -import { User } from "~packages/database-interface/user"; - -export const meta = applyConfig({ - allowedMethods: ["GET"], - auth: { - required: false, - }, - ratelimits: { - duration: 60, - max: 500, - }, - route: "/users/:uuid", -}); - -export default apiRoute(async (req, matchedRoute) => { - const uuid = matchedRoute.params.uuid; - - const user = await User.fromId(uuid); - - if (!user) { - return errorResponse("User not found", 404); - } - - return jsonResponse(user.toLysand()); -}); diff --git a/server/api/users/[uuid]/outbox/index.ts b/server/api/users/[uuid]/outbox/index.ts deleted file mode 100644 index 08d41219..00000000 --- a/server/api/users/[uuid]/outbox/index.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { apiRoute, applyConfig } from "@api"; -import { jsonResponse } from "@response"; -import { and, count, eq, inArray } from "drizzle-orm"; -import { db } from "~drizzle/db"; -import { Notes } from "~drizzle/schema"; -import { Note } from "~packages/database-interface/note"; - -export const meta = applyConfig({ - allowedMethods: ["GET"], - auth: { - required: false, - }, - ratelimits: { - duration: 60, - max: 500, - }, - route: "/users/:uuid/outbox", -}); - -export default apiRoute(async (req, matchedRoute, extraData) => { - const uuid = matchedRoute.params.uuid; - const pageNumber = Number(matchedRoute.query.page) || 1; - const config = await extraData.configManager.getConfig(); - const host = new URL(config.http.base_url).hostname; - - const notes = await Note.manyFromSql( - and( - eq(Notes.authorId, uuid), - inArray(Notes.visibility, ["public", "unlisted"]), - ), - undefined, - 20, - 20 * (pageNumber - 1), - ); - - const totalNotes = await db - .select({ - count: count(), - }) - .from(Notes) - .where( - and( - eq(Notes.authorId, uuid), - inArray(Notes.visibility, ["public", "unlisted"]), - ), - ); - - return jsonResponse({ - first: `${host}/users/${uuid}/outbox?page=1`, - last: `${host}/users/${uuid}/outbox?page=1`, - total_items: totalNotes, - // Server actor - author: new URL("/users/actor", config.http.base_url).toString(), - next: - notes.length === 20 - ? new URL( - `/users/${uuid}/outbox?page=${pageNumber + 1}`, - config.http.base_url, - ).toString() - : undefined, - prev: - pageNumber > 1 - ? new URL( - `/users/${uuid}/outbox?page=${pageNumber - 1}`, - config.http.base_url, - ).toString() - : undefined, - items: notes.map((note) => note.toLysand()), - }); -}); diff --git a/server/api/well-known/host-meta/index.ts b/server/api/well-known/host-meta/index.ts index 1b2c85e5..dde2a95d 100644 --- a/server/api/well-known/host-meta/index.ts +++ b/server/api/well-known/host-meta/index.ts @@ -1,5 +1,7 @@ -import { apiRoute, applyConfig } from "@api"; +import { applyConfig } from "@api"; import { xmlResponse } from "@response"; +import type { Hono } from "hono"; +import { config } from "~packages/config-manager"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -13,13 +15,12 @@ export const meta = applyConfig({ route: "/.well-known/host-meta", }); -export default apiRoute(async (req, matchedRoute, extraData) => { - const config = await extraData.configManager.getConfig(); - - return xmlResponse( - ``, - ); -}); +export default (app: Hono) => + app.on(meta.allowedMethods, meta.route, async () => { + return xmlResponse( + ``, + ); + }); diff --git a/server/api/well-known/jwks/index.ts b/server/api/well-known/jwks/index.ts index 2c53545a..c4d1d296 100644 --- a/server/api/well-known/jwks/index.ts +++ b/server/api/well-known/jwks/index.ts @@ -1,6 +1,7 @@ -import { apiRoute, applyConfig } from "@api"; +import { applyConfig } from "@api"; import { jsonResponse } from "@response"; -import { createRemoteJWKSet, exportJWK } from "jose"; +import type { Hono } from "hono"; +import { exportJWK } from "jose"; import { config } from "~packages/config-manager"; export const meta = applyConfig({ @@ -15,28 +16,29 @@ export const meta = applyConfig({ route: "/.well-known/jwks", }); -export default apiRoute(async (req, matchedRoute, extraData) => { - const publicKey = await crypto.subtle.importKey( - "spki", - Buffer.from(config.oidc.jwt_key.split(";")[1], "base64"), - "Ed25519", - true, - ["verify"], - ); +export default (app: Hono) => + app.on(meta.allowedMethods, meta.route, async () => { + const publicKey = await crypto.subtle.importKey( + "spki", + Buffer.from(config.oidc.jwt_key.split(";")[1], "base64"), + "Ed25519", + true, + ["verify"], + ); - const jwk = await exportJWK(publicKey); + const jwk = await exportJWK(publicKey); - // Remove the private key - jwk.d = undefined; + // Remove the private key + jwk.d = undefined; - return jsonResponse({ - keys: [ - { - ...jwk, - use: "sig", - alg: "EdDSA", - kid: "1", - }, - ], + return jsonResponse({ + keys: [ + { + ...jwk, + use: "sig", + alg: "EdDSA", + kid: "1", + }, + ], + }); }); -}); diff --git a/server/api/well-known/lysand.ts b/server/api/well-known/lysand.ts index 3a754b7d..230f6b03 100644 --- a/server/api/well-known/lysand.ts +++ b/server/api/well-known/lysand.ts @@ -1,8 +1,10 @@ -import { apiRoute, applyConfig } from "@api"; +import { applyConfig } from "@api"; import { urlToContentFormat } from "@content_types"; import { jsonResponse } from "@response"; +import type { Hono } from "hono"; import type * as Lysand from "lysand-types"; import pkg from "~package.json"; +import { config } from "~packages/config-manager"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -16,19 +18,16 @@ export const meta = applyConfig({ route: "/.well-known/lysand", }); -export default apiRoute(async (req, matchedRoute, extraData) => { - const config = await extraData.configManager.getConfig(); - - // In the format acct:name@example.com - return jsonResponse({ - type: "ServerMetadata", - name: config.instance.name, - version: pkg.version, - description: config.instance.description, - logo: urlToContentFormat(config.instance.logo) ?? undefined, - banner: urlToContentFormat(config.instance.banner) ?? undefined, - supported_extensions: ["org.lysand:custom_emojis"], - website: "https://lysand.org", - // TODO: Add admins, moderators field - } satisfies Lysand.ServerMetadata); -}); +export default (app: Hono) => + app.on(meta.allowedMethods, meta.route, async () => { + return jsonResponse({ + type: "ServerMetadata", + name: config.instance.name, + version: pkg.version, + description: config.instance.description, + logo: urlToContentFormat(config.instance.logo) ?? undefined, + banner: urlToContentFormat(config.instance.banner) ?? undefined, + supported_extensions: ["org.lysand:custom_emojis"], + website: "https://lysand.org", + } satisfies Lysand.ServerMetadata); + }); diff --git a/server/api/well-known/nodeinfo/2.0/index.ts b/server/api/well-known/nodeinfo/2.0/index.ts index c67f3648..e1bbc91c 100644 --- a/server/api/well-known/nodeinfo/2.0/index.ts +++ b/server/api/well-known/nodeinfo/2.0/index.ts @@ -1,5 +1,6 @@ -import { apiRoute, applyConfig } from "@api"; +import { applyConfig } from "@api"; import { jsonResponse } from "@response"; +import type { Hono } from "hono"; import manifest from "~package.json"; export const meta = applyConfig({ @@ -14,21 +15,18 @@ export const meta = applyConfig({ route: "/.well-known/nodeinfo/2.0", }); -/** - * ActivityPub nodeinfo 2.0 endpoint - */ -export default apiRoute(() => { - // TODO: Implement this - return jsonResponse({ - version: "2.0", - software: { name: "lysand", version: manifest.version }, - protocols: ["lysand"], - services: { outbound: [], inbound: [] }, - usage: { - users: { total: 0, activeMonth: 0, activeHalfyear: 0 }, - localPosts: 0, - }, - openRegistrations: false, - metadata: {}, +export default (app: Hono) => + app.on(meta.allowedMethods, meta.route, async () => { + return jsonResponse({ + version: "2.0", + software: { name: "lysand", version: manifest.version }, + protocols: ["lysand"], + services: { outbound: [], inbound: [] }, + usage: { + users: { total: 0, activeMonth: 0, activeHalfyear: 0 }, + localPosts: 0, + }, + openRegistrations: false, + metadata: {}, + }); }); -}); diff --git a/server/api/well-known/nodeinfo/index.ts b/server/api/well-known/nodeinfo/index.ts index 0aee0bc1..d8990f4e 100644 --- a/server/api/well-known/nodeinfo/index.ts +++ b/server/api/well-known/nodeinfo/index.ts @@ -1,5 +1,7 @@ -import { apiRoute, applyConfig } from "@api"; +import { applyConfig } from "@api"; import { redirect } from "@response"; +import type { Hono } from "hono"; +import { config } from "~packages/config-manager"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -13,11 +15,10 @@ export const meta = applyConfig({ route: "/.well-known/nodeinfo", }); -export default apiRoute(async (req, matchedRoute, extraData) => { - const config = await extraData.configManager.getConfig(); - - return redirect( - new URL("/.well-known/nodeinfo/2.0", config.http.base_url), - 301, - ); -}); +export default (app: Hono) => + app.on(meta.allowedMethods, meta.route, async () => { + return redirect( + new URL("/.well-known/nodeinfo/2.0", config.http.base_url), + 301, + ); + }); diff --git a/server/api/well-known/openid-configuration/index.ts b/server/api/well-known/openid-configuration/index.ts index bab12344..36838e44 100644 --- a/server/api/well-known/openid-configuration/index.ts +++ b/server/api/well-known/openid-configuration/index.ts @@ -1,5 +1,6 @@ -import { apiRoute, applyConfig } from "@api"; +import { applyConfig } from "@api"; import { jsonResponse } from "@response"; +import type { Hono } from "hono"; import { config } from "~packages/config-manager"; export const meta = applyConfig({ @@ -14,19 +15,20 @@ export const meta = applyConfig({ route: "/.well-known/openid-configuration", }); -export default apiRoute(async (req, matchedRoute, extraData) => { - const base_url = new URL(config.http.base_url); - return jsonResponse({ - issuer: base_url.origin.toString(), - authorization_endpoint: `${base_url.origin}/oauth/authorize`, - token_endpoint: `${base_url.origin}/oauth/token`, - userinfo_endpoint: `${base_url.origin}/api/v1/accounts/verify_credentials`, - jwks_uri: `${base_url.origin}/.well-known/jwks`, - response_types_supported: ["code"], - subject_types_supported: ["public"], - id_token_signing_alg_values_supported: ["EdDSA"], - scopes_supported: ["openid", "profile", "email"], - token_endpoint_auth_methods_supported: ["client_secret_basic"], - claims_supported: ["sub"], +export default (app: Hono) => + app.on(meta.allowedMethods, meta.route, async () => { + const base_url = new URL(config.http.base_url); + return jsonResponse({ + issuer: base_url.origin.toString(), + authorization_endpoint: `${base_url.origin}/oauth/authorize`, + token_endpoint: `${base_url.origin}/oauth/token`, + userinfo_endpoint: `${base_url.origin}/api/v1/accounts/verify_credentials`, + jwks_uri: `${base_url.origin}/.well-known/jwks`, + response_types_supported: ["code"], + subject_types_supported: ["public"], + id_token_signing_alg_values_supported: ["EdDSA"], + scopes_supported: ["openid", "profile", "email"], + token_endpoint_auth_methods_supported: ["client_secret_basic"], + claims_supported: ["sub"], + }); }); -}); diff --git a/server/api/well-known/webfinger/index.ts b/server/api/well-known/webfinger/index.ts index 991c5873..9742bd61 100644 --- a/server/api/well-known/webfinger/index.ts +++ b/server/api/well-known/webfinger/index.ts @@ -1,9 +1,12 @@ -import { apiRoute, applyConfig, idValidator } from "@api"; +import { applyConfig, handleZodError, idValidator } from "@api"; +import { zValidator } from "@hono/zod-validator"; import { errorResponse, jsonResponse } from "@response"; import { eq } from "drizzle-orm"; +import type { Hono } from "hono"; import { lookup } from "mime-types"; import { z } from "zod"; import { Users } from "~drizzle/schema"; +import { config } from "~packages/config-manager"; import { User } from "~packages/database-interface/user"; export const meta = applyConfig({ @@ -18,62 +21,70 @@ export const meta = applyConfig({ route: "/.well-known/webfinger", }); -export const schema = z.object({ - resource: z.string().trim().min(1).max(512), -}); +export const schemas = { + query: z.object({ + resource: z.string().trim().min(1).max(512).startsWith("acct:"), + }), +}; -export default apiRoute( - async (req, matchedRoute, extraData) => { - const { resource } = extraData.parsedRequest; +export default (app: Hono) => + app.on( + meta.allowedMethods, + meta.route, + zValidator("query", schemas.query, handleZodError), + async (context) => { + const { resource } = context.req.valid("query"); - // Check if resource is in the correct format (acct:uuid/username@domain) - if (!resource.match(/^acct:[a-zA-Z0-9-]+@[a-zA-Z0-9.-:]+$/)) { - return errorResponse( - "Invalid resource (should be acct:(id or username)@domain)", - 400, + // Check if resource is in the correct format (acct:uuid/username@domain) + if (!resource.match(/^acct:[a-zA-Z0-9-]+@[a-zA-Z0-9.-:]+$/)) { + return errorResponse( + "Invalid resource (should be acct:(id or username)@domain)", + 400, + ); + } + + const requestedUser = resource.split("acct:")[1]; + + const host = new URL(config.http.base_url).host; + + // Check if user is a local user + if (requestedUser.split("@")[1] !== host) { + return errorResponse("User is a remote user", 404); + } + + const isUuid = requestedUser.split("@")[0].match(idValidator); + + const user = await User.fromSql( + eq( + isUuid ? Users.id : Users.username, + requestedUser.split("@")[0], + ), ); - } - const requestedUser = resource.split("acct:")[1]; + if (!user) { + return errorResponse("User not found", 404); + } - const config = await extraData.configManager.getConfig(); - const host = new URL(config.http.base_url).host; + return jsonResponse({ + subject: `acct:${ + isUuid ? user.id : user.getUser().username + }@${host}`, - // Check if user is a local user - if (requestedUser.split("@")[1] !== host) { - return errorResponse("User is a remote user", 404); - } - - const isUuid = requestedUser.split("@")[0].match(idValidator); - - const user = await User.fromSql( - eq(isUuid ? Users.id : Users.username, requestedUser.split("@")[0]), - ); - - if (!user) { - return errorResponse("User not found", 404); - } - - return jsonResponse({ - subject: `acct:${ - isUuid ? user.id : user.getUser().username - }@${host}`, - - links: [ - { - rel: "self", - type: "application/json", - href: new URL( - `/users/${user.id}`, - config.http.base_url, - ).toString(), - }, - { - rel: "avatar", - type: lookup(user.getAvatarUrl(config)), - href: user.getAvatarUrl(config), - }, - ], - }); - }, -); + links: [ + { + rel: "self", + type: "application/json", + href: new URL( + `/users/${user.id}`, + config.http.base_url, + ).toString(), + }, + { + rel: "avatar", + type: lookup(user.getAvatarUrl(config)), + href: user.getAvatarUrl(config), + }, + ], + }); + }, + ); diff --git a/server2.ts b/server2.ts new file mode 100644 index 00000000..d85fc356 --- /dev/null +++ b/server2.ts @@ -0,0 +1,21 @@ +import type { Config } from "config-manager"; +import type { Hono } from "hono"; + +export const createServer = (config: Config, app: Hono) => + Bun.serve({ + port: config.http.bind_port, + tls: config.http.tls.enabled + ? { + key: Bun.file(config.http.tls.key), + cert: Bun.file(config.http.tls.cert), + passphrase: config.http.tls.passphrase, + ca: config.http.tls.ca + ? Bun.file(config.http.tls.ca) + : undefined, + } + : undefined, + hostname: config.http.bind || "0.0.0.0", // defaults to "0.0.0.0" + fetch(req, server) { + return app.fetch(req, { ip: server.requestIP(req) }); + }, + }); diff --git a/tests/utils.ts b/tests/utils.ts index 7bea0159..98f0227f 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -3,17 +3,16 @@ import { asc, inArray, like } from "drizzle-orm"; import type { Status } from "~database/entities/Status"; import { db } from "~drizzle/db"; import { Notes, Tokens, Users } from "~drizzle/schema"; -import { server } from "~index"; +import { app } from "~index"; import { Note } from "~packages/database-interface/note"; import { User } from "~packages/database-interface/user"; /** * This allows us to send a test request to the server even when it isnt running - * CURRENTLY NOT WORKING, NEEDS TO BE FIXED * @param req Request to send * @returns Response from the server */ export async function sendTestRequest(req: Request) { - return server.fetch(req); + return app.fetch(req); } export function wrapRelativeUrl(url: string, base_url: string) { diff --git a/utils/api.ts b/utils/api.ts index 08938ba9..b84376e2 100644 --- a/utils/api.ts +++ b/utils/api.ts @@ -1,4 +1,9 @@ +import { errorResponse } from "@response"; import { config } from "config-manager"; +import type { Context } from "hono"; +import { createMiddleware } from "hono/factory"; +import type { BodyData } from "hono/utils/body"; +import { validator } from "hono/validator"; import { anyOf, caseInsensitive, @@ -7,7 +12,18 @@ import { digit, exactly, } from "magic-regexp"; -import type { APIRouteMetadata, RouteHandler } from "server-handler"; +import { parse } from "qs"; +import type { + APIRouteExports, + APIRouteMetadata, + HttpVerb, + RouteHandler, +} from "server-handler"; +import type { z } from "zod"; +import { fromZodError } from "zod-validation-error"; +import type { Application } from "~database/entities/Application"; +import { getFromHeader, getFromRequest } from "~database/entities/User"; +import type { User } from "~packages/database-interface/user"; export const applyConfig = (routeMeta: APIRouteMetadata) => { const newMeta = routeMeta; @@ -27,7 +43,7 @@ export const apiRoute = < Metadata extends APIRouteMetadata, ZodSchema extends Zod.AnyZodObject, >( - routeFunction: RouteHandler, + routeFunction: APIRouteExports["default"], ) => { return routeFunction; }; @@ -46,3 +62,91 @@ export const idValidator = createRegExp( anyOf(digit, charIn("ABCDEF")).times(12), [caseInsensitive], ); + +export const handleZodError = ( + result: + | { success: true; data?: object } + | { success: false; error: z.ZodError; data?: object }, + context: Context, +) => { + if (!result.success) { + return errorResponse(fromZodError(result.error).message, 422); + } +}; + +export const auth = (authData: APIRouteMetadata["auth"]) => + validator("header", async (value, context) => { + const auth = value.authorization + ? await getFromHeader(value.authorization) + : null; + + const error = errorResponse("Unauthorized", 401); + + if (!auth) { + if (authData.required) { + return context.json( + { + error: "Unauthorized", + }, + 401, + error.headers.toJSON(), + ); + } + + if ( + authData.requiredOnMethods?.includes( + context.req.method as HttpVerb, + ) + ) { + return context.json( + { + error: "Unauthorized", + }, + 401, + error.headers.toJSON(), + ); + } + } else { + return { + user: auth.user as User, + token: auth.token as string, + application: auth.application as Application | null, + }; + } + + return { + user: null, + token: null, + application: null, + }; + }); + +export const qs = () => { + return createMiddleware(async (context, next) => { + const parsed = parse(await context.req.text(), { + parseArrays: true, + interpretNumericEntities: true, + }); + + context.req.parseBody = () => + Promise.resolve(parsed as T); + // @ts-ignore Very bad hack + context.req.formData = () => Promise.resolve(parsed); + // @ts-ignore I'm so sorry for this + context.req.bodyCache.formData = parsed; + await next(); + }); +}; + +export const qsQuery = () => { + return createMiddleware(async (context, next) => { + const parsed = parse(context.req.query(), { + parseArrays: true, + interpretNumericEntities: true, + }); + + // @ts-ignore Very bad hack + context.req.query = () => parsed; + await next(); + }); +};