diff --git a/.env.example b/.env.example index f4540ab9..a73bc0cb 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,13 @@ DATABASE_URL_DEV="postgresql://username:password@localhost:5432/redcliffrecord-d PUBLIC_URL="http[s]://[tailscale-hostname-or-localhost]:[port]" PUBLIC_DEV_PORT="5173" # Only for dev server +# Authentication (Optional — when absent, auth is disabled and all requests are admin) +# GitHub OAuth App credentials — create at: https://github.com/settings/developers +ADMIN_GITHUB_ID="" # Numeric GitHub user ID of the admin +GITHUB_CLIENT_ID="" # GitHub OAuth App client ID +GITHUB_CLIENT_SECRET="" # GitHub OAuth App client secret +SESSION_SECRET="" # ≥32 char random string for HMAC signing (generate: openssl rand -base64 32) + # OpenAI Configuration (Required for embeddings) OPENAI_API_KEY="sk-..." diff --git a/README.md b/README.md index ac4ea195..86cc00ea 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,43 @@ Each integration is optional. Only configure the ones you need: Note: Currently hardcoded to the author's album. See [INTEGRATIONS.md](./INTEGRATIONS.md#adobe-lightroom-integration) for setup details. +### 5. Authentication (Optional) + +Authentication uses GitHub OAuth to restrict mutations to a single admin user. **When auth env vars are absent, all requests are treated as admin** — no setup needed for local-only use. + +To enable authentication: + +1. Find your GitHub user ID: + + ```bash + gh api user --jq .id + ``` + +2. Create a [GitHub OAuth App](https://github.com/settings/developers) → "New OAuth App": + - **Homepage URL**: your app URL (e.g. `http://localhost:5173`) + - **Authorization callback URL**: `/api/auth/callback` + +3. Generate a session secret: + + ```bash + openssl rand -base64 32 + ``` + +4. Add to `.env`: + ``` + ADMIN_GITHUB_ID= + GITHUB_CLIENT_ID= + GITHUB_CLIENT_SECRET= + SESSION_SECRET= + ``` + +All four must be set for auth to activate. With auth enabled: + +- Read-only queries (records, search, media, links) remain public +- All mutations require admin session +- Browsing history and GitHub commit data require admin session +- CLI (`rcr`) always bypasses auth + ### Configure Cloudflare R2 Storage For media storage, you'll need a Cloudflare R2 bucket: @@ -267,8 +304,6 @@ Notes: ## Production Build & Deployment -**⚠️ Security Warning**: This application currently has **no authentication or authorization**. If deployed publicly, anyone with the URL will have full read/write access to all data through the UI. Only deploy to production if you understand and accept this security risk, or implement authentication first. - ### Build for Production ```bash diff --git a/server.ts b/server.ts index d76361c2..dd966532 100644 --- a/server.ts +++ b/server.ts @@ -460,6 +460,25 @@ async function initializeStaticRoutes(clientDirectory: string): Promise = { + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()', +}; + +function withSecurityHeaders(response: Response): Response { + const headers = new Headers(response.headers); + for (const [key, value] of Object.entries(SECURITY_HEADERS)) { + headers.set(key, value); + } + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + /** * Initialize the server */ @@ -491,12 +510,13 @@ async function initializeServer() { ...routes, // Fallback to TanStack Start handler for all other routes - '/*': (req: Request) => { + '/*': async (req: Request) => { try { - return handler.fetch(req); + const response = await handler.fetch(req); + return withSecurityHeaders(response); } catch (error) { log.error(`Server handler error: ${String(error)}`); - return new Response('Internal Server Error', { status: 500 }); + return withSecurityHeaders(new Response('Internal Server Error', { status: 500 })); } }, }, diff --git a/src/app/components/auth-button.tsx b/src/app/components/auth-button.tsx new file mode 100644 index 00000000..5595d365 --- /dev/null +++ b/src/app/components/auth-button.tsx @@ -0,0 +1,51 @@ +import { LogInIcon, LogOutIcon, ShieldIcon, ShieldOffIcon } from 'lucide-react'; +import { trpc } from '@/app/trpc'; +import { Button } from './button'; + +export function AuthButton() { + const { data } = trpc.admin.session.useQuery(); + const utils = trpc.useUtils(); + + // Dev-only toggle for simulating admin vs public when auth is not configured + if (import.meta.env.DEV && !data?.authEnabled) { + const isAdmin = data?.isAdmin ?? true; + return ( + + ); + } + + if (!data?.authEnabled) return null; + + if (data.isAdmin) { + return ( +
+ +
+ ); + } + + return ( + + ); +} diff --git a/src/app/lib/hooks/use-record-search.ts b/src/app/lib/hooks/use-record-search.ts index 075f0a0b..2f83b6b4 100644 --- a/src/app/lib/hooks/use-record-search.ts +++ b/src/app/lib/hooks/use-record-search.ts @@ -11,6 +11,8 @@ interface UseRecordSearchOptions { textLimit?: number; /** Maximum vector search results (default: 5) */ vectorLimit?: number; + /** Enable vector/semantic search (default: true). Disable for non-admin users. */ + enableVectorSearch?: boolean; } /** @@ -20,7 +22,13 @@ interface UseRecordSearchOptions { * are filtered to exclude any records already in text results. */ export function useRecordSearch(query: string, options: UseRecordSearchOptions = {}) { - const { debounceMs = 300, minQueryLength = 1, textLimit = 10, vectorLimit = 5 } = options; + const { + debounceMs = 300, + minQueryLength = 1, + textLimit = 10, + vectorLimit = 5, + enableVectorSearch = true, + } = options; const debouncedQuery = useDebounce(query, debounceMs); const shouldSearch = debouncedQuery.length >= minQueryLength; @@ -40,7 +48,7 @@ export function useRecordSearch(query: string, options: UseRecordSearchOptions = const { data: vectorResults = [], isFetching: vectorFetching } = trpc.search.byVector.useQuery( { query: debouncedQuery, limit: vectorLimit }, { - enabled: shouldSearch, + enabled: shouldSearch && enableVectorSearch, trpc: { context: { skipBatch: true, diff --git a/src/app/routeTree.gen.ts b/src/app/routeTree.gen.ts index 047ecdcb..65dd2605 100644 --- a/src/app/routeTree.gen.ts +++ b/src/app/routeTree.gen.ts @@ -13,6 +13,9 @@ import { Route as RecordsRouteRouteImport } from './routes/records/route' import { Route as IndexRouteImport } from './routes/index' import { Route as RecordsRecordIdRouteImport } from './routes/records/$recordId' import { Route as ApiTrpcSplatRouteImport } from './routes/api/trpc/$' +import { Route as ApiAuthLogoutRouteImport } from './routes/api/auth/logout' +import { Route as ApiAuthGithubRouteImport } from './routes/api/auth/github' +import { Route as ApiAuthCallbackRouteImport } from './routes/api/auth/callback' const RecordsRouteRoute = RecordsRouteRouteImport.update({ id: '/records', @@ -34,17 +37,38 @@ const ApiTrpcSplatRoute = ApiTrpcSplatRouteImport.update({ path: '/api/trpc/$', getParentRoute: () => rootRouteImport, } as any) +const ApiAuthLogoutRoute = ApiAuthLogoutRouteImport.update({ + id: '/api/auth/logout', + path: '/api/auth/logout', + getParentRoute: () => rootRouteImport, +} as any) +const ApiAuthGithubRoute = ApiAuthGithubRouteImport.update({ + id: '/api/auth/github', + path: '/api/auth/github', + getParentRoute: () => rootRouteImport, +} as any) +const ApiAuthCallbackRoute = ApiAuthCallbackRouteImport.update({ + id: '/api/auth/callback', + path: '/api/auth/callback', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/records': typeof RecordsRouteRouteWithChildren '/records/$recordId': typeof RecordsRecordIdRoute + '/api/auth/callback': typeof ApiAuthCallbackRoute + '/api/auth/github': typeof ApiAuthGithubRoute + '/api/auth/logout': typeof ApiAuthLogoutRoute '/api/trpc/$': typeof ApiTrpcSplatRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/records': typeof RecordsRouteRouteWithChildren '/records/$recordId': typeof RecordsRecordIdRoute + '/api/auth/callback': typeof ApiAuthCallbackRoute + '/api/auth/github': typeof ApiAuthGithubRoute + '/api/auth/logout': typeof ApiAuthLogoutRoute '/api/trpc/$': typeof ApiTrpcSplatRoute } export interface FileRoutesById { @@ -52,19 +76,47 @@ export interface FileRoutesById { '/': typeof IndexRoute '/records': typeof RecordsRouteRouteWithChildren '/records/$recordId': typeof RecordsRecordIdRoute + '/api/auth/callback': typeof ApiAuthCallbackRoute + '/api/auth/github': typeof ApiAuthGithubRoute + '/api/auth/logout': typeof ApiAuthLogoutRoute '/api/trpc/$': typeof ApiTrpcSplatRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/records' | '/records/$recordId' | '/api/trpc/$' + fullPaths: + | '/' + | '/records' + | '/records/$recordId' + | '/api/auth/callback' + | '/api/auth/github' + | '/api/auth/logout' + | '/api/trpc/$' fileRoutesByTo: FileRoutesByTo - to: '/' | '/records' | '/records/$recordId' | '/api/trpc/$' - id: '__root__' | '/' | '/records' | '/records/$recordId' | '/api/trpc/$' + to: + | '/' + | '/records' + | '/records/$recordId' + | '/api/auth/callback' + | '/api/auth/github' + | '/api/auth/logout' + | '/api/trpc/$' + id: + | '__root__' + | '/' + | '/records' + | '/records/$recordId' + | '/api/auth/callback' + | '/api/auth/github' + | '/api/auth/logout' + | '/api/trpc/$' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute RecordsRouteRoute: typeof RecordsRouteRouteWithChildren + ApiAuthCallbackRoute: typeof ApiAuthCallbackRoute + ApiAuthGithubRoute: typeof ApiAuthGithubRoute + ApiAuthLogoutRoute: typeof ApiAuthLogoutRoute ApiTrpcSplatRoute: typeof ApiTrpcSplatRoute } @@ -98,6 +150,27 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiTrpcSplatRouteImport parentRoute: typeof rootRouteImport } + '/api/auth/logout': { + id: '/api/auth/logout' + path: '/api/auth/logout' + fullPath: '/api/auth/logout' + preLoaderRoute: typeof ApiAuthLogoutRouteImport + parentRoute: typeof rootRouteImport + } + '/api/auth/github': { + id: '/api/auth/github' + path: '/api/auth/github' + fullPath: '/api/auth/github' + preLoaderRoute: typeof ApiAuthGithubRouteImport + parentRoute: typeof rootRouteImport + } + '/api/auth/callback': { + id: '/api/auth/callback' + path: '/api/auth/callback' + fullPath: '/api/auth/callback' + preLoaderRoute: typeof ApiAuthCallbackRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -116,6 +189,9 @@ const RecordsRouteRouteWithChildren = RecordsRouteRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, RecordsRouteRoute: RecordsRouteRouteWithChildren, + ApiAuthCallbackRoute: ApiAuthCallbackRoute, + ApiAuthGithubRoute: ApiAuthGithubRoute, + ApiAuthLogoutRoute: ApiAuthLogoutRoute, ApiTrpcSplatRoute: ApiTrpcSplatRoute, } export const routeTree = rootRouteImport diff --git a/src/app/routes/-app-components/app-layout.tsx b/src/app/routes/-app-components/app-layout.tsx index 4ca3835e..f1607a88 100644 --- a/src/app/routes/-app-components/app-layout.tsx +++ b/src/app/routes/-app-components/app-layout.tsx @@ -1,6 +1,7 @@ import { Link, type LinkComponentProps } from '@tanstack/react-router'; import { MoonIcon, MountainSnowIcon, SunIcon } from 'lucide-react'; import { type ReactNode } from 'react'; +import { AuthButton } from '@/components/auth-button'; import { Button } from '@/components/button'; import { KeyboardShortcutsHelp } from '@/components/keyboard-shortcuts-help'; import { Separator } from '@/components/separator'; @@ -47,6 +48,7 @@ export const AppLayout = ({ children, currentTheme, onThemeChange }: AppLayoutPr
  • + - + {isAdmin && ( + + )}
    @@ -419,27 +423,29 @@ export const RecordsGrid = () => {
    -
    - - - - All - - - Yes - - - No - - -
    + {isAdmin && ( +
    + + + + All + + + Yes + + + No + + +
    + )}
    {
    ), [ + isAdmin, reset, setFilters, types, diff --git a/src/app/routes/records/-components/relations.tsx b/src/app/routes/records/-components/relations.tsx index 5d67cef9..8f63d7e7 100644 --- a/src/app/routes/records/-components/relations.tsx +++ b/src/app/routes/records/-components/relations.tsx @@ -28,9 +28,10 @@ const PREDICATE_TYPE_ORDER = exhaustive()([ interface RelationsListProps { id: DbId; + isAdmin: boolean; } -export const RelationsList = ({ id }: RelationsListProps) => { +export const RelationsList = ({ id, isAdmin }: RelationsListProps) => { const { data: recordLinks } = useRecordLinks(id); const predicates = usePredicateMap(); const mergeRecordsMutation = useMergeRecords(); @@ -89,43 +90,45 @@ export const RelationsList = ({ id }: RelationsListProps) => {

    Relations ({totalLinks})

    - - Add - - } - buttonProps={{ - ref: addRelationshipButtonRef, - size: 'sm', - variant: 'outline', - className: 'h-[1.5lh]', - }} - buildActions={({ sourceId, targetId }) => { - return [ - { - key: 'merge-records', - label: ( - <> - Merge - - ), - onSelect: () => { - void navigate({ - to: '/records/$recordId', - params: { recordId: targetId }, - state: { focusForm: true }, - }); - mergeRecordsMutation.mutate({ - sourceId, - targetId, - }); + {isAdmin && ( + + Add + + } + buttonProps={{ + ref: addRelationshipButtonRef, + size: 'sm', + variant: 'outline', + className: 'h-[1.5lh]', + }} + buildActions={({ sourceId, targetId }) => { + return [ + { + key: 'merge-records', + label: ( + <> + Merge + + ), + onSelect: () => { + void navigate({ + to: '/records/$recordId', + params: { recordId: targetId }, + state: { focusForm: true }, + }); + mergeRecordsMutation.mutate({ + sourceId, + targetId, + }); + }, }, - }, - ]; - }} - /> + ]; + }} + /> + )} {outgoingLinks.length > 0 && ( <> @@ -138,49 +141,55 @@ export const RelationsList = ({ id }: RelationsListProps) => { key={`${link.sourceId}-${link.targetId}-${link.predicate}`} className="flex items-center gap-2" > - { - return [ - { - key: 'merge-records', - label: ( - <> - Merge - - ), - onSelect: () => { - void navigate({ - to: '/records/$recordId', - params: { recordId: targetId }, - state: { focusForm: true }, - }); - mergeRecordsMutation.mutate({ - sourceId, - targetId, - }); + {isAdmin ? ( + { + return [ + { + key: 'merge-records', + label: ( + <> + Merge + + ), + onSelect: () => { + void navigate({ + to: '/records/$recordId', + params: { recordId: targetId }, + state: { focusForm: true }, + }); + mergeRecordsMutation.mutate({ + sourceId, + targetId, + }); + }, }, - }, - { - key: 'delete-link', - label: ( - <> - Delete - - ), - onSelect: () => { - deleteLinkMutation.mutate([link.id]); + { + key: 'delete-link', + label: ( + <> + Delete + + ), + onSelect: () => { + deleteLinkMutation.mutate([link.id]); + }, }, - }, - ]; - }} - /> + ]; + }} + /> + ) : ( + + {predicates[link.predicate]?.name ?? 'Unknown'} + + )} { Incoming
      - {incomingLinks.map((link) => ( -
    • - { - const inv = predicates[link.predicate]?.inverseSlug; - return inv - ? (PREDICATES[inv as keyof typeof PREDICATES]?.name ?? 'Unknown') - : (predicates[link.predicate]?.name ?? 'Unknown'); - })()} - sourceId={link.targetId} - initialTargetId={link.sourceId} - incoming - link={link} - buttonProps={{ - className: 'w-30', - }} - buildActions={() => { - return [ - { - key: 'merge-records', - label: ( - <> - Merge - - ), - onSelect: () => { - void navigate({ - to: '/records/$recordId', - params: { recordId: link.sourceId }, - state: { focusForm: true }, - }); - mergeRecordsMutation.mutate({ - sourceId: link.targetId, - targetId: link.sourceId, - }); - }, - }, - { - key: 'delete-link', - label: ( - <> - Delete - - ), - onSelect: () => { - deleteLinkMutation.mutate([link.id]); - }, - }, - ]; - }} - /> - -
    • - ))} + {incomingLinks.map((link) => { + const incomingLabel = (() => { + const inv = predicates[link.predicate]?.inverseSlug; + return inv + ? (PREDICATES[inv as keyof typeof PREDICATES]?.name ?? 'Unknown') + : (predicates[link.predicate]?.name ?? 'Unknown'); + })(); + return ( +
    • + {isAdmin ? ( + { + return [ + { + key: 'merge-records', + label: ( + <> + Merge + + ), + onSelect: () => { + void navigate({ + to: '/records/$recordId', + params: { recordId: link.sourceId }, + state: { focusForm: true }, + }); + mergeRecordsMutation.mutate({ + sourceId: link.targetId, + targetId: link.sourceId, + }); + }, + }, + { + key: 'delete-link', + label: ( + <> + Delete + + ), + onSelect: () => { + deleteLinkMutation.mutate([link.id]); + }, + }, + ]; + }} + /> + ) : ( + + {incomingLabel} + + )} + +
    • + ); + })}
    )} @@ -274,7 +292,7 @@ export const RelationsList = ({ id }: RelationsListProps) => { ); }; -export const SimilarRecords = ({ id }: { id: DbId }) => { +export const SimilarRecords = ({ id, isAdmin }: { id: DbId; isAdmin: boolean }) => { const navigate = useNavigate(); const mergeRecordsMutation = useMergeRecords(); @@ -302,40 +320,46 @@ export const SimilarRecords = ({ id }: { id: DbId }) => {
      {similarRecords.map((record) => (
    • - { - return [ - { - key: 'merge-records', - label: ( - <> - Merge - - ), - onSelect: () => { - void navigate({ - to: '/records/$recordId', - params: { recordId: targetId }, - state: { focusForm: true }, - }); - mergeRecordsMutation.mutate({ - sourceId, - targetId, - }); + {isAdmin ? ( + { + return [ + { + key: 'merge-records', + label: ( + <> + Merge + + ), + onSelect: () => { + void navigate({ + to: '/records/$recordId', + params: { recordId: targetId }, + state: { focusForm: true }, + }); + mergeRecordsMutation.mutate({ + sourceId, + targetId, + }); + }, }, - }, - ]; - }} - /> + ]; + }} + /> + ) : ( + + {Math.round(record.similarity * 100)}% + + )} p.type === 'creation' || p.type === 'containment') .map((p) => p.slug); -function createRecordLoader() { +function createRecordLoader(isAdmin: boolean) { return new DataLoader(async (ids) => { const rows = await db.query.records.findMany({ where: { id: { in: ids as number[], }, + ...(isAdmin ? {} : { isPrivate: false }), }, columns: { textEmbedding: false, @@ -69,12 +72,34 @@ function createRecordLoader() { * * @see https://trpc.io/docs/server/context */ -export const createTRPCContext = (opts: { headers: Headers }) => { +export const createTRPCContext = (opts: { headers: Headers; isAdmin?: boolean }) => { + const clientIp = + opts.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? + opts.headers.get('x-real-ip') ?? + 'unknown'; + + // When isAdmin explicitly passed (e.g. CLI) → use that. + // When auth env vars missing → auth disabled → treat as admin. + // Otherwise → parse session cookie. + const isAdmin = + opts.isAdmin ?? + (() => { + // In development, allow simulating non-admin via cookie + if (process.env.NODE_ENV === 'development') { + if (getCookie(opts.headers.get('cookie'), 'rcr_dev_role') === 'public') return false; + } + if (!isAuthConfigured()) return true; + const userId = parseSessionCookie(opts.headers.get('cookie')); + return userId !== null && isAdminUser(userId); + })(); + return { ...opts, db, + clientIp, + isAdmin, loaders: { - record: createRecordLoader(), + record: createRecordLoader(isAdmin), }, }; }; @@ -206,3 +231,50 @@ const timingMiddleware = t.middleware(async ({ next, path }) => { * are logged in. */ export const publicProcedure = t.procedure.use(timingMiddleware); + +/** + * Rate-limited procedure for expensive operations (OpenAI calls, uploads, bulk mutations). + * Default: 20 requests per 60s per IP+path. Override per-procedure via createRateLimitedProcedure. + */ +const defaultLimiter = createRateLimiter({ windowMs: 60_000, maxRequests: 100 }); + +const rateLimitMiddleware = t.middleware(async ({ ctx, next, path }) => { + defaultLimiter(`${ctx.clientIp}:${path}`); + return next(); +}); + +export const rateLimitedProcedure = t.procedure.use(timingMiddleware).use(rateLimitMiddleware); + +/** + * Create a rate-limited procedure with custom limits. + */ +export function createRateLimitedProcedure(opts: { windowMs: number; maxRequests: number }) { + const limiter = createRateLimiter(opts); + return t.procedure.use(timingMiddleware).use( + t.middleware(async ({ ctx, next, path }) => { + limiter(`${ctx.clientIp}:${path}`); + return next(); + }) + ); +} + +/** + * Admin-only procedure — throws UNAUTHORIZED if the request is not from an admin session. + */ +const authMiddleware = t.middleware(({ ctx, next }) => { + if (!ctx.isAdmin) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Admin access required' }); + } + return next(); +}); + +export const adminProcedure = publicProcedure.use(authMiddleware); + +export const adminRateLimitedProcedure = rateLimitedProcedure.use(authMiddleware); + +/** + * Create an admin-only rate-limited procedure with custom limits. + */ +export function createAdminRateLimitedProcedure(opts: { windowMs: number; maxRequests: number }) { + return createRateLimitedProcedure(opts).use(authMiddleware); +} diff --git a/src/server/api/routers/admin.ts b/src/server/api/routers/admin.ts index 2e7ffae6..c3ac5855 100644 --- a/src/server/api/routers/admin.ts +++ b/src/server/api/routers/admin.ts @@ -1,10 +1,22 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { createEmbedding } from '@/lib/server/create-embedding'; -import { createTRPCRouter, publicProcedure } from '../init'; +import { isAuthConfigured } from '@/server/lib/auth'; +import { + adminProcedure, + createAdminRateLimitedProcedure, + createTRPCRouter, + publicProcedure, +} from '../init'; + +const embeddingProcedure = createAdminRateLimitedProcedure({ windowMs: 60_000, maxRequests: 200 }); export const adminRouter = createTRPCRouter({ - createEmbedding: publicProcedure.input(z.string()).mutation(async ({ input }) => { + session: publicProcedure.query(({ ctx }) => ({ + isAdmin: ctx.isAdmin, + authEnabled: isAuthConfigured(), + })), + createEmbedding: embeddingProcedure.input(z.string()).mutation(async ({ input }) => { try { const embedding = await createEmbedding(input); @@ -21,7 +33,7 @@ export const adminRouter = createTRPCRouter({ }); } }), - testError: publicProcedure.mutation(() => { + testError: adminProcedure.mutation(() => { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Test error', diff --git a/src/server/api/routers/browsing.ts b/src/server/api/routers/browsing.ts index 67292ee6..38039f43 100644 --- a/src/server/api/routers/browsing.ts +++ b/src/server/api/routers/browsing.ts @@ -2,7 +2,7 @@ import { browsingHistoryOmitList, BrowsingHistoryOmitListInsertSchema } from '@h import { inArray } from 'drizzle-orm'; import { z } from 'zod'; import { DateSchema } from '@/shared/types/api'; -import { createTRPCRouter, publicProcedure } from '../init'; +import { adminProcedure, createTRPCRouter } from '../init'; const OverviewSchema = z.object({ date: z.string(), @@ -283,7 +283,7 @@ function createEmptySummary(date: string): DailySummary { } export const browsingRouter = createTRPCRouter({ - dailySummary: publicProcedure + dailySummary: adminProcedure .input(z.object({ date: DateSchema })) .query(async ({ ctx: { db }, input: { date } }): Promise => { const startOfDay = new Date(`${date}T00:00:00`); @@ -325,7 +325,7 @@ export const browsingRouter = createTRPCRouter({ /** * List all URL patterns in the browsing history omit list */ - listOmitPatterns: publicProcedure.query(async ({ ctx: { db } }): Promise => { + listOmitPatterns: adminProcedure.query(async ({ ctx: { db } }): Promise => { const rows = await db.query.browsingHistoryOmitList.findMany({ columns: { pattern: true }, orderBy: { pattern: 'asc' }, @@ -338,7 +338,7 @@ export const browsingRouter = createTRPCRouter({ * * Patterns use SQL LIKE syntax (% for wildcard, _ for single character) */ - upsertOmitPattern: publicProcedure + upsertOmitPattern: adminProcedure .input(BrowsingHistoryOmitListInsertSchema) .mutation(async ({ ctx: { db }, input }) => { const now = new Date(); @@ -356,7 +356,7 @@ export const browsingRouter = createTRPCRouter({ /** * Delete patterns from the browsing history omit list */ - deleteOmitPatterns: publicProcedure + deleteOmitPatterns: adminProcedure .input(z.array(z.string().min(1))) .mutation(async ({ ctx: { db }, input }) => { if (input.length === 0) { diff --git a/src/server/api/routers/github.ts b/src/server/api/routers/github.ts index 16ff0449..ac9fe5a5 100644 --- a/src/server/api/routers/github.ts +++ b/src/server/api/routers/github.ts @@ -2,7 +2,7 @@ import type { GithubCommitSelect } from '@hozo'; import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { DateSchema } from '@/shared/types/api'; -import { createTRPCRouter, publicProcedure } from '../init'; +import { adminProcedure, createTRPCRouter } from '../init'; const CommitSummarySchema = z.object({ id: z.string(), @@ -48,7 +48,7 @@ export const githubRouter = createTRPCRouter({ /** * Get daily commit summary */ - dailySummary: publicProcedure + dailySummary: adminProcedure .input(z.object({ date: DateSchema })) .query(async ({ ctx: { db }, input: { date } }): Promise => { const startOfDay = new Date(`${date}T00:00:00`); @@ -82,7 +82,7 @@ export const githubRouter = createTRPCRouter({ /** * Get a single commit by ID with full details including file changes */ - getCommit: publicProcedure + getCommit: adminProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx: { db }, input: { id } }) => { const commit = await db.query.githubCommits.findFirst({ diff --git a/src/server/api/routers/links.ts b/src/server/api/routers/links.ts index 054deafb..ce278770 100644 --- a/src/server/api/routers/links.ts +++ b/src/server/api/routers/links.ts @@ -12,7 +12,7 @@ import { eq, inArray } from 'drizzle-orm'; import { z } from 'zod'; import { IdSchema, type DbId } from '@/shared/types/api'; import type { RecordLinks, RecordLinksMap } from '@/shared/types/domain'; -import { createTRPCRouter, publicProcedure } from '../init'; +import { adminProcedure, createTRPCRouter, publicProcedure } from '../init'; export const linksRouter = createTRPCRouter({ /** @@ -139,7 +139,7 @@ export const linksRouter = createTRPCRouter({ * * Used by: Relationship creation/editing, record linking interfaces */ - upsert: publicProcedure.input(LinkInsertSchema).mutation(async ({ ctx: { db }, input }) => { + upsert: adminProcedure.input(LinkInsertSchema).mutation(async ({ ctx: { db }, input }) => { /* 1 ─ lookup predicate from const */ const predicateDef = PREDICATES[input.predicate]; @@ -226,7 +226,7 @@ export const linksRouter = createTRPCRouter({ * * Used by: Relationship management, bulk operations, cleanup processes */ - delete: publicProcedure.input(z.array(IdSchema)).mutation(async ({ ctx: { db }, input }) => { + delete: adminProcedure.input(z.array(IdSchema)).mutation(async ({ ctx: { db }, input }) => { if (input.length === 0) { return []; // Return empty array if input is empty } diff --git a/src/server/api/routers/media.ts b/src/server/api/routers/media.ts index 3837f637..592d51b1 100644 --- a/src/server/api/routers/media.ts +++ b/src/server/api/routers/media.ts @@ -13,7 +13,16 @@ import { getMediaInsertData, uploadClientFileToR2, uploadMediaToR2 } from '@/ser import { embedRecordById } from '@/server/services/embed-records'; import { generateAltText } from '@/server/services/generate-alt-text'; import { IdSchema, LimitSchema, OffsetSchema } from '@/shared/types/api'; -import { createTRPCRouter, publicProcedure } from '../init'; +import { + adminProcedure, + adminRateLimitedProcedure, + createAdminRateLimitedProcedure, + createTRPCRouter, + publicProcedure, +} from '../init'; + +const deleteProcedure = createAdminRateLimitedProcedure({ windowMs: 60_000, maxRequests: 100 }); +const altTextProcedure = createAdminRateLimitedProcedure({ windowMs: 60_000, maxRequests: 200 }); // Schema for media create input const MediaCreateFileInputSchema = z.object({ @@ -58,40 +67,44 @@ export const mediaRouter = createTRPCRouter({ /** * List media items with optional filters */ - list: publicProcedure.input(MediaListInputSchema).query(async ({ ctx: { db }, input }) => { - const { type, hasAltText, recordId, limit, offset, orderBy } = input; + list: publicProcedure + .input(MediaListInputSchema) + .query(async ({ ctx: { db, isAdmin }, input }) => { + const { type, hasAltText, recordId, limit, offset, orderBy } = input; - const results = await db.query.media.findMany({ - where: { - type, - recordId, - altText: - hasAltText === true - ? { isNotNull: true } - : hasAltText === false - ? { isNull: true } - : undefined, - }, - limit, - offset, - orderBy: (media, { asc, desc }) => - orderBy.map(({ field, direction }) => - direction === 'asc' ? asc(media[field]) : desc(media[field]) - ), - }); + const results = await db.query.media.findMany({ + where: { + type, + recordId, + altText: + hasAltText === true + ? { isNotNull: true } + : hasAltText === false + ? { isNull: true } + : undefined, + ...(isAdmin ? {} : { record: { isPrivate: false } }), + }, + limit, + offset, + orderBy: (media, { asc, desc }) => + orderBy.map(({ field, direction }) => + direction === 'asc' ? asc(media[field]) : desc(media[field]) + ), + }); - return results; - }), + return results; + }), /** * Get a single media item by ID, optionally including parent record context */ get: publicProcedure .input(z.object({ id: IdSchema, includeRecord: z.boolean().optional().default(false) })) - .query(async ({ ctx: { db }, input }) => { + .query(async ({ ctx: { db, isAdmin }, input }) => { const mediaItem = await db.query.media.findFirst({ where: { id: input.id, + ...(isAdmin ? {} : { record: { isPrivate: false } }), }, with: input.includeRecord ? { @@ -118,7 +131,7 @@ export const mediaRouter = createTRPCRouter({ /** * Update media metadata (primarily for alt text) */ - update: publicProcedure.input(MediaUpdateInputSchema).mutation(async ({ ctx: { db }, input }) => { + update: adminProcedure.input(MediaUpdateInputSchema).mutation(async ({ ctx: { db }, input }) => { const { id, altText } = input; // Fetch existing media with recordId for embedding regeneration @@ -147,64 +160,66 @@ export const mediaRouter = createTRPCRouter({ return updated; }), - create: publicProcedure.input(MediaCreateInputSchema).mutation(async ({ ctx: { db }, input }) => { - try { - const r2Url = - 'fileData' in input - ? await uploadClientFileToR2( - Buffer.from(input.fileData, 'base64'), - input.fileType, - input.fileName - ) - : await uploadMediaToR2(input.url); - - // 3. Get metadata for the uploaded file using its R2 URL - const mediaInsertData = await getMediaInsertData(r2Url, { - recordId: input.recordId, - }); - - if (!mediaInsertData) { - console.error('Failed to get media metadata after upload.'); - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Failed to get media metadata after upload.', + create: adminRateLimitedProcedure + .input(MediaCreateInputSchema) + .mutation(async ({ ctx: { db }, input }) => { + try { + const r2Url = + 'fileData' in input + ? await uploadClientFileToR2( + Buffer.from(input.fileData, 'base64'), + input.fileType, + input.fileName + ) + : await uploadMediaToR2(input.url); + + // 3. Get metadata for the uploaded file using its R2 URL + const mediaInsertData = await getMediaInsertData(r2Url, { + recordId: input.recordId, }); - } - // 4. Insert media record into the database - const [newMedia] = await db.insert(media).values(mediaInsertData).returning(); - - if (!newMedia) { - console.error('Failed to insert media record into database.'); + if (!mediaInsertData) { + console.error('Failed to get media metadata after upload.'); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to get media metadata after upload.', + }); + } + + // 4. Insert media record into the database + const [newMedia] = await db.insert(media).values(mediaInsertData).returning(); + + if (!newMedia) { + console.error('Failed to insert media record into database.'); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to insert media record into database.', + }); + } + + // 5. Fire-and-forget alt text generation for images + // Don't await - this is best-effort and should never slow down or fail uploads + if (newMedia.type === 'image') { + generateAltText([newMedia.id]).catch((error) => { + console.warn(`Failed to generate alt text for media ${newMedia.id}:`, error); + }); + } + + return newMedia; + } catch (error) { + // Log the specific error before wrapping it + console.error('Caught error during media creation:', error); + if (error instanceof TRPCError) { + throw error; + } throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', - message: 'Failed to insert media record into database.', + message: `Failed to create media: ${error instanceof Error ? error.message : 'Unknown error'}`, }); } + }), - // 5. Fire-and-forget alt text generation for images - // Don't await - this is best-effort and should never slow down or fail uploads - if (newMedia.type === 'image') { - generateAltText([newMedia.id]).catch((error) => { - console.warn(`Failed to generate alt text for media ${newMedia.id}:`, error); - }); - } - - return newMedia; - } catch (error) { - // Log the specific error before wrapping it - console.error('Caught error during media creation:', error); - if (error instanceof TRPCError) { - throw error; - } - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: `Failed to create media: ${error instanceof Error ? error.message : 'Unknown error'}`, - }); - } - }), - - delete: publicProcedure.input(z.array(IdSchema)).mutation(async ({ ctx: { db }, input }) => { + delete: deleteProcedure.input(z.array(IdSchema)).mutation(async ({ ctx: { db }, input }) => { const mediaToDelete = await db.query.media.findMany({ where: { id: { in: input }, @@ -275,7 +290,7 @@ export const mediaRouter = createTRPCRouter({ /** * Generate alt text for media items using OpenAI vision */ - generateAltText: publicProcedure + generateAltText: altTextProcedure .input( z.object({ ids: z.array(IdSchema), diff --git a/src/server/api/routers/records/delete.ts b/src/server/api/routers/records/delete.ts index 939ec823..2ba94050 100644 --- a/src/server/api/routers/records/delete.ts +++ b/src/server/api/routers/records/delete.ts @@ -24,9 +24,9 @@ import { TRPCError } from '@trpc/server'; import { inArray } from 'drizzle-orm'; import { z } from 'zod'; import { IdSchema } from '@/shared/types/api'; -import { publicProcedure } from '../../init'; +import { adminProcedure } from '../../init'; -export const deleteRecords = publicProcedure +export const deleteRecords = adminProcedure .input(z.array(IdSchema)) .mutation(async ({ ctx: { db }, input }): Promise => { const recordsToDelete = await db.query.records.findMany({ diff --git a/src/server/api/routers/records/edit.ts b/src/server/api/routers/records/edit.ts index 7b067303..39f5fa80 100644 --- a/src/server/api/routers/records/edit.ts +++ b/src/server/api/routers/records/edit.ts @@ -4,7 +4,7 @@ import { inArray } from 'drizzle-orm'; import { z } from 'zod'; import { IdSchema, type DbId } from '@/shared/types/api'; import type { RecordGet } from '@/shared/types/domain'; -import { publicProcedure } from '../../init'; +import { adminProcedure } from '../../init'; // Schema for bulk update data - omit fields that shouldn't be bulk-updated const BulkUpdateDataSchema = RecordInsertSchema.omit({ @@ -14,7 +14,7 @@ const BulkUpdateDataSchema = RecordInsertSchema.omit({ textEmbedding: true, }).partial(); -export const upsert = publicProcedure +export const upsert = adminProcedure .input(RecordInsertSchema) .mutation(async ({ ctx: { db, loaders }, input }): Promise => { const [result] = await db @@ -50,7 +50,7 @@ export const upsert = publicProcedure return record; }); -export const bulkUpdate = publicProcedure +export const bulkUpdate = adminProcedure .input( z.object({ ids: z.array(IdSchema).min(1), diff --git a/src/server/api/routers/records/embed.ts b/src/server/api/routers/records/embed.ts index 42658909..f4ab6566 100644 --- a/src/server/api/routers/records/embed.ts +++ b/src/server/api/routers/records/embed.ts @@ -1,9 +1,9 @@ import { TRPCError } from '@trpc/server'; import { embedRecordById } from '@/server/services/embed-records'; import { IdParamSchema } from '@/shared/types/api'; -import { publicProcedure } from '../../init'; +import { adminProcedure } from '../../init'; -export const embed = publicProcedure +export const embed = adminProcedure .input(IdParamSchema) .mutation(async ({ ctx: { db }, input: { id } }) => { const result = await embedRecordById(id); diff --git a/src/server/api/routers/records/favicon.ts b/src/server/api/routers/records/favicon.ts index 4b65381b..8ae83b5d 100644 --- a/src/server/api/routers/records/favicon.ts +++ b/src/server/api/routers/records/favicon.ts @@ -1,8 +1,9 @@ import { z } from 'zod'; import { uploadMediaToR2 } from '@/server/lib/media'; -import { publicProcedure } from '../../init'; +import { assertPublicUrl } from '@/server/lib/url-utils'; +import { adminProcedure } from '../../init'; -export const fetchFavicon = publicProcedure +export const fetchFavicon = adminProcedure .input( z.object({ url: z.url(), @@ -10,6 +11,7 @@ export const fetchFavicon = publicProcedure }) ) .mutation(async ({ input }) => { + assertPublicUrl(input.url); const domain = new URL(input.url).hostname; const faviconUrl = `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=${input.size}`; const r2Url = await uploadMediaToR2(faviconUrl); diff --git a/src/server/api/routers/records/list.ts b/src/server/api/routers/records/list.ts index 2fbea6c3..eec12da4 100644 --- a/src/server/api/routers/records/list.ts +++ b/src/server/api/routers/records/list.ts @@ -24,7 +24,7 @@ function buildTitleFilter( export const list = publicProcedure .input(ListRecordsInputSchema) - .query(async ({ ctx: { db }, input }): Promise => { + .query(async ({ ctx: { db, isAdmin }, input }): Promise => { const { filters: { types, @@ -35,7 +35,7 @@ export const list = publicProcedure hasTitle, minRating, maxRating, - isPrivate, + isPrivate: isPrivateFilter, isCurated, hasReminder, hasEmbedding, @@ -80,7 +80,7 @@ export const list = publicProcedure ilike: `%${domain}%`, } : undefined, - isPrivate, + isPrivate: isAdmin ? isPrivateFilter : false, isCurated, ...(hasParent === true ? { diff --git a/src/server/api/routers/records/merge.ts b/src/server/api/routers/records/merge.ts index 9c03a84b..6f3013e5 100644 --- a/src/server/api/routers/records/merge.ts +++ b/src/server/api/routers/records/merge.ts @@ -24,7 +24,7 @@ import { eq, getTableName, inArray } from 'drizzle-orm'; import { z } from 'zod'; import { mergeRecords } from '@/shared/lib/merge-records'; import type { DbId } from '@/shared/types/api'; -import { publicProcedure } from '../../init'; +import { adminProcedure } from '../../init'; /** Integration tables whose `recordId` may be reassigned during a merge. */ export const integrationTableMap = { @@ -59,7 +59,7 @@ export type MergeSnapshot = { }>; }; -export const merge = publicProcedure +export const merge = adminProcedure .input( z.object({ sourceId: z.number().int().positive(), diff --git a/src/server/api/routers/records/tree.ts b/src/server/api/routers/records/tree.ts index 5c240f8c..95c3e046 100644 --- a/src/server/api/routers/records/tree.ts +++ b/src/server/api/routers/records/tree.ts @@ -5,10 +5,11 @@ import { publicProcedure } from '../../init'; export const getFamilyTree = publicProcedure .input(IdParamSchema) - .query(async ({ ctx: { db }, input: { id } }) => { + .query(async ({ ctx: { db, isAdmin }, input: { id } }) => { const family = await db.query.records.findFirst({ where: { id, + ...(isAdmin ? {} : { isPrivate: false }), }, columns: { id: true, diff --git a/src/server/api/routers/records/undo-merge.ts b/src/server/api/routers/records/undo-merge.ts index b6b0684d..93456eef 100644 --- a/src/server/api/routers/records/undo-merge.ts +++ b/src/server/api/routers/records/undo-merge.ts @@ -9,7 +9,7 @@ import { import { TRPCError } from '@trpc/server'; import { eq, inArray } from 'drizzle-orm'; import { z } from 'zod'; -import { publicProcedure } from '../../init'; +import { adminProcedure } from '../../init'; import { type IntegrationTableName, integrationTableMap } from './merge'; const IntegrationTableNameSchema = z.enum( @@ -40,7 +40,7 @@ const MergeSnapshotSchema = z.object({ ), }); -export const undoMerge = publicProcedure +export const undoMerge = adminProcedure .input(z.object({ snapshot: MergeSnapshotSchema })) .mutation(async ({ ctx: { db }, input: { snapshot } }) => { const { sourceRecord, targetRecord } = snapshot; diff --git a/src/server/api/routers/search.ts b/src/server/api/routers/search.ts index d84f8133..5e4b9399 100644 --- a/src/server/api/routers/search.ts +++ b/src/server/api/routers/search.ts @@ -12,7 +12,12 @@ import { createEmbedding } from '@/lib/server/create-embedding'; import { similarity, SIMILARITY_THRESHOLD } from '@/server/lib/constants'; import { seriateRecordsByEmbedding } from '@/server/lib/seriation'; import { IdSchema, SearchRecordsInputSchema } from '@/shared/types/api'; -import { createTRPCRouter, publicProcedure } from '../init'; +import { createAdminRateLimitedProcedure, createTRPCRouter, publicProcedure } from '../init'; + +const vectorSearchProcedure = createAdminRateLimitedProcedure({ + windowMs: 60_000, + maxRequests: 60, +}); /** Predicate slugs for relevant link types in search results */ const searchLinkPredicates = Object.values(PREDICATES) @@ -66,7 +71,7 @@ export type SearchResult = { export const searchRouter = createTRPCRouter({ byTextQuery: publicProcedure .input(SearchRecordsInputSchema) - .query(({ ctx: { db }, input }): Promise => { + .query(({ ctx: { db, isAdmin }, input }): Promise => { const { query, filters: { recordType }, @@ -82,6 +87,7 @@ export const searchRouter = createTRPCRouter({ ${records.abbreviation} <-> ${query} < ${SIMILARITY_THRESHOLD} )`, type: recordType, + ...(isAdmin ? {} : { isPrivate: false }), }, limit, orderBy: (records, { desc, sql }) => [ @@ -149,7 +155,7 @@ export const searchRouter = createTRPCRouter({ }); }), - byVector: publicProcedure + byVector: vectorSearchProcedure .input( z.object({ query: z.string(), diff --git a/src/server/cli/rcr/lib/caller.ts b/src/server/cli/rcr/lib/caller.ts index cd9113c9..93838601 100644 --- a/src/server/cli/rcr/lib/caller.ts +++ b/src/server/cli/rcr/lib/caller.ts @@ -22,6 +22,7 @@ export function createCLICaller() { // Create a minimal context for CLI usage (no real HTTP headers needed) const ctx = createTRPCContext({ headers: new Headers(), + isAdmin: true, }); return createCaller(ctx); diff --git a/src/server/lib/auth.ts b/src/server/lib/auth.ts new file mode 100644 index 00000000..094bcd5e --- /dev/null +++ b/src/server/lib/auth.ts @@ -0,0 +1,88 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +const COOKIE_NAME = 'rcr_session'; +const MAX_AGE_SECONDS = 30 * 24 * 60 * 60; // 30 days + +function getSecret(): string | undefined { + return process.env.SESSION_SECRET; +} + +function sign(payload: string, secret: string): string { + return createHmac('sha256', secret).update(payload).digest('hex'); +} + +/** Build a signed session cookie value: `userId.iat.exp.signature` */ +export function createSessionCookie(githubUserId: string): string { + const secret = getSecret(); + if (!secret) throw new Error('SESSION_SECRET is not configured'); + + const iat = Math.floor(Date.now() / 1000); + const exp = iat + MAX_AGE_SECONDS; + const payload = `${githubUserId}.${iat}.${exp}`; + const sig = sign(payload, secret); + return `${payload}.${sig}`; +} + +/** Parse and validate the `rcr_session` cookie. Returns GitHub user ID or null. */ +export function parseSessionCookie(cookieHeader: string | null): string | null { + if (!cookieHeader) return null; + + const secret = getSecret(); + if (!secret) return null; + + const match = cookieHeader + .split(';') + .map((c) => c.trim()) + .find((c) => c.startsWith(`${COOKIE_NAME}=`)); + if (!match) return null; + + const value = match.slice(COOKIE_NAME.length + 1); + const parts = value.split('.'); + if (parts.length !== 4) return null; + + const [userId, iatStr, expStr, sig] = parts as [string, string, string, string]; + const payload = `${userId}.${iatStr}.${expStr}`; + + // Timing-safe signature comparison + const expected = sign(payload, secret); + const sigBuf = Buffer.from(sig); + const expectedBuf = Buffer.from(expected); + if (sigBuf.length !== expectedBuf.length || !timingSafeEqual(sigBuf, expectedBuf)) return null; + + // Check expiry + const exp = Number(expStr); + if (Number.isNaN(exp) || Math.floor(Date.now() / 1000) > exp) return null; + + return userId; +} + +export function isAdminUser(githubUserId: string): boolean { + return process.env.ADMIN_GITHUB_ID === githubUserId; +} + +/** Whether auth is fully configured (all required env vars present). */ +export function isAuthConfigured(): boolean { + return !!( + process.env.SESSION_SECRET && + process.env.ADMIN_GITHUB_ID && + process.env.GITHUB_CLIENT_ID && + process.env.GITHUB_CLIENT_SECRET + ); +} + +const isDev = process.env.NODE_ENV !== 'production'; + +export function sessionSetCookieHeader(value: string): string { + return `${COOKIE_NAME}=${value}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${MAX_AGE_SECONDS}${isDev ? '' : '; Secure'}`; +} + +export function sessionClearCookieHeader(): string { + return `${COOKIE_NAME}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0${isDev ? '' : '; Secure'}`; +} + +/** Read a single cookie value by name from a Cookie header string. */ +export function getCookie(header: string | null, name: string): string | null { + if (!header) return null; + const match = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`)); + return match?.[1] ?? null; +} diff --git a/src/server/lib/media.ts b/src/server/lib/media.ts index 4296c8f9..1b5a5914 100644 --- a/src/server/lib/media.ts +++ b/src/server/lib/media.ts @@ -3,7 +3,7 @@ import { S3Client } from 'bun'; import mime from 'mime-types'; import type { MediaMetadata } from '@/shared/types/media'; import { getImageMetadata } from './image-metadata'; -import { validateAndFormatUrl } from './url-utils'; +import { assertPublicUrl, validateAndFormatUrl } from './url-utils'; /* --------------------------------------------------------------------------- * Constants & Defaults @@ -120,6 +120,8 @@ export async function getSmartMetadata(url: string): Promise { throw new Error(`Invalid URL: ${url}`); } + assertPublicUrl(validatedUrl); + // Start with a HEAD request to get basic info without downloading the entire file const headResponse = await fetch(validatedUrl, { method: 'HEAD' }); let mediaType: MediaType = DEFAULT_MEDIA_TYPE; @@ -187,6 +189,8 @@ export async function uploadMediaToR2(mediaUrl: string): Promise { return mediaUrl; } + assertPublicUrl(mediaUrl); + const startTime = Date.now(); console.log(`[Media Upload] Starting download: ${mediaUrl}`); diff --git a/src/server/lib/rate-limit.ts b/src/server/lib/rate-limit.ts new file mode 100644 index 00000000..046ef4ac --- /dev/null +++ b/src/server/lib/rate-limit.ts @@ -0,0 +1,45 @@ +import { TRPCError } from '@trpc/server'; + +interface RateLimiterOptions { + windowMs: number; + maxRequests: number; +} + +/** + * In-memory sliding-window rate limiter. + * Returns a `check(key)` function that throws TRPCError (TOO_MANY_REQUESTS) if limit exceeded. + */ +export function createRateLimiter({ windowMs, maxRequests }: RateLimiterOptions) { + const windows = new Map(); + + // Periodic cleanup to prevent memory leaks from stale keys + const cleanup = setInterval(() => { + const now = Date.now(); + for (const [key, timestamps] of windows) { + const filtered = timestamps.filter((t) => now - t < windowMs); + if (filtered.length === 0) { + windows.delete(key); + } else { + windows.set(key, filtered); + } + } + }, windowMs); + + // Don't block process exit + cleanup.unref(); + + return function check(key: string): void { + const now = Date.now(); + const timestamps = (windows.get(key) ?? []).filter((t) => now - t < windowMs); + + if (timestamps.length >= maxRequests) { + throw new TRPCError({ + code: 'TOO_MANY_REQUESTS', + message: `Rate limit exceeded. Try again in ${Math.ceil(windowMs / 1000)}s.`, + }); + } + + timestamps.push(now); + windows.set(key, timestamps); + }; +} diff --git a/src/server/lib/url-utils.ts b/src/server/lib/url-utils.ts index a190c4b8..da12b3dd 100644 --- a/src/server/lib/url-utils.ts +++ b/src/server/lib/url-utils.ts @@ -1,3 +1,4 @@ +import { TRPCError } from '@trpc/server'; import { z } from 'zod'; // More robust URL schema with custom error message @@ -5,6 +6,76 @@ const urlSchema = z.url({ error: 'Invalid URL format. Please provide a valid URL.', }); +/* --------------------------------------------------------------------------- + * SSRF Protection + * -------------------------------------------------------------------------*/ + +const BLOCKED_HOSTNAMES = new Set([ + 'localhost', + 'metadata.google.internal', + 'metadata.google', + 'kubernetes.default.svc', +]); + +/** Matches private/reserved IPv4 ranges and link-local */ +const PRIVATE_IP_PATTERNS = [ + /^127\./, // loopback + /^10\./, // RFC 1918 class A + /^172\.(1[6-9]|2\d|3[01])\./, // RFC 1918 class B + /^192\.168\./, // RFC 1918 class C + /^169\.254\./, // link-local / cloud metadata + /^0\./, // "this" network +]; + +const BLOCKED_IPV6 = new Set(['::1', '::', '::ffff:127.0.0.1']); + +/** + * Rejects URLs targeting internal/private network addresses. + * Call before any server-side fetch of user-supplied URLs. + */ +export function assertPublicUrl(url: string): void { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new TRPCError({ code: 'BAD_REQUEST', message: `Invalid URL: ${url}` }); + } + + // Only allow http(s) + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `URL protocol not allowed: ${parsed.protocol}`, + }); + } + + const hostname = parsed.hostname.toLowerCase(); + + // Block known-dangerous hostnames + if (BLOCKED_HOSTNAMES.has(hostname)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `URL hostname not allowed: ${hostname}`, + }); + } + + // Block private IPv4 ranges + if (PRIVATE_IP_PATTERNS.some((re) => re.test(hostname))) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `URL resolves to a private IP range: ${hostname}`, + }); + } + + // Block IPv6 loopback and mapped addresses (bracket-stripped by URL parser) + if (BLOCKED_IPV6.has(hostname) || hostname.startsWith('::ffff:')) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `URL resolves to a private IPv6 address: ${hostname}`, + }); + } +} + type UrlOptions = { skipHttps?: boolean; }; diff --git a/src/shared/lib/env.ts b/src/shared/lib/env.ts index 400eaa85..01a4be3f 100644 --- a/src/shared/lib/env.ts +++ b/src/shared/lib/env.ts @@ -26,6 +26,12 @@ export const ServerEnvSchema = z.object({ S3_BUCKET: z.string(), ASSETS_DOMAIN: z.string(), + // Authentication (all optional — when absent, auth is disabled and all requests are admin) + ADMIN_GITHUB_ID: z.string().optional(), + GITHUB_CLIENT_ID: z.string().optional(), + GITHUB_CLIENT_SECRET: z.string().optional(), + SESSION_SECRET: z.string().min(32).optional(), + // External Services AIRTABLE_BASE_ID: z.string(), AIRTABLE_ACCESS_TOKEN: z.string(),