chore: fix linting in entire project

This commit is contained in:
mrjvs
2026-08-09 21:28:45 +02:00
parent 843f1abdde
commit 2400e98d8f
41 changed files with 350 additions and 264 deletions

View File

@@ -73,10 +73,10 @@ export default defineNuxtConfig({
icon: {
clientBundle: {
scan: true,
scan: true
},
provider: 'none',
serverBundle: "local",
serverBundle: 'local'
},
components: [

View File

@@ -1,15 +1,18 @@
import { usePapr } from "~~/server/utils/papr";
import { ApiAccountCheckoutLink, CheckoutSchema } from "~~/shared/api-types";
import { usePapr } from '~~/server/utils/papr';
import { CheckoutSchema } from '~~/shared/api-types';
import type { ApiAccountCheckoutLink } from '~~/shared/api-types';
export default defineEventHandler(async (event): Promise<ApiAccountCheckoutLink> => {
const auth = enforceLoggedIn(event);
const papr = await usePapr(event);
const stripe = useStripe(event);
const config = useRuntimeConfig(event);
if (!stripe || !papr) throw createError({
status: 400,
message: 'Stripe integration not configured',
})
if (!stripe || !papr) {
throw createError({
status: 400,
message: 'Stripe integration not configured'
});
}
const body = await readZodBody(event, CheckoutSchema);
const { data: searchResults } = await stripe.customers.search({
@@ -29,15 +32,15 @@ export default defineEventHandler(async (event): Promise<ApiAccountCheckoutLink>
if (auth.accessLevel >= 2) {
throw createError({
status: 400,
message: 'Staff members do not need to purchase tiers',
})
message: 'Staff members do not need to purchase tiers'
});
}
await papr.Pnid.updateOne({ pid: auth.pid }, {
$set: {
'connections.stripe.customer_id': customer.id,
'connections.stripe.latest_webhook_timestamp': 0
}
})
});
const priceId = body.priceId;
const session = await stripe.checkout.sessions.create({
@@ -50,11 +53,13 @@ export default defineEventHandler(async (event): Promise<ApiAccountCheckoutLink>
customer: customer.id,
mode: 'subscription',
success_url: new URL('/account?upgrade_success=true', config.public.baseUrl).toString(),
cancel_url: new URL('/account?upgrade_success=false', config.public.baseUrl).toString(),
cancel_url: new URL('/account?upgrade_success=false', config.public.baseUrl).toString()
});
if (!session.url) throw new Error("Failed to create session");
if (!session.url) {
throw new Error('Failed to create session');
}
return {
url: session.url,
}
url: session.url
};
});

View File

@@ -1,16 +1,18 @@
import { useDiscord } from "~~/server/utils/discord";
import { ApiAccountDiscordLink } from "~~/shared/api-types";
import { useDiscord } from '~~/server/utils/discord';
import type { ApiAccountDiscordLink } from '~~/shared/api-types';
export default defineEventHandler(async (event): Promise<ApiAccountDiscordLink> => {
enforceLoggedIn(event);
const discord = useDiscord(event);
if (!discord) throw createError({
status: 400,
message: 'Discord integration not configured',
})
if (!discord) {
throw createError({
status: 400,
message: 'Discord integration not configured'
});
}
const redirectUrl = discord.makeCallbackUrl();
const url = new URL("https://discord.com/oauth2/authorize");
const url = new URL('https://discord.com/oauth2/authorize');
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', 'code');
url.searchParams.set('scope', 'identify');
@@ -18,6 +20,6 @@ export default defineEventHandler(async (event): Promise<ApiAccountDiscordLink>
url.searchParams.set('prompt', 'scope');
url.searchParams.set('integration_type', '1');
return {
url: url.toString(),
}
url: url.toString()
};
});

View File

@@ -1,18 +1,20 @@
import { removeDiscordMemberSupporterRole, removeDiscordMemberTesterRole } from "~~/server/utils/discord";
import { removeDiscordMemberSupporterRole, removeDiscordMemberTesterRole } from '~~/server/utils/discord';
export default defineEventHandler(async (event): Promise<void> => {
const auth = enforceLoggedIn(event);
const discord = useDiscord(event);
if (!discord) throw createError({
status: 400,
message: 'Discord integration not configured',
})
if (!discord) {
throw createError({
status: 400,
message: 'Discord integration not configured'
});
}
const grpc = useApiGrpcWithToken(event, auth.token);
const oldUserData = await grpc.getUserData({});
const oldDiscordId = oldUserData.connections?.discord?.id;
await grpc.setDiscordConnectionData({
id: '',
id: ''
});
const priceId = oldUserData.connections?.stripe?.priceId;

View File

@@ -1,24 +1,30 @@
import { ApiAccountTiers, TierItem } from "~~/shared/api-types";
import type { ApiAccountTiers, TierItem } from '~~/shared/api-types';
export default defineEventHandler(async (event): Promise<ApiAccountTiers> => {
enforceLoggedIn(event);
const stripe = useStripe(event);
if (!stripe) throw createError({
status: 400,
message: 'Stripe integration not configured',
})
if (!stripe) {
throw createError({
status: 400,
message: 'Stripe integration not configured'
});
}
const prices = await stripe.prices.list().autoPagingToArray({ limit: 10 });
const products = await stripe.products.list().autoPagingToArray({ limit: 10 });
const tiers: TierItem[] = [];
for (let product of products) {
if (!product.active) continue;
for (const product of products) {
if (!product.active) {
continue;
}
const price = prices.find(price => price.id === product.default_price);
if (!price) continue;
if (!price) {
continue;
}
const tierLevel = Number(product.metadata.tier_level ?? "0");
const tierLevel = Number(product.metadata.tier_level ?? '0');
const hasDiscordReadPerk = product.metadata.discord_read === 'true';
const hasBetaAccessPerk = product.metadata.beta === 'true';
@@ -31,12 +37,12 @@ export default defineEventHandler(async (event): Promise<ApiAccountTiers> => {
description: product.description,
perks: {
discordRead: hasDiscordReadPerk,
beta: hasBetaAccessPerk,
},
})
beta: hasBetaAccessPerk
}
});
}
return {
tiers,
}
tiers
};
});

View File

@@ -1,4 +1,4 @@
import { AccountUpdateSchema } from "~~/shared/api-types";
import { AccountUpdateSchema } from '~~/shared/api-types';
export default defineEventHandler(async (event): Promise<void> => {
const body = await readZodBody(event, AccountUpdateSchema);
@@ -8,11 +8,11 @@ export default defineEventHandler(async (event): Promise<void> => {
// There's no equivalent GRPC endpoint to use, so we're using the HTTP api
await apiFetch('/v1/user', {
headers: {
'Authorization': `Bearer ${auth.token}`,
Authorization: `Bearer ${auth.token}`
},
body: {
mii: body.mii,
environment: body.environment
}
})
});
});

View File

@@ -1,17 +1,19 @@
import { hcaptchaVerify } from "~~/server/utils/hcaptcha";
import { ForgotPasswordSchema } from "~~/shared/api-types";
import { hcaptchaVerify } from '~~/server/utils/hcaptcha';
import { ForgotPasswordSchema } from '~~/shared/api-types';
export default defineEventHandler(async (event): Promise<void> => {
const body = await readZodBody(event, ForgotPasswordSchema);
const grpc = useApiGrpc(event);
const captchaResult = await hcaptchaVerify(event, body.captchaResponse);
if (!captchaResult) throw createError({
status: 400,
message: 'Invalid captcha',
});
if (!captchaResult) {
throw createError({
status: 400,
message: 'Invalid captcha'
});
}
await grpc.forgotPassword({
emailAddressOrUsername: body.emailOrPassword,
})
emailAddressOrUsername: body.emailOrPassword
});
});

View File

@@ -1,6 +1,6 @@
import { ServerError } from "nice-grpc";
import { ApiAuthLogin, LoginSchema } from "#shared/api-types"
import { useLegacyApiGrpc } from "~~/server/utils/useGrpc";
import { ServerError } from 'nice-grpc';
import { LoginSchema } from '#shared/api-types';
import type { ApiAuthLogin } from '#shared/api-types';
export default defineEventHandler(async (event): Promise<ApiAuthLogin> => {
const body = await readZodBody(event, LoginSchema);

View File

@@ -1,4 +1,4 @@
import type { GetApiAuthMe } from "#shared/api-types"
import type { GetApiAuthMe } from '#shared/api-types';
export default defineEventHandler(async (event): Promise<GetApiAuthMe> => {
const auth = enforceLoggedIn(event);
@@ -8,9 +8,11 @@ export default defineEventHandler(async (event): Promise<GetApiAuthMe> => {
return {
pid: data.pid,
username: data.username,
mii: data.mii ? {
imageUrl: `https://r2-cdn.pretendo.cc/mii/${data.pid}/normal_face.png`,
name: data.mii.name,
} : null,
mii: data.mii
? {
imageUrl: `https://r2-cdn.pretendo.cc/mii/${data.pid}/normal_face.png`,
name: data.mii.name
}
: null
};
});

View File

@@ -1,9 +1,11 @@
import { ApiAuthLogin, RegisterSchema } from "#shared/api-types"
import { RegisterSchema } from '#shared/api-types';
import type { ApiAuthLogin } from '#shared/api-types';
export default defineEventHandler(async (event): Promise<ApiAuthLogin> => {
const body = await readZodBody(event, RegisterSchema);
const grpc = useApiGrpc(event);
// eslint-disable-next-line no-useless-catch -- Temp before error handling is implemented
try {
// TODO Add ip
// TODO Add birthday
@@ -13,7 +15,7 @@ export default defineEventHandler(async (event): Promise<ApiAuthLogin> => {
captchaResponse: body.captchaResponse,
username: body.username,
password: body.password,
passwordConfirm: body.password,
passwordConfirm: body.password
});
return {
@@ -21,6 +23,7 @@ export default defineEventHandler(async (event): Promise<ApiAuthLogin> => {
refreshToken: res.refreshToken
};
} catch (error: unknown) {
// TODO handle errors
throw error;
}
});

View File

@@ -1,4 +1,4 @@
import { ResetPasswordSchema } from "~~/shared/api-types";
import { ResetPasswordSchema } from '~~/shared/api-types';
export default defineEventHandler(async (event): Promise<void> => {
const body = await readZodBody(event, ResetPasswordSchema);
@@ -7,6 +7,6 @@ export default defineEventHandler(async (event): Promise<void> => {
await grpc.resetPassword({
password: body.password,
passwordConfirm: body.passwordConfirm,
token: body.resetToken,
})
token: body.resetToken
});
});

View File

@@ -1,6 +1,6 @@
import type { GetProgress, ProgressItem } from "#shared/api-types"
import { getGithubProjects } from "../utils/getGithubProgress";
import { getStripeDonations } from "../utils/getStripeDonations";
import { getGithubProjects } from '../utils/getGithubProgress';
import { getStripeDonations } from '../utils/getStripeDonations';
import type { GetProgress, ProgressItem } from '#shared/api-types';
const donationGoalCents = 3000 * 100;
@@ -10,7 +10,7 @@ export default defineEventHandler(async (event): Promise<GetProgress> => {
const donationData = await getStripeDonations(stripe);
const { projects } = await getGithubProjects(octokit);
const items: ProgressItem[] = projects.map(v => {
const items: ProgressItem[] = projects.map((v) => {
const totalTasks = v.tasks.length;
const completedTasks = v.tasks.filter(v => v.status === 'completed').length;
const percentage = Math.floor(completedTasks / totalTasks * 100);
@@ -21,9 +21,9 @@ export default defineEventHandler(async (event): Promise<GetProgress> => {
completion: percentage,
tasks: v.tasks.map(task => ({
status: task.status,
title: task.title,
title: task.title
}))
}
};
});
const summedCompletion = items.reduce((a, v) => a + v.completion, 0);
const completionPercentage = Math.floor(summedCompletion / items.length * 100);
@@ -32,9 +32,9 @@ export default defineEventHandler(async (event): Promise<GetProgress> => {
completion: completionPercentage,
donations: {
currentCents: donationData.totalDonationsCents,
goalCents: donationGoalCents,
goalCents: donationGoalCents
},
items: projects.map(v => {
items: projects.map((v) => {
const totalTasks = v.tasks.length;
const completedTasks = v.tasks.filter(v => v.status === 'completed').length;
const percentage = Math.floor(completedTasks / totalTasks * 100);
@@ -45,9 +45,9 @@ export default defineEventHandler(async (event): Promise<GetProgress> => {
completion: percentage,
tasks: v.tasks.map(task => ({
status: task.status,
title: task.title,
title: task.title
}))
}
};
})
};
});

View File

@@ -1,6 +1,6 @@
export default defineEventHandler(async (event) => {
const authHeader = getRequestHeader(event, 'authorization');
setAuthContext(event, null)
setAuthContext(event, null);
if (authHeader) {
try {
const [type, token] = authHeader.split(' ', 2);
@@ -18,8 +18,8 @@ export default defineEventHandler(async (event) => {
username: userData.username,
token: token,
accessLevel: userData.accessLevel,
email: userData.emailAddress,
})
email: userData.emailAddress
});
} catch (err) {
console.error('Failed to request user data: ', err);
return; // Continue like nothing happened, further steps will validate if authed

View File

@@ -1,18 +1,18 @@
import { assignDiscordMemberSupporterRole, assignDiscordMemberTesterRole } from "~~/server/utils/discord";
import { assignDiscordMemberSupporterRole, assignDiscordMemberTesterRole } from '~~/server/utils/discord';
type DiscordTokenResponse = {
"access_token": string,
"token_type": string,
"expires_in": number,
"refresh_token": string,
"scope": string
}
access_token: string;
token_type: string;
expires_in: number;
refresh_token: string;
scope: string;
};
type DiscordUserResponse = {
"user"?: {
id: string,
}
}
user?: {
id: string;
};
};
// Discord oauth callback
export default defineEventHandler(async (event) => {
@@ -22,7 +22,7 @@ export default defineEventHandler(async (event) => {
}
const discordFetch = $fetch.create({
baseURL: discord.baseUrl,
baseURL: discord.baseUrl
});
const accessTokenCookie = getCookie(event, 'access_token');
const query = getQuery(event);
@@ -34,14 +34,14 @@ export default defineEventHandler(async (event) => {
const tokens = await discordFetch<DiscordTokenResponse>('/oauth2/token', {
body: new URLSearchParams({
'grant_type': 'authorization_code',
'code': authCode,
'redirect_uri': discord.makeCallbackUrl(),
grant_type: 'authorization_code',
code: authCode,
redirect_uri: discord.makeCallbackUrl()
})
});
const authInfo = await discordFetch<DiscordUserResponse>('/oauth2/@me', {
headers: {
'Authorization': `Bearer ${tokens.access_token}`
Authorization: `Bearer ${tokens.access_token}`
}
});
if (!authInfo.user) {

View File

@@ -1,5 +1,5 @@
import { GetUserDataResponse } from "@pretendonetwork/grpc/api/get_user_data_rpc";
import { createHmac } from "node:crypto";
import { createHmac } from 'node:crypto';
import type { GetUserDataResponse } from '@pretendonetwork/grpc/api/get_user_data_rpc';
function getDicourseSignature(secret: string, payload: string) {
return createHmac('sha256', secret).update(payload).digest('hex');
@@ -42,7 +42,7 @@ export default defineEventHandler(async (event) => {
const redirect = `/sso/discourse?${redirectUrlParams}`;
const urlParams = new URLSearchParams();
urlParams.append('redirect', redirect)
urlParams.append('redirect', redirect);
return sendRedirect(event, `/login?${urlParams}`); // Not logged in, redirect to login
}

View File

@@ -1,32 +1,40 @@
import { Stripe } from "stripe";
import { handleStripeEvent } from "~~/server/utils/handleStripeWebhook";
import { handleStripeEvent } from '~~/server/utils/handleStripeWebhook';
import type { Stripe } from 'stripe';
export default defineEventHandler(async (event): Promise<{ success: boolean, message?: string }> => {
export default defineEventHandler(async (event): Promise<{ success: boolean; message?: string }> => {
const config = useRuntimeConfig(event);
const stripe = useStripe(event);
if (!stripe || !config.stripeWebhookSecret) throw new Error('Stripe not configured on this instance');
if (!stripe || !config.stripeWebhookSecret) {
throw new Error('Stripe not configured on this instance');
}
let webhookEvent: Stripe.Event;
try {
const signatureHeader = getHeader(event, 'stripe-signature');
if (!signatureHeader) throw new Error('No signature header on webhook event');
if (!signatureHeader) {
throw new Error('No signature header on webhook event');
}
const rawBody = await readRawBody(event) ?? '';
if (!rawBody) throw new Error('No body on webhook event');
if (!rawBody) {
throw new Error('No body on webhook event');
}
webhookEvent = stripe.webhooks.constructEvent(rawBody, signatureHeader, config.stripeWebhookSecret);
} catch (error) {
console.error(error);
setResponseStatus(event, 400);
return {
success: false,
message: 'Invalid webhook',
}
message: 'Invalid webhook'
};
}
const notificationEmails: string[] = [];
if (config.stripeNotificationEmail) notificationEmails.push(config.stripeNotificationEmail);
if (config.stripeNotificationEmail) {
notificationEmails.push(config.stripeNotificationEmail);
}
await handleStripeEvent(event, stripe, webhookEvent, notificationEmails);
return {
success: true,
}
success: true
};
});

View File

@@ -1,17 +1,17 @@
import { REST } from '@discordjs/rest';
import { Routes } from 'discord-api-types/v10';
import type { H3Event } from 'h3'
import type { H3Event } from 'h3';
type DiscordIds = { guildId: string, supporterRoleId: string | null, testerRoleId: string | null }
type DiscordIds = { guildId: string; supporterRoleId: string | null; testerRoleId: string | null };
type DiscordInstance = {
rest: REST,
clientId: string,
clientSecret: string,
baseUrl: string,
ids: DiscordIds,
makeCallbackUrl: () => string,
}
rest: REST;
clientId: string;
clientSecret: string;
baseUrl: string;
ids: DiscordIds;
makeCallbackUrl: () => string;
};
let discordInstance: DiscordInstance | null = null;
@@ -21,18 +21,18 @@ export function useDiscord(event: H3Event): DiscordInstance | null {
if (config.discordBotToken && config.discordGuildId && config.discordClientId && config.discordClientSecret) {
discordInstance = {
rest: new REST({ version: '10' }).setToken(config.discordBotToken),
baseUrl: "https://discord.com/api/v10",
baseUrl: 'https://discord.com/api/v10',
clientId: config.discordClientId,
clientSecret: config.discordClientSecret,
ids: {
guildId: config.discordGuildId,
testerRoleId: config.discordTesterRoleId ? config.discordTesterRoleId : null,
supporterRoleId: config.discordSupporterRoleId ? config.discordSupporterRoleId : null,
supporterRoleId: config.discordSupporterRoleId ? config.discordSupporterRoleId : null
},
makeCallbackUrl() {
return new URL('/account/connect/discord', config.public.baseUrl).toString()
},
}
return new URL('/account/connect/discord', config.public.baseUrl).toString();
}
};
}
}

View File

@@ -1,4 +1,4 @@
import type { H3Event } from 'h3'
import type { H3Event } from 'h3';
export type AuthContext = {
pid: number;

View File

@@ -1,4 +1,4 @@
import { Octokit } from "octokit";
import type { Octokit } from 'octokit';
export type GithubProjectTaskStatus = 'completed' | 'inprogress' | 'notstarted';
@@ -7,17 +7,17 @@ export type GithubProject = {
url: string;
tasks: Array<{
title: string;
status: GithubProjectTaskStatus,
}>
}
status: GithubProjectTaskStatus;
}>;
};
export type GithubProjectResponse = {
projects: GithubProject[];
}
};
const orgName = "PretendoNetwork";
const orgName = 'PretendoNetwork';
const cacheMaxAgeMs = 60 * 60 * 1000; // 1 hour
let cache: { response: GithubProjectResponse, createdAt: Date } | null = null;
let cache: { response: GithubProjectResponse; createdAt: Date } | null = null;
const getProjectsV2GQL = `
query getProjectsV2($orgName: String!, $cursor: String) {
@@ -79,7 +79,7 @@ query getProjectsV2Fields($id: ID!, $cursor: String) {
`;
async function getGitHubProjectsV2(octokit: Octokit) {
const projects: Array<{ id: string, title: string, url: string | null }> = [];
const projects: Array<{ id: string; title: string; url: string | null }> = [];
const data = await octokit.graphql.paginate(getProjectsV2GQL, {
orgName: orgName
@@ -89,7 +89,7 @@ async function getGitHubProjectsV2(octokit: Octokit) {
projects.push({
id: node.id,
title: node.title,
url: node.repositories.nodes[0]?.url ?? null,
url: node.repositories.nodes[0]?.url ?? null
});
}
@@ -97,7 +97,7 @@ async function getGitHubProjectsV2(octokit: Octokit) {
}
async function getGitHubProjectsV2Fields(octokit: Octokit, id: string) {
const output: Array<{ title: string, column: string }> = [];
const output: Array<{ title: string; column: string }> = [];
const data: any = await octokit.graphql.paginate(getProjectsV2FieldsGQL, {
id: id
@@ -125,7 +125,7 @@ async function getGithubProjectsData(octokit: Octokit): Promise<GithubProjectRes
const projectOutput: GithubProject = {
title: project.title,
url: project.url,
tasks: [],
tasks: []
};
const fields = await getGitHubProjectsV2Fields(octokit, project.id);
@@ -133,24 +133,26 @@ async function getGithubProjectsData(octokit: Octokit): Promise<GithubProjectRes
const fieldMap: Record<string, GithubProjectTaskStatus> = {
done: 'completed',
in_progress: 'inprogress',
todo: 'notstarted',
}
todo: 'notstarted'
};
for (const field of fields) {
const normalizedStatus = field.column.toLowerCase().replace(' ', '_');
const status = fieldMap[normalizedStatus];
if (!status) continue;
if (!status) {
continue;
}
projectOutput.tasks.push({
status,
title: field.title,
})
title: field.title
});
}
output.push(projectOutput);
}
return {
projects: output,
projects: output
};
}
@@ -159,8 +161,8 @@ export async function getGithubProjects(octokit: Octokit | null, ignoreCache = f
// No github credentials, assume there are no projects
if (!octokit) {
return {
projects: [],
}
projects: []
};
}
cache = {

View File

@@ -1,25 +1,27 @@
import Stripe from "stripe";
import type { Stripe } from 'stripe';
export type StripeDonationResponse = {
donatorCount: number;
totalDonationsCents: number;
}
};
const cacheMaxAgeMs = 60 * 60 * 1000; // 1 hour
let cache: { response: StripeDonationResponse, createdAt: Date } | null = null;
let cache: { response: StripeDonationResponse; createdAt: Date } | null = null;
async function getStripeDonationData(stripe: Stripe): Promise<StripeDonationResponse> {
const donationData: StripeDonationResponse = {
donatorCount: 0,
totalDonationsCents: 0,
totalDonationsCents: 0
};
await stripe.subscriptions.list({
limit: 100,
status: 'active',
status: 'active'
}).autoPagingEach((sub) => {
const plan = sub.items.data[0]?.plan;
if (!plan) return;
if (!plan) {
return;
}
donationData.donatorCount += 1;
donationData.totalDonationsCents += plan?.amount ?? 0;
});
@@ -27,15 +29,14 @@ async function getStripeDonationData(stripe: Stripe): Promise<StripeDonationResp
return donationData;
}
export async function getStripeDonations(stripe: Stripe | null, ignoreCache = false): Promise<StripeDonationResponse> {
if (!cache || new Date(cache.createdAt.getTime() + cacheMaxAgeMs) < new Date() || ignoreCache) {
// No credentials, fill in blank data
if (!stripe) {
return {
donatorCount: 0,
totalDonationsCents: 0,
}
totalDonationsCents: 0
};
}
cache = {

View File

@@ -1,32 +1,35 @@
import { Stripe } from "stripe";
import { useMailer } from "./mailer";
import { PnidDocument, usePapr } from "./papr";
import { Transporter } from "nodemailer";
import { PaprMatchKeysAndValues } from "papr";
import { useMailer } from './mailer';
import { usePapr } from './papr';
import { assignDiscordMemberSupporterRole, assignDiscordMemberTesterRole, removeDiscordMemberSupporterRole, removeDiscordMemberTesterRole, useDiscord } from './discord';
import type { Transporter } from 'nodemailer';
import type { PaprMatchKeysAndValues } from 'papr';
import type { Stripe } from 'stripe';
import type { H3Event } from 'h3';
import { assignDiscordMemberSupporterRole, assignDiscordMemberTesterRole, removeDiscordMemberSupporterRole, removeDiscordMemberTesterRole, useDiscord } from "./discord";
import type { PnidDocument } from './papr';
async function sendEmailToCustomer(mailer: Transporter, customer: Stripe.Customer, ops: { pid: number, title: string, body: string }): Promise<void> {
async function sendEmailToCustomer(mailer: Transporter, customer: Stripe.Customer, ops: { pid: number; title: string; body: string }): Promise<void> {
try {
if (!customer.email) throw new Error("Customer does not have an email");
if (!customer.email) {
throw new Error('Customer does not have an email');
}
await mailer.sendMail({
to: customer.email,
subject: ops.title,
text: ops.body,
text: ops.body
});
} catch (error) {
console.error(`Error sending email | ${customer.id}, ${ops.pid}, ${customer.email} |`, error);
}
}
async function sendToNotificationEmails(mailer: Transporter, notificationEmails: string[], ops: { title: string, body: string }): Promise<void> {
async function sendToNotificationEmails(mailer: Transporter, notificationEmails: string[], ops: { title: string; body: string }): Promise<void> {
for (const email of notificationEmails) {
// * Send notification emails for new sub
try {
await mailer.sendMail({
to: email,
subject: `[Pretendo] - ${ops.title}`,
text: ops.body,
text: ops.body
});
} catch (error) {
console.error(`Error sending notification email | ${email} |`, error);
@@ -49,7 +52,9 @@ export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook:
if (webhook.type === 'customer.subscription.updated' || webhook.type === 'customer.subscription.deleted') {
const subscription = webhook.data.object;
const subscriptionItem = subscription.items.data[0];
if (!subscriptionItem) throw new Error("No subscription item subscription");
if (!subscriptionItem) {
throw new Error('No subscription item subscription');
}
const product = await stripe.products.retrieve(subscriptionItem.plan.product as string);
const customer = await stripe.customers.retrieve(subscription.customer as string);
@@ -69,7 +74,9 @@ export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook:
const invoice = await stripe.invoices.retrieve(subscription.latest_invoice as string);
const intent = invoice.payments?.data[0]?.payment.payment_intent;
if (!intent) throw new Error("No intent found")
if (!intent) {
throw new Error('No intent found');
}
await stripe.refunds.create({
payment_intent: intent as string
});
@@ -81,7 +88,7 @@ export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook:
pid: 0,
title: 'Pretendo Network Subscription Failed - No Linked PNID',
body: `Your recent subscription to Pretendo Network has failed.\nThis is due to no PNID PID being linked to the Stripe customer account used. The subscription has been canceled and refunded. Please contact Jon immediately.\nStripe Customer ID: ${customer.id}`
})
});
} else {
console.error(`Stripe user ${customer.id} has no PNID linked!`);
}
@@ -104,7 +111,9 @@ export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook:
if (subscription.latest_invoice) {
const invoice = await stripe.invoices.retrieve(subscription.latest_invoice as string);
const intent = invoice.payments?.data[0]?.payment.payment_intent;
if (!intent) throw new Error("No intent found")
if (!intent) {
throw new Error('No intent found');
}
await stripe.refunds.create({
payment_intent: intent as string
});
@@ -117,7 +126,7 @@ export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook:
pid: 0,
title: 'Pretendo Network Subscription Failed - PNID Not Found',
body: `Your recent subscription to Pretendo Network has failed.\nThis is due to the provided PNID not being found. The subscription has been canceled and refunded. Please contact Jon immediately.\nStripe Customer ID: ${customer.id}\nPNID PID: ${pid}`
})
});
} else {
console.error(`PNID PID ${pid} does not exist! Found on Stripe user ${customer.id}!`);
}
@@ -235,7 +244,7 @@ export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook:
pid,
title: `Pretendo Network ${product.name} Subscription - Active`,
body: `Thank you for purchasing the ${product.name} tier! We greatly value your support, thank you for helping keep Pretendo Network alive!\nIt may take a moment for your account dashboard to reflect these changes. Please wait a moment and refresh the dashboard to see them!`
})
});
if (discord && discordId && product.metadata.discord_role_id) {
await assignDiscordMemberSupporterRole(discord, discordId, product.metadata.discord_role_id).catch((error) => {
@@ -245,14 +254,14 @@ export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook:
await sendToNotificationEmails(mailer, notificationEmails, {
title: `New ${product.name} subscription`,
body: `${pnid.username} just became a ${product.name} tier subscriber`,
})
body: `${pnid.username} just became a ${product.name} tier subscriber`
});
} else if (subscription.status === 'canceled') {
await sendEmailToCustomer(mailer, customer, {
pid,
title: `Pretendo Network ${product.name} Subscription - Canceled`,
body: `Your subscription for the ${product.name} tier has been canceled. We thank for your previous support, and hope you still enjoy the network! `
})
});
if (discord && discordId && product.metadata.discord_role_id) {
await removeDiscordMemberSupporterRole(discord, discordId, product.metadata.discord_role_id).catch((error) => {
@@ -262,14 +271,14 @@ export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook:
await sendToNotificationEmails(mailer, notificationEmails, {
title: `Canceled ${product.name} subscription`,
body: `${pnid.username} just canceled their ${product.name} tier subscription`,
})
body: `${pnid.username} just canceled their ${product.name} tier subscription`
});
} else if (subscription.status === 'unpaid') {
await sendEmailToCustomer(mailer, customer, {
pid,
title: `Pretendo Network ${product.name} Subscription - Unpaid`,
body: `Your subscription for the ${product.name} tier has been canceled due to non payment. We thank for your previous support, and hope you still enjoy the network! `
})
});
if (discord && discordId && product.metadata.discord_role_id) {
await removeDiscordMemberSupporterRole(discord, discordId, product.metadata.discord_role_id).catch((error) => {
@@ -279,14 +288,14 @@ export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook:
await sendToNotificationEmails(mailer, notificationEmails, {
title: `Removed ${product.name} subscription`,
body: `${pnid.username}'s ${product.name} tier subscription has been canceled due to non payment`,
})
body: `${pnid.username}'s ${product.name} tier subscription has been canceled due to non payment`
});
} else {
await sendEmailToCustomer(mailer, customer, {
pid,
title: `Pretendo Network ${product.name} Subscription - ${subscription.status}`,
body: `Your subscription for the ${product.name} tier has changed status to ${subscription.status}. This is usually caused by payment failure. Your account has been reverted back to default until payment resumes. If you believe this to be an error, please reach out for support on our Discord server, and we thank you for your previous support!`
})
});
if (discord && discordId && product.metadata.discord_role_id) {
await removeDiscordMemberSupporterRole(discord, discordId, product.metadata.discord_role_id).catch((error) => {
@@ -296,8 +305,8 @@ export async function handleStripeEvent(event: H3Event, stripe: Stripe, webhook:
await sendToNotificationEmails(mailer, notificationEmails, {
title: `Removed ${product.name} subscription`,
body: `${pnid.username}'s ${product.name} tier subscription status has been changed to ${subscription.status}`,
})
body: `${pnid.username}'s ${product.name} tier subscription status has been changed to ${subscription.status}`
});
}
}
}

View File

@@ -1,14 +1,22 @@
import type { H3Event } from 'h3'
import hcaptcha from 'hcaptcha'
import hcaptcha from 'hcaptcha';
import type { H3Event } from 'h3';
export async function hcaptchaVerify(event: H3Event, captchaResponse: string | null | undefined): Promise<boolean> {
const config = useRuntimeConfig(event);
if (!config.hcaptchaSiteKey) return true; // No captcha is configured, always valid
if (!config.hcaptchaSecretKey) throw new Error("Hcaptcha not configured correctly, missing secret key");
if (!config.hcaptchaSiteKey) {
return true;
} // No captcha is configured, always valid
if (!config.hcaptchaSecretKey) {
throw new Error('Hcaptcha not configured correctly, missing secret key');
}
if (!captchaResponse) return false; // No captcha filled in, invalid
if (!captchaResponse) {
return false;
} // No captcha filled in, invalid
const captchaVerify = await hcaptcha.verify(config.hcaptchaSiteKey, captchaResponse, undefined, config.hcaptchaSiteKey);
if (!captchaVerify.success) return false; // Invalid captcha response
if (!captchaVerify.success) {
return false;
} // Invalid captcha response
return true;
}

View File

@@ -1,6 +1,6 @@
export function useHttpApi(event: H3Event) {
const config = useRuntimeConfig(event);
return $fetch.create({
baseURL: config.public.apiBase,
baseURL: config.public.apiBase
});
}

View File

@@ -1,5 +1,6 @@
import { createTransport, Transporter } from "nodemailer";
import type { H3Event } from 'h3'
import { createTransport } from 'nodemailer';
import type { Transporter } from 'nodemailer';
import type { H3Event } from 'h3';
let transport: Transporter | null = null;
@@ -10,7 +11,7 @@ export function useMailer(event: H3Event): Transporter | null {
transport = createTransport({
from: {
address: config.smtpFromEmail,
name: config.smtpFromName ? config.smtpFromName : undefined,
name: config.smtpFromName ? config.smtpFromName : undefined
},
host: config.smtpHost,
port: config.smtpPort,
@@ -18,7 +19,7 @@ export function useMailer(event: H3Event): Transporter | null {
auth: {
user: config.smtpUser ? config.smtpUser : undefined,
pass: config.smtpPassword ? config.smtpPassword : undefined
},
}
});
}
}

View File

@@ -1,5 +1,5 @@
import { Octokit } from "octokit"
import type { H3Event } from 'h3'
import { Octokit } from 'octokit';
import type { H3Event } from 'h3';
let octokit: Octokit | null = null;

View File

@@ -1,6 +1,6 @@
import { MongoClient } from "mongodb";
import Papr, { schema, types } from "papr"
import type { H3Event } from 'h3'
import { MongoClient } from 'mongodb';
import Papr, { schema, types } from 'papr';
import type { H3Event } from 'h3';
const papr = new Papr();
let conn: MongoClient | null = null;
@@ -17,10 +17,10 @@ const pnidSchema = schema({
price_id: types.string(),
tier_level: types.number(),
tier_name: types.string(),
latest_webhook_timestamp: types.number(),
latest_webhook_timestamp: types.number()
}),
discord: types.object({
id: types.string(),
id: types.string()
})
})
});
@@ -29,7 +29,7 @@ export type PnidDocument = (typeof pnidSchema)[0];
const paprInstance = {
papr,
Pnid,
Pnid
} as const;
export type PaprInstance = typeof paprInstance;

View File

@@ -1,5 +1,5 @@
import type { z, ZodType } from 'zod';
import type { H3Event } from 'h3'
import type { H3Event } from 'h3';
export async function readZodBody<T extends ZodType>(event: H3Event, schema: T): Promise<z.infer<T>> {
const body = await readValidatedBody(event, schema.safeParse);

View File

@@ -1,5 +1,5 @@
import { Stripe } from "stripe"
import type { H3Event } from 'h3'
import { Stripe } from 'stripe';
import type { H3Event } from 'h3';
let stripe: Stripe | null = null;

View File

@@ -3,7 +3,7 @@ import { ApiServiceDefinition } from '@pretendonetwork/grpc/api/v2/api_service';
import { APIDefinition } from '@pretendonetwork/grpc/api/api_service';
import { AccountServiceDefinition } from '@pretendonetwork/grpc/account/v2/account_service';
import type { Channel, Client, CompatServiceDefinition } from 'nice-grpc';
import type { H3Event } from 'h3'
import type { H3Event } from 'h3';
let grpc: { channel: Channel } | null = null;

View File

@@ -7,26 +7,26 @@ export type GetApiAuthMe = {
imageUrl: string;
name: string;
} | null;
}
};
export type ProgressItem = {
title: string,
githubUrl?: string,
completion: number,
title: string;
githubUrl?: string;
completion: number;
tasks: Array<{
status: 'completed' | 'inprogress' | 'notstarted',
title: string,
}>
}
status: 'completed' | 'inprogress' | 'notstarted';
title: string;
}>;
};
export type GetProgress = {
donations: {
currentCents: number,
goalCents: number,
},
currentCents: number;
goalCents: number;
};
completion: number;
items: ProgressItem[];
}
};
export type ApiAuthLogin = {
accessToken: string;
@@ -34,11 +34,11 @@ export type ApiAuthLogin = {
};
export type ApiAccountDiscordLink = {
url: string
url: string;
};
export type ApiAccountCheckoutLink = {
url: string
url: string;
};
export type TierItem = {
@@ -51,12 +51,12 @@ export type TierItem = {
perks: {
discordRead: boolean;
beta: boolean;
}
}
};
};
export type ApiAccountTiers = {
tiers: TierItem[];
}
};
export const LoginSchema = z.object({
username: z.string(),
@@ -86,7 +86,7 @@ export type ApiAccountUpdateRequest = z.infer<typeof AccountUpdateSchema>;
export const ResetPasswordSchema = z.object({
password: z.string(),
passwordConfirm: z.string(),
resetToken: z.string(),
resetToken: z.string()
});
export type ApiAuthResetPasswordRequest = z.infer<typeof ResetPasswordSchema>;
@@ -97,6 +97,6 @@ export const ForgotPasswordSchema = z.object({
export type ApiAuthForgotPasswordRequest = z.infer<typeof ForgotPasswordSchema>;
export const CheckoutSchema = z.object({
priceId: z.string(),
priceId: z.string()
});
export type ApiAccountCheckoutRequest = z.infer<typeof CheckoutSchema>;

View File

@@ -470,23 +470,32 @@ onMounted(() => {
v-if="user"
class="user-widget-wrapper logged-in"
>
<div class="user-widget-toggle" @click="widgetOpen = true">
<div
class="user-widget-toggle"
@click="widgetOpen = true"
>
<img
:src="user.mii?.imageUrl ?? '#'"
:alt="user.mii?.name ?? ''"
:alt="user.mii?.name ?? ''"
>
</div>
<div class="user-widget" :class="{
'active': widgetOpen
}">
<div
class="user-widget"
:class="{
'active': widgetOpen
}"
>
<div class="user-avatar">
<img
:src="user.mii?.imageUrl ?? '#'"
:src="user.mii?.imageUrl ?? '#'"
:alt="user.mii?.name ?? ''"
>
</div>
<div class="user-info">
<div v-if="user.mii" class="mii-name">
<div
v-if="user.mii"
class="mii-name"
>
{{ user.mii.name }}
</div>
<div class="pnid">

View File

@@ -5,5 +5,5 @@
<slot />
<Footer />
</div>
</div>
</div>
</template>

View File

@@ -1,8 +1,10 @@
import type { GetApiAuthMe } from "#shared/api-types"
import type { GetApiAuthMe } from '#shared/api-types';
export default defineNuxtRouteMiddleware(async () => {
const meStore = useMeStore();
if (meStore.loaded) return; // Already loaded
if (meStore.loaded) {
return;
} // Already loaded
const authStore = useAuthStore();
const token = authStore.getToken();
@@ -14,13 +16,13 @@ export default defineNuxtRouteMiddleware(async () => {
try {
const res = await $fetch<GetApiAuthMe>('/api/auth/me', {
headers: {
'Authorization': `Bearer ${token}`
Authorization: `Bearer ${token}`
}
})
});
meStore.setMe({
pid: res.pid,
username: res.username,
mii: res.mii,
mii: res.mii
});
} catch {
meStore.setMe(null);

View File

@@ -1,12 +1,16 @@
function notAllowed() {
return navigateTo("/");
return navigateTo('/');
}
export default defineNuxtRouteMiddleware(async (to) => {
const meStore = useMeStore();
if (!meStore.loaded) throw new Error("Mestore must be loaded before reaching this middleware");
if (!meStore.loaded) {
throw new Error('Mestore must be loaded before reaching this middleware');
}
if (to.meta.needsAuth) {
if (!meStore.user) return notAllowed();
if (!meStore.user) {
return notAllowed();
}
}
});

View File

@@ -1,15 +1,14 @@
<script setup lang="ts">
definePageMeta({
needsAuth: true,
})
needsAuth: true
});
const me = useMeStore();
</script>
<template>
<div>
<h1>Hello {{ me.user?.username }}</h1>
<h1>Hello {{ me.user?.username }}</h1>
<p>TODO: account stub page</p>
</div>
</template>

View File

@@ -92,8 +92,8 @@ async function registerSubmission() {
type="password"
autocomplete="new-password"
required
passwordrules="minlength: 6; maxlength: 16; max-consecutive: 2; allowed: [-!-~];"
pattern="[-!-~]{6,16}"
passwordrules="minlength: 6; maxlength: 16; max-consecutive: 2; allowed: [-!-~];"
pattern="[-!-~]{6,16}"
>
</div>
<div>

View File

@@ -140,10 +140,15 @@ function titleSuffixHandler(path: string) {
<h2 class="title">
<a href="/progress">{{ $t("progress.title") }} ({{ progress.data.value?.completion ?? 0 }}%)</a>
</h2>
<div v-for="project of progress.data.value?.items ?? []" :key="project.title">
<p>{{ project.title }} [{{ project.completion }}%]</p>
</div>
<p v-if="(progress.data.value?.items ?? []).length === 0">No projects</p>
<div
v-for="project of progress.data.value?.items ?? []"
:key="project.title"
>
<p>{{ project.title }} [{{ project.completion }}%]</p>
</div>
<p v-if="(progress.data.value?.items ?? []).length === 0">
No projects
</p>
</div>
</section>

View File

@@ -1,19 +1,34 @@
<script setup lang="ts">
const progress = await useFetch('/api/progress');
const projects = computed(() => progress.data.value?.items ?? []);
const donations = computed(() => progress.data.value?.donations)
const donations = computed(() => progress.data.value?.donations);
</script>
<template>
<div>
<p>TODO: progress stub page</p>
<h1 v-if="donations">{{ donations.currentCents }}¢ / {{ donations.goalCents }}¢</h1>
<div v-for="project of projects" :key="project.title">
<h1>{{ project.title }} ({{ project.completion }}%)</h1>
<a v-if="project.githubUrl" :href="project.githubUrl">Github</a>
<p v-for="task of project.tasks" :key="task.title">[{{ task.status }}] {{ task.title }}</p>
<hr />
</div>
<p v-if="projects.length === 0">No projects</p>
<h1 v-if="donations">
{{ donations.currentCents }}¢ / {{ donations.goalCents }}¢
</h1>
<div
v-for="project of projects"
:key="project.title"
>
<h1>{{ project.title }} ({{ project.completion }}%)</h1>
<a
v-if="project.githubUrl"
:href="project.githubUrl"
>Github</a>
<p
v-for="task of project.tasks"
:key="task.title"
>
[{{ task.status }}] {{ task.title }}
</p>
<hr>
</div>
<p v-if="projects.length === 0">
No projects
</p>
</div>
</template>

View File

@@ -1,4 +1,4 @@
declare module "#app" {
declare module '#app' {
interface PageMeta {
needsAuth?: boolean;
}

View File

@@ -1,3 +1,4 @@
/* eslint-disable no-self-assign -- Self assign is needed for refresh cookies */
import type { CookieOptions } from '#app';
type AuthState = {
@@ -73,6 +74,6 @@ export function useAuthStore() {
getToken,
refresh,
set,
logout,
logout
};
}

View File

@@ -2,9 +2,9 @@ export type Me = {
pid: number;
username: string;
mii: {
imageUrl: string,
name: string,
} | null,
imageUrl: string;
name: string;
} | null;
};
export const useMeStore = defineStore('me', () => {