feat: replace Directus with custom Node.js GraphQL backend
Removes Directus 11 and replaces it with a lean, purpose-built backend: - packages/backend/: Fastify v5 + GraphQL Yoga v5 + Pothos (code-first) with Drizzle ORM, Redis sessions (session_token cookie), argon2 auth, Nodemailer, fluent-ffmpeg, and full gamification system ported from bundle - Frontend: @directus/sdk replaced by graphql-request v7; services.ts fully rewritten with identical signatures; directus.ts now re-exports from api.ts - Cookie renamed directus_session_token → session_token - Dev proxy target updated 8055 → 4000 - compose.yml: Directus service removed, backend service added (port 4000) - Dockerfile.backend: new multi-stage image with ffmpeg - Dockerfile: bundle build step and ffmpeg removed from frontend image - data-migration.ts: one-time script to migrate all Directus/sexy_ tables Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
226
packages/backend/src/graphql/resolvers/auth.ts
Normal file
226
packages/backend/src/graphql/resolvers/auth.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { GraphQLError } from "graphql";
|
||||
import { builder } from "../builder.js";
|
||||
import { CurrentUserType } from "../types/index.js";
|
||||
import { users } from "../../db/schema/index.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { hash, verify as verifyArgon } from "../../lib/argon.js";
|
||||
import { setSession, deleteSession } from "../../lib/auth.js";
|
||||
import { sendVerification, sendPasswordReset } from "../../lib/email.js";
|
||||
import { slugify } from "../../lib/slugify.js";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
builder.mutationField("login", (t) =>
|
||||
t.field({
|
||||
type: CurrentUserType,
|
||||
args: {
|
||||
email: t.arg.string({ required: true }),
|
||||
password: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
const user = await ctx.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, args.email.toLowerCase()))
|
||||
.limit(1);
|
||||
|
||||
if (!user[0]) throw new GraphQLError("Invalid credentials");
|
||||
|
||||
const valid = await verifyArgon(user[0].password_hash, args.password);
|
||||
if (!valid) throw new GraphQLError("Invalid credentials");
|
||||
|
||||
const token = nanoid(32);
|
||||
const sessionUser = {
|
||||
id: user[0].id,
|
||||
email: user[0].email,
|
||||
role: user[0].role,
|
||||
first_name: user[0].first_name,
|
||||
last_name: user[0].last_name,
|
||||
artist_name: user[0].artist_name,
|
||||
slug: user[0].slug,
|
||||
avatar: user[0].avatar,
|
||||
};
|
||||
|
||||
await setSession(token, sessionUser);
|
||||
|
||||
// Set session cookie
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
const cookieValue = `session_token=${token}; HttpOnly; Path=/; SameSite=Lax; Max-Age=86400${isProduction ? "; Secure" : ""}`;
|
||||
(ctx.reply as any).header?.("Set-Cookie", cookieValue);
|
||||
|
||||
// For graphql-yoga response
|
||||
if ((ctx as any).serverResponse) {
|
||||
(ctx as any).serverResponse.setHeader("Set-Cookie", cookieValue);
|
||||
}
|
||||
|
||||
return user[0];
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("logout", (t) =>
|
||||
t.field({
|
||||
type: "Boolean",
|
||||
resolve: async (_root, _args, ctx) => {
|
||||
const cookieHeader = ctx.request.headers.get("cookie") || "";
|
||||
const cookies = Object.fromEntries(
|
||||
cookieHeader.split(";").map((c) => {
|
||||
const [k, ...v] = c.trim().split("=");
|
||||
return [k.trim(), v.join("=")];
|
||||
}),
|
||||
);
|
||||
const token = cookies["session_token"];
|
||||
if (token) {
|
||||
await deleteSession(token);
|
||||
}
|
||||
// Clear cookie
|
||||
const cookieValue = "session_token=; HttpOnly; Path=/; Max-Age=0";
|
||||
(ctx.reply as any).header?.("Set-Cookie", cookieValue);
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("register", (t) =>
|
||||
t.field({
|
||||
type: "Boolean",
|
||||
args: {
|
||||
email: t.arg.string({ required: true }),
|
||||
password: t.arg.string({ required: true }),
|
||||
firstName: t.arg.string({ required: true }),
|
||||
lastName: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
const existing = await ctx.db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.email, args.email.toLowerCase()))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) throw new GraphQLError("Email already registered");
|
||||
|
||||
const passwordHash = await hash(args.password);
|
||||
const artistName = `${args.firstName} ${args.lastName}`;
|
||||
const baseSlug = slugify(artistName);
|
||||
const verifyToken = nanoid(32);
|
||||
|
||||
// Ensure unique slug
|
||||
let slug = baseSlug;
|
||||
let attempt = 0;
|
||||
while (true) {
|
||||
const existing = await ctx.db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.slug, slug))
|
||||
.limit(1);
|
||||
if (existing.length === 0) break;
|
||||
attempt++;
|
||||
slug = `${baseSlug}-${attempt}`;
|
||||
}
|
||||
|
||||
await ctx.db.insert(users).values({
|
||||
email: args.email.toLowerCase(),
|
||||
password_hash: passwordHash,
|
||||
first_name: args.firstName,
|
||||
last_name: args.lastName,
|
||||
artist_name: artistName,
|
||||
slug,
|
||||
role: "viewer",
|
||||
email_verify_token: verifyToken,
|
||||
email_verified: false,
|
||||
});
|
||||
|
||||
await sendVerification(args.email, verifyToken);
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("verifyEmail", (t) =>
|
||||
t.field({
|
||||
type: "Boolean",
|
||||
args: {
|
||||
token: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
const user = await ctx.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email_verify_token, args.token))
|
||||
.limit(1);
|
||||
|
||||
if (!user[0]) throw new GraphQLError("Invalid verification token");
|
||||
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({ email_verified: true, email_verify_token: null })
|
||||
.where(eq(users.id, user[0].id));
|
||||
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("requestPasswordReset", (t) =>
|
||||
t.field({
|
||||
type: "Boolean",
|
||||
args: {
|
||||
email: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
const user = await ctx.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, args.email.toLowerCase()))
|
||||
.limit(1);
|
||||
|
||||
// Always return true to prevent email enumeration
|
||||
if (!user[0]) return true;
|
||||
|
||||
const token = nanoid(32);
|
||||
const expiry = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
|
||||
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({ password_reset_token: token, password_reset_expiry: expiry })
|
||||
.where(eq(users.id, user[0].id));
|
||||
|
||||
await sendPasswordReset(args.email, token);
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
builder.mutationField("resetPassword", (t) =>
|
||||
t.field({
|
||||
type: "Boolean",
|
||||
args: {
|
||||
token: t.arg.string({ required: true }),
|
||||
newPassword: t.arg.string({ required: true }),
|
||||
},
|
||||
resolve: async (_root, args, ctx) => {
|
||||
const user = await ctx.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.password_reset_token, args.token))
|
||||
.limit(1);
|
||||
|
||||
if (!user[0]) throw new GraphQLError("Invalid or expired reset token");
|
||||
if (user[0].password_reset_expiry && user[0].password_reset_expiry < new Date()) {
|
||||
throw new GraphQLError("Reset token expired");
|
||||
}
|
||||
|
||||
const passwordHash = await hash(args.newPassword);
|
||||
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({
|
||||
password_hash: passwordHash,
|
||||
password_reset_token: null,
|
||||
password_reset_expiry: null,
|
||||
})
|
||||
.where(eq(users.id, user[0].id));
|
||||
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user