Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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-..."

Expand Down
39 changes: 37 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**: `<your-url>/api/auth/callback`

3. Generate a session secret:

```bash
openssl rand -base64 32
```

4. Add to `.env`:
```
ADMIN_GITHUB_ID=<your-numeric-github-id>
GITHUB_CLIENT_ID=<oauth-app-client-id>
GITHUB_CLIENT_SECRET=<oauth-app-client-secret>
SESSION_SECRET=<random-32+-char-string>
```

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:
Expand Down Expand Up @@ -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
Expand Down
26 changes: 23 additions & 3 deletions server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,25 @@ async function initializeStaticRoutes(clientDirectory: string): Promise<PreloadR
return { routes, loaded, skipped };
}

const SECURITY_HEADERS: Record<string, string> = {
'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
*/
Expand Down Expand Up @@ -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 }));
}
},
},
Expand Down
51 changes: 51 additions & 0 deletions src/app/components/auth-button.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Button
variant="ghost"
size="sm"
onClick={() => {
document.cookie = isAdmin
? 'rcr_dev_role=public; path=/'
: 'rcr_dev_role=; path=/; max-age=0';
void utils.invalidate();
}}
className="gap-1.5 text-xs"
>
{isAdmin ? <ShieldIcon className="size-3.5" /> : <ShieldOffIcon className="size-3.5" />}
{isAdmin ? 'Admin' : 'Public'}
</Button>
);
}

if (!data?.authEnabled) return null;

if (data.isAdmin) {
return (
<form action="/api/auth/logout" method="POST">
<Button type="submit" variant="ghost" size="icon">
<LogOutIcon className="h-5 w-5" />
<span className="sr-only">Sign out</span>
</Button>
</form>
);
}

return (
<Button variant="ghost" size="icon" asChild>
<a href="/api/auth/github">
<LogInIcon className="h-5 w-5" />
<span className="sr-only">Sign in</span>
</a>
</Button>
);
}
12 changes: 10 additions & 2 deletions src/app/lib/hooks/use-record-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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;
Expand All @@ -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,
Expand Down
82 changes: 79 additions & 3 deletions src/app/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -34,37 +37,86 @@ 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 {
__root__: typeof rootRouteImport
'/': 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
}

Expand Down Expand Up @@ -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
}
}
}

Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/app/routes/-app-components/app-layout.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -47,6 +48,7 @@ export const AppLayout = ({ children, currentTheme, onThemeChange }: AppLayoutPr
<li className="flex items-center gap-2">
<SiteSearch />
<KeyboardShortcutsHelp />
<AuthButton />
<Button variant="ghost" onClick={toggleTheme} className="h-9 w-9 p-0">
{currentTheme === 'light' ? (
<SunIcon className="h-5 w-5" />
Expand Down
Loading