From 32c97a2467199d55718e824a30516193410c3565 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 28 Jan 2025 17:22:45 +0200 Subject: [PATCH] Bluesky via Discord connection + upgrade remix-auth + remove Twitter references (#2058) * Remove Twitter references * Upgrade remix auth, bsky via Discord * Test --- README.md | 2 +- app/components/Dialog.tsx | 3 +- app/components/icons/Twitter.tsx | 16 -- app/constants.ts | 1 - app/db/seed/index.ts | 10 +- app/db/tables.ts | 2 - app/db/types.ts | 2 - .../api-public/routes/user.$identifier.ts | 3 +- app/features/api-public/schema.ts | 3 +- app/features/articles/routes/a.$slug.tsx | 1 - .../auth/core/DiscordStrategy.server.ts | 216 ++++++++---------- .../auth/core/authenticator.server.ts | 14 +- app/features/auth/core/routes.server.ts | 39 +++- app/features/info/routes/contributions.tsx | 90 ++------ .../routes/plus.suggestions.tsx | 1 - app/features/sendouq/routes/q.info.tsx | 10 +- app/features/sendouq/routes/q.rules.tsx | 2 +- app/features/team/TeamRepository.server.ts | 5 +- .../team/routes/t.$customUrl.edit.test.ts | 2 - .../team/routes/t.$customUrl.edit.tsx | 24 +- app/features/team/routes/t.$customUrl.tsx | 24 +- app/features/team/team-constants.ts | 1 - app/features/team/team-schemas.server.ts | 4 - app/features/team/team.css | 13 -- .../components/SocialLinksList.tsx | 10 - .../routes/org.$slug.tsx | 8 - .../tournament-organization.css | 4 - app/features/tournament/routes/to.$id.tsx | 13 -- .../user-page/UserRepository.server.ts | 7 +- .../user-page/routes/u.$identifier.edit.tsx | 25 +- .../user-page/routes/u.$identifier.index.tsx | 11 +- app/styles/u.css | 9 - app/utils/string.test.ts | 2 +- app/utils/urls.ts | 8 - e2e/team.spec.ts | 8 +- locales/da/faq.json | 2 +- locales/da/team.json | 1 - locales/da/user.json | 3 +- locales/de/faq.json | 2 +- locales/de/team.json | 1 - locales/de/user.json | 3 +- locales/en/common.json | 2 +- locales/en/faq.json | 2 +- locales/en/team.json | 1 - locales/en/user.json | 5 +- locales/es-ES/common.json | 1 - locales/es-ES/faq.json | 2 +- locales/es-ES/team.json | 1 - locales/es-ES/user.json | 3 +- locales/es-US/common.json | 1 - locales/es-US/faq.json | 2 +- locales/es-US/team.json | 1 - locales/es-US/user.json | 3 +- locales/fr-CA/faq.json | 2 +- locales/fr-CA/team.json | 1 - locales/fr-CA/user.json | 3 +- locales/fr-EU/faq.json | 2 +- locales/fr-EU/team.json | 1 - locales/fr-EU/user.json | 3 +- locales/he/faq.json | 2 +- locales/he/team.json | 1 - locales/he/user.json | 3 +- locales/it/faq.json | 2 +- locales/it/user.json | 1 - locales/ja/common.json | 1 - locales/ja/faq.json | 2 +- locales/ja/team.json | 1 - locales/ja/user.json | 4 +- locales/pl/faq.json | 1 - locales/pl/team.json | 1 - locales/pl/user.json | 1 - locales/pt-BR/common.json | 1 - locales/pt-BR/faq.json | 2 +- locales/pt-BR/team.json | 1 - locales/pt-BR/user.json | 3 +- locales/ru/faq.json | 2 +- locales/ru/team.json | 1 - locales/ru/user.json | 3 +- locales/zh/common.json | 1 - locales/zh/faq.json | 2 +- locales/zh/team.json | 1 - locales/zh/user.json | 3 +- migrations/079-remove-twitter.js | 8 + package-lock.json | 126 +++++++--- package.json | 4 +- 85 files changed, 305 insertions(+), 514 deletions(-) delete mode 100644 app/components/icons/Twitter.tsx create mode 100644 migrations/079-remove-twitter.js diff --git a/README.md b/README.md index 6cd0fe937..90a14b0f4 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ sendou.ink/ │ ├── components/ -- React components │ ├── db/ -- Database layer │ ├── hooks/ -- React hooks -│ ├── modules/ -- "nodu_modules but part of the app" https://twitter.com/ryanflorence/status/1535103735952658432 +│ ├── modules/ -- "node_modules but part of the app" │ ├── routes/ -- Routes see: https://remix.run/docs/en/v1/guides/routing │ ├── styles/ -- All .css files of the project for styling │ ├── utils/ -- Random helper functions used in many places diff --git a/app/components/Dialog.tsx b/app/components/Dialog.tsx index 081f30fbe..6a0449123 100644 --- a/app/components/Dialog.tsx +++ b/app/components/Dialog.tsx @@ -1,6 +1,8 @@ import React from "react"; import invariant from "~/utils/invariant"; +// TODO: use react aria components + export function Dialog({ children, isOpen, @@ -59,7 +61,6 @@ function useDOMSync(isOpen: boolean) { if (isOpen) { dialog.showModal(); - // TODO: can be replaced with https://twitter.com/argyleink/status/1529869352660439048 once gets control html.classList.add("lock-scroll"); } else { dialog.close(); diff --git a/app/components/icons/Twitter.tsx b/app/components/icons/Twitter.tsx deleted file mode 100644 index f55668dc9..000000000 --- a/app/components/icons/Twitter.tsx +++ /dev/null @@ -1,16 +0,0 @@ -export function TwitterIcon({ className }: { className?: string }) { - return ( - - Twitter Icon - - - ); -} diff --git a/app/constants.ts b/app/constants.ts index bfb0370b0..2babab8e3 100644 --- a/app/constants.ts +++ b/app/constants.ts @@ -14,7 +14,6 @@ export const USER = { CUSTOM_NAME_MAX_LENGTH: 32, CUSTOM_NAME_REGEXP: notAllEmptyCharactersRegExp, BATTLEFY_MAX_LENGTH: 32, - BSKY_MAX_LENGTH: 50, IN_GAME_NAME_TEXT_MAX_LENGTH: 20, IN_GAME_NAME_DISCRIMINATOR_MAX_LENGTH: 5, WEAPON_POOL_MAX_SIZE: 5, diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index 0daf81510..f3946836c 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -245,7 +245,6 @@ async function adminUser() { twitch: "Sendou", youtubeId: "UCWbJLXByvsfQvTcR4HLPs5Q", discordAvatar: ADMIN_TEST_AVATAR, - twitter: "sendouc", discordUniqueName: "sendou", }); } @@ -294,7 +293,6 @@ function nzapUser() { twitch: null, youtubeId: null, discordAvatar: NZAP_TEST_AVATAR, - twitter: null, discordUniqueName: null, }); } @@ -474,7 +472,6 @@ function fakeUser(usedNames: Set) { discordId: String(faker.string.numeric(17)), discordName: uniqueDiscordName(usedNames), twitch: null, - twitter: null, youtubeId: null, discordUniqueName: null, }); @@ -1578,12 +1575,11 @@ function detailedTeam() { sql .prepare( /* sql */ ` - insert into "AllTeam" ("name", "customUrl", "inviteCode", "twitter", "bio", "avatarImgId", "bannerImgId") + insert into "AllTeam" ("name", "customUrl", "inviteCode", "bio", "avatarImgId", "bannerImgId") values ( 'Alliance Rogue', 'alliance-rogue', '${nanoid(INVITE_CODE_LENGTH)}', - 'AllianceRogueFR', '${faker.lorem.paragraph()}', 1, 2 @@ -1643,13 +1639,12 @@ function otherTeams() { sql .prepare( /* sql */ ` - insert into "AllTeam" ("id", "name", "customUrl", "inviteCode", "twitter", "bio") + insert into "AllTeam" ("id", "name", "customUrl", "inviteCode", "bio") values ( @id, @name, @customUrl, @inviteCode, - @twitter, @bio ) `, @@ -1659,7 +1654,6 @@ function otherTeams() { name: teamName, customUrl: teamCustomUrl, inviteCode: nanoid(INVITE_CODE_LENGTH), - twitter: faker.internet.username(), bio: faker.lorem.paragraph(), }); diff --git a/app/db/tables.ts b/app/db/tables.ts index 6296064ed..d7fabf8e0 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -34,7 +34,6 @@ export interface Team { id: GeneratedAlways; inviteCode: string; name: string; - twitter: string | null; bsky: string | null; } @@ -784,7 +783,6 @@ export interface User { showDiscordUniqueName: Generated; stickSens: number | null; twitch: string | null; - twitter: string | null; bsky: string | null; battlefy: string | null; vc: Generated<"YES" | "NO" | "LISTEN_ONLY">; diff --git a/app/db/types.ts b/app/db/types.ts index c30d41099..1c1ffef56 100644 --- a/app/db/types.ts +++ b/app/db/types.ts @@ -23,7 +23,6 @@ export interface User { discordUniqueName: string | null; showDiscordUniqueName: number; twitch: string | null; - twitter: string | null; youtubeId: string | null; bio: string | null; css: string | null; @@ -419,7 +418,6 @@ export interface Team { customUrl: string; inviteCode: string; css: string | null; - twitter: string | null; bio: string | null; avatarImgId: number | null; bannerImgId: number | null; diff --git a/app/features/api-public/routes/user.$identifier.ts b/app/features/api-public/routes/user.$identifier.ts index 3de0e7952..a720566ac 100644 --- a/app/features/api-public/routes/user.$identifier.ts +++ b/app/features/api-public/routes/user.$identifier.ts @@ -32,7 +32,6 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { "User.country", "User.discordName", "User.twitch", - "User.twitter", "User.battlefy", "User.bsky", "User.customUrl", @@ -95,9 +94,9 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { plusServerTier: user.tier as GetUserResponse["plusServerTier"], socials: { twitch: user.twitch, - twitter: user.twitter, battlefy: user.battlefy, bsky: user.bsky, + twitter: null, // deprecated field }, peakXp: user.xRankPlacements.length > 0 diff --git a/app/features/api-public/schema.ts b/app/features/api-public/schema.ts index 5b11e09ed..5145a8167 100644 --- a/app/features/api-public/schema.ts +++ b/app/features/api-public/schema.ts @@ -26,7 +26,8 @@ export interface GetUserResponse { country: string | null; socials: { twitch: string | null; - twitter: string | null; + // @deprecated + twitter: null; battlefy: string | null; bsky: string | null; }; diff --git a/app/features/articles/routes/a.$slug.tsx b/app/features/articles/routes/a.$slug.tsx index 07ae6e5b0..871eb71ef 100644 --- a/app/features/articles/routes/a.$slug.tsx +++ b/app/features/articles/routes/a.$slug.tsx @@ -53,7 +53,6 @@ export const meta: MetaFunction = (args) => { { property: "og:title", content: data.title }, { name: "description", content: description }, { property: "og:description", content: description }, - { name: "twitter:card", content: "summary_large_image" }, { property: "og:image", content: articlePreviewUrl(args.params.slug) }, { property: "og:type", content: "article" }, { property: "og:site_name", content: "sendou.ink" }, diff --git a/app/features/auth/core/DiscordStrategy.server.ts b/app/features/auth/core/DiscordStrategy.server.ts index 1ad6e89c1..e1d26df24 100644 --- a/app/features/auth/core/DiscordStrategy.server.ts +++ b/app/features/auth/core/DiscordStrategy.server.ts @@ -1,15 +1,9 @@ -import type { OAuth2Profile } from "remix-auth-oauth2"; import { OAuth2Strategy } from "remix-auth-oauth2"; import { z } from "zod"; import type { User } from "~/db/types"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import invariant from "~/utils/invariant"; import { logger } from "~/utils/logger"; -import { DISCORD_AUTH_KEY } from "./authenticator.server"; - -interface DiscordExtraParams extends Record { - scope: string; -} export type LoggedInUser = User["id"]; @@ -35,88 +29,14 @@ const discordUserDetailsSchema = z.tuple([ partialDiscordConnectionsSchema, ]); -export class DiscordStrategy extends OAuth2Strategy< - LoggedInUser, - OAuth2Profile, - DiscordExtraParams -> { - name = DISCORD_AUTH_KEY; - scope: string; +export const DiscordStrategy = () => { + const envVars = authEnvVars(); - constructor() { - const envVars = authEnvVars(); - - super( - { - authorizationURL: "https://discord.com/api/oauth2/authorize", - tokenURL: - process.env.AUTH_GATEWAY_TOKEN_URL ?? - "https://discord.com/api/oauth2/token", - clientID: envVars.DISCORD_CLIENT_ID, - clientSecret: envVars.DISCORD_CLIENT_SECRET, - callbackURL: new URL("/auth/callback", envVars.BASE_URL).toString(), - }, - async ({ accessToken }) => { - try { - const discordResponses = this.authGatewayEnabled() - ? await this.fetchProfileViaGateway(accessToken) - : await this.fetchProfileViaDiscordApi(accessToken); - - const [user, connections] = - discordUserDetailsSchema.parse(discordResponses); - - const isAlreadyRegistered = Boolean( - await UserRepository.identifierToUserId(user.id), - ); - - if (!isAlreadyRegistered && !user.verified) { - logger.info(`User is not verified with id: ${user.id}`); - throw new Error("Unverified user"); - } - - const userFromDb = await UserRepository.upsert({ - discordAvatar: user.avatar ?? null, - discordId: user.id, - discordName: user.global_name ?? user.username, - discordUniqueName: user.global_name ? user.username : null, - ...this.parseConnections(connections), - }); - - return userFromDb.id; - } catch (e) { - console.error("Failed to finish authentication:\n", e); - throw new Error("Failed to finish authentication"); - } - }, - ); - - this.scope = "identify connections email"; - } - - private authGatewayEnabled() { + const authGatewayEnabled = () => { return Boolean(process.env.AUTH_GATEWAY_TOKEN_URL); - } + }; - private async fetchProfileViaDiscordApi(token: string) { - const authHeader: [string, string] = ["Authorization", `Bearer ${token}`]; - - return Promise.all([ - fetch("https://discord.com/api/users/@me", { - headers: [authHeader], - }).then(this.jsonIfOk), - fetch("https://discord.com/api/users/@me/connections", { - headers: [authHeader], - }).then(this.jsonIfOk), - ]); - } - - private async fetchProfileViaGateway(token: string) { - const url = `${process.env.AUTH_GATEWAY_PROFILE_URL}?token=${token}`; - - return fetch(url).then(this.jsonIfOk); - } - - private jsonIfOk(res: Response) { + const jsonIfOk = (res: Response) => { if (!res.ok) { throw new Error( `Auth related call failed with status code ${res.status}`, @@ -124,48 +44,106 @@ export class DiscordStrategy extends OAuth2Strategy< } return res.json(); - } + }; - private parseConnections( - connections: z.infer, - ) { - if (!connections) throw new Error("No connections"); + const fetchProfileViaDiscordApi = (token: string) => { + const authHeader: [string, string] = ["Authorization", `Bearer ${token}`]; - const result: { - twitch: string | null; - twitter: string | null; - youtubeId: string | null; - } = { - twitch: null, - twitter: null, - youtubeId: null, - }; + return Promise.all([ + fetch("https://discord.com/api/users/@me", { + headers: [authHeader], + }).then(jsonIfOk), + fetch("https://discord.com/api/users/@me/connections", { + headers: [authHeader], + }).then(jsonIfOk), + ]); + }; - for (const connection of connections) { - if (connection.visibility !== 1 || !connection.verified) continue; + const fetchProfileViaGateway = (token: string) => { + const url = `${process.env.AUTH_GATEWAY_PROFILE_URL}?token=${token}`; - switch (connection.type) { - case "twitch": - result.twitch = connection.name; - break; - case "twitter": - result.twitter = connection.name; - break; - case "youtube": - result.youtubeId = connection.id; + return fetch(url).then(jsonIfOk); + }; + + return new OAuth2Strategy( + { + clientId: envVars.DISCORD_CLIENT_ID, + clientSecret: envVars.DISCORD_CLIENT_SECRET, + + authorizationEndpoint: "https://discord.com/api/oauth2/authorize", + tokenEndpoint: + process.env.AUTH_GATEWAY_TOKEN_URL || + "https://discord.com/api/oauth2/token", + redirectURI: new URL("/auth/callback", envVars.BASE_URL).toString(), + + scopes: ["identify", "connections", "email"], + }, + async ({ tokens }) => { + try { + const discordResponses = authGatewayEnabled() + ? await fetchProfileViaGateway(tokens.accessToken()) + : await fetchProfileViaDiscordApi(tokens.accessToken()); + + const [user, connections] = + discordUserDetailsSchema.parse(discordResponses); + + const isAlreadyRegistered = Boolean( + await UserRepository.identifierToUserId(user.id), + ); + + if (!isAlreadyRegistered && !user.verified) { + logger.info(`User is not verified with id: ${user.id}`); + throw new Error("Unverified user"); + } + + const userFromDb = await UserRepository.upsert({ + discordAvatar: user.avatar ?? null, + discordId: user.id, + discordName: user.global_name ?? user.username, + discordUniqueName: user.global_name ? user.username : null, + ...parseConnections(connections), + }); + + return userFromDb.id; + } catch (e) { + console.error("Failed to finish authentication:\n", e); + throw new Error("Failed to finish authentication"); } + }, + ); +}; + +function parseConnections( + connections: z.infer, +) { + if (!connections) throw new Error("No connections"); + + const result: { + twitch: string | null; + youtubeId: string | null; + bsky: string | null; + } = { + twitch: null, + youtubeId: null, + bsky: null, + }; + + for (const connection of connections) { + if (connection.visibility !== 1 || !connection.verified) continue; + + switch (connection.type) { + case "twitch": + result.twitch = connection.name; + break; + case "youtube": + result.youtubeId = connection.id; + break; + case "bluesky": + result.bsky = connection.name; } - - return result; } - protected authorizationParams() { - const urlSearchParams: Record = { - scope: this.scope, - }; - - return new URLSearchParams(urlSearchParams); - } + return result; } function authEnvVars() { diff --git a/app/features/auth/core/authenticator.server.ts b/app/features/auth/core/authenticator.server.ts index bf4232c96..9dc341977 100644 --- a/app/features/auth/core/authenticator.server.ts +++ b/app/features/auth/core/authenticator.server.ts @@ -1,17 +1,9 @@ import { Authenticator } from "remix-auth"; -import { DiscordStrategy } from "./DiscordStrategy.server"; -import type { LoggedInUser } from "./DiscordStrategy.server"; -import { authSessionStorage } from "./session.server"; +import { DiscordStrategy, type LoggedInUser } from "./DiscordStrategy.server"; -export const DISCORD_AUTH_KEY = "discord"; export const SESSION_KEY = "user"; export const IMPERSONATED_SESSION_KEY = "impersonated_user"; -export const authenticator = new Authenticator( - authSessionStorage, - { - sessionKey: SESSION_KEY, - }, -); +export const authenticator = new Authenticator(); -authenticator.use(new DiscordStrategy()); +authenticator.use(DiscordStrategy(), "discord"); diff --git a/app/features/auth/core/routes.server.ts b/app/features/auth/core/routes.server.ts index 1fb469994..ef24abc54 100644 --- a/app/features/auth/core/routes.server.ts +++ b/app/features/auth/core/routes.server.ts @@ -4,13 +4,13 @@ import { isbot } from "isbot"; import { z } from "zod"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import { canAccessLohiEndpoint, canPerformAdminActions } from "~/permissions"; +import { logger } from "~/utils/logger"; import { parseSearchParams, validate } from "~/utils/remix.server"; import { ADMIN_PAGE, authErrorUrl } from "~/utils/urls"; import { createLogInLink } from "../queries/createLogInLink.server"; import { deleteLogInLinkByCode } from "../queries/deleteLogInLinkByCode.server"; import { userIdByLogInLinkCode } from "../queries/userIdByLogInLinkCode.server"; import { - DISCORD_AUTH_KEY, IMPERSONATED_SESSION_KEY, SESSION_KEY, authenticator, @@ -22,23 +22,42 @@ export const callbackLoader: LoaderFunction = async ({ request }) => { const url = new URL(request.url); if (url.searchParams.get("error") === "access_denied") { // The user denied the authentication request - // This is part of the oauth2 protocol, but remix-auth-oauth2 doesn't do - // nice error handling for this case. // https://www.oauth.com/oauth2-servers/server-side-apps/possible-errors/ throw redirect(authErrorUrl("aborted")); } - await authenticator.authenticate(DISCORD_AUTH_KEY, request, { - successRedirect: "/", - failureRedirect: authErrorUrl("unknown"), - }); + try { + const userId = await authenticator.authenticate("discord", request); - throw new Response("Unknown authentication state", { status: 500 }); + const session = await authSessionStorage.getSession( + request.headers.get(SESSION_KEY), + ); + + session.set(SESSION_KEY, userId); + + return redirect("/", { + headers: { + "Set-Cookie": await authSessionStorage.commitSession(session), + }, + }); + } catch (error) { + if (error instanceof Error) { + logger.error("Error during authentication:", error); + throw redirect(authErrorUrl("unknown")); + } + + throw error; + } }; export const logOutAction: ActionFunction = async ({ request }) => { - await authenticator.logout(request, { redirectTo: "/" }); + const session = await authSessionStorage.getSession( + request.headers.get(SESSION_KEY), + ); + return redirect("/", { + headers: { "Set-Cookie": await authSessionStorage.destroySession(session) }, + }); }; export const logInAction: ActionFunction = async ({ request }) => { @@ -47,7 +66,7 @@ export const logInAction: ActionFunction = async ({ request }) => { "Login is temporarily disabled", ); - return await authenticator.authenticate(DISCORD_AUTH_KEY, request); + return await authenticator.authenticate("discord", request); }; export const impersonateAction: ActionFunction = async ({ request }) => { diff --git a/app/features/info/routes/contributions.tsx b/app/features/info/routes/contributions.tsx index 93761d292..299e0f887 100644 --- a/app/features/info/routes/contributions.tsx +++ b/app/features/info/routes/contributions.tsx @@ -8,15 +8,9 @@ import { languages } from "~/modules/i18n/config"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { makeTitle } from "~/utils/strings"; import { - ANTARISKA_TWITTER, - BORZOIC_TWITTER, GITHUB_CONTRIBUTORS_URL, - LEAN_TWITTER, RHODESMAS_FREESOUND_PROFILE_URL, - SENDOU_TWITTER_URL, SPLATOON_3_INK, - UBERU_TWITTER, - YAGA_TWITTER, } from "~/utils/urls"; export const meta: MetaFunction = () => { @@ -38,7 +32,7 @@ const PROGRAMMERS = [ ] as const; const TRANSLATORS: Array<{ - translators: Array; + translators: Array; language: (typeof languages)[number]["code"]; }> = [ { @@ -46,11 +40,7 @@ const TRANSLATORS: Array<{ language: "da", }, { - translators: [ - { name: "NoAim™bUrn", twitter: "noaim_brn" }, - { name: "Alice", twitter: "Aloschus" }, - "jgiefer", - ], + translators: ["NoAim™bUrn", "Alice", "jgiefer"], language: "de", }, { @@ -74,7 +64,7 @@ const TRANSLATORS: Array<{ language: "he", }, { - translators: [{ name: "funyaaa", twitter: "funyaaa1" }, "taqm", "yutarour"], + translators: ["funyaaa", "taqm", "yutarour"], language: "ja", }, { @@ -86,15 +76,15 @@ const TRANSLATORS: Array<{ language: "pl", }, { - translators: [{ name: "Ant", twitter: "Ant__Spl" }], + translators: ["Ant"], language: "pt-BR", }, { - translators: [{ name: "Ferrari", twitter: "Blusling" }], + translators: ["Ferrari"], language: "nl", }, { - translators: [{ name: "DoubleCookies", twitter: "DblCookies" }, "yaga"], + translators: ["DoubleCookies", "yaga"], language: "ru", }, { @@ -111,11 +101,7 @@ export default function ContributionsPage() {

- Sendou.ink is a project by{" "} - - Sendou - {" "} - with help from contributors: + Sendou.ink is a project by Sendou with help from contributors: