diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000000..4404b35481c --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,19 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node +{ + "name": "Node.js & TypeScript", + // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile + "image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm", + + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [], + + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "yarn install", + + // Configure tool-specific properties. + // "customizations": {}, + + // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. + // "remoteUser": "root" +} diff --git a/.env b/.env index bea0e89a20e..26ecffd7a0b 100644 --- a/.env +++ b/.env @@ -81,7 +81,7 @@ COOKIE_NAME=hf-chat COOKIE_SAMESITE=# can be "lax", "strict", "none" or left empty COOKIE_SECURE=# set to true to only allow cookies over https TRUSTED_EMAIL_HEADER=# header to use to get the user email, only use if you know what you are doing - +USE_HFCO_COOKIE=#only if you deploy huggingchat ### Admin stuff ### ADMIN_CLI_LOGIN=true # set to false to disable the CLI login ADMIN_TOKEN=#We recommend leaving this empty, you can get the token from the terminal. diff --git a/README.md b/README.md index 6e5f1104dc0..553e077f6bc 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ![Chat UI repository thumbnail](https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/chat-ui/Frame%2013.png) -A chat interface using open source models, eg OpenAssistant or Llama. It is a SvelteKit app and it powers the [HuggingChat app on hf.co/chat](https://huggingface.co/chat). +A chat interface using open source models, eg Qwen, GPT-OSS, or Llama. It is a SvelteKit app and it powers the [HuggingChat app on hf.co/chat](https://huggingface.co/chat). 0. [Quickstart](#quickstart) 1. [Database Options](#database-options) @@ -11,11 +11,11 @@ A chat interface using open source models, eg OpenAssistant or Llama. It is a Sv 4. [Extra parameters](#extra-parameters) 5. [Building](#building) -> Note on models: Chat UI only supports OpenAI-compatible APIs via `OPENAI_BASE_URL` and the `/models` endpoint. Provider-specific integrations (legacy `MODELS` env var, GGUF discovery, embeddings, web-search helpers, etc.) are removed, but any service that speaks the OpenAI protocol—Hugging Face router, llama.cpp server, Ollama’s OpenAI bridge, OpenRouter, Anthropic-on-OpenRouter, etc.—will work. +> Note on models: Chat UI v2 only supports OpenAI-compatible APIs via `OPENAI_BASE_URL` and the `/models` endpoint. Provider-specific integrations (legacy `MODELS` env var, GGUF discovery, embeddings, web-search helpers, etc.) are removed, but any service that speaks the OpenAI protocol—Hugging Face router, llama.cpp server, Ollama’s OpenAI bridge, OpenRouter, Anthropic-on-OpenRouter, etc.—will work. ## Quickstart -Chat UI speaks to OpenAI-compatible APIs only. The fastest way to get running is with the Hugging Face Inference Providers router plus your personal Hugging Face access token. +Chat UI speaks to OpenAI-compatible APIs only. The fastest way to get running is with the [Hugging Face Inference Providers router](https://huggingface.co/docs/inference-providers) plus your personal Hugging Face access token. **Step 1 – Create `.env.local`:** diff --git a/src/hooks.server.ts b/src/hooks.server.ts index ff6d4dc2f64..9104b7e7d2f 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -2,7 +2,12 @@ import { config, ready } from "$lib/server/config"; import type { Handle, HandleServerError, ServerInit, HandleFetch } from "@sveltejs/kit"; import { collections } from "$lib/server/database"; import { base } from "$app/paths"; -import { authenticateRequest, refreshSessionCookie, requiresUser } from "$lib/server/auth"; +import { + authenticateFromHfCookie, + authenticateRequest, + refreshSessionCookie, + requiresUser, +} from "$lib/server/auth"; import { ERROR_MESSAGES } from "$lib/stores/errors"; import { addWeeks } from "date-fns"; import { checkAndRunMigrations } from "$lib/migrations/migrations"; @@ -121,10 +126,12 @@ export const handle: Handle = async ({ event, resolve }) => { } } - const auth = await authenticateRequest( - { type: "svelte", value: event.request.headers }, - { type: "svelte", value: event.cookies } - ); + const auth = config.USE_HFCO_COOKIE + ? await authenticateFromHfCookie(event.request.headers, event.cookies) + : await authenticateRequest( + { type: "svelte", value: event.request.headers }, + { type: "svelte", value: event.cookies } + ); event.locals.user = auth.user || undefined; event.locals.sessionId = auth.sessionId; diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 34c20a85077..e29976fed6c 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -17,6 +17,7 @@ import { logger } from "$lib/server/logger"; import { ObjectId } from "mongodb"; import type { Cookie } from "elysia"; import { adminTokenManager } from "./adminToken"; +import { updateUser } from "../../routes/login/callback/updateUser"; export interface OIDCSettings { redirectURI: string; @@ -311,3 +312,80 @@ export async function authenticateRequest( return { user: undefined, sessionId, secretSessionId, isAdmin: false }; } + +export async function authenticateFromHfCookie( + headers: Headers, + cookies: Cookies +): Promise { + const cookieHfco = cookies.get("token"); + + if (cookieHfco) { + const hash = await sha256(cookieHfco); + const sessionId = hash; + const secretSessionId = hash; + + const cacheHit = await collections.tokenCaches.findOne({ tokenHash: hash }); + if (cacheHit) { + const user = await collections.users.findOne({ hfUserId: cacheHit.userId }); + if (!user) { + throw new Error("User not found"); + } + return { + user, + sessionId, + secretSessionId, + isAdmin: user.isAdmin || adminTokenManager.isAdmin(sessionId), + }; + } + + /// Pass the cookie as an actual cookie, not a Bearer token. + const response = await fetch("https://huggingface.co/api/whoami-v2", { + headers: { Cookie: `token=${cookieHfco}` }, + }); + + if (!response.ok) { + throw new Error("Unauthorized"); + } + + const userData = z + .object({ + id: z.string(), + name: z.string(), + avatarUrl: z.string(), + }) + .parse(await response.json()); + + const locals = { + user: userData, + sessionId, + secretSessionId, + isAdmin: adminTokenManager.isAdmin(sessionId), + }; + await updateUser({ + userData, + locals, + cookies, + userAgent: headers.get("user-agent") ?? undefined, + }); + await collections.tokenCaches.insertOne({ + tokenHash: hash, + userId: userData.id, + createdAt: new Date(), + updatedAt: new Date(), + }); + + /// TODO: Get a JWT token for inference from the Hub + + return locals; + } + + // Generate new session if none exists + const secretSessionId = crypto.randomUUID(); + const sessionId = await sha256(secretSessionId); + + if (await collections.sessions.findOne({ sessionId })) { + throw new Error("Session ID collision"); + } + + return { user: undefined, sessionId, secretSessionId, isAdmin: false }; +}