diff --git a/.gitignore b/.gitignore index b178b93..26101aa 100644 --- a/.gitignore +++ b/.gitignore @@ -45,8 +45,8 @@ next-env.d.ts __pycache__/ *.pyc -# RAG backend generated index (rebuilt on deploy) -rag-backend/index/ +# RAG backend FAISS index — auto-rebuilt on startup from committed docs +# rag-backend/index/ ← intentionally NOT ignored so index is committed diff --git a/app/api/rag/[...path]/route.ts b/app/api/rag/[...path]/route.ts new file mode 100644 index 0000000..e1a6659 --- /dev/null +++ b/app/api/rag/[...path]/route.ts @@ -0,0 +1,117 @@ +import { NextRequest, NextResponse } from "next/server"; +import { CookieOptions, createServerClient } from "@supabase/ssr"; +import { cookies } from "next/headers"; + +// Use a server-only env var (not NEXT_PUBLIC_*) to avoid leaking the internal +// backend URL into the client bundle. +const RAG_API_URL = process.env.RAG_API_URL || process.env.NEXT_PUBLIC_RAG_API_URL || "http://127.0.0.1:8000"; +const RAG_API_KEY = process.env.RAG_API_KEY || ""; + +export async function GET(request: NextRequest) { + return proxyRequest(request); +} + +export async function POST(request: NextRequest) { + return proxyRequest(request); +} + +export async function DELETE(request: NextRequest) { + return proxyRequest(request); +} + +async function proxyRequest(request: NextRequest) { + // 0. Fail fast if RAG_API_KEY is not configured + if (!RAG_API_KEY) { + console.error("[Proxy Error]: RAG_API_KEY environment variable is not set."); + return NextResponse.json( + { detail: "Server misconfiguration: backend API key not set." }, + { status: 500 } + ); + } + + // 1. Verify Authentication + const cookieStore = await cookies(); + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return cookieStore.getAll(); + }, + setAll(cookiesToSet: { name: string; value: string; options: CookieOptions }[]) { + try { + cookiesToSet.forEach(({ name, value, options }) => { + cookieStore.set(name, value, options); + }); + } catch { + // The `set` method was called from a Server Component. + // This can be ignored if you have middleware refreshing + // user sessions. + } + }, + }, + } + ); + + const { data: { user } } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ detail: "Unauthorized" }, { status: 401 }); + } + + // 2. Construct Backend URL (extract the path segment after /api/rag/) + // e.g. /api/rag/documents -> documents + // e.g. /api/rag/documents/doc1 -> documents/doc1 + const match = request.nextUrl.pathname.match(/^\/api\/rag\/(.*)$/); + const path = match ? match[1] : ""; + + // Pass query parameters along to the backend + const queryString = request.nextUrl.search; + const backendUrl = `${RAG_API_URL}/${path}${queryString}`; + + // 3. Prepare headers + const headers = new Headers(); + headers.set("Authorization", `Bearer ${RAG_API_KEY}`); + + // Forward Content-Type if present (important for file uploads vs JSON) + const contentType = request.headers.get("Content-Type"); + if (contentType) { + headers.set("Content-Type", contentType); + } + + // 4. Forward the request — stream the body instead of buffering + try { + const fetchOptions: RequestInit = { + method: request.method, + headers: headers, + // Stream the body directly instead of buffering via .blob() + // For GET/HEAD requests, body must be omitted. + body: ["GET", "HEAD"].includes(request.method) ? undefined : request.body, + // Disable caching for proxy requests + cache: "no-store", + // @ts-expect-error -- Next.js extended fetch supports duplex for streaming + duplex: "half", + }; + + const response = await fetch(backendUrl, fetchOptions); + + // 5. Stream the response back to the client + const responseHeaders = new Headers(response.headers); + // Remove headers that might cause issues when proxying + responseHeaders.delete("content-encoding"); + responseHeaders.delete("transfer-encoding"); + + return new NextResponse(response.body, { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + }); + } catch (error: unknown) { + console.error("[Proxy Error]:", error); + return NextResponse.json( + { detail: "Backend connection failed" }, + { status: 502 } + ); + } +} diff --git a/app/chat/page.tsx b/app/chat/page.tsx index 2d7996d..0eef925 100644 --- a/app/chat/page.tsx +++ b/app/chat/page.tsx @@ -18,40 +18,41 @@ import { type ChatSession, type SearchResult, } from "@/lib/chat-history"; -const RAG_API = process.env.NEXT_PUBLIC_RAG_API_URL ?? "http://localhost:8000"; -const DOCS_API = RAG_API + "/documents"; +// Use the local Next.js API proxy to securely attach the API key +const RAG_API = "/api/rag"; +const DOCS_API = RAG_API + "/documents"; const UPLOAD_API = RAG_API + "/upload"; const DELETE_API = (name: string) => `${RAG_API}/documents/${encodeURIComponent(name)}`; // ── Types ──────────────────────────────────────────────── -interface DocFile { name: string; size_kb: number } -interface Source { document: string; snippet: string; score: number } -interface Message { - id: string; - role: "user" | "assistant"; - content: string; - sources?: Source[]; +interface DocFile { name: string; size_kb: number } +interface Source { document: string; snippet: string; score: number } +interface Message { + id: string; + role: "user" | "assistant"; + content: string; + sources?: Source[]; confidence?: "high" | "medium" | "low"; - loading?: boolean; + loading?: boolean; } // ── PDF Export ─────────────────────────────────────────── async function exportAnswerPDF( - question: string, - answer: string, - sources: Source[], + question: string, + answer: string, + sources: Source[], confidence: string, ) { const { jsPDF } = await import("jspdf"); - const doc = new jsPDF({ unit: "mm", format: "a4" }); + const doc = new jsPDF({ unit: "mm", format: "a4" }); const pageW = doc.internal.pageSize.getWidth(); const margin = 18; - const maxW = pageW - margin * 2; - let y = 20; + const maxW = pageW - margin * 2; + let y = 20; const addText = ( - text: string, - size: number, + text: string, + size: number, style: "normal" | "bold" | "italic", color: [number, number, number] = [30, 30, 30], ) => { @@ -78,8 +79,8 @@ async function exportAnswerPDF( // Confidence pill const confColor: [number, number, number] = - confidence === "high" ? [16, 185, 129] : - confidence === "medium" ? [245, 158, 11] : [239, 68, 68]; + confidence === "high" ? [16, 185, 129] : + confidence === "medium" ? [245, 158, 11] : [239, 68, 68]; doc.setFillColor(...confColor); doc.roundedRect(margin, y - 4, 42, 6, 1.5, 1.5, "F"); doc.setFontSize(8); doc.setFont("helvetica", "bold"); doc.setTextColor(255, 255, 255); @@ -122,9 +123,9 @@ async function exportAnswerPDF( // ── Small components ───────────────────────────────────── function ConfidenceBadge({ level }: { level: "high" | "medium" | "low" }) { const map = { - high: { bar: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30", dot: "bg-emerald-400", label: "High confidence" }, - medium: { bar: "bg-amber-500/15 text-amber-400 border-amber-500/30", dot: "bg-amber-400", label: "Medium confidence" }, - low: { bar: "bg-red-500/15 text-red-400 border-red-500/30", dot: "bg-red-400", label: "Low confidence" }, + high: { bar: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30", dot: "bg-emerald-400", label: "High confidence" }, + medium: { bar: "bg-amber-500/15 text-amber-400 border-amber-500/30", dot: "bg-amber-400", label: "Medium confidence" }, + low: { bar: "bg-red-500/15 text-red-400 border-red-500/30", dot: "bg-red-400", label: "Low confidence" }, }; const c = map[level]; return ( @@ -163,7 +164,7 @@ function MessageBubble({ msg, onExport, }: { - msg: Message; + msg: Message; onExport?: (msg: Message) => void; }) { const isUser = msg.role === "user"; @@ -229,33 +230,33 @@ export default function ChatPage() { const router = useRouter(); // ── Auth ──────────────────────────────────────────────── - const [user, setUser] = React.useState(null); + const [user, setUser] = React.useState(null); const [authChecked, setAuthChecked] = React.useState(false); // ── Messages ──────────────────────────────────────────── const [messages, setMessages] = React.useState([]); - const [input, setInput] = React.useState(""); - const [sending, setSending] = React.useState(false); + const [input, setInput] = React.useState(""); + const [sending, setSending] = React.useState(false); // ── Documents ─────────────────────────────────────────── - const [docs, setDocs] = React.useState([]); + const [docs, setDocs] = React.useState([]); const [uploading, setUploading] = React.useState(false); - const [deleting, setDeleting] = React.useState(null); + const [deleting, setDeleting] = React.useState(null); // ── Sidebar / history ──────────────────────────────────── - const [sidebarTab, setSidebarTab] = React.useState<"files" | "history">("files"); - const [sessions, setSessions] = React.useState([]); - const [sessionId, setSessionId] = React.useState(null); + const [sidebarTab, setSidebarTab] = React.useState<"files" | "history">("files"); + const [sessions, setSessions] = React.useState([]); + const [sessionId, setSessionId] = React.useState(null); const [loadingHist, setLoadingHist] = React.useState(false); // ── Search ──────────────────────────────────────────────── - const [historySearch, setHistorySearch] = React.useState(""); - const [searchResults, setSearchResults] = React.useState([]); - const [isSearching, setIsSearching] = React.useState(false); + const [historySearch, setHistorySearch] = React.useState(""); + const [searchResults, setSearchResults] = React.useState([]); + const [isSearching, setIsSearching] = React.useState(false); // ── Share ───────────────────────────────────────────────── const [sharedSessions, setSharedSessions] = React.useState>(new Set()); - const [shareCopied, setShareCopied] = React.useState(null); + const [shareCopied, setShareCopied] = React.useState(null); // ── Voice ───────────────────────────────────────────────── const [listening, setListening] = React.useState(false); @@ -265,10 +266,10 @@ export default function ChatPage() { const [profileOpen, setProfileOpen] = React.useState(false); // ── Refs ──────────────────────────────────────────────── - const bottomRef = React.useRef(null); - const inputRef = React.useRef(null); + const bottomRef = React.useRef(null); + const inputRef = React.useRef(null); const fileInputRef = React.useRef(null); - const profileRef = React.useRef(null); + const profileRef = React.useRef(null); React.useEffect(() => { function handler(e: MouseEvent) { @@ -309,7 +310,7 @@ export default function ChatPage() { setSessions(s); } }); - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [router]); React.useEffect(() => { fetchDocs(); }, []); @@ -343,10 +344,10 @@ export default function ChatPage() { setSessionId(sid); const msgs = await loadMessages(sid); const restored: Message[] = msgs.map((m) => ({ - id: m.id, - role: m.role as "user" | "assistant", - content: m.content, - sources: (m.sources as unknown as Source[]) ?? [], + id: m.id, + role: m.role as "user" | "assistant", + content: m.content, + sources: (m.sources as unknown as Source[]) ?? [], confidence: (m.confidence as "high" | "medium" | "low") ?? undefined, })); setMessages(restored); @@ -374,7 +375,7 @@ export default function ChatPage() { await shareSession(sid); setSharedSessions((prev) => new Set([...prev, sid])); const link = `${window.location.origin}/share/${sid}`; - await navigator.clipboard.writeText(link).catch(() => {}); + await navigator.clipboard.writeText(link).catch(() => { }); setShareCopied(sid); setTimeout(() => setShareCopied(null), 2500); } @@ -392,9 +393,9 @@ export default function ChatPage() { rec.continuous = false; rec.interimResults = true; rec.lang = "en-US"; - rec.onstart = () => setListening(true); - rec.onend = () => { setListening(false); inputRef.current?.focus(); }; - rec.onerror = () => setListening(false); + rec.onstart = () => setListening(true); + rec.onend = () => { setListening(false); inputRef.current?.focus(); }; + rec.onerror = () => setListening(false); rec.onresult = (e: any) => { const transcript = Array.from(e.results as any[]) .map((r: any) => r[0].transcript).join(""); @@ -419,7 +420,7 @@ export default function ChatPage() { const form = new FormData(); form.append("file", file); try { - const res = await fetch(UPLOAD_API, { method: "POST", body: form }); + const res = await fetch(UPLOAD_API, { method: "POST", body: form }); const data = await res.json(); if (!res.ok) throw new Error(data.detail ?? "Upload failed"); setDocs(data.documents ?? []); @@ -434,7 +435,7 @@ export default function ChatPage() { async function handleDelete(name: string) { setDeleting(name); try { - const res = await fetch(DELETE_API(name), { method: "DELETE" }); + const res = await fetch(DELETE_API(name), { method: "DELETE" }); const data = await res.json(); if (!res.ok) throw new Error(data.detail ?? "Delete failed"); setDocs(data.documents ?? []); @@ -448,7 +449,7 @@ export default function ChatPage() { // ── PDF export ──────────────────────────────────────────── function handleExport(msg: Message) { const idx = messages.findIndex((m) => m.id === msg.id); - const q = messages.slice(0, idx).filter((m) => m.role === "user").pop(); + const q = messages.slice(0, idx).filter((m) => m.role === "user").pop(); exportAnswerPDF(q?.content ?? "Question", msg.content, msg.sources ?? [], msg.confidence ?? "low"); } @@ -457,8 +458,8 @@ export default function ChatPage() { const question = input.trim(); if (!question || sending) return; - const userMsg: Message = { id: Date.now().toString(), role: "user", content: question }; - const placeholderId = Date.now().toString() + "-ai"; + const userMsg: Message = { id: Date.now().toString(), role: "user", content: question }; + const placeholderId = Date.now().toString() + "-ai"; const placeholder: Message = { id: placeholderId, role: "assistant", content: "", loading: true }; setMessages((prev) => [...prev, userMsg, placeholder]); @@ -485,9 +486,9 @@ export default function ChatPage() { try { const res = await fetch(RAG_API + "/ask", { - method: "POST", + method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ question, history }), + body: JSON.stringify({ question, history }), }); if (!res.ok) throw new Error(`API error ${res.status}`); const data = await res.json(); @@ -531,8 +532,8 @@ export default function ChatPage() { ); } - const displayName = user?.user_metadata?.full_name || user?.email?.split("@")[0] || "You"; - const avatarSrc = user ? resolveAvatar(user.id, user.user_metadata?.avatar_url) : null; + const displayName = user?.user_metadata?.full_name || user?.email?.split("@")[0] || "You"; + const avatarSrc = user ? resolveAvatar(user.id, user.user_metadata?.avatar_url) : null; const avatarLetter = (displayName as string)[0]?.toUpperCase() ?? "U"; return ( @@ -732,8 +733,8 @@ export default function ChatPage() { shareCopied === s.id ? "opacity-100 text-emerald-400" : sharedSessions.has(s.id) - ? "opacity-100 text-white/40 hover:text-amber-400" - : "text-white/15 hover:text-white/50 hover:bg-white/8", + ? "opacity-100 text-white/40 hover:text-amber-400" + : "text-white/15 hover:text-white/50 hover:bg-white/8", )} > {shareCopied === s.id ? : } diff --git a/components/ui/auth-modal.tsx b/components/ui/auth-modal.tsx index 991f35e..e3d12b4 100644 --- a/components/ui/auth-modal.tsx +++ b/components/ui/auth-modal.tsx @@ -204,9 +204,9 @@ export function AuthModal({ open, onOpenChange, onSuccess }: AuthModalProps) { } const titles: Record = { - login: { title: "Welcome back", desc: "Sign in to your account" }, + login: { title: "Welcome back", desc: "Sign in to your account" }, signup: { title: "Create an account", desc: "Join the TopDevs community" }, - forgot: { title: "Reset password", desc: "We'll send a reset link to your email" }, + forgot: { title: "Reset password", desc: "We'll send a reset link to your email" }, }; return ( @@ -295,7 +295,7 @@ export function AuthModal({ open, onOpenChange, onSuccess }: AuthModalProps) { - diff --git a/flashfetch-extension/background.js b/flashfetch-extension/background.js index 4f0951c..8ec3d48 100644 --- a/flashfetch-extension/background.js +++ b/flashfetch-extension/background.js @@ -28,8 +28,8 @@ chrome.contextMenus.onClicked.addListener((info, tab) => { } if (info.menuItemId === "flashfetch-save-page") { - chrome.storage.local.get(["ff_token", "ff_api_url"], async (data) => { - const apiUrl = data.ff_api_url || "https://rag-document-qa-bot-production.up.railway.app"; + chrome.storage.local.get(["ff_api_key", "ff_api_url"], async (data) => { + const apiUrl = data.ff_api_url || "http://localhost:8000"; // Inject content script to grab page text chrome.scripting.executeScript( @@ -49,7 +49,7 @@ chrome.contextMenus.onClicked.addListener((info, tab) => { try { const headers = {}; - if (data.ff_token) headers["Authorization"] = `Bearer ${data.ff_token}`; + if (data.ff_api_key) headers["Authorization"] = `Bearer ${data.ff_api_key}`; const res = await fetch(`${apiUrl}/upload`, { method: "POST", @@ -77,6 +77,6 @@ chrome.contextMenus.onClicked.addListener((info, tab) => { // Injected into the active tab to extract readable text function extractPageText() { const title = document.title; - const body = document.body ? document.body.innerText : ""; + const body = document.body ? document.body.innerText : ""; return { title, text: body.slice(0, 50000) }; // cap at 50 KB } diff --git a/flashfetch-extension/manifest.json b/flashfetch-extension/manifest.json index 580ae79..12d77c7 100644 --- a/flashfetch-extension/manifest.json +++ b/flashfetch-extension/manifest.json @@ -35,7 +35,8 @@ "notifications" ], "host_permissions": [ - "https://rag-document-qa-bot-production.up.railway.app/*", + "https://*.onrender.com/*", + "https://*.vercel.app/*", "http://localhost:8000/*", "file:///*" ] diff --git a/flashfetch-extension/popup.html b/flashfetch-extension/popup.html index 3b5be87..6caafbc 100644 --- a/flashfetch-extension/popup.html +++ b/flashfetch-extension/popup.html @@ -13,11 +13,12 @@ FlashFetch -

Configure API URL (optional)

+

Setup your Backend

- + +
- + diff --git a/flashfetch-extension/popup.js b/flashfetch-extension/popup.js index cc1292b..3156f9e 100644 --- a/flashfetch-extension/popup.js +++ b/flashfetch-extension/popup.js @@ -1,23 +1,26 @@ // Constants +// In production: update this to your Render backend URL +// e.g. "https://flashfetch-backend.onrender.com" const DEFAULT_API = "http://localhost:8000"; -const OLD_RAILWAY = "https://rag-document-qa-bot-production.up.railway.app"; +const OLD_RAILWAY = "https://rag-document-qa-bot-production.up.railway.app"; // State let conversationHistory = []; -let activeFileContext = null; +let activeFileContext = null; // DOM -const authScreen = document.getElementById("auth-screen"); -const chatScreen = document.getElementById("chat-screen"); -const apiUrlInput = document.getElementById("api-url-input"); -const saveTokenBtn = document.getElementById("save-token-btn"); -const messagesEl = document.getElementById("messages"); -const questionInput = document.getElementById("question-input"); -const sendBtn = document.getElementById("send-btn"); -const savePageBtn = document.getElementById("save-page-btn"); -const logoutBtn = document.getElementById("logout-btn"); -const statusBar = document.getElementById("status-bar"); -const fileBanner = document.getElementById("file-banner"); +const authScreen = document.getElementById("auth-screen"); +const chatScreen = document.getElementById("chat-screen"); +const apiUrlInput = document.getElementById("api-url-input"); +const apiKeyInput = document.getElementById("api-key-input"); +const saveTokenBtn = document.getElementById("save-token-btn"); +const messagesEl = document.getElementById("messages"); +const questionInput = document.getElementById("question-input"); +const sendBtn = document.getElementById("send-btn"); +const savePageBtn = document.getElementById("save-page-btn"); +const logoutBtn = document.getElementById("logout-btn"); +const statusBar = document.getElementById("status-bar"); +const fileBanner = document.getElementById("file-banner"); const fileBannerName = document.getElementById("file-banner-name"); const fileBannerClear = document.getElementById("file-banner-clear"); @@ -43,12 +46,12 @@ function detectOpenFile() { const tab = tabs[0]; if (!tab || !tab.url) return; - const url = tab.url; - const isFile = url.startsWith("file://"); - const rawName = url.split("/").pop().split("?")[0] || "document"; + const url = tab.url; + const isFile = url.startsWith("file://"); + const rawName = url.split("/").pop().split("?")[0] || "document"; const fileName = decodeURIComponent(rawName); - const isDrive = url.includes("drive.google.com") || url.includes("docs.google.com"); + const isDrive = url.includes("drive.google.com") || url.includes("docs.google.com"); const isPDFUrl = !isFile && url.toLowerCase().endsWith(".pdf"); if (isDrive || isPDFUrl) { @@ -83,16 +86,20 @@ function detectOpenFile() { // Load any URL via backend /extract-url function loadFromUrl(url, label) { - chrome.storage.local.get(["ff_api_url"], async (data) => { + chrome.storage.local.get(["ff_api_url", "ff_api_key"], async (data) => { const apiUrl = data.ff_api_url || DEFAULT_API; + const apiKey = data.ff_api_key || ""; clearEmptyState(); showLoadingBanner(label); setStatus("Reading " + label + "..."); try { + const headers = { "Content-Type": "application/json" }; + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + const res = await fetch(apiUrl + "/extract-url", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers, body: JSON.stringify({ url }), }); @@ -104,8 +111,8 @@ function loadFromUrl(url, label) { const json = await res.json(); hideStatus(); setFileContext({ - title: json.filename, - text: json.text, + title: json.filename, + text: json.text, fileType: json.filename.toLowerCase().endsWith(".pdf") ? "pdf" : "txt", }); @@ -134,9 +141,9 @@ function setFileContext(res) { } function appendPDFHelp(fileName, errorMsg) { - const div = document.createElement("div"); + const div = document.createElement("div"); div.className = "msg assistant"; - const bubble = document.createElement("div"); + const bubble = document.createElement("div"); bubble.className = "bubble"; if (errorMsg && errorMsg.includes("Anyone with the link")) { @@ -184,16 +191,17 @@ function showChat() { saveTokenBtn.addEventListener("click", () => { const apiUrl = apiUrlInput.value.trim() || DEFAULT_API; - chrome.storage.local.set({ ff_api_url: apiUrl }, () => { - setStatus("API URL saved: " + apiUrl, "success"); + const apiKey = apiKeyInput.value.trim(); + chrome.storage.local.set({ ff_api_url: apiUrl, ff_api_key: apiKey }, () => { + setStatus("Settings saved!", "success"); setTimeout(() => hideStatus(), 2000); }); }); logoutBtn.addEventListener("click", () => { - chrome.storage.local.remove(["ff_api_url"], () => { + chrome.storage.local.remove(["ff_api_url", "ff_api_key"], () => { conversationHistory = []; - activeFileContext = null; + activeFileContext = null; setStatus("Reset done", "success"); setTimeout(() => hideStatus(), 1500); }); @@ -216,8 +224,9 @@ async function sendQuestion() { const question = questionInput.value.trim(); if (!question) return; - chrome.storage.local.get(["ff_api_url"], async (data) => { + chrome.storage.local.get(["ff_api_url", "ff_api_key"], async (data) => { const apiUrl = data.ff_api_url || DEFAULT_API; + const apiKey = data.ff_api_key || ""; clearEmptyState(); appendMessage("user", question); @@ -230,6 +239,7 @@ async function sendQuestion() { try { const headers = { "Content-Type": "application/json" }; + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; let endpoint, body; @@ -238,8 +248,8 @@ async function sendQuestion() { body = JSON.stringify({ question, context_text: activeFileContext.text, - filename: activeFileContext.filename, - history: historyToSend.slice(0, -1), + filename: activeFileContext.filename, + history: historyToSend.slice(0, -1), }); } else { endpoint = apiUrl + "/ask"; @@ -254,10 +264,10 @@ async function sendQuestion() { if (!res.ok) throw new Error("API error " + res.status); - const json = await res.json(); - const answer = json.answer || "No answer returned."; + const json = await res.json(); + const answer = json.answer || "No answer returned."; const confidence = json.confidence || "low"; - const sources = json.sources || []; + const sources = json.sources || []; appendMessage("assistant", answer, confidence, sources); conversationHistory.push({ role: "assistant", content: answer }); @@ -274,30 +284,34 @@ async function sendQuestion() { // Save current page / upload savePageBtn.addEventListener("click", async () => { - chrome.storage.local.get(["ff_api_url"], async (data) => { + chrome.storage.local.get(["ff_api_url", "ff_api_key"], async (data) => { const apiUrl = data.ff_api_url || DEFAULT_API; + const apiKey = data.ff_api_key || ""; chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => { const tab = tabs[0]; if (!tab || !tab.url) { setStatus("Cannot read this tab", "error"); return; } - const url = tab.url; - const isPDF = url.toLowerCase().endsWith(".pdf"); - const isFile = url.startsWith("file://"); - const rawName = url.split("/").pop().split("?")[0] || "document"; + const url = tab.url; + const isPDF = url.toLowerCase().endsWith(".pdf"); + const isFile = url.startsWith("file://"); + const rawName = url.split("/").pop().split("?")[0] || "document"; const fileName = decodeURIComponent(rawName); if (isFile) { setStatus("Reading " + fileName + "..."); try { - const fileRes = await fetch(url); - const blob = await fileRes.blob(); + const fileRes = await fetch(url); + const blob = await fileRes.blob(); const mimeType = isPDF ? "application/pdf" : "text/plain"; - const upload = new Blob([blob], { type: mimeType }); + const upload = new Blob([blob], { type: mimeType }); const formData = new FormData(); formData.append("file", upload, fileName); setStatus("Uploading to FlashFetch..."); - const res = await fetch(apiUrl + "/upload", { method: "POST", body: formData }); + const headers = {}; + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + + const res = await fetch(apiUrl + "/upload", { method: "POST", headers, body: formData }); if (!res.ok) throw new Error("Upload failed " + res.status); setStatus("Uploaded: " + fileName, "success"); clearEmptyState(); @@ -315,13 +329,16 @@ savePageBtn.addEventListener("click", async () => { setStatus("Cannot read this page", "error"); return; } const { title, text } = response; - const blob = new Blob([text], { type: "text/plain" }); - const fname = (title || "webpage").replace(/[^a-z0-9]/gi, "_").slice(0, 40) + ".txt"; + const blob = new Blob([text], { type: "text/plain" }); + const fname = (title || "webpage").replace(/[^a-z0-9]/gi, "_").slice(0, 40) + ".txt"; const formData = new FormData(); formData.append("file", blob, fname); setStatus("Uploading..."); try { - const res = await fetch(apiUrl + "/upload", { method: "POST", body: formData }); + const headers = {}; + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + + const res = await fetch(apiUrl + "/upload", { method: "POST", headers, body: formData }); if (!res.ok) throw new Error("Upload failed " + res.status); setStatus("Saved: " + fname, "success"); setTimeout(() => hideStatus(), 3000); @@ -340,9 +357,9 @@ function clearEmptyState() { } function appendMessage(role, text, confidence, sources) { - const div = document.createElement("div"); + const div = document.createElement("div"); div.className = "msg " + role; - const bubble = document.createElement("div"); + const bubble = document.createElement("div"); bubble.className = "bubble"; bubble.textContent = text; div.appendChild(bubble); @@ -373,7 +390,7 @@ function appendMessage(role, text, confidence, sources) { let typingCounter = 0; function appendTyping() { - const id = "typing-" + (++typingCounter); + const id = "typing-" + (++typingCounter); const wrapper = document.createElement("div"); wrapper.className = "msg assistant"; wrapper.id = id; diff --git a/middleware.ts b/middleware.ts index 56d4074..dca65b2 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,4 +1,4 @@ -import { createServerClient } from "@supabase/ssr"; +import { CookieOptions, createServerClient } from "@supabase/ssr"; import { NextResponse, type NextRequest } from "next/server"; export async function middleware(request: NextRequest) { @@ -16,7 +16,7 @@ export async function middleware(request: NextRequest) { getAll() { return request.cookies.getAll(); }, - setAll(cookiesToSet) { + setAll(cookiesToSet: { name: string; value: string; options: CookieOptions }[]) { cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value) ); @@ -29,7 +29,16 @@ export async function middleware(request: NextRequest) { }); // Refresh the session — do NOT remove this line - await supabase.auth.getUser(); + const { data: { user } } = await supabase.auth.getUser(); + + // Protect /chat and /admin routes + const isProtectedRoute = request.nextUrl.pathname.startsWith('/chat') || request.nextUrl.pathname.startsWith('/admin'); + + if (isProtectedRoute && !user) { + const redirectUrl = request.nextUrl.clone(); + redirectUrl.pathname = '/'; + return NextResponse.redirect(redirectUrl); + } return supabaseResponse; } diff --git a/package-lock.json b/package-lock.json index 4444089..427136c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/trusted-types": "^2.0.7", "eslint": "^9", "eslint-config-next": "16.1.6", "shadcn": "^3.8.5", @@ -4192,8 +4193,8 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true + "devOptional": true, + "license": "MIT" }, "node_modules/@types/validate-npm-package-name": { "version": "4.0.2", diff --git a/package.json b/package.json index 67836c0..3cc43d3 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/trusted-types": "^2.0.7", "eslint": "^9", "eslint-config-next": "16.1.6", "shadcn": "^3.8.5", diff --git a/rag-backend/index/chunks.pkl b/rag-backend/index/chunks.pkl new file mode 100644 index 0000000..128581c Binary files /dev/null and b/rag-backend/index/chunks.pkl differ diff --git a/rag-backend/index/faiss.index b/rag-backend/index/faiss.index new file mode 100644 index 0000000..e715f65 Binary files /dev/null and b/rag-backend/index/faiss.index differ diff --git a/rag-backend/main.py b/rag-backend/main.py index 647b032..96bad07 100644 --- a/rag-backend/main.py +++ b/rag-backend/main.py @@ -17,8 +17,12 @@ import re import httpx import fitz # PyMuPDF +import ipaddress +import urllib.parse +import socket -from fastapi import FastAPI, HTTPException, UploadFile, File +from fastapi import FastAPI, HTTPException, UploadFile, File, Depends, Security +from fastapi.security import APIKeyHeader from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, Field @@ -35,10 +39,77 @@ version="1.0.0", ) +# Allowed redirect hosts (we only follow redirects from these domains) +_REDIRECT_ALLOW = {"drive.google.com", "docs.google.com", "googleusercontent.com"} + +def is_safe_url(url: str) -> bool: + """Validate that *url* resolves exclusively to global (public) IPs. + + Uses getaddrinfo to check **all** A/AAAA records (not just the first), + and enforces ip_address.is_global so link-local, loopback, private and + reserved ranges are all rejected. + """ + try: + parsed = urllib.parse.urlparse(url) + if parsed.scheme not in ("http", "https"): + return False + hostname = parsed.hostname + if not hostname: + return False + # Resolve every A and AAAA record for the hostname + addrs = socket.getaddrinfo(hostname, None, proto=socket.IPPROTO_TCP) + if not addrs: + return False + for family, _type, _proto, _canonname, sockaddr in addrs: + ip_obj = ipaddress.ip_address(sockaddr[0]) + if not ip_obj.is_global: + return False + return True + except Exception: + return False + +def sanitize_filename(filename: str) -> str: + basename = os.path.basename(filename) + if not basename or basename in {".", ".."}: + raise ValueError("Invalid filename") + return basename + +# ── Auth setup ─────────────────────────────────────────── +API_KEY_NAME = "Authorization" +api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False) + +def get_api_key(api_key: str = Security(api_key_header)) -> str: + """Validate the Authorization header against RAG_API_KEY. + + Fail-closed: if the env var is unset the server returns 500 instead of + silently allowing unauthenticated access. + """ + expected_key = os.getenv("RAG_API_KEY") + + if not expected_key: + raise HTTPException( + status_code=500, + detail="Server misconfiguration: RAG_API_KEY is not set.", + ) + + if not api_key: + raise HTTPException(status_code=401, detail="Missing Authorization header") + + # strip "Bearer " prefix if provided + token = api_key.replace("Bearer ", "").strip() + + if token != expected_key: + raise HTTPException(status_code=401, detail="Invalid API Key") + return token + app.add_middleware( CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, + allow_origins=[ + "http://localhost:3000", + "https://flashfetch.vercel.app", + "*" # Wildcard for the Chrome Extension + ], + allow_credentials=False, allow_methods=["*"], allow_headers=["*"], ) @@ -72,9 +143,36 @@ class AnswerResponse(BaseModel): confidence: str # "high" | "medium" | "low" +# ── Startup: auto-index committed docs ─────────────────── +@app.on_event("startup") +async def auto_ingest_on_startup(): + """ + On every cold start (Render free tier, local dev, etc.): + If docs exist but the FAISS index is missing, rebuild it automatically. + This ensures the demo docs committed to the repo are always indexed. + """ + index_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "index", "faiss.index") + docs_exist = any( + f.endswith((".txt", ".md", ".pdf")) + for f in os.listdir(DOCS_DIR) + ) if os.path.isdir(DOCS_DIR) else False + + if docs_exist and not os.path.exists(index_path): + print("[startup] FAISS index missing — auto-building from docs...") + try: + run_ingest() + print("[startup] Index built successfully.") + except Exception as e: + print(f"[startup] WARNING: auto-ingest failed: {e}") + elif os.path.exists(index_path): + print("[startup] FAISS index found — skipping rebuild.") + else: + print("[startup] No docs found — upload a file to begin.") + + # ── Routes ──────────────────────────────────────────────── @app.post("/ask", response_model=AnswerResponse) -def ask(query: Query): +def ask(query: Query, api_key: str = Depends(get_api_key)): """ Accepts a question, retrieves relevant document chunks, and returns a grounded answer from the Groq LLM. @@ -91,7 +189,7 @@ def ask(query: Query): @app.post("/ask-with-context", response_model=AnswerResponse) -def ask_with_context(query: InlineQuery): +def ask_with_context(query: InlineQuery, api_key: str = Depends(get_api_key)): """ Answer a question using raw text passed inline (no document upload needed). The Chrome extension uses this when a file is open in the browser. @@ -110,13 +208,15 @@ def ask_with_context(query: InlineQuery): @app.post("/extract-url") -def extract_url(req: UrlExtractRequest): +def extract_url(req: UrlExtractRequest, api_key: str = Depends(get_api_key)): """ Download any URL and extract its text content. Supports Google Drive share links, direct PDF URLs, plain text URLs. Returns { filename, text, char_count } for use with /ask-with-context. """ url = req.url.strip() + if not is_safe_url(url): + raise HTTPException(status_code=400, detail="Invalid or unsafe URL provided.") # ── Convert Google Drive share/view/preview URL → direct download ───── # Patterns: @@ -217,15 +317,19 @@ def _list_docs() -> list[dict]: @app.get("/documents") -def list_documents(): +def list_documents(api_key: str = Depends(get_api_key)): """Return all documents currently in the docs/ folder.""" return {"documents": _list_docs()} @app.post("/upload") -async def upload_document(file: UploadFile = File(...)): +async def upload_document(file: UploadFile = File(...), api_key: str = Depends(get_api_key)): """Upload a .txt / .md / .pdf file, re-index everything, return updated doc list.""" - fname = file.filename or "" + try: + fname = sanitize_filename(file.filename or "") + except ValueError: + raise HTTPException(status_code=400, detail="Invalid filename.") + if not fname.lower().endswith(SUPPORTED): raise HTTPException( status_code=400, @@ -249,9 +353,14 @@ async def upload_document(file: UploadFile = File(...)): @app.delete("/documents/{filename}") -def delete_document(filename: str): +def delete_document(filename: str, api_key: str = Depends(get_api_key)): """Remove a document and re-index.""" - path = os.path.join(DOCS_DIR, filename) + try: + safe_name = sanitize_filename(filename) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid filename.") + + path = os.path.join(DOCS_DIR, safe_name) if not os.path.exists(path): raise HTTPException(status_code=404, detail="File not found.") os.remove(path) diff --git a/rag-backend/requirements.txt b/rag-backend/requirements.txt index 026f33f..e86c077 100644 --- a/rag-backend/requirements.txt +++ b/rag-backend/requirements.txt @@ -2,7 +2,7 @@ fastapi==0.115.0 uvicorn==0.30.6 sentence-transformers==3.0.1 faiss-cpu==1.13.2 -PyMuPDF==1.23.8 +PyMuPDF>=1.24.0,<2 groq==0.13.0 httpx==0.27.2 python-dotenv==1.0.1 diff --git a/supabase/admin.sql b/supabase/admin.sql index 9ed7c81..7d0afbc 100644 --- a/supabase/admin.sql +++ b/supabase/admin.sql @@ -1,161 +1,157 @@ --- ============================================================ --- Admin Portal Tables for FlashFetch --- Run this in your Supabase SQL Editor AFTER chat-history.sql --- ============================================================ - --- 1. Admin users table ─────────────────────────────────────── --- To grant admin: INSERT INTO public.admin_users (user_id) VALUES (''); --- To revoke: DELETE FROM public.admin_users WHERE user_id = ''; -CREATE TABLE IF NOT EXISTS public.admin_users ( - user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, - granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- 2. RLS: Only the row's own user OR existing admins can read -ALTER TABLE public.admin_users ENABLE ROW LEVEL SECURITY; - -DROP POLICY IF EXISTS "admins_read_own" ON public.admin_users; -CREATE POLICY "admins_read_own" ON public.admin_users - FOR SELECT USING (auth.uid() = user_id); - --- 3. Analytics view ────────────────────────────────────────── --- Gives admins a single view to query from the dashboard. -DROP VIEW IF EXISTS public.admin_analytics; -CREATE VIEW public.admin_analytics AS -SELECT - -- total sessions - (SELECT COUNT(*) FROM public.chat_sessions) AS total_sessions, - -- total queries (user messages only) - (SELECT COUNT(*) FROM public.chat_messages WHERE role = 'user') AS total_queries, - -- confidence counts - (SELECT COUNT(*) FROM public.chat_messages WHERE role = 'assistant' AND confidence = 'high') AS high_confidence, - (SELECT COUNT(*) FROM public.chat_messages WHERE role = 'assistant' AND confidence = 'medium') AS medium_confidence, - (SELECT COUNT(*) FROM public.chat_messages WHERE role = 'assistant' AND confidence = 'low') AS low_confidence, - -- distinct users - (SELECT COUNT(DISTINCT user_id) FROM public.chat_sessions) AS total_users; - --- 4. Top documents view ────────────────────────────────────── --- Unnests the sources JSONB array to count per-document hits. -DROP VIEW IF EXISTS public.admin_top_documents; -CREATE VIEW public.admin_top_documents AS -SELECT - src->>'document' AS document_name, - COUNT(*) AS hit_count -FROM public.chat_messages, - jsonb_array_elements(sources) AS src -WHERE role = 'assistant' - AND jsonb_array_length(sources) > 0 -GROUP BY 1 -ORDER BY 2 DESC -LIMIT 10; - --- 5. Daily query volume (last 14 days) ────────────────────── -DROP VIEW IF EXISTS public.admin_daily_queries; -CREATE VIEW public.admin_daily_queries AS -SELECT - DATE(created_at)::TEXT AS day, - COUNT(*) AS query_count -FROM public.chat_messages -WHERE role = 'user' - AND created_at >= NOW() - INTERVAL '14 days' -GROUP BY 1 -ORDER BY 1 ASC; - --- 6. Restrict view access ──────────────────────────────────── --- Views cannot have RLS directly (Supabase shows "UNRESTRICTED"). --- Fix: revoke from anon, allow only authenticated, and wrap each --- in a SECURITY DEFINER function that checks admin status. - --- Helper: is current user an admin? -CREATE OR REPLACE FUNCTION public.is_admin() -RETURNS BOOLEAN -LANGUAGE sql SECURITY DEFINER STABLE -AS $$ - SELECT EXISTS ( - SELECT 1 FROM public.admin_users WHERE user_id = auth.uid() + -- ============================================================ + -- Admin Portal Tables for FlashFetch + -- Run this in your Supabase SQL Editor AFTER chat-history.sql + -- ============================================================ + + -- 1. Admin users table ─────────────────────────────────────── + -- To grant admin: INSERT INTO public.admin_users (user_id) VALUES (''); + -- To revoke: DELETE FROM public.admin_users WHERE user_id = ''; + CREATE TABLE IF NOT EXISTS public.admin_users ( + user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -$$; - --- Revoke direct view access from anonymous and public -REVOKE ALL ON public.admin_analytics FROM anon, public; -REVOKE ALL ON public.admin_top_documents FROM anon, public; -REVOKE ALL ON public.admin_daily_queries FROM anon, public; - --- Allow authenticated role to read (app enforces admin check via is_admin()) -GRANT SELECT ON public.admin_analytics TO authenticated; -GRANT SELECT ON public.admin_top_documents TO authenticated; -GRANT SELECT ON public.admin_daily_queries TO authenticated; - --- 7. Secure wrapper functions (SECURITY DEFINER = run as owner) ── --- These enforce the admin check at the database level. --- Call these instead of querying views directly in production. - -CREATE OR REPLACE FUNCTION public.get_admin_analytics() -RETURNS SETOF public.admin_analytics -LANGUAGE sql SECURITY DEFINER STABLE -AS $$ - SELECT * FROM public.admin_analytics - WHERE public.is_admin(); -$$; - -CREATE OR REPLACE FUNCTION public.get_admin_top_documents() -RETURNS SETOF public.admin_top_documents -LANGUAGE sql SECURITY DEFINER STABLE -AS $$ - SELECT * FROM public.admin_top_documents - WHERE public.is_admin(); -$$; - -CREATE OR REPLACE FUNCTION public.get_admin_daily_queries() -RETURNS SETOF public.admin_daily_queries -LANGUAGE sql SECURITY DEFINER STABLE -AS $$ - SELECT * FROM public.admin_daily_queries - WHERE public.is_admin(); -$$; - -GRANT EXECUTE ON FUNCTION public.get_admin_analytics() TO authenticated; -GRANT EXECUTE ON FUNCTION public.get_admin_top_documents() TO authenticated; -GRANT EXECUTE ON FUNCTION public.get_admin_daily_queries() TO authenticated; - --- 8. Allow admins to SELECT all rows in chat tables ────────── --- Without these policies the views only see the calling user's rows. --- With SECURITY DEFINER the functions run as postgres (bypasses RLS), --- but direct .from() calls need explicit admin-read policies. - -DROP POLICY IF EXISTS "admins_read_all_sessions" ON public.chat_sessions; -CREATE POLICY "admins_read_all_sessions" ON public.chat_sessions - FOR SELECT USING (public.is_admin()); - -DROP POLICY IF EXISTS "admins_read_all_messages" ON public.chat_messages; -CREATE POLICY "admins_read_all_messages" ON public.chat_messages - FOR SELECT USING (public.is_admin()); - --- 9. get_all_sessions RPC ────────────────────────────────────── --- Returns all sessions across all users for the admin dashboard. --- SECURITY DEFINER ensures RLS is bypassed (runs as postgres). -CREATE OR REPLACE FUNCTION public.get_all_sessions(row_limit INT DEFAULT 20) -RETURNS TABLE ( - id UUID, - user_id UUID, - title TEXT, - created_at TIMESTAMPTZ, - message_count BIGINT -) -LANGUAGE sql SECURITY DEFINER STABLE -AS $$ + + -- 2. RLS: Only the row's own user can read their admin_users entry + ALTER TABLE public.admin_users ENABLE ROW LEVEL SECURITY; + + DROP POLICY IF EXISTS "admins_read_own" ON public.admin_users; + CREATE POLICY "admins_read_own" ON public.admin_users + FOR SELECT USING (auth.uid() = user_id); + + -- 3. Analytics view ────────────────────────────────────────── + -- Gives admins a single view to query from the dashboard. + DROP VIEW IF EXISTS public.admin_analytics; + CREATE VIEW public.admin_analytics AS + SELECT + -- total sessions + (SELECT COUNT(*) FROM public.chat_sessions) AS total_sessions, + -- total queries (user messages only) + (SELECT COUNT(*) FROM public.chat_messages WHERE role = 'user') AS total_queries, + -- confidence counts + (SELECT COUNT(*) FROM public.chat_messages WHERE role = 'assistant' AND confidence = 'high') AS high_confidence, + (SELECT COUNT(*) FROM public.chat_messages WHERE role = 'assistant' AND confidence = 'medium') AS medium_confidence, + (SELECT COUNT(*) FROM public.chat_messages WHERE role = 'assistant' AND confidence = 'low') AS low_confidence, + -- distinct users + (SELECT COUNT(DISTINCT user_id) FROM public.chat_sessions) AS total_users; + + -- 4. Top documents view ────────────────────────────────────── + -- Unnests the sources JSONB array to count per-document hits. + DROP VIEW IF EXISTS public.admin_top_documents; + CREATE VIEW public.admin_top_documents AS + SELECT + src->>'document' AS document_name, + COUNT(*) AS hit_count + FROM public.chat_messages, + jsonb_array_elements(sources) AS src + WHERE role = 'assistant' + AND jsonb_array_length(sources) > 0 + GROUP BY 1 + ORDER BY 2 DESC + LIMIT 10; + + -- 5. Daily query volume (last 14 days) ────────────────────── + DROP VIEW IF EXISTS public.admin_daily_queries; + CREATE VIEW public.admin_daily_queries AS SELECT - s.id, - s.user_id, - s.title, - s.created_at, - COUNT(m.id) AS message_count - FROM public.chat_sessions s - LEFT JOIN public.chat_messages m ON m.session_id = s.id - WHERE public.is_admin() - GROUP BY s.id, s.user_id, s.title, s.created_at - ORDER BY s.created_at DESC - LIMIT row_limit; -$$; - -GRANT EXECUTE ON FUNCTION public.get_all_sessions(INT) TO authenticated; + DATE(created_at)::TEXT AS day, + COUNT(*) AS query_count + FROM public.chat_messages + WHERE role = 'user' + AND created_at >= NOW() - INTERVAL '14 days' + GROUP BY 1 + ORDER BY 1 ASC; + + -- 6. Restrict view access ──────────────────────────────────── + -- Views cannot have RLS directly (Supabase shows "UNRESTRICTED"). + -- Fix: revoke from anon, allow only authenticated, and wrap each + -- in a SECURITY DEFINER function that checks admin status. + + -- Helper: is current user an admin? + CREATE OR REPLACE FUNCTION public.is_admin() + RETURNS BOOLEAN + LANGUAGE sql SECURITY DEFINER STABLE + AS $$ + SELECT EXISTS ( + SELECT 1 FROM public.admin_users WHERE user_id = auth.uid() + ); + $$; + + -- Revoke direct view access from all roles. + -- Access is only permitted through the admin-only SECURITY DEFINER RPCs below. + REVOKE ALL ON public.admin_analytics FROM anon, public, authenticated; + REVOKE ALL ON public.admin_top_documents FROM anon, public, authenticated; + REVOKE ALL ON public.admin_daily_queries FROM anon, public, authenticated; + + -- 7. Secure wrapper functions (SECURITY DEFINER = run as owner) ── + -- These enforce the admin check at the database level. + -- Call these instead of querying views directly in production. + + CREATE OR REPLACE FUNCTION public.get_admin_analytics() + RETURNS SETOF public.admin_analytics + LANGUAGE sql SECURITY DEFINER STABLE + AS $$ + SELECT * FROM public.admin_analytics + WHERE public.is_admin(); + $$; + + CREATE OR REPLACE FUNCTION public.get_admin_top_documents() + RETURNS SETOF public.admin_top_documents + LANGUAGE sql SECURITY DEFINER STABLE + AS $$ + SELECT * FROM public.admin_top_documents + WHERE public.is_admin(); + $$; + + CREATE OR REPLACE FUNCTION public.get_admin_daily_queries() + RETURNS SETOF public.admin_daily_queries + LANGUAGE sql SECURITY DEFINER STABLE + AS $$ + SELECT * FROM public.admin_daily_queries + WHERE public.is_admin(); + $$; + + GRANT EXECUTE ON FUNCTION public.get_admin_analytics() TO authenticated; + GRANT EXECUTE ON FUNCTION public.get_admin_top_documents() TO authenticated; + GRANT EXECUTE ON FUNCTION public.get_admin_daily_queries() TO authenticated; + + -- 8. Allow admins to SELECT all rows in chat tables ────────── + -- Without these policies the views only see the calling user's rows. + -- With SECURITY DEFINER the functions run as postgres (bypasses RLS), + -- but direct .from() calls need explicit admin-read policies. + + DROP POLICY IF EXISTS "admins_read_all_sessions" ON public.chat_sessions; + CREATE POLICY "admins_read_all_sessions" ON public.chat_sessions + FOR SELECT USING (public.is_admin()); + + DROP POLICY IF EXISTS "admins_read_all_messages" ON public.chat_messages; + CREATE POLICY "admins_read_all_messages" ON public.chat_messages + FOR SELECT USING (public.is_admin()); + + -- 9. get_all_sessions RPC ────────────────────────────────────── + -- Returns all sessions across all users for the admin dashboard. + -- SECURITY DEFINER ensures RLS is bypassed (runs as postgres). + CREATE OR REPLACE FUNCTION public.get_all_sessions(row_limit INT DEFAULT 20) + RETURNS TABLE ( + id UUID, + user_id UUID, + title TEXT, + created_at TIMESTAMPTZ, + message_count BIGINT + ) + LANGUAGE sql SECURITY DEFINER STABLE + AS $$ + SELECT + s.id, + s.user_id, + s.title, + s.created_at, + COUNT(m.id) AS message_count + FROM public.chat_sessions s + LEFT JOIN public.chat_messages m ON m.session_id = s.id + WHERE public.is_admin() + GROUP BY s.id, s.user_id, s.title, s.created_at + ORDER BY s.created_at DESC + LIMIT row_limit; + $$; + + GRANT EXECUTE ON FUNCTION public.get_all_sessions(INT) TO authenticated;