From 13cf0898707a67cc62457b68f79bf41b89d5208a Mon Sep 17 00:00:00 2001 From: Gib Date: Mon, 23 Jun 2025 16:59:02 -0500 Subject: [PATCH] Making progress on rewrite. Looking into supabase cache helpers. --- scripts/supabase/generate_types | 6 +- src/app/layout.tsx | 11 +- src/components/ui/sidebar.tsx | 2 +- src/instrumentation-client.ts | 36 ++++ src/instrumentation.ts | 10 + src/lib/hooks/context/index.tsx | 4 + src/lib/hooks/context/use-auth.tsx | 11 + .../hooks/context}/use-mobile.ts | 6 +- src/lib/hooks/context/use-query.tsx | 22 ++ src/lib/hooks/context/use-theme.tsx | 69 ++++++ src/lib/hooks/context/use-tv-mode.tsx | 78 +++++++ src/lib/utils.ts | 18 +- src/middleware.ts | 24 +++ src/server/db/schema.ts | 7 +- src/utils/ban-suspicious-ips.ts | 102 +++++++++ src/utils/supabase/client.ts | 23 ++ src/utils/supabase/database.types.ts | 203 ++++++++++++++++++ src/utils/supabase/index.ts | 5 + src/utils/supabase/middleware.ts | 56 +++++ src/utils/supabase/server.ts | 30 +++ src/utils/supabase/types.ts | 30 +++ 21 files changed, 740 insertions(+), 13 deletions(-) create mode 100644 src/instrumentation-client.ts create mode 100644 src/instrumentation.ts create mode 100644 src/lib/hooks/context/index.tsx create mode 100644 src/lib/hooks/context/use-auth.tsx rename src/{hooks => lib/hooks/context}/use-mobile.ts (90%) create mode 100644 src/lib/hooks/context/use-query.tsx create mode 100644 src/lib/hooks/context/use-theme.tsx create mode 100644 src/lib/hooks/context/use-tv-mode.tsx create mode 100644 src/middleware.ts create mode 100644 src/utils/ban-suspicious-ips.ts create mode 100644 src/utils/supabase/client.ts create mode 100644 src/utils/supabase/database.types.ts create mode 100644 src/utils/supabase/index.ts create mode 100644 src/utils/supabase/middleware.ts create mode 100644 src/utils/supabase/server.ts create mode 100644 src/utils/supabase/types.ts diff --git a/scripts/supabase/generate_types b/scripts/supabase/generate_types index 4566356..eb5c7de 100755 --- a/scripts/supabase/generate_types +++ b/scripts/supabase/generate_types @@ -109,8 +109,8 @@ sudo -E npx supabase gen types typescript \ # Check if the command was successful if [ $? -eq 0 ] && [ -s "$TEMP_FILE" ] && ! grep -q "Error" "$TEMP_FILE"; then # Move the temp file to the final destination - mv "$TEMP_FILE" "$OUTPUT_DIR/types.ts" - echo -e "${GREEN}✓ TypeScript types successfully generated at $OUTPUT_DIR/types.ts${NC}" + mv "$TEMP_FILE" "$OUTPUT_DIR/database.types.ts" + echo -e "${GREEN}✓ TypeScript types successfully generated at $OUTPUT_DIR/database.types.ts${NC}" # Show the first few lines to confirm it looks right echo -e "${YELLOW}Preview of generated types:${NC}" @@ -130,4 +130,4 @@ unset DB_URL echo -e "${GREEN}${BOLD}Type generation complete!${NC}" echo -e "You can now use these types in your Next.js application." -echo -e "Import them with: ${BLUE}import { Database } from '@/utils/supabase/types'${NC}" +echo -e "Import them with: ${BLUE}import { Database } from '@/utils/supabase/database.types'${NC}" diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2d79c7f..2c36ac1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,9 @@ import '@/styles/globals.css'; import { type Metadata } from 'next'; import { Geist } from 'next/font/google'; +import { + ReactQueryClientProvider, +} from '@/lib/hooks/context'; export const metadata: Metadata = { title: 'Create T3 App', @@ -18,8 +21,10 @@ export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { return ( - - {children} - + + + {children} + + ); } diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index f545f66..a22ae51 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -5,7 +5,7 @@ import { Slot } from '@radix-ui/react-slot'; import { cva, VariantProps } from 'class-variance-authority'; import { PanelLeftIcon } from 'lucide-react'; -import { useIsMobile } from '@/hooks/use-mobile'; +import { useIsMobile } from '@/lib/hooks/context/use-mobile'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; diff --git a/src/instrumentation-client.ts b/src/instrumentation-client.ts new file mode 100644 index 0000000..7f44f8d --- /dev/null +++ b/src/instrumentation-client.ts @@ -0,0 +1,36 @@ +// This file configures the initialization of Sentry on the client. +// The added config here will be used whenever a users loads a page in their browser. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN!, + + // Adds request headers and IP for users, for more info visit: + // https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii + sendDefaultPii: true, + + // Set tracesSampleRate to 1.0 to capture 100% + // of transactions for tracing. + // We recommend adjusting this value in production + // Learn more at + // https://docs.sentry.io/platforms/javascript/configuration/options/#traces-sample-rate + tracesSampleRate: 1.0, + // Replay may only be enabled for the client-side + integrations: [Sentry.replayIntegration()], + + // Capture Replay for 10% of all sessions, + // plus for 100% of sessions with an error + // Learn more at + // https://docs.sentry.io/platforms/javascript/session-replay/configuration/#general-integration-configuration + replaysSessionSampleRate: 0.1, + replaysOnErrorSampleRate: 1.0, + + // Note: if you want to override the automatic release value, do not set a + // `release` value here - use the environment variable `SENTRY_RELEASE`, so + // that it will also get attached to your source maps +}); + +// This export will instrument router navigations, and is only relevant if you enable tracing. +// `captureRouterTransitionStart` is available from SDK version 9.12.0 onwards +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 0000000..b77a16f --- /dev/null +++ b/src/instrumentation.ts @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/nextjs'; +import type { Instrumentation } from 'next'; + +export const register = async () => { + await import('../sentry.server.config'); +}; + +export const onRequestError: Instrumentation.onRequestError = (...args) => { + Sentry.captureRequestError(...args); +}; diff --git a/src/lib/hooks/context/index.tsx b/src/lib/hooks/context/index.tsx new file mode 100644 index 0000000..080964a --- /dev/null +++ b/src/lib/hooks/context/index.tsx @@ -0,0 +1,4 @@ +export { useIsMobile } from './use-mobile'; +export { ReactQueryClientProvider } from './use-query'; +export { ThemeProvider, ThemeToggle } from './use-theme'; +export { TVModeProvider, useTVMode, TVToggle } from './use-tv-mode'; diff --git a/src/lib/hooks/context/use-auth.tsx b/src/lib/hooks/context/use-auth.tsx new file mode 100644 index 0000000..32e7362 --- /dev/null +++ b/src/lib/hooks/context/use-auth.tsx @@ -0,0 +1,11 @@ +'use client'; + +import React, { + type ReactNode, + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react'; diff --git a/src/hooks/use-mobile.ts b/src/lib/hooks/context/use-mobile.ts similarity index 90% rename from src/hooks/use-mobile.ts rename to src/lib/hooks/context/use-mobile.ts index 821f8ff..4056236 100644 --- a/src/hooks/use-mobile.ts +++ b/src/lib/hooks/context/use-mobile.ts @@ -2,7 +2,7 @@ import * as React from 'react'; const MOBILE_BREAKPOINT = 768; -export function useIsMobile() { +const useIsMobile = () => { const [isMobile, setIsMobile] = React.useState( undefined, ); @@ -18,4 +18,6 @@ export function useIsMobile() { }, []); return !!isMobile; -} +}; + +export { useIsMobile }; diff --git a/src/lib/hooks/context/use-query.tsx b/src/lib/hooks/context/use-query.tsx new file mode 100644 index 0000000..94659a4 --- /dev/null +++ b/src/lib/hooks/context/use-query.tsx @@ -0,0 +1,22 @@ +'use client' + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { useState } from 'react' + +const ReactQueryClientProvider = ({ children }: { children: React.ReactNode }) => { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + // With SSR, we usually want to set some default staleTime + // above 0 to avoid refetching immediately on the client + staleTime: 60 * 1000, + }, + }, + }) + ) + return {children} +}; + +export { ReactQueryClientProvider }; diff --git a/src/lib/hooks/context/use-theme.tsx b/src/lib/hooks/context/use-theme.tsx new file mode 100644 index 0000000..71c808a --- /dev/null +++ b/src/lib/hooks/context/use-theme.tsx @@ -0,0 +1,69 @@ +'use client'; +import * as React from 'react'; +import { ThemeProvider as NextThemesProvider } from 'next-themes'; +import { Moon, Sun } from 'lucide-react'; +import { useTheme } from 'next-themes'; +import { Button } from '@/components/ui'; + +const ThemeProvider = ({ + children, + ...props +}: React.ComponentProps) => { + const [mounted, setMounted] = React.useState(false); + + React.useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) return null; + + return {children}; +}; + +type ThemeToggleProps = React.ButtonHTMLAttributes & { + size?: number; +}; + +const ThemeToggle = ({ size = 1, ...props }: ThemeToggleProps) => { + const { setTheme, resolvedTheme } = useTheme(); + const [mounted, setMounted] = React.useState(false); + + React.useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + return ( + + ); + } + + const toggleTheme = () => { + if (resolvedTheme === 'dark') setTheme('light'); + else setTheme('dark'); + }; + + return ( + + ); +}; + +export { ThemeProvider, ThemeToggle }; diff --git a/src/lib/hooks/context/use-tv-mode.tsx b/src/lib/hooks/context/use-tv-mode.tsx new file mode 100644 index 0000000..dce7512 --- /dev/null +++ b/src/lib/hooks/context/use-tv-mode.tsx @@ -0,0 +1,78 @@ +'use client'; +import Image from 'next/image'; +import React, { createContext, useContext, useState } from 'react'; +import type { ReactNode } from 'react'; +import { Button, type buttonVariants } from '@/components/ui'; +import { type ComponentProps } from 'react'; +import { type VariantProps } from 'class-variance-authority'; + +type TVModeContextProps = { + tvMode: boolean; + toggleTVMode: () => void; +}; + +type TVToggleProps = { + className?: ComponentProps<'button'>['className']; + buttonSize?: VariantProps['size']; + buttonVariant?: VariantProps['variant']; + imageWidth?: number; + imageHeight?: number; +}; + +const TVModeContext = createContext(undefined); + +const TVModeProvider = ({ children }: { children: ReactNode }) => { + const [tvMode, setTVMode] = useState(false); + const toggleTVMode = () => { + setTVMode((prev) => !prev); + }; + return ( + + {children} + + ); +}; + +const useTVMode = () => { + const context = useContext(TVModeContext); + if (!context) { + throw new Error('useTVMode must be used within a TVModeProvider'); + } + return context; +}; + +const TVToggle = ({ + className = 'my-auto cursor-pointer', + buttonSize = 'default', + buttonVariant = 'link', + imageWidth = 25, + imageHeight = 25, +}: TVToggleProps) => { + const { tvMode, toggleTVMode } = useTVMode(); + return ( + + ); +}; + +export { TVModeProvider, useTVMode, TVToggle }; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 2819a83..1ee4273 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,20 @@ import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; -export function cn(...inputs: ClassValue[]) { +export const cn = (...inputs: ClassValue[]) => { return twMerge(clsx(inputs)); -} +}; + +export const ccn = ({ + context, + className, + on = '', + off = '', +}: { + context: boolean; + className: string; + on: string; + off: string; +}) => { + return className + ' ' + (context ? on : off); +}; diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..a2c1306 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,24 @@ +import { type NextRequest } from 'next/server'; +import { updateSession } from '@/utils/supabase/middleware'; +import { banSuspiciousIPs } from '@/utils/ban-suspicious-ips'; + +export const middleware = async (request: NextRequest) => { + const banResponse = banSuspiciousIPs(request); + if (banResponse) return banResponse; + return await updateSession(request); +}; + +export const config = { + matcher: [ + /* + * Match all request paths except: + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + * - /monitoring-tunnel (Sentry monitoring) + * - images - .svg, .png, .jpg, .jpeg, .gif, .webp + * Feel free to modify this pattern to include more paths. + */ + '/((?!_next/static|_next/image|favicon.ico|monitoring-tunnel|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', + ], +}; diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index d6ca9d7..7ad58a1 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -1,4 +1,5 @@ // https://orm.drizzle.team/docs/sql-schema-declaration +// honestly I don't know why I would want Drizzle with Supabase but I'll keep it for now. import { sql } from 'drizzle-orm'; import { @@ -9,11 +10,13 @@ import { text, timestamp, uuid, + varchar, } from 'drizzle-orm/pg-core'; export const users = pgTable('users', { - id: uuid('id').primaryKey(), - name: text('name').notNull(), + id: serial('id').primaryKey(), + fullName: text('full_name'), + phone: varchar('phone', { length: 256 }), status: text('status').notNull(), updatedAt: timestamp('updatedAt') .default(sql`CURRENT_TIMESTAMP`) diff --git a/src/utils/ban-suspicious-ips.ts b/src/utils/ban-suspicious-ips.ts new file mode 100644 index 0000000..7216c4d --- /dev/null +++ b/src/utils/ban-suspicious-ips.ts @@ -0,0 +1,102 @@ +import { type NextRequest, NextResponse } from 'next/server'; + +// In-memory store for tracking IPs (use Redis in production) +const ipAttempts = new Map(); +const bannedIPs = new Set(); + +// Ban Arctic Wolf Explicitly +bannedIPs.add('::ffff:10.0.1.49'); + +// Suspicious patterns that indicate malicious activity +const MALICIOUS_PATTERNS = [ + /web-inf/i, + /\.jsp/i, + /\.php/i, + /puttest/i, + /WEB-INF/i, + /\.xml$/i, + /perl/i, + /xampp/i, + /phpwebgallery/i, + /FileManager/i, + /standalonemanager/i, + /h2console/i, + /WebAdmin/i, + /login_form\.php/i, + /%2e/i, + /%u002e/i, + /\.%00/i, + /\.\./, + /lcgi/i, +]; + +// Suspicious HTTP methods +const SUSPICIOUS_METHODS = ['TRACE', 'PUT', 'DELETE', 'PATCH']; + +const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute +const MAX_ATTEMPTS = 10; // Max suspicious requests per window +const BAN_DURATION = 30 * 60 * 1000; // 30 minutes + +const getClientIP = (request: NextRequest): string => { + const forwarded = request.headers.get('x-forwarded-for'); + const realIP = request.headers.get('x-real-ip'); + const cfConnectingIP = request.headers.get('cf-connecting-ip'); + + if (forwarded) return (forwarded.split(',')[0] ?? '').trim(); + if (realIP) return realIP; + if (cfConnectingIP) return cfConnectingIP; + return request.headers.get('host') ?? 'unknown'; +}; + +const isPathSuspicious = (pathname: string): boolean => { + return MALICIOUS_PATTERNS.some((pattern) => pattern.test(pathname)); +}; + +const isMethodSuspicious = (method: string): boolean => { + return SUSPICIOUS_METHODS.includes(method); +}; + +const updateIPAttempts = (ip: string): boolean => { + const now = Date.now(); + const attempts = ipAttempts.get(ip); + + if (!attempts || now - attempts.lastAttempt > RATE_LIMIT_WINDOW) { + ipAttempts.set(ip, { count: 1, lastAttempt: now }); + return false; + } + attempts.count++; + attempts.lastAttempt = now; + if (attempts.count > MAX_ATTEMPTS) { + bannedIPs.add(ip); + ipAttempts.delete(ip); + // Auto-unban after duration (in production, use a proper scheduler) + setTimeout(() => { + bannedIPs.delete(ip); + }, BAN_DURATION); + return true; + } + return false; +}; + +export const banSuspiciousIPs = (request: NextRequest): NextResponse | null => { + const { pathname } = request.nextUrl; + const method = request.method; + const ip = getClientIP(request); + + if (bannedIPs.has(ip)) return new NextResponse('Access denied.', { status: 403 }); + + const isSuspiciousPath = isPathSuspicious(pathname); + const isSuspiciousMethod = isMethodSuspicious(method); + + if (isSuspiciousPath || isSuspiciousMethod) { + const shouldBan = updateIPAttempts(ip); + if (shouldBan) { + console.log(`🔨 IP ${ip} has been banned for suspicious activity`); + return new NextResponse('Access denied - IP banned. Please fuck off.', { + status: 403, + }); + } + return new NextResponse('Not Found', { status: 404 }); + } + return null; +}; diff --git a/src/utils/supabase/client.ts b/src/utils/supabase/client.ts new file mode 100644 index 0000000..6e38d28 --- /dev/null +++ b/src/utils/supabase/client.ts @@ -0,0 +1,23 @@ +'use client'; + +import { createBrowserClient } from '@supabase/ssr'; +import type { Database } from '@/utils/supabase/database.types'; +import type { SupabaseClient } from '@/utils/supabase/types'; +import { useMemo } from 'react'; + +let client: SupabaseClient | undefined; + +const getSupbaseClient = () => { + if (client) return client; + client = createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + ); + return client; +}; + +const useSupabaseClient = () => { + return useMemo(getSupbaseClient, []); +}; + +export { useSupabaseClient }; diff --git a/src/utils/supabase/database.types.ts b/src/utils/supabase/database.types.ts new file mode 100644 index 0000000..c114c75 --- /dev/null +++ b/src/utils/supabase/database.types.ts @@ -0,0 +1,203 @@ +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[]; + +export type Database = { + public: { + Tables: { + profiles: { + Row: { + avatar_url: string | null; + email: string | null; + full_name: string | null; + id: string; + provider: string | null; + updated_at: string | null; + }; + Insert: { + avatar_url?: string | null; + email?: string | null; + full_name?: string | null; + id: string; + provider?: string | null; + updated_at?: string | null; + }; + Update: { + avatar_url?: string | null; + email?: string | null; + full_name?: string | null; + id?: string; + provider?: string | null; + updated_at?: string | null; + }; + Relationships: []; + }; + statuses: { + Row: { + created_at: string; + id: string; + status: string; + updated_by_id: string | null; + user_id: string; + }; + Insert: { + created_at?: string; + id?: string; + status: string; + updated_by_id?: string | null; + user_id: string; + }; + Update: { + created_at?: string; + id?: string; + status?: string; + updated_by_id?: string | null; + user_id?: string; + }; + Relationships: [ + { + foreignKeyName: 'statuses_updated_by_id_fkey'; + columns: ['updated_by_id']; + isOneToOne: false; + referencedRelation: 'profiles'; + referencedColumns: ['id']; + }, + { + foreignKeyName: 'statuses_user_id_fkey'; + columns: ['user_id']; + isOneToOne: false; + referencedRelation: 'profiles'; + referencedColumns: ['id']; + }, + ]; + }; + }; + Views: { + [_ in never]: never; + }; + Functions: { + [_ in never]: never; + }; + Enums: { + [_ in never]: never; + }; + CompositeTypes: { + [_ in never]: never; + }; + }; +}; + +type DefaultSchema = Database[Extract]; + +export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema['Tables'] & DefaultSchema['Views']) + | { schema: keyof Database }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof Database; + } + ? keyof (Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'] & + Database[DefaultSchemaTableNameOrOptions['schema']]['Views']) + : never = never, +> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database } + ? (Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'] & + Database[DefaultSchemaTableNameOrOptions['schema']]['Views'])[TableName] extends { + Row: infer R; + } + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema['Tables'] & + DefaultSchema['Views']) + ? (DefaultSchema['Tables'] & + DefaultSchema['Views'])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R; + } + ? R + : never + : never; + +export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema['Tables'] + | { schema: keyof Database }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof Database; + } + ? keyof Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database } + ? Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends { + Insert: infer I; + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables'] + ? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I; + } + ? I + : never + : never; + +export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema['Tables'] + | { schema: keyof Database }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof Database; + } + ? keyof Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database } + ? Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends { + Update: infer U; + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables'] + ? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends { + Update: infer U; + } + ? U + : never + : never; + +export type Enums< + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema['Enums'] + | { schema: keyof Database }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof Database; + } + ? keyof Database[DefaultSchemaEnumNameOrOptions['schema']]['Enums'] + : never = never, +> = DefaultSchemaEnumNameOrOptions extends { schema: keyof Database } + ? Database[DefaultSchemaEnumNameOrOptions['schema']]['Enums'][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema['Enums'] + ? DefaultSchema['Enums'][DefaultSchemaEnumNameOrOptions] + : never; + +export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema['CompositeTypes'] + | { schema: keyof Database }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof Database; + } + ? keyof Database[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'] + : never = never, +> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database } + ? Database[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema['CompositeTypes'] + ? DefaultSchema['CompositeTypes'][PublicCompositeTypeNameOrOptions] + : never; + +export const Constants = { + public: { + Enums: {}, + }, +} as const; diff --git a/src/utils/supabase/index.ts b/src/utils/supabase/index.ts new file mode 100644 index 0000000..44bc7a5 --- /dev/null +++ b/src/utils/supabase/index.ts @@ -0,0 +1,5 @@ +export { useSupabaseClient } from './client'; +export { updateSession } from './middleware'; +export { useSupabaseServer } from './server'; +export type { Database } from './database.types'; +export type * from './types'; diff --git a/src/utils/supabase/middleware.ts b/src/utils/supabase/middleware.ts new file mode 100644 index 0000000..e82fec5 --- /dev/null +++ b/src/utils/supabase/middleware.ts @@ -0,0 +1,56 @@ +import { createServerClient } from '@supabase/ssr'; +import { type NextRequest, NextResponse } from 'next/server'; +import type { Database } from '@/utils/supabase/database.types'; + +export const updateSession = async ( + request: NextRequest, +): Promise => { + try { + // Create an unmodified response + let response = NextResponse.next({ + request: { + headers: request.headers, + }, + }); + + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return request.cookies.getAll(); + }, + setAll(cookiesToSet) { + cookiesToSet.forEach(({ name, value }) => + request.cookies.set(name, value), + ); + response = NextResponse.next({ + request, + }); + cookiesToSet.forEach(({ name, value, options }) => + response.cookies.set(name, value, options), + ); + }, + }, + }, + ); + + // This will refresh session if expired - required for Server Components + // https://supabase.com/docs/guides/auth/server-side/nextjs + const user = await supabase.auth.getUser(); + + // protected routes + if (request.nextUrl.pathname.startsWith('/reset-password') && user.error) { + return NextResponse.redirect(new URL('/sign-in', request.url)); + } + + return response; + } catch (e) { + return NextResponse.next({ + request: { + headers: request.headers, + }, + }); + } +}; diff --git a/src/utils/supabase/server.ts b/src/utils/supabase/server.ts new file mode 100644 index 0000000..6b9d69c --- /dev/null +++ b/src/utils/supabase/server.ts @@ -0,0 +1,30 @@ +'use server'; + +import 'server-only'; +import { createServerClient } from '@supabase/ssr'; +import type { Database } from '@/utils/supabase/database.types'; +import { cookies } from 'next/headers'; + +export const useSupabaseServer = async () => { + const cookieStore = await cookies(); + return createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return cookieStore.getAll(); + }, + setAll(cookiesToSet) { + try { + cookiesToSet.forEach(({ name, value, options }) => { + cookieStore.set(name, value, options); + }); + } catch (error) { + console.error(`Error setting cookies: ${error as string}`); + } + }, + }, + }, + ); +}; diff --git a/src/utils/supabase/types.ts b/src/utils/supabase/types.ts new file mode 100644 index 0000000..5949a7e --- /dev/null +++ b/src/utils/supabase/types.ts @@ -0,0 +1,30 @@ +import type { Database } from '@/utils/supabase/database.types'; +import type { SupabaseClient as SBClient } from '@supabase/supabase-js' + +export type SupabaseClient = SBClient; + +export type { User } from '@supabase/supabase-js'; + +// Table row types +export type Profile = Database['public']['Tables']['profiles']['Row']; +export type Status = Database['public']['Tables']['statuses']['Row']; + +// Insert types +export type ProfileInsert = Database['public']['Tables']['profiles']['Insert']; +export type StatusInsert = Database['public']['Tables']['statuses']['Insert']; + +// Update types +export type ProfileUpdate = Database['public']['Tables']['profiles']['Update']; +export type StatusUpdate = Database['public']['Tables']['statuses']['Update']; + +// Generic helper to get any table's row type +export type TableRow = + Database['public']['Tables'][T]['Row']; + +// Generic helper to get any table's insert type +export type TableInsert = + Database['public']['Tables'][T]['Insert']; + +// Generic helper to get any table's update type +export type TableUpdate = + Database['public']['Tables'][T]['Update'];