feat(web): Next.js 15 app shell, auth, i18n, base UI
App Router setup with next-intl (en/fr), Better Auth wiring, shadcn/ui components, Tailwind, AES-256-GCM encrypt util, Redis rate limiter, tier limit checker, site_settings helper for runtime .env overrides.
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import crypto from "node:crypto";
|
||||
import { headers } from "next/headers";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { db, apiKeys, users, eq } from "@epicure/db";
|
||||
import { applyRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export async function requireSession() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) {
|
||||
return { session: null, response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
|
||||
}
|
||||
return { session, response: null };
|
||||
}
|
||||
|
||||
export async function requireAdmin() {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return { session: null, response };
|
||||
if (session!.user.role !== "admin") {
|
||||
return { session: null, response: NextResponse.json({ error: "Forbidden" }, { status: 403 }) };
|
||||
}
|
||||
return { session, response: null };
|
||||
}
|
||||
|
||||
type SessionLike = {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
tier: string;
|
||||
role?: string;
|
||||
image?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
type RateLimitOpts = { limit: number; windowSeconds: number };
|
||||
|
||||
export async function requireSessionOrApiKey(
|
||||
req: NextRequest,
|
||||
opts?: { rateLimit?: RateLimitOpts }
|
||||
): Promise<{ session: SessionLike; response: null } | { session: null; response: NextResponse }> {
|
||||
// 1. Try Bearer API key
|
||||
const authHeader = req.headers.get("authorization");
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
const rawKey = authHeader.slice(7).trim();
|
||||
if (rawKey.startsWith("ek_")) {
|
||||
const keyHash = crypto.createHash("sha256").update(rawKey).digest("hex");
|
||||
|
||||
const [keyRow] = await db
|
||||
.select({ id: apiKeys.id, userId: apiKeys.userId })
|
||||
.from(apiKeys)
|
||||
.where(eq(apiKeys.keyHash, keyHash))
|
||||
.limit(1);
|
||||
|
||||
if (keyRow) {
|
||||
// Update lastUsedAt asynchronously — don't block response
|
||||
void db
|
||||
.update(apiKeys)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(eq(apiKeys.id, keyRow.id));
|
||||
|
||||
const [user] = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
name: users.name,
|
||||
tier: users.tier,
|
||||
role: users.role,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, keyRow.userId))
|
||||
.limit(1);
|
||||
|
||||
if (user) {
|
||||
// Apply rate limit when requested (API key path only)
|
||||
if (opts?.rateLimit) {
|
||||
const { limit, windowSeconds } = opts.rateLimit;
|
||||
const rateLimitResponse = await applyRateLimit(
|
||||
`rl:api:${user.id}`,
|
||||
limit,
|
||||
windowSeconds
|
||||
);
|
||||
if (rateLimitResponse) {
|
||||
return { session: null, response: rateLimitResponse };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
session: { user: { ...user, image: null } },
|
||||
response: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
session: null,
|
||||
response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fall back to session cookie
|
||||
return requireSession();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
export const authClient = createAuthClient();
|
||||
|
||||
export const {
|
||||
signIn,
|
||||
signUp,
|
||||
signOut,
|
||||
useSession,
|
||||
getSession,
|
||||
} = authClient;
|
||||
@@ -0,0 +1,113 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { db, users, sessions, accounts, verifications, eq, count } from "@epicure/db";
|
||||
import { sendEmail, verifyEmailHtml, resetPasswordHtml, welcomeHtml } from "@/lib/email";
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
schema: { user: users, session: sessions, account: accounts, verification: verifications },
|
||||
}),
|
||||
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
requireEmailVerification: true,
|
||||
sendResetPassword: async ({ user, url }) => {
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: "Reset your Epicure password",
|
||||
html: resetPasswordHtml(url),
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
emailVerification: {
|
||||
sendOnSignUp: true,
|
||||
autoSignInAfterVerification: true,
|
||||
sendVerificationEmail: async ({ user, url }) => {
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: "Verify your Epicure email",
|
||||
html: verifyEmailHtml(url),
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: process.env["GOOGLE_CLIENT_ID"] ?? "",
|
||||
clientSecret: process.env["GOOGLE_CLIENT_SECRET"] ?? "",
|
||||
},
|
||||
},
|
||||
|
||||
session: {
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 60 * 5,
|
||||
},
|
||||
},
|
||||
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
after: async (user) => {
|
||||
// First registered user becomes admin
|
||||
const result = await db.select({ total: count() }).from(users);
|
||||
if ((result[0]?.total ?? 0) === 1) {
|
||||
await db.update(users).set({ role: "admin" }).where(eq(users.id, user.id));
|
||||
}
|
||||
// Welcome email (fire and forget)
|
||||
sendEmail({
|
||||
to: user.email,
|
||||
subject: "Welcome to Epicure",
|
||||
html: welcomeHtml(user.name),
|
||||
}).catch(() => {});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
user: {
|
||||
additionalFields: {
|
||||
role: {
|
||||
type: "string",
|
||||
defaultValue: "user",
|
||||
input: false,
|
||||
},
|
||||
tier: {
|
||||
type: "string",
|
||||
defaultValue: "free",
|
||||
input: false,
|
||||
},
|
||||
username: {
|
||||
type: "string",
|
||||
required: false,
|
||||
},
|
||||
bio: {
|
||||
type: "string",
|
||||
required: false,
|
||||
},
|
||||
unitPref: {
|
||||
type: "string",
|
||||
defaultValue: "metric",
|
||||
},
|
||||
locale: {
|
||||
type: "string",
|
||||
defaultValue: "en",
|
||||
},
|
||||
},
|
||||
changeEmail: {
|
||||
enabled: true,
|
||||
sendChangeEmailVerification: async ({ newEmail, url }) => {
|
||||
await sendEmail({
|
||||
to: newEmail,
|
||||
subject: "Verify your new Epicure email",
|
||||
html: verifyEmailHtml(url),
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export type Session = typeof auth.$Infer.Session;
|
||||
export type User = typeof auth.$Infer.Session.user;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes, createHash } from "crypto";
|
||||
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
|
||||
function getKey(): Buffer {
|
||||
const secret = process.env["BETTER_AUTH_SECRET"] ?? "dev-secret-needs-32-bytes-padding!";
|
||||
return createHash("sha256").update(secret).digest();
|
||||
}
|
||||
|
||||
export function encrypt(plaintext: string): string {
|
||||
const key = getKey();
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
const authTag = (cipher as ReturnType<typeof createCipheriv> & { getAuthTag(): Buffer }).getAuthTag();
|
||||
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}`;
|
||||
}
|
||||
|
||||
export function decrypt(ciphertext: string): string {
|
||||
const parts = ciphertext.split(":");
|
||||
if (parts.length !== 3) throw new Error("Invalid ciphertext format");
|
||||
const [ivHex, authTagHex, encryptedHex] = parts as [string, string, string];
|
||||
const key = getKey();
|
||||
const iv = Buffer.from(ivHex, "hex");
|
||||
const authTag = Buffer.from(authTagHex, "hex");
|
||||
const encrypted = Buffer.from(encryptedHex, "hex");
|
||||
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
||||
(decipher as ReturnType<typeof createDecipheriv> & { setAuthTag(tag: Buffer): void }).setAuthTag(authTag);
|
||||
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8");
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
|
||||
export const SUPPORTED_LOCALES = [
|
||||
{ code: "en", label: "English" },
|
||||
{ code: "fr", label: "Français" },
|
||||
] as const;
|
||||
|
||||
export type Locale = (typeof SUPPORTED_LOCALES)[number]["code"];
|
||||
|
||||
const STORAGE_KEY = "epicure-locale";
|
||||
|
||||
const LocaleContext = createContext<{
|
||||
locale: Locale;
|
||||
setLocale: (locale: Locale) => void;
|
||||
}>({ locale: "en", setLocale: () => {} });
|
||||
|
||||
export function useLocale() {
|
||||
return useContext(LocaleContext);
|
||||
}
|
||||
|
||||
export function I18nProvider({
|
||||
children,
|
||||
messages,
|
||||
initialLocale,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
messages: Record<string, Record<string, unknown>>;
|
||||
initialLocale?: Locale;
|
||||
}) {
|
||||
const resolved = initialLocale ?? ("en" as Locale);
|
||||
const [locale, setLocaleState] = useState<Locale>(resolved);
|
||||
const [currentMessages, setCurrentMessages] = useState(messages[resolved] ?? messages["en"]!);
|
||||
|
||||
useEffect(() => {
|
||||
// If no server-side locale, fall back to localStorage
|
||||
if (!initialLocale) {
|
||||
const stored = localStorage.getItem(STORAGE_KEY) as Locale | null;
|
||||
if (stored && messages[stored]) {
|
||||
setLocaleState(stored);
|
||||
setCurrentMessages(messages[stored]!);
|
||||
}
|
||||
}
|
||||
}, [messages, initialLocale]);
|
||||
|
||||
function setLocale(next: Locale) {
|
||||
setLocaleState(next);
|
||||
setCurrentMessages(messages[next]!);
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
fetch("/api/v1/users/me", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ locale: next }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
return (
|
||||
<LocaleContext.Provider value={{ locale, setLocale }}>
|
||||
<NextIntlClientProvider locale={locale} messages={currentMessages} timeZone="UTC">
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
</LocaleContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { getRedis } from "./redis";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function rateLimit(
|
||||
key: string,
|
||||
limit: number,
|
||||
windowSeconds: number
|
||||
): Promise<{ ok: boolean; remaining: number; reset: number }> {
|
||||
const redis = getRedis();
|
||||
const now = Date.now();
|
||||
const windowStart = now - windowSeconds * 1000;
|
||||
const reset = Math.ceil((now + windowSeconds * 1000) / 1000);
|
||||
|
||||
const pipeline = redis.pipeline();
|
||||
pipeline.zremrangebyscore(key, "-inf", windowStart);
|
||||
pipeline.zadd(key, now, `${now}-${Math.random()}`);
|
||||
pipeline.zcard(key);
|
||||
pipeline.expire(key, windowSeconds);
|
||||
const results = await pipeline.exec();
|
||||
|
||||
const count = (results?.[2]?.[1] as number) ?? 0;
|
||||
const remaining = Math.max(0, limit - count);
|
||||
const ok = count <= limit;
|
||||
|
||||
return { ok, remaining, reset };
|
||||
}
|
||||
|
||||
export async function applyRateLimit(
|
||||
key: string,
|
||||
limit: number,
|
||||
windowSeconds: number
|
||||
): Promise<NextResponse | null> {
|
||||
const { ok, remaining, reset } = await rateLimit(key, limit, windowSeconds);
|
||||
|
||||
if (!ok) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many requests", retryAfter: reset },
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"X-RateLimit-Limit": String(limit),
|
||||
"X-RateLimit-Remaining": "0",
|
||||
"X-RateLimit-Reset": String(reset),
|
||||
"Retry-After": String(reset - Math.floor(Date.now() / 1000)),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { db, siteSettings, eq } from "@epicure/db";
|
||||
import { encrypt, decrypt } from "@/lib/encrypt";
|
||||
|
||||
export type SiteSettingKey =
|
||||
| "OPENAI_API_KEY"
|
||||
| "ANTHROPIC_API_KEY"
|
||||
| "OPENROUTER_API_KEY"
|
||||
| "OPENROUTER_DEFAULT_MODEL"
|
||||
| "OLLAMA_BASE_URL"
|
||||
| "NEXT_PUBLIC_VAPID_PUBLIC_KEY"
|
||||
| "VAPID_PRIVATE_KEY";
|
||||
|
||||
const SECRET_KEYS: SiteSettingKey[] = [
|
||||
"OPENAI_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"VAPID_PRIVATE_KEY",
|
||||
];
|
||||
|
||||
export function isSecretKey(key: SiteSettingKey): boolean {
|
||||
return SECRET_KEYS.includes(key);
|
||||
}
|
||||
|
||||
export async function getSiteSetting(key: SiteSettingKey): Promise<string | null> {
|
||||
try {
|
||||
const row = await db.query.siteSettings.findFirst({ where: eq(siteSettings.key, key) });
|
||||
if (row?.value) {
|
||||
return row.isSecret ? decrypt(row.value) : row.value;
|
||||
}
|
||||
} catch {
|
||||
// fall through to env
|
||||
}
|
||||
return process.env[key] ?? null;
|
||||
}
|
||||
|
||||
export async function getAllSiteSettings(): Promise<Record<string, { value: string | null; isSecret: boolean; fromDb: boolean }>> {
|
||||
const rows = await db.query.siteSettings.findMany();
|
||||
const dbMap = new Map(rows.map((r) => [r.key, r]));
|
||||
|
||||
const KNOWN_KEYS: SiteSettingKey[] = [
|
||||
"OPENAI_API_KEY",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"OPENROUTER_DEFAULT_MODEL",
|
||||
"OLLAMA_BASE_URL",
|
||||
"NEXT_PUBLIC_VAPID_PUBLIC_KEY",
|
||||
"VAPID_PRIVATE_KEY",
|
||||
];
|
||||
|
||||
const result: Record<string, { value: string | null; isSecret: boolean; fromDb: boolean }> = {};
|
||||
for (const k of KNOWN_KEYS) {
|
||||
const row = dbMap.get(k);
|
||||
const secret = isSecretKey(k);
|
||||
if (row) {
|
||||
result[k] = {
|
||||
value: secret ? "••••••••••••" : (row.value ?? null),
|
||||
isSecret: secret,
|
||||
fromDb: true,
|
||||
};
|
||||
} else {
|
||||
const envVal = process.env[k];
|
||||
result[k] = {
|
||||
value: envVal ? (secret ? "••••••••••••" : envVal) : null,
|
||||
isSecret: secret,
|
||||
fromDb: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function setSiteSetting(
|
||||
key: SiteSettingKey,
|
||||
value: string | null,
|
||||
updatedById: string
|
||||
): Promise<void> {
|
||||
if (value === null || value === "") {
|
||||
await db.delete(siteSettings).where(eq(siteSettings.key, key));
|
||||
return;
|
||||
}
|
||||
const secret = isSecretKey(key);
|
||||
const stored = secret ? encrypt(value) : value;
|
||||
await db
|
||||
.insert(siteSettings)
|
||||
.values({ key, value: stored, isSecret: secret, updatedById, updatedAt: new Date() })
|
||||
.onConflictDoUpdate({
|
||||
target: siteSettings.key,
|
||||
set: { value: stored, updatedAt: new Date(), updatedById },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { db } from "@epicure/db";
|
||||
import { tierDefinitions, userUsage } from "@epicure/db";
|
||||
import { eq, and, sql } from "@epicure/db";
|
||||
|
||||
function currentMonth() {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export type LimitKey = "recipe" | "aiCall" | "storage";
|
||||
|
||||
export class TierLimitError extends Error {
|
||||
constructor(public readonly limit: LimitKey, public readonly tier: string) {
|
||||
super(`Tier limit reached: ${limit} (tier: ${tier})`);
|
||||
this.name = "TierLimitError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkTierLimit(
|
||||
userId: string,
|
||||
userTier: "free" | "pro",
|
||||
key: LimitKey
|
||||
): Promise<void> {
|
||||
const [tierDef] = await db
|
||||
.select()
|
||||
.from(tierDefinitions)
|
||||
.where(eq(tierDefinitions.tier, userTier));
|
||||
|
||||
if (!tierDef) return;
|
||||
|
||||
const month = currentMonth();
|
||||
const [usage] = await db
|
||||
.select()
|
||||
.from(userUsage)
|
||||
.where(and(eq(userUsage.userId, userId), eq(userUsage.month, month)));
|
||||
|
||||
const current = usage ?? {
|
||||
aiCallsUsed: 0,
|
||||
recipeCount: 0,
|
||||
storageUsedMb: 0,
|
||||
};
|
||||
|
||||
if (key === "recipe" && current.recipeCount >= tierDef.maxRecipes) {
|
||||
throw new TierLimitError("recipe", userTier);
|
||||
}
|
||||
if (key === "aiCall" && current.aiCallsUsed >= tierDef.aiCallsPerMonth) {
|
||||
throw new TierLimitError("aiCall", userTier);
|
||||
}
|
||||
}
|
||||
|
||||
export async function incrementUsage(
|
||||
userId: string,
|
||||
key: LimitKey,
|
||||
amount = 1
|
||||
): Promise<void> {
|
||||
const month = currentMonth();
|
||||
const id = `${userId}-${month}`;
|
||||
|
||||
const initialValues = {
|
||||
id,
|
||||
userId,
|
||||
month,
|
||||
aiCallsUsed: key === "aiCall" ? amount : 0,
|
||||
recipeCount: key === "recipe" ? amount : 0,
|
||||
storageUsedMb: key === "storage" ? amount : 0,
|
||||
};
|
||||
|
||||
const incrementSet =
|
||||
key === "aiCall"
|
||||
? { aiCallsUsed: sql`${userUsage.aiCallsUsed} + ${amount}` }
|
||||
: key === "recipe"
|
||||
? { recipeCount: sql`${userUsage.recipeCount} + ${amount}` }
|
||||
: { storageUsedMb: sql`${userUsage.storageUsedMb} + ${amount}` };
|
||||
|
||||
await db
|
||||
.insert(userUsage)
|
||||
.values(initialValues)
|
||||
.onConflictDoUpdate({
|
||||
target: [userUsage.userId, userUsage.month],
|
||||
set: incrementSet,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Reference in New Issue
Block a user