diff --git a/.env.example b/.env.example index 585a1f4..75d23de 100644 --- a/.env.example +++ b/.env.example @@ -34,3 +34,6 @@ UPLOAD_LOCATION= # The location of the config file. CONFIG_FILE=config.json + +# Stripe information. +STRIPE_SECRET_KEY= \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index 49a4ac9..b6d8ed4 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/config.json b/config.json index 9dee426..037d6a5 100644 --- a/config.json +++ b/config.json @@ -8,6 +8,15 @@ "enabled": false }, "messaging": { + "direct": { + "enabled": true + }, + "group": { + "enabled": true + } + }, + "subscriptions": { + "_comment": "Subscriptions allow you to monetise your Nova instance.", "enabled": true } } diff --git a/package.json b/package.json index 5244bc3..beec986 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "license": "GPL-3.0-only", "main": "server.ts", "scripts": { - "dev": "bun --watch src/server.ts", + "dev": "NODE_ENV=dev bun --watch src/server.ts", "start": "bun src/server.ts", "db:generate": "drizzle-kit generate", "db:migrate": "bun drizzle/migrate.ts ./drizzle", @@ -35,6 +35,7 @@ "mime-types": "^2.1.35", "pg": "^8.13.1", "redis": "^4.7.0", + "stripe": "^17.5.0", "zod": "^3.24.1" }, "devDependencies": { diff --git a/src/config.ts b/src/config.ts index 6a76e77..31e5753 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,7 @@ import { OIDC, OIDCPluginOptionsBase } from '@nexirift/plugin-oidc'; import { readFileSync } from 'fs'; import { tokenClient } from './redis'; +import Stripe from 'stripe'; const file = (Bun.env.CONFIG_FILE as string) ?? 'config.json'; @@ -13,6 +14,18 @@ type Config = { age_verification: { enabled: boolean; }; + messaging: { + direct: { + enabled: boolean; + }; + group: { + enabled: boolean; + }; + }; + subscription: { + enabled: boolean; + tiers: string[]; + }; }; openid: OIDCPluginOptionsBase; file: string; @@ -43,3 +56,5 @@ export const config: Config = { }, file }; + +export const stripe = new Stripe(Bun.env.STRIPE_SECRET_KEY! || 'no_stripe_key'); diff --git a/src/drizzle/schema/user/User.ts b/src/drizzle/schema/user/User.ts index bc8e4ed..f0ce7a3 100644 --- a/src/drizzle/schema/user/User.ts +++ b/src/drizzle/schema/user/User.ts @@ -33,6 +33,8 @@ export const user = pgTable('user', { profession: citext('profession'), location: citext('location'), website: citext('website'), + stripe_customer_id: citext('stripe_customer_id'), + stripe_subscription_id: citext('stripe_subscription_id'), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at') .notNull() diff --git a/src/lib/logger.ts b/src/lib/logger.ts index accd373..ac0c71e 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -3,9 +3,12 @@ import debug from 'debug'; export const log = debug('app:log'); export const guardianLog = debug('lib:guardian'); export const authentikLog = debug('lib:authentik'); +export const stripeLog = debug('lib:stripe'); export const error = debug('app:error'); // Enables all debug namespaces export function enableAll() { - return debug.enable('app:log,lib:guardian,lib:authentik,app:error'); + return debug.enable( + 'app:log,lib:guardian,lib:authentik,lib:stripe,app:error' + ); } diff --git a/src/lib/server.ts b/src/lib/server.ts index 5515526..13e306b 100644 --- a/src/lib/server.ts +++ b/src/lib/server.ts @@ -6,12 +6,14 @@ import { S3Client } from '@aws-sdk/client-s3'; import { Upload } from '@aws-sdk/lib-storage'; import { mockClient } from 'aws-sdk-client-mock'; import mime from 'mime-types'; -import { config } from '../config'; +import { config, stripe } from '../config'; import { db } from '../drizzle/db'; import { postMedia, user } from '../drizzle/schema'; import { syncClient, tokenClient } from '../redis'; import { authorize } from './authentication'; import { convertModelToUser, getHashedPk, internalUsers } from './authentik'; +import { enableAll, stripeLog } from './logger'; +import { eq } from 'drizzle-orm'; /** * "Legacy" endpoint for uploading media. @@ -179,7 +181,6 @@ async function mediaUploadEndpoint(req: Request) { */ async function webhookEndpoint(req: Request) { const url = new URL(req.url); - console.log(url.pathname, isTestMode); switch (url.pathname) { case `/webhook/${isTestMode ? 'TEST-AUTH' : Bun.env.WEBHOOK_AUTH}`: if ( @@ -274,13 +275,66 @@ async function webhookEndpoint(req: Request) { { status: 404 } ); case `/webhook/${isTestMode ? 'TEST-STRIPE' : Bun.env.WEBHOOK_STRIPE}`: - return Response.json( - { - status: 'WORK_IN_PROGRESS', - message: 'This webhook is not implemented yet' - }, - { status: 404 } - ); + enableAll(); + + const body = await req.json(); + switch (body.type) { + case 'customer.subscription.created': + console.log(body.type, body); + + const customer = await stripe.customers.retrieve( + body.data.object.customer + ); + + const customerId = + 'metadata' in customer + ? (customer.metadata['id'] as string) + : ''; + + const customerEmail = + 'email' in customer ? (customer.email as string) : ''; + + const userRecord = await db.query.user.findFirst({ + where: (user, { eq, or }) => + or( + eq( + user.stripe_customer_id, + body.data.object.customer + ), + eq(user.id, customerId), + eq(user.email, customerEmail) + ) + }); + + if (!userRecord) { + stripeLog( + `customer ${customer.id} has a subscription, but no user was found, bailing out...` + ); + return Response.json({}, { status: 200 }); + } + + stripeLog( + `customer ${customer.id} associated with user ${userRecord.id} has been charged, setting subscription to ${body.id}...` + ); + + await db + .update(user) + .set({ + stripe_customer_id: customer.id, + stripe_subscription_id: body.id + }) + .where(eq(user.id, userRecord.id)) + .execute(); + + return Response.json({}, { status: 200 }); + case 'charge.refunded': + console.log('๐Ÿ’ณ User has been refunded, downgrading...'); + // TODO: Update subscription status for user. + return Response.json({}, { status: 200 }); + default: + //console.log('๐Ÿ’ณ Unknown webhook type'); + return Response.json({}, { status: 200 }); + } default: return Response.json( { diff --git a/src/server.ts b/src/server.ts index 134d66a..a07bb32 100755 --- a/src/server.ts +++ b/src/server.ts @@ -6,7 +6,7 @@ import gradient from 'gradient-string'; import { handleProtocols, makeHandler } from 'graphql-ws/lib/use/bun'; import { createYoga } from 'graphql-yoga'; import { version } from '../package.json'; -import { config } from './config'; +import { config, stripe } from './config'; import { Context } from './context'; import { db, prodDbClient } from './drizzle/db'; import getGitCommitHash from './git'; @@ -179,6 +179,25 @@ export async function startServer() { console.log('๐Ÿงช Running in test mode'); } console.log('\x1b[0m'); + + if (Bun.env.STRIPE_SECRET_KEY) { + if ( + server.hostname === 'localhost' || + server.hostname === '127.0.0.1' + ) { + console.log( + `๐Ÿ’ณ Stripe requires CLI for webhooks due to the hostname being ${server.hostname}.` + ); + } else { + const webhookEndpoint = await stripe.webhookEndpoints.create({ + enabled_events: ['charge.succeeded', 'charge.failed'], + url: new URL( + `/webhook/${Bun.env.WEBHOOK_STRIPE}`, + `http://${server.hostname}:${server.port}` + ).toString() + }); + } + } } if (!isTestMode) { diff --git a/src/types/user/conversation/Conversation.ts b/src/types/user/conversation/Conversation.ts index 0b5f59d..f13c3cf 100644 --- a/src/types/user/conversation/Conversation.ts +++ b/src/types/user/conversation/Conversation.ts @@ -1,4 +1,5 @@ import { builder } from '../../../builder'; +import { config } from '../../../config'; import { db } from '../../../drizzle/db'; import { type UserConversationSchemaType } from '../../../drizzle/schema'; import { UserConversationParticipant } from './Participant'; @@ -12,6 +13,15 @@ export const UserConversation = builder.objectRef('UserConversation'); UserConversation.implement({ + authScopes: (t) => { + if (t.type === 'DIRECT' && !config.features.messaging.direct.enabled) { + return false; + } + if (t.type === 'GROUP' && !config.features.messaging.group.enabled) { + return false; + } + return true; + }, fields: (t) => ({ id: t.exposeString('id', { nullable: false }), name: t.exposeString('name', { nullable: true }),