Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

312 changes: 312 additions & 0 deletions graphql/invite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,312 @@
import { normalizeEmail } from "@hackerspub/models/account";
import { negotiateLocale } from "@hackerspub/models/i18n";
import {
type Account as AccountTable,
accountTable,
type Actor,
} from "@hackerspub/models/schema";
import { createSignupToken, type SignupToken } from "@hackerspub/models/signup";
import type { Uuid } from "@hackerspub/models/uuid";
import { getLogger } from "@logtape/logtape";
import { expandGlob } from "@std/fs";
import { join } from "@std/path";
import { createMessage, type Message } from "@upyo/core";
import { and, eq, gt, sql } from "drizzle-orm";
import { parseTemplate } from "url-template";
import { Account } from "./account.ts";
import { builder } from "./builder.ts";
import { EMAIL_FROM } from "./email.ts";

const logger = getLogger(["hackerspub", "graphql", "invite"]);

interface Invitation {
inviterId: Uuid;
email: string;
locale: Intl.Locale;
message?: string;
}

const InvitationRef = builder.objectRef<Invitation>("Invitation");

InvitationRef.implement({
description: "An invitation that has been created.",
fields: (t) => ({
inviter: t.field({
type: Account,
async resolve(invitation, _, ctx) {
const account = await ctx.db.query.accountTable.findFirst({
where: { id: invitation.inviterId },
with: { actor: true },
});
if (account == null) {
throw new Error(
`Account with ID ${invitation.inviterId} not found.`,
);
}
return account;
},
}),
email: t.field({
type: "Email",
resolve(invitation) {
return invitation.email;
},
}),
locale: t.field({
type: "Locale",
resolve(invitation) {
return invitation.locale;
},
}),
message: t.field({
type: "Markdown",
nullable: true,
resolve(invitation) {
return invitation.message ?? null;
},
}),
}),
});

const InviteInviterError = builder.enumType("InviteInviterError", {
values: ["INVITER_NOT_AUTHENTICATED", "INVITER_NO_INVITATIONS_LEFT"] as const,
});

const InviteEmailError = builder.enumType("InviteEmailError", {
values: ["EMAIL_INVALID", "EMAIL_ALREADY_TAKEN"] as const,
});

const InviteVerifyUrlError = builder.enumType("InviteVerifyUrlError", {
values: ["VERIFY_URL_NO_TOKEN", "VERIFY_URL_NO_CODE"] as const,
});

interface InviteValidationErrors {
inviter?: typeof InviteInviterError.$inferType;
email?: typeof InviteEmailError.$inferType;
verifyUrl?: typeof InviteVerifyUrlError.$inferType;
emailOwnerId?: Uuid;
}

const InviteValidationErrorsRef = builder.objectRef<InviteValidationErrors>(
"InviteValidationErrors",
);

InviteValidationErrorsRef.implement({
description: "Validation errors that occurred during the invitation process.",
fields: (t) => ({
inviter: t.field({
type: InviteInviterError,
nullable: true,
resolve: (errors) => errors.inviter ?? null,
}),
email: t.field({
type: InviteEmailError,
nullable: true,
resolve: (errors) => errors.email ?? null,
}),
verifyUrl: t.field({
type: InviteVerifyUrlError,
nullable: true,
resolve: (errors) => errors.verifyUrl ?? null,
}),
emailOwner: t.field({
type: Account,
nullable: true,
resolve(errors, _, ctx) {
if (errors.emailOwnerId == null) return null;
return ctx.db.query.accountTable.findFirst({
where: { id: errors.emailOwnerId },
});
},
}),
}),
});

const InviteResultRef = builder.unionType("InviteResult", {
types: [InvitationRef, InviteValidationErrorsRef],
resolveType(obj) {
if ("inviterId" in obj) return InvitationRef;
return InviteValidationErrorsRef;
},
});

export const EXPIRATION = Temporal.Duration.from({ hours: 48 });

builder.mutationField("invite", (t) =>
t.field({
type: InviteResultRef,
args: {
email: t.arg({ type: "Email", required: true }),
locale: t.arg({ type: "Locale", required: true }),
message: t.arg({ type: "Markdown" }),
verifyUrl: t.arg({
type: "URITemplate",
required: true,
description:
"The RFC 6570-compliant URI Template for the verification link. Available variables: `{token}` and `{code}`.",
}),
},
async resolve(_root, args, ctx) {
const errors = {} as InviteValidationErrors;
if (ctx.account == null) errors.inviter = "INVITER_NOT_AUTHENTICATED";
else if (ctx.account.leftInvitations < 1) {
errors.inviter = "INVITER_NO_INVITATIONS_LEFT";
}
let email: string | undefined;
try {
email = normalizeEmail(args.email);
} catch {
errors.email = "EMAIL_INVALID";
}
if (email != null) {
const existingEmail = await ctx.db.query.accountEmailTable.findFirst({
where: { email },
});
if (existingEmail != null) {
errors.email = "EMAIL_ALREADY_TAKEN";
errors.emailOwnerId = existingEmail.accountId;
}
}
const verifyUrlTemplate = parseTemplate(args.verifyUrl);
const a = verifyUrlTemplate.expand({
token: "00000000-0000-0000-0000-000000000000",
code: "AAAAAA",
});
const b = verifyUrlTemplate.expand({
token: "ffffffff-ffff-ffff-ffff-ffffffffffff",
code: "AAAAAA",
});
if (a === b) {
errors.verifyUrl = "VERIFY_URL_NO_TOKEN";
}
const c = verifyUrlTemplate.expand({
token: "00000000-0000-0000-0000-000000000000",
code: "BBBBBB",
});
if (a === c) {
errors.verifyUrl = "VERIFY_URL_NO_CODE";
}
if (
errors.inviter != null || errors.email != null ||
errors.email != null || ctx.account == null || email == null
) {
return errors;
}
const updated = await ctx.db.update(accountTable).set({
leftInvitations: sql`${accountTable.leftInvitations} - 1`,
}).where(
and(
eq(accountTable.id, ctx.account.id),
gt(accountTable.leftInvitations, 0),
),
).returning();
if (updated.length < 1) {
return {
inviter: "INVITER_NO_INVITATIONS_LEFT",
} satisfies InviteValidationErrors;
}
const token = await createSignupToken(ctx.kv, email, {
inviterId: ctx.account.id,
expiration: EXPIRATION,
});
const message = await getEmailMessage({
locale: args.locale,
inviter: ctx.account,
verifyUrlTemplate: args.verifyUrl,
to: email,
token,
message: args.message ?? undefined,
});
const receipt = await ctx.email.send(message);
if (!receipt.successful) {
logger.error(
"Failed to send invitation email: {errors}",
{ errors: receipt.errorMessages },
);
}
return {
inviterId: ctx.account.id,
email,
locale: args.locale,
message: args.message ?? undefined,
};
},
}));

const LOCALES_DIR = join(import.meta.dirname!, "locales");

async function getEmailTemplate(
locale: Intl.Locale,
message: boolean,
): Promise<{ subject: string; content: string }> {
const availableLocales: Record<string, string> = {};
const files = expandGlob(join(LOCALES_DIR, "*.json"), {
includeDirs: false,
});
for await (const file of files) {
if (!file.isFile) continue;
const match = file.name.match(/^(.+)\.json$/);
if (match == null) continue;
const localeName = match[1];
availableLocales[localeName] = file.path;
}
const selectedLocale =
negotiateLocale(locale, Object.keys(availableLocales)) ??
new Intl.Locale("en");
const path = availableLocales[selectedLocale.baseName];
const json = await Deno.readTextFile(path);
const data = JSON.parse(json);
return {
subject: data.invite.emailSubject,
content: message
? data.invite.emailContentWithMessage
: data.invite.emailContent,
};
}

async function getEmailMessage(
{ locale, inviter, to, verifyUrlTemplate, token, message }: {
locale: Intl.Locale;
inviter: AccountTable & { actor: Actor };
to: string;
verifyUrlTemplate: string;
token: SignupToken;
message?: string;
},
): Promise<Message> {
const verifyUrl = parseTemplate(verifyUrlTemplate).expand({
token: token.token,
code: token.code,
});
const expiration = EXPIRATION.toLocaleString(locale.baseName, {
// @ts-ignore: DurationFormatOptions, not DateTimeFormatOptions
style: "long",
});
const template = await getEmailTemplate(locale, message != null);
function substitute(template: string): string {
return template.replaceAll(
/\{\{(verifyUrl|code|expiration|inviter|inviterName|message)\}\}/g,
(m) => {
return m === "{{verifyUrl}}"
? verifyUrl
: m === "{{code}}"
? token.code
: m === "{{expiration}}"
? expiration
: m === "{{inviter}}"
? `${inviter.name} (${inviter.actor.handle})`
: m === "{{inviterName}}"
? inviter.name
: (message ?? "");
},
);
}
return createMessage({
from: EMAIL_FROM,
to,
subject: substitute(template.subject),
content: {
text: substitute(template.content),
},
});
}
5 changes: 5 additions & 0 deletions graphql/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,10 @@
"login": {
"emailSubject": "Sign in to Hackers' Pub",
"emailContent": "Welcome back to Hackers' Pub! To sign in, click the following link:\n\n{{verifyUrl}}\n\nor submit this code through the sign-in page:\n\n{{code}}\n\nThis link and code will expire in {{expiration}}.\n\nIf you didn't request this email, you can safely ignore it.\n"
},
"invite": {
"emailSubject": "{{inviterName}} invites you to Hackers' Pub!",
"emailContent": "{{inviter}} invites you to Hackers' Pub!\n\nHackers' Pub is a place for software engineers to share their knowledge and experience with each other. It's also an ActivityPub-enabled social network, so you can follow your favorite hackers in the fediverse and get their latest posts in your feed.\n\nTo accept the invitation, click the following link:\n\n{{verifyUrl}}\n\nThis link will expire in {{expiration}}.\n",
"emailContentWithMessage": "{{inviter}} invites you to Hackers' Pub! Here's a message from {{inviterName}}:\n\n{{message}}\n\nTo accept the invitation, click the following link:\n\n{{verifyUrl}}\n\nThis link will expire in {{expiration}}.\n"
}
}
5 changes: 5 additions & 0 deletions graphql/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,10 @@
"login": {
"emailSubject": "Hackers' Pubへのログイン",
"emailContent": "Hackers' Pubへお帰りなさい!ログインするには以下のリンクをクリックしてください:\n\n{{verifyUrl}}\n\n他には下のコードをページに入力して下さい:\n\n{{code}}\n\nこのリンクとコードは{{expiration}}後に有効期限が切れます。\n\nこのメールに心当たりがない場合は、無視していただいて構いません。\n"
},
"invite": {
"emailSubject": "{{inviterName}}さんがHackers' Pubに招待しています!",
"emailContent": "{{inviter}}さんがHackers' Pubに招待しています!\n\nHackers' Pubは、ソフトウェアエンジニアが知識と経験を共有する場所です。また、ActivityPubに対応したソーシャルネットワークでもあり、フェディバース(fediverse)で気に入ったハッカーをフォローして、最新のコンテンツをフィードで受け取ることができます。\n\n招待を受け入れるには、以下のリンクをクリックしてください:\n\n{{verifyUrl}}\n\nこのリンクは{{expiration}}後に有効期限が切れます。\n",
"emailContentWithMessage": "{{inviter}}さんがHackers' Pubに招待しています!{{inviterName}}さんからのメッセージです:\n\n{{message}}\n\n招待を受け入れるには、以下のリンクをクリックしてください:\n\n{{verifyUrl}}\n\nこのリンクは{{expiration}}後に有効期限が切れます。\n"
}
}
5 changes: 5 additions & 0 deletions graphql/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,10 @@
"login": {
"emailSubject": "Hackers' Pub 로그인",
"emailContent": "Hackers' Pub에 다시 오신 것을 환영합니다! 로그인하려면 다음 링크를 클릭하세요:\n\n{{verifyUrl}}\n\n혹은 이 코드를 입력해주세요:\n\n{{code}}\n\n이 링크와 코드는 {{expiration}} 후에 만료됩니다.\n\n이 이메일을 요청하지 않았다면 무시해도 됩니다.\n"
},
"invite": {
"emailSubject": "{{inviterName}} 님이 Hackers' Pub에 초대합니다!",
"emailContent": "{{inviter}} 님이 Hackers' Pub에 초대합니다!\n\nHackers' Pub은 소프트웨어 프로그래머들이 지식과 경험을 서로 나누는 곳입니다. 또한 ActivityPub을 지원하는 소셜 네트워크이기도 하며, 연합우주(fediverse)에서 즐겨찾는 소프트웨어 프로그래머들을 팔로하고 최신 콘텐츠를 받아 볼 수 있습니다.\n\n초대장을 받으려면 아래 링크를 클릭하세요:\n\n{{verifyUrl}}\n\n이 링크는 {{expiration}} 후에 만료됩니다.\n",
"emailContentWithMessage": "{{inviter}} 님이 Hackers' Pub에 초대합니다! 다음은 {{inviterName}} 님의 메세지입니다:\n\n{{message}}\n\n초대장을 받으려면 아래 링크를 클릭하세요:\n\n{{verifyUrl}}\n\n이 링크는 {{expiration}} 후에 만료됩니다.\n"
}
}
5 changes: 5 additions & 0 deletions graphql/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,10 @@
"login": {
"emailSubject": "登录 Hackers' Pub",
"emailContent": "欢迎回到 Hackers' Pub!要登录,点击以下链接:\n\n{{verifyUrl}}\n\n或通过登录页面提交此密码:\n\n{{code}}\n\n该链接和密码将于 {{expiration}} 失效。\n\n如果你并没申请这个电子邮件,可以安全地忽略它。\n"
},
"invite": {
"emailSubject": "{{inviterName}} 邀请你加入 Hackers' Pub!",
"emailContent": "{{inviter}} 邀请你加入 Hackers' Pub!\n\nHackers' Pub 是个为软件工程师共同分享知识和经验的地方。它也是启用了 ActivityPub 的社交网络,这样你可以在联邦宇宙里关注你喜爱的黑客,并获取最新的帖子。\n\n要接受邀请,请点击以下链接:\n\n{{verifyUrl}}\n\n该链接将于 {{expiration}} 失效。\n",
"emailContentWithMessage": "{{inviter}} 邀请你加入 Hackers' Pub!以下是来自 {{inviterName}} 的信息:\n\n{{message}}\n\n要接受邀请,请点击以下链接:\n\n{{verifyUrl}}\n\n该链接将于 {{expiration}} 失效。\n"
}
}
5 changes: 5 additions & 0 deletions graphql/locales/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,10 @@
"login": {
"emailSubject": "登入 Hackers' Pub",
"emailContent": "歡迎回到 Hackers' Pub!要登入,點擊以下連結:\n\n{{verifyUrl}}\n\n或透過登入頁面提交此密碼:\n\n{{code}}\n\n該連結和密碼將於 {{expiration}} 失效。\n\n如果你並沒申請這個電子郵件,可以安全地忽略它。\n"
},
"invite": {
"emailSubject": "{{inviterName}} 邀請你加入 Hackers' Pub!",
"emailContent": "{{inviter}} 邀請你加入 Hackers' Pub!\n\nHackers' Pub 是個為軟體工程師共同分享知識和經驗的地方。它也是啟用了 ActivityPub 的社交網路,這樣你可以在聯邦宇宙裡關注你喜愛的駭客,並獲取最新的貼文。\n\n要接受邀請,請點擊以下連結:\n\n{{verifyUrl}}\n\n該連結將於 {{expiration}} 失效。\n",
"emailContentWithMessage": "{{inviter}} 邀請你加入 Hackers' Pub!以下是來自 {{inviterName}} 的訊息:\n\n{{message}}\n\n要接受邀請,請點擊以下連結:\n\n{{verifyUrl}}\n\n該連結將於 {{expiration}} 失效。\n"
}
}
Loading
Loading