From 66df10d6b02cb1bed437665bc293dbcd5b9c73ff Mon Sep 17 00:00:00 2001 From: TheArcaneBrony Date: Sat, 14 Jan 2023 13:08:48 +0100 Subject: Move endpoints to respective versions, split out non implemented routes Signed-off-by: TheArcaneBrony --- src/api/Server.ts | 6 +- src/api/routes/-/healthz.ts | 13 - src/api/routes/-/readyz.ts | 13 - src/api/routes/applications/#id/bot/index.ts | 102 ------ src/api/routes/applications/#id/entitlements.ts | 12 - src/api/routes/applications/#id/index.ts | 81 ----- src/api/routes/applications/#id/skus.ts | 11 - src/api/routes/applications/detectable.ts | 11 - src/api/routes/applications/index.ts | 42 --- .../routes/auth/generate-registration-tokens.ts | 49 --- src/api/routes/auth/location-metadata.ts | 17 - src/api/routes/auth/login.ts | 138 -------- src/api/routes/auth/logout.ts | 17 - src/api/routes/auth/mfa/totp.ts | 52 --- src/api/routes/auth/register.ts | 278 ---------------- .../auth/verify/view-backup-codes-challenge.ts | 34 -- src/api/routes/channels/#channel_id/followers.ts | 14 - src/api/routes/channels/#channel_id/index.ts | 103 ------ src/api/routes/channels/#channel_id/invites.ts | 91 ------ .../#channel_id/messages/#message_id/ack.ts | 52 --- .../#channel_id/messages/#message_id/crosspost.ts | 38 --- .../#channel_id/messages/#message_id/index.ts | 246 --------------- .../#channel_id/messages/#message_id/reactions.ts | 250 --------------- .../channels/#channel_id/messages/bulk-delete.ts | 65 ---- .../routes/channels/#channel_id/messages/index.ts | 351 --------------------- src/api/routes/channels/#channel_id/permissions.ts | 101 ------ src/api/routes/channels/#channel_id/pins.ts | 113 ------- src/api/routes/channels/#channel_id/purge.ts | 99 ------ src/api/routes/channels/#channel_id/recipients.ts | 89 ------ src/api/routes/channels/#channel_id/typing.ts | 45 --- src/api/routes/channels/#channel_id/webhooks.ts | 71 ----- src/api/routes/discoverable-guilds.ts | 46 --- src/api/routes/discovery.ts | 20 -- src/api/routes/download/index.ts | 34 -- src/api/routes/experiments.ts | 11 - src/api/routes/gateway/bot.ts | 40 --- src/api/routes/gateway/index.ts | 26 -- src/api/routes/gifs/search.ts | 31 -- src/api/routes/gifs/trending-gifs.ts | 31 -- src/api/routes/gifs/trending.ts | 72 ----- src/api/routes/guild-recommendations.ts | 30 -- src/api/routes/guilds/#guild_id/audit-logs.ts | 17 - src/api/routes/guilds/#guild_id/bans.ts | 200 ------------ src/api/routes/guilds/#guild_id/channels.ts | 86 ----- src/api/routes/guilds/#guild_id/delete.ts | 44 --- .../guilds/#guild_id/discovery-requirements.ts | 39 --- src/api/routes/guilds/#guild_id/emojis.ts | 148 --------- src/api/routes/guilds/#guild_id/index.ts | 129 -------- src/api/routes/guilds/#guild_id/integrations.ts | 9 - src/api/routes/guilds/#guild_id/invites.ts | 22 -- .../routes/guilds/#guild_id/member-verification.ts | 14 - .../guilds/#guild_id/members/#member_id/index.ts | 130 -------- .../guilds/#guild_id/members/#member_id/nick.ts | 26 -- .../members/#member_id/roles/#role_id/index.ts | 29 -- src/api/routes/guilds/#guild_id/members/index.ts | 32 -- src/api/routes/guilds/#guild_id/messages/search.ts | 137 -------- src/api/routes/guilds/#guild_id/premium.ts | 10 - src/api/routes/guilds/#guild_id/profile/index.ts | 48 --- src/api/routes/guilds/#guild_id/prune.ts | 106 ------- src/api/routes/guilds/#guild_id/regions.ts | 20 -- .../guilds/#guild_id/roles/#role_id/index.ts | 92 ------ src/api/routes/guilds/#guild_id/roles/index.ts | 123 -------- src/api/routes/guilds/#guild_id/stickers.ts | 137 -------- src/api/routes/guilds/#guild_id/templates.ts | 116 ------- src/api/routes/guilds/#guild_id/vanity-url.ts | 82 ----- .../#guild_id/voice-states/#user_id/index.ts | 71 ----- src/api/routes/guilds/#guild_id/webhooks.ts | 9 - src/api/routes/guilds/#guild_id/welcome-screen.ts | 43 --- src/api/routes/guilds/#guild_id/widget.json.ts | 97 ------ src/api/routes/guilds/#guild_id/widget.png.ts | 179 ----------- src/api/routes/guilds/#guild_id/widget.ts | 40 --- src/api/routes/guilds/index.ts | 47 --- src/api/routes/guilds/templates/index.ts | 132 -------- src/api/routes/invites/index.ts | 89 ------ src/api/routes/oauth2/authorize.ts | 168 ---------- src/api/routes/oauth2/tokens.ts | 10 - src/api/routes/outbound-promotions.ts | 11 - src/api/routes/partners/#guild_id/requirements.ts | 39 --- src/api/routes/ping.ts | 26 -- src/api/routes/policies/instance/domains.ts | 21 -- src/api/routes/policies/instance/index.ts | 11 - src/api/routes/policies/instance/limits.ts | 11 - src/api/routes/policies/stats.ts | 29 -- .../routes/scheduled-maintenances/upcoming_json.ts | 16 - src/api/routes/science.ts | 11 - src/api/routes/stage-instances.ts | 11 - src/api/routes/sticker-packs/index.ts | 13 - src/api/routes/stickers/#sticker_id/index.ts | 12 - src/api/routes/stop.ts | 16 - .../store/published-listings/applications.ts | 79 ----- .../applications/#id/subscription-plans.ts | 25 -- src/api/routes/store/published-listings/skus.ts | 79 ----- .../skus/#sku_id/subscription-plans.ts | 313 ------------------ src/api/routes/teams.ts | 11 - src/api/routes/template.ts.disabled | 11 - src/api/routes/track.ts | 11 - src/api/routes/updates.ts | 35 -- src/api/routes/users/#id/delete.ts | 39 --- src/api/routes/users/#id/index.ts | 13 - src/api/routes/users/#id/profile.ts | 182 ----------- src/api/routes/users/#id/relationships.ts | 54 ---- .../@me/activities/statistics/applications.ts | 11 - src/api/routes/users/@me/affinities/guilds.ts | 11 - src/api/routes/users/@me/affinities/users.ts | 11 - .../users/@me/applications/#app_id/entitlements.ts | 11 - src/api/routes/users/@me/billing/country-code.ts | 11 - .../routes/users/@me/billing/payment-sources.ts | 11 - src/api/routes/users/@me/billing/subscriptions.ts | 11 - src/api/routes/users/@me/channels.ts | 39 --- src/api/routes/users/@me/connections.ts | 11 - src/api/routes/users/@me/delete.ts | 38 --- src/api/routes/users/@me/devices.ts | 11 - src/api/routes/users/@me/disable.ts | 32 -- src/api/routes/users/@me/email-settings.ts | 20 -- src/api/routes/users/@me/entitlements.ts | 11 - src/api/routes/users/@me/guilds.ts | 76 ----- .../routes/users/@me/guilds/#guild_id/settings.ts | 44 --- .../users/@me/guilds/premium/subscription-slots.ts | 11 - src/api/routes/users/@me/index.ts | 156 --------- src/api/routes/users/@me/library.ts | 11 - src/api/routes/users/@me/mfa/codes-verification.ts | 49 --- src/api/routes/users/@me/mfa/codes.ts | 62 ---- src/api/routes/users/@me/mfa/totp/disable.ts | 56 ---- src/api/routes/users/@me/mfa/totp/enable.ts | 59 ---- src/api/routes/users/@me/notes.ts | 68 ---- src/api/routes/users/@me/relationships.ts | 259 --------------- src/api/routes/users/@me/settings.ts | 35 -- src/api/routes/v0/applications/#id/skus.ts | 11 + src/api/routes/v0/applications/detectable.ts | 11 + src/api/routes/v0/channels/#channel_id/webhooks.ts | 25 ++ src/api/routes/v0/experiments.ts | 11 + src/api/routes/v0/guilds/#guild_id/integrations.ts | 9 + .../v0/guilds/#guild_id/member-verification.ts | 14 + src/api/routes/v0/guilds/#guild_id/premium.ts | 10 + src/api/routes/v0/guilds/#guild_id/webhooks.ts | 9 + src/api/routes/v0/oauth2/tokens.ts | 10 + src/api/routes/v0/outbound-promotions.ts | 11 + src/api/routes/v0/science.ts | 11 + src/api/routes/v0/stage-instances.ts | 11 + src/api/routes/v0/teams.ts | 11 + src/api/routes/v0/template.ts.disabled | 11 + src/api/routes/v0/track.ts | 11 + .../@me/activities/statistics/applications.ts | 11 + src/api/routes/v0/users/@me/affinities/guilds.ts | 11 + src/api/routes/v0/users/@me/affinities/users.ts | 11 + .../users/@me/applications/#app_id/entitlements.ts | 11 + .../routes/v0/users/@me/billing/country-code.ts | 11 + .../routes/v0/users/@me/billing/payment-sources.ts | 11 + .../routes/v0/users/@me/billing/subscriptions.ts | 11 + src/api/routes/v0/users/@me/connections.ts | 11 + src/api/routes/v0/users/@me/devices.ts | 11 + src/api/routes/v0/users/@me/entitlements.ts | 11 + .../users/@me/guilds/premium/subscription-slots.ts | 11 + src/api/routes/v0/users/@me/library.ts | 11 + src/api/routes/v9/-/healthz.ts | 13 + src/api/routes/v9/-/readyz.ts | 13 + src/api/routes/v9/applications/#id/bot/index.ts | 102 ++++++ src/api/routes/v9/applications/#id/entitlements.ts | 12 + src/api/routes/v9/applications/#id/index.ts | 81 +++++ src/api/routes/v9/applications/index.ts | 42 +++ .../routes/v9/auth/generate-registration-tokens.ts | 49 +++ src/api/routes/v9/auth/location-metadata.ts | 17 + src/api/routes/v9/auth/login.ts | 138 ++++++++ src/api/routes/v9/auth/logout.ts | 17 + src/api/routes/v9/auth/mfa/totp.ts | 52 +++ src/api/routes/v9/auth/register.ts | 278 ++++++++++++++++ .../v9/auth/verify/view-backup-codes-challenge.ts | 34 ++ .../routes/v9/channels/#channel_id/followers.ts | 14 + src/api/routes/v9/channels/#channel_id/index.ts | 103 ++++++ src/api/routes/v9/channels/#channel_id/invites.ts | 91 ++++++ .../#channel_id/messages/#message_id/ack.ts | 52 +++ .../#channel_id/messages/#message_id/crosspost.ts | 38 +++ .../#channel_id/messages/#message_id/index.ts | 246 +++++++++++++++ .../#channel_id/messages/#message_id/reactions.ts | 250 +++++++++++++++ .../channels/#channel_id/messages/bulk-delete.ts | 65 ++++ .../v9/channels/#channel_id/messages/index.ts | 351 +++++++++++++++++++++ .../routes/v9/channels/#channel_id/permissions.ts | 101 ++++++ src/api/routes/v9/channels/#channel_id/pins.ts | 113 +++++++ src/api/routes/v9/channels/#channel_id/purge.ts | 99 ++++++ .../routes/v9/channels/#channel_id/recipients.ts | 89 ++++++ src/api/routes/v9/channels/#channel_id/typing.ts | 45 +++ src/api/routes/v9/channels/#channel_id/webhooks.ts | 66 ++++ src/api/routes/v9/discoverable-guilds.ts | 46 +++ src/api/routes/v9/discovery.ts | 20 ++ src/api/routes/v9/download/index.ts | 34 ++ src/api/routes/v9/gateway/bot.ts | 40 +++ src/api/routes/v9/gateway/index.ts | 26 ++ src/api/routes/v9/gifs/search.ts | 31 ++ src/api/routes/v9/gifs/trending-gifs.ts | 31 ++ src/api/routes/v9/gifs/trending.ts | 72 +++++ src/api/routes/v9/guild-recommendations.ts | 30 ++ src/api/routes/v9/guilds/#guild_id/audit-logs.ts | 17 + src/api/routes/v9/guilds/#guild_id/bans.ts | 200 ++++++++++++ src/api/routes/v9/guilds/#guild_id/channels.ts | 86 +++++ src/api/routes/v9/guilds/#guild_id/delete.ts | 44 +++ .../v9/guilds/#guild_id/discovery-requirements.ts | 39 +++ src/api/routes/v9/guilds/#guild_id/emojis.ts | 148 +++++++++ src/api/routes/v9/guilds/#guild_id/index.ts | 129 ++++++++ src/api/routes/v9/guilds/#guild_id/invites.ts | 22 ++ .../guilds/#guild_id/members/#member_id/index.ts | 130 ++++++++ .../v9/guilds/#guild_id/members/#member_id/nick.ts | 26 ++ .../members/#member_id/roles/#role_id/index.ts | 29 ++ .../routes/v9/guilds/#guild_id/members/index.ts | 32 ++ .../routes/v9/guilds/#guild_id/messages/search.ts | 137 ++++++++ .../routes/v9/guilds/#guild_id/profile/index.ts | 48 +++ src/api/routes/v9/guilds/#guild_id/prune.ts | 106 +++++++ src/api/routes/v9/guilds/#guild_id/regions.ts | 20 ++ .../v9/guilds/#guild_id/roles/#role_id/index.ts | 92 ++++++ src/api/routes/v9/guilds/#guild_id/roles/index.ts | 123 ++++++++ src/api/routes/v9/guilds/#guild_id/stickers.ts | 137 ++++++++ src/api/routes/v9/guilds/#guild_id/templates.ts | 116 +++++++ src/api/routes/v9/guilds/#guild_id/vanity-url.ts | 82 +++++ .../#guild_id/voice-states/#user_id/index.ts | 71 +++++ .../routes/v9/guilds/#guild_id/welcome-screen.ts | 43 +++ src/api/routes/v9/guilds/#guild_id/widget.json.ts | 97 ++++++ src/api/routes/v9/guilds/#guild_id/widget.png.ts | 179 +++++++++++ src/api/routes/v9/guilds/#guild_id/widget.ts | 40 +++ src/api/routes/v9/guilds/index.ts | 47 +++ src/api/routes/v9/guilds/templates/index.ts | 132 ++++++++ src/api/routes/v9/invites/index.ts | 89 ++++++ src/api/routes/v9/oauth2/authorize.ts | 168 ++++++++++ .../routes/v9/partners/#guild_id/requirements.ts | 39 +++ src/api/routes/v9/ping.ts | 26 ++ src/api/routes/v9/policies/instance/domains.ts | 21 ++ src/api/routes/v9/policies/instance/index.ts | 11 + src/api/routes/v9/policies/instance/limits.ts | 11 + src/api/routes/v9/policies/stats.ts | 29 ++ .../v9/scheduled-maintenances/upcoming_json.ts | 16 + src/api/routes/v9/sticker-packs/index.ts | 13 + src/api/routes/v9/stickers/#sticker_id/index.ts | 12 + src/api/routes/v9/stop.ts | 16 + .../v9/store/published-listings/applications.ts | 79 +++++ .../applications/#id/subscription-plans.ts | 25 ++ src/api/routes/v9/store/published-listings/skus.ts | 79 +++++ .../skus/#sku_id/subscription-plans.ts | 313 ++++++++++++++++++ src/api/routes/v9/updates.ts | 35 ++ src/api/routes/v9/users/#id/delete.ts | 38 +++ src/api/routes/v9/users/#id/index.ts | 13 + src/api/routes/v9/users/#id/profile.ts | 182 +++++++++++ src/api/routes/v9/users/#id/relationships.ts | 54 ++++ src/api/routes/v9/users/@me/channels.ts | 39 +++ src/api/routes/v9/users/@me/delete.ts | 38 +++ src/api/routes/v9/users/@me/disable.ts | 32 ++ src/api/routes/v9/users/@me/email-settings.ts | 20 ++ src/api/routes/v9/users/@me/guilds.ts | 76 +++++ .../v9/users/@me/guilds/#guild_id/settings.ts | 44 +++ src/api/routes/v9/users/@me/index.ts | 156 +++++++++ .../routes/v9/users/@me/mfa/codes-verification.ts | 49 +++ src/api/routes/v9/users/@me/mfa/codes.ts | 62 ++++ src/api/routes/v9/users/@me/mfa/totp/disable.ts | 56 ++++ src/api/routes/v9/users/@me/mfa/totp/enable.ts | 59 ++++ src/api/routes/v9/users/@me/notes.ts | 68 ++++ src/api/routes/v9/users/@me/relationships.ts | 259 +++++++++++++++ src/api/routes/v9/users/@me/settings.ts | 35 ++ src/api/routes/v9/voice/regions.ts | 11 + src/api/routes/voice/regions.ts | 11 - 256 files changed, 7855 insertions(+), 7840 deletions(-) delete mode 100644 src/api/routes/-/healthz.ts delete mode 100644 src/api/routes/-/readyz.ts delete mode 100644 src/api/routes/applications/#id/bot/index.ts delete mode 100644 src/api/routes/applications/#id/entitlements.ts delete mode 100644 src/api/routes/applications/#id/index.ts delete mode 100644 src/api/routes/applications/#id/skus.ts delete mode 100644 src/api/routes/applications/detectable.ts delete mode 100644 src/api/routes/applications/index.ts delete mode 100644 src/api/routes/auth/generate-registration-tokens.ts delete mode 100644 src/api/routes/auth/location-metadata.ts delete mode 100644 src/api/routes/auth/login.ts delete mode 100644 src/api/routes/auth/logout.ts delete mode 100644 src/api/routes/auth/mfa/totp.ts delete mode 100644 src/api/routes/auth/register.ts delete mode 100644 src/api/routes/auth/verify/view-backup-codes-challenge.ts delete mode 100644 src/api/routes/channels/#channel_id/followers.ts delete mode 100644 src/api/routes/channels/#channel_id/index.ts delete mode 100644 src/api/routes/channels/#channel_id/invites.ts delete mode 100644 src/api/routes/channels/#channel_id/messages/#message_id/ack.ts delete mode 100644 src/api/routes/channels/#channel_id/messages/#message_id/crosspost.ts delete mode 100644 src/api/routes/channels/#channel_id/messages/#message_id/index.ts delete mode 100644 src/api/routes/channels/#channel_id/messages/#message_id/reactions.ts delete mode 100644 src/api/routes/channels/#channel_id/messages/bulk-delete.ts delete mode 100644 src/api/routes/channels/#channel_id/messages/index.ts delete mode 100644 src/api/routes/channels/#channel_id/permissions.ts delete mode 100644 src/api/routes/channels/#channel_id/pins.ts delete mode 100644 src/api/routes/channels/#channel_id/purge.ts delete mode 100644 src/api/routes/channels/#channel_id/recipients.ts delete mode 100644 src/api/routes/channels/#channel_id/typing.ts delete mode 100644 src/api/routes/channels/#channel_id/webhooks.ts delete mode 100644 src/api/routes/discoverable-guilds.ts delete mode 100644 src/api/routes/discovery.ts delete mode 100644 src/api/routes/download/index.ts delete mode 100644 src/api/routes/experiments.ts delete mode 100644 src/api/routes/gateway/bot.ts delete mode 100644 src/api/routes/gateway/index.ts delete mode 100644 src/api/routes/gifs/search.ts delete mode 100644 src/api/routes/gifs/trending-gifs.ts delete mode 100644 src/api/routes/gifs/trending.ts delete mode 100644 src/api/routes/guild-recommendations.ts delete mode 100644 src/api/routes/guilds/#guild_id/audit-logs.ts delete mode 100644 src/api/routes/guilds/#guild_id/bans.ts delete mode 100644 src/api/routes/guilds/#guild_id/channels.ts delete mode 100644 src/api/routes/guilds/#guild_id/delete.ts delete mode 100644 src/api/routes/guilds/#guild_id/discovery-requirements.ts delete mode 100644 src/api/routes/guilds/#guild_id/emojis.ts delete mode 100644 src/api/routes/guilds/#guild_id/index.ts delete mode 100644 src/api/routes/guilds/#guild_id/integrations.ts delete mode 100644 src/api/routes/guilds/#guild_id/invites.ts delete mode 100644 src/api/routes/guilds/#guild_id/member-verification.ts delete mode 100644 src/api/routes/guilds/#guild_id/members/#member_id/index.ts delete mode 100644 src/api/routes/guilds/#guild_id/members/#member_id/nick.ts delete mode 100644 src/api/routes/guilds/#guild_id/members/#member_id/roles/#role_id/index.ts delete mode 100644 src/api/routes/guilds/#guild_id/members/index.ts delete mode 100644 src/api/routes/guilds/#guild_id/messages/search.ts delete mode 100644 src/api/routes/guilds/#guild_id/premium.ts delete mode 100644 src/api/routes/guilds/#guild_id/profile/index.ts delete mode 100644 src/api/routes/guilds/#guild_id/prune.ts delete mode 100644 src/api/routes/guilds/#guild_id/regions.ts delete mode 100644 src/api/routes/guilds/#guild_id/roles/#role_id/index.ts delete mode 100644 src/api/routes/guilds/#guild_id/roles/index.ts delete mode 100644 src/api/routes/guilds/#guild_id/stickers.ts delete mode 100644 src/api/routes/guilds/#guild_id/templates.ts delete mode 100644 src/api/routes/guilds/#guild_id/vanity-url.ts delete mode 100644 src/api/routes/guilds/#guild_id/voice-states/#user_id/index.ts delete mode 100644 src/api/routes/guilds/#guild_id/webhooks.ts delete mode 100644 src/api/routes/guilds/#guild_id/welcome-screen.ts delete mode 100644 src/api/routes/guilds/#guild_id/widget.json.ts delete mode 100644 src/api/routes/guilds/#guild_id/widget.png.ts delete mode 100644 src/api/routes/guilds/#guild_id/widget.ts delete mode 100644 src/api/routes/guilds/index.ts delete mode 100644 src/api/routes/guilds/templates/index.ts delete mode 100644 src/api/routes/invites/index.ts delete mode 100644 src/api/routes/oauth2/authorize.ts delete mode 100644 src/api/routes/oauth2/tokens.ts delete mode 100644 src/api/routes/outbound-promotions.ts delete mode 100644 src/api/routes/partners/#guild_id/requirements.ts delete mode 100644 src/api/routes/ping.ts delete mode 100644 src/api/routes/policies/instance/domains.ts delete mode 100644 src/api/routes/policies/instance/index.ts delete mode 100644 src/api/routes/policies/instance/limits.ts delete mode 100644 src/api/routes/policies/stats.ts delete mode 100644 src/api/routes/scheduled-maintenances/upcoming_json.ts delete mode 100644 src/api/routes/science.ts delete mode 100644 src/api/routes/stage-instances.ts delete mode 100644 src/api/routes/sticker-packs/index.ts delete mode 100644 src/api/routes/stickers/#sticker_id/index.ts delete mode 100644 src/api/routes/stop.ts delete mode 100644 src/api/routes/store/published-listings/applications.ts delete mode 100644 src/api/routes/store/published-listings/applications/#id/subscription-plans.ts delete mode 100644 src/api/routes/store/published-listings/skus.ts delete mode 100644 src/api/routes/store/published-listings/skus/#sku_id/subscription-plans.ts delete mode 100644 src/api/routes/teams.ts delete mode 100644 src/api/routes/template.ts.disabled delete mode 100644 src/api/routes/track.ts delete mode 100644 src/api/routes/updates.ts delete mode 100644 src/api/routes/users/#id/delete.ts delete mode 100644 src/api/routes/users/#id/index.ts delete mode 100644 src/api/routes/users/#id/profile.ts delete mode 100644 src/api/routes/users/#id/relationships.ts delete mode 100644 src/api/routes/users/@me/activities/statistics/applications.ts delete mode 100644 src/api/routes/users/@me/affinities/guilds.ts delete mode 100644 src/api/routes/users/@me/affinities/users.ts delete mode 100644 src/api/routes/users/@me/applications/#app_id/entitlements.ts delete mode 100644 src/api/routes/users/@me/billing/country-code.ts delete mode 100644 src/api/routes/users/@me/billing/payment-sources.ts delete mode 100644 src/api/routes/users/@me/billing/subscriptions.ts delete mode 100644 src/api/routes/users/@me/channels.ts delete mode 100644 src/api/routes/users/@me/connections.ts delete mode 100644 src/api/routes/users/@me/delete.ts delete mode 100644 src/api/routes/users/@me/devices.ts delete mode 100644 src/api/routes/users/@me/disable.ts delete mode 100644 src/api/routes/users/@me/email-settings.ts delete mode 100644 src/api/routes/users/@me/entitlements.ts delete mode 100644 src/api/routes/users/@me/guilds.ts delete mode 100644 src/api/routes/users/@me/guilds/#guild_id/settings.ts delete mode 100644 src/api/routes/users/@me/guilds/premium/subscription-slots.ts delete mode 100644 src/api/routes/users/@me/index.ts delete mode 100644 src/api/routes/users/@me/library.ts delete mode 100644 src/api/routes/users/@me/mfa/codes-verification.ts delete mode 100644 src/api/routes/users/@me/mfa/codes.ts delete mode 100644 src/api/routes/users/@me/mfa/totp/disable.ts delete mode 100644 src/api/routes/users/@me/mfa/totp/enable.ts delete mode 100644 src/api/routes/users/@me/notes.ts delete mode 100644 src/api/routes/users/@me/relationships.ts delete mode 100644 src/api/routes/users/@me/settings.ts create mode 100644 src/api/routes/v0/applications/#id/skus.ts create mode 100644 src/api/routes/v0/applications/detectable.ts create mode 100644 src/api/routes/v0/channels/#channel_id/webhooks.ts create mode 100644 src/api/routes/v0/experiments.ts create mode 100644 src/api/routes/v0/guilds/#guild_id/integrations.ts create mode 100644 src/api/routes/v0/guilds/#guild_id/member-verification.ts create mode 100644 src/api/routes/v0/guilds/#guild_id/premium.ts create mode 100644 src/api/routes/v0/guilds/#guild_id/webhooks.ts create mode 100644 src/api/routes/v0/oauth2/tokens.ts create mode 100644 src/api/routes/v0/outbound-promotions.ts create mode 100644 src/api/routes/v0/science.ts create mode 100644 src/api/routes/v0/stage-instances.ts create mode 100644 src/api/routes/v0/teams.ts create mode 100644 src/api/routes/v0/template.ts.disabled create mode 100644 src/api/routes/v0/track.ts create mode 100644 src/api/routes/v0/users/@me/activities/statistics/applications.ts create mode 100644 src/api/routes/v0/users/@me/affinities/guilds.ts create mode 100644 src/api/routes/v0/users/@me/affinities/users.ts create mode 100644 src/api/routes/v0/users/@me/applications/#app_id/entitlements.ts create mode 100644 src/api/routes/v0/users/@me/billing/country-code.ts create mode 100644 src/api/routes/v0/users/@me/billing/payment-sources.ts create mode 100644 src/api/routes/v0/users/@me/billing/subscriptions.ts create mode 100644 src/api/routes/v0/users/@me/connections.ts create mode 100644 src/api/routes/v0/users/@me/devices.ts create mode 100644 src/api/routes/v0/users/@me/entitlements.ts create mode 100644 src/api/routes/v0/users/@me/guilds/premium/subscription-slots.ts create mode 100644 src/api/routes/v0/users/@me/library.ts create mode 100644 src/api/routes/v9/-/healthz.ts create mode 100644 src/api/routes/v9/-/readyz.ts create mode 100644 src/api/routes/v9/applications/#id/bot/index.ts create mode 100644 src/api/routes/v9/applications/#id/entitlements.ts create mode 100644 src/api/routes/v9/applications/#id/index.ts create mode 100644 src/api/routes/v9/applications/index.ts create mode 100644 src/api/routes/v9/auth/generate-registration-tokens.ts create mode 100644 src/api/routes/v9/auth/location-metadata.ts create mode 100644 src/api/routes/v9/auth/login.ts create mode 100644 src/api/routes/v9/auth/logout.ts create mode 100644 src/api/routes/v9/auth/mfa/totp.ts create mode 100644 src/api/routes/v9/auth/register.ts create mode 100644 src/api/routes/v9/auth/verify/view-backup-codes-challenge.ts create mode 100644 src/api/routes/v9/channels/#channel_id/followers.ts create mode 100644 src/api/routes/v9/channels/#channel_id/index.ts create mode 100644 src/api/routes/v9/channels/#channel_id/invites.ts create mode 100644 src/api/routes/v9/channels/#channel_id/messages/#message_id/ack.ts create mode 100644 src/api/routes/v9/channels/#channel_id/messages/#message_id/crosspost.ts create mode 100644 src/api/routes/v9/channels/#channel_id/messages/#message_id/index.ts create mode 100644 src/api/routes/v9/channels/#channel_id/messages/#message_id/reactions.ts create mode 100644 src/api/routes/v9/channels/#channel_id/messages/bulk-delete.ts create mode 100644 src/api/routes/v9/channels/#channel_id/messages/index.ts create mode 100644 src/api/routes/v9/channels/#channel_id/permissions.ts create mode 100644 src/api/routes/v9/channels/#channel_id/pins.ts create mode 100644 src/api/routes/v9/channels/#channel_id/purge.ts create mode 100644 src/api/routes/v9/channels/#channel_id/recipients.ts create mode 100644 src/api/routes/v9/channels/#channel_id/typing.ts create mode 100644 src/api/routes/v9/channels/#channel_id/webhooks.ts create mode 100644 src/api/routes/v9/discoverable-guilds.ts create mode 100644 src/api/routes/v9/discovery.ts create mode 100644 src/api/routes/v9/download/index.ts create mode 100644 src/api/routes/v9/gateway/bot.ts create mode 100644 src/api/routes/v9/gateway/index.ts create mode 100644 src/api/routes/v9/gifs/search.ts create mode 100644 src/api/routes/v9/gifs/trending-gifs.ts create mode 100644 src/api/routes/v9/gifs/trending.ts create mode 100644 src/api/routes/v9/guild-recommendations.ts create mode 100644 src/api/routes/v9/guilds/#guild_id/audit-logs.ts create mode 100644 src/api/routes/v9/guilds/#guild_id/bans.ts create mode 100644 src/api/routes/v9/guilds/#guild_id/channels.ts create mode 100644 src/api/routes/v9/guilds/#guild_id/delete.ts create mode 100644 src/api/routes/v9/guilds/#guild_id/discovery-requirements.ts create mode 100644 src/api/routes/v9/guilds/#guild_id/emojis.ts create mode 100644 src/api/routes/v9/guilds/#guild_id/index.ts create mode 100644 src/api/routes/v9/guilds/#guild_id/invites.ts create mode 100644 src/api/routes/v9/guilds/#guild_id/members/#member_id/index.ts create mode 100644 src/api/routes/v9/guilds/#guild_id/members/#member_id/nick.ts create mode 100644 src/api/routes/v9/guilds/#guild_id/members/#member_id/roles/#role_id/index.ts create mode 100644 src/api/routes/v9/guilds/#guild_id/members/index.ts create mode 100644 src/api/routes/v9/guilds/#guild_id/messages/search.ts create mode 100644 src/api/routes/v9/guilds/#guild_id/profile/index.ts create mode 100644 src/api/routes/v9/guilds/#guild_id/prune.ts create mode 100644 src/api/routes/v9/guilds/#guild_id/regions.ts create mode 100644 src/api/routes/v9/guilds/#guild_id/roles/#role_id/index.ts create mode 100644 src/api/routes/v9/guilds/#guild_id/roles/index.ts create mode 100644 src/api/routes/v9/guilds/#guild_id/stickers.ts create mode 100644 src/api/routes/v9/guilds/#guild_id/templates.ts create mode 100644 src/api/routes/v9/guilds/#guild_id/vanity-url.ts create mode 100644 src/api/routes/v9/guilds/#guild_id/voice-states/#user_id/index.ts create mode 100644 src/api/routes/v9/guilds/#guild_id/welcome-screen.ts create mode 100644 src/api/routes/v9/guilds/#guild_id/widget.json.ts create mode 100644 src/api/routes/v9/guilds/#guild_id/widget.png.ts create mode 100644 src/api/routes/v9/guilds/#guild_id/widget.ts create mode 100644 src/api/routes/v9/guilds/index.ts create mode 100644 src/api/routes/v9/guilds/templates/index.ts create mode 100644 src/api/routes/v9/invites/index.ts create mode 100644 src/api/routes/v9/oauth2/authorize.ts create mode 100644 src/api/routes/v9/partners/#guild_id/requirements.ts create mode 100644 src/api/routes/v9/ping.ts create mode 100644 src/api/routes/v9/policies/instance/domains.ts create mode 100644 src/api/routes/v9/policies/instance/index.ts create mode 100644 src/api/routes/v9/policies/instance/limits.ts create mode 100644 src/api/routes/v9/policies/stats.ts create mode 100644 src/api/routes/v9/scheduled-maintenances/upcoming_json.ts create mode 100644 src/api/routes/v9/sticker-packs/index.ts create mode 100644 src/api/routes/v9/stickers/#sticker_id/index.ts create mode 100644 src/api/routes/v9/stop.ts create mode 100644 src/api/routes/v9/store/published-listings/applications.ts create mode 100644 src/api/routes/v9/store/published-listings/applications/#id/subscription-plans.ts create mode 100644 src/api/routes/v9/store/published-listings/skus.ts create mode 100644 src/api/routes/v9/store/published-listings/skus/#sku_id/subscription-plans.ts create mode 100644 src/api/routes/v9/updates.ts create mode 100644 src/api/routes/v9/users/#id/delete.ts create mode 100644 src/api/routes/v9/users/#id/index.ts create mode 100644 src/api/routes/v9/users/#id/profile.ts create mode 100644 src/api/routes/v9/users/#id/relationships.ts create mode 100644 src/api/routes/v9/users/@me/channels.ts create mode 100644 src/api/routes/v9/users/@me/delete.ts create mode 100644 src/api/routes/v9/users/@me/disable.ts create mode 100644 src/api/routes/v9/users/@me/email-settings.ts create mode 100644 src/api/routes/v9/users/@me/guilds.ts create mode 100644 src/api/routes/v9/users/@me/guilds/#guild_id/settings.ts create mode 100644 src/api/routes/v9/users/@me/index.ts create mode 100644 src/api/routes/v9/users/@me/mfa/codes-verification.ts create mode 100644 src/api/routes/v9/users/@me/mfa/codes.ts create mode 100644 src/api/routes/v9/users/@me/mfa/totp/disable.ts create mode 100644 src/api/routes/v9/users/@me/mfa/totp/enable.ts create mode 100644 src/api/routes/v9/users/@me/notes.ts create mode 100644 src/api/routes/v9/users/@me/relationships.ts create mode 100644 src/api/routes/v9/users/@me/settings.ts create mode 100644 src/api/routes/v9/voice/regions.ts delete mode 100644 src/api/routes/voice/regions.ts (limited to 'src') diff --git a/src/api/Server.ts b/src/api/Server.ts index 1a0ea6b2..f74505f6 100644 --- a/src/api/Server.ts +++ b/src/api/Server.ts @@ -88,11 +88,7 @@ export class FosscordServer extends Server { //app.use("/__development", ) //app.use("/__internals", ) - app.use("/api/v6", api); - app.use("/api/v7", api); - app.use("/api/v8", api); - app.use("/api/v9", api); - app.use("/api", api); // allow unversioned requests + app.use("/api", api); //versioning happens based on route folder name this.app.use(ErrorHandler); TestClient(this.app); diff --git a/src/api/routes/-/healthz.ts b/src/api/routes/-/healthz.ts deleted file mode 100644 index d9d1c026..00000000 --- a/src/api/routes/-/healthz.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Router, Response, Request } from "express"; -import { route } from "@fosscord/api"; -import { getDatabase } from "@fosscord/util"; - -const router = Router(); - -router.get("/", route({}), (req: Request, res: Response) => { - if (!getDatabase()) return res.sendStatus(503); - - return res.sendStatus(200); -}); - -export default router; diff --git a/src/api/routes/-/readyz.ts b/src/api/routes/-/readyz.ts deleted file mode 100644 index d9d1c026..00000000 --- a/src/api/routes/-/readyz.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Router, Response, Request } from "express"; -import { route } from "@fosscord/api"; -import { getDatabase } from "@fosscord/util"; - -const router = Router(); - -router.get("/", route({}), (req: Request, res: Response) => { - if (!getDatabase()) return res.sendStatus(503); - - return res.sendStatus(200); -}); - -export default router; diff --git a/src/api/routes/applications/#id/bot/index.ts b/src/api/routes/applications/#id/bot/index.ts deleted file mode 100644 index c4cfccd8..00000000 --- a/src/api/routes/applications/#id/bot/index.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { Request, Response, Router } from "express"; -import { route } from "@fosscord/api"; -import { - Application, - generateToken, - User, - BotModifySchema, - handleFile, - DiscordApiErrors, -} from "@fosscord/util"; -import { HTTPError } from "lambert-server"; -import { verifyToken } from "node-2fa"; - -const router: Router = Router(); - -router.post("/", route({}), async (req: Request, res: Response) => { - const app = await Application.findOneOrFail({ - where: { id: req.params.id }, - relations: ["owner"], - }); - - if (app.owner.id != req.user_id) - throw DiscordApiErrors.ACTION_NOT_AUTHORIZED_ON_APPLICATION; - - const user = await User.register({ - username: app.name, - password: undefined, - id: app.id, - req, - }); - - user.id = app.id; - user.premium_since = new Date(); - user.bot = true; - - await user.save(); - - // flags is NaN here? - app.assign({ bot: user, flags: app.flags || 0 }); - - await app.save(); - - res.send({ - token: await generateToken(user.id), - }).status(204); -}); - -router.post("/reset", route({}), async (req: Request, res: Response) => { - let bot = await User.findOneOrFail({ where: { id: req.params.id } }); - let owner = await User.findOneOrFail({ where: { id: req.user_id } }); - - if (owner.id != req.user_id) - throw DiscordApiErrors.ACTION_NOT_AUTHORIZED_ON_APPLICATION; - - if ( - owner.totp_secret && - (!req.body.code || verifyToken(owner.totp_secret, req.body.code)) - ) - throw new HTTPError(req.t("auth:login.INVALID_TOTP_CODE"), 60008); - - bot.data = { hash: undefined, valid_tokens_since: new Date() }; - - await bot.save(); - - let token = await generateToken(bot.id); - - res.json({ token }).status(200); -}); - -router.patch( - "/", - route({ body: "BotModifySchema" }), - async (req: Request, res: Response) => { - const body = req.body as BotModifySchema; - if (!body.avatar?.trim()) delete body.avatar; - - const app = await Application.findOneOrFail({ - where: { id: req.params.id }, - relations: ["bot", "owner"], - }); - - if (!app.bot) throw DiscordApiErrors.BOT_ONLY_ENDPOINT; - - if (app.owner.id != req.user_id) - throw DiscordApiErrors.ACTION_NOT_AUTHORIZED_ON_APPLICATION; - - if (body.avatar) - body.avatar = await handleFile( - `/avatars/${app.id}`, - body.avatar as string, - ); - - app.bot.assign(body); - - app.bot.save(); - - await app.save(); - res.json(app).status(200); - }, -); - -export default router; diff --git a/src/api/routes/applications/#id/entitlements.ts b/src/api/routes/applications/#id/entitlements.ts deleted file mode 100644 index cfcfe40f..00000000 --- a/src/api/routes/applications/#id/entitlements.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Router, Response, Request } from "express"; -import { route } from "@fosscord/api"; - -const router = Router(); - -router.get("/", route({}), (req: Request, res: Response) => { - // TODO: - //const { exclude_consumed } = req.query; - res.status(200).send([]); -}); - -export default router; diff --git a/src/api/routes/applications/#id/index.ts b/src/api/routes/applications/#id/index.ts deleted file mode 100644 index 11cd5a56..00000000 --- a/src/api/routes/applications/#id/index.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { Request, Response, Router } from "express"; -import { route } from "@fosscord/api"; -import { - Application, - OrmUtils, - DiscordApiErrors, - ApplicationModifySchema, - User, -} from "@fosscord/util"; -import { verifyToken } from "node-2fa"; -import { HTTPError } from "lambert-server"; - -const router: Router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - const app = await Application.findOneOrFail({ - where: { id: req.params.id }, - relations: ["owner", "bot"], - }); - if (app.owner.id != req.user_id) - throw DiscordApiErrors.ACTION_NOT_AUTHORIZED_ON_APPLICATION; - - return res.json(app); -}); - -router.patch( - "/", - route({ body: "ApplicationModifySchema" }), - async (req: Request, res: Response) => { - const body = req.body as ApplicationModifySchema; - - const app = await Application.findOneOrFail({ - where: { id: req.params.id }, - relations: ["owner", "bot"], - }); - - if (app.owner.id != req.user_id) - throw DiscordApiErrors.ACTION_NOT_AUTHORIZED_ON_APPLICATION; - - if ( - app.owner.totp_secret && - (!req.body.code || - verifyToken(app.owner.totp_secret, req.body.code)) - ) - throw new HTTPError(req.t("auth:login.INVALID_TOTP_CODE"), 60008); - - if (app.bot) { - app.bot.assign({ bio: body.description }); - await app.bot.save(); - } - - app.assign(body); - - await app.save(); - - return res.json(app); - }, -); - -router.post("/delete", route({}), async (req: Request, res: Response) => { - const app = await Application.findOneOrFail({ - where: { id: req.params.id }, - relations: ["bot", "owner"], - }); - if (app.owner.id != req.user_id) - throw DiscordApiErrors.ACTION_NOT_AUTHORIZED_ON_APPLICATION; - - if ( - app.owner.totp_secret && - (!req.body.code || verifyToken(app.owner.totp_secret, req.body.code)) - ) - throw new HTTPError(req.t("auth:login.INVALID_TOTP_CODE"), 60008); - - if (app.bot) await User.delete({ id: app.bot.id }); - - await Application.delete({ id: app.id }); - - res.send().status(200); -}); - -export default router; diff --git a/src/api/routes/applications/#id/skus.ts b/src/api/routes/applications/#id/skus.ts deleted file mode 100644 index 2383e6f7..00000000 --- a/src/api/routes/applications/#id/skus.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Request, Response, Router } from "express"; -import { route } from "@fosscord/api"; -import { Application, OrmUtils, Team, trimSpecial, User } from "@fosscord/util"; - -const router: Router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - res.json([]).status(200); -}); - -export default router; diff --git a/src/api/routes/applications/detectable.ts b/src/api/routes/applications/detectable.ts deleted file mode 100644 index 28ce42da..00000000 --- a/src/api/routes/applications/detectable.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Request, Response, Router } from "express"; -import { route } from "@fosscord/api"; - -const router: Router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - //TODO - res.send([]).status(200); -}); - -export default router; diff --git a/src/api/routes/applications/index.ts b/src/api/routes/applications/index.ts deleted file mode 100644 index a6b35bfa..00000000 --- a/src/api/routes/applications/index.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Request, Response, Router } from "express"; -import { route } from "@fosscord/api"; -import { - Application, - ApplicationCreateSchema, - trimSpecial, - User, -} from "@fosscord/util"; - -const router: Router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - let results = await Application.find({ - where: { owner: { id: req.user_id } }, - relations: ["owner", "bot"], - }); - res.json(results).status(200); -}); - -router.post( - "/", - route({ body: "ApplicationCreateSchema" }), - async (req: Request, res: Response) => { - const body = req.body as ApplicationCreateSchema; - const user = await User.findOneOrFail({ where: { id: req.user_id } }); - - const app = Application.create({ - name: trimSpecial(body.name), - description: "", - bot_public: true, - owner: user, - verify_key: "IMPLEMENTME", - flags: 0, - }); - - await app.save(); - - res.json(app); - }, -); - -export default router; diff --git a/src/api/routes/auth/generate-registration-tokens.ts b/src/api/routes/auth/generate-registration-tokens.ts deleted file mode 100644 index ba40bd9a..00000000 --- a/src/api/routes/auth/generate-registration-tokens.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { route, random } from "@fosscord/api"; -import { Config, ValidRegistrationToken } from "@fosscord/util"; -import { Request, Response, Router } from "express"; - -const router: Router = Router(); -export default router; - -router.get( - "/", - route({ right: "OPERATOR" }), - async (req: Request, res: Response) => { - const count = req.query.count ? parseInt(req.query.count as string) : 1; - const length = req.query.length - ? parseInt(req.query.length as string) - : 255; - - let tokens: ValidRegistrationToken[] = []; - - for (let i = 0; i < count; i++) { - const token = ValidRegistrationToken.create({ - token: random(length), - expires_at: - Date.now() + - Config.get().security.defaultRegistrationTokenExpiration, - }); - tokens.push(token); - } - - // Why are these options used, exactly? - await ValidRegistrationToken.save(tokens, { - chunk: 1000, - reload: false, - transaction: false, - }); - - const ret = req.query.include_url - ? tokens.map( - (x) => - `${Config.get().general.frontPage}/register?token=${ - x.token - }`, - ) - : tokens.map((x) => x.token); - - if (req.query.plain) return res.send(ret.join("\n")); - - return res.json({ tokens: ret }); - }, -); diff --git a/src/api/routes/auth/location-metadata.ts b/src/api/routes/auth/location-metadata.ts deleted file mode 100644 index 0ae946ed..00000000 --- a/src/api/routes/auth/location-metadata.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Router, Request, Response } from "express"; -import { route } from "@fosscord/api"; -import { getIpAdress, IPAnalysis } from "@fosscord/api"; -const router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - //TODO - //Note: It's most likely related to legal. At the moment Discord hasn't finished this too - const country_code = (await IPAnalysis(getIpAdress(req))).country_code; - res.json({ - consent_required: false, - country_code: country_code, - promotional_email_opt_in: { required: true, pre_checked: false }, - }); -}); - -export default router; diff --git a/src/api/routes/auth/login.ts b/src/api/routes/auth/login.ts deleted file mode 100644 index 7434fa35..00000000 --- a/src/api/routes/auth/login.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { Request, Response, Router } from "express"; -import { route, getIpAdress, verifyCaptcha } from "@fosscord/api"; -import bcrypt from "bcrypt"; -import { - Config, - User, - generateToken, - adjustEmail, - FieldErrors, - LoginSchema, -} from "@fosscord/util"; -import crypto from "crypto"; - -const router: Router = Router(); -export default router; - -router.post( - "/", - route({ body: "LoginSchema" }), - async (req: Request, res: Response) => { - const { login, password, captcha_key, undelete } = - req.body as LoginSchema; - const email = adjustEmail(login); - - const config = Config.get(); - - if (config.login.requireCaptcha && config.security.captcha.enabled) { - const { sitekey, service } = config.security.captcha; - if (!captcha_key) { - return res.status(400).json({ - captcha_key: ["captcha-required"], - captcha_sitekey: sitekey, - captcha_service: service, - }); - } - - const ip = getIpAdress(req); - const verify = await verifyCaptcha(captcha_key, ip); - if (!verify.success) { - return res.status(400).json({ - captcha_key: verify["error-codes"], - captcha_sitekey: sitekey, - captcha_service: service, - }); - } - } - - const user = await User.findOneOrFail({ - where: [{ phone: login }, { email: email }], - select: [ - "data", - "id", - "disabled", - "deleted", - "settings", - "totp_secret", - "mfa_enabled", - ], - }).catch((e) => { - throw FieldErrors({ - login: { - message: req.t("auth:login.INVALID_LOGIN"), - code: "INVALID_LOGIN", - }, - }); - }); - - if (undelete) { - // undelete refers to un'disable' here - if (user.disabled) - await User.update({ id: user.id }, { disabled: false }); - if (user.deleted) - await User.update({ id: user.id }, { deleted: false }); - } else { - if (user.deleted) - return res.status(400).json({ - message: "This account is scheduled for deletion.", - code: 20011, - }); - if (user.disabled) - return res.status(400).json({ - message: req.t("auth:login.ACCOUNT_DISABLED"), - code: 20013, - }); - } - - // the salt is saved in the password refer to bcrypt docs - const same_password = await bcrypt.compare( - password, - user.data.hash || "", - ); - if (!same_password) { - throw FieldErrors({ - password: { - message: req.t("auth:login.INVALID_PASSWORD"), - code: "INVALID_PASSWORD", - }, - }); - } - - if (user.mfa_enabled) { - // TODO: This is not a discord.com ticket. I'm not sure what it is but I'm lazy - const ticket = crypto.randomBytes(40).toString("hex"); - - await User.update({ id: user.id }, { totp_last_ticket: ticket }); - - return res.json({ - ticket: ticket, - mfa: true, - sms: false, // TODO - token: null, - }); - } - - const token = await generateToken(user.id); - - // Notice this will have a different token structure, than discord - // Discord header is just the user id as string, which is not possible with npm-jsonwebtoken package - // https://user-images.githubusercontent.com/6506416/81051916-dd8c9900-8ec2-11ea-8794-daf12d6f31f0.png - - res.json({ token, settings: user.settings }); - }, -); - -/** - * POST /auth/login - * @argument { login: "email@gmail.com", password: "cleartextpassword", undelete: false, captcha_key: null, login_source: null, gift_code_sku_id: null, } - - * MFA required: - * @returns {"token": null, "mfa": true, "sms": true, "ticket": "SOME TICKET JWT TOKEN"} - - * Captcha required: - * @returns {"captcha_key": ["captcha-required"], "captcha_sitekey": null, "captcha_service": "recaptcha"} - - * Sucess: - * @returns {"token": "USERTOKEN", "settings": {"locale": "en", "theme": "dark"}} - - */ diff --git a/src/api/routes/auth/logout.ts b/src/api/routes/auth/logout.ts deleted file mode 100644 index e1bdbea3..00000000 --- a/src/api/routes/auth/logout.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { route } from "@fosscord/api"; -import { Request, Response, Router } from "express"; - -const router: Router = Router(); -export default router; - -router.post("/", route({}), async (req: Request, res: Response) => { - if (req.body.provider != null || req.body.voip_provider != null) { - console.log(`[LOGOUT]: provider or voip provider not null!`, req.body); - } else { - delete req.body.provider; - delete req.body.voip_provider; - if (Object.keys(req.body).length != 0) - console.log(`[LOGOUT]: Extra fields sent in logout!`, req.body); - } - res.status(204).send(); -}); diff --git a/src/api/routes/auth/mfa/totp.ts b/src/api/routes/auth/mfa/totp.ts deleted file mode 100644 index 83cf7648..00000000 --- a/src/api/routes/auth/mfa/totp.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Router, Request, Response } from "express"; -import { route } from "@fosscord/api"; -import { BackupCode, generateToken, User, TotpSchema } from "@fosscord/util"; -import { verifyToken } from "node-2fa"; -import { HTTPError } from "lambert-server"; -const router = Router(); - -router.post( - "/", - route({ body: "TotpSchema" }), - async (req: Request, res: Response) => { - const { code, ticket, gift_code_sku_id, login_source } = - req.body as TotpSchema; - - const user = await User.findOneOrFail({ - where: { - totp_last_ticket: ticket, - }, - select: ["id", "totp_secret", "settings"], - }); - - const backup = await BackupCode.findOne({ - where: { - code: code, - expired: false, - consumed: false, - user: { id: user.id }, - }, - }); - - if (!backup) { - const ret = verifyToken(user.totp_secret!, code); - if (!ret || ret.delta != 0) - throw new HTTPError( - req.t("auth:login.INVALID_TOTP_CODE"), - 60008, - ); - } else { - backup.consumed = true; - await backup.save(); - } - - await User.update({ id: user.id }, { totp_last_ticket: "" }); - - return res.json({ - token: await generateToken(user.id), - user_settings: user.settings, - }); - }, -); - -export default router; diff --git a/src/api/routes/auth/register.ts b/src/api/routes/auth/register.ts deleted file mode 100644 index 3d968114..00000000 --- a/src/api/routes/auth/register.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { Request, Response, Router } from "express"; -import { - Config, - generateToken, - Invite, - FieldErrors, - User, - adjustEmail, - RegisterSchema, - ValidRegistrationToken, -} from "@fosscord/util"; -import { - route, - getIpAdress, - IPAnalysis, - isProxy, - verifyCaptcha, -} from "@fosscord/api"; -import bcrypt from "bcrypt"; -import { HTTPError } from "lambert-server"; -import { LessThan, MoreThan } from "typeorm"; - -const router: Router = Router(); - -router.post( - "/", - route({ body: "RegisterSchema" }), - async (req: Request, res: Response) => { - const body = req.body as RegisterSchema; - const { register, security, limits } = Config.get(); - const ip = getIpAdress(req); - - // Reg tokens - // They're a one time use token that bypasses registration limits ( rates, disabled reg, etc ) - let regTokenUsed = false; - if (req.get("Referrer") && req.get("Referrer")?.includes("token=")) { - // eg theyre on https://staging.fosscord.com/register?token=whatever - const token = req.get("Referrer")!.split("token=")[1].split("&")[0]; - if (token) { - const regToken = await ValidRegistrationToken.findOne({ - where: { token, expires_at: MoreThan(new Date()) }, - }); - await ValidRegistrationToken.delete({ token }); - regTokenUsed = true; - console.log( - `[REGISTER] Registration token ${token} used for registration!`, - ); - } else { - console.log( - `[REGISTER] Invalid registration token ${token} used for registration by ${ip}!`, - ); - } - } - - // email will be slightly modified version of the user supplied email -> e.g. protection against GMail Trick - let email = adjustEmail(body.email); - - // check if registration is allowed - if (!regTokenUsed && !register.allowNewRegistration) { - throw FieldErrors({ - email: { - code: "REGISTRATION_DISABLED", - message: req.t("auth:register.REGISTRATION_DISABLED"), - }, - }); - } - - // check if the user agreed to the Terms of Service - if (!body.consent) { - throw FieldErrors({ - consent: { - code: "CONSENT_REQUIRED", - message: req.t("auth:register.CONSENT_REQUIRED"), - }, - }); - } - - if (!regTokenUsed && register.disabled) { - throw FieldErrors({ - email: { - code: "DISABLED", - message: "registration is disabled on this instance", - }, - }); - } - - if ( - !regTokenUsed && - register.requireCaptcha && - security.captcha.enabled - ) { - const { sitekey, service } = security.captcha; - if (!body.captcha_key) { - return res?.status(400).json({ - captcha_key: ["captcha-required"], - captcha_sitekey: sitekey, - captcha_service: service, - }); - } - - const verify = await verifyCaptcha(body.captcha_key, ip); - if (!verify.success) { - return res.status(400).json({ - captcha_key: verify["error-codes"], - captcha_sitekey: sitekey, - captcha_service: service, - }); - } - } - - if (!regTokenUsed && !register.allowMultipleAccounts) { - // TODO: check if fingerprint was eligible generated - const exists = await User.findOne({ - where: { fingerprints: body.fingerprint }, - select: ["id"], - }); - - if (exists) { - throw FieldErrors({ - email: { - code: "EMAIL_ALREADY_REGISTERED", - message: req.t( - "auth:register.EMAIL_ALREADY_REGISTERED", - ), - }, - }); - } - } - - if (!regTokenUsed && register.blockProxies) { - if (isProxy(await IPAnalysis(ip))) { - console.log(`proxy ${ip} blocked from registration`); - throw new HTTPError("Your IP is blocked from registration"); - } - } - - // TODO: gift_code_sku_id? - // TODO: check password strength - - if (email) { - // replace all dots and chars after +, if its a gmail.com email - if (!email) { - throw FieldErrors({ - email: { - code: "INVALID_EMAIL", - message: req?.t("auth:register.INVALID_EMAIL"), - }, - }); - } - - // check if there is already an account with this email - const exists = await User.findOne({ where: { email: email } }); - - if (exists) { - throw FieldErrors({ - email: { - code: "EMAIL_ALREADY_REGISTERED", - message: req.t( - "auth:register.EMAIL_ALREADY_REGISTERED", - ), - }, - }); - } - } else if (register.email.required) { - throw FieldErrors({ - email: { - code: "BASE_TYPE_REQUIRED", - message: req.t("common:field.BASE_TYPE_REQUIRED"), - }, - }); - } - - if (register.dateOfBirth.required && !body.date_of_birth) { - throw FieldErrors({ - date_of_birth: { - code: "BASE_TYPE_REQUIRED", - message: req.t("common:field.BASE_TYPE_REQUIRED"), - }, - }); - } else if ( - register.dateOfBirth.required && - register.dateOfBirth.minimum - ) { - const minimum = new Date(); - minimum.setFullYear( - minimum.getFullYear() - register.dateOfBirth.minimum, - ); - body.date_of_birth = new Date(body.date_of_birth as Date); - - // higher is younger - if (body.date_of_birth > minimum) { - throw FieldErrors({ - date_of_birth: { - code: "DATE_OF_BIRTH_UNDERAGE", - message: req.t("auth:register.DATE_OF_BIRTH_UNDERAGE", { - years: register.dateOfBirth.minimum, - }), - }, - }); - } - } - - if (body.password) { - // the salt is saved in the password refer to bcrypt docs - body.password = await bcrypt.hash(body.password, 12); - } else if (register.password.required) { - throw FieldErrors({ - password: { - code: "BASE_TYPE_REQUIRED", - message: req.t("common:field.BASE_TYPE_REQUIRED"), - }, - }); - } - - if ( - !regTokenUsed && - !body.invite && - (register.requireInvite || - (register.guestsRequireInvite && !register.email)) - ) { - // require invite to register -> e.g. for organizations to send invites to their employees - throw FieldErrors({ - email: { - code: "INVITE_ONLY", - message: req.t("auth:register.INVITE_ONLY"), - }, - }); - } - - if ( - !regTokenUsed && - limits.absoluteRate.register.enabled && - (await User.count({ - where: { - created_at: MoreThan( - new Date( - Date.now() - limits.absoluteRate.register.window, - ), - ), - }, - })) >= limits.absoluteRate.register.limit - ) { - console.log( - `Global register ratelimit exceeded for ${getIpAdress(req)}, ${ - req.body.username - }, ${req.body.invite || "No invite given"}`, - ); - throw FieldErrors({ - email: { - code: "TOO_MANY_REGISTRATIONS", - message: req.t("auth:register.TOO_MANY_REGISTRATIONS"), - }, - }); - } - - const user = await User.register({ ...body, req }); - - if (body.invite) { - // await to fail if the invite doesn't exist (necessary for requireInvite to work properly) (username only signups are possible) - await Invite.joinGuild(user.id, body.invite); - } - - return res.json({ token: await generateToken(user.id) }); - }, -); - -export default router; - -/** - * POST /auth/register - * @argument { "fingerprint":"805826570869932034.wR8vi8lGlFBJerErO9LG5NViJFw", "email":"qo8etzvaf@gmail.com", "username":"qp39gr98", "password":"wtp9gep9gw", "invite":null, "consent":true, "date_of_birth":"2000-04-04", "gift_code_sku_id":null, "captcha_key":null} - * - * Field Error - * @returns { "code": 50035, "errors": { "consent": { "_errors": [{ "code": "CONSENT_REQUIRED", "message": "You must agree to Discord's Terms of Service and Privacy Policy." }]}}, "message": "Invalid Form Body"} - * - * Success 200: - * @returns {token: "OMITTED"} - */ diff --git a/src/api/routes/auth/verify/view-backup-codes-challenge.ts b/src/api/routes/auth/verify/view-backup-codes-challenge.ts deleted file mode 100644 index 65f0a57c..00000000 --- a/src/api/routes/auth/verify/view-backup-codes-challenge.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Router, Request, Response } from "express"; -import { route } from "@fosscord/api"; -import { FieldErrors, User, BackupCodesChallengeSchema } from "@fosscord/util"; -import bcrypt from "bcrypt"; -const router = Router(); - -router.post( - "/", - route({ body: "BackupCodesChallengeSchema" }), - async (req: Request, res: Response) => { - const { password } = req.body as BackupCodesChallengeSchema; - - const user = await User.findOneOrFail({ - where: { id: req.user_id }, - select: ["data"], - }); - - if (!(await bcrypt.compare(password, user.data.hash || ""))) { - throw FieldErrors({ - password: { - message: req.t("auth:login.INVALID_PASSWORD"), - code: "INVALID_PASSWORD", - }, - }); - } - - return res.json({ - nonce: "NoncePlaceholder", - regenerate_nonce: "RegenNoncePlaceholder", - }); - }, -); - -export default router; diff --git a/src/api/routes/channels/#channel_id/followers.ts b/src/api/routes/channels/#channel_id/followers.ts deleted file mode 100644 index 641af4f8..00000000 --- a/src/api/routes/channels/#channel_id/followers.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Router, Response, Request } from "express"; -const router: Router = Router(); -// TODO: - -export default router; - -/** - * - * @param {"webhook_channel_id":"754001514330062952"} - * - * Creates a WebHook in the channel and returns the id of it - * - * @returns {"channel_id": "816382962056560690", "webhook_id": "834910735095037962"} - */ diff --git a/src/api/routes/channels/#channel_id/index.ts b/src/api/routes/channels/#channel_id/index.ts deleted file mode 100644 index a164fff6..00000000 --- a/src/api/routes/channels/#channel_id/index.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { - Channel, - ChannelDeleteEvent, - ChannelType, - ChannelUpdateEvent, - emitEvent, - Recipient, - handleFile, - ChannelModifySchema, -} from "@fosscord/util"; -import { Request, Response, Router } from "express"; -import { route } from "@fosscord/api"; - -const router: Router = Router(); -// TODO: delete channel -// TODO: Get channel - -router.get( - "/", - route({ permission: "VIEW_CHANNEL" }), - async (req: Request, res: Response) => { - const { channel_id } = req.params; - - const channel = await Channel.findOneOrFail({ - where: { id: channel_id }, - }); - - return res.send(channel); - }, -); - -router.delete( - "/", - route({ permission: "MANAGE_CHANNELS" }), - async (req: Request, res: Response) => { - const { channel_id } = req.params; - - const channel = await Channel.findOneOrFail({ - where: { id: channel_id }, - relations: ["recipients"], - }); - - if (channel.type === ChannelType.DM) { - const recipient = await Recipient.findOneOrFail({ - where: { channel_id: channel_id, user_id: req.user_id }, - }); - recipient.closed = true; - await Promise.all([ - recipient.save(), - emitEvent({ - event: "CHANNEL_DELETE", - data: channel, - user_id: req.user_id, - } as ChannelDeleteEvent), - ]); - } else if (channel.type === ChannelType.GROUP_DM) { - await Channel.removeRecipientFromChannel(channel, req.user_id); - } else { - await Promise.all([ - Channel.delete({ id: channel_id }), - emitEvent({ - event: "CHANNEL_DELETE", - data: channel, - channel_id, - } as ChannelDeleteEvent), - ]); - } - - res.send(channel); - }, -); - -router.patch( - "/", - route({ body: "ChannelModifySchema", permission: "MANAGE_CHANNELS" }), - async (req: Request, res: Response) => { - var payload = req.body as ChannelModifySchema; - const { channel_id } = req.params; - if (payload.icon) - payload.icon = await handleFile( - `/channel-icons/${channel_id}`, - payload.icon, - ); - - const channel = await Channel.findOneOrFail({ - where: { id: channel_id }, - }); - channel.assign(payload); - - await Promise.all([ - channel.save(), - emitEvent({ - event: "CHANNEL_UPDATE", - data: channel, - channel_id, - } as ChannelUpdateEvent), - ]); - - res.send(channel); - }, -); - -export default router; diff --git a/src/api/routes/channels/#channel_id/invites.ts b/src/api/routes/channels/#channel_id/invites.ts deleted file mode 100644 index c8d56fa4..00000000 --- a/src/api/routes/channels/#channel_id/invites.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { Router, Request, Response } from "express"; -import { HTTPError } from "lambert-server"; -import { route } from "@fosscord/api"; -import { random } from "@fosscord/api"; -import { - Channel, - Invite, - InviteCreateEvent, - emitEvent, - User, - Guild, - PublicInviteRelation, -} from "@fosscord/util"; -import { isTextChannel } from "./messages"; - -const router: Router = Router(); - -router.post( - "/", - route({ - body: "InviteCreateSchema", - permission: "CREATE_INSTANT_INVITE", - right: "CREATE_INVITES", - }), - async (req: Request, res: Response) => { - const { user_id } = req; - const { channel_id } = req.params; - const channel = await Channel.findOneOrFail({ - where: { id: channel_id }, - select: ["id", "name", "type", "guild_id"], - }); - isTextChannel(channel.type); - - if (!channel.guild_id) { - throw new HTTPError("This channel doesn't exist", 404); - } - const { guild_id } = channel; - - const expires_at = new Date(req.body.max_age * 1000 + Date.now()); - - const invite = await Invite.create({ - code: random(), - temporary: req.body.temporary || true, - uses: 0, - max_uses: req.body.max_uses, - max_age: req.body.max_age, - expires_at, - created_at: new Date(), - guild_id, - channel_id: channel_id, - inviter_id: user_id, - }).save(); - const data = invite.toJSON(); - data.inviter = await User.getPublicUser(req.user_id); - data.guild = await Guild.findOne({ where: { id: guild_id } }); - data.channel = channel; - - await emitEvent({ - event: "INVITE_CREATE", - data, - guild_id, - } as InviteCreateEvent); - res.status(201).send(data); - }, -); - -router.get( - "/", - route({ permission: "MANAGE_CHANNELS" }), - async (req: Request, res: Response) => { - const { user_id } = req; - const { channel_id } = req.params; - const channel = await Channel.findOneOrFail({ - where: { id: channel_id }, - }); - - if (!channel.guild_id) { - throw new HTTPError("This channel doesn't exist", 404); - } - const { guild_id } = channel; - - const invites = await Invite.find({ - where: { guild_id }, - relations: PublicInviteRelation, - }); - - res.status(200).send(invites); - }, -); - -export default router; diff --git a/src/api/routes/channels/#channel_id/messages/#message_id/ack.ts b/src/api/routes/channels/#channel_id/messages/#message_id/ack.ts deleted file mode 100644 index 1a30143f..00000000 --- a/src/api/routes/channels/#channel_id/messages/#message_id/ack.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { - emitEvent, - getPermission, - MessageAckEvent, - ReadState, -} from "@fosscord/util"; -import { Request, Response, Router } from "express"; -import { route } from "@fosscord/api"; - -const router = Router(); - -// TODO: public read receipts & privacy scoping -// TODO: send read state event to all channel members -// TODO: advance-only notification cursor - -router.post( - "/", - route({ body: "MessageAcknowledgeSchema" }), - async (req: Request, res: Response) => { - const { channel_id, message_id } = req.params; - - const permission = await getPermission( - req.user_id, - undefined, - channel_id, - ); - permission.hasThrow("VIEW_CHANNEL"); - - let read_state = await ReadState.findOne({ - where: { user_id: req.user_id, channel_id }, - }); - if (!read_state) - read_state = ReadState.create({ user_id: req.user_id, channel_id }); - read_state.last_message_id = message_id; - - await read_state.save(); - - await emitEvent({ - event: "MESSAGE_ACK", - user_id: req.user_id, - data: { - channel_id, - message_id, - version: 3763, - }, - } as MessageAckEvent); - - res.json({ token: null }); - }, -); - -export default router; diff --git a/src/api/routes/channels/#channel_id/messages/#message_id/crosspost.ts b/src/api/routes/channels/#channel_id/messages/#message_id/crosspost.ts deleted file mode 100644 index d8b55ccd..00000000 --- a/src/api/routes/channels/#channel_id/messages/#message_id/crosspost.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Router, Response, Request } from "express"; -import { route } from "@fosscord/api"; - -const router = Router(); - -router.post( - "/", - route({ permission: "MANAGE_MESSAGES" }), - (req: Request, res: Response) => { - // TODO: - res.json({ - id: "", - type: 0, - content: "", - channel_id: "", - author: { - id: "", - username: "", - avatar: "", - discriminator: "", - public_flags: 64, - }, - attachments: [], - embeds: [], - mentions: [], - mention_roles: [], - pinned: false, - mention_everyone: false, - tts: false, - timestamp: "", - edited_timestamp: null, - flags: 1, - components: [], - }).status(200); - }, -); - -export default router; diff --git a/src/api/routes/channels/#channel_id/messages/#message_id/index.ts b/src/api/routes/channels/#channel_id/messages/#message_id/index.ts deleted file mode 100644 index d57d9a1b..00000000 --- a/src/api/routes/channels/#channel_id/messages/#message_id/index.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { - Attachment, - Channel, - emitEvent, - FosscordApiErrors, - getPermission, - getRights, - Message, - MessageCreateEvent, - MessageDeleteEvent, - MessageUpdateEvent, - Snowflake, - uploadFile, - MessageCreateSchema, - DiscordApiErrors, -} from "@fosscord/util"; -import { Router, Response, Request } from "express"; -import multer from "multer"; -import { route } from "@fosscord/api"; -import { handleMessage, postHandleMessage } from "@fosscord/api"; -import { HTTPError } from "lambert-server"; - -const router = Router(); -// TODO: message content/embed string length limit - -const messageUpload = multer({ - limits: { - fileSize: 1024 * 1024 * 100, - fields: 10, - files: 1, - }, - storage: multer.memoryStorage(), -}); // max upload 50 mb - -router.patch( - "/", - route({ - body: "MessageCreateSchema", - permission: "SEND_MESSAGES", - right: "SEND_MESSAGES", - }), - async (req: Request, res: Response) => { - const { message_id, channel_id } = req.params; - var body = req.body as MessageCreateSchema; - - const message = await Message.findOneOrFail({ - where: { id: message_id, channel_id }, - relations: ["attachments"], - }); - - const permissions = await getPermission( - req.user_id, - undefined, - channel_id, - ); - - const rights = await getRights(req.user_id); - - if (req.user_id !== message.author_id) { - if (!rights.has("MANAGE_MESSAGES")) { - permissions.hasThrow("MANAGE_MESSAGES"); - body = { flags: body.flags }; - // guild admins can only suppress embeds of other messages, no such restriction imposed to instance-wide admins - } - } else rights.hasThrow("SELF_EDIT_MESSAGES"); - - const new_message = await handleMessage({ - ...message, - // TODO: should message_reference be overridable? - // @ts-ignore - message_reference: message.message_reference, - ...body, - author_id: message.author_id, - channel_id, - id: message_id, - edited_timestamp: new Date(), - }); - - await Promise.all([ - new_message.save(), - await emitEvent({ - event: "MESSAGE_UPDATE", - channel_id, - data: { ...new_message, nonce: undefined }, - } as MessageUpdateEvent), - ]); - - postHandleMessage(new_message); - - return res.json(new_message); - }, -); - -// Backfill message with specific timestamp -router.put( - "/", - messageUpload.single("file"), - async (req, res, next) => { - if (req.body.payload_json) { - req.body = JSON.parse(req.body.payload_json); - } - - next(); - }, - route({ - body: "MessageCreateSchema", - permission: "SEND_MESSAGES", - right: "SEND_BACKDATED_EVENTS", - }), - async (req: Request, res: Response) => { - const { channel_id, message_id } = req.params; - var body = req.body as MessageCreateSchema; - const attachments: Attachment[] = []; - - const rights = await getRights(req.user_id); - rights.hasThrow("SEND_MESSAGES"); - - // regex to check if message contains anything other than numerals ( also no decimals ) - if (!message_id.match(/^\+?\d+$/)) { - throw new HTTPError("Message IDs must be positive integers", 400); - } - - const snowflake = Snowflake.deconstruct(message_id); - if (Date.now() < snowflake.timestamp) { - // message is in the future - throw FosscordApiErrors.CANNOT_BACKFILL_TO_THE_FUTURE; - } - - const exists = await Message.findOne({ - where: { id: message_id, channel_id: channel_id }, - }); - if (exists) { - throw FosscordApiErrors.CANNOT_REPLACE_BY_BACKFILL; - } - - if (req.file) { - try { - const file = await uploadFile( - `/attachments/${req.params.channel_id}`, - req.file, - ); - attachments.push( - Attachment.create({ ...file, proxy_url: file.url }), - ); - } catch (error) { - return res.status(400).json(error); - } - } - const channel = await Channel.findOneOrFail({ - where: { id: channel_id }, - relations: ["recipients", "recipients.user"], - }); - - const embeds = body.embeds || []; - if (body.embed) embeds.push(body.embed); - let message = await handleMessage({ - ...body, - type: 0, - pinned: false, - author_id: req.user_id, - id: message_id, - embeds, - channel_id, - attachments, - edited_timestamp: undefined, - timestamp: new Date(snowflake.timestamp), - }); - - //Fix for the client bug - delete message.member; - - await Promise.all([ - message.save(), - emitEvent({ - event: "MESSAGE_CREATE", - channel_id: channel_id, - data: message, - } as MessageCreateEvent), - channel.save(), - ]); - - postHandleMessage(message).catch((e) => {}); // no await as it shouldnt block the message send function and silently catch error - - return res.json(message); - }, -); - -router.get( - "/", - route({ permission: "VIEW_CHANNEL" }), - async (req: Request, res: Response) => { - const { message_id, channel_id } = req.params; - - const message = await Message.findOneOrFail({ - where: { id: message_id, channel_id }, - relations: ["attachments"], - }); - - const permissions = await getPermission( - req.user_id, - undefined, - channel_id, - ); - - if (message.author_id !== req.user_id) - permissions.hasThrow("READ_MESSAGE_HISTORY"); - - return res.json(message); - }, -); - -router.delete("/", route({}), async (req: Request, res: Response) => { - const { message_id, channel_id } = req.params; - - const channel = await Channel.findOneOrFail({ where: { id: channel_id } }); - const message = await Message.findOneOrFail({ where: { id: message_id } }); - - const rights = await getRights(req.user_id); - - if (message.author_id !== req.user_id) { - if (!rights.has("MANAGE_MESSAGES")) { - const permission = await getPermission( - req.user_id, - channel.guild_id, - channel_id, - ); - permission.hasThrow("MANAGE_MESSAGES"); - } - } else rights.hasThrow("SELF_DELETE_MESSAGES"); - - await Message.delete({ id: message_id }); - - await emitEvent({ - event: "MESSAGE_DELETE", - channel_id, - data: { - id: message_id, - channel_id, - guild_id: channel.guild_id, - }, - } as MessageDeleteEvent); - - res.sendStatus(204); -}); - -export default router; diff --git a/src/api/routes/channels/#channel_id/messages/#message_id/reactions.ts b/src/api/routes/channels/#channel_id/messages/#message_id/reactions.ts deleted file mode 100644 index 9f774682..00000000 --- a/src/api/routes/channels/#channel_id/messages/#message_id/reactions.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { - Channel, - emitEvent, - Emoji, - getPermission, - Member, - Message, - MessageReactionAddEvent, - MessageReactionRemoveAllEvent, - MessageReactionRemoveEmojiEvent, - MessageReactionRemoveEvent, - PartialEmoji, - PublicUserProjection, - User, -} from "@fosscord/util"; -import { route } from "@fosscord/api"; -import { Router, Response, Request } from "express"; -import { HTTPError } from "lambert-server"; -import { In } from "typeorm"; - -const router = Router(); -// TODO: check if emoji is really an unicode emoji or a prperly encoded external emoji - -function getEmoji(emoji: string): PartialEmoji { - emoji = decodeURIComponent(emoji); - const parts = emoji.includes(":") && emoji.split(":"); - if (parts) - return { - name: parts[0], - id: parts[1], - }; - - return { - id: undefined, - name: emoji, - }; -} - -router.delete( - "/", - route({ permission: "MANAGE_MESSAGES" }), - async (req: Request, res: Response) => { - const { message_id, channel_id } = req.params; - - const channel = await Channel.findOneOrFail({ - where: { id: channel_id }, - }); - - await Message.update({ id: message_id, channel_id }, { reactions: [] }); - - await emitEvent({ - event: "MESSAGE_REACTION_REMOVE_ALL", - channel_id, - data: { - channel_id, - message_id, - guild_id: channel.guild_id, - }, - } as MessageReactionRemoveAllEvent); - - res.sendStatus(204); - }, -); - -router.delete( - "/:emoji", - route({ permission: "MANAGE_MESSAGES" }), - async (req: Request, res: Response) => { - const { message_id, channel_id } = req.params; - const emoji = getEmoji(req.params.emoji); - - const message = await Message.findOneOrFail({ - where: { id: message_id, channel_id }, - }); - - const already_added = message.reactions.find( - (x) => - (x.emoji.id === emoji.id && emoji.id) || - x.emoji.name === emoji.name, - ); - if (!already_added) throw new HTTPError("Reaction not found", 404); - message.reactions.remove(already_added); - - await Promise.all([ - message.save(), - emitEvent({ - event: "MESSAGE_REACTION_REMOVE_EMOJI", - channel_id, - data: { - channel_id, - message_id, - guild_id: message.guild_id, - emoji, - }, - } as MessageReactionRemoveEmojiEvent), - ]); - - res.sendStatus(204); - }, -); - -router.get( - "/:emoji", - route({ permission: "VIEW_CHANNEL" }), - async (req: Request, res: Response) => { - const { message_id, channel_id } = req.params; - const emoji = getEmoji(req.params.emoji); - - const message = await Message.findOneOrFail({ - where: { id: message_id, channel_id }, - }); - const reaction = message.reactions.find( - (x) => - (x.emoji.id === emoji.id && emoji.id) || - x.emoji.name === emoji.name, - ); - if (!reaction) throw new HTTPError("Reaction not found", 404); - - const users = await User.find({ - where: { - id: In(reaction.user_ids), - }, - select: PublicUserProjection, - }); - - res.json(users); - }, -); - -router.put( - "/:emoji/:user_id", - route({ permission: "READ_MESSAGE_HISTORY", right: "SELF_ADD_REACTIONS" }), - async (req: Request, res: Response) => { - const { message_id, channel_id, user_id } = req.params; - if (user_id !== "@me") throw new HTTPError("Invalid user"); - const emoji = getEmoji(req.params.emoji); - - const channel = await Channel.findOneOrFail({ - where: { id: channel_id }, - }); - const message = await Message.findOneOrFail({ - where: { id: message_id, channel_id }, - }); - const already_added = message.reactions.find( - (x) => - (x.emoji.id === emoji.id && emoji.id) || - x.emoji.name === emoji.name, - ); - - if (!already_added) req.permission!.hasThrow("ADD_REACTIONS"); - - if (emoji.id) { - const external_emoji = await Emoji.findOneOrFail({ - where: { id: emoji.id }, - }); - if (!already_added) req.permission!.hasThrow("USE_EXTERNAL_EMOJIS"); - emoji.animated = external_emoji.animated; - emoji.name = external_emoji.name; - } - - if (already_added) { - if (already_added.user_ids.includes(req.user_id)) - return res.sendStatus(204); // Do not throw an error ¯\_(ツ)_/¯ as discord also doesn't throw any error - already_added.count++; - } else - message.reactions.push({ - count: 1, - emoji, - user_ids: [req.user_id], - }); - - await message.save(); - - const member = - channel.guild_id && - (await Member.findOneOrFail({ where: { id: req.user_id } })); - - await emitEvent({ - event: "MESSAGE_REACTION_ADD", - channel_id, - data: { - user_id: req.user_id, - channel_id, - message_id, - guild_id: channel.guild_id, - emoji, - member, - }, - } as MessageReactionAddEvent); - - res.sendStatus(204); - }, -); - -router.delete( - "/:emoji/:user_id", - route({}), - async (req: Request, res: Response) => { - var { message_id, channel_id, user_id } = req.params; - - const emoji = getEmoji(req.params.emoji); - - const channel = await Channel.findOneOrFail({ - where: { id: channel_id }, - }); - const message = await Message.findOneOrFail({ - where: { id: message_id, channel_id }, - }); - - if (user_id === "@me") user_id = req.user_id; - else { - const permissions = await getPermission( - req.user_id, - undefined, - channel_id, - ); - permissions.hasThrow("MANAGE_MESSAGES"); - } - - const already_added = message.reactions.find( - (x) => - (x.emoji.id === emoji.id && emoji.id) || - x.emoji.name === emoji.name, - ); - if (!already_added || !already_added.user_ids.includes(user_id)) - throw new HTTPError("Reaction not found", 404); - - already_added.count--; - - if (already_added.count <= 0) message.reactions.remove(already_added); - - await message.save(); - - await emitEvent({ - event: "MESSAGE_REACTION_REMOVE", - channel_id, - data: { - user_id: req.user_id, - channel_id, - message_id, - guild_id: channel.guild_id, - emoji, - }, - } as MessageReactionRemoveEvent); - - res.sendStatus(204); - }, -); - -export default router; diff --git a/src/api/routes/channels/#channel_id/messages/bulk-delete.ts b/src/api/routes/channels/#channel_id/messages/bulk-delete.ts deleted file mode 100644 index 553ab17e..00000000 --- a/src/api/routes/channels/#channel_id/messages/bulk-delete.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Router, Response, Request } from "express"; -import { - Channel, - Config, - emitEvent, - getPermission, - getRights, - MessageDeleteBulkEvent, - Message, -} from "@fosscord/util"; -import { HTTPError } from "lambert-server"; -import { route } from "@fosscord/api"; - -const router: Router = Router(); - -export default router; - -// should users be able to bulk delete messages or only bots? ANSWER: all users -// should this request fail, if you provide messages older than 14 days/invalid ids? ANSWER: NO -// https://discord.com/developers/docs/resources/channel#bulk-delete-messages -router.post( - "/", - route({ body: "BulkDeleteSchema" }), - async (req: Request, res: Response) => { - const { channel_id } = req.params; - const channel = await Channel.findOneOrFail({ - where: { id: channel_id }, - }); - if (!channel.guild_id) - throw new HTTPError("Can't bulk delete dm channel messages", 400); - - const rights = await getRights(req.user_id); - rights.hasThrow("SELF_DELETE_MESSAGES"); - - let superuser = rights.has("MANAGE_MESSAGES"); - const permission = await getPermission( - req.user_id, - channel?.guild_id, - channel_id, - ); - - const { maxBulkDelete } = Config.get().limits.message; - - const { messages } = req.body as { messages: string[] }; - if (messages.length === 0) - throw new HTTPError("You must specify messages to bulk delete"); - if (!superuser) { - permission.hasThrow("MANAGE_MESSAGES"); - if (messages.length > maxBulkDelete) - throw new HTTPError( - `You cannot delete more than ${maxBulkDelete} messages`, - ); - } - - await Message.delete(messages); - - await emitEvent({ - event: "MESSAGE_DELETE_BULK", - channel_id, - data: { ids: messages, channel_id, guild_id: channel.guild_id }, - } as MessageDeleteBulkEvent); - - res.sendStatus(204); - }, -); diff --git a/src/api/routes/channels/#channel_id/messages/index.ts b/src/api/routes/channels/#channel_id/messages/index.ts deleted file mode 100644 index 2968437d..00000000 --- a/src/api/routes/channels/#channel_id/messages/index.ts +++ /dev/null @@ -1,351 +0,0 @@ -import { Router, Response, Request } from "express"; -import { - Attachment, - Channel, - ChannelType, - Config, - DmChannelDTO, - emitEvent, - FieldErrors, - getPermission, - Message, - MessageCreateEvent, - Snowflake, - uploadFile, - Member, - Role, - MessageCreateSchema, - ReadState, - DiscordApiErrors, - getRights, - Rights, -} from "@fosscord/util"; -import { HTTPError } from "lambert-server"; -import { - handleMessage, - postHandleMessage, - route, - getIpAdress, -} from "@fosscord/api"; -import multer from "multer"; -import { yellow } from "picocolors"; -import { FindManyOptions, LessThan, MoreThan } from "typeorm"; -import { URL } from "url"; - -const router: Router = Router(); - -export default router; - -export function isTextChannel(type: ChannelType): boolean { - switch (type) { - case ChannelType.GUILD_STORE: - case ChannelType.GUILD_VOICE: - case ChannelType.GUILD_STAGE_VOICE: - case ChannelType.GUILD_CATEGORY: - case ChannelType.GUILD_FORUM: - case ChannelType.DIRECTORY: - throw new HTTPError("not a text channel", 400); - case ChannelType.DM: - case ChannelType.GROUP_DM: - case ChannelType.GUILD_NEWS: - case ChannelType.GUILD_NEWS_THREAD: - case ChannelType.GUILD_PUBLIC_THREAD: - case ChannelType.GUILD_PRIVATE_THREAD: - case ChannelType.GUILD_TEXT: - case ChannelType.ENCRYPTED: - case ChannelType.ENCRYPTED_THREAD: - return true; - default: - throw new HTTPError("unimplemented", 400); - } -} - -// https://discord.com/developers/docs/resources/channel#create-message -// get messages -router.get("/", async (req: Request, res: Response) => { - const channel_id = req.params.channel_id; - const channel = await Channel.findOneOrFail({ where: { id: channel_id } }); - if (!channel) throw new HTTPError("Channel not found", 404); - - isTextChannel(channel.type); - const around = req.query.around ? `${req.query.around}` : undefined; - const before = req.query.before ? `${req.query.before}` : undefined; - const after = req.query.after ? `${req.query.after}` : undefined; - const limit = Number(req.query.limit) || 50; - if (limit < 1 || limit > 100) - throw new HTTPError("limit must be between 1 and 100", 422); - - var halfLimit = Math.floor(limit / 2); - - const permissions = await getPermission( - req.user_id, - channel.guild_id, - channel_id, - ); - permissions.hasThrow("VIEW_CHANNEL"); - if (!permissions.has("READ_MESSAGE_HISTORY")) return res.json([]); - - var query: FindManyOptions & { where: { id?: any } } = { - order: { timestamp: "DESC" }, - take: limit, - where: { channel_id }, - relations: [ - "author", - "webhook", - "application", - "mentions", - "mention_roles", - "mention_channels", - "sticker_items", - "attachments", - ], - }; - - if (after) { - if (BigInt(after) > BigInt(Snowflake.generate())) - return res.status(422); - query.where.id = MoreThan(after); - } else if (before) { - if (BigInt(before) < BigInt(req.params.channel_id)) - return res.status(422); - query.where.id = LessThan(before); - } else if (around) { - query.where.id = [ - MoreThan((BigInt(around) - BigInt(halfLimit)).toString()), - LessThan((BigInt(around) + BigInt(halfLimit)).toString()), - ]; - - return res.json([]); // TODO: fix around - } - - const messages = await Message.find(query); - const endpoint = Config.get().cdn.endpointPublic; - - return res.json( - messages.map((x: any) => { - (x.reactions || []).forEach((x: any) => { - // @ts-ignore - if ((x.user_ids || []).includes(req.user_id)) x.me = true; - // @ts-ignore - delete x.user_ids; - }); - // @ts-ignore - if (!x.author) - x.author = { - id: "4", - discriminator: "0000", - username: "Fosscord Ghost", - public_flags: "0", - avatar: null, - }; - x.attachments?.forEach((y: any) => { - // dynamically set attachment proxy_url in case the endpoint changed - const uri = y.proxy_url.startsWith("http") - ? y.proxy_url - : `https://example.org${y.proxy_url}`; - y.proxy_url = `${endpoint == null ? "" : endpoint}${ - new URL(uri).pathname - }`; - }); - - /** - Some clients ( discord.js ) only check if a property exists within the response, - which causes erorrs when, say, the `application` property is `null`. - **/ - - // for (var curr in x) { - // if (x[curr] === null) - // delete x[curr]; - // } - - return x; - }), - ); -}); - -// TODO: config max upload size -const messageUpload = multer({ - limits: { - fileSize: Config.get().limits.message.maxAttachmentSize, - fields: 10, - // files: 1 - }, - storage: multer.memoryStorage(), -}); // max upload 50 mb -/** - TODO: dynamically change limit of MessageCreateSchema with config - - https://discord.com/developers/docs/resources/channel#create-message - TODO: text channel slowdown (per-user and across-users) - Q: trim and replace message content and every embed field A: NO, given this cannot be implemented in E2EE channels - TODO: only dispatch notifications for mentions denoted in allowed_mentions -**/ -// Send message -router.post( - "/", - messageUpload.any(), - (req, res, next) => { - if (req.body.payload_json) { - req.body = JSON.parse(req.body.payload_json); - } - - next(); - }, - route({ - body: "MessageCreateSchema", - permission: "SEND_MESSAGES", - right: "SEND_MESSAGES", - }), - async (req: Request, res: Response) => { - const { channel_id } = req.params; - var body = req.body as MessageCreateSchema; - const attachments: Attachment[] = []; - - const channel = await Channel.findOneOrFail({ - where: { id: channel_id }, - relations: ["recipients", "recipients.user"], - }); - if (!channel.isWritable()) { - throw new HTTPError( - `Cannot send messages to channel of type ${channel.type}`, - 400, - ); - } - - if (body.nonce) { - const existing = await Message.findOne({ - where: { - nonce: body.nonce, - channel_id: channel.id, - author_id: req.user_id, - }, - }); - if (existing) { - return res.json(existing); - } - } - - if (!req.rights.has(Rights.FLAGS.BYPASS_RATE_LIMITS)) { - var limits = Config.get().limits; - if (limits.absoluteRate.register.enabled) { - const count = await Message.count({ - where: { - channel_id, - timestamp: MoreThan( - new Date( - Date.now() - - limits.absoluteRate.sendMessage.window, - ), - ), - }, - }); - - if (count >= limits.absoluteRate.sendMessage.limit) - throw FieldErrors({ - channel_id: { - code: "TOO_MANY_MESSAGES", - message: req.t("common:toomany.MESSAGE"), - }, - }); - } - } - - const files = (req.files as Express.Multer.File[]) ?? []; - for (var currFile of files) { - try { - const file = await uploadFile( - `/attachments/${channel.id}`, - currFile, - ); - attachments.push( - Attachment.create({ ...file, proxy_url: file.url }), - ); - } catch (error) { - return res.status(400).json({ message: error!.toString() }); - } - } - - const embeds = body.embeds || []; - if (body.embed) embeds.push(body.embed); - let message = await handleMessage({ - ...body, - type: 0, - pinned: false, - author_id: req.user_id, - embeds, - channel_id, - attachments, - edited_timestamp: undefined, - timestamp: new Date(), - }); - - channel.last_message_id = message.id; - - if (channel.isDm()) { - const channel_dto = await DmChannelDTO.from(channel); - - // Only one recipients should be closed here, since in group DMs the recipient is deleted not closed - await Promise.all( - channel.recipients!.map((recipient) => { - if (recipient.closed) { - recipient.closed = false; - return Promise.all([ - recipient.save(), - emitEvent({ - event: "CHANNEL_CREATE", - data: channel_dto.excludedRecipients([ - recipient.user_id, - ]), - user_id: recipient.user_id, - }), - ]); - } - }), - ); - } - - if (message.guild_id) { - // handleMessage will fetch the Member, but only if they are not guild owner. - // have to fetch ourselves otherwise. - if (!message.member) { - message.member = await Member.findOneOrFail({ - where: { id: req.user_id, guild_id: message.guild_id }, - relations: ["roles"], - }); - } - - //@ts-ignore - message.member.roles = message.member.roles - .filter((x) => x.id != x.guild_id) - .map((x) => x.id); - } - - let read_state = await ReadState.findOne({ - where: { user_id: req.user_id, channel_id }, - }); - if (!read_state) - read_state = ReadState.create({ user_id: req.user_id, channel_id }); - read_state.last_message_id = message.id; - - await Promise.all([ - read_state.save(), - message.save(), - emitEvent({ - event: "MESSAGE_CREATE", - channel_id: channel_id, - data: message, - } as MessageCreateEvent), - message.guild_id - ? Member.update( - { id: req.user_id, guild_id: message.guild_id }, - { last_message_id: message.id }, - ) - : null, - channel.save(), - ]); - - postHandleMessage(message).catch((e) => {}); // no await as it shouldnt block the message send function and silently catch error - - return res.json(message); - }, -); diff --git a/src/api/routes/channels/#channel_id/permissions.ts b/src/api/routes/channels/#channel_id/permissions.ts deleted file mode 100644 index b08cd0c8..00000000 --- a/src/api/routes/channels/#channel_id/permissions.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { - Channel, - ChannelPermissionOverwrite, - ChannelUpdateEvent, - emitEvent, - Member, - Role, - ChannelPermissionOverwriteSchema, -} from "@fosscord/util"; -import { Router, Response, Request } from "express"; -import { HTTPError } from "lambert-server"; - -import { route } from "@fosscord/api"; -const router: Router = Router(); - -// TODO: Only permissions your bot has in the guild or channel can be allowed/denied (unless your bot has a MANAGE_ROLES overwrite in the channel) - -router.put( - "/:overwrite_id", - route({ - body: "ChannelPermissionOverwriteSchema", - permission: "MANAGE_ROLES", - }), - async (req: Request, res: Response) => { - const { channel_id, overwrite_id } = req.params; - const body = req.body as ChannelPermissionOverwriteSchema; - - var channel = await Channel.findOneOrFail({ - where: { id: channel_id }, - }); - if (!channel.guild_id) throw new HTTPError("Channel not found", 404); - - if (body.type === 0) { - if (!(await Role.count({ where: { id: overwrite_id } }))) - throw new HTTPError("role not found", 404); - } else if (body.type === 1) { - if (!(await Member.count({ where: { id: overwrite_id } }))) - throw new HTTPError("user not found", 404); - } else throw new HTTPError("type not supported", 501); - - //@ts-ignore - var overwrite: ChannelPermissionOverwrite = - channel.permission_overwrites?.find((x) => x.id === overwrite_id); - if (!overwrite) { - // @ts-ignore - overwrite = { - id: overwrite_id, - type: body.type, - }; - channel.permission_overwrites!.push(overwrite); - } - overwrite.allow = String( - req.permission!.bitfield & (BigInt(body.allow) || BigInt("0")), - ); - overwrite.deny = String( - req.permission!.bitfield & (BigInt(body.deny) || BigInt("0")), - ); - - await Promise.all([ - channel.save(), - emitEvent({ - event: "CHANNEL_UPDATE", - channel_id, - data: channel, - } as ChannelUpdateEvent), - ]); - - return res.sendStatus(204); - }, -); - -// TODO: check permission hierarchy -router.delete( - "/:overwrite_id", - route({ permission: "MANAGE_ROLES" }), - async (req: Request, res: Response) => { - const { channel_id, overwrite_id } = req.params; - - const channel = await Channel.findOneOrFail({ - where: { id: channel_id }, - }); - if (!channel.guild_id) throw new HTTPError("Channel not found", 404); - - channel.permission_overwrites = channel.permission_overwrites!.filter( - (x) => x.id === overwrite_id, - ); - - await Promise.all([ - channel.save(), - emitEvent({ - event: "CHANNEL_UPDATE", - channel_id, - data: channel, - } as ChannelUpdateEvent), - ]); - - return res.sendStatus(204); - }, -); - -export default router; diff --git a/src/api/routes/channels/#channel_id/pins.ts b/src/api/routes/channels/#channel_id/pins.ts deleted file mode 100644 index d3f6960a..00000000 --- a/src/api/routes/channels/#channel_id/pins.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { - Channel, - ChannelPinsUpdateEvent, - Config, - emitEvent, - getPermission, - Message, - MessageUpdateEvent, - DiscordApiErrors, -} from "@fosscord/util"; -import { Router, Request, Response } from "express"; -import { HTTPError } from "lambert-server"; -import { route } from "@fosscord/api"; - -const router: Router = Router(); - -router.put( - "/:message_id", - route({ permission: "VIEW_CHANNEL" }), - async (req: Request, res: Response) => { - const { channel_id, message_id } = req.params; - - const message = await Message.findOneOrFail({ - where: { id: message_id }, - }); - - // * in dm channels anyone can pin messages -> only check for guilds - if (message.guild_id) req.permission!.hasThrow("MANAGE_MESSAGES"); - - const pinned_count = await Message.count({ - where: { channel: { id: channel_id }, pinned: true }, - }); - const { maxPins } = Config.get().limits.channel; - if (pinned_count >= maxPins) - throw DiscordApiErrors.MAXIMUM_PINS.withParams(maxPins); - - await Promise.all([ - Message.update({ id: message_id }, { pinned: true }), - emitEvent({ - event: "MESSAGE_UPDATE", - channel_id, - data: message, - } as MessageUpdateEvent), - emitEvent({ - event: "CHANNEL_PINS_UPDATE", - channel_id, - data: { - channel_id, - guild_id: message.guild_id, - last_pin_timestamp: undefined, - }, - } as ChannelPinsUpdateEvent), - ]); - - res.sendStatus(204); - }, -); - -router.delete( - "/:message_id", - route({ permission: "VIEW_CHANNEL" }), - async (req: Request, res: Response) => { - const { channel_id, message_id } = req.params; - - const channel = await Channel.findOneOrFail({ - where: { id: channel_id }, - }); - if (channel.guild_id) req.permission!.hasThrow("MANAGE_MESSAGES"); - - const message = await Message.findOneOrFail({ - where: { id: message_id }, - }); - message.pinned = false; - - await Promise.all([ - message.save(), - - emitEvent({ - event: "MESSAGE_UPDATE", - channel_id, - data: message, - } as MessageUpdateEvent), - - emitEvent({ - event: "CHANNEL_PINS_UPDATE", - channel_id, - data: { - channel_id, - guild_id: channel.guild_id, - last_pin_timestamp: undefined, - }, - } as ChannelPinsUpdateEvent), - ]); - - res.sendStatus(204); - }, -); - -router.get( - "/", - route({ permission: ["READ_MESSAGE_HISTORY"] }), - async (req: Request, res: Response) => { - const { channel_id } = req.params; - - let pins = await Message.find({ - where: { channel_id: channel_id, pinned: true }, - }); - - res.send(pins); - }, -); - -export default router; diff --git a/src/api/routes/channels/#channel_id/purge.ts b/src/api/routes/channels/#channel_id/purge.ts deleted file mode 100644 index a9f88662..00000000 --- a/src/api/routes/channels/#channel_id/purge.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { HTTPError } from "lambert-server"; -import { route } from "@fosscord/api"; -import { isTextChannel } from "./messages"; -import { FindManyOptions, Between, Not } from "typeorm"; -import { - Channel, - Config, - emitEvent, - getPermission, - getRights, - Message, - MessageDeleteBulkEvent, - PurgeSchema, -} from "@fosscord/util"; -import { Router, Response, Request } from "express"; - -const router: Router = Router(); - -export default router; - -/** -TODO: apply the delete bit by bit to prevent client and database stress -**/ -router.post( - "/", - route({ - /*body: "PurgeSchema",*/ - }), - async (req: Request, res: Response) => { - const { channel_id } = req.params; - const channel = await Channel.findOneOrFail({ - where: { id: channel_id }, - }); - - if (!channel.guild_id) - throw new HTTPError("Can't purge dm channels", 400); - isTextChannel(channel.type); - - const rights = await getRights(req.user_id); - if (!rights.has("MANAGE_MESSAGES")) { - const permissions = await getPermission( - req.user_id, - channel.guild_id, - channel_id, - ); - permissions.hasThrow("MANAGE_MESSAGES"); - permissions.hasThrow("MANAGE_CHANNELS"); - } - - const { before, after } = req.body as PurgeSchema; - - // TODO: send the deletion event bite-by-bite to prevent client stress - - var query: FindManyOptions & { where: { id?: any } } = { - order: { id: "ASC" }, - // take: limit, - where: { - channel_id, - id: Between(after, before), // the right way around - author_id: rights.has("SELF_DELETE_MESSAGES") - ? undefined - : Not(req.user_id), - // if you lack the right of self-deletion, you can't delete your own messages, even in purges - }, - relations: [ - "author", - "webhook", - "application", - "mentions", - "mention_roles", - "mention_channels", - "sticker_items", - "attachments", - ], - }; - - const messages = await Message.find(query); - const endpoint = Config.get().cdn.endpointPublic; - - if (messages.length == 0) { - res.sendStatus(304); - return; - } - - await Message.delete(messages.map((x) => x.id)); - - await emitEvent({ - event: "MESSAGE_DELETE_BULK", - channel_id, - data: { - ids: messages.map((x) => x.id), - channel_id, - guild_id: channel.guild_id, - }, - } as MessageDeleteBulkEvent); - - res.sendStatus(204); - }, -); diff --git a/src/api/routes/channels/#channel_id/recipients.ts b/src/api/routes/channels/#channel_id/recipients.ts deleted file mode 100644 index cc7e5756..00000000 --- a/src/api/routes/channels/#channel_id/recipients.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { Request, Response, Router } from "express"; -import { - Channel, - ChannelRecipientAddEvent, - ChannelType, - DiscordApiErrors, - DmChannelDTO, - emitEvent, - PublicUserProjection, - Recipient, - User, -} from "@fosscord/util"; -import { route } from "@fosscord/api"; - -const router: Router = Router(); - -router.put("/:user_id", route({}), async (req: Request, res: Response) => { - const { channel_id, user_id } = req.params; - const channel = await Channel.findOneOrFail({ - where: { id: channel_id }, - relations: ["recipients"], - }); - - if (channel.type !== ChannelType.GROUP_DM) { - const recipients = [ - ...channel.recipients!.map((r) => r.user_id), - user_id, - ].unique(); - - const new_channel = await Channel.createDMChannel( - recipients, - req.user_id, - ); - return res.status(201).json(new_channel); - } else { - if (channel.recipients!.map((r) => r.user_id).includes(user_id)) { - throw DiscordApiErrors.INVALID_RECIPIENT; //TODO is this the right error? - } - - channel.recipients!.push( - Recipient.create({ channel_id: channel_id, user_id: user_id }), - ); - await channel.save(); - - await emitEvent({ - event: "CHANNEL_CREATE", - data: await DmChannelDTO.from(channel, [user_id]), - user_id: user_id, - }); - - await emitEvent({ - event: "CHANNEL_RECIPIENT_ADD", - data: { - channel_id: channel_id, - user: await User.findOneOrFail({ - where: { id: user_id }, - select: PublicUserProjection, - }), - }, - channel_id: channel_id, - } as ChannelRecipientAddEvent); - return res.sendStatus(204); - } -}); - -router.delete("/:user_id", route({}), async (req: Request, res: Response) => { - const { channel_id, user_id } = req.params; - const channel = await Channel.findOneOrFail({ - where: { id: channel_id }, - relations: ["recipients"], - }); - if ( - !( - channel.type === ChannelType.GROUP_DM && - (channel.owner_id === req.user_id || user_id === req.user_id) - ) - ) - throw DiscordApiErrors.MISSING_PERMISSIONS; - - if (!channel.recipients!.map((r) => r.user_id).includes(user_id)) { - throw DiscordApiErrors.INVALID_RECIPIENT; //TODO is this the right error? - } - - await Channel.removeRecipientFromChannel(channel, user_id); - - return res.sendStatus(204); -}); - -export default router; diff --git a/src/api/routes/channels/#channel_id/typing.ts b/src/api/routes/channels/#channel_id/typing.ts deleted file mode 100644 index 03f76205..00000000 --- a/src/api/routes/channels/#channel_id/typing.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Channel, emitEvent, Member, TypingStartEvent } from "@fosscord/util"; -import { route } from "@fosscord/api"; -import { Router, Request, Response } from "express"; - -const router: Router = Router(); - -router.post( - "/", - route({ permission: "SEND_MESSAGES" }), - async (req: Request, res: Response) => { - const { channel_id } = req.params; - const user_id = req.user_id; - const timestamp = Date.now(); - const channel = await Channel.findOneOrFail({ - where: { id: channel_id }, - }); - const member = await Member.findOne({ - where: { id: user_id, guild_id: channel.guild_id }, - relations: ["roles", "user"], - }); - - await emitEvent({ - event: "TYPING_START", - channel_id: channel_id, - data: { - ...(member - ? { - member: { - ...member, - roles: member?.roles?.map((x) => x.id), - }, - } - : null), - channel_id, - timestamp, - user_id, - guild_id: channel.guild_id, - }, - } as TypingStartEvent); - - res.sendStatus(204); - }, -); - -export default router; diff --git a/src/api/routes/channels/#channel_id/webhooks.ts b/src/api/routes/channels/#channel_id/webhooks.ts deleted file mode 100644 index f303ef80..00000000 --- a/src/api/routes/channels/#channel_id/webhooks.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Router, Response, Request } from "express"; -import { route } from "@fosscord/api"; -import { - Channel, - Config, - handleFile, - trimSpecial, - User, - Webhook, - WebhookCreateSchema, - WebhookType, -} from "@fosscord/util"; -import { HTTPError } from "lambert-server"; -import { isTextChannel } from "./messages/index"; -import { DiscordApiErrors } from "@fosscord/util"; -import crypto from "crypto"; - -const router: Router = Router(); - -//TODO: implement webhooks -router.get("/", route({}), async (req: Request, res: Response) => { - res.json([]); -}); - -// TODO: use Image Data Type for avatar instead of String -router.post( - "/", - route({ body: "WebhookCreateSchema", permission: "MANAGE_WEBHOOKS" }), - async (req: Request, res: Response) => { - const channel_id = req.params.channel_id; - const channel = await Channel.findOneOrFail({ - where: { id: channel_id }, - }); - - isTextChannel(channel.type); - if (!channel.guild_id) throw new HTTPError("Not a guild channel", 400); - - const webhook_count = await Webhook.count({ where: { channel_id } }); - const { maxWebhooks } = Config.get().limits.channel; - if (webhook_count > maxWebhooks) - throw DiscordApiErrors.MAXIMUM_WEBHOOKS.withParams(maxWebhooks); - - var { avatar, name } = req.body as WebhookCreateSchema; - name = trimSpecial(name); - - // TODO: move this - if (name === "clyde") throw new HTTPError("Invalid name", 400); - if (name === "Fosscord Ghost") throw new HTTPError("Invalid name", 400); - - if (avatar) avatar = await handleFile(`/avatars/${channel_id}`, avatar); - - const hook = Webhook.create({ - type: WebhookType.Incoming, - name, - avatar, - guild_id: channel.guild_id, - channel_id: channel.id, - user_id: req.user_id, - token: crypto.randomBytes(24).toString("base64"), - }); - - const user = await User.getPublicUser(req.user_id); - - return res.json({ - ...hook, - user: user, - }); - }, -); - -export default router; diff --git a/src/api/routes/discoverable-guilds.ts b/src/api/routes/discoverable-guilds.ts deleted file mode 100644 index 428ca605..00000000 --- a/src/api/routes/discoverable-guilds.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Guild, Config } from "@fosscord/util"; - -import { Router, Request, Response } from "express"; -import { route } from "@fosscord/api"; -import { Like } from "typeorm"; - -const router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - const { offset, limit, categories } = req.query; - var showAllGuilds = Config.get().guild.discovery.showAllGuilds; - var configLimit = Config.get().guild.discovery.limit; - let guilds; - if (categories == undefined) { - guilds = showAllGuilds - ? await Guild.find({ take: Math.abs(Number(limit || configLimit)) }) - : await Guild.find({ - where: { features: Like(`%DISCOVERABLE%`) }, - take: Math.abs(Number(limit || configLimit)), - }); - } else { - guilds = showAllGuilds - ? await Guild.find({ - where: { primary_category_id: categories.toString() }, - take: Math.abs(Number(limit || configLimit)), - }) - : await Guild.find({ - where: { - primary_category_id: categories.toString(), - features: Like("%DISCOVERABLE%"), - }, - take: Math.abs(Number(limit || configLimit)), - }); - } - - const total = guilds ? guilds.length : undefined; - - res.send({ - total: total, - guilds: guilds, - offset: Number(offset || Config.get().guild.discovery.offset), - limit: Number(limit || configLimit), - }); -}); - -export default router; diff --git a/src/api/routes/discovery.ts b/src/api/routes/discovery.ts deleted file mode 100644 index 90450035..00000000 --- a/src/api/routes/discovery.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Categories } from "@fosscord/util"; -import { Router, Response, Request } from "express"; -import { route } from "@fosscord/api"; - -const router = Router(); - -router.get("/categories", route({}), async (req: Request, res: Response) => { - // TODO: - // Get locale instead - - const { locale, primary_only } = req.query; - - const out = primary_only - ? await Categories.find() - : await Categories.find({ where: { is_primary: true } }); - - res.send(out); -}); - -export default router; diff --git a/src/api/routes/download/index.ts b/src/api/routes/download/index.ts deleted file mode 100644 index 1c135f25..00000000 --- a/src/api/routes/download/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Router, Response, Request } from "express"; -import { route } from "@fosscord/api"; -import { FieldErrors, Release } from "@fosscord/util"; - -const router = Router(); - -/* - TODO: Putting the download route in /routes/download.ts doesn't register the route, for some reason - But putting it here *does* -*/ - -router.get("/", route({}), async (req: Request, res: Response) => { - const { platform } = req.query; - - if (!platform) - throw FieldErrors({ - platform: { - code: "BASE_TYPE_REQUIRED", - message: req.t("common:field.BASE_TYPE_REQUIRED"), - }, - }); - - const release = await Release.findOneOrFail({ - where: { - enabled: true, - platform: platform as string, - }, - order: { pub_date: "DESC" }, - }); - - res.redirect(release.url); -}); - -export default router; diff --git a/src/api/routes/experiments.ts b/src/api/routes/experiments.ts deleted file mode 100644 index b2b7d724..00000000 --- a/src/api/routes/experiments.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Router, Response, Request } from "express"; -import { route } from "@fosscord/api"; - -const router = Router(); - -router.get("/", route({}), (req: Request, res: Response) => { - // TODO: - res.send({ fingerprint: "", assignments: [], guild_experiments: [] }); -}); - -export default router; diff --git a/src/api/routes/gateway/bot.ts b/src/api/routes/gateway/bot.ts deleted file mode 100644 index 2e26d019..00000000 --- a/src/api/routes/gateway/bot.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Config } from "@fosscord/util"; -import { Router, Response, Request } from "express"; -import { route, RouteOptions } from "@fosscord/api"; - -const router = Router(); - -export interface GatewayBotResponse { - url: string; - shards: number; - session_start_limit: { - total: number; - remaining: number; - reset_after: number; - max_concurrency: number; - }; -} - -const options: RouteOptions = { - test: { - response: { - body: "GatewayBotResponse", - }, - }, -}; - -router.get("/", route(options), (req: Request, res: Response) => { - const { endpointPublic } = Config.get().gateway; - res.json({ - url: endpointPublic || process.env.GATEWAY || "ws://localhost:3002", - shards: 1, - session_start_limit: { - total: 1000, - remaining: 999, - reset_after: 14400000, - max_concurrency: 1, - }, - }); -}); - -export default router; diff --git a/src/api/routes/gateway/index.ts b/src/api/routes/gateway/index.ts deleted file mode 100644 index a6ed9dc4..00000000 --- a/src/api/routes/gateway/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Config } from "@fosscord/util"; -import { Router, Response, Request } from "express"; -import { route, RouteOptions } from "@fosscord/api"; - -const router = Router(); - -export interface GatewayResponse { - url: string; -} - -const options: RouteOptions = { - test: { - response: { - body: "GatewayResponse", - }, - }, -}; - -router.get("/", route(options), (req: Request, res: Response) => { - const { endpointPublic } = Config.get().gateway; - res.json({ - url: endpointPublic || process.env.GATEWAY || "ws://localhost:3002", - }); -}); - -export default router; diff --git a/src/api/routes/gifs/search.ts b/src/api/routes/gifs/search.ts deleted file mode 100644 index 54352215..00000000 --- a/src/api/routes/gifs/search.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Router, Response, Request } from "express"; -import fetch from "node-fetch"; -import ProxyAgent from "proxy-agent"; -import { route } from "@fosscord/api"; -import { getGifApiKey, parseGifResult } from "./trending"; - -const router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - // TODO: Custom providers - const { q, media_format, locale } = req.query; - - const apiKey = getGifApiKey(); - - const agent = new ProxyAgent(); - - const response = await fetch( - `https://g.tenor.com/v1/search?q=${q}&media_format=${media_format}&locale=${locale}&key=${apiKey}`, - { - agent, - method: "get", - headers: { "Content-Type": "application/json" }, - }, - ); - - const { results } = (await response.json()) as any; // TODO: types - - res.json(results.map(parseGifResult)).status(200); -}); - -export default router; diff --git a/src/api/routes/gifs/trending-gifs.ts b/src/api/routes/gifs/trending-gifs.ts deleted file mode 100644 index e4b28e24..00000000 --- a/src/api/routes/gifs/trending-gifs.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Router, Response, Request } from "express"; -import fetch from "node-fetch"; -import ProxyAgent from "proxy-agent"; -import { route } from "@fosscord/api"; -import { getGifApiKey, parseGifResult } from "./trending"; - -const router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - // TODO: Custom providers - const { media_format, locale } = req.query; - - const apiKey = getGifApiKey(); - - const agent = new ProxyAgent(); - - const response = await fetch( - `https://g.tenor.com/v1/trending?media_format=${media_format}&locale=${locale}&key=${apiKey}`, - { - agent, - method: "get", - headers: { "Content-Type": "application/json" }, - }, - ); - - const { results } = (await response.json()) as any; // TODO: types - - res.json(results.map(parseGifResult)).status(200); -}); - -export default router; diff --git a/src/api/routes/gifs/trending.ts b/src/api/routes/gifs/trending.ts deleted file mode 100644 index 58044ea5..00000000 --- a/src/api/routes/gifs/trending.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Router, Response, Request } from "express"; -import fetch from "node-fetch"; -import ProxyAgent from "proxy-agent"; -import { route } from "@fosscord/api"; -import { Config } from "@fosscord/util"; -import { HTTPError } from "lambert-server"; - -const router = Router(); - -export function parseGifResult(result: any) { - return { - id: result.id, - title: result.title, - url: result.itemurl, - src: result.media[0].mp4.url, - gif_src: result.media[0].gif.url, - width: result.media[0].mp4.dims[0], - height: result.media[0].mp4.dims[1], - preview: result.media[0].mp4.preview, - }; -} - -export function getGifApiKey() { - const { enabled, provider, apiKey } = Config.get().gif; - if (!enabled) throw new HTTPError(`Gifs are disabled`); - if (provider !== "tenor" || !apiKey) - throw new HTTPError(`${provider} gif provider not supported`); - - return apiKey; -} - -router.get("/", route({}), async (req: Request, res: Response) => { - // TODO: Custom providers - // TODO: return gifs as mp4 - const { media_format, locale } = req.query; - - const apiKey = getGifApiKey(); - - const agent = new ProxyAgent(); - - const [responseSource, trendGifSource] = await Promise.all([ - fetch( - `https://g.tenor.com/v1/categories?locale=${locale}&key=${apiKey}`, - { - agent, - method: "get", - headers: { "Content-Type": "application/json" }, - }, - ), - fetch( - `https://g.tenor.com/v1/trending?locale=${locale}&key=${apiKey}`, - { - agent, - method: "get", - headers: { "Content-Type": "application/json" }, - }, - ), - ]); - - const { tags } = (await responseSource.json()) as any; // TODO: types - const { results } = (await trendGifSource.json()) as any; //TODO: types; - - res.json({ - categories: tags.map((x: any) => ({ - name: x.searchterm, - src: x.image, - })), - gifs: [parseGifResult(results[0])], - }).status(200); -}); - -export default router; diff --git a/src/api/routes/guild-recommendations.ts b/src/api/routes/guild-recommendations.ts deleted file mode 100644 index 8bf1e508..00000000 --- a/src/api/routes/guild-recommendations.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Guild, Config } from "@fosscord/util"; - -import { Router, Request, Response } from "express"; -import { route } from "@fosscord/api"; -import { Like } from "typeorm"; - -const router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - const { limit, personalization_disabled } = req.query; - var showAllGuilds = Config.get().guild.discovery.showAllGuilds; - - const genLoadId = (size: Number) => - [...Array(size)] - .map(() => Math.floor(Math.random() * 16).toString(16)) - .join(""); - - const guilds = showAllGuilds - ? await Guild.find({ take: Math.abs(Number(limit || 24)) }) - : await Guild.find({ - where: { features: Like("%DISCOVERABLE%") }, - take: Math.abs(Number(limit || 24)), - }); - res.send({ - recommended_guilds: guilds, - load_id: `server_recs/${genLoadId(32)}`, - }).status(200); -}); - -export default router; diff --git a/src/api/routes/guilds/#guild_id/audit-logs.ts b/src/api/routes/guilds/#guild_id/audit-logs.ts deleted file mode 100644 index 76a11f6b..00000000 --- a/src/api/routes/guilds/#guild_id/audit-logs.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Router, Response, Request } from "express"; -import { route } from "@fosscord/api"; -const router = Router(); - -//TODO: implement audit logs -router.get("/", route({}), async (req: Request, res: Response) => { - res.json({ - audit_log_entries: [], - users: [], - integrations: [], - webhooks: [], - guild_scheduled_events: [], - threads: [], - application_commands: [], - }); -}); -export default router; diff --git a/src/api/routes/guilds/#guild_id/bans.ts b/src/api/routes/guilds/#guild_id/bans.ts deleted file mode 100644 index 930985d7..00000000 --- a/src/api/routes/guilds/#guild_id/bans.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { Request, Response, Router } from "express"; -import { - DiscordApiErrors, - emitEvent, - GuildBanAddEvent, - GuildBanRemoveEvent, - Ban, - User, - Member, - BanRegistrySchema, - BanModeratorSchema, -} from "@fosscord/util"; -import { HTTPError } from "lambert-server"; -import { getIpAdress, route } from "@fosscord/api"; - -const router: Router = Router(); - -/* TODO: Deleting the secrets is just a temporary go-around. Views should be implemented for both safety and better handling. */ - -router.get( - "/", - route({ permission: "BAN_MEMBERS" }), - async (req: Request, res: Response) => { - const { guild_id } = req.params; - - let bans = await Ban.find({ where: { guild_id: guild_id } }); - let promisesToAwait: object[] = []; - const bansObj: object[] = []; - - bans.filter((ban) => ban.user_id !== ban.executor_id); // pretend self-bans don't exist to prevent victim chasing - - bans.forEach((ban) => { - promisesToAwait.push(User.getPublicUser(ban.user_id)); - }); - - const bannedUsers: object[] = await Promise.all(promisesToAwait); - - bans.forEach((ban, index) => { - const user = bannedUsers[index] as User; - bansObj.push({ - reason: ban.reason, - user: { - username: user.username, - discriminator: user.discriminator, - id: user.id, - avatar: user.avatar, - public_flags: user.public_flags, - }, - }); - }); - - return res.json(bansObj); - }, -); - -router.get( - "/:user", - route({ permission: "BAN_MEMBERS" }), - async (req: Request, res: Response) => { - const { guild_id } = req.params; - const user_id = req.params.ban; - - let ban = (await Ban.findOneOrFail({ - where: { guild_id: guild_id, user_id: user_id }, - })) as BanRegistrySchema; - - if (ban.user_id === ban.executor_id) throw DiscordApiErrors.UNKNOWN_BAN; - // pretend self-bans don't exist to prevent victim chasing - - /* Filter secret from registry. */ - - ban = ban as BanModeratorSchema; - - delete ban.ip; - - return res.json(ban); - }, -); - -router.put( - "/:user_id", - route({ body: "BanCreateSchema", permission: "BAN_MEMBERS" }), - async (req: Request, res: Response) => { - const { guild_id } = req.params; - const banned_user_id = req.params.user_id; - - if ( - req.user_id === banned_user_id && - banned_user_id === req.permission!.cache.guild?.owner_id - ) - throw new HTTPError( - "You are the guild owner, hence can't ban yourself", - 403, - ); - - if (req.permission!.cache.guild?.owner_id === banned_user_id) - throw new HTTPError("You can't ban the owner", 400); - - const banned_user = await User.getPublicUser(banned_user_id); - - const ban = Ban.create({ - user_id: banned_user_id, - guild_id: guild_id, - ip: getIpAdress(req), - executor_id: req.user_id, - reason: req.body.reason, // || otherwise empty - }); - - await Promise.all([ - Member.removeFromGuild(banned_user_id, guild_id), - ban.save(), - emitEvent({ - event: "GUILD_BAN_ADD", - data: { - guild_id: guild_id, - user: banned_user, - }, - guild_id: guild_id, - } as GuildBanAddEvent), - ]); - - return res.json(ban); - }, -); - -router.put( - "/@me", - route({ body: "BanCreateSchema" }), - async (req: Request, res: Response) => { - const { guild_id } = req.params; - - const banned_user = await User.getPublicUser(req.params.user_id); - - if (req.permission!.cache.guild?.owner_id === req.params.user_id) - throw new HTTPError( - "You are the guild owner, hence can't ban yourself", - 403, - ); - - const ban = Ban.create({ - user_id: req.params.user_id, - guild_id: guild_id, - ip: getIpAdress(req), - executor_id: req.params.user_id, - reason: req.body.reason, // || otherwise empty - }); - - await Promise.all([ - Member.removeFromGuild(req.user_id, guild_id), - ban.save(), - emitEvent({ - event: "GUILD_BAN_ADD", - data: { - guild_id: guild_id, - user: banned_user, - }, - guild_id: guild_id, - } as GuildBanAddEvent), - ]); - - return res.json(ban); - }, -); - -router.delete( - "/:user_id", - route({ permission: "BAN_MEMBERS" }), - async (req: Request, res: Response) => { - const { guild_id, user_id } = req.params; - - let ban = await Ban.findOneOrFail({ - where: { guild_id: guild_id, user_id: user_id }, - }); - - if (ban.user_id === ban.executor_id) throw DiscordApiErrors.UNKNOWN_BAN; - // make self-bans irreversible and hide them from view to avoid victim chasing - - const banned_user = await User.getPublicUser(user_id); - - await Promise.all([ - Ban.delete({ - user_id: user_id, - guild_id, - }), - - emitEvent({ - event: "GUILD_BAN_REMOVE", - data: { - guild_id, - user: banned_user, - }, - guild_id, - } as GuildBanRemoveEvent), - ]); - - return res.status(204).send(); - }, -); - -export default router; diff --git a/src/api/routes/guilds/#guild_id/channels.ts b/src/api/routes/guilds/#guild_id/channels.ts deleted file mode 100644 index eae93607..00000000 --- a/src/api/routes/guilds/#guild_id/channels.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { Router, Response, Request } from "express"; -import { - Channel, - ChannelUpdateEvent, - emitEvent, - ChannelModifySchema, - ChannelReorderSchema, -} from "@fosscord/util"; -import { HTTPError } from "lambert-server"; -import { route } from "@fosscord/api"; -const router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - const { guild_id } = req.params; - const channels = await Channel.find({ where: { guild_id } }); - - res.json(channels); -}); - -router.post( - "/", - route({ body: "ChannelModifySchema", permission: "MANAGE_CHANNELS" }), - async (req: Request, res: Response) => { - // creates a new guild channel https://discord.com/developers/docs/resources/guild#create-guild-channel - const { guild_id } = req.params; - const body = req.body as ChannelModifySchema; - - const channel = await Channel.createChannel( - { ...body, guild_id }, - req.user_id, - ); - - res.status(201).json(channel); - }, -); - -router.patch( - "/", - route({ body: "ChannelReorderSchema", permission: "MANAGE_CHANNELS" }), - async (req: Request, res: Response) => { - // changes guild channel position - const { guild_id } = req.params; - const body = req.body as ChannelReorderSchema; - - await Promise.all([ - body.map(async (x) => { - if (x.position == null && !x.parent_id) - throw new HTTPError( - `You need to at least specify position or parent_id`, - 400, - ); - - const opts: any = {}; - if (x.position != null) opts.position = x.position; - - if (x.parent_id) { - opts.parent_id = x.parent_id; - const parent_channel = await Channel.findOneOrFail({ - where: { id: x.parent_id, guild_id }, - select: ["permission_overwrites"], - }); - if (x.lock_permissions) { - opts.permission_overwrites = - parent_channel.permission_overwrites; - } - } - - await Channel.update({ guild_id, id: x.id }, opts); - const channel = await Channel.findOneOrFail({ - where: { guild_id, id: x.id }, - }); - - await emitEvent({ - event: "CHANNEL_UPDATE", - data: channel, - channel_id: x.id, - guild_id, - } as ChannelUpdateEvent); - }), - ]); - - res.sendStatus(204); - }, -); - -export default router; diff --git a/src/api/routes/guilds/#guild_id/delete.ts b/src/api/routes/guilds/#guild_id/delete.ts deleted file mode 100644 index b951e4f4..00000000 --- a/src/api/routes/guilds/#guild_id/delete.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { - Channel, - emitEvent, - GuildDeleteEvent, - Guild, - Member, - Message, - Role, - Invite, - Emoji, -} from "@fosscord/util"; -import { Router, Request, Response } from "express"; -import { HTTPError } from "lambert-server"; -import { route } from "@fosscord/api"; - -const router = Router(); - -// discord prefixes this route with /delete instead of using the delete method -// docs are wrong https://discord.com/developers/docs/resources/guild#delete-guild -router.post("/", route({}), async (req: Request, res: Response) => { - var { guild_id } = req.params; - - const guild = await Guild.findOneOrFail({ - where: { id: guild_id }, - select: ["owner_id"], - }); - if (guild.owner_id !== req.user_id) - throw new HTTPError("You are not the owner of this guild", 401); - - await Promise.all([ - Guild.delete({ id: guild_id }), // this will also delete all guild related data - emitEvent({ - event: "GUILD_DELETE", - data: { - id: guild_id, - }, - guild_id: guild_id, - } as GuildDeleteEvent), - ]); - - return res.sendStatus(204); -}); - -export default router; diff --git a/src/api/routes/guilds/#guild_id/discovery-requirements.ts b/src/api/routes/guilds/#guild_id/discovery-requirements.ts deleted file mode 100644 index 7e63c06b..00000000 --- a/src/api/routes/guilds/#guild_id/discovery-requirements.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Guild, Config } from "@fosscord/util"; - -import { Router, Request, Response } from "express"; -import { route } from "@fosscord/api"; - -const router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - const { guild_id } = req.params; - // TODO: - // Load from database - // Admin control, but for now it allows anyone to be discoverable - - res.send({ - guild_id: guild_id, - safe_environment: true, - healthy: true, - health_score_pending: false, - size: true, - nsfw_properties: {}, - protected: true, - sufficient: true, - sufficient_without_grace_period: true, - valid_rules_channel: true, - retention_healthy: true, - engagement_healthy: true, - age: true, - minimum_age: 0, - health_score: { - avg_nonnew_participators: 0, - avg_nonnew_communicators: 0, - num_intentful_joiners: 0, - perc_ret_w1_intentful: 0, - }, - minimum_size: 0, - }); -}); - -export default router; diff --git a/src/api/routes/guilds/#guild_id/emojis.ts b/src/api/routes/guilds/#guild_id/emojis.ts deleted file mode 100644 index 6e8570eb..00000000 --- a/src/api/routes/guilds/#guild_id/emojis.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { Router, Request, Response } from "express"; -import { - Config, - DiscordApiErrors, - emitEvent, - Emoji, - GuildEmojisUpdateEvent, - handleFile, - Member, - Snowflake, - User, - EmojiCreateSchema, - EmojiModifySchema, -} from "@fosscord/util"; -import { route } from "@fosscord/api"; - -const router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - const { guild_id } = req.params; - - await Member.IsInGuildOrFail(req.user_id, guild_id); - - const emojis = await Emoji.find({ - where: { guild_id: guild_id }, - relations: ["user"], - }); - - return res.json(emojis); -}); - -router.get("/:emoji_id", route({}), async (req: Request, res: Response) => { - const { guild_id, emoji_id } = req.params; - - await Member.IsInGuildOrFail(req.user_id, guild_id); - - const emoji = await Emoji.findOneOrFail({ - where: { guild_id: guild_id, id: emoji_id }, - relations: ["user"], - }); - - return res.json(emoji); -}); - -router.post( - "/", - route({ - body: "EmojiCreateSchema", - permission: "MANAGE_EMOJIS_AND_STICKERS", - }), - async (req: Request, res: Response) => { - const { guild_id } = req.params; - const body = req.body as EmojiCreateSchema; - - const id = Snowflake.generate(); - const emoji_count = await Emoji.count({ - where: { guild_id: guild_id }, - }); - const { maxEmojis } = Config.get().limits.guild; - - if (emoji_count >= maxEmojis) - throw DiscordApiErrors.MAXIMUM_NUMBER_OF_EMOJIS_REACHED.withParams( - maxEmojis, - ); - if (body.require_colons == null) body.require_colons = true; - - const user = await User.findOneOrFail({ where: { id: req.user_id } }); - body.image = (await handleFile(`/emojis/${id}`, body.image)) as string; - - const emoji = await Emoji.create({ - id: id, - guild_id: guild_id, - ...body, - require_colons: body.require_colons ?? undefined, // schema allows nulls, db does not - user: user, - managed: false, - animated: false, // TODO: Add support animated emojis - available: true, - roles: [], - }).save(); - - await emitEvent({ - event: "GUILD_EMOJIS_UPDATE", - guild_id: guild_id, - data: { - guild_id: guild_id, - emojis: await Emoji.find({ where: { guild_id: guild_id } }), - }, - } as GuildEmojisUpdateEvent); - - return res.status(201).json(emoji); - }, -); - -router.patch( - "/:emoji_id", - route({ - body: "EmojiModifySchema", - permission: "MANAGE_EMOJIS_AND_STICKERS", - }), - async (req: Request, res: Response) => { - const { emoji_id, guild_id } = req.params; - const body = req.body as EmojiModifySchema; - - const emoji = await Emoji.create({ - ...body, - id: emoji_id, - guild_id: guild_id, - }).save(); - - await emitEvent({ - event: "GUILD_EMOJIS_UPDATE", - guild_id: guild_id, - data: { - guild_id: guild_id, - emojis: await Emoji.find({ where: { guild_id: guild_id } }), - }, - } as GuildEmojisUpdateEvent); - - return res.json(emoji); - }, -); - -router.delete( - "/:emoji_id", - route({ permission: "MANAGE_EMOJIS_AND_STICKERS" }), - async (req: Request, res: Response) => { - const { emoji_id, guild_id } = req.params; - - await Emoji.delete({ - id: emoji_id, - guild_id: guild_id, - }); - - await emitEvent({ - event: "GUILD_EMOJIS_UPDATE", - guild_id: guild_id, - data: { - guild_id: guild_id, - emojis: await Emoji.find({ where: { guild_id: guild_id } }), - }, - } as GuildEmojisUpdateEvent); - - res.sendStatus(204); - }, -); - -export default router; diff --git a/src/api/routes/guilds/#guild_id/index.ts b/src/api/routes/guilds/#guild_id/index.ts deleted file mode 100644 index 79c20678..00000000 --- a/src/api/routes/guilds/#guild_id/index.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { Request, Response, Router } from "express"; -import { - DiscordApiErrors, - emitEvent, - getPermission, - getRights, - Guild, - GuildUpdateEvent, - handleFile, - Member, - GuildUpdateSchema, - FosscordApiErrors, -} from "@fosscord/util"; -import { HTTPError } from "lambert-server"; -import { route } from "@fosscord/api"; - -const router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - const { guild_id } = req.params; - - const [guild, member] = await Promise.all([ - Guild.findOneOrFail({ where: { id: guild_id } }), - Member.findOne({ where: { guild_id: guild_id, id: req.user_id } }), - ]); - if (!member) - throw new HTTPError( - "You are not a member of the guild you are trying to access", - 401, - ); - - // @ts-ignore - guild.joined_at = member?.joined_at; - - return res.send(guild); -}); - -router.patch( - "/", - route({ body: "GuildUpdateSchema" }), - async (req: Request, res: Response) => { - const body = req.body as GuildUpdateSchema; - const { guild_id } = req.params; - - const rights = await getRights(req.user_id); - const permission = await getPermission(req.user_id, guild_id); - - if (!rights.has("MANAGE_GUILDS") && !permission.has("MANAGE_GUILD")) - throw DiscordApiErrors.MISSING_PERMISSIONS.withParams( - "MANAGE_GUILDS", - ); - - var guild = await Guild.findOneOrFail({ - where: { id: guild_id }, - relations: ["emojis", "roles", "stickers"], - }); - - // TODO: guild update check image - - if (body.icon && body.icon != guild.icon) - body.icon = await handleFile(`/icons/${guild_id}`, body.icon); - - if (body.banner && body.banner !== guild.banner) - body.banner = await handleFile(`/banners/${guild_id}`, body.banner); - - if (body.splash && body.splash !== guild.splash) - body.splash = await handleFile( - `/splashes/${guild_id}`, - body.splash, - ); - - if ( - body.discovery_splash && - body.discovery_splash !== guild.discovery_splash - ) - body.discovery_splash = await handleFile( - `/discovery-splashes/${guild_id}`, - body.discovery_splash, - ); - - if (body.features) { - const diff = guild.features - .filter((x) => !body.features?.includes(x)) - .concat( - body.features.filter((x) => !guild.features.includes(x)), - ); - - // TODO move these - const MUTABLE_FEATURES = [ - "COMMUNITY", - "INVITES_DISABLED", - "DISCOVERABLE", - ]; - - for (var feature of diff) { - if (MUTABLE_FEATURES.includes(feature)) continue; - - throw FosscordApiErrors.FEATURE_IS_IMMUTABLE.withParams( - feature, - ); - } - - // for some reason, they don't update in the assign. - guild.features = body.features; - } - - // TODO: check if body ids are valid - guild.assign(body); - - const data = guild.toJSON(); - // TODO: guild hashes - // TODO: fix vanity_url_code, template_id - delete data.vanity_url_code; - delete data.template_id; - - await Promise.all([ - guild.save(), - emitEvent({ - event: "GUILD_UPDATE", - data, - guild_id, - } as GuildUpdateEvent), - ]); - - return res.json(data); - }, -); - -export default router; diff --git a/src/api/routes/guilds/#guild_id/integrations.ts b/src/api/routes/guilds/#guild_id/integrations.ts deleted file mode 100644 index a8e78062..00000000 --- a/src/api/routes/guilds/#guild_id/integrations.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Router, Response, Request } from "express"; -import { route } from "@fosscord/api"; -const router = Router(); - -//TODO: implement integrations list -router.get("/", route({}), async (req: Request, res: Response) => { - res.json([]); -}); -export default router; diff --git a/src/api/routes/guilds/#guild_id/invites.ts b/src/api/routes/guilds/#guild_id/invites.ts deleted file mode 100644 index 4d033e9c..00000000 --- a/src/api/routes/guilds/#guild_id/invites.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { getPermission, Invite, PublicInviteRelation } from "@fosscord/util"; -import { route } from "@fosscord/api"; -import { Request, Response, Router } from "express"; - -const router = Router(); - -router.get( - "/", - route({ permission: "MANAGE_GUILD" }), - async (req: Request, res: Response) => { - const { guild_id } = req.params; - - const invites = await Invite.find({ - where: { guild_id }, - relations: PublicInviteRelation, - }); - - return res.json(invites); - }, -); - -export default router; diff --git a/src/api/routes/guilds/#guild_id/member-verification.ts b/src/api/routes/guilds/#guild_id/member-verification.ts deleted file mode 100644 index c2f946b2..00000000 --- a/src/api/routes/guilds/#guild_id/member-verification.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Router, Request, Response } from "express"; -import { route } from "@fosscord/api"; -const router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - // TODO: member verification - - res.status(404).json({ - message: "Unknown Guild Member Verification Form", - code: 10068, - }); -}); - -export default router; diff --git a/src/api/routes/guilds/#guild_id/members/#member_id/index.ts b/src/api/routes/guilds/#guild_id/members/#member_id/index.ts deleted file mode 100644 index 0fcdd57c..00000000 --- a/src/api/routes/guilds/#guild_id/members/#member_id/index.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { Request, Response, Router } from "express"; -import { - Member, - getPermission, - getRights, - Role, - GuildMemberUpdateEvent, - emitEvent, - Sticker, - Emoji, - Guild, - handleFile, - MemberChangeSchema, -} from "@fosscord/util"; -import { route } from "@fosscord/api"; - -const router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - const { guild_id, member_id } = req.params; - await Member.IsInGuildOrFail(req.user_id, guild_id); - - const member = await Member.findOneOrFail({ - where: { id: member_id, guild_id }, - }); - - return res.json(member); -}); - -router.patch( - "/", - route({ body: "MemberChangeSchema" }), - async (req: Request, res: Response) => { - let { guild_id, member_id } = req.params; - if (member_id === "@me") member_id = req.user_id; - const body = req.body as MemberChangeSchema; - - let member = await Member.findOneOrFail({ - where: { id: member_id, guild_id }, - relations: ["roles", "user"], - }); - const permission = await getPermission(req.user_id, guild_id); - const everyone = await Role.findOneOrFail({ - where: { guild_id: guild_id, name: "@everyone", position: 0 }, - }); - - if (body.avatar) - body.avatar = await handleFile( - `/guilds/${guild_id}/users/${member_id}/avatars`, - body.avatar as string, - ); - - member.assign(body); - - if ("roles" in body) { - permission.hasThrow("MANAGE_ROLES"); - - body.roles = body.roles || []; - body.roles.filter((x) => !!x); - - if (body.roles.indexOf(everyone.id) === -1) - body.roles.push(everyone.id); - member.roles = body.roles.map((x) => Role.create({ id: x })); // foreign key constraint will fail if role doesn't exist - } - - await member.save(); - - member.roles = member.roles.filter((x) => x.id !== everyone.id); - - // do not use promise.all as we have to first write to db before emitting the event to catch errors - await emitEvent({ - event: "GUILD_MEMBER_UPDATE", - guild_id, - data: { ...member, roles: member.roles.map((x) => x.id) }, - } as GuildMemberUpdateEvent); - - res.json(member); - }, -); - -router.put("/", route({}), async (req: Request, res: Response) => { - // TODO: Lurker mode - - const rights = await getRights(req.user_id); - - let { guild_id, member_id } = req.params; - if (member_id === "@me") { - member_id = req.user_id; - rights.hasThrow("JOIN_GUILDS"); - } else { - // TODO: join others by controller - } - - var guild = await Guild.findOneOrFail({ - where: { id: guild_id }, - }); - - var emoji = await Emoji.find({ - where: { guild_id: guild_id }, - }); - - var roles = await Role.find({ - where: { guild_id: guild_id }, - }); - - var stickers = await Sticker.find({ - where: { guild_id: guild_id }, - }); - - await Member.addToGuild(member_id, guild_id); - res.send({ ...guild, emojis: emoji, roles: roles, stickers: stickers }); -}); - -router.delete("/", route({}), async (req: Request, res: Response) => { - const { guild_id, member_id } = req.params; - const permission = await getPermission(req.user_id, guild_id); - const rights = await getRights(req.user_id); - if (member_id === "@me" || member_id === req.user_id) { - // TODO: unless force-joined - rights.hasThrow("SELF_LEAVE_GROUPS"); - } else { - rights.hasThrow("KICK_BAN_MEMBERS"); - permission.hasThrow("KICK_MEMBERS"); - } - - await Member.removeFromGuild(member_id, guild_id); - res.sendStatus(204); -}); - -export default router; diff --git a/src/api/routes/guilds/#guild_id/members/#member_id/nick.ts b/src/api/routes/guilds/#guild_id/members/#member_id/nick.ts deleted file mode 100644 index 20443821..00000000 --- a/src/api/routes/guilds/#guild_id/members/#member_id/nick.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { getPermission, Member, PermissionResolvable } from "@fosscord/util"; -import { route } from "@fosscord/api"; -import { Request, Response, Router } from "express"; - -const router = Router(); - -router.patch( - "/", - route({ body: "MemberNickChangeSchema" }), - async (req: Request, res: Response) => { - var { guild_id, member_id } = req.params; - var permissionString: PermissionResolvable = "MANAGE_NICKNAMES"; - if (member_id === "@me") { - member_id = req.user_id; - permissionString = "CHANGE_NICKNAME"; - } - - const perms = await getPermission(req.user_id, guild_id); - perms.hasThrow(permissionString); - - await Member.changeNickname(member_id, guild_id, req.body.nick); - res.status(200).send(); - }, -); - -export default router; diff --git a/src/api/routes/guilds/#guild_id/members/#member_id/roles/#role_id/index.ts b/src/api/routes/guilds/#guild_id/members/#member_id/roles/#role_id/index.ts deleted file mode 100644 index c0383912..00000000 --- a/src/api/routes/guilds/#guild_id/members/#member_id/roles/#role_id/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { getPermission, Member } from "@fosscord/util"; -import { route } from "@fosscord/api"; -import { Request, Response, Router } from "express"; - -const router = Router(); - -router.delete( - "/", - route({ permission: "MANAGE_ROLES" }), - async (req: Request, res: Response) => { - const { guild_id, role_id, member_id } = req.params; - - await Member.removeRole(member_id, guild_id, role_id); - res.sendStatus(204); - }, -); - -router.put( - "/", - route({ permission: "MANAGE_ROLES" }), - async (req: Request, res: Response) => { - const { guild_id, role_id, member_id } = req.params; - - await Member.addRole(member_id, guild_id, role_id); - res.sendStatus(204); - }, -); - -export default router; diff --git a/src/api/routes/guilds/#guild_id/members/index.ts b/src/api/routes/guilds/#guild_id/members/index.ts deleted file mode 100644 index b516b9e9..00000000 --- a/src/api/routes/guilds/#guild_id/members/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Request, Response, Router } from "express"; -import { Guild, Member, PublicMemberProjection } from "@fosscord/util"; -import { route } from "@fosscord/api"; -import { MoreThan } from "typeorm"; -import { HTTPError } from "lambert-server"; - -const router = Router(); - -// TODO: send over websocket -// TODO: check for GUILD_MEMBERS intent - -router.get("/", route({}), async (req: Request, res: Response) => { - const { guild_id } = req.params; - const limit = Number(req.query.limit) || 1; - if (limit > 1000 || limit < 1) - throw new HTTPError("Limit must be between 1 and 1000"); - const after = `${req.query.after}`; - const query = after ? { id: MoreThan(after) } : {}; - - await Member.IsInGuildOrFail(req.user_id, guild_id); - - const members = await Member.find({ - where: { guild_id, ...query }, - select: PublicMemberProjection, - take: limit, - order: { id: "ASC" }, - }); - - return res.json(members); -}); - -export default router; diff --git a/src/api/routes/guilds/#guild_id/messages/search.ts b/src/api/routes/guilds/#guild_id/messages/search.ts deleted file mode 100644 index 88488871..00000000 --- a/src/api/routes/guilds/#guild_id/messages/search.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { Request, Response, Router } from "express"; -import { route } from "@fosscord/api"; -import { getPermission, FieldErrors, Message, Channel } from "@fosscord/util"; -import { HTTPError } from "lambert-server"; -import { FindManyOptions, In, Like } from "typeorm"; - -const router: Router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - const { - channel_id, - content, - include_nsfw, // TODO - offset, - sort_order, - sort_by, // TODO: Handle 'relevance' - limit, - author_id, - } = req.query; - - const parsedLimit = Number(limit) || 50; - if (parsedLimit < 1 || parsedLimit > 100) - throw new HTTPError("limit must be between 1 and 100", 422); - - if (sort_order) { - if ( - typeof sort_order != "string" || - ["desc", "asc"].indexOf(sort_order) == -1 - ) - throw FieldErrors({ - sort_order: { - message: "Value must be one of ('desc', 'asc').", - code: "BASE_TYPE_CHOICES", - }, - }); // todo this is wrong - } - - const permissions = await getPermission( - req.user_id, - req.params.guild_id, - channel_id as string | undefined, - ); - permissions.hasThrow("VIEW_CHANNEL"); - if (!permissions.has("READ_MESSAGE_HISTORY")) - return res.json({ messages: [], total_results: 0 }); - - var query: FindManyOptions = { - order: { - timestamp: sort_order - ? (sort_order.toUpperCase() as "ASC" | "DESC") - : "DESC", - }, - take: parsedLimit || 0, - where: { - guild: { - id: req.params.guild_id, - }, - }, - relations: [ - "author", - "webhook", - "application", - "mentions", - "mention_roles", - "mention_channels", - "sticker_items", - "attachments", - ], - skip: offset ? Number(offset) : 0, - }; - //@ts-ignore - if (channel_id) query.where!.channel = { id: channel_id }; - else { - // get all channel IDs that this user can access - const channels = await Channel.find({ - where: { guild_id: req.params.guild_id }, - select: ["id"], - }); - const ids = []; - - for (var channel of channels) { - const perm = await getPermission( - req.user_id, - req.params.guild_id, - channel.id, - ); - if (!perm.has("VIEW_CHANNEL") || !perm.has("READ_MESSAGE_HISTORY")) - continue; - ids.push(channel.id); - } - - //@ts-ignore - query.where!.channel = { id: In(ids) }; - } - //@ts-ignore - if (author_id) query.where!.author = { id: author_id }; - //@ts-ignore - if (content) query.where!.content = Like(`%${content}%`); - - const messages: Message[] = await Message.find(query); - - const messagesDto = messages.map((x) => [ - { - id: x.id, - type: x.type, - content: x.content, - channel_id: x.channel_id, - author: { - id: x.author?.id, - username: x.author?.username, - avatar: x.author?.avatar, - avatar_decoration: null, - discriminator: x.author?.discriminator, - public_flags: x.author?.public_flags, - }, - attachments: x.attachments, - embeds: x.embeds, - mentions: x.mentions, - mention_roles: x.mention_roles, - pinned: x.pinned, - mention_everyone: x.mention_everyone, - tts: x.tts, - timestamp: x.timestamp, - edited_timestamp: x.edited_timestamp, - flags: x.flags, - components: x.components, - hit: true, - }, - ]); - - return res.json({ - messages: messagesDto, - total_results: messages.length, - }); -}); - -export default router; diff --git a/src/api/routes/guilds/#guild_id/premium.ts b/src/api/routes/guilds/#guild_id/premium.ts deleted file mode 100644 index 75361ac6..00000000 --- a/src/api/routes/guilds/#guild_id/premium.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Router, Request, Response } from "express"; -import { route } from "@fosscord/api"; -const router = Router(); - -router.get("/subscriptions", route({}), async (req: Request, res: Response) => { - // TODO: - res.json([]); -}); - -export default router; diff --git a/src/api/routes/guilds/#guild_id/profile/index.ts b/src/api/routes/guilds/#guild_id/profile/index.ts deleted file mode 100644 index 20a7fa95..00000000 --- a/src/api/routes/guilds/#guild_id/profile/index.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { route } from "@fosscord/api"; -import { - emitEvent, - GuildMemberUpdateEvent, - handleFile, - Member, - MemberChangeProfileSchema, - OrmUtils, -} from "@fosscord/util"; -import { Request, Response, Router } from "express"; - -const router = Router(); - -router.patch( - "/:member_id", - route({ body: "MemberChangeProfileSchema" }), - async (req: Request, res: Response) => { - let { guild_id, member_id } = req.params; - if (member_id === "@me") member_id = req.user_id; - const body = req.body as MemberChangeProfileSchema; - - let member = await Member.findOneOrFail({ - where: { id: req.user_id, guild_id }, - relations: ["roles", "user"], - }); - - if (body.banner) - body.banner = await handleFile( - `/guilds/${guild_id}/users/${req.user_id}/avatars`, - body.banner as string, - ); - - member = await OrmUtils.mergeDeep(member, body); - - await member.save(); - - // do not use promise.all as we have to first write to db before emitting the event to catch errors - await emitEvent({ - event: "GUILD_MEMBER_UPDATE", - guild_id, - data: { ...member, roles: member.roles.map((x) => x.id) }, - } as GuildMemberUpdateEvent); - - res.json(member); - }, -); - -export default router; diff --git a/src/api/routes/guilds/#guild_id/prune.ts b/src/api/routes/guilds/#guild_id/prune.ts deleted file mode 100644 index 8089ad84..00000000 --- a/src/api/routes/guilds/#guild_id/prune.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { Router, Request, Response } from "express"; -import { Guild, Member, Snowflake } from "@fosscord/util"; -import { LessThan, IsNull } from "typeorm"; -import { route } from "@fosscord/api"; -const router = Router(); - -//Returns all inactive members, respecting role hierarchy -export const inactiveMembers = async ( - guild_id: string, - user_id: string, - days: number, - roles: string[] = [], -) => { - var date = new Date(); - date.setDate(date.getDate() - days); - //Snowflake should have `generateFromTime` method? Or similar? - var minId = BigInt(date.valueOf() - Snowflake.EPOCH) << BigInt(22); - - /** - idea: ability to customise the cutoff variable - possible candidates: public read receipt, last presence, last VC leave - **/ - var members = await Member.find({ - where: [ - { - guild_id, - last_message_id: LessThan(minId.toString()), - }, - { - guild_id, - last_message_id: IsNull(), - }, - ], - relations: ["roles"], - }); - if (!members.length) return []; - - //I'm sure I can do this in the above db query ( and it would probably be better to do so ), but oh well. - if (roles.length && members.length) - members = members.filter((user) => - user.roles?.some((role) => roles.includes(role.id)), - ); - - const me = await Member.findOneOrFail({ - where: { id: user_id, guild_id }, - relations: ["roles"], - }); - const myHighestRole = Math.max(...(me.roles?.map((x) => x.position) || [])); - - const guild = await Guild.findOneOrFail({ where: { id: guild_id } }); - - members = members.filter( - (member) => - member.id !== guild.owner_id && //can't kick owner - member.roles?.some( - (role) => - role.position < myHighestRole || //roles higher than me can't be kicked - me.id === guild.owner_id, //owner can kick anyone - ), - ); - - return members; -}; - -router.get("/", route({}), async (req: Request, res: Response) => { - const days = parseInt(req.query.days as string); - - var roles = req.query.include_roles; - if (typeof roles === "string") roles = [roles]; //express will return array otherwise - - const members = await inactiveMembers( - req.params.guild_id, - req.user_id, - days, - roles as string[], - ); - - res.send({ pruned: members.length }); -}); - -router.post( - "/", - route({ permission: "KICK_MEMBERS", right: "KICK_BAN_MEMBERS" }), - async (req: Request, res: Response) => { - const days = parseInt(req.body.days); - - var roles = req.query.include_roles; - if (typeof roles === "string") roles = [roles]; - - const { guild_id } = req.params; - const members = await inactiveMembers( - guild_id, - req.user_id, - days, - roles as string[], - ); - - await Promise.all( - members.map((x) => Member.removeFromGuild(x.id, guild_id)), - ); - - res.send({ purged: members.length }); - }, -); - -export default router; diff --git a/src/api/routes/guilds/#guild_id/regions.ts b/src/api/routes/guilds/#guild_id/regions.ts deleted file mode 100644 index 0b275ea4..00000000 --- a/src/api/routes/guilds/#guild_id/regions.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Config, Guild, Member } from "@fosscord/util"; -import { Request, Response, Router } from "express"; -import { getVoiceRegions, route } from "@fosscord/api"; -import { getIpAdress } from "@fosscord/api"; - -const router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - const { guild_id } = req.params; - const guild = await Guild.findOneOrFail({ where: { id: guild_id } }); - //TODO we should use an enum for guild's features and not hardcoded strings - return res.json( - await getVoiceRegions( - getIpAdress(req), - guild.features.includes("VIP_REGIONS"), - ), - ); -}); - -export default router; diff --git a/src/api/routes/guilds/#guild_id/roles/#role_id/index.ts b/src/api/routes/guilds/#guild_id/roles/#role_id/index.ts deleted file mode 100644 index 84648703..00000000 --- a/src/api/routes/guilds/#guild_id/roles/#role_id/index.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { Router, Request, Response } from "express"; -import { - Role, - Member, - GuildRoleUpdateEvent, - GuildRoleDeleteEvent, - emitEvent, - handleFile, - RoleModifySchema, -} from "@fosscord/util"; -import { route } from "@fosscord/api"; -import { HTTPError } from "lambert-server"; - -const router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - const { guild_id, role_id } = req.params; - await Member.IsInGuildOrFail(req.user_id, guild_id); - const role = await Role.findOneOrFail({ where: { guild_id, id: role_id } }); - return res.json(role); -}); - -router.delete( - "/", - route({ permission: "MANAGE_ROLES" }), - async (req: Request, res: Response) => { - const { guild_id, role_id } = req.params; - if (role_id === guild_id) - throw new HTTPError("You can't delete the @everyone role"); - - await Promise.all([ - Role.delete({ - id: role_id, - guild_id: guild_id, - }), - emitEvent({ - event: "GUILD_ROLE_DELETE", - guild_id, - data: { - guild_id, - role_id, - }, - } as GuildRoleDeleteEvent), - ]); - - res.sendStatus(204); - }, -); - -// TODO: check role hierarchy - -router.patch( - "/", - route({ body: "RoleModifySchema", permission: "MANAGE_ROLES" }), - async (req: Request, res: Response) => { - const { role_id, guild_id } = req.params; - const body = req.body as RoleModifySchema; - - if (body.icon && body.icon.length) - body.icon = await handleFile( - `/role-icons/${role_id}`, - body.icon as string, - ); - else body.icon = undefined; - - const role = await Role.findOneOrFail({ - where: { id: role_id, guild: { id: guild_id } }, - }); - role.assign({ - ...body, - permissions: String( - req.permission!.bitfield & BigInt(body.permissions || "0"), - ), - }); - - await Promise.all([ - role.save(), - emitEvent({ - event: "GUILD_ROLE_UPDATE", - guild_id, - data: { - guild_id, - role, - }, - } as GuildRoleUpdateEvent), - ]); - - res.json(role); - }, -); - -export default router; diff --git a/src/api/routes/guilds/#guild_id/roles/index.ts b/src/api/routes/guilds/#guild_id/roles/index.ts deleted file mode 100644 index 4cd47cf3..00000000 --- a/src/api/routes/guilds/#guild_id/roles/index.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { Request, Response, Router } from "express"; -import { - Role, - getPermission, - Member, - GuildRoleCreateEvent, - GuildRoleUpdateEvent, - emitEvent, - Config, - DiscordApiErrors, - RoleModifySchema, - RolePositionUpdateSchema, - Snowflake, -} from "@fosscord/util"; -import { route } from "@fosscord/api"; -import { Not } from "typeorm"; - -const router: Router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - const guild_id = req.params.guild_id; - - await Member.IsInGuildOrFail(req.user_id, guild_id); - - const roles = await Role.find({ where: { guild_id: guild_id } }); - - return res.json(roles); -}); - -router.post( - "/", - route({ body: "RoleModifySchema", permission: "MANAGE_ROLES" }), - async (req: Request, res: Response) => { - const guild_id = req.params.guild_id; - const body = req.body as RoleModifySchema; - - const role_count = await Role.count({ where: { guild_id } }); - const { maxRoles } = Config.get().limits.guild; - - if (role_count > maxRoles) - throw DiscordApiErrors.MAXIMUM_ROLES.withParams(maxRoles); - - const role = Role.create({ - // values before ...body are default and can be overriden - position: 1, - hoist: false, - color: 0, - mentionable: false, - ...body, - guild_id: guild_id, - managed: false, - permissions: String( - req.permission!.bitfield & BigInt(body.permissions || "0"), - ), - tags: undefined, - icon: undefined, - unicode_emoji: undefined, - id: Snowflake.generate(), - }); - - await Promise.all([ - role.save(), - // Move all existing roles up one position, to accommodate the new role - Role.createQueryBuilder("roles") - .where({ - guild: { id: guild_id }, - name: Not("@everyone"), - id: Not(role.id), - }) - .update({ position: () => "position + 1" }) - .execute(), - emitEvent({ - event: "GUILD_ROLE_CREATE", - guild_id, - data: { - guild_id, - role: role, - }, - } as GuildRoleCreateEvent), - ]); - - res.json(role); - }, -); - -router.patch( - "/", - route({ body: "RolePositionUpdateSchema" }), - async (req: Request, res: Response) => { - const { guild_id } = req.params; - const body = req.body as RolePositionUpdateSchema; - - const perms = await getPermission(req.user_id, guild_id); - perms.hasThrow("MANAGE_ROLES"); - - await Promise.all( - body.map(async (x) => - Role.update({ guild_id, id: x.id }, { position: x.position }), - ), - ); - - const roles = await Role.find({ - where: body.map((x) => ({ id: x.id, guild_id })), - }); - - await Promise.all( - roles.map((x) => - emitEvent({ - event: "GUILD_ROLE_UPDATE", - guild_id, - data: { - guild_id, - role: x, - }, - } as GuildRoleUpdateEvent), - ), - ); - - res.json(roles); - }, -); - -export default router; diff --git a/src/api/routes/guilds/#guild_id/stickers.ts b/src/api/routes/guilds/#guild_id/stickers.ts deleted file mode 100644 index 3b1f5f8e..00000000 --- a/src/api/routes/guilds/#guild_id/stickers.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { - emitEvent, - GuildStickersUpdateEvent, - Member, - Snowflake, - Sticker, - StickerFormatType, - StickerType, - uploadFile, - ModifyGuildStickerSchema, -} from "@fosscord/util"; -import { Router, Request, Response } from "express"; -import { route } from "@fosscord/api"; -import multer from "multer"; -import { HTTPError } from "lambert-server"; -const router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - const { guild_id } = req.params; - await Member.IsInGuildOrFail(req.user_id, guild_id); - - res.json(await Sticker.find({ where: { guild_id } })); -}); - -const bodyParser = multer({ - limits: { - fileSize: 1024 * 1024 * 100, - fields: 10, - files: 1, - }, - storage: multer.memoryStorage(), -}).single("file"); - -router.post( - "/", - bodyParser, - route({ - permission: "MANAGE_EMOJIS_AND_STICKERS", - body: "ModifyGuildStickerSchema", - }), - async (req: Request, res: Response) => { - if (!req.file) throw new HTTPError("missing file"); - - const { guild_id } = req.params; - const body = req.body as ModifyGuildStickerSchema; - const id = Snowflake.generate(); - - const [sticker] = await Promise.all([ - Sticker.create({ - ...body, - guild_id, - id, - type: StickerType.GUILD, - format_type: getStickerFormat(req.file.mimetype), - available: true, - }).save(), - uploadFile(`/stickers/${id}`, req.file), - ]); - - await sendStickerUpdateEvent(guild_id); - - res.json(sticker); - }, -); - -export function getStickerFormat(mime_type: string) { - switch (mime_type) { - case "image/apng": - return StickerFormatType.APNG; - case "application/json": - return StickerFormatType.LOTTIE; - case "image/png": - return StickerFormatType.PNG; - case "image/gif": - return StickerFormatType.GIF; - default: - throw new HTTPError( - "invalid sticker format: must be png, apng or lottie", - ); - } -} - -router.get("/:sticker_id", route({}), async (req: Request, res: Response) => { - const { guild_id, sticker_id } = req.params; - await Member.IsInGuildOrFail(req.user_id, guild_id); - - res.json( - await Sticker.findOneOrFail({ where: { guild_id, id: sticker_id } }), - ); -}); - -router.patch( - "/:sticker_id", - route({ - body: "ModifyGuildStickerSchema", - permission: "MANAGE_EMOJIS_AND_STICKERS", - }), - async (req: Request, res: Response) => { - const { guild_id, sticker_id } = req.params; - const body = req.body as ModifyGuildStickerSchema; - - const sticker = await Sticker.create({ - ...body, - guild_id, - id: sticker_id, - }).save(); - await sendStickerUpdateEvent(guild_id); - - return res.json(sticker); - }, -); - -async function sendStickerUpdateEvent(guild_id: string) { - return emitEvent({ - event: "GUILD_STICKERS_UPDATE", - guild_id: guild_id, - data: { - guild_id: guild_id, - stickers: await Sticker.find({ where: { guild_id: guild_id } }), - }, - } as GuildStickersUpdateEvent); -} - -router.delete( - "/:sticker_id", - route({ permission: "MANAGE_EMOJIS_AND_STICKERS" }), - async (req: Request, res: Response) => { - const { guild_id, sticker_id } = req.params; - - await Sticker.delete({ guild_id, id: sticker_id }); - await sendStickerUpdateEvent(guild_id); - - return res.sendStatus(204); - }, -); - -export default router; diff --git a/src/api/routes/guilds/#guild_id/templates.ts b/src/api/routes/guilds/#guild_id/templates.ts deleted file mode 100644 index 3b5eddaa..00000000 --- a/src/api/routes/guilds/#guild_id/templates.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { Request, Response, Router } from "express"; -import { Guild, Template } from "@fosscord/util"; -import { HTTPError } from "lambert-server"; -import { route } from "@fosscord/api"; -import { generateCode } from "@fosscord/api"; - -const router: Router = Router(); - -const TemplateGuildProjection: (keyof Guild)[] = [ - "name", - "description", - "region", - "verification_level", - "default_message_notifications", - "explicit_content_filter", - "preferred_locale", - "afk_timeout", - "roles", - // "channels", - "afk_channel_id", - "system_channel_id", - "system_channel_flags", - "icon", -]; - -router.get("/", route({}), async (req: Request, res: Response) => { - const { guild_id } = req.params; - - var templates = await Template.find({ - where: { source_guild_id: guild_id }, - }); - - return res.json(templates); -}); - -router.post( - "/", - route({ body: "TemplateCreateSchema", permission: "MANAGE_GUILD" }), - async (req: Request, res: Response) => { - const { guild_id } = req.params; - const guild = await Guild.findOneOrFail({ - where: { id: guild_id }, - select: TemplateGuildProjection, - }); - const exists = await Template.findOneOrFail({ - where: { id: guild_id }, - }).catch((e) => {}); - if (exists) throw new HTTPError("Template already exists", 400); - - const template = await Template.create({ - ...req.body, - code: generateCode(), - creator_id: req.user_id, - created_at: new Date(), - updated_at: new Date(), - source_guild_id: guild_id, - serialized_source_guild: guild, - }).save(); - - res.json(template); - }, -); - -router.delete( - "/:code", - route({ permission: "MANAGE_GUILD" }), - async (req: Request, res: Response) => { - const { code, guild_id } = req.params; - - const template = await Template.delete({ - code, - source_guild_id: guild_id, - }); - - res.json(template); - }, -); - -router.put( - "/:code", - route({ permission: "MANAGE_GUILD" }), - async (req: Request, res: Response) => { - const { code, guild_id } = req.params; - const guild = await Guild.findOneOrFail({ - where: { id: guild_id }, - select: TemplateGuildProjection, - }); - - const template = await Template.create({ - code, - serialized_source_guild: guild, - }).save(); - - res.json(template); - }, -); - -router.patch( - "/:code", - route({ body: "TemplateModifySchema", permission: "MANAGE_GUILD" }), - async (req: Request, res: Response) => { - const { code, guild_id } = req.params; - const { name, description } = req.body; - - const template = await Template.create({ - code, - name: name, - description: description, - source_guild_id: guild_id, - }).save(); - - res.json(template); - }, -); - -export default router; diff --git a/src/api/routes/guilds/#guild_id/vanity-url.ts b/src/api/routes/guilds/#guild_id/vanity-url.ts deleted file mode 100644 index 9a96b066..00000000 --- a/src/api/routes/guilds/#guild_id/vanity-url.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { - Channel, - ChannelType, - Guild, - Invite, - VanityUrlSchema, -} from "@fosscord/util"; -import { Router, Request, Response } from "express"; -import { route } from "@fosscord/api"; -import { HTTPError } from "lambert-server"; - -const router = Router(); - -const InviteRegex = /\W/g; - -router.get( - "/", - route({ permission: "MANAGE_GUILD" }), - async (req: Request, res: Response) => { - const { guild_id } = req.params; - const guild = await Guild.findOneOrFail({ where: { id: guild_id } }); - - if (!guild.features.includes("ALIASABLE_NAMES")) { - const invite = await Invite.findOne({ - where: { guild_id: guild_id, vanity_url: true }, - }); - if (!invite) return res.json({ code: null }); - - return res.json({ code: invite.code, uses: invite.uses }); - } else { - const invite = await Invite.find({ - where: { guild_id: guild_id, vanity_url: true }, - }); - if (!invite || invite.length == 0) return res.json({ code: null }); - - return res.json( - invite.map((x) => ({ code: x.code, uses: x.uses })), - ); - } - }, -); - -router.patch( - "/", - route({ body: "VanityUrlSchema", permission: "MANAGE_GUILD" }), - async (req: Request, res: Response) => { - const { guild_id } = req.params; - const body = req.body as VanityUrlSchema; - const code = body.code?.replace(InviteRegex, ""); - - const guild = await Guild.findOneOrFail({ where: { id: guild_id } }); - if (!guild.features.includes("VANITY_URL")) - throw new HTTPError("Your guild doesn't support vanity urls"); - - if (!code || code.length === 0) - throw new HTTPError("Code cannot be null or empty"); - - const invite = await Invite.findOne({ where: { code } }); - if (invite) throw new HTTPError("Invite already exists"); - - const { id } = await Channel.findOneOrFail({ - where: { guild_id, type: ChannelType.GUILD_TEXT }, - }); - - await Invite.create({ - vanity_url: true, - code: code, - temporary: false, - uses: 0, - max_uses: 0, - max_age: 0, - created_at: new Date(), - expires_at: new Date(), - guild_id: guild_id, - channel_id: id, - }).save(); - - return res.json({ code: code }); - }, -); - -export default router; diff --git a/src/api/routes/guilds/#guild_id/voice-states/#user_id/index.ts b/src/api/routes/guilds/#guild_id/voice-states/#user_id/index.ts deleted file mode 100644 index af03a07e..00000000 --- a/src/api/routes/guilds/#guild_id/voice-states/#user_id/index.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { - Channel, - ChannelType, - DiscordApiErrors, - emitEvent, - getPermission, - VoiceState, - VoiceStateUpdateEvent, - VoiceStateUpdateSchema, -} from "@fosscord/util"; -import { route } from "@fosscord/api"; -import { Request, Response, Router } from "express"; - -const router = Router(); -//TODO need more testing when community guild and voice stage channel are working - -router.patch( - "/", - route({ body: "VoiceStateUpdateSchema" }), - async (req: Request, res: Response) => { - const body = req.body as VoiceStateUpdateSchema; - var { guild_id, user_id } = req.params; - if (user_id === "@me") user_id = req.user_id; - - const perms = await getPermission( - req.user_id, - guild_id, - body.channel_id, - ); - - /* - From https://discord.com/developers/docs/resources/guild#modify-current-user-voice-state - You must have the MUTE_MEMBERS permission to unsuppress others. You can always suppress yourself. - You must have the REQUEST_TO_SPEAK permission to request to speak. You can always clear your own request to speak. - */ - if (body.suppress && user_id !== req.user_id) { - perms.hasThrow("MUTE_MEMBERS"); - } - if (!body.suppress) body.request_to_speak_timestamp = new Date(); - if (body.request_to_speak_timestamp) perms.hasThrow("REQUEST_TO_SPEAK"); - - const voice_state = await VoiceState.findOne({ - where: { - guild_id, - channel_id: body.channel_id, - user_id, - }, - }); - if (!voice_state) throw DiscordApiErrors.UNKNOWN_VOICE_STATE; - - voice_state.assign(body); - const channel = await Channel.findOneOrFail({ - where: { guild_id, id: body.channel_id }, - }); - if (channel.type !== ChannelType.GUILD_STAGE_VOICE) { - throw DiscordApiErrors.CANNOT_EXECUTE_ON_THIS_CHANNEL_TYPE; - } - - await Promise.all([ - voice_state.save(), - emitEvent({ - event: "VOICE_STATE_UPDATE", - data: voice_state, - guild_id, - } as VoiceStateUpdateEvent), - ]); - return res.sendStatus(204); - }, -); - -export default router; diff --git a/src/api/routes/guilds/#guild_id/webhooks.ts b/src/api/routes/guilds/#guild_id/webhooks.ts deleted file mode 100644 index 9c4e8a8d..00000000 --- a/src/api/routes/guilds/#guild_id/webhooks.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Router, Response, Request } from "express"; -import { route } from "@fosscord/api"; -const router = Router(); - -//TODO: implement webhooks -router.get("/", route({}), async (req: Request, res: Response) => { - res.json([]); -}); -export default router; diff --git a/src/api/routes/guilds/#guild_id/welcome-screen.ts b/src/api/routes/guilds/#guild_id/welcome-screen.ts deleted file mode 100644 index 80ab138b..00000000 --- a/src/api/routes/guilds/#guild_id/welcome-screen.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Request, Response, Router } from "express"; -import { Guild, Member, GuildUpdateWelcomeScreenSchema } from "@fosscord/util"; -import { HTTPError } from "lambert-server"; -import { route } from "@fosscord/api"; - -const router: Router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - const guild_id = req.params.guild_id; - - const guild = await Guild.findOneOrFail({ where: { id: guild_id } }); - await Member.IsInGuildOrFail(req.user_id, guild_id); - - res.json(guild.welcome_screen); -}); - -router.patch( - "/", - route({ - body: "GuildUpdateWelcomeScreenSchema", - permission: "MANAGE_GUILD", - }), - async (req: Request, res: Response) => { - const guild_id = req.params.guild_id; - const body = req.body as GuildUpdateWelcomeScreenSchema; - - const guild = await Guild.findOneOrFail({ where: { id: guild_id } }); - - if (!guild.welcome_screen.enabled) - throw new HTTPError("Welcome screen disabled", 400); - if (body.welcome_channels) - guild.welcome_screen.welcome_channels = body.welcome_channels; // TODO: check if they exist and are valid - if (body.description) - guild.welcome_screen.description = body.description; - if (body.enabled != null) guild.welcome_screen.enabled = body.enabled; - - await guild.save(); - - res.sendStatus(204); - }, -); - -export default router; diff --git a/src/api/routes/guilds/#guild_id/widget.json.ts b/src/api/routes/guilds/#guild_id/widget.json.ts deleted file mode 100644 index 2c3124a2..00000000 --- a/src/api/routes/guilds/#guild_id/widget.json.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Request, Response, Router } from "express"; -import { - Config, - Permissions, - Guild, - Invite, - Channel, - Member, -} from "@fosscord/util"; -import { HTTPError } from "lambert-server"; -import { random, route } from "@fosscord/api"; - -const router: Router = Router(); - -// Undocumented API notes: -// An invite is created for the widget_channel_id on request (only if an existing one created by the widget doesn't already exist) -// This invite created doesn't include an inviter object like user created ones and has a default expiry of 24 hours -// Missing user object information is intentional (https://github.com/discord/discord-api-docs/issues/1287) -// channels returns voice channel objects where @everyone has the CONNECT permission -// members (max 100 returned) is a sample of all members, and bots par invisible status, there exists some alphabetical distribution pattern between the members returned - -// https://discord.com/developers/docs/resources/guild#get-guild-widget -// TODO: Cache the response for a guild for 5 minutes regardless of response -router.get("/", route({}), async (req: Request, res: Response) => { - const { guild_id } = req.params; - - const guild = await Guild.findOneOrFail({ where: { id: guild_id } }); - if (!guild.widget_enabled) throw new HTTPError("Widget Disabled", 404); - - // Fetch existing widget invite for widget channel - var invite = await Invite.findOne({ - where: { channel_id: guild.widget_channel_id }, - }); - - if (guild.widget_channel_id && !invite) { - // Create invite for channel if none exists - // TODO: Refactor invite create code to a shared function - const max_age = 86400; // 24 hours - const expires_at = new Date(max_age * 1000 + Date.now()); - - invite = await Invite.create({ - code: random(), - temporary: false, - uses: 0, - max_uses: 0, - max_age: max_age, - expires_at, - created_at: new Date(), - guild_id, - channel_id: guild.widget_channel_id, - }).save(); - } - - // Fetch voice channels, and the @everyone permissions object - const channels = [] as any[]; - - ( - await Channel.find({ - where: { guild_id: guild_id, type: 2 }, - order: { position: "ASC" }, - }) - ).filter((doc) => { - // Only return channels where @everyone has the CONNECT permission - if ( - doc.permission_overwrites === undefined || - Permissions.channelPermission( - doc.permission_overwrites, - Permissions.FLAGS.CONNECT, - ) === Permissions.FLAGS.CONNECT - ) { - channels.push({ - id: doc.id, - name: doc.name, - position: doc.position, - }); - } - }); - - // Fetch members - // TODO: Understand how Discord's max 100 random member sample works, and apply to here (see top of this file) - let members = await Member.find({ where: { guild_id: guild_id } }); - - // Construct object to respond with - const data = { - id: guild_id, - name: guild.name, - instant_invite: invite?.code, - channels: channels, - members: members, - presence_count: guild.presence_count, - }; - - res.set("Cache-Control", "public, max-age=300"); - return res.json(data); -}); - -export default router; diff --git a/src/api/routes/guilds/#guild_id/widget.png.ts b/src/api/routes/guilds/#guild_id/widget.png.ts deleted file mode 100644 index eaec8f07..00000000 --- a/src/api/routes/guilds/#guild_id/widget.png.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { Request, Response, Router } from "express"; -import { Guild } from "@fosscord/util"; -import { HTTPError } from "lambert-server"; -import { route } from "@fosscord/api"; -import fs from "fs"; -import path from "path"; - -const router: Router = Router(); - -// TODO: use svg templates instead of node-canvas for improved performance and to change it easily - -// https://discord.com/developers/docs/resources/guild#get-guild-widget-image -// TODO: Cache the response -router.get("/", route({}), async (req: Request, res: Response) => { - const { guild_id } = req.params; - - const guild = await Guild.findOneOrFail({ where: { id: guild_id } }); - if (!guild.widget_enabled) throw new HTTPError("Unknown Guild", 404); - - // Fetch guild information - const icon = guild.icon; - const name = guild.name; - const presence = guild.presence_count + " ONLINE"; - - // Fetch parameter - const style = req.query.style?.toString() || "shield"; - if ( - !["shield", "banner1", "banner2", "banner3", "banner4"].includes(style) - ) { - throw new HTTPError( - "Value must be one of ('shield', 'banner1', 'banner2', 'banner3', 'banner4').", - 400, - ); - } - - // Setup canvas - const { createCanvas } = require("canvas"); - const { loadImage } = require("canvas"); - const sizeOf = require("image-size"); - - // TODO: Widget style templates need Fosscord branding - const source = path.join( - __dirname, - "..", - "..", - "..", - "..", - "..", - "assets", - "widget", - `${style}.png`, - ); - if (!fs.existsSync(source)) { - throw new HTTPError("Widget template does not exist.", 400); - } - - // Create base template image for parameter - const { width, height } = await sizeOf(source); - const canvas = createCanvas(width, height); - const ctx = canvas.getContext("2d"); - const template = await loadImage(source); - ctx.drawImage(template, 0, 0); - - // Add the guild specific information to the template asset image - switch (style) { - case "shield": - ctx.textAlign = "center"; - await drawText( - ctx, - 73, - 13, - "#FFFFFF", - "thin 10px Verdana", - presence, - ); - break; - case "banner1": - if (icon) await drawIcon(ctx, 20, 27, 50, icon); - await drawText(ctx, 83, 51, "#FFFFFF", "12px Verdana", name, 22); - await drawText( - ctx, - 83, - 66, - "#C9D2F0FF", - "thin 11px Verdana", - presence, - ); - break; - case "banner2": - if (icon) await drawIcon(ctx, 13, 19, 36, icon); - await drawText(ctx, 62, 34, "#FFFFFF", "12px Verdana", name, 15); - await drawText( - ctx, - 62, - 49, - "#C9D2F0FF", - "thin 11px Verdana", - presence, - ); - break; - case "banner3": - if (icon) await drawIcon(ctx, 20, 20, 50, icon); - await drawText(ctx, 83, 44, "#FFFFFF", "12px Verdana", name, 27); - await drawText( - ctx, - 83, - 58, - "#C9D2F0FF", - "thin 11px Verdana", - presence, - ); - break; - case "banner4": - if (icon) await drawIcon(ctx, 21, 136, 50, icon); - await drawText(ctx, 84, 156, "#FFFFFF", "13px Verdana", name, 27); - await drawText( - ctx, - 84, - 171, - "#C9D2F0FF", - "thin 12px Verdana", - presence, - ); - break; - default: - throw new HTTPError( - "Value must be one of ('shield', 'banner1', 'banner2', 'banner3', 'banner4').", - 400, - ); - } - - // Return final image - const buffer = canvas.toBuffer("image/png"); - res.set("Content-Type", "image/png"); - res.set("Cache-Control", "public, max-age=3600"); - return res.send(buffer); -}); - -async function drawIcon( - canvas: any, - x: number, - y: number, - scale: number, - icon: string, -) { - // @ts-ignore - const img = new require("canvas").Image(); - img.src = icon; - - // Do some canvas clipping magic! - canvas.save(); - canvas.beginPath(); - - const r = scale / 2; // use scale to determine radius - canvas.arc(x + r, y + r, r, 0, 2 * Math.PI, false); // start circle at x, and y coords + radius to find center - - canvas.clip(); - canvas.drawImage(img, x, y, scale, scale); - - canvas.restore(); -} - -async function drawText( - canvas: any, - x: number, - y: number, - color: string, - font: string, - text: string, - maxcharacters?: number, -) { - canvas.fillStyle = color; - canvas.font = font; - if (text.length > (maxcharacters || 0) && maxcharacters) - text = text.slice(0, maxcharacters) + "..."; - canvas.fillText(text, x, y); -} - -export default router; diff --git a/src/api/routes/guilds/#guild_id/widget.ts b/src/api/routes/guilds/#guild_id/widget.ts deleted file mode 100644 index 108339e1..00000000 --- a/src/api/routes/guilds/#guild_id/widget.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Request, Response, Router } from "express"; -import { Guild, WidgetModifySchema } from "@fosscord/util"; -import { route } from "@fosscord/api"; - -const router: Router = Router(); - -// https://discord.com/developers/docs/resources/guild#get-guild-widget-settings -router.get("/", route({}), async (req: Request, res: Response) => { - const { guild_id } = req.params; - - const guild = await Guild.findOneOrFail({ where: { id: guild_id } }); - - return res.json({ - enabled: guild.widget_enabled || false, - channel_id: guild.widget_channel_id || null, - }); -}); - -// https://discord.com/developers/docs/resources/guild#modify-guild-widget -router.patch( - "/", - route({ body: "WidgetModifySchema", permission: "MANAGE_GUILD" }), - async (req: Request, res: Response) => { - const body = req.body as WidgetModifySchema; - const { guild_id } = req.params; - - await Guild.update( - { id: guild_id }, - { - widget_enabled: body.enabled, - widget_channel_id: body.channel_id, - }, - ); - // Widget invite for the widget_channel_id gets created as part of the /guilds/{guild.id}/widget.json request - - return res.json(body); - }, -); - -export default router; diff --git a/src/api/routes/guilds/index.ts b/src/api/routes/guilds/index.ts deleted file mode 100644 index 69575aea..00000000 --- a/src/api/routes/guilds/index.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Router, Request, Response } from "express"; -import { - Role, - Guild, - Config, - getRights, - Member, - DiscordApiErrors, - GuildCreateSchema, -} from "@fosscord/util"; -import { route } from "@fosscord/api"; - -const router: Router = Router(); - -//TODO: create default channel - -router.post( - "/", - route({ body: "GuildCreateSchema", right: "CREATE_GUILDS" }), - async (req: Request, res: Response) => { - const body = req.body as GuildCreateSchema; - - const { maxGuilds } = Config.get().limits.user; - const guild_count = await Member.count({ where: { id: req.user_id } }); - const rights = await getRights(req.user_id); - if (guild_count >= maxGuilds && !rights.has("MANAGE_GUILDS")) { - throw DiscordApiErrors.MAXIMUM_GUILDS.withParams(maxGuilds); - } - - const guild = await Guild.createGuild({ - ...body, - owner_id: req.user_id, - }); - - const { autoJoin } = Config.get().guild; - if (autoJoin.enabled && !autoJoin.guilds?.length) { - // @ts-ignore - await Config.set({ guild: { autoJoin: { guilds: [guild.id] } } }); - } - - await Member.addToGuild(req.user_id, guild.id); - - res.status(201).json({ id: guild.id }); - }, -); - -export default router; diff --git a/src/api/routes/guilds/templates/index.ts b/src/api/routes/guilds/templates/index.ts deleted file mode 100644 index 240bf074..00000000 --- a/src/api/routes/guilds/templates/index.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { Request, Response, Router } from "express"; -import { - Template, - Guild, - Role, - Snowflake, - Config, - Member, - GuildTemplateCreateSchema, -} from "@fosscord/util"; -import { route } from "@fosscord/api"; -import { DiscordApiErrors } from "@fosscord/util"; -import fetch from "node-fetch"; -const router: Router = Router(); - -router.get("/:code", route({}), async (req: Request, res: Response) => { - const { allowDiscordTemplates, allowRaws, enabled } = - Config.get().templates; - if (!enabled) - res.json({ - code: 403, - message: "Template creation & usage is disabled on this instance.", - }).sendStatus(403); - - const { code } = req.params; - - if (code.startsWith("discord:")) { - if (!allowDiscordTemplates) - return res - .json({ - code: 403, - message: - "Discord templates cannot be used on this instance.", - }) - .sendStatus(403); - const discordTemplateID = code.split("discord:", 2)[1]; - - const discordTemplateData = await fetch( - `https://discord.com/api/v9/guilds/templates/${discordTemplateID}`, - { - method: "get", - headers: { "Content-Type": "application/json" }, - }, - ); - return res.json(await discordTemplateData.json()); - } - - if (code.startsWith("external:")) { - if (!allowRaws) - return res - .json({ - code: 403, - message: "Importing raws is disabled on this instance.", - }) - .sendStatus(403); - - return res.json(code.split("external:", 2)[1]); - } - - const template = await Template.findOneOrFail({ where: { code: code } }); - res.json(template); -}); - -router.post( - "/:code", - route({ body: "GuildTemplateCreateSchema" }), - async (req: Request, res: Response) => { - const { - enabled, - allowTemplateCreation, - allowDiscordTemplates, - allowRaws, - } = Config.get().templates; - if (!enabled) - return res - .json({ - code: 403, - message: - "Template creation & usage is disabled on this instance.", - }) - .sendStatus(403); - if (!allowTemplateCreation) - return res - .json({ - code: 403, - message: "Template creation is disabled on this instance.", - }) - .sendStatus(403); - - const { code } = req.params; - const body = req.body as GuildTemplateCreateSchema; - - const { maxGuilds } = Config.get().limits.user; - - const guild_count = await Member.count({ where: { id: req.user_id } }); - if (guild_count >= maxGuilds) { - throw DiscordApiErrors.MAXIMUM_GUILDS.withParams(maxGuilds); - } - - const template = await Template.findOneOrFail({ - where: { code: code }, - }); - - const guild_id = Snowflake.generate(); - - const [guild, role] = await Promise.all([ - Guild.create({ - ...body, - ...template.serialized_source_guild, - id: guild_id, - owner_id: req.user_id, - }).save(), - Role.create({ - id: guild_id, - guild_id: guild_id, - color: 0, - hoist: false, - managed: true, - mentionable: true, - name: "@everyone", - permissions: BigInt("2251804225").toString(), // TODO: where did this come from? - position: 0, - }).save(), - ]); - - await Member.addToGuild(req.user_id, guild_id); - - res.status(201).json({ id: guild.id }); - }, -); - -export default router; diff --git a/src/api/routes/invites/index.ts b/src/api/routes/invites/index.ts deleted file mode 100644 index ce0ba982..00000000 --- a/src/api/routes/invites/index.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { Router, Request, Response } from "express"; -import { - emitEvent, - getPermission, - Guild, - Invite, - InviteDeleteEvent, - User, - PublicInviteRelation, -} from "@fosscord/util"; -import { route } from "@fosscord/api"; -import { HTTPError } from "lambert-server"; - -const router: Router = Router(); - -router.get("/:code", route({}), async (req: Request, res: Response) => { - const { code } = req.params; - - const invite = await Invite.findOneOrFail({ - where: { code }, - relations: PublicInviteRelation, - }); - - res.status(200).send(invite); -}); - -router.post( - "/:code", - route({ right: "USE_MASS_INVITES" }), - async (req: Request, res: Response) => { - const { code } = req.params; - const { guild_id } = await Invite.findOneOrFail({ - where: { code: code }, - }); - const { features } = await Guild.findOneOrFail({ - where: { id: guild_id }, - }); - const { public_flags } = await User.findOneOrFail({ - where: { id: req.user_id }, - }); - - if ( - features.includes("INTERNAL_EMPLOYEE_ONLY") && - (public_flags & 1) !== 1 - ) - throw new HTTPError( - "Only intended for the staff of this server.", - 401, - ); - if (features.includes("INVITES_CLOSED")) - throw new HTTPError("Sorry, this guild has joins closed.", 403); - - const invite = await Invite.joinGuild(req.user_id, code); - - res.json(invite); - }, -); - -// * cant use permission of route() function because path doesn't have guild_id/channel_id -router.delete("/:code", route({}), async (req: Request, res: Response) => { - const { code } = req.params; - const invite = await Invite.findOneOrFail({ where: { code } }); - const { guild_id, channel_id } = invite; - - const permission = await getPermission(req.user_id, guild_id, channel_id); - - if (!permission.has("MANAGE_GUILD") && !permission.has("MANAGE_CHANNELS")) - throw new HTTPError( - "You missing the MANAGE_GUILD or MANAGE_CHANNELS permission", - 401, - ); - - await Promise.all([ - Invite.delete({ code }), - emitEvent({ - event: "INVITE_DELETE", - guild_id: guild_id, - data: { - channel_id: channel_id, - guild_id: guild_id, - code: code, - }, - } as InviteDeleteEvent), - ]); - - res.json({ invite: invite }); -}); - -export default router; diff --git a/src/api/routes/oauth2/authorize.ts b/src/api/routes/oauth2/authorize.ts deleted file mode 100644 index 6374972e..00000000 --- a/src/api/routes/oauth2/authorize.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { Router, Request, Response } from "express"; -import { route } from "@fosscord/api"; -import { - ApiError, - Application, - ApplicationAuthorizeSchema, - getPermission, - DiscordApiErrors, - Member, - Permissions, - User, - getRights, - Rights, - MemberPrivateProjection, -} from "@fosscord/util"; -const router = Router(); - -// TODO: scopes, other oauth types - -router.get("/", route({}), async (req: Request, res: Response) => { - const { client_id, scope, response_type, redirect_url } = req.query; - - const app = await Application.findOne({ - where: { - id: client_id as string, - }, - relations: ["bot"], - }); - - // TODO: use DiscordApiErrors - // findOneOrFail throws code 404 - if (!app) throw DiscordApiErrors.UNKNOWN_APPLICATION; - if (!app.bot) throw DiscordApiErrors.OAUTH2_APPLICATION_BOT_ABSENT; - - const bot = app.bot; - delete app.bot; - - const user = await User.findOneOrFail({ - where: { - id: req.user_id, - bot: false, - }, - select: ["id", "username", "avatar", "discriminator", "public_flags"], - }); - - const guilds = await Member.find({ - where: { - user: { - id: req.user_id, - }, - }, - relations: ["guild", "roles"], - //@ts-ignore - // prettier-ignore - select: ["guild.id", "guild.name", "guild.icon", "guild.mfa_level", "guild.owner_id", "roles.id"], - }); - - const guildsWithPermissions = guilds.map((x) => { - const perms = - x.guild.owner_id === user.id - ? new Permissions(Permissions.FLAGS.ADMINISTRATOR) - : Permissions.finalPermission({ - user: { - id: user.id, - roles: x.roles?.map((x) => x.id) || [], - }, - guild: { - roles: x?.roles || [], - }, - }); - - return { - id: x.guild.id, - name: x.guild.name, - icon: x.guild.icon, - mfa_level: x.guild.mfa_level, - permissions: perms.bitfield.toString(), - }; - }); - - return res.json({ - guilds: guildsWithPermissions, - user: { - id: user.id, - username: user.username, - avatar: user.avatar, - avatar_decoration: null, // TODO - discriminator: user.discriminator, - public_flags: user.public_flags, - }, - application: { - id: app.id, - name: app.name, - icon: app.icon, - description: app.description, - summary: app.summary, - type: app.type, - hook: app.hook, - guild_id: null, // TODO support guilds - bot_public: app.bot_public, - bot_require_code_grant: app.bot_require_code_grant, - verify_key: app.verify_key, - flags: app.flags, - }, - bot: { - id: bot.id, - username: bot.username, - avatar: bot.avatar, - avatar_decoration: null, // TODO - discriminator: bot.discriminator, - public_flags: bot.public_flags, - bot: true, - approximated_guild_count: 0, // TODO - }, - authorized: false, - }); -}); - -router.post( - "/", - route({ body: "ApplicationAuthorizeSchema" }), - async (req: Request, res: Response) => { - const body = req.body as ApplicationAuthorizeSchema; - const { client_id, scope, response_type, redirect_url } = req.query; - - // TODO: captcha verification - // TODO: MFA verification - - const perms = await getPermission( - req.user_id, - body.guild_id, - undefined, - { member_relations: ["user"] }, - ); - // getPermission cache won't exist if we're owner - if ( - Object.keys(perms.cache || {}).length > 0 && - perms.cache.member!.user.bot - ) - throw DiscordApiErrors.UNAUTHORIZED; - perms.hasThrow("MANAGE_GUILD"); - - const app = await Application.findOne({ - where: { - id: client_id as string, - }, - relations: ["bot"], - }); - - // TODO: use DiscordApiErrors - // findOneOrFail throws code 404 - if (!app) throw new ApiError("Unknown Application", 10002, 404); - if (!app.bot) - throw new ApiError( - "OAuth2 application does not have a bot", - 50010, - 400, - ); - - await Member.addToGuild(app.id, body.guild_id); - - return res.json({ - location: "/oauth2/authorized", // redirect URL - }); - }, -); - -export default router; diff --git a/src/api/routes/oauth2/tokens.ts b/src/api/routes/oauth2/tokens.ts deleted file mode 100644 index bd284221..00000000 --- a/src/api/routes/oauth2/tokens.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Router, Request, Response } from "express"; -import { route } from "@fosscord/api"; -const router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - //TODO - res.json([]); -}); - -export default router; diff --git a/src/api/routes/outbound-promotions.ts b/src/api/routes/outbound-promotions.ts deleted file mode 100644 index 411e95bf..00000000 --- a/src/api/routes/outbound-promotions.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Request, Response, Router } from "express"; -import { route } from "@fosscord/api"; - -const router: Router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - //TODO - res.json([]).status(200); -}); - -export default router; diff --git a/src/api/routes/partners/#guild_id/requirements.ts b/src/api/routes/partners/#guild_id/requirements.ts deleted file mode 100644 index 7e63c06b..00000000 --- a/src/api/routes/partners/#guild_id/requirements.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Guild, Config } from "@fosscord/util"; - -import { Router, Request, Response } from "express"; -import { route } from "@fosscord/api"; - -const router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - const { guild_id } = req.params; - // TODO: - // Load from database - // Admin control, but for now it allows anyone to be discoverable - - res.send({ - guild_id: guild_id, - safe_environment: true, - healthy: true, - health_score_pending: false, - size: true, - nsfw_properties: {}, - protected: true, - sufficient: true, - sufficient_without_grace_period: true, - valid_rules_channel: true, - retention_healthy: true, - engagement_healthy: true, - age: true, - minimum_age: 0, - health_score: { - avg_nonnew_participators: 0, - avg_nonnew_communicators: 0, - num_intentful_joiners: 0, - perc_ret_w1_intentful: 0, - }, - minimum_size: 0, - }); -}); - -export default router; diff --git a/src/api/routes/ping.ts b/src/api/routes/ping.ts deleted file mode 100644 index 3c1da2c3..00000000 --- a/src/api/routes/ping.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Router, Response, Request } from "express"; -import { route } from "@fosscord/api"; -import { Config } from "@fosscord/util"; - -const router = Router(); - -router.get("/", route({}), (req: Request, res: Response) => { - const { general } = Config.get(); - res.send({ - ping: "pong!", - instance: { - id: general.instanceId, - name: general.instanceName, - description: general.instanceDescription, - image: general.image, - - correspondenceEmail: general.correspondenceEmail, - correspondenceUserID: general.correspondenceUserID, - - frontPage: general.frontPage, - tosPage: general.tosPage, - }, - }); -}); - -export default router; diff --git a/src/api/routes/policies/instance/domains.ts b/src/api/routes/policies/instance/domains.ts deleted file mode 100644 index f22eac17..00000000 --- a/src/api/routes/policies/instance/domains.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Router, Request, Response } from "express"; -import { route } from "@fosscord/api"; -import { Config } from "@fosscord/util"; -import { config } from "dotenv"; -const router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - const { cdn, gateway } = Config.get(); - - const IdentityForm = { - cdn: cdn.endpointPublic || process.env.CDN || "http://localhost:3001", - gateway: - gateway.endpointPublic || - process.env.GATEWAY || - "ws://localhost:3002", - }; - - res.json(IdentityForm); -}); - -export default router; diff --git a/src/api/routes/policies/instance/index.ts b/src/api/routes/policies/instance/index.ts deleted file mode 100644 index 1c1afa09..00000000 --- a/src/api/routes/policies/instance/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Router, Request, Response } from "express"; -import { route } from "@fosscord/api"; -import { Config } from "@fosscord/util"; -const router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - const { general } = Config.get(); - res.json(general); -}); - -export default router; diff --git a/src/api/routes/policies/instance/limits.ts b/src/api/routes/policies/instance/limits.ts deleted file mode 100644 index 06f14f83..00000000 --- a/src/api/routes/policies/instance/limits.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Router, Request, Response } from "express"; -import { route } from "@fosscord/api"; -import { Config } from "@fosscord/util"; -const router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - const { limits } = Config.get(); - res.json(limits); -}); - -export default router; diff --git a/src/api/routes/policies/stats.ts b/src/api/routes/policies/stats.ts deleted file mode 100644 index dc4652fc..00000000 --- a/src/api/routes/policies/stats.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { route } from "@fosscord/api"; -import { - Config, - getRights, - Guild, - Member, - Message, - User, -} from "@fosscord/util"; -import { Request, Response, Router } from "express"; -const router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - if (!Config.get().security.statsWorldReadable) { - const rights = await getRights(req.user_id); - rights.hasThrow("VIEW_SERVER_STATS"); - } - - res.json({ - counts: { - user: await User.count(), - guild: await Guild.count(), - message: await Message.count(), - members: await Member.count(), - }, - }); -}); - -export default router; diff --git a/src/api/routes/scheduled-maintenances/upcoming_json.ts b/src/api/routes/scheduled-maintenances/upcoming_json.ts deleted file mode 100644 index e42723a1..00000000 --- a/src/api/routes/scheduled-maintenances/upcoming_json.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Router, Request, Response } from "express"; -import { route } from "@fosscord/api"; -const router = Router(); - -router.get( - "/scheduled-maintenances/upcoming.json", - route({}), - async (req: Request, res: Response) => { - res.json({ - page: {}, - scheduled_maintenances: {}, - }); - }, -); - -export default router; diff --git a/src/api/routes/science.ts b/src/api/routes/science.ts deleted file mode 100644 index 8556a3ad..00000000 --- a/src/api/routes/science.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Router, Response, Request } from "express"; -import { route } from "@fosscord/api"; - -const router = Router(); - -router.post("/", route({}), (req: Request, res: Response) => { - // TODO: - res.sendStatus(204); -}); - -export default router; diff --git a/src/api/routes/stage-instances.ts b/src/api/routes/stage-instances.ts deleted file mode 100644 index 411e95bf..00000000 --- a/src/api/routes/stage-instances.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Request, Response, Router } from "express"; -import { route } from "@fosscord/api"; - -const router: Router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - //TODO - res.json([]).status(200); -}); - -export default router; diff --git a/src/api/routes/sticker-packs/index.ts b/src/api/routes/sticker-packs/index.ts deleted file mode 100644 index e6560d12..00000000 --- a/src/api/routes/sticker-packs/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Request, Response, Router } from "express"; -import { route } from "@fosscord/api"; -import { StickerPack } from "@fosscord/util"; - -const router: Router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - const sticker_packs = await StickerPack.find({ relations: ["stickers"] }); - - res.json({ sticker_packs }); -}); - -export default router; diff --git a/src/api/routes/stickers/#sticker_id/index.ts b/src/api/routes/stickers/#sticker_id/index.ts deleted file mode 100644 index b484a7a1..00000000 --- a/src/api/routes/stickers/#sticker_id/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Sticker } from "@fosscord/util"; -import { Router, Request, Response } from "express"; -import { route } from "@fosscord/api"; -const router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - const { sticker_id } = req.params; - - res.json(await Sticker.find({ where: { id: sticker_id } })); -}); - -export default router; diff --git a/src/api/routes/stop.ts b/src/api/routes/stop.ts deleted file mode 100644 index 3f49b360..00000000 --- a/src/api/routes/stop.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Router, Request, Response } from "express"; -import { route } from "@fosscord/api"; - -const router: Router = Router(); - -router.post( - "/", - route({ right: "OPERATOR" }), - async (req: Request, res: Response) => { - console.log(`/stop was called by ${req.user_id} at ${new Date()}`); - res.sendStatus(200); - process.kill(process.pid, "SIGTERM"); - }, -); - -export default router; diff --git a/src/api/routes/store/published-listings/applications.ts b/src/api/routes/store/published-listings/applications.ts deleted file mode 100644 index 6156f43e..00000000 --- a/src/api/routes/store/published-listings/applications.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { Request, Response, Router } from "express"; -import { route } from "@fosscord/api"; - -const router: Router = Router(); - -router.get("/:id", route({}), async (req: Request, res: Response) => { - //TODO - const id = req.params.id; - res.json({ - id: "", - summary: "", - sku: { - id: "", - type: 1, - dependent_sku_id: null, - application_id: "", - manifets_labels: [], - access_type: 2, - name: "", - features: [], - release_date: "", - premium: false, - slug: "", - flags: 4, - genres: [], - legal_notice: "", - application: { - id: "", - name: "", - icon: "", - description: "", - summary: "", - cover_image: "", - primary_sku_id: "", - hook: true, - slug: "", - guild_id: "", - bot_public: "", - bot_require_code_grant: false, - verify_key: "", - publishers: [ - { - id: "", - name: "", - }, - ], - developers: [ - { - id: "", - name: "", - }, - ], - system_requirements: {}, - show_age_gate: false, - price: { - amount: 0, - currency: "EUR", - }, - locales: [], - }, - tagline: "", - description: "", - carousel_items: [ - { - asset_id: "", - }, - ], - header_logo_dark_theme: {}, //{id: "", size: 4665, mime_type: "image/gif", width 160, height: 160} - header_logo_light_theme: {}, - box_art: {}, - thumbnail: {}, - header_background: {}, - hero_background: {}, - assets: [], - }, - }).status(200); -}); - -export default router; diff --git a/src/api/routes/store/published-listings/applications/#id/subscription-plans.ts b/src/api/routes/store/published-listings/applications/#id/subscription-plans.ts deleted file mode 100644 index 845cdfe7..00000000 --- a/src/api/routes/store/published-listings/applications/#id/subscription-plans.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Request, Response, Router } from "express"; -import { route } from "@fosscord/api"; - -const router: Router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - //TODO - res.json([ - { - id: "", - name: "", - interval: 1, - interval_count: 1, - tax_inclusive: true, - sku_id: "", - fallback_price: 499, - fallback_currency: "eur", - currency: "eur", - price: 4199, - price_tier: null, - }, - ]).status(200); -}); - -export default router; diff --git a/src/api/routes/store/published-listings/skus.ts b/src/api/routes/store/published-listings/skus.ts deleted file mode 100644 index 6156f43e..00000000 --- a/src/api/routes/store/published-listings/skus.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { Request, Response, Router } from "express"; -import { route } from "@fosscord/api"; - -const router: Router = Router(); - -router.get("/:id", route({}), async (req: Request, res: Response) => { - //TODO - const id = req.params.id; - res.json({ - id: "", - summary: "", - sku: { - id: "", - type: 1, - dependent_sku_id: null, - application_id: "", - manifets_labels: [], - access_type: 2, - name: "", - features: [], - release_date: "", - premium: false, - slug: "", - flags: 4, - genres: [], - legal_notice: "", - application: { - id: "", - name: "", - icon: "", - description: "", - summary: "", - cover_image: "", - primary_sku_id: "", - hook: true, - slug: "", - guild_id: "", - bot_public: "", - bot_require_code_grant: false, - verify_key: "", - publishers: [ - { - id: "", - name: "", - }, - ], - developers: [ - { - id: "", - name: "", - }, - ], - system_requirements: {}, - show_age_gate: false, - price: { - amount: 0, - currency: "EUR", - }, - locales: [], - }, - tagline: "", - description: "", - carousel_items: [ - { - asset_id: "", - }, - ], - header_logo_dark_theme: {}, //{id: "", size: 4665, mime_type: "image/gif", width 160, height: 160} - header_logo_light_theme: {}, - box_art: {}, - thumbnail: {}, - header_background: {}, - hero_background: {}, - assets: [], - }, - }).status(200); -}); - -export default router; diff --git a/src/api/routes/store/published-listings/skus/#sku_id/subscription-plans.ts b/src/api/routes/store/published-listings/skus/#sku_id/subscription-plans.ts deleted file mode 100644 index 6b49e959..00000000 --- a/src/api/routes/store/published-listings/skus/#sku_id/subscription-plans.ts +++ /dev/null @@ -1,313 +0,0 @@ -import { Request, Response, Router } from "express"; -import { route } from "@fosscord/api"; - -const router: Router = Router(); - -const skus = new Map([ - [ - "521842865731534868", - [ - { - id: "511651856145973248", - name: "Individual Premium Tier 3 Monthly (Legacy)", - interval: 1, - interval_count: 1, - tax_inclusive: true, - sku_id: "521842865731534868", - currency: "eur", - price: 0, - price_tier: null, - }, - { - id: "511651860671627264", - name: "Individiual Premium Tier 3 Yearly (Legacy)", - interval: 2, - interval_count: 1, - tax_inclusive: true, - sku_id: "521842865731534868", - currency: "eur", - price: 0, - price_tier: null, - }, - ], - ], - [ - "521846918637420545", - [ - { - id: "511651871736201216", - name: "Individual Premium Tier 2 Monthly", - interval: 1, - interval_count: 1, - tax_inclusive: true, - sku_id: "521846918637420545", - currency: "eur", - price: 0, - price_tier: null, - }, - { - id: "511651876987469824", - name: "Individual Premum Tier 2 Yearly", - interval: 2, - interval_count: 1, - tax_inclusive: true, - sku_id: "521846918637420545", - currency: "eur", - price: 0, - price_tier: null, - }, - { - id: "978380684370378761", - name: "Individual Premum Tier 1", - interval: 2, - interval_count: 1, - tax_inclusive: true, - sku_id: "521846918637420545", - currency: "eur", - price: 0, - price_tier: null, - }, - ], - ], - [ - "521847234246082599", - [ - { - id: "642251038925127690", - name: "Individual Premium Tier 3 Quarterly", - interval: 1, - interval_count: 3, - tax_inclusive: true, - sku_id: "521847234246082599", - currency: "eur", - price: 0, - price_tier: null, - }, - { - id: "511651880837840896", - name: "Individual Premium Tier 3 Monthly", - interval: 1, - interval_count: 1, - tax_inclusive: true, - sku_id: "521847234246082599", - currency: "eur", - price: 0, - price_tier: null, - }, - { - id: "511651885459963904", - name: "Individual Premium Tier 3 Yearly", - interval: 2, - interval_count: 1, - tax_inclusive: true, - sku_id: "521847234246082599", - currency: "eur", - price: 0, - price_tier: null, - }, - ], - ], - [ - "590663762298667008", - [ - { - id: "590665532894740483", - name: "Crowd Premium Monthly", - interval: 1, - interval_count: 1, - tax_inclusive: true, - sku_id: "590663762298667008", - discount_price: 0, - currency: "eur", - price: 0, - price_tier: null, - }, - { - id: "590665538238152709", - name: "Crowd Premium Yearly", - interval: 2, - interval_count: 1, - tax_inclusive: true, - sku_id: "590663762298667008", - discount_price: 0, - currency: "eur", - price: 0, - price_tier: null, - }, - ], - ], - [ - "978380684370378762", - [ - [ - { - id: "978380692553465866", - name: "Premium Tier 0 Monthly", - interval: 1, - interval_count: 1, - tax_inclusive: true, - sku_id: "978380684370378762", - currency: "usd", - price: 299, - price_tier: null, - prices: { - "0": { - country_prices: { - country_code: "US", - prices: [ - { - currency: "usd", - amount: 0, - exponent: 2, - }, - ], - }, - payment_source_prices: { - "775487223059316758": [ - { - currency: "usd", - amount: 0, - exponent: 2, - }, - ], - "736345864146255982": [ - { - currency: "usd", - amount: 0, - exponent: 2, - }, - ], - "683074999590060249": [ - { - currency: "usd", - amount: 0, - exponent: 2, - }, - ], - }, - }, - "3": { - country_prices: { - country_code: "US", - prices: [ - { - currency: "usd", - amount: 0, - exponent: 2, - }, - ], - }, - payment_source_prices: { - "775487223059316758": [ - { - currency: "usd", - amount: 0, - exponent: 2, - }, - ], - "736345864146255982": [ - { - currency: "usd", - amount: 0, - exponent: 2, - }, - ], - "683074999590060249": [ - { - currency: "usd", - amount: 0, - exponent: 2, - }, - ], - }, - }, - "4": { - country_prices: { - country_code: "US", - prices: [ - { - currency: "usd", - amount: 0, - exponent: 2, - }, - ], - }, - payment_source_prices: { - "775487223059316758": [ - { - currency: "usd", - amount: 0, - exponent: 2, - }, - ], - "736345864146255982": [ - { - currency: "usd", - amount: 0, - exponent: 2, - }, - ], - "683074999590060249": [ - { - currency: "usd", - amount: 0, - exponent: 2, - }, - ], - }, - }, - "1": { - country_prices: { - country_code: "US", - prices: [ - { - currency: "usd", - amount: 0, - exponent: 2, - }, - ], - }, - payment_source_prices: { - "775487223059316758": [ - { - currency: "usd", - amount: 0, - exponent: 2, - }, - ], - "736345864146255982": [ - { - currency: "usd", - amount: 0, - exponent: 2, - }, - ], - "683074999590060249": [ - { - currency: "usd", - amount: 0, - exponent: 2, - }, - ], - }, - }, - }, - }, - ], - ], - ], -]); - -router.get("/", route({}), async (req: Request, res: Response) => { - // TODO: add the ability to add custom - const { sku_id } = req.params; - - if (!skus.has(sku_id)) { - console.log(`Request for invalid SKU ${sku_id}! Please report this!`); - res.sendStatus(404); - } else { - res.json(skus.get(sku_id)).status(200); - } -}); - -export default router; diff --git a/src/api/routes/teams.ts b/src/api/routes/teams.ts deleted file mode 100644 index 7ce3abcb..00000000 --- a/src/api/routes/teams.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Request, Response, Router } from "express"; -import { route } from "@fosscord/api"; - -const router: Router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - //TODO - res.send([]); -}); - -export default router; diff --git a/src/api/routes/template.ts.disabled b/src/api/routes/template.ts.disabled deleted file mode 100644 index fcc59ef4..00000000 --- a/src/api/routes/template.ts.disabled +++ /dev/null @@ -1,11 +0,0 @@ -//TODO: this is a template for a generic route - -import { Router, Request, Response } from "express"; -import { route } from "@fosscord/api"; -const router = Router(); - -router.get("/",route({}), async (req: Request, res: Response) => { - res.json({}); -}); - -export default router; diff --git a/src/api/routes/track.ts b/src/api/routes/track.ts deleted file mode 100644 index 8556a3ad..00000000 --- a/src/api/routes/track.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Router, Response, Request } from "express"; -import { route } from "@fosscord/api"; - -const router = Router(); - -router.post("/", route({}), (req: Request, res: Response) => { - // TODO: - res.sendStatus(204); -}); - -export default router; diff --git a/src/api/routes/updates.ts b/src/api/routes/updates.ts deleted file mode 100644 index 7e9128f4..00000000 --- a/src/api/routes/updates.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Router, Response, Request } from "express"; -import { route } from "@fosscord/api"; -import { Config, FieldErrors, Release } from "@fosscord/util"; - -const router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - const { client } = Config.get(); - const platform = req.query.platform; - - if (!platform) - throw FieldErrors({ - platform: { - code: "BASE_TYPE_REQUIRED", - message: req.t("common:field.BASE_TYPE_REQUIRED"), - }, - }); - - const release = await Release.findOneOrFail({ - where: { - enabled: true, - platform: platform as string, - }, - order: { pub_date: "DESC" }, - }); - - res.json({ - name: release.name, - pub_date: release.pub_date, - url: release.url, - notes: release.notes, - }); -}); - -export default router; diff --git a/src/api/routes/users/#id/delete.ts b/src/api/routes/users/#id/delete.ts deleted file mode 100644 index 2c08635b..00000000 --- a/src/api/routes/users/#id/delete.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { route } from "@fosscord/api"; -import { - emitEvent, - Member, - PrivateUserProjection, - User, - UserDeleteEvent, - UserDeleteSchema, -} from "@fosscord/util"; -import { Request, Response, Router } from "express"; - -const router = Router(); - -router.post( - "/", - route({ right: "MANAGE_USERS" }), - async (req: Request, res: Response) => { - - let user = await User.findOneOrFail({ - where: { id: req.params.id }, - select: [...PrivateUserProjection, "data"], - }); - await Promise.all([ - Member.delete({ id: req.params.id }), - User.delete({ id: req.params.id }), - ]); - - // TODO: respect intents as USER_DELETE has potential to cause privacy issues - await emitEvent({ - event: "USER_DELETE", - user_id: req.user_id, - data: { user_id: req.params.id }, - } as UserDeleteEvent); - - res.sendStatus(204); - }, -); - -export default router; diff --git a/src/api/routes/users/#id/index.ts b/src/api/routes/users/#id/index.ts deleted file mode 100644 index bdb1060f..00000000 --- a/src/api/routes/users/#id/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Router, Request, Response } from "express"; -import { User } from "@fosscord/util"; -import { route } from "@fosscord/api"; - -const router: Router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - const { id } = req.params; - - res.json(await User.getPublicUser(id)); -}); - -export default router; diff --git a/src/api/routes/users/#id/profile.ts b/src/api/routes/users/#id/profile.ts deleted file mode 100644 index 5c649056..00000000 --- a/src/api/routes/users/#id/profile.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { Router, Request, Response } from "express"; -import { - PublicConnectedAccount, - PublicUser, - User, - UserPublic, - Member, - Guild, - UserProfileModifySchema, - handleFile, - PrivateUserProjection, - emitEvent, - UserUpdateEvent, -} from "@fosscord/util"; -import { route } from "@fosscord/api"; - -const router: Router = Router(); - -export interface UserProfileResponse { - user: UserPublic; - connected_accounts: PublicConnectedAccount; - premium_guild_since?: Date; - premium_since?: Date; -} - -router.get( - "/", - route({ test: { response: { body: "UserProfileResponse" } } }), - async (req: Request, res: Response) => { - if (req.params.id === "@me") req.params.id = req.user_id; - - const { guild_id, with_mutual_guilds } = req.query; - - const user = await User.getPublicUser(req.params.id, { - relations: ["connected_accounts"], - }); - - var mutual_guilds: object[] = []; - var premium_guild_since; - - if (with_mutual_guilds == "true") { - const requested_member = await Member.find({ - where: { id: req.params.id }, - }); - const self_member = await Member.find({ - where: { id: req.user_id }, - }); - - for (const rmem of requested_member) { - if (rmem.premium_since) { - if (premium_guild_since) { - if (premium_guild_since > rmem.premium_since) { - premium_guild_since = rmem.premium_since; - } - } else { - premium_guild_since = rmem.premium_since; - } - } - for (const smem of self_member) { - if (smem.guild_id === rmem.guild_id) { - mutual_guilds.push({ - id: rmem.guild_id, - nick: rmem.nick, - }); - } - } - } - } - - const guild_member = - guild_id && typeof guild_id == "string" - ? await Member.findOneOrFail({ - where: { id: req.params.id, guild_id: guild_id }, - relations: ["roles"], - }) - : undefined; - - // TODO: make proper DTO's in util? - - const userDto = { - username: user.username, - discriminator: user.discriminator, - id: user.id, - public_flags: user.public_flags, - avatar: user.avatar, - accent_color: user.accent_color, - banner: user.banner, - bio: req.user_bot ? null : user.bio, - bot: user.bot, - }; - - const userProfile = { - bio: req.user_bot ? null : user.bio, - accent_color: user.accent_color, - banner: user.banner, - pronouns: user.pronouns, - theme_colors: user.theme_colors, - }; - - const guildMemberDto = guild_member - ? { - avatar: guild_member.avatar, - banner: guild_member.banner, - bio: req.user_bot ? null : guild_member.bio, - communication_disabled_until: - guild_member.communication_disabled_until, - deaf: guild_member.deaf, - flags: user.flags, - is_pending: guild_member.pending, - pending: guild_member.pending, // why is this here twice, discord? - joined_at: guild_member.joined_at, - mute: guild_member.mute, - nick: guild_member.nick, - premium_since: guild_member.premium_since, - roles: guild_member.roles - .map((x) => x.id) - .filter((id) => id != guild_id), - user: userDto, - } - : undefined; - - const guildMemberProfile = { - accent_color: null, - banner: guild_member?.banner || null, - bio: guild_member?.bio || "", - guild_id, - }; - res.json({ - connected_accounts: user.connected_accounts, - premium_guild_since: premium_guild_since, // TODO - premium_since: user.premium_since, // TODO - mutual_guilds: mutual_guilds, // TODO {id: "", nick: null} when ?with_mutual_guilds=true - user: userDto, - premium_type: user.premium_type, - profile_themes_experiment_bucket: 4, // TODO: This doesn't make it available, for some reason? - user_profile: userProfile, - guild_member: guild_id && guildMemberDto, - guild_member_profile: guild_id && guildMemberProfile, - }); - }, -); - -router.patch( - "/", - route({ body: "UserProfileModifySchema" }), - async (req: Request, res: Response) => { - const body = req.body as UserProfileModifySchema; - - if (body.banner) - body.banner = await handleFile( - `/banners/${req.user_id}`, - body.banner as string, - ); - let user = await User.findOneOrFail({ - where: { id: req.user_id }, - select: [...PrivateUserProjection, "data"], - }); - - user.assign(body); - await user.save(); - - // @ts-ignore - delete user.data; - - // TODO: send update member list event in gateway - await emitEvent({ - event: "USER_UPDATE", - user_id: req.user_id, - data: user, - } as UserUpdateEvent); - - res.json({ - accent_color: user.accent_color, - bio: user.bio, - banner: user.banner, - theme_colors: user.theme_colors, - pronouns: user.pronouns, - }); - }, -); - -export default router; diff --git a/src/api/routes/users/#id/relationships.ts b/src/api/routes/users/#id/relationships.ts deleted file mode 100644 index c6480567..00000000 --- a/src/api/routes/users/#id/relationships.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Router, Request, Response } from "express"; -import { User } from "@fosscord/util"; -import { route } from "@fosscord/api"; - -const router: Router = Router(); - -export interface UserRelationsResponse { - object: { - id?: string; - username?: string; - avatar?: string; - discriminator?: string; - public_flags?: number; - }; -} - -router.get( - "/", - route({ test: { response: { body: "UserRelationsResponse" } } }), - async (req: Request, res: Response) => { - var mutual_relations: object[] = []; - const requested_relations = await User.findOneOrFail({ - where: { id: req.params.id }, - relations: ["relationships"], - }); - const self_relations = await User.findOneOrFail({ - where: { id: req.user_id }, - relations: ["relationships"], - }); - - for (const rmem of requested_relations.relationships) { - for (const smem of self_relations.relationships) - if ( - rmem.to_id === smem.to_id && - rmem.type === 1 && - rmem.to_id !== req.user_id - ) { - var relation_user = await User.getPublicUser(rmem.to_id); - - mutual_relations.push({ - id: relation_user.id, - username: relation_user.username, - avatar: relation_user.avatar, - discriminator: relation_user.discriminator, - public_flags: relation_user.public_flags, - }); - } - } - - res.json(mutual_relations); - }, -); - -export default router; diff --git a/src/api/routes/users/@me/activities/statistics/applications.ts b/src/api/routes/users/@me/activities/statistics/applications.ts deleted file mode 100644 index 014df8af..00000000 --- a/src/api/routes/users/@me/activities/statistics/applications.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Router, Response, Request } from "express"; -import { route } from "@fosscord/api"; - -const router = Router(); - -router.get("/", route({}), (req: Request, res: Response) => { - // TODO: - res.json([]).status(200); -}); - -export default router; diff --git a/src/api/routes/users/@me/affinities/guilds.ts b/src/api/routes/users/@me/affinities/guilds.ts deleted file mode 100644 index 8d744744..00000000 --- a/src/api/routes/users/@me/affinities/guilds.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Router, Response, Request } from "express"; -import { route } from "@fosscord/api"; - -const router = Router(); - -router.get("/", route({}), (req: Request, res: Response) => { - // TODO: - res.status(200).send({ guild_affinities: [] }); -}); - -export default router; diff --git a/src/api/routes/users/@me/affinities/users.ts b/src/api/routes/users/@me/affinities/users.ts deleted file mode 100644 index 6d4e4991..00000000 --- a/src/api/routes/users/@me/affinities/users.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Router, Response, Request } from "express"; -import { route } from "@fosscord/api"; - -const router = Router(); - -router.get("/", route({}), (req: Request, res: Response) => { - // TODO: - res.status(200).send({ user_affinities: [], inverse_user_affinities: [] }); -}); - -export default router; diff --git a/src/api/routes/users/@me/applications/#app_id/entitlements.ts b/src/api/routes/users/@me/applications/#app_id/entitlements.ts deleted file mode 100644 index 411e95bf..00000000 --- a/src/api/routes/users/@me/applications/#app_id/entitlements.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Request, Response, Router } from "express"; -import { route } from "@fosscord/api"; - -const router: Router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - //TODO - res.json([]).status(200); -}); - -export default router; diff --git a/src/api/routes/users/@me/billing/country-code.ts b/src/api/routes/users/@me/billing/country-code.ts deleted file mode 100644 index 33d40796..00000000 --- a/src/api/routes/users/@me/billing/country-code.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Request, Response, Router } from "express"; -import { route } from "@fosscord/api"; - -const router: Router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - //TODO - res.json({ country_code: "US" }).status(200); -}); - -export default router; diff --git a/src/api/routes/users/@me/billing/payment-sources.ts b/src/api/routes/users/@me/billing/payment-sources.ts deleted file mode 100644 index 014df8af..00000000 --- a/src/api/routes/users/@me/billing/payment-sources.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Router, Response, Request } from "express"; -import { route } from "@fosscord/api"; - -const router = Router(); - -router.get("/", route({}), (req: Request, res: Response) => { - // TODO: - res.json([]).status(200); -}); - -export default router; diff --git a/src/api/routes/users/@me/billing/subscriptions.ts b/src/api/routes/users/@me/billing/subscriptions.ts deleted file mode 100644 index 411e95bf..00000000 --- a/src/api/routes/users/@me/billing/subscriptions.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Request, Response, Router } from "express"; -import { route } from "@fosscord/api"; - -const router: Router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - //TODO - res.json([]).status(200); -}); - -export default router; diff --git a/src/api/routes/users/@me/channels.ts b/src/api/routes/users/@me/channels.ts deleted file mode 100644 index 237be102..00000000 --- a/src/api/routes/users/@me/channels.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Request, Response, Router } from "express"; -import { - Recipient, - DmChannelDTO, - Channel, - DmChannelCreateSchema, -} from "@fosscord/util"; -import { route } from "@fosscord/api"; - -const router: Router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - const recipients = await Recipient.find({ - where: { user_id: req.user_id, closed: false }, - relations: ["channel", "channel.recipients"], - }); - res.json( - await Promise.all( - recipients.map((r) => DmChannelDTO.from(r.channel, [req.user_id])), - ), - ); -}); - -router.post( - "/", - route({ body: "DmChannelCreateSchema" }), - async (req: Request, res: Response) => { - const body = req.body as DmChannelCreateSchema; - res.json( - await Channel.createDMChannel( - body.recipients, - req.user_id, - body.name, - ), - ); - }, -); - -export default router; diff --git a/src/api/routes/users/@me/connections.ts b/src/api/routes/users/@me/connections.ts deleted file mode 100644 index 411e95bf..00000000 --- a/src/api/routes/users/@me/connections.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Request, Response, Router } from "express"; -import { route } from "@fosscord/api"; - -const router: Router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - //TODO - res.json([]).status(200); -}); - -export default router; diff --git a/src/api/routes/users/@me/delete.ts b/src/api/routes/users/@me/delete.ts deleted file mode 100644 index a9f8167c..00000000 --- a/src/api/routes/users/@me/delete.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Router, Request, Response } from "express"; -import { Guild, Member, User } from "@fosscord/util"; -import { route } from "@fosscord/api"; -import bcrypt from "bcrypt"; -import { HTTPError } from "lambert-server"; - -const router = Router(); - -router.post("/", route({}), async (req: Request, res: Response) => { - const user = await User.findOneOrFail({ - where: { id: req.user_id }, - select: ["data"], - }); //User object - let correctpass = true; - - if (user.data.hash) { - // guest accounts can delete accounts without password - correctpass = await bcrypt.compare(req.body.password, user.data.hash); - if (!correctpass) { - throw new HTTPError(req.t("auth:login.INVALID_PASSWORD")); - } - } - - // TODO: decrement guild member count - - if (correctpass) { - await Promise.all([ - User.delete({ id: req.user_id }), - Member.delete({ id: req.user_id }), - ]); - - res.sendStatus(204); - } else { - res.sendStatus(401); - } -}); - -export default router; diff --git a/src/api/routes/users/@me/devices.ts b/src/api/routes/users/@me/devices.ts deleted file mode 100644 index 8556a3ad..00000000 --- a/src/api/routes/users/@me/devices.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Router, Response, Request } from "express"; -import { route } from "@fosscord/api"; - -const router = Router(); - -router.post("/", route({}), (req: Request, res: Response) => { - // TODO: - res.sendStatus(204); -}); - -export default router; diff --git a/src/api/routes/users/@me/disable.ts b/src/api/routes/users/@me/disable.ts deleted file mode 100644 index 313a888f..00000000 --- a/src/api/routes/users/@me/disable.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { User } from "@fosscord/util"; -import { Router, Response, Request } from "express"; -import { route } from "@fosscord/api"; -import bcrypt from "bcrypt"; - -const router = Router(); - -router.post("/", route({}), async (req: Request, res: Response) => { - const user = await User.findOneOrFail({ - where: { id: req.user_id }, - select: ["data"], - }); //User object - let correctpass = true; - - if (user.data.hash) { - // guest accounts can delete accounts without password - correctpass = await bcrypt.compare(req.body.password, user.data.hash); //Not sure if user typed right password :/ - } - - if (correctpass) { - await User.update({ id: req.user_id }, { disabled: true }); - - res.sendStatus(204); - } else { - res.status(400).json({ - message: "Password does not match", - code: 50018, - }); - } -}); - -export default router; diff --git a/src/api/routes/users/@me/email-settings.ts b/src/api/routes/users/@me/email-settings.ts deleted file mode 100644 index a2834b89..00000000 --- a/src/api/routes/users/@me/email-settings.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Router, Response, Request } from "express"; -import { route } from "@fosscord/api"; - -const router = Router(); - -router.get("/", route({}), (req: Request, res: Response) => { - // TODO: - res.json({ - categories: { - social: true, - communication: true, - tips: false, - updates_and_announcements: false, - recommendations_and_events: false, - }, - initialized: false, - }).status(200); -}); - -export default router; diff --git a/src/api/routes/users/@me/entitlements.ts b/src/api/routes/users/@me/entitlements.ts deleted file mode 100644 index 341e2b4c..00000000 --- a/src/api/routes/users/@me/entitlements.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Router, Response, Request } from "express"; -import { route } from "@fosscord/api"; - -const router = Router(); - -router.get("/gifts", route({}), (req: Request, res: Response) => { - // TODO: - res.json([]).status(200); -}); - -export default router; diff --git a/src/api/routes/users/@me/guilds.ts b/src/api/routes/users/@me/guilds.ts deleted file mode 100644 index e12bf258..00000000 --- a/src/api/routes/users/@me/guilds.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Router, Request, Response } from "express"; -import { - Guild, - Member, - User, - GuildDeleteEvent, - GuildMemberRemoveEvent, - emitEvent, - Config, -} from "@fosscord/util"; -import { HTTPError } from "lambert-server"; -import { route } from "@fosscord/api"; - -const router: Router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - const members = await Member.find({ - relations: ["guild"], - where: { id: req.user_id }, - }); - - let guild = members.map((x) => x.guild); - - if ("with_counts" in req.query && req.query.with_counts == "true") { - guild = []; // TODO: Load guilds with user role permissions number - } - - res.json(guild); -}); - -// user send to leave a certain guild -router.delete("/:guild_id", route({}), async (req: Request, res: Response) => { - const { autoJoin } = Config.get().guild; - const { guild_id } = req.params; - const guild = await Guild.findOneOrFail({ - where: { id: guild_id }, - select: ["owner_id"], - }); - - if (!guild) throw new HTTPError("Guild doesn't exist", 404); - if (guild.owner_id === req.user_id) - throw new HTTPError("You can't leave your own guild", 400); - if ( - autoJoin.enabled && - autoJoin.guilds.includes(guild_id) && - !autoJoin.canLeave - ) { - throw new HTTPError("You can't leave instance auto join guilds", 400); - } - - await Promise.all([ - Member.delete({ id: req.user_id, guild_id: guild_id }), - emitEvent({ - event: "GUILD_DELETE", - data: { - id: guild_id, - }, - user_id: req.user_id, - } as GuildDeleteEvent), - ]); - - const user = await User.getPublicUser(req.user_id); - - await emitEvent({ - event: "GUILD_MEMBER_REMOVE", - data: { - guild_id: guild_id, - user: user, - }, - guild_id: guild_id, - } as GuildMemberRemoveEvent); - - return res.sendStatus(204); -}); - -export default router; diff --git a/src/api/routes/users/@me/guilds/#guild_id/settings.ts b/src/api/routes/users/@me/guilds/#guild_id/settings.ts deleted file mode 100644 index 436261d4..00000000 --- a/src/api/routes/users/@me/guilds/#guild_id/settings.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Router, Response, Request } from "express"; -import { - Channel, - Member, - OrmUtils, - UserGuildSettingsSchema, -} from "@fosscord/util"; -import { route } from "@fosscord/api"; - -const router = Router(); - -// GET doesn't exist on discord.com -router.get("/", route({}), async (req: Request, res: Response) => { - const user = await Member.findOneOrFail({ - where: { id: req.user_id, guild_id: req.params.guild_id }, - select: ["settings"], - }); - return res.json(user.settings); -}); - -router.patch( - "/", - route({ body: "UserGuildSettingsSchema" }), - async (req: Request, res: Response) => { - const body = req.body as UserGuildSettingsSchema; - - if (body.channel_overrides) { - for (var channel in body.channel_overrides) { - Channel.findOneOrFail({ where: { id: channel } }); - } - } - - const user = await Member.findOneOrFail({ - where: { id: req.user_id, guild_id: req.params.guild_id }, - select: ["settings"], - }); - OrmUtils.mergeDeep(user.settings || {}, body); - Member.update({ id: req.user_id, guild_id: req.params.guild_id }, user); - - res.json(user.settings); - }, -); - -export default router; diff --git a/src/api/routes/users/@me/guilds/premium/subscription-slots.ts b/src/api/routes/users/@me/guilds/premium/subscription-slots.ts deleted file mode 100644 index 014df8af..00000000 --- a/src/api/routes/users/@me/guilds/premium/subscription-slots.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Router, Response, Request } from "express"; -import { route } from "@fosscord/api"; - -const router = Router(); - -router.get("/", route({}), (req: Request, res: Response) => { - // TODO: - res.json([]).status(200); -}); - -export default router; diff --git a/src/api/routes/users/@me/index.ts b/src/api/routes/users/@me/index.ts deleted file mode 100644 index 37356d9d..00000000 --- a/src/api/routes/users/@me/index.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { Router, Request, Response } from "express"; -import { - User, - PrivateUserProjection, - emitEvent, - UserUpdateEvent, - handleFile, - FieldErrors, - adjustEmail, - Config, - UserModifySchema, - generateToken, -} from "@fosscord/util"; -import { route } from "@fosscord/api"; -import bcrypt from "bcrypt"; - -const router: Router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - res.json( - await User.findOne({ - select: PrivateUserProjection, - where: { id: req.user_id }, - }), - ); -}); - -router.patch( - "/", - route({ body: "UserModifySchema" }), - async (req: Request, res: Response) => { - const body = req.body as UserModifySchema; - - const user = await User.findOneOrFail({ - where: { id: req.user_id }, - select: [...PrivateUserProjection, "data"], - }); - - // Populated on password change - var newToken: string | undefined; - - if (body.avatar) - body.avatar = await handleFile( - `/avatars/${req.user_id}`, - body.avatar as string, - ); - if (body.banner) - body.banner = await handleFile( - `/banners/${req.user_id}`, - body.banner as string, - ); - - if (body.password) { - if (user.data?.hash) { - const same_password = await bcrypt.compare( - body.password, - user.data.hash || "", - ); - if (!same_password) { - throw FieldErrors({ - password: { - message: req.t("auth:login.INVALID_PASSWORD"), - code: "INVALID_PASSWORD", - }, - }); - } - } else { - user.data.hash = await bcrypt.hash(body.password, 12); - } - } - - if (body.email) { - body.email = adjustEmail(body.email); - if (!body.email && Config.get().register.email.required) - throw FieldErrors({ - email: { - message: req.t("auth:register.EMAIL_INVALID"), - code: "EMAIL_INVALID", - }, - }); - if (!body.password) - throw FieldErrors({ - password: { - message: req.t("auth:register.INVALID_PASSWORD"), - code: "INVALID_PASSWORD", - }, - }); - } - - if (body.new_password) { - if (!body.password && !user.email) { - throw FieldErrors({ - password: { - code: "BASE_TYPE_REQUIRED", - message: req.t("common:field.BASE_TYPE_REQUIRED"), - }, - }); - } - user.data.hash = await bcrypt.hash(body.new_password, 12); - user.data.valid_tokens_since = new Date(); - newToken = (await generateToken(user.id)) as string; - } - - if (body.username) { - var check_username = body?.username?.replace(/\s/g, ""); - if (!check_username) { - throw FieldErrors({ - username: { - code: "BASE_TYPE_REQUIRED", - message: req.t("common:field.BASE_TYPE_REQUIRED"), - }, - }); - } - } - - if (body.discriminator) { - if ( - await User.findOne({ - where: { - discriminator: body.discriminator, - username: body.username || user.username, - }, - }) - ) { - throw FieldErrors({ - discriminator: { - code: "INVALID_DISCRIMINATOR", - message: "This discriminator is already in use.", - }, - }); - } - } - - user.assign(body); - user.validate(); - await user.save(); - - // @ts-ignore - delete user.data; - - // TODO: send update member list event in gateway - await emitEvent({ - event: "USER_UPDATE", - user_id: req.user_id, - data: user, - } as UserUpdateEvent); - - res.json({ - ...user, - newToken, - }); - }, -); - -export default router; -// {"message": "Invalid two-factor code", "code": 60008} diff --git a/src/api/routes/users/@me/library.ts b/src/api/routes/users/@me/library.ts deleted file mode 100644 index 7ac13bae..00000000 --- a/src/api/routes/users/@me/library.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Router, Response, Request } from "express"; -import { route } from "@fosscord/api"; - -const router = Router(); - -router.get("/", route({}), (req: Request, res: Response) => { - // TODO: - res.status(200).send([]); -}); - -export default router; diff --git a/src/api/routes/users/@me/mfa/codes-verification.ts b/src/api/routes/users/@me/mfa/codes-verification.ts deleted file mode 100644 index 3411605b..00000000 --- a/src/api/routes/users/@me/mfa/codes-verification.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Router, Request, Response } from "express"; -import { route } from "@fosscord/api"; -import { - BackupCode, - generateMfaBackupCodes, - User, - CodesVerificationSchema, -} from "@fosscord/util"; - -const router = Router(); - -router.post( - "/", - route({ body: "CodesVerificationSchema" }), - async (req: Request, res: Response) => { - const { key, nonce, regenerate } = req.body as CodesVerificationSchema; - - // TODO: We don't have email/etc etc, so can't send a verification code. - // Once that's done, this route can verify `key` - - const user = await User.findOneOrFail({ where: { id: req.user_id } }); - - var codes: BackupCode[]; - if (regenerate) { - await BackupCode.update( - { user: { id: req.user_id } }, - { expired: true }, - ); - - codes = generateMfaBackupCodes(req.user_id); - await Promise.all(codes.map((x) => x.save())); - } else { - codes = await BackupCode.find({ - where: { - user: { - id: req.user_id, - }, - expired: false, - }, - }); - } - - return res.json({ - backup_codes: codes.map((x) => ({ ...x, expired: undefined })), - }); - }, -); - -export default router; diff --git a/src/api/routes/users/@me/mfa/codes.ts b/src/api/routes/users/@me/mfa/codes.ts deleted file mode 100644 index 33053028..00000000 --- a/src/api/routes/users/@me/mfa/codes.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Router, Request, Response } from "express"; -import { route } from "@fosscord/api"; -import { - BackupCode, - FieldErrors, - generateMfaBackupCodes, - User, - MfaCodesSchema, -} from "@fosscord/util"; -import bcrypt from "bcrypt"; - -const router = Router(); - -// TODO: This route is replaced with users/@me/mfa/codes-verification in newer clients - -router.post( - "/", - route({ body: "MfaCodesSchema" }), - async (req: Request, res: Response) => { - const { password, regenerate } = req.body as MfaCodesSchema; - - const user = await User.findOneOrFail({ - where: { id: req.user_id }, - select: ["data"], - }); - - if (!(await bcrypt.compare(password, user.data.hash || ""))) { - throw FieldErrors({ - password: { - message: req.t("auth:login.INVALID_PASSWORD"), - code: "INVALID_PASSWORD", - }, - }); - } - - var codes: BackupCode[]; - if (regenerate) { - await BackupCode.update( - { user: { id: req.user_id } }, - { expired: true }, - ); - - codes = generateMfaBackupCodes(req.user_id); - await Promise.all(codes.map((x) => x.save())); - } else { - codes = await BackupCode.find({ - where: { - user: { - id: req.user_id, - }, - expired: false, - }, - }); - } - - return res.json({ - backup_codes: codes.map((x) => ({ ...x, expired: undefined })), - }); - }, -); - -export default router; diff --git a/src/api/routes/users/@me/mfa/totp/disable.ts b/src/api/routes/users/@me/mfa/totp/disable.ts deleted file mode 100644 index 7916e598..00000000 --- a/src/api/routes/users/@me/mfa/totp/disable.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Router, Request, Response } from "express"; -import { route } from "@fosscord/api"; -import { verifyToken } from "node-2fa"; -import { HTTPError } from "lambert-server"; -import { - User, - generateToken, - BackupCode, - TotpDisableSchema, -} from "@fosscord/util"; - -const router = Router(); - -router.post( - "/", - route({ body: "TotpDisableSchema" }), - async (req: Request, res: Response) => { - const body = req.body as TotpDisableSchema; - - const user = await User.findOneOrFail({ - where: { id: req.user_id }, - select: ["totp_secret"], - }); - - const backup = await BackupCode.findOne({ where: { code: body.code } }); - if (!backup) { - const ret = verifyToken(user.totp_secret!, body.code); - if (!ret || ret.delta != 0) - throw new HTTPError( - req.t("auth:login.INVALID_TOTP_CODE"), - 60008, - ); - } - - await User.update( - { id: req.user_id }, - { - mfa_enabled: false, - totp_secret: "", - }, - ); - - await BackupCode.update( - { user: { id: req.user_id } }, - { - expired: true, - }, - ); - - return res.json({ - token: await generateToken(user.id), - }); - }, -); - -export default router; diff --git a/src/api/routes/users/@me/mfa/totp/enable.ts b/src/api/routes/users/@me/mfa/totp/enable.ts deleted file mode 100644 index 2c7044da..00000000 --- a/src/api/routes/users/@me/mfa/totp/enable.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Router, Request, Response } from "express"; -import { - User, - generateToken, - generateMfaBackupCodes, - TotpEnableSchema, -} from "@fosscord/util"; -import { route } from "@fosscord/api"; -import bcrypt from "bcrypt"; -import { HTTPError } from "lambert-server"; -import { verifyToken } from "node-2fa"; - -const router = Router(); - -router.post( - "/", - route({ body: "TotpEnableSchema" }), - async (req: Request, res: Response) => { - const body = req.body as TotpEnableSchema; - - const user = await User.findOneOrFail({ - where: { id: req.user_id }, - select: ["data", "email"], - }); - - // TODO: Are guests allowed to enable 2fa? - if (user.data.hash) { - if (!(await bcrypt.compare(body.password, user.data.hash))) { - throw new HTTPError(req.t("auth:login.INVALID_PASSWORD")); - } - } - - if (!body.secret) - throw new HTTPError(req.t("auth:login.INVALID_TOTP_SECRET"), 60005); - - if (!body.code) - throw new HTTPError(req.t("auth:login.INVALID_TOTP_CODE"), 60008); - - if (verifyToken(body.secret, body.code)?.delta != 0) - throw new HTTPError(req.t("auth:login.INVALID_TOTP_CODE"), 60008); - - let backup_codes = generateMfaBackupCodes(req.user_id); - await Promise.all(backup_codes.map((x) => x.save())); - await User.update( - { id: req.user_id }, - { mfa_enabled: true, totp_secret: body.secret }, - ); - - res.send({ - token: await generateToken(user.id), - backup_codes: backup_codes.map((x) => ({ - ...x, - expired: undefined, - })), - }); - }, -); - -export default router; diff --git a/src/api/routes/users/@me/notes.ts b/src/api/routes/users/@me/notes.ts deleted file mode 100644 index e54eb897..00000000 --- a/src/api/routes/users/@me/notes.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Request, Response, Router } from "express"; -import { route } from "@fosscord/api"; -import { User, Note, emitEvent, Snowflake } from "@fosscord/util"; - -const router: Router = Router(); - -router.get("/:id", route({}), async (req: Request, res: Response) => { - const { id } = req.params; - - const note = await Note.findOneOrFail({ - where: { - owner: { id: req.user_id }, - target: { id: id }, - }, - }); - - return res.json({ - note: note?.content, - note_user_id: id, - user_id: req.user_id, - }); -}); - -router.put("/:id", route({}), async (req: Request, res: Response) => { - const { id } = req.params; - const owner = await User.findOneOrFail({ where: { id: req.user_id } }); - const target = await User.findOneOrFail({ where: { id: id } }); //if noted user does not exist throw - const { note } = req.body; - - if (note && note.length) { - // upsert a note - if ( - await Note.findOne({ - where: { owner: { id: owner.id }, target: { id: target.id } }, - }) - ) { - Note.update( - { owner: { id: owner.id }, target: { id: target.id } }, - { owner, target, content: note }, - ); - } else { - Note.insert({ - id: Snowflake.generate(), - owner, - target, - content: note, - }); - } - } else { - await Note.delete({ - owner: { id: owner.id }, - target: { id: target.id }, - }); - } - - await emitEvent({ - event: "USER_NOTE_UPDATE", - data: { - note: note, - id: target.id, - }, - user_id: owner.id, - }); - - return res.status(204); -}); - -export default router; diff --git a/src/api/routes/users/@me/relationships.ts b/src/api/routes/users/@me/relationships.ts deleted file mode 100644 index 3eec704b..00000000 --- a/src/api/routes/users/@me/relationships.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { - RelationshipAddEvent, - User, - PublicUserProjection, - RelationshipType, - RelationshipRemoveEvent, - emitEvent, - Relationship, - Config, -} from "@fosscord/util"; -import { Router, Response, Request } from "express"; -import { HTTPError } from "lambert-server"; -import { DiscordApiErrors } from "@fosscord/util"; -import { route } from "@fosscord/api"; - -const router = Router(); - -const userProjection: (keyof User)[] = [ - "relationships", - ...PublicUserProjection, -]; - -router.get("/", route({}), async (req: Request, res: Response) => { - const user = await User.findOneOrFail({ - where: { id: req.user_id }, - relations: ["relationships", "relationships.to"], - select: ["id", "relationships"], - }); - - //TODO DTO - const related_users = user.relationships.map((r) => { - return { - id: r.to.id, - type: r.type, - nickname: null, - user: r.to.toPublicUser(), - }; - }); - - return res.json(related_users); -}); - -router.put( - "/:id", - route({ body: "RelationshipPutSchema" }), - async (req: Request, res: Response) => { - return await updateRelationship( - req, - res, - await User.findOneOrFail({ - where: { id: req.params.id }, - relations: ["relationships", "relationships.to"], - select: userProjection, - }), - req.body.type ?? RelationshipType.friends, - ); - }, -); - -router.post( - "/", - route({ body: "RelationshipPostSchema" }), - async (req: Request, res: Response) => { - return await updateRelationship( - req, - res, - await User.findOneOrFail({ - relations: ["relationships", "relationships.to"], - select: userProjection, - where: { - discriminator: String(req.body.discriminator).padStart( - 4, - "0", - ), //Discord send the discriminator as integer, we need to add leading zeroes - username: req.body.username, - }, - }), - req.body.type, - ); - }, -); - -router.delete("/:id", route({}), async (req: Request, res: Response) => { - const { id } = req.params; - if (id === req.user_id) - throw new HTTPError("You can't remove yourself as a friend"); - - const user = await User.findOneOrFail({ - where: { id: req.user_id }, - select: userProjection, - relations: ["relationships"], - }); - const friend = await User.findOneOrFail({ - where: { id: id }, - select: userProjection, - relations: ["relationships"], - }); - - const relationship = user.relationships.find((x) => x.to_id === id); - const friendRequest = friend.relationships.find( - (x) => x.to_id === req.user_id, - ); - - if (!relationship) - throw new HTTPError("You are not friends with the user", 404); - if (relationship?.type === RelationshipType.blocked) { - // unblock user - - await Promise.all([ - Relationship.delete({ id: relationship.id }), - emitEvent({ - event: "RELATIONSHIP_REMOVE", - user_id: req.user_id, - data: relationship.toPublicRelationship(), - } as RelationshipRemoveEvent), - ]); - return res.sendStatus(204); - } - if (friendRequest && friendRequest.type !== RelationshipType.blocked) { - await Promise.all([ - Relationship.delete({ id: friendRequest.id }), - await emitEvent({ - event: "RELATIONSHIP_REMOVE", - data: friendRequest.toPublicRelationship(), - user_id: id, - } as RelationshipRemoveEvent), - ]); - } - - await Promise.all([ - Relationship.delete({ id: relationship.id }), - emitEvent({ - event: "RELATIONSHIP_REMOVE", - data: relationship.toPublicRelationship(), - user_id: req.user_id, - } as RelationshipRemoveEvent), - ]); - - return res.sendStatus(204); -}); - -export default router; - -async function updateRelationship( - req: Request, - res: Response, - friend: User, - type: RelationshipType, -) { - const id = friend.id; - if (id === req.user_id) - throw new HTTPError("You can't add yourself as a friend"); - - const user = await User.findOneOrFail({ - where: { id: req.user_id }, - relations: ["relationships", "relationships.to"], - select: userProjection, - }); - - var relationship = user.relationships.find((x) => x.to_id === id); - const friendRequest = friend.relationships.find( - (x) => x.to_id === req.user_id, - ); - - // TODO: you can add infinitely many blocked users (should this be prevented?) - if (type === RelationshipType.blocked) { - if (relationship) { - if (relationship.type === RelationshipType.blocked) - throw new HTTPError("You already blocked the user"); - relationship.type = RelationshipType.blocked; - await relationship.save(); - } else { - relationship = await Relationship.create({ - to_id: id, - type: RelationshipType.blocked, - from_id: req.user_id, - }).save(); - } - - if (friendRequest && friendRequest.type !== RelationshipType.blocked) { - await Promise.all([ - Relationship.delete({ id: friendRequest.id }), - emitEvent({ - event: "RELATIONSHIP_REMOVE", - data: friendRequest.toPublicRelationship(), - user_id: id, - } as RelationshipRemoveEvent), - ]); - } - - await emitEvent({ - event: "RELATIONSHIP_ADD", - data: relationship.toPublicRelationship(), - user_id: req.user_id, - } as RelationshipAddEvent); - - return res.sendStatus(204); - } - - const { maxFriends } = Config.get().limits.user; - if (user.relationships.length >= maxFriends) - throw DiscordApiErrors.MAXIMUM_FRIENDS.withParams(maxFriends); - - var incoming_relationship = Relationship.create({ - nickname: undefined, - type: RelationshipType.incoming, - to: user, - from: friend, - }); - var outgoing_relationship = Relationship.create({ - nickname: undefined, - type: RelationshipType.outgoing, - to: friend, - from: user, - }); - - if (friendRequest) { - if (friendRequest.type === RelationshipType.blocked) - throw new HTTPError("The user blocked you"); - if (friendRequest.type === RelationshipType.friends) - throw new HTTPError("You are already friends with the user"); - // accept friend request - incoming_relationship = friendRequest; - incoming_relationship.type = RelationshipType.friends; - } - - if (relationship) { - if (relationship.type === RelationshipType.outgoing) - throw new HTTPError("You already sent a friend request"); - if (relationship.type === RelationshipType.blocked) - throw new HTTPError( - "Unblock the user before sending a friend request", - ); - if (relationship.type === RelationshipType.friends) - throw new HTTPError("You are already friends with the user"); - outgoing_relationship = relationship; - outgoing_relationship.type = RelationshipType.friends; - } - - await Promise.all([ - incoming_relationship.save(), - outgoing_relationship.save(), - emitEvent({ - event: "RELATIONSHIP_ADD", - data: outgoing_relationship.toPublicRelationship(), - user_id: req.user_id, - } as RelationshipAddEvent), - emitEvent({ - event: "RELATIONSHIP_ADD", - data: { - ...incoming_relationship.toPublicRelationship(), - should_notify: true, - }, - user_id: id, - } as RelationshipAddEvent), - ]); - - return res.sendStatus(204); -} diff --git a/src/api/routes/users/@me/settings.ts b/src/api/routes/users/@me/settings.ts deleted file mode 100644 index cce366ac..00000000 --- a/src/api/routes/users/@me/settings.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Router, Response, Request } from "express"; -import { OrmUtils, User, UserSettingsSchema } from "@fosscord/util"; -import { route } from "@fosscord/api"; - -const router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - const user = await User.findOneOrFail({ - where: { id: req.user_id }, - relations: ["settings"], - }); - return res.json(user.settings); -}); - -router.patch( - "/", - route({ body: "UserSettingsSchema" }), - async (req: Request, res: Response) => { - const body = req.body as UserSettingsSchema; - if (body.locale === "en") body.locale = "en-US"; // fix discord client crash on unkown locale - - const user = await User.findOneOrFail({ - where: { id: req.user_id, bot: false }, - relations: ["settings"], - }); - - user.settings.assign(body); - - user.settings.save(); - - res.json(user.settings); - }, -); - -export default router; diff --git a/src/api/routes/v0/applications/#id/skus.ts b/src/api/routes/v0/applications/#id/skus.ts new file mode 100644 index 00000000..2383e6f7 --- /dev/null +++ b/src/api/routes/v0/applications/#id/skus.ts @@ -0,0 +1,11 @@ +import { Request, Response, Router } from "express"; +import { route } from "@fosscord/api"; +import { Application, OrmUtils, Team, trimSpecial, User } from "@fosscord/util"; + +const router: Router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + res.json([]).status(200); +}); + +export default router; diff --git a/src/api/routes/v0/applications/detectable.ts b/src/api/routes/v0/applications/detectable.ts new file mode 100644 index 00000000..28ce42da --- /dev/null +++ b/src/api/routes/v0/applications/detectable.ts @@ -0,0 +1,11 @@ +import { Request, Response, Router } from "express"; +import { route } from "@fosscord/api"; + +const router: Router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + //TODO + res.send([]).status(200); +}); + +export default router; diff --git a/src/api/routes/v0/channels/#channel_id/webhooks.ts b/src/api/routes/v0/channels/#channel_id/webhooks.ts new file mode 100644 index 00000000..737ed6a8 --- /dev/null +++ b/src/api/routes/v0/channels/#channel_id/webhooks.ts @@ -0,0 +1,25 @@ +import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; +import { + Channel, + Config, + handleFile, + trimSpecial, + User, + Webhook, + WebhookCreateSchema, + WebhookType, +} from "@fosscord/util"; +import { HTTPError } from "lambert-server"; +import { isTextChannel } from "./messages/index"; +import { DiscordApiErrors } from "@fosscord/util"; +import crypto from "crypto"; + +const router: Router = Router(); + +//TODO: implement webhooks +router.get("/", route({}), async (req: Request, res: Response) => { + res.json([]); +}); + +export default router; diff --git a/src/api/routes/v0/experiments.ts b/src/api/routes/v0/experiments.ts new file mode 100644 index 00000000..b2b7d724 --- /dev/null +++ b/src/api/routes/v0/experiments.ts @@ -0,0 +1,11 @@ +import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; + +const router = Router(); + +router.get("/", route({}), (req: Request, res: Response) => { + // TODO: + res.send({ fingerprint: "", assignments: [], guild_experiments: [] }); +}); + +export default router; diff --git a/src/api/routes/v0/guilds/#guild_id/integrations.ts b/src/api/routes/v0/guilds/#guild_id/integrations.ts new file mode 100644 index 00000000..a8e78062 --- /dev/null +++ b/src/api/routes/v0/guilds/#guild_id/integrations.ts @@ -0,0 +1,9 @@ +import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; +const router = Router(); + +//TODO: implement integrations list +router.get("/", route({}), async (req: Request, res: Response) => { + res.json([]); +}); +export default router; diff --git a/src/api/routes/v0/guilds/#guild_id/member-verification.ts b/src/api/routes/v0/guilds/#guild_id/member-verification.ts new file mode 100644 index 00000000..c2f946b2 --- /dev/null +++ b/src/api/routes/v0/guilds/#guild_id/member-verification.ts @@ -0,0 +1,14 @@ +import { Router, Request, Response } from "express"; +import { route } from "@fosscord/api"; +const router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + // TODO: member verification + + res.status(404).json({ + message: "Unknown Guild Member Verification Form", + code: 10068, + }); +}); + +export default router; diff --git a/src/api/routes/v0/guilds/#guild_id/premium.ts b/src/api/routes/v0/guilds/#guild_id/premium.ts new file mode 100644 index 00000000..75361ac6 --- /dev/null +++ b/src/api/routes/v0/guilds/#guild_id/premium.ts @@ -0,0 +1,10 @@ +import { Router, Request, Response } from "express"; +import { route } from "@fosscord/api"; +const router = Router(); + +router.get("/subscriptions", route({}), async (req: Request, res: Response) => { + // TODO: + res.json([]); +}); + +export default router; diff --git a/src/api/routes/v0/guilds/#guild_id/webhooks.ts b/src/api/routes/v0/guilds/#guild_id/webhooks.ts new file mode 100644 index 00000000..9c4e8a8d --- /dev/null +++ b/src/api/routes/v0/guilds/#guild_id/webhooks.ts @@ -0,0 +1,9 @@ +import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; +const router = Router(); + +//TODO: implement webhooks +router.get("/", route({}), async (req: Request, res: Response) => { + res.json([]); +}); +export default router; diff --git a/src/api/routes/v0/oauth2/tokens.ts b/src/api/routes/v0/oauth2/tokens.ts new file mode 100644 index 00000000..bd284221 --- /dev/null +++ b/src/api/routes/v0/oauth2/tokens.ts @@ -0,0 +1,10 @@ +import { Router, Request, Response } from "express"; +import { route } from "@fosscord/api"; +const router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + //TODO + res.json([]); +}); + +export default router; diff --git a/src/api/routes/v0/outbound-promotions.ts b/src/api/routes/v0/outbound-promotions.ts new file mode 100644 index 00000000..411e95bf --- /dev/null +++ b/src/api/routes/v0/outbound-promotions.ts @@ -0,0 +1,11 @@ +import { Request, Response, Router } from "express"; +import { route } from "@fosscord/api"; + +const router: Router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + //TODO + res.json([]).status(200); +}); + +export default router; diff --git a/src/api/routes/v0/science.ts b/src/api/routes/v0/science.ts new file mode 100644 index 00000000..8556a3ad --- /dev/null +++ b/src/api/routes/v0/science.ts @@ -0,0 +1,11 @@ +import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; + +const router = Router(); + +router.post("/", route({}), (req: Request, res: Response) => { + // TODO: + res.sendStatus(204); +}); + +export default router; diff --git a/src/api/routes/v0/stage-instances.ts b/src/api/routes/v0/stage-instances.ts new file mode 100644 index 00000000..411e95bf --- /dev/null +++ b/src/api/routes/v0/stage-instances.ts @@ -0,0 +1,11 @@ +import { Request, Response, Router } from "express"; +import { route } from "@fosscord/api"; + +const router: Router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + //TODO + res.json([]).status(200); +}); + +export default router; diff --git a/src/api/routes/v0/teams.ts b/src/api/routes/v0/teams.ts new file mode 100644 index 00000000..7ce3abcb --- /dev/null +++ b/src/api/routes/v0/teams.ts @@ -0,0 +1,11 @@ +import { Request, Response, Router } from "express"; +import { route } from "@fosscord/api"; + +const router: Router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + //TODO + res.send([]); +}); + +export default router; diff --git a/src/api/routes/v0/template.ts.disabled b/src/api/routes/v0/template.ts.disabled new file mode 100644 index 00000000..fcc59ef4 --- /dev/null +++ b/src/api/routes/v0/template.ts.disabled @@ -0,0 +1,11 @@ +//TODO: this is a template for a generic route + +import { Router, Request, Response } from "express"; +import { route } from "@fosscord/api"; +const router = Router(); + +router.get("/",route({}), async (req: Request, res: Response) => { + res.json({}); +}); + +export default router; diff --git a/src/api/routes/v0/track.ts b/src/api/routes/v0/track.ts new file mode 100644 index 00000000..8556a3ad --- /dev/null +++ b/src/api/routes/v0/track.ts @@ -0,0 +1,11 @@ +import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; + +const router = Router(); + +router.post("/", route({}), (req: Request, res: Response) => { + // TODO: + res.sendStatus(204); +}); + +export default router; diff --git a/src/api/routes/v0/users/@me/activities/statistics/applications.ts b/src/api/routes/v0/users/@me/activities/statistics/applications.ts new file mode 100644 index 00000000..014df8af --- /dev/null +++ b/src/api/routes/v0/users/@me/activities/statistics/applications.ts @@ -0,0 +1,11 @@ +import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; + +const router = Router(); + +router.get("/", route({}), (req: Request, res: Response) => { + // TODO: + res.json([]).status(200); +}); + +export default router; diff --git a/src/api/routes/v0/users/@me/affinities/guilds.ts b/src/api/routes/v0/users/@me/affinities/guilds.ts new file mode 100644 index 00000000..8d744744 --- /dev/null +++ b/src/api/routes/v0/users/@me/affinities/guilds.ts @@ -0,0 +1,11 @@ +import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; + +const router = Router(); + +router.get("/", route({}), (req: Request, res: Response) => { + // TODO: + res.status(200).send({ guild_affinities: [] }); +}); + +export default router; diff --git a/src/api/routes/v0/users/@me/affinities/users.ts b/src/api/routes/v0/users/@me/affinities/users.ts new file mode 100644 index 00000000..6d4e4991 --- /dev/null +++ b/src/api/routes/v0/users/@me/affinities/users.ts @@ -0,0 +1,11 @@ +import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; + +const router = Router(); + +router.get("/", route({}), (req: Request, res: Response) => { + // TODO: + res.status(200).send({ user_affinities: [], inverse_user_affinities: [] }); +}); + +export default router; diff --git a/src/api/routes/v0/users/@me/applications/#app_id/entitlements.ts b/src/api/routes/v0/users/@me/applications/#app_id/entitlements.ts new file mode 100644 index 00000000..411e95bf --- /dev/null +++ b/src/api/routes/v0/users/@me/applications/#app_id/entitlements.ts @@ -0,0 +1,11 @@ +import { Request, Response, Router } from "express"; +import { route } from "@fosscord/api"; + +const router: Router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + //TODO + res.json([]).status(200); +}); + +export default router; diff --git a/src/api/routes/v0/users/@me/billing/country-code.ts b/src/api/routes/v0/users/@me/billing/country-code.ts new file mode 100644 index 00000000..33d40796 --- /dev/null +++ b/src/api/routes/v0/users/@me/billing/country-code.ts @@ -0,0 +1,11 @@ +import { Request, Response, Router } from "express"; +import { route } from "@fosscord/api"; + +const router: Router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + //TODO + res.json({ country_code: "US" }).status(200); +}); + +export default router; diff --git a/src/api/routes/v0/users/@me/billing/payment-sources.ts b/src/api/routes/v0/users/@me/billing/payment-sources.ts new file mode 100644 index 00000000..014df8af --- /dev/null +++ b/src/api/routes/v0/users/@me/billing/payment-sources.ts @@ -0,0 +1,11 @@ +import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; + +const router = Router(); + +router.get("/", route({}), (req: Request, res: Response) => { + // TODO: + res.json([]).status(200); +}); + +export default router; diff --git a/src/api/routes/v0/users/@me/billing/subscriptions.ts b/src/api/routes/v0/users/@me/billing/subscriptions.ts new file mode 100644 index 00000000..411e95bf --- /dev/null +++ b/src/api/routes/v0/users/@me/billing/subscriptions.ts @@ -0,0 +1,11 @@ +import { Request, Response, Router } from "express"; +import { route } from "@fosscord/api"; + +const router: Router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + //TODO + res.json([]).status(200); +}); + +export default router; diff --git a/src/api/routes/v0/users/@me/connections.ts b/src/api/routes/v0/users/@me/connections.ts new file mode 100644 index 00000000..411e95bf --- /dev/null +++ b/src/api/routes/v0/users/@me/connections.ts @@ -0,0 +1,11 @@ +import { Request, Response, Router } from "express"; +import { route } from "@fosscord/api"; + +const router: Router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + //TODO + res.json([]).status(200); +}); + +export default router; diff --git a/src/api/routes/v0/users/@me/devices.ts b/src/api/routes/v0/users/@me/devices.ts new file mode 100644 index 00000000..8556a3ad --- /dev/null +++ b/src/api/routes/v0/users/@me/devices.ts @@ -0,0 +1,11 @@ +import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; + +const router = Router(); + +router.post("/", route({}), (req: Request, res: Response) => { + // TODO: + res.sendStatus(204); +}); + +export default router; diff --git a/src/api/routes/v0/users/@me/entitlements.ts b/src/api/routes/v0/users/@me/entitlements.ts new file mode 100644 index 00000000..341e2b4c --- /dev/null +++ b/src/api/routes/v0/users/@me/entitlements.ts @@ -0,0 +1,11 @@ +import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; + +const router = Router(); + +router.get("/gifts", route({}), (req: Request, res: Response) => { + // TODO: + res.json([]).status(200); +}); + +export default router; diff --git a/src/api/routes/v0/users/@me/guilds/premium/subscription-slots.ts b/src/api/routes/v0/users/@me/guilds/premium/subscription-slots.ts new file mode 100644 index 00000000..014df8af --- /dev/null +++ b/src/api/routes/v0/users/@me/guilds/premium/subscription-slots.ts @@ -0,0 +1,11 @@ +import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; + +const router = Router(); + +router.get("/", route({}), (req: Request, res: Response) => { + // TODO: + res.json([]).status(200); +}); + +export default router; diff --git a/src/api/routes/v0/users/@me/library.ts b/src/api/routes/v0/users/@me/library.ts new file mode 100644 index 00000000..7ac13bae --- /dev/null +++ b/src/api/routes/v0/users/@me/library.ts @@ -0,0 +1,11 @@ +import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; + +const router = Router(); + +router.get("/", route({}), (req: Request, res: Response) => { + // TODO: + res.status(200).send([]); +}); + +export default router; diff --git a/src/api/routes/v9/-/healthz.ts b/src/api/routes/v9/-/healthz.ts new file mode 100644 index 00000000..d9d1c026 --- /dev/null +++ b/src/api/routes/v9/-/healthz.ts @@ -0,0 +1,13 @@ +import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; +import { getDatabase } from "@fosscord/util"; + +const router = Router(); + +router.get("/", route({}), (req: Request, res: Response) => { + if (!getDatabase()) return res.sendStatus(503); + + return res.sendStatus(200); +}); + +export default router; diff --git a/src/api/routes/v9/-/readyz.ts b/src/api/routes/v9/-/readyz.ts new file mode 100644 index 00000000..d9d1c026 --- /dev/null +++ b/src/api/routes/v9/-/readyz.ts @@ -0,0 +1,13 @@ +import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; +import { getDatabase } from "@fosscord/util"; + +const router = Router(); + +router.get("/", route({}), (req: Request, res: Response) => { + if (!getDatabase()) return res.sendStatus(503); + + return res.sendStatus(200); +}); + +export default router; diff --git a/src/api/routes/v9/applications/#id/bot/index.ts b/src/api/routes/v9/applications/#id/bot/index.ts new file mode 100644 index 00000000..c4cfccd8 --- /dev/null +++ b/src/api/routes/v9/applications/#id/bot/index.ts @@ -0,0 +1,102 @@ +import { Request, Response, Router } from "express"; +import { route } from "@fosscord/api"; +import { + Application, + generateToken, + User, + BotModifySchema, + handleFile, + DiscordApiErrors, +} from "@fosscord/util"; +import { HTTPError } from "lambert-server"; +import { verifyToken } from "node-2fa"; + +const router: Router = Router(); + +router.post("/", route({}), async (req: Request, res: Response) => { + const app = await Application.findOneOrFail({ + where: { id: req.params.id }, + relations: ["owner"], + }); + + if (app.owner.id != req.user_id) + throw DiscordApiErrors.ACTION_NOT_AUTHORIZED_ON_APPLICATION; + + const user = await User.register({ + username: app.name, + password: undefined, + id: app.id, + req, + }); + + user.id = app.id; + user.premium_since = new Date(); + user.bot = true; + + await user.save(); + + // flags is NaN here? + app.assign({ bot: user, flags: app.flags || 0 }); + + await app.save(); + + res.send({ + token: await generateToken(user.id), + }).status(204); +}); + +router.post("/reset", route({}), async (req: Request, res: Response) => { + let bot = await User.findOneOrFail({ where: { id: req.params.id } }); + let owner = await User.findOneOrFail({ where: { id: req.user_id } }); + + if (owner.id != req.user_id) + throw DiscordApiErrors.ACTION_NOT_AUTHORIZED_ON_APPLICATION; + + if ( + owner.totp_secret && + (!req.body.code || verifyToken(owner.totp_secret, req.body.code)) + ) + throw new HTTPError(req.t("auth:login.INVALID_TOTP_CODE"), 60008); + + bot.data = { hash: undefined, valid_tokens_since: new Date() }; + + await bot.save(); + + let token = await generateToken(bot.id); + + res.json({ token }).status(200); +}); + +router.patch( + "/", + route({ body: "BotModifySchema" }), + async (req: Request, res: Response) => { + const body = req.body as BotModifySchema; + if (!body.avatar?.trim()) delete body.avatar; + + const app = await Application.findOneOrFail({ + where: { id: req.params.id }, + relations: ["bot", "owner"], + }); + + if (!app.bot) throw DiscordApiErrors.BOT_ONLY_ENDPOINT; + + if (app.owner.id != req.user_id) + throw DiscordApiErrors.ACTION_NOT_AUTHORIZED_ON_APPLICATION; + + if (body.avatar) + body.avatar = await handleFile( + `/avatars/${app.id}`, + body.avatar as string, + ); + + app.bot.assign(body); + + app.bot.save(); + + await app.save(); + res.json(app).status(200); + }, +); + +export default router; diff --git a/src/api/routes/v9/applications/#id/entitlements.ts b/src/api/routes/v9/applications/#id/entitlements.ts new file mode 100644 index 00000000..cfcfe40f --- /dev/null +++ b/src/api/routes/v9/applications/#id/entitlements.ts @@ -0,0 +1,12 @@ +import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; + +const router = Router(); + +router.get("/", route({}), (req: Request, res: Response) => { + // TODO: + //const { exclude_consumed } = req.query; + res.status(200).send([]); +}); + +export default router; diff --git a/src/api/routes/v9/applications/#id/index.ts b/src/api/routes/v9/applications/#id/index.ts new file mode 100644 index 00000000..11cd5a56 --- /dev/null +++ b/src/api/routes/v9/applications/#id/index.ts @@ -0,0 +1,81 @@ +import { Request, Response, Router } from "express"; +import { route } from "@fosscord/api"; +import { + Application, + OrmUtils, + DiscordApiErrors, + ApplicationModifySchema, + User, +} from "@fosscord/util"; +import { verifyToken } from "node-2fa"; +import { HTTPError } from "lambert-server"; + +const router: Router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const app = await Application.findOneOrFail({ + where: { id: req.params.id }, + relations: ["owner", "bot"], + }); + if (app.owner.id != req.user_id) + throw DiscordApiErrors.ACTION_NOT_AUTHORIZED_ON_APPLICATION; + + return res.json(app); +}); + +router.patch( + "/", + route({ body: "ApplicationModifySchema" }), + async (req: Request, res: Response) => { + const body = req.body as ApplicationModifySchema; + + const app = await Application.findOneOrFail({ + where: { id: req.params.id }, + relations: ["owner", "bot"], + }); + + if (app.owner.id != req.user_id) + throw DiscordApiErrors.ACTION_NOT_AUTHORIZED_ON_APPLICATION; + + if ( + app.owner.totp_secret && + (!req.body.code || + verifyToken(app.owner.totp_secret, req.body.code)) + ) + throw new HTTPError(req.t("auth:login.INVALID_TOTP_CODE"), 60008); + + if (app.bot) { + app.bot.assign({ bio: body.description }); + await app.bot.save(); + } + + app.assign(body); + + await app.save(); + + return res.json(app); + }, +); + +router.post("/delete", route({}), async (req: Request, res: Response) => { + const app = await Application.findOneOrFail({ + where: { id: req.params.id }, + relations: ["bot", "owner"], + }); + if (app.owner.id != req.user_id) + throw DiscordApiErrors.ACTION_NOT_AUTHORIZED_ON_APPLICATION; + + if ( + app.owner.totp_secret && + (!req.body.code || verifyToken(app.owner.totp_secret, req.body.code)) + ) + throw new HTTPError(req.t("auth:login.INVALID_TOTP_CODE"), 60008); + + if (app.bot) await User.delete({ id: app.bot.id }); + + await Application.delete({ id: app.id }); + + res.send().status(200); +}); + +export default router; diff --git a/src/api/routes/v9/applications/index.ts b/src/api/routes/v9/applications/index.ts new file mode 100644 index 00000000..a6b35bfa --- /dev/null +++ b/src/api/routes/v9/applications/index.ts @@ -0,0 +1,42 @@ +import { Request, Response, Router } from "express"; +import { route } from "@fosscord/api"; +import { + Application, + ApplicationCreateSchema, + trimSpecial, + User, +} from "@fosscord/util"; + +const router: Router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + let results = await Application.find({ + where: { owner: { id: req.user_id } }, + relations: ["owner", "bot"], + }); + res.json(results).status(200); +}); + +router.post( + "/", + route({ body: "ApplicationCreateSchema" }), + async (req: Request, res: Response) => { + const body = req.body as ApplicationCreateSchema; + const user = await User.findOneOrFail({ where: { id: req.user_id } }); + + const app = Application.create({ + name: trimSpecial(body.name), + description: "", + bot_public: true, + owner: user, + verify_key: "IMPLEMENTME", + flags: 0, + }); + + await app.save(); + + res.json(app); + }, +); + +export default router; diff --git a/src/api/routes/v9/auth/generate-registration-tokens.ts b/src/api/routes/v9/auth/generate-registration-tokens.ts new file mode 100644 index 00000000..ba40bd9a --- /dev/null +++ b/src/api/routes/v9/auth/generate-registration-tokens.ts @@ -0,0 +1,49 @@ +import { route, random } from "@fosscord/api"; +import { Config, ValidRegistrationToken } from "@fosscord/util"; +import { Request, Response, Router } from "express"; + +const router: Router = Router(); +export default router; + +router.get( + "/", + route({ right: "OPERATOR" }), + async (req: Request, res: Response) => { + const count = req.query.count ? parseInt(req.query.count as string) : 1; + const length = req.query.length + ? parseInt(req.query.length as string) + : 255; + + let tokens: ValidRegistrationToken[] = []; + + for (let i = 0; i < count; i++) { + const token = ValidRegistrationToken.create({ + token: random(length), + expires_at: + Date.now() + + Config.get().security.defaultRegistrationTokenExpiration, + }); + tokens.push(token); + } + + // Why are these options used, exactly? + await ValidRegistrationToken.save(tokens, { + chunk: 1000, + reload: false, + transaction: false, + }); + + const ret = req.query.include_url + ? tokens.map( + (x) => + `${Config.get().general.frontPage}/register?token=${ + x.token + }`, + ) + : tokens.map((x) => x.token); + + if (req.query.plain) return res.send(ret.join("\n")); + + return res.json({ tokens: ret }); + }, +); diff --git a/src/api/routes/v9/auth/location-metadata.ts b/src/api/routes/v9/auth/location-metadata.ts new file mode 100644 index 00000000..0ae946ed --- /dev/null +++ b/src/api/routes/v9/auth/location-metadata.ts @@ -0,0 +1,17 @@ +import { Router, Request, Response } from "express"; +import { route } from "@fosscord/api"; +import { getIpAdress, IPAnalysis } from "@fosscord/api"; +const router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + //TODO + //Note: It's most likely related to legal. At the moment Discord hasn't finished this too + const country_code = (await IPAnalysis(getIpAdress(req))).country_code; + res.json({ + consent_required: false, + country_code: country_code, + promotional_email_opt_in: { required: true, pre_checked: false }, + }); +}); + +export default router; diff --git a/src/api/routes/v9/auth/login.ts b/src/api/routes/v9/auth/login.ts new file mode 100644 index 00000000..7434fa35 --- /dev/null +++ b/src/api/routes/v9/auth/login.ts @@ -0,0 +1,138 @@ +import { Request, Response, Router } from "express"; +import { route, getIpAdress, verifyCaptcha } from "@fosscord/api"; +import bcrypt from "bcrypt"; +import { + Config, + User, + generateToken, + adjustEmail, + FieldErrors, + LoginSchema, +} from "@fosscord/util"; +import crypto from "crypto"; + +const router: Router = Router(); +export default router; + +router.post( + "/", + route({ body: "LoginSchema" }), + async (req: Request, res: Response) => { + const { login, password, captcha_key, undelete } = + req.body as LoginSchema; + const email = adjustEmail(login); + + const config = Config.get(); + + if (config.login.requireCaptcha && config.security.captcha.enabled) { + const { sitekey, service } = config.security.captcha; + if (!captcha_key) { + return res.status(400).json({ + captcha_key: ["captcha-required"], + captcha_sitekey: sitekey, + captcha_service: service, + }); + } + + const ip = getIpAdress(req); + const verify = await verifyCaptcha(captcha_key, ip); + if (!verify.success) { + return res.status(400).json({ + captcha_key: verify["error-codes"], + captcha_sitekey: sitekey, + captcha_service: service, + }); + } + } + + const user = await User.findOneOrFail({ + where: [{ phone: login }, { email: email }], + select: [ + "data", + "id", + "disabled", + "deleted", + "settings", + "totp_secret", + "mfa_enabled", + ], + }).catch((e) => { + throw FieldErrors({ + login: { + message: req.t("auth:login.INVALID_LOGIN"), + code: "INVALID_LOGIN", + }, + }); + }); + + if (undelete) { + // undelete refers to un'disable' here + if (user.disabled) + await User.update({ id: user.id }, { disabled: false }); + if (user.deleted) + await User.update({ id: user.id }, { deleted: false }); + } else { + if (user.deleted) + return res.status(400).json({ + message: "This account is scheduled for deletion.", + code: 20011, + }); + if (user.disabled) + return res.status(400).json({ + message: req.t("auth:login.ACCOUNT_DISABLED"), + code: 20013, + }); + } + + // the salt is saved in the password refer to bcrypt docs + const same_password = await bcrypt.compare( + password, + user.data.hash || "", + ); + if (!same_password) { + throw FieldErrors({ + password: { + message: req.t("auth:login.INVALID_PASSWORD"), + code: "INVALID_PASSWORD", + }, + }); + } + + if (user.mfa_enabled) { + // TODO: This is not a discord.com ticket. I'm not sure what it is but I'm lazy + const ticket = crypto.randomBytes(40).toString("hex"); + + await User.update({ id: user.id }, { totp_last_ticket: ticket }); + + return res.json({ + ticket: ticket, + mfa: true, + sms: false, // TODO + token: null, + }); + } + + const token = await generateToken(user.id); + + // Notice this will have a different token structure, than discord + // Discord header is just the user id as string, which is not possible with npm-jsonwebtoken package + // https://user-images.githubusercontent.com/6506416/81051916-dd8c9900-8ec2-11ea-8794-daf12d6f31f0.png + + res.json({ token, settings: user.settings }); + }, +); + +/** + * POST /auth/login + * @argument { login: "email@gmail.com", password: "cleartextpassword", undelete: false, captcha_key: null, login_source: null, gift_code_sku_id: null, } + + * MFA required: + * @returns {"token": null, "mfa": true, "sms": true, "ticket": "SOME TICKET JWT TOKEN"} + + * Captcha required: + * @returns {"captcha_key": ["captcha-required"], "captcha_sitekey": null, "captcha_service": "recaptcha"} + + * Sucess: + * @returns {"token": "USERTOKEN", "settings": {"locale": "en", "theme": "dark"}} + + */ diff --git a/src/api/routes/v9/auth/logout.ts b/src/api/routes/v9/auth/logout.ts new file mode 100644 index 00000000..e1bdbea3 --- /dev/null +++ b/src/api/routes/v9/auth/logout.ts @@ -0,0 +1,17 @@ +import { route } from "@fosscord/api"; +import { Request, Response, Router } from "express"; + +const router: Router = Router(); +export default router; + +router.post("/", route({}), async (req: Request, res: Response) => { + if (req.body.provider != null || req.body.voip_provider != null) { + console.log(`[LOGOUT]: provider or voip provider not null!`, req.body); + } else { + delete req.body.provider; + delete req.body.voip_provider; + if (Object.keys(req.body).length != 0) + console.log(`[LOGOUT]: Extra fields sent in logout!`, req.body); + } + res.status(204).send(); +}); diff --git a/src/api/routes/v9/auth/mfa/totp.ts b/src/api/routes/v9/auth/mfa/totp.ts new file mode 100644 index 00000000..83cf7648 --- /dev/null +++ b/src/api/routes/v9/auth/mfa/totp.ts @@ -0,0 +1,52 @@ +import { Router, Request, Response } from "express"; +import { route } from "@fosscord/api"; +import { BackupCode, generateToken, User, TotpSchema } from "@fosscord/util"; +import { verifyToken } from "node-2fa"; +import { HTTPError } from "lambert-server"; +const router = Router(); + +router.post( + "/", + route({ body: "TotpSchema" }), + async (req: Request, res: Response) => { + const { code, ticket, gift_code_sku_id, login_source } = + req.body as TotpSchema; + + const user = await User.findOneOrFail({ + where: { + totp_last_ticket: ticket, + }, + select: ["id", "totp_secret", "settings"], + }); + + const backup = await BackupCode.findOne({ + where: { + code: code, + expired: false, + consumed: false, + user: { id: user.id }, + }, + }); + + if (!backup) { + const ret = verifyToken(user.totp_secret!, code); + if (!ret || ret.delta != 0) + throw new HTTPError( + req.t("auth:login.INVALID_TOTP_CODE"), + 60008, + ); + } else { + backup.consumed = true; + await backup.save(); + } + + await User.update({ id: user.id }, { totp_last_ticket: "" }); + + return res.json({ + token: await generateToken(user.id), + user_settings: user.settings, + }); + }, +); + +export default router; diff --git a/src/api/routes/v9/auth/register.ts b/src/api/routes/v9/auth/register.ts new file mode 100644 index 00000000..3d968114 --- /dev/null +++ b/src/api/routes/v9/auth/register.ts @@ -0,0 +1,278 @@ +import { Request, Response, Router } from "express"; +import { + Config, + generateToken, + Invite, + FieldErrors, + User, + adjustEmail, + RegisterSchema, + ValidRegistrationToken, +} from "@fosscord/util"; +import { + route, + getIpAdress, + IPAnalysis, + isProxy, + verifyCaptcha, +} from "@fosscord/api"; +import bcrypt from "bcrypt"; +import { HTTPError } from "lambert-server"; +import { LessThan, MoreThan } from "typeorm"; + +const router: Router = Router(); + +router.post( + "/", + route({ body: "RegisterSchema" }), + async (req: Request, res: Response) => { + const body = req.body as RegisterSchema; + const { register, security, limits } = Config.get(); + const ip = getIpAdress(req); + + // Reg tokens + // They're a one time use token that bypasses registration limits ( rates, disabled reg, etc ) + let regTokenUsed = false; + if (req.get("Referrer") && req.get("Referrer")?.includes("token=")) { + // eg theyre on https://staging.fosscord.com/register?token=whatever + const token = req.get("Referrer")!.split("token=")[1].split("&")[0]; + if (token) { + const regToken = await ValidRegistrationToken.findOne({ + where: { token, expires_at: MoreThan(new Date()) }, + }); + await ValidRegistrationToken.delete({ token }); + regTokenUsed = true; + console.log( + `[REGISTER] Registration token ${token} used for registration!`, + ); + } else { + console.log( + `[REGISTER] Invalid registration token ${token} used for registration by ${ip}!`, + ); + } + } + + // email will be slightly modified version of the user supplied email -> e.g. protection against GMail Trick + let email = adjustEmail(body.email); + + // check if registration is allowed + if (!regTokenUsed && !register.allowNewRegistration) { + throw FieldErrors({ + email: { + code: "REGISTRATION_DISABLED", + message: req.t("auth:register.REGISTRATION_DISABLED"), + }, + }); + } + + // check if the user agreed to the Terms of Service + if (!body.consent) { + throw FieldErrors({ + consent: { + code: "CONSENT_REQUIRED", + message: req.t("auth:register.CONSENT_REQUIRED"), + }, + }); + } + + if (!regTokenUsed && register.disabled) { + throw FieldErrors({ + email: { + code: "DISABLED", + message: "registration is disabled on this instance", + }, + }); + } + + if ( + !regTokenUsed && + register.requireCaptcha && + security.captcha.enabled + ) { + const { sitekey, service } = security.captcha; + if (!body.captcha_key) { + return res?.status(400).json({ + captcha_key: ["captcha-required"], + captcha_sitekey: sitekey, + captcha_service: service, + }); + } + + const verify = await verifyCaptcha(body.captcha_key, ip); + if (!verify.success) { + return res.status(400).json({ + captcha_key: verify["error-codes"], + captcha_sitekey: sitekey, + captcha_service: service, + }); + } + } + + if (!regTokenUsed && !register.allowMultipleAccounts) { + // TODO: check if fingerprint was eligible generated + const exists = await User.findOne({ + where: { fingerprints: body.fingerprint }, + select: ["id"], + }); + + if (exists) { + throw FieldErrors({ + email: { + code: "EMAIL_ALREADY_REGISTERED", + message: req.t( + "auth:register.EMAIL_ALREADY_REGISTERED", + ), + }, + }); + } + } + + if (!regTokenUsed && register.blockProxies) { + if (isProxy(await IPAnalysis(ip))) { + console.log(`proxy ${ip} blocked from registration`); + throw new HTTPError("Your IP is blocked from registration"); + } + } + + // TODO: gift_code_sku_id? + // TODO: check password strength + + if (email) { + // replace all dots and chars after +, if its a gmail.com email + if (!email) { + throw FieldErrors({ + email: { + code: "INVALID_EMAIL", + message: req?.t("auth:register.INVALID_EMAIL"), + }, + }); + } + + // check if there is already an account with this email + const exists = await User.findOne({ where: { email: email } }); + + if (exists) { + throw FieldErrors({ + email: { + code: "EMAIL_ALREADY_REGISTERED", + message: req.t( + "auth:register.EMAIL_ALREADY_REGISTERED", + ), + }, + }); + } + } else if (register.email.required) { + throw FieldErrors({ + email: { + code: "BASE_TYPE_REQUIRED", + message: req.t("common:field.BASE_TYPE_REQUIRED"), + }, + }); + } + + if (register.dateOfBirth.required && !body.date_of_birth) { + throw FieldErrors({ + date_of_birth: { + code: "BASE_TYPE_REQUIRED", + message: req.t("common:field.BASE_TYPE_REQUIRED"), + }, + }); + } else if ( + register.dateOfBirth.required && + register.dateOfBirth.minimum + ) { + const minimum = new Date(); + minimum.setFullYear( + minimum.getFullYear() - register.dateOfBirth.minimum, + ); + body.date_of_birth = new Date(body.date_of_birth as Date); + + // higher is younger + if (body.date_of_birth > minimum) { + throw FieldErrors({ + date_of_birth: { + code: "DATE_OF_BIRTH_UNDERAGE", + message: req.t("auth:register.DATE_OF_BIRTH_UNDERAGE", { + years: register.dateOfBirth.minimum, + }), + }, + }); + } + } + + if (body.password) { + // the salt is saved in the password refer to bcrypt docs + body.password = await bcrypt.hash(body.password, 12); + } else if (register.password.required) { + throw FieldErrors({ + password: { + code: "BASE_TYPE_REQUIRED", + message: req.t("common:field.BASE_TYPE_REQUIRED"), + }, + }); + } + + if ( + !regTokenUsed && + !body.invite && + (register.requireInvite || + (register.guestsRequireInvite && !register.email)) + ) { + // require invite to register -> e.g. for organizations to send invites to their employees + throw FieldErrors({ + email: { + code: "INVITE_ONLY", + message: req.t("auth:register.INVITE_ONLY"), + }, + }); + } + + if ( + !regTokenUsed && + limits.absoluteRate.register.enabled && + (await User.count({ + where: { + created_at: MoreThan( + new Date( + Date.now() - limits.absoluteRate.register.window, + ), + ), + }, + })) >= limits.absoluteRate.register.limit + ) { + console.log( + `Global register ratelimit exceeded for ${getIpAdress(req)}, ${ + req.body.username + }, ${req.body.invite || "No invite given"}`, + ); + throw FieldErrors({ + email: { + code: "TOO_MANY_REGISTRATIONS", + message: req.t("auth:register.TOO_MANY_REGISTRATIONS"), + }, + }); + } + + const user = await User.register({ ...body, req }); + + if (body.invite) { + // await to fail if the invite doesn't exist (necessary for requireInvite to work properly) (username only signups are possible) + await Invite.joinGuild(user.id, body.invite); + } + + return res.json({ token: await generateToken(user.id) }); + }, +); + +export default router; + +/** + * POST /auth/register + * @argument { "fingerprint":"805826570869932034.wR8vi8lGlFBJerErO9LG5NViJFw", "email":"qo8etzvaf@gmail.com", "username":"qp39gr98", "password":"wtp9gep9gw", "invite":null, "consent":true, "date_of_birth":"2000-04-04", "gift_code_sku_id":null, "captcha_key":null} + * + * Field Error + * @returns { "code": 50035, "errors": { "consent": { "_errors": [{ "code": "CONSENT_REQUIRED", "message": "You must agree to Discord's Terms of Service and Privacy Policy." }]}}, "message": "Invalid Form Body"} + * + * Success 200: + * @returns {token: "OMITTED"} + */ diff --git a/src/api/routes/v9/auth/verify/view-backup-codes-challenge.ts b/src/api/routes/v9/auth/verify/view-backup-codes-challenge.ts new file mode 100644 index 00000000..65f0a57c --- /dev/null +++ b/src/api/routes/v9/auth/verify/view-backup-codes-challenge.ts @@ -0,0 +1,34 @@ +import { Router, Request, Response } from "express"; +import { route } from "@fosscord/api"; +import { FieldErrors, User, BackupCodesChallengeSchema } from "@fosscord/util"; +import bcrypt from "bcrypt"; +const router = Router(); + +router.post( + "/", + route({ body: "BackupCodesChallengeSchema" }), + async (req: Request, res: Response) => { + const { password } = req.body as BackupCodesChallengeSchema; + + const user = await User.findOneOrFail({ + where: { id: req.user_id }, + select: ["data"], + }); + + if (!(await bcrypt.compare(password, user.data.hash || ""))) { + throw FieldErrors({ + password: { + message: req.t("auth:login.INVALID_PASSWORD"), + code: "INVALID_PASSWORD", + }, + }); + } + + return res.json({ + nonce: "NoncePlaceholder", + regenerate_nonce: "RegenNoncePlaceholder", + }); + }, +); + +export default router; diff --git a/src/api/routes/v9/channels/#channel_id/followers.ts b/src/api/routes/v9/channels/#channel_id/followers.ts new file mode 100644 index 00000000..641af4f8 --- /dev/null +++ b/src/api/routes/v9/channels/#channel_id/followers.ts @@ -0,0 +1,14 @@ +import { Router, Response, Request } from "express"; +const router: Router = Router(); +// TODO: + +export default router; + +/** + * + * @param {"webhook_channel_id":"754001514330062952"} + * + * Creates a WebHook in the channel and returns the id of it + * + * @returns {"channel_id": "816382962056560690", "webhook_id": "834910735095037962"} + */ diff --git a/src/api/routes/v9/channels/#channel_id/index.ts b/src/api/routes/v9/channels/#channel_id/index.ts new file mode 100644 index 00000000..a164fff6 --- /dev/null +++ b/src/api/routes/v9/channels/#channel_id/index.ts @@ -0,0 +1,103 @@ +import { + Channel, + ChannelDeleteEvent, + ChannelType, + ChannelUpdateEvent, + emitEvent, + Recipient, + handleFile, + ChannelModifySchema, +} from "@fosscord/util"; +import { Request, Response, Router } from "express"; +import { route } from "@fosscord/api"; + +const router: Router = Router(); +// TODO: delete channel +// TODO: Get channel + +router.get( + "/", + route({ permission: "VIEW_CHANNEL" }), + async (req: Request, res: Response) => { + const { channel_id } = req.params; + + const channel = await Channel.findOneOrFail({ + where: { id: channel_id }, + }); + + return res.send(channel); + }, +); + +router.delete( + "/", + route({ permission: "MANAGE_CHANNELS" }), + async (req: Request, res: Response) => { + const { channel_id } = req.params; + + const channel = await Channel.findOneOrFail({ + where: { id: channel_id }, + relations: ["recipients"], + }); + + if (channel.type === ChannelType.DM) { + const recipient = await Recipient.findOneOrFail({ + where: { channel_id: channel_id, user_id: req.user_id }, + }); + recipient.closed = true; + await Promise.all([ + recipient.save(), + emitEvent({ + event: "CHANNEL_DELETE", + data: channel, + user_id: req.user_id, + } as ChannelDeleteEvent), + ]); + } else if (channel.type === ChannelType.GROUP_DM) { + await Channel.removeRecipientFromChannel(channel, req.user_id); + } else { + await Promise.all([ + Channel.delete({ id: channel_id }), + emitEvent({ + event: "CHANNEL_DELETE", + data: channel, + channel_id, + } as ChannelDeleteEvent), + ]); + } + + res.send(channel); + }, +); + +router.patch( + "/", + route({ body: "ChannelModifySchema", permission: "MANAGE_CHANNELS" }), + async (req: Request, res: Response) => { + var payload = req.body as ChannelModifySchema; + const { channel_id } = req.params; + if (payload.icon) + payload.icon = await handleFile( + `/channel-icons/${channel_id}`, + payload.icon, + ); + + const channel = await Channel.findOneOrFail({ + where: { id: channel_id }, + }); + channel.assign(payload); + + await Promise.all([ + channel.save(), + emitEvent({ + event: "CHANNEL_UPDATE", + data: channel, + channel_id, + } as ChannelUpdateEvent), + ]); + + res.send(channel); + }, +); + +export default router; diff --git a/src/api/routes/v9/channels/#channel_id/invites.ts b/src/api/routes/v9/channels/#channel_id/invites.ts new file mode 100644 index 00000000..afa5201b --- /dev/null +++ b/src/api/routes/v9/channels/#channel_id/invites.ts @@ -0,0 +1,91 @@ +import { Router, Request, Response } from "express"; +import { HTTPError } from "lambert-server"; +import { route } from "@fosscord/api"; +import { random } from "@fosscord/api"; +import { + Channel, + Invite, + InviteCreateEvent, + emitEvent, + User, + Guild, + PublicInviteRelation, +} from "@fosscord/util"; +import { isTextChannel } from "../../../v0/channels/#channel_id/messages"; + +const router: Router = Router(); + +router.post( + "/", + route({ + body: "InviteCreateSchema", + permission: "CREATE_INSTANT_INVITE", + right: "CREATE_INVITES", + }), + async (req: Request, res: Response) => { + const { user_id } = req; + const { channel_id } = req.params; + const channel = await Channel.findOneOrFail({ + where: { id: channel_id }, + select: ["id", "name", "type", "guild_id"], + }); + isTextChannel(channel.type); + + if (!channel.guild_id) { + throw new HTTPError("This channel doesn't exist", 404); + } + const { guild_id } = channel; + + const expires_at = new Date(req.body.max_age * 1000 + Date.now()); + + const invite = await Invite.create({ + code: random(), + temporary: req.body.temporary || true, + uses: 0, + max_uses: req.body.max_uses, + max_age: req.body.max_age, + expires_at, + created_at: new Date(), + guild_id, + channel_id: channel_id, + inviter_id: user_id, + }).save(); + const data = invite.toJSON(); + data.inviter = await User.getPublicUser(req.user_id); + data.guild = await Guild.findOne({ where: { id: guild_id } }); + data.channel = channel; + + await emitEvent({ + event: "INVITE_CREATE", + data, + guild_id, + } as InviteCreateEvent); + res.status(201).send(data); + }, +); + +router.get( + "/", + route({ permission: "MANAGE_CHANNELS" }), + async (req: Request, res: Response) => { + const { user_id } = req; + const { channel_id } = req.params; + const channel = await Channel.findOneOrFail({ + where: { id: channel_id }, + }); + + if (!channel.guild_id) { + throw new HTTPError("This channel doesn't exist", 404); + } + const { guild_id } = channel; + + const invites = await Invite.find({ + where: { guild_id }, + relations: PublicInviteRelation, + }); + + res.status(200).send(invites); + }, +); + +export default router; diff --git a/src/api/routes/v9/channels/#channel_id/messages/#message_id/ack.ts b/src/api/routes/v9/channels/#channel_id/messages/#message_id/ack.ts new file mode 100644 index 00000000..1a30143f --- /dev/null +++ b/src/api/routes/v9/channels/#channel_id/messages/#message_id/ack.ts @@ -0,0 +1,52 @@ +import { + emitEvent, + getPermission, + MessageAckEvent, + ReadState, +} from "@fosscord/util"; +import { Request, Response, Router } from "express"; +import { route } from "@fosscord/api"; + +const router = Router(); + +// TODO: public read receipts & privacy scoping +// TODO: send read state event to all channel members +// TODO: advance-only notification cursor + +router.post( + "/", + route({ body: "MessageAcknowledgeSchema" }), + async (req: Request, res: Response) => { + const { channel_id, message_id } = req.params; + + const permission = await getPermission( + req.user_id, + undefined, + channel_id, + ); + permission.hasThrow("VIEW_CHANNEL"); + + let read_state = await ReadState.findOne({ + where: { user_id: req.user_id, channel_id }, + }); + if (!read_state) + read_state = ReadState.create({ user_id: req.user_id, channel_id }); + read_state.last_message_id = message_id; + + await read_state.save(); + + await emitEvent({ + event: "MESSAGE_ACK", + user_id: req.user_id, + data: { + channel_id, + message_id, + version: 3763, + }, + } as MessageAckEvent); + + res.json({ token: null }); + }, +); + +export default router; diff --git a/src/api/routes/v9/channels/#channel_id/messages/#message_id/crosspost.ts b/src/api/routes/v9/channels/#channel_id/messages/#message_id/crosspost.ts new file mode 100644 index 00000000..d8b55ccd --- /dev/null +++ b/src/api/routes/v9/channels/#channel_id/messages/#message_id/crosspost.ts @@ -0,0 +1,38 @@ +import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; + +const router = Router(); + +router.post( + "/", + route({ permission: "MANAGE_MESSAGES" }), + (req: Request, res: Response) => { + // TODO: + res.json({ + id: "", + type: 0, + content: "", + channel_id: "", + author: { + id: "", + username: "", + avatar: "", + discriminator: "", + public_flags: 64, + }, + attachments: [], + embeds: [], + mentions: [], + mention_roles: [], + pinned: false, + mention_everyone: false, + tts: false, + timestamp: "", + edited_timestamp: null, + flags: 1, + components: [], + }).status(200); + }, +); + +export default router; diff --git a/src/api/routes/v9/channels/#channel_id/messages/#message_id/index.ts b/src/api/routes/v9/channels/#channel_id/messages/#message_id/index.ts new file mode 100644 index 00000000..d57d9a1b --- /dev/null +++ b/src/api/routes/v9/channels/#channel_id/messages/#message_id/index.ts @@ -0,0 +1,246 @@ +import { + Attachment, + Channel, + emitEvent, + FosscordApiErrors, + getPermission, + getRights, + Message, + MessageCreateEvent, + MessageDeleteEvent, + MessageUpdateEvent, + Snowflake, + uploadFile, + MessageCreateSchema, + DiscordApiErrors, +} from "@fosscord/util"; +import { Router, Response, Request } from "express"; +import multer from "multer"; +import { route } from "@fosscord/api"; +import { handleMessage, postHandleMessage } from "@fosscord/api"; +import { HTTPError } from "lambert-server"; + +const router = Router(); +// TODO: message content/embed string length limit + +const messageUpload = multer({ + limits: { + fileSize: 1024 * 1024 * 100, + fields: 10, + files: 1, + }, + storage: multer.memoryStorage(), +}); // max upload 50 mb + +router.patch( + "/", + route({ + body: "MessageCreateSchema", + permission: "SEND_MESSAGES", + right: "SEND_MESSAGES", + }), + async (req: Request, res: Response) => { + const { message_id, channel_id } = req.params; + var body = req.body as MessageCreateSchema; + + const message = await Message.findOneOrFail({ + where: { id: message_id, channel_id }, + relations: ["attachments"], + }); + + const permissions = await getPermission( + req.user_id, + undefined, + channel_id, + ); + + const rights = await getRights(req.user_id); + + if (req.user_id !== message.author_id) { + if (!rights.has("MANAGE_MESSAGES")) { + permissions.hasThrow("MANAGE_MESSAGES"); + body = { flags: body.flags }; + // guild admins can only suppress embeds of other messages, no such restriction imposed to instance-wide admins + } + } else rights.hasThrow("SELF_EDIT_MESSAGES"); + + const new_message = await handleMessage({ + ...message, + // TODO: should message_reference be overridable? + // @ts-ignore + message_reference: message.message_reference, + ...body, + author_id: message.author_id, + channel_id, + id: message_id, + edited_timestamp: new Date(), + }); + + await Promise.all([ + new_message.save(), + await emitEvent({ + event: "MESSAGE_UPDATE", + channel_id, + data: { ...new_message, nonce: undefined }, + } as MessageUpdateEvent), + ]); + + postHandleMessage(new_message); + + return res.json(new_message); + }, +); + +// Backfill message with specific timestamp +router.put( + "/", + messageUpload.single("file"), + async (req, res, next) => { + if (req.body.payload_json) { + req.body = JSON.parse(req.body.payload_json); + } + + next(); + }, + route({ + body: "MessageCreateSchema", + permission: "SEND_MESSAGES", + right: "SEND_BACKDATED_EVENTS", + }), + async (req: Request, res: Response) => { + const { channel_id, message_id } = req.params; + var body = req.body as MessageCreateSchema; + const attachments: Attachment[] = []; + + const rights = await getRights(req.user_id); + rights.hasThrow("SEND_MESSAGES"); + + // regex to check if message contains anything other than numerals ( also no decimals ) + if (!message_id.match(/^\+?\d+$/)) { + throw new HTTPError("Message IDs must be positive integers", 400); + } + + const snowflake = Snowflake.deconstruct(message_id); + if (Date.now() < snowflake.timestamp) { + // message is in the future + throw FosscordApiErrors.CANNOT_BACKFILL_TO_THE_FUTURE; + } + + const exists = await Message.findOne({ + where: { id: message_id, channel_id: channel_id }, + }); + if (exists) { + throw FosscordApiErrors.CANNOT_REPLACE_BY_BACKFILL; + } + + if (req.file) { + try { + const file = await uploadFile( + `/attachments/${req.params.channel_id}`, + req.file, + ); + attachments.push( + Attachment.create({ ...file, proxy_url: file.url }), + ); + } catch (error) { + return res.status(400).json(error); + } + } + const channel = await Channel.findOneOrFail({ + where: { id: channel_id }, + relations: ["recipients", "recipients.user"], + }); + + const embeds = body.embeds || []; + if (body.embed) embeds.push(body.embed); + let message = await handleMessage({ + ...body, + type: 0, + pinned: false, + author_id: req.user_id, + id: message_id, + embeds, + channel_id, + attachments, + edited_timestamp: undefined, + timestamp: new Date(snowflake.timestamp), + }); + + //Fix for the client bug + delete message.member; + + await Promise.all([ + message.save(), + emitEvent({ + event: "MESSAGE_CREATE", + channel_id: channel_id, + data: message, + } as MessageCreateEvent), + channel.save(), + ]); + + postHandleMessage(message).catch((e) => {}); // no await as it shouldnt block the message send function and silently catch error + + return res.json(message); + }, +); + +router.get( + "/", + route({ permission: "VIEW_CHANNEL" }), + async (req: Request, res: Response) => { + const { message_id, channel_id } = req.params; + + const message = await Message.findOneOrFail({ + where: { id: message_id, channel_id }, + relations: ["attachments"], + }); + + const permissions = await getPermission( + req.user_id, + undefined, + channel_id, + ); + + if (message.author_id !== req.user_id) + permissions.hasThrow("READ_MESSAGE_HISTORY"); + + return res.json(message); + }, +); + +router.delete("/", route({}), async (req: Request, res: Response) => { + const { message_id, channel_id } = req.params; + + const channel = await Channel.findOneOrFail({ where: { id: channel_id } }); + const message = await Message.findOneOrFail({ where: { id: message_id } }); + + const rights = await getRights(req.user_id); + + if (message.author_id !== req.user_id) { + if (!rights.has("MANAGE_MESSAGES")) { + const permission = await getPermission( + req.user_id, + channel.guild_id, + channel_id, + ); + permission.hasThrow("MANAGE_MESSAGES"); + } + } else rights.hasThrow("SELF_DELETE_MESSAGES"); + + await Message.delete({ id: message_id }); + + await emitEvent({ + event: "MESSAGE_DELETE", + channel_id, + data: { + id: message_id, + channel_id, + guild_id: channel.guild_id, + }, + } as MessageDeleteEvent); + + res.sendStatus(204); +}); + +export default router; diff --git a/src/api/routes/v9/channels/#channel_id/messages/#message_id/reactions.ts b/src/api/routes/v9/channels/#channel_id/messages/#message_id/reactions.ts new file mode 100644 index 00000000..9f774682 --- /dev/null +++ b/src/api/routes/v9/channels/#channel_id/messages/#message_id/reactions.ts @@ -0,0 +1,250 @@ +import { + Channel, + emitEvent, + Emoji, + getPermission, + Member, + Message, + MessageReactionAddEvent, + MessageReactionRemoveAllEvent, + MessageReactionRemoveEmojiEvent, + MessageReactionRemoveEvent, + PartialEmoji, + PublicUserProjection, + User, +} from "@fosscord/util"; +import { route } from "@fosscord/api"; +import { Router, Response, Request } from "express"; +import { HTTPError } from "lambert-server"; +import { In } from "typeorm"; + +const router = Router(); +// TODO: check if emoji is really an unicode emoji or a prperly encoded external emoji + +function getEmoji(emoji: string): PartialEmoji { + emoji = decodeURIComponent(emoji); + const parts = emoji.includes(":") && emoji.split(":"); + if (parts) + return { + name: parts[0], + id: parts[1], + }; + + return { + id: undefined, + name: emoji, + }; +} + +router.delete( + "/", + route({ permission: "MANAGE_MESSAGES" }), + async (req: Request, res: Response) => { + const { message_id, channel_id } = req.params; + + const channel = await Channel.findOneOrFail({ + where: { id: channel_id }, + }); + + await Message.update({ id: message_id, channel_id }, { reactions: [] }); + + await emitEvent({ + event: "MESSAGE_REACTION_REMOVE_ALL", + channel_id, + data: { + channel_id, + message_id, + guild_id: channel.guild_id, + }, + } as MessageReactionRemoveAllEvent); + + res.sendStatus(204); + }, +); + +router.delete( + "/:emoji", + route({ permission: "MANAGE_MESSAGES" }), + async (req: Request, res: Response) => { + const { message_id, channel_id } = req.params; + const emoji = getEmoji(req.params.emoji); + + const message = await Message.findOneOrFail({ + where: { id: message_id, channel_id }, + }); + + const already_added = message.reactions.find( + (x) => + (x.emoji.id === emoji.id && emoji.id) || + x.emoji.name === emoji.name, + ); + if (!already_added) throw new HTTPError("Reaction not found", 404); + message.reactions.remove(already_added); + + await Promise.all([ + message.save(), + emitEvent({ + event: "MESSAGE_REACTION_REMOVE_EMOJI", + channel_id, + data: { + channel_id, + message_id, + guild_id: message.guild_id, + emoji, + }, + } as MessageReactionRemoveEmojiEvent), + ]); + + res.sendStatus(204); + }, +); + +router.get( + "/:emoji", + route({ permission: "VIEW_CHANNEL" }), + async (req: Request, res: Response) => { + const { message_id, channel_id } = req.params; + const emoji = getEmoji(req.params.emoji); + + const message = await Message.findOneOrFail({ + where: { id: message_id, channel_id }, + }); + const reaction = message.reactions.find( + (x) => + (x.emoji.id === emoji.id && emoji.id) || + x.emoji.name === emoji.name, + ); + if (!reaction) throw new HTTPError("Reaction not found", 404); + + const users = await User.find({ + where: { + id: In(reaction.user_ids), + }, + select: PublicUserProjection, + }); + + res.json(users); + }, +); + +router.put( + "/:emoji/:user_id", + route({ permission: "READ_MESSAGE_HISTORY", right: "SELF_ADD_REACTIONS" }), + async (req: Request, res: Response) => { + const { message_id, channel_id, user_id } = req.params; + if (user_id !== "@me") throw new HTTPError("Invalid user"); + const emoji = getEmoji(req.params.emoji); + + const channel = await Channel.findOneOrFail({ + where: { id: channel_id }, + }); + const message = await Message.findOneOrFail({ + where: { id: message_id, channel_id }, + }); + const already_added = message.reactions.find( + (x) => + (x.emoji.id === emoji.id && emoji.id) || + x.emoji.name === emoji.name, + ); + + if (!already_added) req.permission!.hasThrow("ADD_REACTIONS"); + + if (emoji.id) { + const external_emoji = await Emoji.findOneOrFail({ + where: { id: emoji.id }, + }); + if (!already_added) req.permission!.hasThrow("USE_EXTERNAL_EMOJIS"); + emoji.animated = external_emoji.animated; + emoji.name = external_emoji.name; + } + + if (already_added) { + if (already_added.user_ids.includes(req.user_id)) + return res.sendStatus(204); // Do not throw an error ¯\_(ツ)_/¯ as discord also doesn't throw any error + already_added.count++; + } else + message.reactions.push({ + count: 1, + emoji, + user_ids: [req.user_id], + }); + + await message.save(); + + const member = + channel.guild_id && + (await Member.findOneOrFail({ where: { id: req.user_id } })); + + await emitEvent({ + event: "MESSAGE_REACTION_ADD", + channel_id, + data: { + user_id: req.user_id, + channel_id, + message_id, + guild_id: channel.guild_id, + emoji, + member, + }, + } as MessageReactionAddEvent); + + res.sendStatus(204); + }, +); + +router.delete( + "/:emoji/:user_id", + route({}), + async (req: Request, res: Response) => { + var { message_id, channel_id, user_id } = req.params; + + const emoji = getEmoji(req.params.emoji); + + const channel = await Channel.findOneOrFail({ + where: { id: channel_id }, + }); + const message = await Message.findOneOrFail({ + where: { id: message_id, channel_id }, + }); + + if (user_id === "@me") user_id = req.user_id; + else { + const permissions = await getPermission( + req.user_id, + undefined, + channel_id, + ); + permissions.hasThrow("MANAGE_MESSAGES"); + } + + const already_added = message.reactions.find( + (x) => + (x.emoji.id === emoji.id && emoji.id) || + x.emoji.name === emoji.name, + ); + if (!already_added || !already_added.user_ids.includes(user_id)) + throw new HTTPError("Reaction not found", 404); + + already_added.count--; + + if (already_added.count <= 0) message.reactions.remove(already_added); + + await message.save(); + + await emitEvent({ + event: "MESSAGE_REACTION_REMOVE", + channel_id, + data: { + user_id: req.user_id, + channel_id, + message_id, + guild_id: channel.guild_id, + emoji, + }, + } as MessageReactionRemoveEvent); + + res.sendStatus(204); + }, +); + +export default router; diff --git a/src/api/routes/v9/channels/#channel_id/messages/bulk-delete.ts b/src/api/routes/v9/channels/#channel_id/messages/bulk-delete.ts new file mode 100644 index 00000000..553ab17e --- /dev/null +++ b/src/api/routes/v9/channels/#channel_id/messages/bulk-delete.ts @@ -0,0 +1,65 @@ +import { Router, Response, Request } from "express"; +import { + Channel, + Config, + emitEvent, + getPermission, + getRights, + MessageDeleteBulkEvent, + Message, +} from "@fosscord/util"; +import { HTTPError } from "lambert-server"; +import { route } from "@fosscord/api"; + +const router: Router = Router(); + +export default router; + +// should users be able to bulk delete messages or only bots? ANSWER: all users +// should this request fail, if you provide messages older than 14 days/invalid ids? ANSWER: NO +// https://discord.com/developers/docs/resources/channel#bulk-delete-messages +router.post( + "/", + route({ body: "BulkDeleteSchema" }), + async (req: Request, res: Response) => { + const { channel_id } = req.params; + const channel = await Channel.findOneOrFail({ + where: { id: channel_id }, + }); + if (!channel.guild_id) + throw new HTTPError("Can't bulk delete dm channel messages", 400); + + const rights = await getRights(req.user_id); + rights.hasThrow("SELF_DELETE_MESSAGES"); + + let superuser = rights.has("MANAGE_MESSAGES"); + const permission = await getPermission( + req.user_id, + channel?.guild_id, + channel_id, + ); + + const { maxBulkDelete } = Config.get().limits.message; + + const { messages } = req.body as { messages: string[] }; + if (messages.length === 0) + throw new HTTPError("You must specify messages to bulk delete"); + if (!superuser) { + permission.hasThrow("MANAGE_MESSAGES"); + if (messages.length > maxBulkDelete) + throw new HTTPError( + `You cannot delete more than ${maxBulkDelete} messages`, + ); + } + + await Message.delete(messages); + + await emitEvent({ + event: "MESSAGE_DELETE_BULK", + channel_id, + data: { ids: messages, channel_id, guild_id: channel.guild_id }, + } as MessageDeleteBulkEvent); + + res.sendStatus(204); + }, +); diff --git a/src/api/routes/v9/channels/#channel_id/messages/index.ts b/src/api/routes/v9/channels/#channel_id/messages/index.ts new file mode 100644 index 00000000..2968437d --- /dev/null +++ b/src/api/routes/v9/channels/#channel_id/messages/index.ts @@ -0,0 +1,351 @@ +import { Router, Response, Request } from "express"; +import { + Attachment, + Channel, + ChannelType, + Config, + DmChannelDTO, + emitEvent, + FieldErrors, + getPermission, + Message, + MessageCreateEvent, + Snowflake, + uploadFile, + Member, + Role, + MessageCreateSchema, + ReadState, + DiscordApiErrors, + getRights, + Rights, +} from "@fosscord/util"; +import { HTTPError } from "lambert-server"; +import { + handleMessage, + postHandleMessage, + route, + getIpAdress, +} from "@fosscord/api"; +import multer from "multer"; +import { yellow } from "picocolors"; +import { FindManyOptions, LessThan, MoreThan } from "typeorm"; +import { URL } from "url"; + +const router: Router = Router(); + +export default router; + +export function isTextChannel(type: ChannelType): boolean { + switch (type) { + case ChannelType.GUILD_STORE: + case ChannelType.GUILD_VOICE: + case ChannelType.GUILD_STAGE_VOICE: + case ChannelType.GUILD_CATEGORY: + case ChannelType.GUILD_FORUM: + case ChannelType.DIRECTORY: + throw new HTTPError("not a text channel", 400); + case ChannelType.DM: + case ChannelType.GROUP_DM: + case ChannelType.GUILD_NEWS: + case ChannelType.GUILD_NEWS_THREAD: + case ChannelType.GUILD_PUBLIC_THREAD: + case ChannelType.GUILD_PRIVATE_THREAD: + case ChannelType.GUILD_TEXT: + case ChannelType.ENCRYPTED: + case ChannelType.ENCRYPTED_THREAD: + return true; + default: + throw new HTTPError("unimplemented", 400); + } +} + +// https://discord.com/developers/docs/resources/channel#create-message +// get messages +router.get("/", async (req: Request, res: Response) => { + const channel_id = req.params.channel_id; + const channel = await Channel.findOneOrFail({ where: { id: channel_id } }); + if (!channel) throw new HTTPError("Channel not found", 404); + + isTextChannel(channel.type); + const around = req.query.around ? `${req.query.around}` : undefined; + const before = req.query.before ? `${req.query.before}` : undefined; + const after = req.query.after ? `${req.query.after}` : undefined; + const limit = Number(req.query.limit) || 50; + if (limit < 1 || limit > 100) + throw new HTTPError("limit must be between 1 and 100", 422); + + var halfLimit = Math.floor(limit / 2); + + const permissions = await getPermission( + req.user_id, + channel.guild_id, + channel_id, + ); + permissions.hasThrow("VIEW_CHANNEL"); + if (!permissions.has("READ_MESSAGE_HISTORY")) return res.json([]); + + var query: FindManyOptions & { where: { id?: any } } = { + order: { timestamp: "DESC" }, + take: limit, + where: { channel_id }, + relations: [ + "author", + "webhook", + "application", + "mentions", + "mention_roles", + "mention_channels", + "sticker_items", + "attachments", + ], + }; + + if (after) { + if (BigInt(after) > BigInt(Snowflake.generate())) + return res.status(422); + query.where.id = MoreThan(after); + } else if (before) { + if (BigInt(before) < BigInt(req.params.channel_id)) + return res.status(422); + query.where.id = LessThan(before); + } else if (around) { + query.where.id = [ + MoreThan((BigInt(around) - BigInt(halfLimit)).toString()), + LessThan((BigInt(around) + BigInt(halfLimit)).toString()), + ]; + + return res.json([]); // TODO: fix around + } + + const messages = await Message.find(query); + const endpoint = Config.get().cdn.endpointPublic; + + return res.json( + messages.map((x: any) => { + (x.reactions || []).forEach((x: any) => { + // @ts-ignore + if ((x.user_ids || []).includes(req.user_id)) x.me = true; + // @ts-ignore + delete x.user_ids; + }); + // @ts-ignore + if (!x.author) + x.author = { + id: "4", + discriminator: "0000", + username: "Fosscord Ghost", + public_flags: "0", + avatar: null, + }; + x.attachments?.forEach((y: any) => { + // dynamically set attachment proxy_url in case the endpoint changed + const uri = y.proxy_url.startsWith("http") + ? y.proxy_url + : `https://example.org${y.proxy_url}`; + y.proxy_url = `${endpoint == null ? "" : endpoint}${ + new URL(uri).pathname + }`; + }); + + /** + Some clients ( discord.js ) only check if a property exists within the response, + which causes erorrs when, say, the `application` property is `null`. + **/ + + // for (var curr in x) { + // if (x[curr] === null) + // delete x[curr]; + // } + + return x; + }), + ); +}); + +// TODO: config max upload size +const messageUpload = multer({ + limits: { + fileSize: Config.get().limits.message.maxAttachmentSize, + fields: 10, + // files: 1 + }, + storage: multer.memoryStorage(), +}); // max upload 50 mb +/** + TODO: dynamically change limit of MessageCreateSchema with config + + https://discord.com/developers/docs/resources/channel#create-message + TODO: text channel slowdown (per-user and across-users) + Q: trim and replace message content and every embed field A: NO, given this cannot be implemented in E2EE channels + TODO: only dispatch notifications for mentions denoted in allowed_mentions +**/ +// Send message +router.post( + "/", + messageUpload.any(), + (req, res, next) => { + if (req.body.payload_json) { + req.body = JSON.parse(req.body.payload_json); + } + + next(); + }, + route({ + body: "MessageCreateSchema", + permission: "SEND_MESSAGES", + right: "SEND_MESSAGES", + }), + async (req: Request, res: Response) => { + const { channel_id } = req.params; + var body = req.body as MessageCreateSchema; + const attachments: Attachment[] = []; + + const channel = await Channel.findOneOrFail({ + where: { id: channel_id }, + relations: ["recipients", "recipients.user"], + }); + if (!channel.isWritable()) { + throw new HTTPError( + `Cannot send messages to channel of type ${channel.type}`, + 400, + ); + } + + if (body.nonce) { + const existing = await Message.findOne({ + where: { + nonce: body.nonce, + channel_id: channel.id, + author_id: req.user_id, + }, + }); + if (existing) { + return res.json(existing); + } + } + + if (!req.rights.has(Rights.FLAGS.BYPASS_RATE_LIMITS)) { + var limits = Config.get().limits; + if (limits.absoluteRate.register.enabled) { + const count = await Message.count({ + where: { + channel_id, + timestamp: MoreThan( + new Date( + Date.now() - + limits.absoluteRate.sendMessage.window, + ), + ), + }, + }); + + if (count >= limits.absoluteRate.sendMessage.limit) + throw FieldErrors({ + channel_id: { + code: "TOO_MANY_MESSAGES", + message: req.t("common:toomany.MESSAGE"), + }, + }); + } + } + + const files = (req.files as Express.Multer.File[]) ?? []; + for (var currFile of files) { + try { + const file = await uploadFile( + `/attachments/${channel.id}`, + currFile, + ); + attachments.push( + Attachment.create({ ...file, proxy_url: file.url }), + ); + } catch (error) { + return res.status(400).json({ message: error!.toString() }); + } + } + + const embeds = body.embeds || []; + if (body.embed) embeds.push(body.embed); + let message = await handleMessage({ + ...body, + type: 0, + pinned: false, + author_id: req.user_id, + embeds, + channel_id, + attachments, + edited_timestamp: undefined, + timestamp: new Date(), + }); + + channel.last_message_id = message.id; + + if (channel.isDm()) { + const channel_dto = await DmChannelDTO.from(channel); + + // Only one recipients should be closed here, since in group DMs the recipient is deleted not closed + await Promise.all( + channel.recipients!.map((recipient) => { + if (recipient.closed) { + recipient.closed = false; + return Promise.all([ + recipient.save(), + emitEvent({ + event: "CHANNEL_CREATE", + data: channel_dto.excludedRecipients([ + recipient.user_id, + ]), + user_id: recipient.user_id, + }), + ]); + } + }), + ); + } + + if (message.guild_id) { + // handleMessage will fetch the Member, but only if they are not guild owner. + // have to fetch ourselves otherwise. + if (!message.member) { + message.member = await Member.findOneOrFail({ + where: { id: req.user_id, guild_id: message.guild_id }, + relations: ["roles"], + }); + } + + //@ts-ignore + message.member.roles = message.member.roles + .filter((x) => x.id != x.guild_id) + .map((x) => x.id); + } + + let read_state = await ReadState.findOne({ + where: { user_id: req.user_id, channel_id }, + }); + if (!read_state) + read_state = ReadState.create({ user_id: req.user_id, channel_id }); + read_state.last_message_id = message.id; + + await Promise.all([ + read_state.save(), + message.save(), + emitEvent({ + event: "MESSAGE_CREATE", + channel_id: channel_id, + data: message, + } as MessageCreateEvent), + message.guild_id + ? Member.update( + { id: req.user_id, guild_id: message.guild_id }, + { last_message_id: message.id }, + ) + : null, + channel.save(), + ]); + + postHandleMessage(message).catch((e) => {}); // no await as it shouldnt block the message send function and silently catch error + + return res.json(message); + }, +); diff --git a/src/api/routes/v9/channels/#channel_id/permissions.ts b/src/api/routes/v9/channels/#channel_id/permissions.ts new file mode 100644 index 00000000..b08cd0c8 --- /dev/null +++ b/src/api/routes/v9/channels/#channel_id/permissions.ts @@ -0,0 +1,101 @@ +import { + Channel, + ChannelPermissionOverwrite, + ChannelUpdateEvent, + emitEvent, + Member, + Role, + ChannelPermissionOverwriteSchema, +} from "@fosscord/util"; +import { Router, Response, Request } from "express"; +import { HTTPError } from "lambert-server"; + +import { route } from "@fosscord/api"; +const router: Router = Router(); + +// TODO: Only permissions your bot has in the guild or channel can be allowed/denied (unless your bot has a MANAGE_ROLES overwrite in the channel) + +router.put( + "/:overwrite_id", + route({ + body: "ChannelPermissionOverwriteSchema", + permission: "MANAGE_ROLES", + }), + async (req: Request, res: Response) => { + const { channel_id, overwrite_id } = req.params; + const body = req.body as ChannelPermissionOverwriteSchema; + + var channel = await Channel.findOneOrFail({ + where: { id: channel_id }, + }); + if (!channel.guild_id) throw new HTTPError("Channel not found", 404); + + if (body.type === 0) { + if (!(await Role.count({ where: { id: overwrite_id } }))) + throw new HTTPError("role not found", 404); + } else if (body.type === 1) { + if (!(await Member.count({ where: { id: overwrite_id } }))) + throw new HTTPError("user not found", 404); + } else throw new HTTPError("type not supported", 501); + + //@ts-ignore + var overwrite: ChannelPermissionOverwrite = + channel.permission_overwrites?.find((x) => x.id === overwrite_id); + if (!overwrite) { + // @ts-ignore + overwrite = { + id: overwrite_id, + type: body.type, + }; + channel.permission_overwrites!.push(overwrite); + } + overwrite.allow = String( + req.permission!.bitfield & (BigInt(body.allow) || BigInt("0")), + ); + overwrite.deny = String( + req.permission!.bitfield & (BigInt(body.deny) || BigInt("0")), + ); + + await Promise.all([ + channel.save(), + emitEvent({ + event: "CHANNEL_UPDATE", + channel_id, + data: channel, + } as ChannelUpdateEvent), + ]); + + return res.sendStatus(204); + }, +); + +// TODO: check permission hierarchy +router.delete( + "/:overwrite_id", + route({ permission: "MANAGE_ROLES" }), + async (req: Request, res: Response) => { + const { channel_id, overwrite_id } = req.params; + + const channel = await Channel.findOneOrFail({ + where: { id: channel_id }, + }); + if (!channel.guild_id) throw new HTTPError("Channel not found", 404); + + channel.permission_overwrites = channel.permission_overwrites!.filter( + (x) => x.id === overwrite_id, + ); + + await Promise.all([ + channel.save(), + emitEvent({ + event: "CHANNEL_UPDATE", + channel_id, + data: channel, + } as ChannelUpdateEvent), + ]); + + return res.sendStatus(204); + }, +); + +export default router; diff --git a/src/api/routes/v9/channels/#channel_id/pins.ts b/src/api/routes/v9/channels/#channel_id/pins.ts new file mode 100644 index 00000000..d3f6960a --- /dev/null +++ b/src/api/routes/v9/channels/#channel_id/pins.ts @@ -0,0 +1,113 @@ +import { + Channel, + ChannelPinsUpdateEvent, + Config, + emitEvent, + getPermission, + Message, + MessageUpdateEvent, + DiscordApiErrors, +} from "@fosscord/util"; +import { Router, Request, Response } from "express"; +import { HTTPError } from "lambert-server"; +import { route } from "@fosscord/api"; + +const router: Router = Router(); + +router.put( + "/:message_id", + route({ permission: "VIEW_CHANNEL" }), + async (req: Request, res: Response) => { + const { channel_id, message_id } = req.params; + + const message = await Message.findOneOrFail({ + where: { id: message_id }, + }); + + // * in dm channels anyone can pin messages -> only check for guilds + if (message.guild_id) req.permission!.hasThrow("MANAGE_MESSAGES"); + + const pinned_count = await Message.count({ + where: { channel: { id: channel_id }, pinned: true }, + }); + const { maxPins } = Config.get().limits.channel; + if (pinned_count >= maxPins) + throw DiscordApiErrors.MAXIMUM_PINS.withParams(maxPins); + + await Promise.all([ + Message.update({ id: message_id }, { pinned: true }), + emitEvent({ + event: "MESSAGE_UPDATE", + channel_id, + data: message, + } as MessageUpdateEvent), + emitEvent({ + event: "CHANNEL_PINS_UPDATE", + channel_id, + data: { + channel_id, + guild_id: message.guild_id, + last_pin_timestamp: undefined, + }, + } as ChannelPinsUpdateEvent), + ]); + + res.sendStatus(204); + }, +); + +router.delete( + "/:message_id", + route({ permission: "VIEW_CHANNEL" }), + async (req: Request, res: Response) => { + const { channel_id, message_id } = req.params; + + const channel = await Channel.findOneOrFail({ + where: { id: channel_id }, + }); + if (channel.guild_id) req.permission!.hasThrow("MANAGE_MESSAGES"); + + const message = await Message.findOneOrFail({ + where: { id: message_id }, + }); + message.pinned = false; + + await Promise.all([ + message.save(), + + emitEvent({ + event: "MESSAGE_UPDATE", + channel_id, + data: message, + } as MessageUpdateEvent), + + emitEvent({ + event: "CHANNEL_PINS_UPDATE", + channel_id, + data: { + channel_id, + guild_id: channel.guild_id, + last_pin_timestamp: undefined, + }, + } as ChannelPinsUpdateEvent), + ]); + + res.sendStatus(204); + }, +); + +router.get( + "/", + route({ permission: ["READ_MESSAGE_HISTORY"] }), + async (req: Request, res: Response) => { + const { channel_id } = req.params; + + let pins = await Message.find({ + where: { channel_id: channel_id, pinned: true }, + }); + + res.send(pins); + }, +); + +export default router; diff --git a/src/api/routes/v9/channels/#channel_id/purge.ts b/src/api/routes/v9/channels/#channel_id/purge.ts new file mode 100644 index 00000000..0be9ab7c --- /dev/null +++ b/src/api/routes/v9/channels/#channel_id/purge.ts @@ -0,0 +1,99 @@ +import { HTTPError } from "lambert-server"; +import { route } from "@fosscord/api"; +import { isTextChannel } from "../../../v0/channels/#channel_id/messages"; +import { FindManyOptions, Between, Not } from "typeorm"; +import { + Channel, + Config, + emitEvent, + getPermission, + getRights, + Message, + MessageDeleteBulkEvent, + PurgeSchema, +} from "@fosscord/util"; +import { Router, Response, Request } from "express"; + +const router: Router = Router(); + +export default router; + +/** +TODO: apply the delete bit by bit to prevent client and database stress +**/ +router.post( + "/", + route({ + /*body: "PurgeSchema",*/ + }), + async (req: Request, res: Response) => { + const { channel_id } = req.params; + const channel = await Channel.findOneOrFail({ + where: { id: channel_id }, + }); + + if (!channel.guild_id) + throw new HTTPError("Can't purge dm channels", 400); + isTextChannel(channel.type); + + const rights = await getRights(req.user_id); + if (!rights.has("MANAGE_MESSAGES")) { + const permissions = await getPermission( + req.user_id, + channel.guild_id, + channel_id, + ); + permissions.hasThrow("MANAGE_MESSAGES"); + permissions.hasThrow("MANAGE_CHANNELS"); + } + + const { before, after } = req.body as PurgeSchema; + + // TODO: send the deletion event bite-by-bite to prevent client stress + + var query: FindManyOptions & { where: { id?: any } } = { + order: { id: "ASC" }, + // take: limit, + where: { + channel_id, + id: Between(after, before), // the right way around + author_id: rights.has("SELF_DELETE_MESSAGES") + ? undefined + : Not(req.user_id), + // if you lack the right of self-deletion, you can't delete your own messages, even in purges + }, + relations: [ + "author", + "webhook", + "application", + "mentions", + "mention_roles", + "mention_channels", + "sticker_items", + "attachments", + ], + }; + + const messages = await Message.find(query); + const endpoint = Config.get().cdn.endpointPublic; + + if (messages.length == 0) { + res.sendStatus(304); + return; + } + + await Message.delete(messages.map((x) => x.id)); + + await emitEvent({ + event: "MESSAGE_DELETE_BULK", + channel_id, + data: { + ids: messages.map((x) => x.id), + channel_id, + guild_id: channel.guild_id, + }, + } as MessageDeleteBulkEvent); + + res.sendStatus(204); + }, +); diff --git a/src/api/routes/v9/channels/#channel_id/recipients.ts b/src/api/routes/v9/channels/#channel_id/recipients.ts new file mode 100644 index 00000000..cc7e5756 --- /dev/null +++ b/src/api/routes/v9/channels/#channel_id/recipients.ts @@ -0,0 +1,89 @@ +import { Request, Response, Router } from "express"; +import { + Channel, + ChannelRecipientAddEvent, + ChannelType, + DiscordApiErrors, + DmChannelDTO, + emitEvent, + PublicUserProjection, + Recipient, + User, +} from "@fosscord/util"; +import { route } from "@fosscord/api"; + +const router: Router = Router(); + +router.put("/:user_id", route({}), async (req: Request, res: Response) => { + const { channel_id, user_id } = req.params; + const channel = await Channel.findOneOrFail({ + where: { id: channel_id }, + relations: ["recipients"], + }); + + if (channel.type !== ChannelType.GROUP_DM) { + const recipients = [ + ...channel.recipients!.map((r) => r.user_id), + user_id, + ].unique(); + + const new_channel = await Channel.createDMChannel( + recipients, + req.user_id, + ); + return res.status(201).json(new_channel); + } else { + if (channel.recipients!.map((r) => r.user_id).includes(user_id)) { + throw DiscordApiErrors.INVALID_RECIPIENT; //TODO is this the right error? + } + + channel.recipients!.push( + Recipient.create({ channel_id: channel_id, user_id: user_id }), + ); + await channel.save(); + + await emitEvent({ + event: "CHANNEL_CREATE", + data: await DmChannelDTO.from(channel, [user_id]), + user_id: user_id, + }); + + await emitEvent({ + event: "CHANNEL_RECIPIENT_ADD", + data: { + channel_id: channel_id, + user: await User.findOneOrFail({ + where: { id: user_id }, + select: PublicUserProjection, + }), + }, + channel_id: channel_id, + } as ChannelRecipientAddEvent); + return res.sendStatus(204); + } +}); + +router.delete("/:user_id", route({}), async (req: Request, res: Response) => { + const { channel_id, user_id } = req.params; + const channel = await Channel.findOneOrFail({ + where: { id: channel_id }, + relations: ["recipients"], + }); + if ( + !( + channel.type === ChannelType.GROUP_DM && + (channel.owner_id === req.user_id || user_id === req.user_id) + ) + ) + throw DiscordApiErrors.MISSING_PERMISSIONS; + + if (!channel.recipients!.map((r) => r.user_id).includes(user_id)) { + throw DiscordApiErrors.INVALID_RECIPIENT; //TODO is this the right error? + } + + await Channel.removeRecipientFromChannel(channel, user_id); + + return res.sendStatus(204); +}); + +export default router; diff --git a/src/api/routes/v9/channels/#channel_id/typing.ts b/src/api/routes/v9/channels/#channel_id/typing.ts new file mode 100644 index 00000000..03f76205 --- /dev/null +++ b/src/api/routes/v9/channels/#channel_id/typing.ts @@ -0,0 +1,45 @@ +import { Channel, emitEvent, Member, TypingStartEvent } from "@fosscord/util"; +import { route } from "@fosscord/api"; +import { Router, Request, Response } from "express"; + +const router: Router = Router(); + +router.post( + "/", + route({ permission: "SEND_MESSAGES" }), + async (req: Request, res: Response) => { + const { channel_id } = req.params; + const user_id = req.user_id; + const timestamp = Date.now(); + const channel = await Channel.findOneOrFail({ + where: { id: channel_id }, + }); + const member = await Member.findOne({ + where: { id: user_id, guild_id: channel.guild_id }, + relations: ["roles", "user"], + }); + + await emitEvent({ + event: "TYPING_START", + channel_id: channel_id, + data: { + ...(member + ? { + member: { + ...member, + roles: member?.roles?.map((x) => x.id), + }, + } + : null), + channel_id, + timestamp, + user_id, + guild_id: channel.guild_id, + }, + } as TypingStartEvent); + + res.sendStatus(204); + }, +); + +export default router; diff --git a/src/api/routes/v9/channels/#channel_id/webhooks.ts b/src/api/routes/v9/channels/#channel_id/webhooks.ts new file mode 100644 index 00000000..f2923f95 --- /dev/null +++ b/src/api/routes/v9/channels/#channel_id/webhooks.ts @@ -0,0 +1,66 @@ +import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; +import { + Channel, + Config, + handleFile, + trimSpecial, + User, + Webhook, + WebhookCreateSchema, + WebhookType, +} from "@fosscord/util"; +import { HTTPError } from "lambert-server"; +import { isTextChannel } from "../../../v0/channels/#channel_id/messages/index"; +import { DiscordApiErrors } from "@fosscord/util"; +import crypto from "crypto"; + +const router: Router = Router(); + +// TODO: use Image Data Type for avatar instead of String +router.post( + "/", + route({ body: "WebhookCreateSchema", permission: "MANAGE_WEBHOOKS" }), + async (req: Request, res: Response) => { + const channel_id = req.params.channel_id; + const channel = await Channel.findOneOrFail({ + where: { id: channel_id }, + }); + + isTextChannel(channel.type); + if (!channel.guild_id) throw new HTTPError("Not a guild channel", 400); + + const webhook_count = await Webhook.count({ where: { channel_id } }); + const { maxWebhooks } = Config.get().limits.channel; + if (webhook_count > maxWebhooks) + throw DiscordApiErrors.MAXIMUM_WEBHOOKS.withParams(maxWebhooks); + + var { avatar, name } = req.body as WebhookCreateSchema; + name = trimSpecial(name); + + // TODO: move this + if (name === "clyde") throw new HTTPError("Invalid name", 400); + if (name === "Fosscord Ghost") throw new HTTPError("Invalid name", 400); + + if (avatar) avatar = await handleFile(`/avatars/${channel_id}`, avatar); + + const hook = Webhook.create({ + type: WebhookType.Incoming, + name, + avatar, + guild_id: channel.guild_id, + channel_id: channel.id, + user_id: req.user_id, + token: crypto.randomBytes(24).toString("base64"), + }); + + const user = await User.getPublicUser(req.user_id); + + return res.json({ + ...hook, + user: user, + }); + }, +); + +export default router; diff --git a/src/api/routes/v9/discoverable-guilds.ts b/src/api/routes/v9/discoverable-guilds.ts new file mode 100644 index 00000000..428ca605 --- /dev/null +++ b/src/api/routes/v9/discoverable-guilds.ts @@ -0,0 +1,46 @@ +import { Guild, Config } from "@fosscord/util"; + +import { Router, Request, Response } from "express"; +import { route } from "@fosscord/api"; +import { Like } from "typeorm"; + +const router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const { offset, limit, categories } = req.query; + var showAllGuilds = Config.get().guild.discovery.showAllGuilds; + var configLimit = Config.get().guild.discovery.limit; + let guilds; + if (categories == undefined) { + guilds = showAllGuilds + ? await Guild.find({ take: Math.abs(Number(limit || configLimit)) }) + : await Guild.find({ + where: { features: Like(`%DISCOVERABLE%`) }, + take: Math.abs(Number(limit || configLimit)), + }); + } else { + guilds = showAllGuilds + ? await Guild.find({ + where: { primary_category_id: categories.toString() }, + take: Math.abs(Number(limit || configLimit)), + }) + : await Guild.find({ + where: { + primary_category_id: categories.toString(), + features: Like("%DISCOVERABLE%"), + }, + take: Math.abs(Number(limit || configLimit)), + }); + } + + const total = guilds ? guilds.length : undefined; + + res.send({ + total: total, + guilds: guilds, + offset: Number(offset || Config.get().guild.discovery.offset), + limit: Number(limit || configLimit), + }); +}); + +export default router; diff --git a/src/api/routes/v9/discovery.ts b/src/api/routes/v9/discovery.ts new file mode 100644 index 00000000..90450035 --- /dev/null +++ b/src/api/routes/v9/discovery.ts @@ -0,0 +1,20 @@ +import { Categories } from "@fosscord/util"; +import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; + +const router = Router(); + +router.get("/categories", route({}), async (req: Request, res: Response) => { + // TODO: + // Get locale instead + + const { locale, primary_only } = req.query; + + const out = primary_only + ? await Categories.find() + : await Categories.find({ where: { is_primary: true } }); + + res.send(out); +}); + +export default router; diff --git a/src/api/routes/v9/download/index.ts b/src/api/routes/v9/download/index.ts new file mode 100644 index 00000000..1c135f25 --- /dev/null +++ b/src/api/routes/v9/download/index.ts @@ -0,0 +1,34 @@ +import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; +import { FieldErrors, Release } from "@fosscord/util"; + +const router = Router(); + +/* + TODO: Putting the download route in /routes/download.ts doesn't register the route, for some reason + But putting it here *does* +*/ + +router.get("/", route({}), async (req: Request, res: Response) => { + const { platform } = req.query; + + if (!platform) + throw FieldErrors({ + platform: { + code: "BASE_TYPE_REQUIRED", + message: req.t("common:field.BASE_TYPE_REQUIRED"), + }, + }); + + const release = await Release.findOneOrFail({ + where: { + enabled: true, + platform: platform as string, + }, + order: { pub_date: "DESC" }, + }); + + res.redirect(release.url); +}); + +export default router; diff --git a/src/api/routes/v9/gateway/bot.ts b/src/api/routes/v9/gateway/bot.ts new file mode 100644 index 00000000..2e26d019 --- /dev/null +++ b/src/api/routes/v9/gateway/bot.ts @@ -0,0 +1,40 @@ +import { Config } from "@fosscord/util"; +import { Router, Response, Request } from "express"; +import { route, RouteOptions } from "@fosscord/api"; + +const router = Router(); + +export interface GatewayBotResponse { + url: string; + shards: number; + session_start_limit: { + total: number; + remaining: number; + reset_after: number; + max_concurrency: number; + }; +} + +const options: RouteOptions = { + test: { + response: { + body: "GatewayBotResponse", + }, + }, +}; + +router.get("/", route(options), (req: Request, res: Response) => { + const { endpointPublic } = Config.get().gateway; + res.json({ + url: endpointPublic || process.env.GATEWAY || "ws://localhost:3002", + shards: 1, + session_start_limit: { + total: 1000, + remaining: 999, + reset_after: 14400000, + max_concurrency: 1, + }, + }); +}); + +export default router; diff --git a/src/api/routes/v9/gateway/index.ts b/src/api/routes/v9/gateway/index.ts new file mode 100644 index 00000000..a6ed9dc4 --- /dev/null +++ b/src/api/routes/v9/gateway/index.ts @@ -0,0 +1,26 @@ +import { Config } from "@fosscord/util"; +import { Router, Response, Request } from "express"; +import { route, RouteOptions } from "@fosscord/api"; + +const router = Router(); + +export interface GatewayResponse { + url: string; +} + +const options: RouteOptions = { + test: { + response: { + body: "GatewayResponse", + }, + }, +}; + +router.get("/", route(options), (req: Request, res: Response) => { + const { endpointPublic } = Config.get().gateway; + res.json({ + url: endpointPublic || process.env.GATEWAY || "ws://localhost:3002", + }); +}); + +export default router; diff --git a/src/api/routes/v9/gifs/search.ts b/src/api/routes/v9/gifs/search.ts new file mode 100644 index 00000000..54352215 --- /dev/null +++ b/src/api/routes/v9/gifs/search.ts @@ -0,0 +1,31 @@ +import { Router, Response, Request } from "express"; +import fetch from "node-fetch"; +import ProxyAgent from "proxy-agent"; +import { route } from "@fosscord/api"; +import { getGifApiKey, parseGifResult } from "./trending"; + +const router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + // TODO: Custom providers + const { q, media_format, locale } = req.query; + + const apiKey = getGifApiKey(); + + const agent = new ProxyAgent(); + + const response = await fetch( + `https://g.tenor.com/v1/search?q=${q}&media_format=${media_format}&locale=${locale}&key=${apiKey}`, + { + agent, + method: "get", + headers: { "Content-Type": "application/json" }, + }, + ); + + const { results } = (await response.json()) as any; // TODO: types + + res.json(results.map(parseGifResult)).status(200); +}); + +export default router; diff --git a/src/api/routes/v9/gifs/trending-gifs.ts b/src/api/routes/v9/gifs/trending-gifs.ts new file mode 100644 index 00000000..e4b28e24 --- /dev/null +++ b/src/api/routes/v9/gifs/trending-gifs.ts @@ -0,0 +1,31 @@ +import { Router, Response, Request } from "express"; +import fetch from "node-fetch"; +import ProxyAgent from "proxy-agent"; +import { route } from "@fosscord/api"; +import { getGifApiKey, parseGifResult } from "./trending"; + +const router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + // TODO: Custom providers + const { media_format, locale } = req.query; + + const apiKey = getGifApiKey(); + + const agent = new ProxyAgent(); + + const response = await fetch( + `https://g.tenor.com/v1/trending?media_format=${media_format}&locale=${locale}&key=${apiKey}`, + { + agent, + method: "get", + headers: { "Content-Type": "application/json" }, + }, + ); + + const { results } = (await response.json()) as any; // TODO: types + + res.json(results.map(parseGifResult)).status(200); +}); + +export default router; diff --git a/src/api/routes/v9/gifs/trending.ts b/src/api/routes/v9/gifs/trending.ts new file mode 100644 index 00000000..58044ea5 --- /dev/null +++ b/src/api/routes/v9/gifs/trending.ts @@ -0,0 +1,72 @@ +import { Router, Response, Request } from "express"; +import fetch from "node-fetch"; +import ProxyAgent from "proxy-agent"; +import { route } from "@fosscord/api"; +import { Config } from "@fosscord/util"; +import { HTTPError } from "lambert-server"; + +const router = Router(); + +export function parseGifResult(result: any) { + return { + id: result.id, + title: result.title, + url: result.itemurl, + src: result.media[0].mp4.url, + gif_src: result.media[0].gif.url, + width: result.media[0].mp4.dims[0], + height: result.media[0].mp4.dims[1], + preview: result.media[0].mp4.preview, + }; +} + +export function getGifApiKey() { + const { enabled, provider, apiKey } = Config.get().gif; + if (!enabled) throw new HTTPError(`Gifs are disabled`); + if (provider !== "tenor" || !apiKey) + throw new HTTPError(`${provider} gif provider not supported`); + + return apiKey; +} + +router.get("/", route({}), async (req: Request, res: Response) => { + // TODO: Custom providers + // TODO: return gifs as mp4 + const { media_format, locale } = req.query; + + const apiKey = getGifApiKey(); + + const agent = new ProxyAgent(); + + const [responseSource, trendGifSource] = await Promise.all([ + fetch( + `https://g.tenor.com/v1/categories?locale=${locale}&key=${apiKey}`, + { + agent, + method: "get", + headers: { "Content-Type": "application/json" }, + }, + ), + fetch( + `https://g.tenor.com/v1/trending?locale=${locale}&key=${apiKey}`, + { + agent, + method: "get", + headers: { "Content-Type": "application/json" }, + }, + ), + ]); + + const { tags } = (await responseSource.json()) as any; // TODO: types + const { results } = (await trendGifSource.json()) as any; //TODO: types; + + res.json({ + categories: tags.map((x: any) => ({ + name: x.searchterm, + src: x.image, + })), + gifs: [parseGifResult(results[0])], + }).status(200); +}); + +export default router; diff --git a/src/api/routes/v9/guild-recommendations.ts b/src/api/routes/v9/guild-recommendations.ts new file mode 100644 index 00000000..8bf1e508 --- /dev/null +++ b/src/api/routes/v9/guild-recommendations.ts @@ -0,0 +1,30 @@ +import { Guild, Config } from "@fosscord/util"; + +import { Router, Request, Response } from "express"; +import { route } from "@fosscord/api"; +import { Like } from "typeorm"; + +const router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const { limit, personalization_disabled } = req.query; + var showAllGuilds = Config.get().guild.discovery.showAllGuilds; + + const genLoadId = (size: Number) => + [...Array(size)] + .map(() => Math.floor(Math.random() * 16).toString(16)) + .join(""); + + const guilds = showAllGuilds + ? await Guild.find({ take: Math.abs(Number(limit || 24)) }) + : await Guild.find({ + where: { features: Like("%DISCOVERABLE%") }, + take: Math.abs(Number(limit || 24)), + }); + res.send({ + recommended_guilds: guilds, + load_id: `server_recs/${genLoadId(32)}`, + }).status(200); +}); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/audit-logs.ts b/src/api/routes/v9/guilds/#guild_id/audit-logs.ts new file mode 100644 index 00000000..76a11f6b --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/audit-logs.ts @@ -0,0 +1,17 @@ +import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; +const router = Router(); + +//TODO: implement audit logs +router.get("/", route({}), async (req: Request, res: Response) => { + res.json({ + audit_log_entries: [], + users: [], + integrations: [], + webhooks: [], + guild_scheduled_events: [], + threads: [], + application_commands: [], + }); +}); +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/bans.ts b/src/api/routes/v9/guilds/#guild_id/bans.ts new file mode 100644 index 00000000..930985d7 --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/bans.ts @@ -0,0 +1,200 @@ +import { Request, Response, Router } from "express"; +import { + DiscordApiErrors, + emitEvent, + GuildBanAddEvent, + GuildBanRemoveEvent, + Ban, + User, + Member, + BanRegistrySchema, + BanModeratorSchema, +} from "@fosscord/util"; +import { HTTPError } from "lambert-server"; +import { getIpAdress, route } from "@fosscord/api"; + +const router: Router = Router(); + +/* TODO: Deleting the secrets is just a temporary go-around. Views should be implemented for both safety and better handling. */ + +router.get( + "/", + route({ permission: "BAN_MEMBERS" }), + async (req: Request, res: Response) => { + const { guild_id } = req.params; + + let bans = await Ban.find({ where: { guild_id: guild_id } }); + let promisesToAwait: object[] = []; + const bansObj: object[] = []; + + bans.filter((ban) => ban.user_id !== ban.executor_id); // pretend self-bans don't exist to prevent victim chasing + + bans.forEach((ban) => { + promisesToAwait.push(User.getPublicUser(ban.user_id)); + }); + + const bannedUsers: object[] = await Promise.all(promisesToAwait); + + bans.forEach((ban, index) => { + const user = bannedUsers[index] as User; + bansObj.push({ + reason: ban.reason, + user: { + username: user.username, + discriminator: user.discriminator, + id: user.id, + avatar: user.avatar, + public_flags: user.public_flags, + }, + }); + }); + + return res.json(bansObj); + }, +); + +router.get( + "/:user", + route({ permission: "BAN_MEMBERS" }), + async (req: Request, res: Response) => { + const { guild_id } = req.params; + const user_id = req.params.ban; + + let ban = (await Ban.findOneOrFail({ + where: { guild_id: guild_id, user_id: user_id }, + })) as BanRegistrySchema; + + if (ban.user_id === ban.executor_id) throw DiscordApiErrors.UNKNOWN_BAN; + // pretend self-bans don't exist to prevent victim chasing + + /* Filter secret from registry. */ + + ban = ban as BanModeratorSchema; + + delete ban.ip; + + return res.json(ban); + }, +); + +router.put( + "/:user_id", + route({ body: "BanCreateSchema", permission: "BAN_MEMBERS" }), + async (req: Request, res: Response) => { + const { guild_id } = req.params; + const banned_user_id = req.params.user_id; + + if ( + req.user_id === banned_user_id && + banned_user_id === req.permission!.cache.guild?.owner_id + ) + throw new HTTPError( + "You are the guild owner, hence can't ban yourself", + 403, + ); + + if (req.permission!.cache.guild?.owner_id === banned_user_id) + throw new HTTPError("You can't ban the owner", 400); + + const banned_user = await User.getPublicUser(banned_user_id); + + const ban = Ban.create({ + user_id: banned_user_id, + guild_id: guild_id, + ip: getIpAdress(req), + executor_id: req.user_id, + reason: req.body.reason, // || otherwise empty + }); + + await Promise.all([ + Member.removeFromGuild(banned_user_id, guild_id), + ban.save(), + emitEvent({ + event: "GUILD_BAN_ADD", + data: { + guild_id: guild_id, + user: banned_user, + }, + guild_id: guild_id, + } as GuildBanAddEvent), + ]); + + return res.json(ban); + }, +); + +router.put( + "/@me", + route({ body: "BanCreateSchema" }), + async (req: Request, res: Response) => { + const { guild_id } = req.params; + + const banned_user = await User.getPublicUser(req.params.user_id); + + if (req.permission!.cache.guild?.owner_id === req.params.user_id) + throw new HTTPError( + "You are the guild owner, hence can't ban yourself", + 403, + ); + + const ban = Ban.create({ + user_id: req.params.user_id, + guild_id: guild_id, + ip: getIpAdress(req), + executor_id: req.params.user_id, + reason: req.body.reason, // || otherwise empty + }); + + await Promise.all([ + Member.removeFromGuild(req.user_id, guild_id), + ban.save(), + emitEvent({ + event: "GUILD_BAN_ADD", + data: { + guild_id: guild_id, + user: banned_user, + }, + guild_id: guild_id, + } as GuildBanAddEvent), + ]); + + return res.json(ban); + }, +); + +router.delete( + "/:user_id", + route({ permission: "BAN_MEMBERS" }), + async (req: Request, res: Response) => { + const { guild_id, user_id } = req.params; + + let ban = await Ban.findOneOrFail({ + where: { guild_id: guild_id, user_id: user_id }, + }); + + if (ban.user_id === ban.executor_id) throw DiscordApiErrors.UNKNOWN_BAN; + // make self-bans irreversible and hide them from view to avoid victim chasing + + const banned_user = await User.getPublicUser(user_id); + + await Promise.all([ + Ban.delete({ + user_id: user_id, + guild_id, + }), + + emitEvent({ + event: "GUILD_BAN_REMOVE", + data: { + guild_id, + user: banned_user, + }, + guild_id, + } as GuildBanRemoveEvent), + ]); + + return res.status(204).send(); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/channels.ts b/src/api/routes/v9/guilds/#guild_id/channels.ts new file mode 100644 index 00000000..eae93607 --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/channels.ts @@ -0,0 +1,86 @@ +import { Router, Response, Request } from "express"; +import { + Channel, + ChannelUpdateEvent, + emitEvent, + ChannelModifySchema, + ChannelReorderSchema, +} from "@fosscord/util"; +import { HTTPError } from "lambert-server"; +import { route } from "@fosscord/api"; +const router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const { guild_id } = req.params; + const channels = await Channel.find({ where: { guild_id } }); + + res.json(channels); +}); + +router.post( + "/", + route({ body: "ChannelModifySchema", permission: "MANAGE_CHANNELS" }), + async (req: Request, res: Response) => { + // creates a new guild channel https://discord.com/developers/docs/resources/guild#create-guild-channel + const { guild_id } = req.params; + const body = req.body as ChannelModifySchema; + + const channel = await Channel.createChannel( + { ...body, guild_id }, + req.user_id, + ); + + res.status(201).json(channel); + }, +); + +router.patch( + "/", + route({ body: "ChannelReorderSchema", permission: "MANAGE_CHANNELS" }), + async (req: Request, res: Response) => { + // changes guild channel position + const { guild_id } = req.params; + const body = req.body as ChannelReorderSchema; + + await Promise.all([ + body.map(async (x) => { + if (x.position == null && !x.parent_id) + throw new HTTPError( + `You need to at least specify position or parent_id`, + 400, + ); + + const opts: any = {}; + if (x.position != null) opts.position = x.position; + + if (x.parent_id) { + opts.parent_id = x.parent_id; + const parent_channel = await Channel.findOneOrFail({ + where: { id: x.parent_id, guild_id }, + select: ["permission_overwrites"], + }); + if (x.lock_permissions) { + opts.permission_overwrites = + parent_channel.permission_overwrites; + } + } + + await Channel.update({ guild_id, id: x.id }, opts); + const channel = await Channel.findOneOrFail({ + where: { guild_id, id: x.id }, + }); + + await emitEvent({ + event: "CHANNEL_UPDATE", + data: channel, + channel_id: x.id, + guild_id, + } as ChannelUpdateEvent); + }), + ]); + + res.sendStatus(204); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/delete.ts b/src/api/routes/v9/guilds/#guild_id/delete.ts new file mode 100644 index 00000000..b951e4f4 --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/delete.ts @@ -0,0 +1,44 @@ +import { + Channel, + emitEvent, + GuildDeleteEvent, + Guild, + Member, + Message, + Role, + Invite, + Emoji, +} from "@fosscord/util"; +import { Router, Request, Response } from "express"; +import { HTTPError } from "lambert-server"; +import { route } from "@fosscord/api"; + +const router = Router(); + +// discord prefixes this route with /delete instead of using the delete method +// docs are wrong https://discord.com/developers/docs/resources/guild#delete-guild +router.post("/", route({}), async (req: Request, res: Response) => { + var { guild_id } = req.params; + + const guild = await Guild.findOneOrFail({ + where: { id: guild_id }, + select: ["owner_id"], + }); + if (guild.owner_id !== req.user_id) + throw new HTTPError("You are not the owner of this guild", 401); + + await Promise.all([ + Guild.delete({ id: guild_id }), // this will also delete all guild related data + emitEvent({ + event: "GUILD_DELETE", + data: { + id: guild_id, + }, + guild_id: guild_id, + } as GuildDeleteEvent), + ]); + + return res.sendStatus(204); +}); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/discovery-requirements.ts b/src/api/routes/v9/guilds/#guild_id/discovery-requirements.ts new file mode 100644 index 00000000..7e63c06b --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/discovery-requirements.ts @@ -0,0 +1,39 @@ +import { Guild, Config } from "@fosscord/util"; + +import { Router, Request, Response } from "express"; +import { route } from "@fosscord/api"; + +const router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const { guild_id } = req.params; + // TODO: + // Load from database + // Admin control, but for now it allows anyone to be discoverable + + res.send({ + guild_id: guild_id, + safe_environment: true, + healthy: true, + health_score_pending: false, + size: true, + nsfw_properties: {}, + protected: true, + sufficient: true, + sufficient_without_grace_period: true, + valid_rules_channel: true, + retention_healthy: true, + engagement_healthy: true, + age: true, + minimum_age: 0, + health_score: { + avg_nonnew_participators: 0, + avg_nonnew_communicators: 0, + num_intentful_joiners: 0, + perc_ret_w1_intentful: 0, + }, + minimum_size: 0, + }); +}); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/emojis.ts b/src/api/routes/v9/guilds/#guild_id/emojis.ts new file mode 100644 index 00000000..6e8570eb --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/emojis.ts @@ -0,0 +1,148 @@ +import { Router, Request, Response } from "express"; +import { + Config, + DiscordApiErrors, + emitEvent, + Emoji, + GuildEmojisUpdateEvent, + handleFile, + Member, + Snowflake, + User, + EmojiCreateSchema, + EmojiModifySchema, +} from "@fosscord/util"; +import { route } from "@fosscord/api"; + +const router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const { guild_id } = req.params; + + await Member.IsInGuildOrFail(req.user_id, guild_id); + + const emojis = await Emoji.find({ + where: { guild_id: guild_id }, + relations: ["user"], + }); + + return res.json(emojis); +}); + +router.get("/:emoji_id", route({}), async (req: Request, res: Response) => { + const { guild_id, emoji_id } = req.params; + + await Member.IsInGuildOrFail(req.user_id, guild_id); + + const emoji = await Emoji.findOneOrFail({ + where: { guild_id: guild_id, id: emoji_id }, + relations: ["user"], + }); + + return res.json(emoji); +}); + +router.post( + "/", + route({ + body: "EmojiCreateSchema", + permission: "MANAGE_EMOJIS_AND_STICKERS", + }), + async (req: Request, res: Response) => { + const { guild_id } = req.params; + const body = req.body as EmojiCreateSchema; + + const id = Snowflake.generate(); + const emoji_count = await Emoji.count({ + where: { guild_id: guild_id }, + }); + const { maxEmojis } = Config.get().limits.guild; + + if (emoji_count >= maxEmojis) + throw DiscordApiErrors.MAXIMUM_NUMBER_OF_EMOJIS_REACHED.withParams( + maxEmojis, + ); + if (body.require_colons == null) body.require_colons = true; + + const user = await User.findOneOrFail({ where: { id: req.user_id } }); + body.image = (await handleFile(`/emojis/${id}`, body.image)) as string; + + const emoji = await Emoji.create({ + id: id, + guild_id: guild_id, + ...body, + require_colons: body.require_colons ?? undefined, // schema allows nulls, db does not + user: user, + managed: false, + animated: false, // TODO: Add support animated emojis + available: true, + roles: [], + }).save(); + + await emitEvent({ + event: "GUILD_EMOJIS_UPDATE", + guild_id: guild_id, + data: { + guild_id: guild_id, + emojis: await Emoji.find({ where: { guild_id: guild_id } }), + }, + } as GuildEmojisUpdateEvent); + + return res.status(201).json(emoji); + }, +); + +router.patch( + "/:emoji_id", + route({ + body: "EmojiModifySchema", + permission: "MANAGE_EMOJIS_AND_STICKERS", + }), + async (req: Request, res: Response) => { + const { emoji_id, guild_id } = req.params; + const body = req.body as EmojiModifySchema; + + const emoji = await Emoji.create({ + ...body, + id: emoji_id, + guild_id: guild_id, + }).save(); + + await emitEvent({ + event: "GUILD_EMOJIS_UPDATE", + guild_id: guild_id, + data: { + guild_id: guild_id, + emojis: await Emoji.find({ where: { guild_id: guild_id } }), + }, + } as GuildEmojisUpdateEvent); + + return res.json(emoji); + }, +); + +router.delete( + "/:emoji_id", + route({ permission: "MANAGE_EMOJIS_AND_STICKERS" }), + async (req: Request, res: Response) => { + const { emoji_id, guild_id } = req.params; + + await Emoji.delete({ + id: emoji_id, + guild_id: guild_id, + }); + + await emitEvent({ + event: "GUILD_EMOJIS_UPDATE", + guild_id: guild_id, + data: { + guild_id: guild_id, + emojis: await Emoji.find({ where: { guild_id: guild_id } }), + }, + } as GuildEmojisUpdateEvent); + + res.sendStatus(204); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/index.ts b/src/api/routes/v9/guilds/#guild_id/index.ts new file mode 100644 index 00000000..79c20678 --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/index.ts @@ -0,0 +1,129 @@ +import { Request, Response, Router } from "express"; +import { + DiscordApiErrors, + emitEvent, + getPermission, + getRights, + Guild, + GuildUpdateEvent, + handleFile, + Member, + GuildUpdateSchema, + FosscordApiErrors, +} from "@fosscord/util"; +import { HTTPError } from "lambert-server"; +import { route } from "@fosscord/api"; + +const router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const { guild_id } = req.params; + + const [guild, member] = await Promise.all([ + Guild.findOneOrFail({ where: { id: guild_id } }), + Member.findOne({ where: { guild_id: guild_id, id: req.user_id } }), + ]); + if (!member) + throw new HTTPError( + "You are not a member of the guild you are trying to access", + 401, + ); + + // @ts-ignore + guild.joined_at = member?.joined_at; + + return res.send(guild); +}); + +router.patch( + "/", + route({ body: "GuildUpdateSchema" }), + async (req: Request, res: Response) => { + const body = req.body as GuildUpdateSchema; + const { guild_id } = req.params; + + const rights = await getRights(req.user_id); + const permission = await getPermission(req.user_id, guild_id); + + if (!rights.has("MANAGE_GUILDS") && !permission.has("MANAGE_GUILD")) + throw DiscordApiErrors.MISSING_PERMISSIONS.withParams( + "MANAGE_GUILDS", + ); + + var guild = await Guild.findOneOrFail({ + where: { id: guild_id }, + relations: ["emojis", "roles", "stickers"], + }); + + // TODO: guild update check image + + if (body.icon && body.icon != guild.icon) + body.icon = await handleFile(`/icons/${guild_id}`, body.icon); + + if (body.banner && body.banner !== guild.banner) + body.banner = await handleFile(`/banners/${guild_id}`, body.banner); + + if (body.splash && body.splash !== guild.splash) + body.splash = await handleFile( + `/splashes/${guild_id}`, + body.splash, + ); + + if ( + body.discovery_splash && + body.discovery_splash !== guild.discovery_splash + ) + body.discovery_splash = await handleFile( + `/discovery-splashes/${guild_id}`, + body.discovery_splash, + ); + + if (body.features) { + const diff = guild.features + .filter((x) => !body.features?.includes(x)) + .concat( + body.features.filter((x) => !guild.features.includes(x)), + ); + + // TODO move these + const MUTABLE_FEATURES = [ + "COMMUNITY", + "INVITES_DISABLED", + "DISCOVERABLE", + ]; + + for (var feature of diff) { + if (MUTABLE_FEATURES.includes(feature)) continue; + + throw FosscordApiErrors.FEATURE_IS_IMMUTABLE.withParams( + feature, + ); + } + + // for some reason, they don't update in the assign. + guild.features = body.features; + } + + // TODO: check if body ids are valid + guild.assign(body); + + const data = guild.toJSON(); + // TODO: guild hashes + // TODO: fix vanity_url_code, template_id + delete data.vanity_url_code; + delete data.template_id; + + await Promise.all([ + guild.save(), + emitEvent({ + event: "GUILD_UPDATE", + data, + guild_id, + } as GuildUpdateEvent), + ]); + + return res.json(data); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/invites.ts b/src/api/routes/v9/guilds/#guild_id/invites.ts new file mode 100644 index 00000000..4d033e9c --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/invites.ts @@ -0,0 +1,22 @@ +import { getPermission, Invite, PublicInviteRelation } from "@fosscord/util"; +import { route } from "@fosscord/api"; +import { Request, Response, Router } from "express"; + +const router = Router(); + +router.get( + "/", + route({ permission: "MANAGE_GUILD" }), + async (req: Request, res: Response) => { + const { guild_id } = req.params; + + const invites = await Invite.find({ + where: { guild_id }, + relations: PublicInviteRelation, + }); + + return res.json(invites); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/members/#member_id/index.ts b/src/api/routes/v9/guilds/#guild_id/members/#member_id/index.ts new file mode 100644 index 00000000..0fcdd57c --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/members/#member_id/index.ts @@ -0,0 +1,130 @@ +import { Request, Response, Router } from "express"; +import { + Member, + getPermission, + getRights, + Role, + GuildMemberUpdateEvent, + emitEvent, + Sticker, + Emoji, + Guild, + handleFile, + MemberChangeSchema, +} from "@fosscord/util"; +import { route } from "@fosscord/api"; + +const router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const { guild_id, member_id } = req.params; + await Member.IsInGuildOrFail(req.user_id, guild_id); + + const member = await Member.findOneOrFail({ + where: { id: member_id, guild_id }, + }); + + return res.json(member); +}); + +router.patch( + "/", + route({ body: "MemberChangeSchema" }), + async (req: Request, res: Response) => { + let { guild_id, member_id } = req.params; + if (member_id === "@me") member_id = req.user_id; + const body = req.body as MemberChangeSchema; + + let member = await Member.findOneOrFail({ + where: { id: member_id, guild_id }, + relations: ["roles", "user"], + }); + const permission = await getPermission(req.user_id, guild_id); + const everyone = await Role.findOneOrFail({ + where: { guild_id: guild_id, name: "@everyone", position: 0 }, + }); + + if (body.avatar) + body.avatar = await handleFile( + `/guilds/${guild_id}/users/${member_id}/avatars`, + body.avatar as string, + ); + + member.assign(body); + + if ("roles" in body) { + permission.hasThrow("MANAGE_ROLES"); + + body.roles = body.roles || []; + body.roles.filter((x) => !!x); + + if (body.roles.indexOf(everyone.id) === -1) + body.roles.push(everyone.id); + member.roles = body.roles.map((x) => Role.create({ id: x })); // foreign key constraint will fail if role doesn't exist + } + + await member.save(); + + member.roles = member.roles.filter((x) => x.id !== everyone.id); + + // do not use promise.all as we have to first write to db before emitting the event to catch errors + await emitEvent({ + event: "GUILD_MEMBER_UPDATE", + guild_id, + data: { ...member, roles: member.roles.map((x) => x.id) }, + } as GuildMemberUpdateEvent); + + res.json(member); + }, +); + +router.put("/", route({}), async (req: Request, res: Response) => { + // TODO: Lurker mode + + const rights = await getRights(req.user_id); + + let { guild_id, member_id } = req.params; + if (member_id === "@me") { + member_id = req.user_id; + rights.hasThrow("JOIN_GUILDS"); + } else { + // TODO: join others by controller + } + + var guild = await Guild.findOneOrFail({ + where: { id: guild_id }, + }); + + var emoji = await Emoji.find({ + where: { guild_id: guild_id }, + }); + + var roles = await Role.find({ + where: { guild_id: guild_id }, + }); + + var stickers = await Sticker.find({ + where: { guild_id: guild_id }, + }); + + await Member.addToGuild(member_id, guild_id); + res.send({ ...guild, emojis: emoji, roles: roles, stickers: stickers }); +}); + +router.delete("/", route({}), async (req: Request, res: Response) => { + const { guild_id, member_id } = req.params; + const permission = await getPermission(req.user_id, guild_id); + const rights = await getRights(req.user_id); + if (member_id === "@me" || member_id === req.user_id) { + // TODO: unless force-joined + rights.hasThrow("SELF_LEAVE_GROUPS"); + } else { + rights.hasThrow("KICK_BAN_MEMBERS"); + permission.hasThrow("KICK_MEMBERS"); + } + + await Member.removeFromGuild(member_id, guild_id); + res.sendStatus(204); +}); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/members/#member_id/nick.ts b/src/api/routes/v9/guilds/#guild_id/members/#member_id/nick.ts new file mode 100644 index 00000000..20443821 --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/members/#member_id/nick.ts @@ -0,0 +1,26 @@ +import { getPermission, Member, PermissionResolvable } from "@fosscord/util"; +import { route } from "@fosscord/api"; +import { Request, Response, Router } from "express"; + +const router = Router(); + +router.patch( + "/", + route({ body: "MemberNickChangeSchema" }), + async (req: Request, res: Response) => { + var { guild_id, member_id } = req.params; + var permissionString: PermissionResolvable = "MANAGE_NICKNAMES"; + if (member_id === "@me") { + member_id = req.user_id; + permissionString = "CHANGE_NICKNAME"; + } + + const perms = await getPermission(req.user_id, guild_id); + perms.hasThrow(permissionString); + + await Member.changeNickname(member_id, guild_id, req.body.nick); + res.status(200).send(); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/members/#member_id/roles/#role_id/index.ts b/src/api/routes/v9/guilds/#guild_id/members/#member_id/roles/#role_id/index.ts new file mode 100644 index 00000000..c0383912 --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/members/#member_id/roles/#role_id/index.ts @@ -0,0 +1,29 @@ +import { getPermission, Member } from "@fosscord/util"; +import { route } from "@fosscord/api"; +import { Request, Response, Router } from "express"; + +const router = Router(); + +router.delete( + "/", + route({ permission: "MANAGE_ROLES" }), + async (req: Request, res: Response) => { + const { guild_id, role_id, member_id } = req.params; + + await Member.removeRole(member_id, guild_id, role_id); + res.sendStatus(204); + }, +); + +router.put( + "/", + route({ permission: "MANAGE_ROLES" }), + async (req: Request, res: Response) => { + const { guild_id, role_id, member_id } = req.params; + + await Member.addRole(member_id, guild_id, role_id); + res.sendStatus(204); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/members/index.ts b/src/api/routes/v9/guilds/#guild_id/members/index.ts new file mode 100644 index 00000000..b516b9e9 --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/members/index.ts @@ -0,0 +1,32 @@ +import { Request, Response, Router } from "express"; +import { Guild, Member, PublicMemberProjection } from "@fosscord/util"; +import { route } from "@fosscord/api"; +import { MoreThan } from "typeorm"; +import { HTTPError } from "lambert-server"; + +const router = Router(); + +// TODO: send over websocket +// TODO: check for GUILD_MEMBERS intent + +router.get("/", route({}), async (req: Request, res: Response) => { + const { guild_id } = req.params; + const limit = Number(req.query.limit) || 1; + if (limit > 1000 || limit < 1) + throw new HTTPError("Limit must be between 1 and 1000"); + const after = `${req.query.after}`; + const query = after ? { id: MoreThan(after) } : {}; + + await Member.IsInGuildOrFail(req.user_id, guild_id); + + const members = await Member.find({ + where: { guild_id, ...query }, + select: PublicMemberProjection, + take: limit, + order: { id: "ASC" }, + }); + + return res.json(members); +}); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/messages/search.ts b/src/api/routes/v9/guilds/#guild_id/messages/search.ts new file mode 100644 index 00000000..88488871 --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/messages/search.ts @@ -0,0 +1,137 @@ +import { Request, Response, Router } from "express"; +import { route } from "@fosscord/api"; +import { getPermission, FieldErrors, Message, Channel } from "@fosscord/util"; +import { HTTPError } from "lambert-server"; +import { FindManyOptions, In, Like } from "typeorm"; + +const router: Router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const { + channel_id, + content, + include_nsfw, // TODO + offset, + sort_order, + sort_by, // TODO: Handle 'relevance' + limit, + author_id, + } = req.query; + + const parsedLimit = Number(limit) || 50; + if (parsedLimit < 1 || parsedLimit > 100) + throw new HTTPError("limit must be between 1 and 100", 422); + + if (sort_order) { + if ( + typeof sort_order != "string" || + ["desc", "asc"].indexOf(sort_order) == -1 + ) + throw FieldErrors({ + sort_order: { + message: "Value must be one of ('desc', 'asc').", + code: "BASE_TYPE_CHOICES", + }, + }); // todo this is wrong + } + + const permissions = await getPermission( + req.user_id, + req.params.guild_id, + channel_id as string | undefined, + ); + permissions.hasThrow("VIEW_CHANNEL"); + if (!permissions.has("READ_MESSAGE_HISTORY")) + return res.json({ messages: [], total_results: 0 }); + + var query: FindManyOptions = { + order: { + timestamp: sort_order + ? (sort_order.toUpperCase() as "ASC" | "DESC") + : "DESC", + }, + take: parsedLimit || 0, + where: { + guild: { + id: req.params.guild_id, + }, + }, + relations: [ + "author", + "webhook", + "application", + "mentions", + "mention_roles", + "mention_channels", + "sticker_items", + "attachments", + ], + skip: offset ? Number(offset) : 0, + }; + //@ts-ignore + if (channel_id) query.where!.channel = { id: channel_id }; + else { + // get all channel IDs that this user can access + const channels = await Channel.find({ + where: { guild_id: req.params.guild_id }, + select: ["id"], + }); + const ids = []; + + for (var channel of channels) { + const perm = await getPermission( + req.user_id, + req.params.guild_id, + channel.id, + ); + if (!perm.has("VIEW_CHANNEL") || !perm.has("READ_MESSAGE_HISTORY")) + continue; + ids.push(channel.id); + } + + //@ts-ignore + query.where!.channel = { id: In(ids) }; + } + //@ts-ignore + if (author_id) query.where!.author = { id: author_id }; + //@ts-ignore + if (content) query.where!.content = Like(`%${content}%`); + + const messages: Message[] = await Message.find(query); + + const messagesDto = messages.map((x) => [ + { + id: x.id, + type: x.type, + content: x.content, + channel_id: x.channel_id, + author: { + id: x.author?.id, + username: x.author?.username, + avatar: x.author?.avatar, + avatar_decoration: null, + discriminator: x.author?.discriminator, + public_flags: x.author?.public_flags, + }, + attachments: x.attachments, + embeds: x.embeds, + mentions: x.mentions, + mention_roles: x.mention_roles, + pinned: x.pinned, + mention_everyone: x.mention_everyone, + tts: x.tts, + timestamp: x.timestamp, + edited_timestamp: x.edited_timestamp, + flags: x.flags, + components: x.components, + hit: true, + }, + ]); + + return res.json({ + messages: messagesDto, + total_results: messages.length, + }); +}); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/profile/index.ts b/src/api/routes/v9/guilds/#guild_id/profile/index.ts new file mode 100644 index 00000000..20a7fa95 --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/profile/index.ts @@ -0,0 +1,48 @@ +import { route } from "@fosscord/api"; +import { + emitEvent, + GuildMemberUpdateEvent, + handleFile, + Member, + MemberChangeProfileSchema, + OrmUtils, +} from "@fosscord/util"; +import { Request, Response, Router } from "express"; + +const router = Router(); + +router.patch( + "/:member_id", + route({ body: "MemberChangeProfileSchema" }), + async (req: Request, res: Response) => { + let { guild_id, member_id } = req.params; + if (member_id === "@me") member_id = req.user_id; + const body = req.body as MemberChangeProfileSchema; + + let member = await Member.findOneOrFail({ + where: { id: req.user_id, guild_id }, + relations: ["roles", "user"], + }); + + if (body.banner) + body.banner = await handleFile( + `/guilds/${guild_id}/users/${req.user_id}/avatars`, + body.banner as string, + ); + + member = await OrmUtils.mergeDeep(member, body); + + await member.save(); + + // do not use promise.all as we have to first write to db before emitting the event to catch errors + await emitEvent({ + event: "GUILD_MEMBER_UPDATE", + guild_id, + data: { ...member, roles: member.roles.map((x) => x.id) }, + } as GuildMemberUpdateEvent); + + res.json(member); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/prune.ts b/src/api/routes/v9/guilds/#guild_id/prune.ts new file mode 100644 index 00000000..8089ad84 --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/prune.ts @@ -0,0 +1,106 @@ +import { Router, Request, Response } from "express"; +import { Guild, Member, Snowflake } from "@fosscord/util"; +import { LessThan, IsNull } from "typeorm"; +import { route } from "@fosscord/api"; +const router = Router(); + +//Returns all inactive members, respecting role hierarchy +export const inactiveMembers = async ( + guild_id: string, + user_id: string, + days: number, + roles: string[] = [], +) => { + var date = new Date(); + date.setDate(date.getDate() - days); + //Snowflake should have `generateFromTime` method? Or similar? + var minId = BigInt(date.valueOf() - Snowflake.EPOCH) << BigInt(22); + + /** + idea: ability to customise the cutoff variable + possible candidates: public read receipt, last presence, last VC leave + **/ + var members = await Member.find({ + where: [ + { + guild_id, + last_message_id: LessThan(minId.toString()), + }, + { + guild_id, + last_message_id: IsNull(), + }, + ], + relations: ["roles"], + }); + if (!members.length) return []; + + //I'm sure I can do this in the above db query ( and it would probably be better to do so ), but oh well. + if (roles.length && members.length) + members = members.filter((user) => + user.roles?.some((role) => roles.includes(role.id)), + ); + + const me = await Member.findOneOrFail({ + where: { id: user_id, guild_id }, + relations: ["roles"], + }); + const myHighestRole = Math.max(...(me.roles?.map((x) => x.position) || [])); + + const guild = await Guild.findOneOrFail({ where: { id: guild_id } }); + + members = members.filter( + (member) => + member.id !== guild.owner_id && //can't kick owner + member.roles?.some( + (role) => + role.position < myHighestRole || //roles higher than me can't be kicked + me.id === guild.owner_id, //owner can kick anyone + ), + ); + + return members; +}; + +router.get("/", route({}), async (req: Request, res: Response) => { + const days = parseInt(req.query.days as string); + + var roles = req.query.include_roles; + if (typeof roles === "string") roles = [roles]; //express will return array otherwise + + const members = await inactiveMembers( + req.params.guild_id, + req.user_id, + days, + roles as string[], + ); + + res.send({ pruned: members.length }); +}); + +router.post( + "/", + route({ permission: "KICK_MEMBERS", right: "KICK_BAN_MEMBERS" }), + async (req: Request, res: Response) => { + const days = parseInt(req.body.days); + + var roles = req.query.include_roles; + if (typeof roles === "string") roles = [roles]; + + const { guild_id } = req.params; + const members = await inactiveMembers( + guild_id, + req.user_id, + days, + roles as string[], + ); + + await Promise.all( + members.map((x) => Member.removeFromGuild(x.id, guild_id)), + ); + + res.send({ purged: members.length }); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/regions.ts b/src/api/routes/v9/guilds/#guild_id/regions.ts new file mode 100644 index 00000000..0b275ea4 --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/regions.ts @@ -0,0 +1,20 @@ +import { Config, Guild, Member } from "@fosscord/util"; +import { Request, Response, Router } from "express"; +import { getVoiceRegions, route } from "@fosscord/api"; +import { getIpAdress } from "@fosscord/api"; + +const router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const { guild_id } = req.params; + const guild = await Guild.findOneOrFail({ where: { id: guild_id } }); + //TODO we should use an enum for guild's features and not hardcoded strings + return res.json( + await getVoiceRegions( + getIpAdress(req), + guild.features.includes("VIP_REGIONS"), + ), + ); +}); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/roles/#role_id/index.ts b/src/api/routes/v9/guilds/#guild_id/roles/#role_id/index.ts new file mode 100644 index 00000000..84648703 --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/roles/#role_id/index.ts @@ -0,0 +1,92 @@ +import { Router, Request, Response } from "express"; +import { + Role, + Member, + GuildRoleUpdateEvent, + GuildRoleDeleteEvent, + emitEvent, + handleFile, + RoleModifySchema, +} from "@fosscord/util"; +import { route } from "@fosscord/api"; +import { HTTPError } from "lambert-server"; + +const router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const { guild_id, role_id } = req.params; + await Member.IsInGuildOrFail(req.user_id, guild_id); + const role = await Role.findOneOrFail({ where: { guild_id, id: role_id } }); + return res.json(role); +}); + +router.delete( + "/", + route({ permission: "MANAGE_ROLES" }), + async (req: Request, res: Response) => { + const { guild_id, role_id } = req.params; + if (role_id === guild_id) + throw new HTTPError("You can't delete the @everyone role"); + + await Promise.all([ + Role.delete({ + id: role_id, + guild_id: guild_id, + }), + emitEvent({ + event: "GUILD_ROLE_DELETE", + guild_id, + data: { + guild_id, + role_id, + }, + } as GuildRoleDeleteEvent), + ]); + + res.sendStatus(204); + }, +); + +// TODO: check role hierarchy + +router.patch( + "/", + route({ body: "RoleModifySchema", permission: "MANAGE_ROLES" }), + async (req: Request, res: Response) => { + const { role_id, guild_id } = req.params; + const body = req.body as RoleModifySchema; + + if (body.icon && body.icon.length) + body.icon = await handleFile( + `/role-icons/${role_id}`, + body.icon as string, + ); + else body.icon = undefined; + + const role = await Role.findOneOrFail({ + where: { id: role_id, guild: { id: guild_id } }, + }); + role.assign({ + ...body, + permissions: String( + req.permission!.bitfield & BigInt(body.permissions || "0"), + ), + }); + + await Promise.all([ + role.save(), + emitEvent({ + event: "GUILD_ROLE_UPDATE", + guild_id, + data: { + guild_id, + role, + }, + } as GuildRoleUpdateEvent), + ]); + + res.json(role); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/roles/index.ts b/src/api/routes/v9/guilds/#guild_id/roles/index.ts new file mode 100644 index 00000000..4cd47cf3 --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/roles/index.ts @@ -0,0 +1,123 @@ +import { Request, Response, Router } from "express"; +import { + Role, + getPermission, + Member, + GuildRoleCreateEvent, + GuildRoleUpdateEvent, + emitEvent, + Config, + DiscordApiErrors, + RoleModifySchema, + RolePositionUpdateSchema, + Snowflake, +} from "@fosscord/util"; +import { route } from "@fosscord/api"; +import { Not } from "typeorm"; + +const router: Router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const guild_id = req.params.guild_id; + + await Member.IsInGuildOrFail(req.user_id, guild_id); + + const roles = await Role.find({ where: { guild_id: guild_id } }); + + return res.json(roles); +}); + +router.post( + "/", + route({ body: "RoleModifySchema", permission: "MANAGE_ROLES" }), + async (req: Request, res: Response) => { + const guild_id = req.params.guild_id; + const body = req.body as RoleModifySchema; + + const role_count = await Role.count({ where: { guild_id } }); + const { maxRoles } = Config.get().limits.guild; + + if (role_count > maxRoles) + throw DiscordApiErrors.MAXIMUM_ROLES.withParams(maxRoles); + + const role = Role.create({ + // values before ...body are default and can be overriden + position: 1, + hoist: false, + color: 0, + mentionable: false, + ...body, + guild_id: guild_id, + managed: false, + permissions: String( + req.permission!.bitfield & BigInt(body.permissions || "0"), + ), + tags: undefined, + icon: undefined, + unicode_emoji: undefined, + id: Snowflake.generate(), + }); + + await Promise.all([ + role.save(), + // Move all existing roles up one position, to accommodate the new role + Role.createQueryBuilder("roles") + .where({ + guild: { id: guild_id }, + name: Not("@everyone"), + id: Not(role.id), + }) + .update({ position: () => "position + 1" }) + .execute(), + emitEvent({ + event: "GUILD_ROLE_CREATE", + guild_id, + data: { + guild_id, + role: role, + }, + } as GuildRoleCreateEvent), + ]); + + res.json(role); + }, +); + +router.patch( + "/", + route({ body: "RolePositionUpdateSchema" }), + async (req: Request, res: Response) => { + const { guild_id } = req.params; + const body = req.body as RolePositionUpdateSchema; + + const perms = await getPermission(req.user_id, guild_id); + perms.hasThrow("MANAGE_ROLES"); + + await Promise.all( + body.map(async (x) => + Role.update({ guild_id, id: x.id }, { position: x.position }), + ), + ); + + const roles = await Role.find({ + where: body.map((x) => ({ id: x.id, guild_id })), + }); + + await Promise.all( + roles.map((x) => + emitEvent({ + event: "GUILD_ROLE_UPDATE", + guild_id, + data: { + guild_id, + role: x, + }, + } as GuildRoleUpdateEvent), + ), + ); + + res.json(roles); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/stickers.ts b/src/api/routes/v9/guilds/#guild_id/stickers.ts new file mode 100644 index 00000000..3b1f5f8e --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/stickers.ts @@ -0,0 +1,137 @@ +import { + emitEvent, + GuildStickersUpdateEvent, + Member, + Snowflake, + Sticker, + StickerFormatType, + StickerType, + uploadFile, + ModifyGuildStickerSchema, +} from "@fosscord/util"; +import { Router, Request, Response } from "express"; +import { route } from "@fosscord/api"; +import multer from "multer"; +import { HTTPError } from "lambert-server"; +const router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const { guild_id } = req.params; + await Member.IsInGuildOrFail(req.user_id, guild_id); + + res.json(await Sticker.find({ where: { guild_id } })); +}); + +const bodyParser = multer({ + limits: { + fileSize: 1024 * 1024 * 100, + fields: 10, + files: 1, + }, + storage: multer.memoryStorage(), +}).single("file"); + +router.post( + "/", + bodyParser, + route({ + permission: "MANAGE_EMOJIS_AND_STICKERS", + body: "ModifyGuildStickerSchema", + }), + async (req: Request, res: Response) => { + if (!req.file) throw new HTTPError("missing file"); + + const { guild_id } = req.params; + const body = req.body as ModifyGuildStickerSchema; + const id = Snowflake.generate(); + + const [sticker] = await Promise.all([ + Sticker.create({ + ...body, + guild_id, + id, + type: StickerType.GUILD, + format_type: getStickerFormat(req.file.mimetype), + available: true, + }).save(), + uploadFile(`/stickers/${id}`, req.file), + ]); + + await sendStickerUpdateEvent(guild_id); + + res.json(sticker); + }, +); + +export function getStickerFormat(mime_type: string) { + switch (mime_type) { + case "image/apng": + return StickerFormatType.APNG; + case "application/json": + return StickerFormatType.LOTTIE; + case "image/png": + return StickerFormatType.PNG; + case "image/gif": + return StickerFormatType.GIF; + default: + throw new HTTPError( + "invalid sticker format: must be png, apng or lottie", + ); + } +} + +router.get("/:sticker_id", route({}), async (req: Request, res: Response) => { + const { guild_id, sticker_id } = req.params; + await Member.IsInGuildOrFail(req.user_id, guild_id); + + res.json( + await Sticker.findOneOrFail({ where: { guild_id, id: sticker_id } }), + ); +}); + +router.patch( + "/:sticker_id", + route({ + body: "ModifyGuildStickerSchema", + permission: "MANAGE_EMOJIS_AND_STICKERS", + }), + async (req: Request, res: Response) => { + const { guild_id, sticker_id } = req.params; + const body = req.body as ModifyGuildStickerSchema; + + const sticker = await Sticker.create({ + ...body, + guild_id, + id: sticker_id, + }).save(); + await sendStickerUpdateEvent(guild_id); + + return res.json(sticker); + }, +); + +async function sendStickerUpdateEvent(guild_id: string) { + return emitEvent({ + event: "GUILD_STICKERS_UPDATE", + guild_id: guild_id, + data: { + guild_id: guild_id, + stickers: await Sticker.find({ where: { guild_id: guild_id } }), + }, + } as GuildStickersUpdateEvent); +} + +router.delete( + "/:sticker_id", + route({ permission: "MANAGE_EMOJIS_AND_STICKERS" }), + async (req: Request, res: Response) => { + const { guild_id, sticker_id } = req.params; + + await Sticker.delete({ guild_id, id: sticker_id }); + await sendStickerUpdateEvent(guild_id); + + return res.sendStatus(204); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/templates.ts b/src/api/routes/v9/guilds/#guild_id/templates.ts new file mode 100644 index 00000000..3b5eddaa --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/templates.ts @@ -0,0 +1,116 @@ +import { Request, Response, Router } from "express"; +import { Guild, Template } from "@fosscord/util"; +import { HTTPError } from "lambert-server"; +import { route } from "@fosscord/api"; +import { generateCode } from "@fosscord/api"; + +const router: Router = Router(); + +const TemplateGuildProjection: (keyof Guild)[] = [ + "name", + "description", + "region", + "verification_level", + "default_message_notifications", + "explicit_content_filter", + "preferred_locale", + "afk_timeout", + "roles", + // "channels", + "afk_channel_id", + "system_channel_id", + "system_channel_flags", + "icon", +]; + +router.get("/", route({}), async (req: Request, res: Response) => { + const { guild_id } = req.params; + + var templates = await Template.find({ + where: { source_guild_id: guild_id }, + }); + + return res.json(templates); +}); + +router.post( + "/", + route({ body: "TemplateCreateSchema", permission: "MANAGE_GUILD" }), + async (req: Request, res: Response) => { + const { guild_id } = req.params; + const guild = await Guild.findOneOrFail({ + where: { id: guild_id }, + select: TemplateGuildProjection, + }); + const exists = await Template.findOneOrFail({ + where: { id: guild_id }, + }).catch((e) => {}); + if (exists) throw new HTTPError("Template already exists", 400); + + const template = await Template.create({ + ...req.body, + code: generateCode(), + creator_id: req.user_id, + created_at: new Date(), + updated_at: new Date(), + source_guild_id: guild_id, + serialized_source_guild: guild, + }).save(); + + res.json(template); + }, +); + +router.delete( + "/:code", + route({ permission: "MANAGE_GUILD" }), + async (req: Request, res: Response) => { + const { code, guild_id } = req.params; + + const template = await Template.delete({ + code, + source_guild_id: guild_id, + }); + + res.json(template); + }, +); + +router.put( + "/:code", + route({ permission: "MANAGE_GUILD" }), + async (req: Request, res: Response) => { + const { code, guild_id } = req.params; + const guild = await Guild.findOneOrFail({ + where: { id: guild_id }, + select: TemplateGuildProjection, + }); + + const template = await Template.create({ + code, + serialized_source_guild: guild, + }).save(); + + res.json(template); + }, +); + +router.patch( + "/:code", + route({ body: "TemplateModifySchema", permission: "MANAGE_GUILD" }), + async (req: Request, res: Response) => { + const { code, guild_id } = req.params; + const { name, description } = req.body; + + const template = await Template.create({ + code, + name: name, + description: description, + source_guild_id: guild_id, + }).save(); + + res.json(template); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/vanity-url.ts b/src/api/routes/v9/guilds/#guild_id/vanity-url.ts new file mode 100644 index 00000000..9a96b066 --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/vanity-url.ts @@ -0,0 +1,82 @@ +import { + Channel, + ChannelType, + Guild, + Invite, + VanityUrlSchema, +} from "@fosscord/util"; +import { Router, Request, Response } from "express"; +import { route } from "@fosscord/api"; +import { HTTPError } from "lambert-server"; + +const router = Router(); + +const InviteRegex = /\W/g; + +router.get( + "/", + route({ permission: "MANAGE_GUILD" }), + async (req: Request, res: Response) => { + const { guild_id } = req.params; + const guild = await Guild.findOneOrFail({ where: { id: guild_id } }); + + if (!guild.features.includes("ALIASABLE_NAMES")) { + const invite = await Invite.findOne({ + where: { guild_id: guild_id, vanity_url: true }, + }); + if (!invite) return res.json({ code: null }); + + return res.json({ code: invite.code, uses: invite.uses }); + } else { + const invite = await Invite.find({ + where: { guild_id: guild_id, vanity_url: true }, + }); + if (!invite || invite.length == 0) return res.json({ code: null }); + + return res.json( + invite.map((x) => ({ code: x.code, uses: x.uses })), + ); + } + }, +); + +router.patch( + "/", + route({ body: "VanityUrlSchema", permission: "MANAGE_GUILD" }), + async (req: Request, res: Response) => { + const { guild_id } = req.params; + const body = req.body as VanityUrlSchema; + const code = body.code?.replace(InviteRegex, ""); + + const guild = await Guild.findOneOrFail({ where: { id: guild_id } }); + if (!guild.features.includes("VANITY_URL")) + throw new HTTPError("Your guild doesn't support vanity urls"); + + if (!code || code.length === 0) + throw new HTTPError("Code cannot be null or empty"); + + const invite = await Invite.findOne({ where: { code } }); + if (invite) throw new HTTPError("Invite already exists"); + + const { id } = await Channel.findOneOrFail({ + where: { guild_id, type: ChannelType.GUILD_TEXT }, + }); + + await Invite.create({ + vanity_url: true, + code: code, + temporary: false, + uses: 0, + max_uses: 0, + max_age: 0, + created_at: new Date(), + expires_at: new Date(), + guild_id: guild_id, + channel_id: id, + }).save(); + + return res.json({ code: code }); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/voice-states/#user_id/index.ts b/src/api/routes/v9/guilds/#guild_id/voice-states/#user_id/index.ts new file mode 100644 index 00000000..af03a07e --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/voice-states/#user_id/index.ts @@ -0,0 +1,71 @@ +import { + Channel, + ChannelType, + DiscordApiErrors, + emitEvent, + getPermission, + VoiceState, + VoiceStateUpdateEvent, + VoiceStateUpdateSchema, +} from "@fosscord/util"; +import { route } from "@fosscord/api"; +import { Request, Response, Router } from "express"; + +const router = Router(); +//TODO need more testing when community guild and voice stage channel are working + +router.patch( + "/", + route({ body: "VoiceStateUpdateSchema" }), + async (req: Request, res: Response) => { + const body = req.body as VoiceStateUpdateSchema; + var { guild_id, user_id } = req.params; + if (user_id === "@me") user_id = req.user_id; + + const perms = await getPermission( + req.user_id, + guild_id, + body.channel_id, + ); + + /* + From https://discord.com/developers/docs/resources/guild#modify-current-user-voice-state + You must have the MUTE_MEMBERS permission to unsuppress others. You can always suppress yourself. + You must have the REQUEST_TO_SPEAK permission to request to speak. You can always clear your own request to speak. + */ + if (body.suppress && user_id !== req.user_id) { + perms.hasThrow("MUTE_MEMBERS"); + } + if (!body.suppress) body.request_to_speak_timestamp = new Date(); + if (body.request_to_speak_timestamp) perms.hasThrow("REQUEST_TO_SPEAK"); + + const voice_state = await VoiceState.findOne({ + where: { + guild_id, + channel_id: body.channel_id, + user_id, + }, + }); + if (!voice_state) throw DiscordApiErrors.UNKNOWN_VOICE_STATE; + + voice_state.assign(body); + const channel = await Channel.findOneOrFail({ + where: { guild_id, id: body.channel_id }, + }); + if (channel.type !== ChannelType.GUILD_STAGE_VOICE) { + throw DiscordApiErrors.CANNOT_EXECUTE_ON_THIS_CHANNEL_TYPE; + } + + await Promise.all([ + voice_state.save(), + emitEvent({ + event: "VOICE_STATE_UPDATE", + data: voice_state, + guild_id, + } as VoiceStateUpdateEvent), + ]); + return res.sendStatus(204); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/welcome-screen.ts b/src/api/routes/v9/guilds/#guild_id/welcome-screen.ts new file mode 100644 index 00000000..80ab138b --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/welcome-screen.ts @@ -0,0 +1,43 @@ +import { Request, Response, Router } from "express"; +import { Guild, Member, GuildUpdateWelcomeScreenSchema } from "@fosscord/util"; +import { HTTPError } from "lambert-server"; +import { route } from "@fosscord/api"; + +const router: Router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const guild_id = req.params.guild_id; + + const guild = await Guild.findOneOrFail({ where: { id: guild_id } }); + await Member.IsInGuildOrFail(req.user_id, guild_id); + + res.json(guild.welcome_screen); +}); + +router.patch( + "/", + route({ + body: "GuildUpdateWelcomeScreenSchema", + permission: "MANAGE_GUILD", + }), + async (req: Request, res: Response) => { + const guild_id = req.params.guild_id; + const body = req.body as GuildUpdateWelcomeScreenSchema; + + const guild = await Guild.findOneOrFail({ where: { id: guild_id } }); + + if (!guild.welcome_screen.enabled) + throw new HTTPError("Welcome screen disabled", 400); + if (body.welcome_channels) + guild.welcome_screen.welcome_channels = body.welcome_channels; // TODO: check if they exist and are valid + if (body.description) + guild.welcome_screen.description = body.description; + if (body.enabled != null) guild.welcome_screen.enabled = body.enabled; + + await guild.save(); + + res.sendStatus(204); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/widget.json.ts b/src/api/routes/v9/guilds/#guild_id/widget.json.ts new file mode 100644 index 00000000..2c3124a2 --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/widget.json.ts @@ -0,0 +1,97 @@ +import { Request, Response, Router } from "express"; +import { + Config, + Permissions, + Guild, + Invite, + Channel, + Member, +} from "@fosscord/util"; +import { HTTPError } from "lambert-server"; +import { random, route } from "@fosscord/api"; + +const router: Router = Router(); + +// Undocumented API notes: +// An invite is created for the widget_channel_id on request (only if an existing one created by the widget doesn't already exist) +// This invite created doesn't include an inviter object like user created ones and has a default expiry of 24 hours +// Missing user object information is intentional (https://github.com/discord/discord-api-docs/issues/1287) +// channels returns voice channel objects where @everyone has the CONNECT permission +// members (max 100 returned) is a sample of all members, and bots par invisible status, there exists some alphabetical distribution pattern between the members returned + +// https://discord.com/developers/docs/resources/guild#get-guild-widget +// TODO: Cache the response for a guild for 5 minutes regardless of response +router.get("/", route({}), async (req: Request, res: Response) => { + const { guild_id } = req.params; + + const guild = await Guild.findOneOrFail({ where: { id: guild_id } }); + if (!guild.widget_enabled) throw new HTTPError("Widget Disabled", 404); + + // Fetch existing widget invite for widget channel + var invite = await Invite.findOne({ + where: { channel_id: guild.widget_channel_id }, + }); + + if (guild.widget_channel_id && !invite) { + // Create invite for channel if none exists + // TODO: Refactor invite create code to a shared function + const max_age = 86400; // 24 hours + const expires_at = new Date(max_age * 1000 + Date.now()); + + invite = await Invite.create({ + code: random(), + temporary: false, + uses: 0, + max_uses: 0, + max_age: max_age, + expires_at, + created_at: new Date(), + guild_id, + channel_id: guild.widget_channel_id, + }).save(); + } + + // Fetch voice channels, and the @everyone permissions object + const channels = [] as any[]; + + ( + await Channel.find({ + where: { guild_id: guild_id, type: 2 }, + order: { position: "ASC" }, + }) + ).filter((doc) => { + // Only return channels where @everyone has the CONNECT permission + if ( + doc.permission_overwrites === undefined || + Permissions.channelPermission( + doc.permission_overwrites, + Permissions.FLAGS.CONNECT, + ) === Permissions.FLAGS.CONNECT + ) { + channels.push({ + id: doc.id, + name: doc.name, + position: doc.position, + }); + } + }); + + // Fetch members + // TODO: Understand how Discord's max 100 random member sample works, and apply to here (see top of this file) + let members = await Member.find({ where: { guild_id: guild_id } }); + + // Construct object to respond with + const data = { + id: guild_id, + name: guild.name, + instant_invite: invite?.code, + channels: channels, + members: members, + presence_count: guild.presence_count, + }; + + res.set("Cache-Control", "public, max-age=300"); + return res.json(data); +}); + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/widget.png.ts b/src/api/routes/v9/guilds/#guild_id/widget.png.ts new file mode 100644 index 00000000..eaec8f07 --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/widget.png.ts @@ -0,0 +1,179 @@ +import { Request, Response, Router } from "express"; +import { Guild } from "@fosscord/util"; +import { HTTPError } from "lambert-server"; +import { route } from "@fosscord/api"; +import fs from "fs"; +import path from "path"; + +const router: Router = Router(); + +// TODO: use svg templates instead of node-canvas for improved performance and to change it easily + +// https://discord.com/developers/docs/resources/guild#get-guild-widget-image +// TODO: Cache the response +router.get("/", route({}), async (req: Request, res: Response) => { + const { guild_id } = req.params; + + const guild = await Guild.findOneOrFail({ where: { id: guild_id } }); + if (!guild.widget_enabled) throw new HTTPError("Unknown Guild", 404); + + // Fetch guild information + const icon = guild.icon; + const name = guild.name; + const presence = guild.presence_count + " ONLINE"; + + // Fetch parameter + const style = req.query.style?.toString() || "shield"; + if ( + !["shield", "banner1", "banner2", "banner3", "banner4"].includes(style) + ) { + throw new HTTPError( + "Value must be one of ('shield', 'banner1', 'banner2', 'banner3', 'banner4').", + 400, + ); + } + + // Setup canvas + const { createCanvas } = require("canvas"); + const { loadImage } = require("canvas"); + const sizeOf = require("image-size"); + + // TODO: Widget style templates need Fosscord branding + const source = path.join( + __dirname, + "..", + "..", + "..", + "..", + "..", + "assets", + "widget", + `${style}.png`, + ); + if (!fs.existsSync(source)) { + throw new HTTPError("Widget template does not exist.", 400); + } + + // Create base template image for parameter + const { width, height } = await sizeOf(source); + const canvas = createCanvas(width, height); + const ctx = canvas.getContext("2d"); + const template = await loadImage(source); + ctx.drawImage(template, 0, 0); + + // Add the guild specific information to the template asset image + switch (style) { + case "shield": + ctx.textAlign = "center"; + await drawText( + ctx, + 73, + 13, + "#FFFFFF", + "thin 10px Verdana", + presence, + ); + break; + case "banner1": + if (icon) await drawIcon(ctx, 20, 27, 50, icon); + await drawText(ctx, 83, 51, "#FFFFFF", "12px Verdana", name, 22); + await drawText( + ctx, + 83, + 66, + "#C9D2F0FF", + "thin 11px Verdana", + presence, + ); + break; + case "banner2": + if (icon) await drawIcon(ctx, 13, 19, 36, icon); + await drawText(ctx, 62, 34, "#FFFFFF", "12px Verdana", name, 15); + await drawText( + ctx, + 62, + 49, + "#C9D2F0FF", + "thin 11px Verdana", + presence, + ); + break; + case "banner3": + if (icon) await drawIcon(ctx, 20, 20, 50, icon); + await drawText(ctx, 83, 44, "#FFFFFF", "12px Verdana", name, 27); + await drawText( + ctx, + 83, + 58, + "#C9D2F0FF", + "thin 11px Verdana", + presence, + ); + break; + case "banner4": + if (icon) await drawIcon(ctx, 21, 136, 50, icon); + await drawText(ctx, 84, 156, "#FFFFFF", "13px Verdana", name, 27); + await drawText( + ctx, + 84, + 171, + "#C9D2F0FF", + "thin 12px Verdana", + presence, + ); + break; + default: + throw new HTTPError( + "Value must be one of ('shield', 'banner1', 'banner2', 'banner3', 'banner4').", + 400, + ); + } + + // Return final image + const buffer = canvas.toBuffer("image/png"); + res.set("Content-Type", "image/png"); + res.set("Cache-Control", "public, max-age=3600"); + return res.send(buffer); +}); + +async function drawIcon( + canvas: any, + x: number, + y: number, + scale: number, + icon: string, +) { + // @ts-ignore + const img = new require("canvas").Image(); + img.src = icon; + + // Do some canvas clipping magic! + canvas.save(); + canvas.beginPath(); + + const r = scale / 2; // use scale to determine radius + canvas.arc(x + r, y + r, r, 0, 2 * Math.PI, false); // start circle at x, and y coords + radius to find center + + canvas.clip(); + canvas.drawImage(img, x, y, scale, scale); + + canvas.restore(); +} + +async function drawText( + canvas: any, + x: number, + y: number, + color: string, + font: string, + text: string, + maxcharacters?: number, +) { + canvas.fillStyle = color; + canvas.font = font; + if (text.length > (maxcharacters || 0) && maxcharacters) + text = text.slice(0, maxcharacters) + "..."; + canvas.fillText(text, x, y); +} + +export default router; diff --git a/src/api/routes/v9/guilds/#guild_id/widget.ts b/src/api/routes/v9/guilds/#guild_id/widget.ts new file mode 100644 index 00000000..108339e1 --- /dev/null +++ b/src/api/routes/v9/guilds/#guild_id/widget.ts @@ -0,0 +1,40 @@ +import { Request, Response, Router } from "express"; +import { Guild, WidgetModifySchema } from "@fosscord/util"; +import { route } from "@fosscord/api"; + +const router: Router = Router(); + +// https://discord.com/developers/docs/resources/guild#get-guild-widget-settings +router.get("/", route({}), async (req: Request, res: Response) => { + const { guild_id } = req.params; + + const guild = await Guild.findOneOrFail({ where: { id: guild_id } }); + + return res.json({ + enabled: guild.widget_enabled || false, + channel_id: guild.widget_channel_id || null, + }); +}); + +// https://discord.com/developers/docs/resources/guild#modify-guild-widget +router.patch( + "/", + route({ body: "WidgetModifySchema", permission: "MANAGE_GUILD" }), + async (req: Request, res: Response) => { + const body = req.body as WidgetModifySchema; + const { guild_id } = req.params; + + await Guild.update( + { id: guild_id }, + { + widget_enabled: body.enabled, + widget_channel_id: body.channel_id, + }, + ); + // Widget invite for the widget_channel_id gets created as part of the /guilds/{guild.id}/widget.json request + + return res.json(body); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/index.ts b/src/api/routes/v9/guilds/index.ts new file mode 100644 index 00000000..69575aea --- /dev/null +++ b/src/api/routes/v9/guilds/index.ts @@ -0,0 +1,47 @@ +import { Router, Request, Response } from "express"; +import { + Role, + Guild, + Config, + getRights, + Member, + DiscordApiErrors, + GuildCreateSchema, +} from "@fosscord/util"; +import { route } from "@fosscord/api"; + +const router: Router = Router(); + +//TODO: create default channel + +router.post( + "/", + route({ body: "GuildCreateSchema", right: "CREATE_GUILDS" }), + async (req: Request, res: Response) => { + const body = req.body as GuildCreateSchema; + + const { maxGuilds } = Config.get().limits.user; + const guild_count = await Member.count({ where: { id: req.user_id } }); + const rights = await getRights(req.user_id); + if (guild_count >= maxGuilds && !rights.has("MANAGE_GUILDS")) { + throw DiscordApiErrors.MAXIMUM_GUILDS.withParams(maxGuilds); + } + + const guild = await Guild.createGuild({ + ...body, + owner_id: req.user_id, + }); + + const { autoJoin } = Config.get().guild; + if (autoJoin.enabled && !autoJoin.guilds?.length) { + // @ts-ignore + await Config.set({ guild: { autoJoin: { guilds: [guild.id] } } }); + } + + await Member.addToGuild(req.user_id, guild.id); + + res.status(201).json({ id: guild.id }); + }, +); + +export default router; diff --git a/src/api/routes/v9/guilds/templates/index.ts b/src/api/routes/v9/guilds/templates/index.ts new file mode 100644 index 00000000..240bf074 --- /dev/null +++ b/src/api/routes/v9/guilds/templates/index.ts @@ -0,0 +1,132 @@ +import { Request, Response, Router } from "express"; +import { + Template, + Guild, + Role, + Snowflake, + Config, + Member, + GuildTemplateCreateSchema, +} from "@fosscord/util"; +import { route } from "@fosscord/api"; +import { DiscordApiErrors } from "@fosscord/util"; +import fetch from "node-fetch"; +const router: Router = Router(); + +router.get("/:code", route({}), async (req: Request, res: Response) => { + const { allowDiscordTemplates, allowRaws, enabled } = + Config.get().templates; + if (!enabled) + res.json({ + code: 403, + message: "Template creation & usage is disabled on this instance.", + }).sendStatus(403); + + const { code } = req.params; + + if (code.startsWith("discord:")) { + if (!allowDiscordTemplates) + return res + .json({ + code: 403, + message: + "Discord templates cannot be used on this instance.", + }) + .sendStatus(403); + const discordTemplateID = code.split("discord:", 2)[1]; + + const discordTemplateData = await fetch( + `https://discord.com/api/v9/guilds/templates/${discordTemplateID}`, + { + method: "get", + headers: { "Content-Type": "application/json" }, + }, + ); + return res.json(await discordTemplateData.json()); + } + + if (code.startsWith("external:")) { + if (!allowRaws) + return res + .json({ + code: 403, + message: "Importing raws is disabled on this instance.", + }) + .sendStatus(403); + + return res.json(code.split("external:", 2)[1]); + } + + const template = await Template.findOneOrFail({ where: { code: code } }); + res.json(template); +}); + +router.post( + "/:code", + route({ body: "GuildTemplateCreateSchema" }), + async (req: Request, res: Response) => { + const { + enabled, + allowTemplateCreation, + allowDiscordTemplates, + allowRaws, + } = Config.get().templates; + if (!enabled) + return res + .json({ + code: 403, + message: + "Template creation & usage is disabled on this instance.", + }) + .sendStatus(403); + if (!allowTemplateCreation) + return res + .json({ + code: 403, + message: "Template creation is disabled on this instance.", + }) + .sendStatus(403); + + const { code } = req.params; + const body = req.body as GuildTemplateCreateSchema; + + const { maxGuilds } = Config.get().limits.user; + + const guild_count = await Member.count({ where: { id: req.user_id } }); + if (guild_count >= maxGuilds) { + throw DiscordApiErrors.MAXIMUM_GUILDS.withParams(maxGuilds); + } + + const template = await Template.findOneOrFail({ + where: { code: code }, + }); + + const guild_id = Snowflake.generate(); + + const [guild, role] = await Promise.all([ + Guild.create({ + ...body, + ...template.serialized_source_guild, + id: guild_id, + owner_id: req.user_id, + }).save(), + Role.create({ + id: guild_id, + guild_id: guild_id, + color: 0, + hoist: false, + managed: true, + mentionable: true, + name: "@everyone", + permissions: BigInt("2251804225").toString(), // TODO: where did this come from? + position: 0, + }).save(), + ]); + + await Member.addToGuild(req.user_id, guild_id); + + res.status(201).json({ id: guild.id }); + }, +); + +export default router; diff --git a/src/api/routes/v9/invites/index.ts b/src/api/routes/v9/invites/index.ts new file mode 100644 index 00000000..ce0ba982 --- /dev/null +++ b/src/api/routes/v9/invites/index.ts @@ -0,0 +1,89 @@ +import { Router, Request, Response } from "express"; +import { + emitEvent, + getPermission, + Guild, + Invite, + InviteDeleteEvent, + User, + PublicInviteRelation, +} from "@fosscord/util"; +import { route } from "@fosscord/api"; +import { HTTPError } from "lambert-server"; + +const router: Router = Router(); + +router.get("/:code", route({}), async (req: Request, res: Response) => { + const { code } = req.params; + + const invite = await Invite.findOneOrFail({ + where: { code }, + relations: PublicInviteRelation, + }); + + res.status(200).send(invite); +}); + +router.post( + "/:code", + route({ right: "USE_MASS_INVITES" }), + async (req: Request, res: Response) => { + const { code } = req.params; + const { guild_id } = await Invite.findOneOrFail({ + where: { code: code }, + }); + const { features } = await Guild.findOneOrFail({ + where: { id: guild_id }, + }); + const { public_flags } = await User.findOneOrFail({ + where: { id: req.user_id }, + }); + + if ( + features.includes("INTERNAL_EMPLOYEE_ONLY") && + (public_flags & 1) !== 1 + ) + throw new HTTPError( + "Only intended for the staff of this server.", + 401, + ); + if (features.includes("INVITES_CLOSED")) + throw new HTTPError("Sorry, this guild has joins closed.", 403); + + const invite = await Invite.joinGuild(req.user_id, code); + + res.json(invite); + }, +); + +// * cant use permission of route() function because path doesn't have guild_id/channel_id +router.delete("/:code", route({}), async (req: Request, res: Response) => { + const { code } = req.params; + const invite = await Invite.findOneOrFail({ where: { code } }); + const { guild_id, channel_id } = invite; + + const permission = await getPermission(req.user_id, guild_id, channel_id); + + if (!permission.has("MANAGE_GUILD") && !permission.has("MANAGE_CHANNELS")) + throw new HTTPError( + "You missing the MANAGE_GUILD or MANAGE_CHANNELS permission", + 401, + ); + + await Promise.all([ + Invite.delete({ code }), + emitEvent({ + event: "INVITE_DELETE", + guild_id: guild_id, + data: { + channel_id: channel_id, + guild_id: guild_id, + code: code, + }, + } as InviteDeleteEvent), + ]); + + res.json({ invite: invite }); +}); + +export default router; diff --git a/src/api/routes/v9/oauth2/authorize.ts b/src/api/routes/v9/oauth2/authorize.ts new file mode 100644 index 00000000..6374972e --- /dev/null +++ b/src/api/routes/v9/oauth2/authorize.ts @@ -0,0 +1,168 @@ +import { Router, Request, Response } from "express"; +import { route } from "@fosscord/api"; +import { + ApiError, + Application, + ApplicationAuthorizeSchema, + getPermission, + DiscordApiErrors, + Member, + Permissions, + User, + getRights, + Rights, + MemberPrivateProjection, +} from "@fosscord/util"; +const router = Router(); + +// TODO: scopes, other oauth types + +router.get("/", route({}), async (req: Request, res: Response) => { + const { client_id, scope, response_type, redirect_url } = req.query; + + const app = await Application.findOne({ + where: { + id: client_id as string, + }, + relations: ["bot"], + }); + + // TODO: use DiscordApiErrors + // findOneOrFail throws code 404 + if (!app) throw DiscordApiErrors.UNKNOWN_APPLICATION; + if (!app.bot) throw DiscordApiErrors.OAUTH2_APPLICATION_BOT_ABSENT; + + const bot = app.bot; + delete app.bot; + + const user = await User.findOneOrFail({ + where: { + id: req.user_id, + bot: false, + }, + select: ["id", "username", "avatar", "discriminator", "public_flags"], + }); + + const guilds = await Member.find({ + where: { + user: { + id: req.user_id, + }, + }, + relations: ["guild", "roles"], + //@ts-ignore + // prettier-ignore + select: ["guild.id", "guild.name", "guild.icon", "guild.mfa_level", "guild.owner_id", "roles.id"], + }); + + const guildsWithPermissions = guilds.map((x) => { + const perms = + x.guild.owner_id === user.id + ? new Permissions(Permissions.FLAGS.ADMINISTRATOR) + : Permissions.finalPermission({ + user: { + id: user.id, + roles: x.roles?.map((x) => x.id) || [], + }, + guild: { + roles: x?.roles || [], + }, + }); + + return { + id: x.guild.id, + name: x.guild.name, + icon: x.guild.icon, + mfa_level: x.guild.mfa_level, + permissions: perms.bitfield.toString(), + }; + }); + + return res.json({ + guilds: guildsWithPermissions, + user: { + id: user.id, + username: user.username, + avatar: user.avatar, + avatar_decoration: null, // TODO + discriminator: user.discriminator, + public_flags: user.public_flags, + }, + application: { + id: app.id, + name: app.name, + icon: app.icon, + description: app.description, + summary: app.summary, + type: app.type, + hook: app.hook, + guild_id: null, // TODO support guilds + bot_public: app.bot_public, + bot_require_code_grant: app.bot_require_code_grant, + verify_key: app.verify_key, + flags: app.flags, + }, + bot: { + id: bot.id, + username: bot.username, + avatar: bot.avatar, + avatar_decoration: null, // TODO + discriminator: bot.discriminator, + public_flags: bot.public_flags, + bot: true, + approximated_guild_count: 0, // TODO + }, + authorized: false, + }); +}); + +router.post( + "/", + route({ body: "ApplicationAuthorizeSchema" }), + async (req: Request, res: Response) => { + const body = req.body as ApplicationAuthorizeSchema; + const { client_id, scope, response_type, redirect_url } = req.query; + + // TODO: captcha verification + // TODO: MFA verification + + const perms = await getPermission( + req.user_id, + body.guild_id, + undefined, + { member_relations: ["user"] }, + ); + // getPermission cache won't exist if we're owner + if ( + Object.keys(perms.cache || {}).length > 0 && + perms.cache.member!.user.bot + ) + throw DiscordApiErrors.UNAUTHORIZED; + perms.hasThrow("MANAGE_GUILD"); + + const app = await Application.findOne({ + where: { + id: client_id as string, + }, + relations: ["bot"], + }); + + // TODO: use DiscordApiErrors + // findOneOrFail throws code 404 + if (!app) throw new ApiError("Unknown Application", 10002, 404); + if (!app.bot) + throw new ApiError( + "OAuth2 application does not have a bot", + 50010, + 400, + ); + + await Member.addToGuild(app.id, body.guild_id); + + return res.json({ + location: "/oauth2/authorized", // redirect URL + }); + }, +); + +export default router; diff --git a/src/api/routes/v9/partners/#guild_id/requirements.ts b/src/api/routes/v9/partners/#guild_id/requirements.ts new file mode 100644 index 00000000..7e63c06b --- /dev/null +++ b/src/api/routes/v9/partners/#guild_id/requirements.ts @@ -0,0 +1,39 @@ +import { Guild, Config } from "@fosscord/util"; + +import { Router, Request, Response } from "express"; +import { route } from "@fosscord/api"; + +const router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const { guild_id } = req.params; + // TODO: + // Load from database + // Admin control, but for now it allows anyone to be discoverable + + res.send({ + guild_id: guild_id, + safe_environment: true, + healthy: true, + health_score_pending: false, + size: true, + nsfw_properties: {}, + protected: true, + sufficient: true, + sufficient_without_grace_period: true, + valid_rules_channel: true, + retention_healthy: true, + engagement_healthy: true, + age: true, + minimum_age: 0, + health_score: { + avg_nonnew_participators: 0, + avg_nonnew_communicators: 0, + num_intentful_joiners: 0, + perc_ret_w1_intentful: 0, + }, + minimum_size: 0, + }); +}); + +export default router; diff --git a/src/api/routes/v9/ping.ts b/src/api/routes/v9/ping.ts new file mode 100644 index 00000000..3c1da2c3 --- /dev/null +++ b/src/api/routes/v9/ping.ts @@ -0,0 +1,26 @@ +import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; +import { Config } from "@fosscord/util"; + +const router = Router(); + +router.get("/", route({}), (req: Request, res: Response) => { + const { general } = Config.get(); + res.send({ + ping: "pong!", + instance: { + id: general.instanceId, + name: general.instanceName, + description: general.instanceDescription, + image: general.image, + + correspondenceEmail: general.correspondenceEmail, + correspondenceUserID: general.correspondenceUserID, + + frontPage: general.frontPage, + tosPage: general.tosPage, + }, + }); +}); + +export default router; diff --git a/src/api/routes/v9/policies/instance/domains.ts b/src/api/routes/v9/policies/instance/domains.ts new file mode 100644 index 00000000..f22eac17 --- /dev/null +++ b/src/api/routes/v9/policies/instance/domains.ts @@ -0,0 +1,21 @@ +import { Router, Request, Response } from "express"; +import { route } from "@fosscord/api"; +import { Config } from "@fosscord/util"; +import { config } from "dotenv"; +const router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const { cdn, gateway } = Config.get(); + + const IdentityForm = { + cdn: cdn.endpointPublic || process.env.CDN || "http://localhost:3001", + gateway: + gateway.endpointPublic || + process.env.GATEWAY || + "ws://localhost:3002", + }; + + res.json(IdentityForm); +}); + +export default router; diff --git a/src/api/routes/v9/policies/instance/index.ts b/src/api/routes/v9/policies/instance/index.ts new file mode 100644 index 00000000..1c1afa09 --- /dev/null +++ b/src/api/routes/v9/policies/instance/index.ts @@ -0,0 +1,11 @@ +import { Router, Request, Response } from "express"; +import { route } from "@fosscord/api"; +import { Config } from "@fosscord/util"; +const router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const { general } = Config.get(); + res.json(general); +}); + +export default router; diff --git a/src/api/routes/v9/policies/instance/limits.ts b/src/api/routes/v9/policies/instance/limits.ts new file mode 100644 index 00000000..06f14f83 --- /dev/null +++ b/src/api/routes/v9/policies/instance/limits.ts @@ -0,0 +1,11 @@ +import { Router, Request, Response } from "express"; +import { route } from "@fosscord/api"; +import { Config } from "@fosscord/util"; +const router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const { limits } = Config.get(); + res.json(limits); +}); + +export default router; diff --git a/src/api/routes/v9/policies/stats.ts b/src/api/routes/v9/policies/stats.ts new file mode 100644 index 00000000..dc4652fc --- /dev/null +++ b/src/api/routes/v9/policies/stats.ts @@ -0,0 +1,29 @@ +import { route } from "@fosscord/api"; +import { + Config, + getRights, + Guild, + Member, + Message, + User, +} from "@fosscord/util"; +import { Request, Response, Router } from "express"; +const router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + if (!Config.get().security.statsWorldReadable) { + const rights = await getRights(req.user_id); + rights.hasThrow("VIEW_SERVER_STATS"); + } + + res.json({ + counts: { + user: await User.count(), + guild: await Guild.count(), + message: await Message.count(), + members: await Member.count(), + }, + }); +}); + +export default router; diff --git a/src/api/routes/v9/scheduled-maintenances/upcoming_json.ts b/src/api/routes/v9/scheduled-maintenances/upcoming_json.ts new file mode 100644 index 00000000..e42723a1 --- /dev/null +++ b/src/api/routes/v9/scheduled-maintenances/upcoming_json.ts @@ -0,0 +1,16 @@ +import { Router, Request, Response } from "express"; +import { route } from "@fosscord/api"; +const router = Router(); + +router.get( + "/scheduled-maintenances/upcoming.json", + route({}), + async (req: Request, res: Response) => { + res.json({ + page: {}, + scheduled_maintenances: {}, + }); + }, +); + +export default router; diff --git a/src/api/routes/v9/sticker-packs/index.ts b/src/api/routes/v9/sticker-packs/index.ts new file mode 100644 index 00000000..e6560d12 --- /dev/null +++ b/src/api/routes/v9/sticker-packs/index.ts @@ -0,0 +1,13 @@ +import { Request, Response, Router } from "express"; +import { route } from "@fosscord/api"; +import { StickerPack } from "@fosscord/util"; + +const router: Router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const sticker_packs = await StickerPack.find({ relations: ["stickers"] }); + + res.json({ sticker_packs }); +}); + +export default router; diff --git a/src/api/routes/v9/stickers/#sticker_id/index.ts b/src/api/routes/v9/stickers/#sticker_id/index.ts new file mode 100644 index 00000000..b484a7a1 --- /dev/null +++ b/src/api/routes/v9/stickers/#sticker_id/index.ts @@ -0,0 +1,12 @@ +import { Sticker } from "@fosscord/util"; +import { Router, Request, Response } from "express"; +import { route } from "@fosscord/api"; +const router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const { sticker_id } = req.params; + + res.json(await Sticker.find({ where: { id: sticker_id } })); +}); + +export default router; diff --git a/src/api/routes/v9/stop.ts b/src/api/routes/v9/stop.ts new file mode 100644 index 00000000..3f49b360 --- /dev/null +++ b/src/api/routes/v9/stop.ts @@ -0,0 +1,16 @@ +import { Router, Request, Response } from "express"; +import { route } from "@fosscord/api"; + +const router: Router = Router(); + +router.post( + "/", + route({ right: "OPERATOR" }), + async (req: Request, res: Response) => { + console.log(`/stop was called by ${req.user_id} at ${new Date()}`); + res.sendStatus(200); + process.kill(process.pid, "SIGTERM"); + }, +); + +export default router; diff --git a/src/api/routes/v9/store/published-listings/applications.ts b/src/api/routes/v9/store/published-listings/applications.ts new file mode 100644 index 00000000..6156f43e --- /dev/null +++ b/src/api/routes/v9/store/published-listings/applications.ts @@ -0,0 +1,79 @@ +import { Request, Response, Router } from "express"; +import { route } from "@fosscord/api"; + +const router: Router = Router(); + +router.get("/:id", route({}), async (req: Request, res: Response) => { + //TODO + const id = req.params.id; + res.json({ + id: "", + summary: "", + sku: { + id: "", + type: 1, + dependent_sku_id: null, + application_id: "", + manifets_labels: [], + access_type: 2, + name: "", + features: [], + release_date: "", + premium: false, + slug: "", + flags: 4, + genres: [], + legal_notice: "", + application: { + id: "", + name: "", + icon: "", + description: "", + summary: "", + cover_image: "", + primary_sku_id: "", + hook: true, + slug: "", + guild_id: "", + bot_public: "", + bot_require_code_grant: false, + verify_key: "", + publishers: [ + { + id: "", + name: "", + }, + ], + developers: [ + { + id: "", + name: "", + }, + ], + system_requirements: {}, + show_age_gate: false, + price: { + amount: 0, + currency: "EUR", + }, + locales: [], + }, + tagline: "", + description: "", + carousel_items: [ + { + asset_id: "", + }, + ], + header_logo_dark_theme: {}, //{id: "", size: 4665, mime_type: "image/gif", width 160, height: 160} + header_logo_light_theme: {}, + box_art: {}, + thumbnail: {}, + header_background: {}, + hero_background: {}, + assets: [], + }, + }).status(200); +}); + +export default router; diff --git a/src/api/routes/v9/store/published-listings/applications/#id/subscription-plans.ts b/src/api/routes/v9/store/published-listings/applications/#id/subscription-plans.ts new file mode 100644 index 00000000..845cdfe7 --- /dev/null +++ b/src/api/routes/v9/store/published-listings/applications/#id/subscription-plans.ts @@ -0,0 +1,25 @@ +import { Request, Response, Router } from "express"; +import { route } from "@fosscord/api"; + +const router: Router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + //TODO + res.json([ + { + id: "", + name: "", + interval: 1, + interval_count: 1, + tax_inclusive: true, + sku_id: "", + fallback_price: 499, + fallback_currency: "eur", + currency: "eur", + price: 4199, + price_tier: null, + }, + ]).status(200); +}); + +export default router; diff --git a/src/api/routes/v9/store/published-listings/skus.ts b/src/api/routes/v9/store/published-listings/skus.ts new file mode 100644 index 00000000..6156f43e --- /dev/null +++ b/src/api/routes/v9/store/published-listings/skus.ts @@ -0,0 +1,79 @@ +import { Request, Response, Router } from "express"; +import { route } from "@fosscord/api"; + +const router: Router = Router(); + +router.get("/:id", route({}), async (req: Request, res: Response) => { + //TODO + const id = req.params.id; + res.json({ + id: "", + summary: "", + sku: { + id: "", + type: 1, + dependent_sku_id: null, + application_id: "", + manifets_labels: [], + access_type: 2, + name: "", + features: [], + release_date: "", + premium: false, + slug: "", + flags: 4, + genres: [], + legal_notice: "", + application: { + id: "", + name: "", + icon: "", + description: "", + summary: "", + cover_image: "", + primary_sku_id: "", + hook: true, + slug: "", + guild_id: "", + bot_public: "", + bot_require_code_grant: false, + verify_key: "", + publishers: [ + { + id: "", + name: "", + }, + ], + developers: [ + { + id: "", + name: "", + }, + ], + system_requirements: {}, + show_age_gate: false, + price: { + amount: 0, + currency: "EUR", + }, + locales: [], + }, + tagline: "", + description: "", + carousel_items: [ + { + asset_id: "", + }, + ], + header_logo_dark_theme: {}, //{id: "", size: 4665, mime_type: "image/gif", width 160, height: 160} + header_logo_light_theme: {}, + box_art: {}, + thumbnail: {}, + header_background: {}, + hero_background: {}, + assets: [], + }, + }).status(200); +}); + +export default router; diff --git a/src/api/routes/v9/store/published-listings/skus/#sku_id/subscription-plans.ts b/src/api/routes/v9/store/published-listings/skus/#sku_id/subscription-plans.ts new file mode 100644 index 00000000..6b49e959 --- /dev/null +++ b/src/api/routes/v9/store/published-listings/skus/#sku_id/subscription-plans.ts @@ -0,0 +1,313 @@ +import { Request, Response, Router } from "express"; +import { route } from "@fosscord/api"; + +const router: Router = Router(); + +const skus = new Map([ + [ + "521842865731534868", + [ + { + id: "511651856145973248", + name: "Individual Premium Tier 3 Monthly (Legacy)", + interval: 1, + interval_count: 1, + tax_inclusive: true, + sku_id: "521842865731534868", + currency: "eur", + price: 0, + price_tier: null, + }, + { + id: "511651860671627264", + name: "Individiual Premium Tier 3 Yearly (Legacy)", + interval: 2, + interval_count: 1, + tax_inclusive: true, + sku_id: "521842865731534868", + currency: "eur", + price: 0, + price_tier: null, + }, + ], + ], + [ + "521846918637420545", + [ + { + id: "511651871736201216", + name: "Individual Premium Tier 2 Monthly", + interval: 1, + interval_count: 1, + tax_inclusive: true, + sku_id: "521846918637420545", + currency: "eur", + price: 0, + price_tier: null, + }, + { + id: "511651876987469824", + name: "Individual Premum Tier 2 Yearly", + interval: 2, + interval_count: 1, + tax_inclusive: true, + sku_id: "521846918637420545", + currency: "eur", + price: 0, + price_tier: null, + }, + { + id: "978380684370378761", + name: "Individual Premum Tier 1", + interval: 2, + interval_count: 1, + tax_inclusive: true, + sku_id: "521846918637420545", + currency: "eur", + price: 0, + price_tier: null, + }, + ], + ], + [ + "521847234246082599", + [ + { + id: "642251038925127690", + name: "Individual Premium Tier 3 Quarterly", + interval: 1, + interval_count: 3, + tax_inclusive: true, + sku_id: "521847234246082599", + currency: "eur", + price: 0, + price_tier: null, + }, + { + id: "511651880837840896", + name: "Individual Premium Tier 3 Monthly", + interval: 1, + interval_count: 1, + tax_inclusive: true, + sku_id: "521847234246082599", + currency: "eur", + price: 0, + price_tier: null, + }, + { + id: "511651885459963904", + name: "Individual Premium Tier 3 Yearly", + interval: 2, + interval_count: 1, + tax_inclusive: true, + sku_id: "521847234246082599", + currency: "eur", + price: 0, + price_tier: null, + }, + ], + ], + [ + "590663762298667008", + [ + { + id: "590665532894740483", + name: "Crowd Premium Monthly", + interval: 1, + interval_count: 1, + tax_inclusive: true, + sku_id: "590663762298667008", + discount_price: 0, + currency: "eur", + price: 0, + price_tier: null, + }, + { + id: "590665538238152709", + name: "Crowd Premium Yearly", + interval: 2, + interval_count: 1, + tax_inclusive: true, + sku_id: "590663762298667008", + discount_price: 0, + currency: "eur", + price: 0, + price_tier: null, + }, + ], + ], + [ + "978380684370378762", + [ + [ + { + id: "978380692553465866", + name: "Premium Tier 0 Monthly", + interval: 1, + interval_count: 1, + tax_inclusive: true, + sku_id: "978380684370378762", + currency: "usd", + price: 299, + price_tier: null, + prices: { + "0": { + country_prices: { + country_code: "US", + prices: [ + { + currency: "usd", + amount: 0, + exponent: 2, + }, + ], + }, + payment_source_prices: { + "775487223059316758": [ + { + currency: "usd", + amount: 0, + exponent: 2, + }, + ], + "736345864146255982": [ + { + currency: "usd", + amount: 0, + exponent: 2, + }, + ], + "683074999590060249": [ + { + currency: "usd", + amount: 0, + exponent: 2, + }, + ], + }, + }, + "3": { + country_prices: { + country_code: "US", + prices: [ + { + currency: "usd", + amount: 0, + exponent: 2, + }, + ], + }, + payment_source_prices: { + "775487223059316758": [ + { + currency: "usd", + amount: 0, + exponent: 2, + }, + ], + "736345864146255982": [ + { + currency: "usd", + amount: 0, + exponent: 2, + }, + ], + "683074999590060249": [ + { + currency: "usd", + amount: 0, + exponent: 2, + }, + ], + }, + }, + "4": { + country_prices: { + country_code: "US", + prices: [ + { + currency: "usd", + amount: 0, + exponent: 2, + }, + ], + }, + payment_source_prices: { + "775487223059316758": [ + { + currency: "usd", + amount: 0, + exponent: 2, + }, + ], + "736345864146255982": [ + { + currency: "usd", + amount: 0, + exponent: 2, + }, + ], + "683074999590060249": [ + { + currency: "usd", + amount: 0, + exponent: 2, + }, + ], + }, + }, + "1": { + country_prices: { + country_code: "US", + prices: [ + { + currency: "usd", + amount: 0, + exponent: 2, + }, + ], + }, + payment_source_prices: { + "775487223059316758": [ + { + currency: "usd", + amount: 0, + exponent: 2, + }, + ], + "736345864146255982": [ + { + currency: "usd", + amount: 0, + exponent: 2, + }, + ], + "683074999590060249": [ + { + currency: "usd", + amount: 0, + exponent: 2, + }, + ], + }, + }, + }, + }, + ], + ], + ], +]); + +router.get("/", route({}), async (req: Request, res: Response) => { + // TODO: add the ability to add custom + const { sku_id } = req.params; + + if (!skus.has(sku_id)) { + console.log(`Request for invalid SKU ${sku_id}! Please report this!`); + res.sendStatus(404); + } else { + res.json(skus.get(sku_id)).status(200); + } +}); + +export default router; diff --git a/src/api/routes/v9/updates.ts b/src/api/routes/v9/updates.ts new file mode 100644 index 00000000..7e9128f4 --- /dev/null +++ b/src/api/routes/v9/updates.ts @@ -0,0 +1,35 @@ +import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; +import { Config, FieldErrors, Release } from "@fosscord/util"; + +const router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const { client } = Config.get(); + const platform = req.query.platform; + + if (!platform) + throw FieldErrors({ + platform: { + code: "BASE_TYPE_REQUIRED", + message: req.t("common:field.BASE_TYPE_REQUIRED"), + }, + }); + + const release = await Release.findOneOrFail({ + where: { + enabled: true, + platform: platform as string, + }, + order: { pub_date: "DESC" }, + }); + + res.json({ + name: release.name, + pub_date: release.pub_date, + url: release.url, + notes: release.notes, + }); +}); + +export default router; diff --git a/src/api/routes/v9/users/#id/delete.ts b/src/api/routes/v9/users/#id/delete.ts new file mode 100644 index 00000000..6112e943 --- /dev/null +++ b/src/api/routes/v9/users/#id/delete.ts @@ -0,0 +1,38 @@ +import { route } from "@fosscord/api"; +import { + emitEvent, + Member, + PrivateUserProjection, + User, + UserDeleteEvent, + UserDeleteSchema, +} from "@fosscord/util"; +import { Request, Response, Router } from "express"; + +const router = Router(); + +router.post( + "/", + route({ right: "MANAGE_USERS" }), + async (req: Request, res: Response) => { + let user = await User.findOneOrFail({ + where: { id: req.params.id }, + select: [...PrivateUserProjection, "data"], + }); + await Promise.all([ + Member.delete({ id: req.params.id }), + User.delete({ id: req.params.id }), + ]); + + // TODO: respect intents as USER_DELETE has potential to cause privacy issues + await emitEvent({ + event: "USER_DELETE", + user_id: req.user_id, + data: { user_id: req.params.id }, + } as UserDeleteEvent); + + res.sendStatus(204); + }, +); + +export default router; diff --git a/src/api/routes/v9/users/#id/index.ts b/src/api/routes/v9/users/#id/index.ts new file mode 100644 index 00000000..bdb1060f --- /dev/null +++ b/src/api/routes/v9/users/#id/index.ts @@ -0,0 +1,13 @@ +import { Router, Request, Response } from "express"; +import { User } from "@fosscord/util"; +import { route } from "@fosscord/api"; + +const router: Router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const { id } = req.params; + + res.json(await User.getPublicUser(id)); +}); + +export default router; diff --git a/src/api/routes/v9/users/#id/profile.ts b/src/api/routes/v9/users/#id/profile.ts new file mode 100644 index 00000000..5c649056 --- /dev/null +++ b/src/api/routes/v9/users/#id/profile.ts @@ -0,0 +1,182 @@ +import { Router, Request, Response } from "express"; +import { + PublicConnectedAccount, + PublicUser, + User, + UserPublic, + Member, + Guild, + UserProfileModifySchema, + handleFile, + PrivateUserProjection, + emitEvent, + UserUpdateEvent, +} from "@fosscord/util"; +import { route } from "@fosscord/api"; + +const router: Router = Router(); + +export interface UserProfileResponse { + user: UserPublic; + connected_accounts: PublicConnectedAccount; + premium_guild_since?: Date; + premium_since?: Date; +} + +router.get( + "/", + route({ test: { response: { body: "UserProfileResponse" } } }), + async (req: Request, res: Response) => { + if (req.params.id === "@me") req.params.id = req.user_id; + + const { guild_id, with_mutual_guilds } = req.query; + + const user = await User.getPublicUser(req.params.id, { + relations: ["connected_accounts"], + }); + + var mutual_guilds: object[] = []; + var premium_guild_since; + + if (with_mutual_guilds == "true") { + const requested_member = await Member.find({ + where: { id: req.params.id }, + }); + const self_member = await Member.find({ + where: { id: req.user_id }, + }); + + for (const rmem of requested_member) { + if (rmem.premium_since) { + if (premium_guild_since) { + if (premium_guild_since > rmem.premium_since) { + premium_guild_since = rmem.premium_since; + } + } else { + premium_guild_since = rmem.premium_since; + } + } + for (const smem of self_member) { + if (smem.guild_id === rmem.guild_id) { + mutual_guilds.push({ + id: rmem.guild_id, + nick: rmem.nick, + }); + } + } + } + } + + const guild_member = + guild_id && typeof guild_id == "string" + ? await Member.findOneOrFail({ + where: { id: req.params.id, guild_id: guild_id }, + relations: ["roles"], + }) + : undefined; + + // TODO: make proper DTO's in util? + + const userDto = { + username: user.username, + discriminator: user.discriminator, + id: user.id, + public_flags: user.public_flags, + avatar: user.avatar, + accent_color: user.accent_color, + banner: user.banner, + bio: req.user_bot ? null : user.bio, + bot: user.bot, + }; + + const userProfile = { + bio: req.user_bot ? null : user.bio, + accent_color: user.accent_color, + banner: user.banner, + pronouns: user.pronouns, + theme_colors: user.theme_colors, + }; + + const guildMemberDto = guild_member + ? { + avatar: guild_member.avatar, + banner: guild_member.banner, + bio: req.user_bot ? null : guild_member.bio, + communication_disabled_until: + guild_member.communication_disabled_until, + deaf: guild_member.deaf, + flags: user.flags, + is_pending: guild_member.pending, + pending: guild_member.pending, // why is this here twice, discord? + joined_at: guild_member.joined_at, + mute: guild_member.mute, + nick: guild_member.nick, + premium_since: guild_member.premium_since, + roles: guild_member.roles + .map((x) => x.id) + .filter((id) => id != guild_id), + user: userDto, + } + : undefined; + + const guildMemberProfile = { + accent_color: null, + banner: guild_member?.banner || null, + bio: guild_member?.bio || "", + guild_id, + }; + res.json({ + connected_accounts: user.connected_accounts, + premium_guild_since: premium_guild_since, // TODO + premium_since: user.premium_since, // TODO + mutual_guilds: mutual_guilds, // TODO {id: "", nick: null} when ?with_mutual_guilds=true + user: userDto, + premium_type: user.premium_type, + profile_themes_experiment_bucket: 4, // TODO: This doesn't make it available, for some reason? + user_profile: userProfile, + guild_member: guild_id && guildMemberDto, + guild_member_profile: guild_id && guildMemberProfile, + }); + }, +); + +router.patch( + "/", + route({ body: "UserProfileModifySchema" }), + async (req: Request, res: Response) => { + const body = req.body as UserProfileModifySchema; + + if (body.banner) + body.banner = await handleFile( + `/banners/${req.user_id}`, + body.banner as string, + ); + let user = await User.findOneOrFail({ + where: { id: req.user_id }, + select: [...PrivateUserProjection, "data"], + }); + + user.assign(body); + await user.save(); + + // @ts-ignore + delete user.data; + + // TODO: send update member list event in gateway + await emitEvent({ + event: "USER_UPDATE", + user_id: req.user_id, + data: user, + } as UserUpdateEvent); + + res.json({ + accent_color: user.accent_color, + bio: user.bio, + banner: user.banner, + theme_colors: user.theme_colors, + pronouns: user.pronouns, + }); + }, +); + +export default router; diff --git a/src/api/routes/v9/users/#id/relationships.ts b/src/api/routes/v9/users/#id/relationships.ts new file mode 100644 index 00000000..c6480567 --- /dev/null +++ b/src/api/routes/v9/users/#id/relationships.ts @@ -0,0 +1,54 @@ +import { Router, Request, Response } from "express"; +import { User } from "@fosscord/util"; +import { route } from "@fosscord/api"; + +const router: Router = Router(); + +export interface UserRelationsResponse { + object: { + id?: string; + username?: string; + avatar?: string; + discriminator?: string; + public_flags?: number; + }; +} + +router.get( + "/", + route({ test: { response: { body: "UserRelationsResponse" } } }), + async (req: Request, res: Response) => { + var mutual_relations: object[] = []; + const requested_relations = await User.findOneOrFail({ + where: { id: req.params.id }, + relations: ["relationships"], + }); + const self_relations = await User.findOneOrFail({ + where: { id: req.user_id }, + relations: ["relationships"], + }); + + for (const rmem of requested_relations.relationships) { + for (const smem of self_relations.relationships) + if ( + rmem.to_id === smem.to_id && + rmem.type === 1 && + rmem.to_id !== req.user_id + ) { + var relation_user = await User.getPublicUser(rmem.to_id); + + mutual_relations.push({ + id: relation_user.id, + username: relation_user.username, + avatar: relation_user.avatar, + discriminator: relation_user.discriminator, + public_flags: relation_user.public_flags, + }); + } + } + + res.json(mutual_relations); + }, +); + +export default router; diff --git a/src/api/routes/v9/users/@me/channels.ts b/src/api/routes/v9/users/@me/channels.ts new file mode 100644 index 00000000..237be102 --- /dev/null +++ b/src/api/routes/v9/users/@me/channels.ts @@ -0,0 +1,39 @@ +import { Request, Response, Router } from "express"; +import { + Recipient, + DmChannelDTO, + Channel, + DmChannelCreateSchema, +} from "@fosscord/util"; +import { route } from "@fosscord/api"; + +const router: Router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const recipients = await Recipient.find({ + where: { user_id: req.user_id, closed: false }, + relations: ["channel", "channel.recipients"], + }); + res.json( + await Promise.all( + recipients.map((r) => DmChannelDTO.from(r.channel, [req.user_id])), + ), + ); +}); + +router.post( + "/", + route({ body: "DmChannelCreateSchema" }), + async (req: Request, res: Response) => { + const body = req.body as DmChannelCreateSchema; + res.json( + await Channel.createDMChannel( + body.recipients, + req.user_id, + body.name, + ), + ); + }, +); + +export default router; diff --git a/src/api/routes/v9/users/@me/delete.ts b/src/api/routes/v9/users/@me/delete.ts new file mode 100644 index 00000000..a9f8167c --- /dev/null +++ b/src/api/routes/v9/users/@me/delete.ts @@ -0,0 +1,38 @@ +import { Router, Request, Response } from "express"; +import { Guild, Member, User } from "@fosscord/util"; +import { route } from "@fosscord/api"; +import bcrypt from "bcrypt"; +import { HTTPError } from "lambert-server"; + +const router = Router(); + +router.post("/", route({}), async (req: Request, res: Response) => { + const user = await User.findOneOrFail({ + where: { id: req.user_id }, + select: ["data"], + }); //User object + let correctpass = true; + + if (user.data.hash) { + // guest accounts can delete accounts without password + correctpass = await bcrypt.compare(req.body.password, user.data.hash); + if (!correctpass) { + throw new HTTPError(req.t("auth:login.INVALID_PASSWORD")); + } + } + + // TODO: decrement guild member count + + if (correctpass) { + await Promise.all([ + User.delete({ id: req.user_id }), + Member.delete({ id: req.user_id }), + ]); + + res.sendStatus(204); + } else { + res.sendStatus(401); + } +}); + +export default router; diff --git a/src/api/routes/v9/users/@me/disable.ts b/src/api/routes/v9/users/@me/disable.ts new file mode 100644 index 00000000..313a888f --- /dev/null +++ b/src/api/routes/v9/users/@me/disable.ts @@ -0,0 +1,32 @@ +import { User } from "@fosscord/util"; +import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; +import bcrypt from "bcrypt"; + +const router = Router(); + +router.post("/", route({}), async (req: Request, res: Response) => { + const user = await User.findOneOrFail({ + where: { id: req.user_id }, + select: ["data"], + }); //User object + let correctpass = true; + + if (user.data.hash) { + // guest accounts can delete accounts without password + correctpass = await bcrypt.compare(req.body.password, user.data.hash); //Not sure if user typed right password :/ + } + + if (correctpass) { + await User.update({ id: req.user_id }, { disabled: true }); + + res.sendStatus(204); + } else { + res.status(400).json({ + message: "Password does not match", + code: 50018, + }); + } +}); + +export default router; diff --git a/src/api/routes/v9/users/@me/email-settings.ts b/src/api/routes/v9/users/@me/email-settings.ts new file mode 100644 index 00000000..a2834b89 --- /dev/null +++ b/src/api/routes/v9/users/@me/email-settings.ts @@ -0,0 +1,20 @@ +import { Router, Response, Request } from "express"; +import { route } from "@fosscord/api"; + +const router = Router(); + +router.get("/", route({}), (req: Request, res: Response) => { + // TODO: + res.json({ + categories: { + social: true, + communication: true, + tips: false, + updates_and_announcements: false, + recommendations_and_events: false, + }, + initialized: false, + }).status(200); +}); + +export default router; diff --git a/src/api/routes/v9/users/@me/guilds.ts b/src/api/routes/v9/users/@me/guilds.ts new file mode 100644 index 00000000..e12bf258 --- /dev/null +++ b/src/api/routes/v9/users/@me/guilds.ts @@ -0,0 +1,76 @@ +import { Router, Request, Response } from "express"; +import { + Guild, + Member, + User, + GuildDeleteEvent, + GuildMemberRemoveEvent, + emitEvent, + Config, +} from "@fosscord/util"; +import { HTTPError } from "lambert-server"; +import { route } from "@fosscord/api"; + +const router: Router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const members = await Member.find({ + relations: ["guild"], + where: { id: req.user_id }, + }); + + let guild = members.map((x) => x.guild); + + if ("with_counts" in req.query && req.query.with_counts == "true") { + guild = []; // TODO: Load guilds with user role permissions number + } + + res.json(guild); +}); + +// user send to leave a certain guild +router.delete("/:guild_id", route({}), async (req: Request, res: Response) => { + const { autoJoin } = Config.get().guild; + const { guild_id } = req.params; + const guild = await Guild.findOneOrFail({ + where: { id: guild_id }, + select: ["owner_id"], + }); + + if (!guild) throw new HTTPError("Guild doesn't exist", 404); + if (guild.owner_id === req.user_id) + throw new HTTPError("You can't leave your own guild", 400); + if ( + autoJoin.enabled && + autoJoin.guilds.includes(guild_id) && + !autoJoin.canLeave + ) { + throw new HTTPError("You can't leave instance auto join guilds", 400); + } + + await Promise.all([ + Member.delete({ id: req.user_id, guild_id: guild_id }), + emitEvent({ + event: "GUILD_DELETE", + data: { + id: guild_id, + }, + user_id: req.user_id, + } as GuildDeleteEvent), + ]); + + const user = await User.getPublicUser(req.user_id); + + await emitEvent({ + event: "GUILD_MEMBER_REMOVE", + data: { + guild_id: guild_id, + user: user, + }, + guild_id: guild_id, + } as GuildMemberRemoveEvent); + + return res.sendStatus(204); +}); + +export default router; diff --git a/src/api/routes/v9/users/@me/guilds/#guild_id/settings.ts b/src/api/routes/v9/users/@me/guilds/#guild_id/settings.ts new file mode 100644 index 00000000..436261d4 --- /dev/null +++ b/src/api/routes/v9/users/@me/guilds/#guild_id/settings.ts @@ -0,0 +1,44 @@ +import { Router, Response, Request } from "express"; +import { + Channel, + Member, + OrmUtils, + UserGuildSettingsSchema, +} from "@fosscord/util"; +import { route } from "@fosscord/api"; + +const router = Router(); + +// GET doesn't exist on discord.com +router.get("/", route({}), async (req: Request, res: Response) => { + const user = await Member.findOneOrFail({ + where: { id: req.user_id, guild_id: req.params.guild_id }, + select: ["settings"], + }); + return res.json(user.settings); +}); + +router.patch( + "/", + route({ body: "UserGuildSettingsSchema" }), + async (req: Request, res: Response) => { + const body = req.body as UserGuildSettingsSchema; + + if (body.channel_overrides) { + for (var channel in body.channel_overrides) { + Channel.findOneOrFail({ where: { id: channel } }); + } + } + + const user = await Member.findOneOrFail({ + where: { id: req.user_id, guild_id: req.params.guild_id }, + select: ["settings"], + }); + OrmUtils.mergeDeep(user.settings || {}, body); + Member.update({ id: req.user_id, guild_id: req.params.guild_id }, user); + + res.json(user.settings); + }, +); + +export default router; diff --git a/src/api/routes/v9/users/@me/index.ts b/src/api/routes/v9/users/@me/index.ts new file mode 100644 index 00000000..37356d9d --- /dev/null +++ b/src/api/routes/v9/users/@me/index.ts @@ -0,0 +1,156 @@ +import { Router, Request, Response } from "express"; +import { + User, + PrivateUserProjection, + emitEvent, + UserUpdateEvent, + handleFile, + FieldErrors, + adjustEmail, + Config, + UserModifySchema, + generateToken, +} from "@fosscord/util"; +import { route } from "@fosscord/api"; +import bcrypt from "bcrypt"; + +const router: Router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + res.json( + await User.findOne({ + select: PrivateUserProjection, + where: { id: req.user_id }, + }), + ); +}); + +router.patch( + "/", + route({ body: "UserModifySchema" }), + async (req: Request, res: Response) => { + const body = req.body as UserModifySchema; + + const user = await User.findOneOrFail({ + where: { id: req.user_id }, + select: [...PrivateUserProjection, "data"], + }); + + // Populated on password change + var newToken: string | undefined; + + if (body.avatar) + body.avatar = await handleFile( + `/avatars/${req.user_id}`, + body.avatar as string, + ); + if (body.banner) + body.banner = await handleFile( + `/banners/${req.user_id}`, + body.banner as string, + ); + + if (body.password) { + if (user.data?.hash) { + const same_password = await bcrypt.compare( + body.password, + user.data.hash || "", + ); + if (!same_password) { + throw FieldErrors({ + password: { + message: req.t("auth:login.INVALID_PASSWORD"), + code: "INVALID_PASSWORD", + }, + }); + } + } else { + user.data.hash = await bcrypt.hash(body.password, 12); + } + } + + if (body.email) { + body.email = adjustEmail(body.email); + if (!body.email && Config.get().register.email.required) + throw FieldErrors({ + email: { + message: req.t("auth:register.EMAIL_INVALID"), + code: "EMAIL_INVALID", + }, + }); + if (!body.password) + throw FieldErrors({ + password: { + message: req.t("auth:register.INVALID_PASSWORD"), + code: "INVALID_PASSWORD", + }, + }); + } + + if (body.new_password) { + if (!body.password && !user.email) { + throw FieldErrors({ + password: { + code: "BASE_TYPE_REQUIRED", + message: req.t("common:field.BASE_TYPE_REQUIRED"), + }, + }); + } + user.data.hash = await bcrypt.hash(body.new_password, 12); + user.data.valid_tokens_since = new Date(); + newToken = (await generateToken(user.id)) as string; + } + + if (body.username) { + var check_username = body?.username?.replace(/\s/g, ""); + if (!check_username) { + throw FieldErrors({ + username: { + code: "BASE_TYPE_REQUIRED", + message: req.t("common:field.BASE_TYPE_REQUIRED"), + }, + }); + } + } + + if (body.discriminator) { + if ( + await User.findOne({ + where: { + discriminator: body.discriminator, + username: body.username || user.username, + }, + }) + ) { + throw FieldErrors({ + discriminator: { + code: "INVALID_DISCRIMINATOR", + message: "This discriminator is already in use.", + }, + }); + } + } + + user.assign(body); + user.validate(); + await user.save(); + + // @ts-ignore + delete user.data; + + // TODO: send update member list event in gateway + await emitEvent({ + event: "USER_UPDATE", + user_id: req.user_id, + data: user, + } as UserUpdateEvent); + + res.json({ + ...user, + newToken, + }); + }, +); + +export default router; +// {"message": "Invalid two-factor code", "code": 60008} diff --git a/src/api/routes/v9/users/@me/mfa/codes-verification.ts b/src/api/routes/v9/users/@me/mfa/codes-verification.ts new file mode 100644 index 00000000..3411605b --- /dev/null +++ b/src/api/routes/v9/users/@me/mfa/codes-verification.ts @@ -0,0 +1,49 @@ +import { Router, Request, Response } from "express"; +import { route } from "@fosscord/api"; +import { + BackupCode, + generateMfaBackupCodes, + User, + CodesVerificationSchema, +} from "@fosscord/util"; + +const router = Router(); + +router.post( + "/", + route({ body: "CodesVerificationSchema" }), + async (req: Request, res: Response) => { + const { key, nonce, regenerate } = req.body as CodesVerificationSchema; + + // TODO: We don't have email/etc etc, so can't send a verification code. + // Once that's done, this route can verify `key` + + const user = await User.findOneOrFail({ where: { id: req.user_id } }); + + var codes: BackupCode[]; + if (regenerate) { + await BackupCode.update( + { user: { id: req.user_id } }, + { expired: true }, + ); + + codes = generateMfaBackupCodes(req.user_id); + await Promise.all(codes.map((x) => x.save())); + } else { + codes = await BackupCode.find({ + where: { + user: { + id: req.user_id, + }, + expired: false, + }, + }); + } + + return res.json({ + backup_codes: codes.map((x) => ({ ...x, expired: undefined })), + }); + }, +); + +export default router; diff --git a/src/api/routes/v9/users/@me/mfa/codes.ts b/src/api/routes/v9/users/@me/mfa/codes.ts new file mode 100644 index 00000000..33053028 --- /dev/null +++ b/src/api/routes/v9/users/@me/mfa/codes.ts @@ -0,0 +1,62 @@ +import { Router, Request, Response } from "express"; +import { route } from "@fosscord/api"; +import { + BackupCode, + FieldErrors, + generateMfaBackupCodes, + User, + MfaCodesSchema, +} from "@fosscord/util"; +import bcrypt from "bcrypt"; + +const router = Router(); + +// TODO: This route is replaced with users/@me/mfa/codes-verification in newer clients + +router.post( + "/", + route({ body: "MfaCodesSchema" }), + async (req: Request, res: Response) => { + const { password, regenerate } = req.body as MfaCodesSchema; + + const user = await User.findOneOrFail({ + where: { id: req.user_id }, + select: ["data"], + }); + + if (!(await bcrypt.compare(password, user.data.hash || ""))) { + throw FieldErrors({ + password: { + message: req.t("auth:login.INVALID_PASSWORD"), + code: "INVALID_PASSWORD", + }, + }); + } + + var codes: BackupCode[]; + if (regenerate) { + await BackupCode.update( + { user: { id: req.user_id } }, + { expired: true }, + ); + + codes = generateMfaBackupCodes(req.user_id); + await Promise.all(codes.map((x) => x.save())); + } else { + codes = await BackupCode.find({ + where: { + user: { + id: req.user_id, + }, + expired: false, + }, + }); + } + + return res.json({ + backup_codes: codes.map((x) => ({ ...x, expired: undefined })), + }); + }, +); + +export default router; diff --git a/src/api/routes/v9/users/@me/mfa/totp/disable.ts b/src/api/routes/v9/users/@me/mfa/totp/disable.ts new file mode 100644 index 00000000..7916e598 --- /dev/null +++ b/src/api/routes/v9/users/@me/mfa/totp/disable.ts @@ -0,0 +1,56 @@ +import { Router, Request, Response } from "express"; +import { route } from "@fosscord/api"; +import { verifyToken } from "node-2fa"; +import { HTTPError } from "lambert-server"; +import { + User, + generateToken, + BackupCode, + TotpDisableSchema, +} from "@fosscord/util"; + +const router = Router(); + +router.post( + "/", + route({ body: "TotpDisableSchema" }), + async (req: Request, res: Response) => { + const body = req.body as TotpDisableSchema; + + const user = await User.findOneOrFail({ + where: { id: req.user_id }, + select: ["totp_secret"], + }); + + const backup = await BackupCode.findOne({ where: { code: body.code } }); + if (!backup) { + const ret = verifyToken(user.totp_secret!, body.code); + if (!ret || ret.delta != 0) + throw new HTTPError( + req.t("auth:login.INVALID_TOTP_CODE"), + 60008, + ); + } + + await User.update( + { id: req.user_id }, + { + mfa_enabled: false, + totp_secret: "", + }, + ); + + await BackupCode.update( + { user: { id: req.user_id } }, + { + expired: true, + }, + ); + + return res.json({ + token: await generateToken(user.id), + }); + }, +); + +export default router; diff --git a/src/api/routes/v9/users/@me/mfa/totp/enable.ts b/src/api/routes/v9/users/@me/mfa/totp/enable.ts new file mode 100644 index 00000000..2c7044da --- /dev/null +++ b/src/api/routes/v9/users/@me/mfa/totp/enable.ts @@ -0,0 +1,59 @@ +import { Router, Request, Response } from "express"; +import { + User, + generateToken, + generateMfaBackupCodes, + TotpEnableSchema, +} from "@fosscord/util"; +import { route } from "@fosscord/api"; +import bcrypt from "bcrypt"; +import { HTTPError } from "lambert-server"; +import { verifyToken } from "node-2fa"; + +const router = Router(); + +router.post( + "/", + route({ body: "TotpEnableSchema" }), + async (req: Request, res: Response) => { + const body = req.body as TotpEnableSchema; + + const user = await User.findOneOrFail({ + where: { id: req.user_id }, + select: ["data", "email"], + }); + + // TODO: Are guests allowed to enable 2fa? + if (user.data.hash) { + if (!(await bcrypt.compare(body.password, user.data.hash))) { + throw new HTTPError(req.t("auth:login.INVALID_PASSWORD")); + } + } + + if (!body.secret) + throw new HTTPError(req.t("auth:login.INVALID_TOTP_SECRET"), 60005); + + if (!body.code) + throw new HTTPError(req.t("auth:login.INVALID_TOTP_CODE"), 60008); + + if (verifyToken(body.secret, body.code)?.delta != 0) + throw new HTTPError(req.t("auth:login.INVALID_TOTP_CODE"), 60008); + + let backup_codes = generateMfaBackupCodes(req.user_id); + await Promise.all(backup_codes.map((x) => x.save())); + await User.update( + { id: req.user_id }, + { mfa_enabled: true, totp_secret: body.secret }, + ); + + res.send({ + token: await generateToken(user.id), + backup_codes: backup_codes.map((x) => ({ + ...x, + expired: undefined, + })), + }); + }, +); + +export default router; diff --git a/src/api/routes/v9/users/@me/notes.ts b/src/api/routes/v9/users/@me/notes.ts new file mode 100644 index 00000000..e54eb897 --- /dev/null +++ b/src/api/routes/v9/users/@me/notes.ts @@ -0,0 +1,68 @@ +import { Request, Response, Router } from "express"; +import { route } from "@fosscord/api"; +import { User, Note, emitEvent, Snowflake } from "@fosscord/util"; + +const router: Router = Router(); + +router.get("/:id", route({}), async (req: Request, res: Response) => { + const { id } = req.params; + + const note = await Note.findOneOrFail({ + where: { + owner: { id: req.user_id }, + target: { id: id }, + }, + }); + + return res.json({ + note: note?.content, + note_user_id: id, + user_id: req.user_id, + }); +}); + +router.put("/:id", route({}), async (req: Request, res: Response) => { + const { id } = req.params; + const owner = await User.findOneOrFail({ where: { id: req.user_id } }); + const target = await User.findOneOrFail({ where: { id: id } }); //if noted user does not exist throw + const { note } = req.body; + + if (note && note.length) { + // upsert a note + if ( + await Note.findOne({ + where: { owner: { id: owner.id }, target: { id: target.id } }, + }) + ) { + Note.update( + { owner: { id: owner.id }, target: { id: target.id } }, + { owner, target, content: note }, + ); + } else { + Note.insert({ + id: Snowflake.generate(), + owner, + target, + content: note, + }); + } + } else { + await Note.delete({ + owner: { id: owner.id }, + target: { id: target.id }, + }); + } + + await emitEvent({ + event: "USER_NOTE_UPDATE", + data: { + note: note, + id: target.id, + }, + user_id: owner.id, + }); + + return res.status(204); +}); + +export default router; diff --git a/src/api/routes/v9/users/@me/relationships.ts b/src/api/routes/v9/users/@me/relationships.ts new file mode 100644 index 00000000..3eec704b --- /dev/null +++ b/src/api/routes/v9/users/@me/relationships.ts @@ -0,0 +1,259 @@ +import { + RelationshipAddEvent, + User, + PublicUserProjection, + RelationshipType, + RelationshipRemoveEvent, + emitEvent, + Relationship, + Config, +} from "@fosscord/util"; +import { Router, Response, Request } from "express"; +import { HTTPError } from "lambert-server"; +import { DiscordApiErrors } from "@fosscord/util"; +import { route } from "@fosscord/api"; + +const router = Router(); + +const userProjection: (keyof User)[] = [ + "relationships", + ...PublicUserProjection, +]; + +router.get("/", route({}), async (req: Request, res: Response) => { + const user = await User.findOneOrFail({ + where: { id: req.user_id }, + relations: ["relationships", "relationships.to"], + select: ["id", "relationships"], + }); + + //TODO DTO + const related_users = user.relationships.map((r) => { + return { + id: r.to.id, + type: r.type, + nickname: null, + user: r.to.toPublicUser(), + }; + }); + + return res.json(related_users); +}); + +router.put( + "/:id", + route({ body: "RelationshipPutSchema" }), + async (req: Request, res: Response) => { + return await updateRelationship( + req, + res, + await User.findOneOrFail({ + where: { id: req.params.id }, + relations: ["relationships", "relationships.to"], + select: userProjection, + }), + req.body.type ?? RelationshipType.friends, + ); + }, +); + +router.post( + "/", + route({ body: "RelationshipPostSchema" }), + async (req: Request, res: Response) => { + return await updateRelationship( + req, + res, + await User.findOneOrFail({ + relations: ["relationships", "relationships.to"], + select: userProjection, + where: { + discriminator: String(req.body.discriminator).padStart( + 4, + "0", + ), //Discord send the discriminator as integer, we need to add leading zeroes + username: req.body.username, + }, + }), + req.body.type, + ); + }, +); + +router.delete("/:id", route({}), async (req: Request, res: Response) => { + const { id } = req.params; + if (id === req.user_id) + throw new HTTPError("You can't remove yourself as a friend"); + + const user = await User.findOneOrFail({ + where: { id: req.user_id }, + select: userProjection, + relations: ["relationships"], + }); + const friend = await User.findOneOrFail({ + where: { id: id }, + select: userProjection, + relations: ["relationships"], + }); + + const relationship = user.relationships.find((x) => x.to_id === id); + const friendRequest = friend.relationships.find( + (x) => x.to_id === req.user_id, + ); + + if (!relationship) + throw new HTTPError("You are not friends with the user", 404); + if (relationship?.type === RelationshipType.blocked) { + // unblock user + + await Promise.all([ + Relationship.delete({ id: relationship.id }), + emitEvent({ + event: "RELATIONSHIP_REMOVE", + user_id: req.user_id, + data: relationship.toPublicRelationship(), + } as RelationshipRemoveEvent), + ]); + return res.sendStatus(204); + } + if (friendRequest && friendRequest.type !== RelationshipType.blocked) { + await Promise.all([ + Relationship.delete({ id: friendRequest.id }), + await emitEvent({ + event: "RELATIONSHIP_REMOVE", + data: friendRequest.toPublicRelationship(), + user_id: id, + } as RelationshipRemoveEvent), + ]); + } + + await Promise.all([ + Relationship.delete({ id: relationship.id }), + emitEvent({ + event: "RELATIONSHIP_REMOVE", + data: relationship.toPublicRelationship(), + user_id: req.user_id, + } as RelationshipRemoveEvent), + ]); + + return res.sendStatus(204); +}); + +export default router; + +async function updateRelationship( + req: Request, + res: Response, + friend: User, + type: RelationshipType, +) { + const id = friend.id; + if (id === req.user_id) + throw new HTTPError("You can't add yourself as a friend"); + + const user = await User.findOneOrFail({ + where: { id: req.user_id }, + relations: ["relationships", "relationships.to"], + select: userProjection, + }); + + var relationship = user.relationships.find((x) => x.to_id === id); + const friendRequest = friend.relationships.find( + (x) => x.to_id === req.user_id, + ); + + // TODO: you can add infinitely many blocked users (should this be prevented?) + if (type === RelationshipType.blocked) { + if (relationship) { + if (relationship.type === RelationshipType.blocked) + throw new HTTPError("You already blocked the user"); + relationship.type = RelationshipType.blocked; + await relationship.save(); + } else { + relationship = await Relationship.create({ + to_id: id, + type: RelationshipType.blocked, + from_id: req.user_id, + }).save(); + } + + if (friendRequest && friendRequest.type !== RelationshipType.blocked) { + await Promise.all([ + Relationship.delete({ id: friendRequest.id }), + emitEvent({ + event: "RELATIONSHIP_REMOVE", + data: friendRequest.toPublicRelationship(), + user_id: id, + } as RelationshipRemoveEvent), + ]); + } + + await emitEvent({ + event: "RELATIONSHIP_ADD", + data: relationship.toPublicRelationship(), + user_id: req.user_id, + } as RelationshipAddEvent); + + return res.sendStatus(204); + } + + const { maxFriends } = Config.get().limits.user; + if (user.relationships.length >= maxFriends) + throw DiscordApiErrors.MAXIMUM_FRIENDS.withParams(maxFriends); + + var incoming_relationship = Relationship.create({ + nickname: undefined, + type: RelationshipType.incoming, + to: user, + from: friend, + }); + var outgoing_relationship = Relationship.create({ + nickname: undefined, + type: RelationshipType.outgoing, + to: friend, + from: user, + }); + + if (friendRequest) { + if (friendRequest.type === RelationshipType.blocked) + throw new HTTPError("The user blocked you"); + if (friendRequest.type === RelationshipType.friends) + throw new HTTPError("You are already friends with the user"); + // accept friend request + incoming_relationship = friendRequest; + incoming_relationship.type = RelationshipType.friends; + } + + if (relationship) { + if (relationship.type === RelationshipType.outgoing) + throw new HTTPError("You already sent a friend request"); + if (relationship.type === RelationshipType.blocked) + throw new HTTPError( + "Unblock the user before sending a friend request", + ); + if (relationship.type === RelationshipType.friends) + throw new HTTPError("You are already friends with the user"); + outgoing_relationship = relationship; + outgoing_relationship.type = RelationshipType.friends; + } + + await Promise.all([ + incoming_relationship.save(), + outgoing_relationship.save(), + emitEvent({ + event: "RELATIONSHIP_ADD", + data: outgoing_relationship.toPublicRelationship(), + user_id: req.user_id, + } as RelationshipAddEvent), + emitEvent({ + event: "RELATIONSHIP_ADD", + data: { + ...incoming_relationship.toPublicRelationship(), + should_notify: true, + }, + user_id: id, + } as RelationshipAddEvent), + ]); + + return res.sendStatus(204); +} diff --git a/src/api/routes/v9/users/@me/settings.ts b/src/api/routes/v9/users/@me/settings.ts new file mode 100644 index 00000000..cce366ac --- /dev/null +++ b/src/api/routes/v9/users/@me/settings.ts @@ -0,0 +1,35 @@ +import { Router, Response, Request } from "express"; +import { OrmUtils, User, UserSettingsSchema } from "@fosscord/util"; +import { route } from "@fosscord/api"; + +const router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + const user = await User.findOneOrFail({ + where: { id: req.user_id }, + relations: ["settings"], + }); + return res.json(user.settings); +}); + +router.patch( + "/", + route({ body: "UserSettingsSchema" }), + async (req: Request, res: Response) => { + const body = req.body as UserSettingsSchema; + if (body.locale === "en") body.locale = "en-US"; // fix discord client crash on unkown locale + + const user = await User.findOneOrFail({ + where: { id: req.user_id, bot: false }, + relations: ["settings"], + }); + + user.settings.assign(body); + + user.settings.save(); + + res.json(user.settings); + }, +); + +export default router; diff --git a/src/api/routes/v9/voice/regions.ts b/src/api/routes/v9/voice/regions.ts new file mode 100644 index 00000000..4de304ee --- /dev/null +++ b/src/api/routes/v9/voice/regions.ts @@ -0,0 +1,11 @@ +import { Router, Request, Response } from "express"; +import { getIpAdress, route } from "@fosscord/api"; +import { getVoiceRegions } from "@fosscord/api"; + +const router: Router = Router(); + +router.get("/", route({}), async (req: Request, res: Response) => { + res.json(await getVoiceRegions(getIpAdress(req), true)); //vip true? +}); + +export default router; diff --git a/src/api/routes/voice/regions.ts b/src/api/routes/voice/regions.ts deleted file mode 100644 index 4de304ee..00000000 --- a/src/api/routes/voice/regions.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Router, Request, Response } from "express"; -import { getIpAdress, route } from "@fosscord/api"; -import { getVoiceRegions } from "@fosscord/api"; - -const router: Router = Router(); - -router.get("/", route({}), async (req: Request, res: Response) => { - res.json(await getVoiceRegions(getIpAdress(req), true)); //vip true? -}); - -export default router; -- cgit 1.5.1