Adrien Denat coyotte508 HF staff victor HF staff commited on
Commit
64d3841
1 Parent(s): 31ef570

Login flow (#193)

Browse files

* replace EthicsModal by LoginModal and start binding auth logic

* start implementing login modal + give a try to

@auth

/sveltekit

using Github oauth as a test

* start custom auth implementation instead of auth.js for consistency with moon-landing

* fetch user data from provider

* add migration from anonymous to user + bind frontend

* add missing auth secret + only migrate conversations for pre-existing users

* remove email scope as it's not needed

Co-authored-by: Eliott C. <coyotte508@gmail.com>

* no need to define .well-known path

Co-authored-by: Eliott C. <coyotte508@gmail.com>

* use env var for hf hub website url

* move sessionId to signature on csrf token exchange

* remove ethic modal check and set default settings on new users

* anonymous users can read only + modal on write tries

* refresh session cookie when existing user signin again rather than use the old one

* allow users to keep using the app without loging-in if env var is not present

* typo

* handle denied login

* do not use a form action for login as there is nothing post-ed

* use requiresUser instead of env var to define login modal or not

* move back to a form action for login so user can't be linked to /login directly

* show login modal even for pre-existing users

* fix logic of account creation/updates

* settings insertOne instead of updateOne

* fix missing userId to settings creation

* fix missing updatedAt when updating settings of pre-existing users

* show login modal for everyone + add comments

* fix login modal condition for both required/not required login

* 🔨

* bring back missing form values in login modal

* refactor default settings spread around to a constant

* missing default settings

* typo

* rename a bunch of things to remove SSO references

* always migrate conversations

* remove unneeded sha256 Node specific function, replace with browser crypto API

* fix typings

* Update src/lib/components/LoginModal.svelte

* use authCondition() in callback

* add logout

* 🐛 Fix signout

cc @Grsmto, because "path" of the cookie should be "/"

* fixup! 🐛 Fix signout

* sign out button ui

* add hf logo in the button

---------

Co-authored-by: Eliott C. <coyotte508@gmail.com>
Co-authored-by: Victor Mustar <victor.mustar@gmail.com>

.env CHANGED
@@ -7,8 +7,9 @@ COOKIE_NAME=hf-chat
7
  HF_ACCESS_TOKEN=#hf_<token> from from https://huggingface.co/settings/token
8
 
9
  # Parameters to enable "Sign in with HF"
10
- HF_CLIENT_ID=
11
- HF_CLIENT_SECRET=
 
12
 
13
  # 'name', 'userMessageToken', 'assistantMessageToken' are required
14
  MODELS=`[
 
7
  HF_ACCESS_TOKEN=#hf_<token> from from https://huggingface.co/settings/token
8
 
9
  # Parameters to enable "Sign in with HF"
10
+ OPENID_CLIENT_ID=
11
+ OPENID_CLIENT_SECRET=
12
+ OPENID_PROVIDER_URL=https://huggingface.co
13
 
14
  # 'name', 'userMessageToken', 'assistantMessageToken' are required
15
  MODELS=`[
package-lock.json CHANGED
@@ -17,6 +17,7 @@
17
  "marked": "^4.3.0",
18
  "mongodb": "^5.3.0",
19
  "nanoid": "^4.0.2",
 
20
  "parquetjs": "^0.11.2",
21
  "postcss": "^8.4.21",
22
  "tailwind-scrollbar": "^3.0.0",
@@ -2485,6 +2486,14 @@
2485
  "jiti": "bin/jiti.js"
2486
  }
2487
  },
 
 
 
 
 
 
 
 
2488
  "node_modules/js-sdsl": {
2489
  "version": "4.3.0",
2490
  "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz",
@@ -2615,7 +2624,6 @@
2615
  "version": "6.0.0",
2616
  "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
2617
  "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
2618
- "dev": true,
2619
  "dependencies": {
2620
  "yallist": "^4.0.0"
2621
  },
@@ -2916,6 +2924,14 @@
2916
  "node": ">=0.10"
2917
  }
2918
  },
 
 
 
 
 
 
 
 
2919
  "node_modules/once": {
2920
  "version": "1.4.0",
2921
  "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -2939,6 +2955,28 @@
2939
  "url": "https://github.com/sponsors/sindresorhus"
2940
  }
2941
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2942
  "node_modules/optionator": {
2943
  "version": "0.9.1",
2944
  "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
@@ -4458,8 +4496,7 @@
4458
  "node_modules/yallist": {
4459
  "version": "4.0.0",
4460
  "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
4461
- "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
4462
- "dev": true
4463
  },
4464
  "node_modules/yaml": {
4465
  "version": "1.10.2",
 
17
  "marked": "^4.3.0",
18
  "mongodb": "^5.3.0",
19
  "nanoid": "^4.0.2",
20
+ "openid-client": "^5.4.2",
21
  "parquetjs": "^0.11.2",
22
  "postcss": "^8.4.21",
23
  "tailwind-scrollbar": "^3.0.0",
 
2486
  "jiti": "bin/jiti.js"
2487
  }
2488
  },
2489
+ "node_modules/jose": {
2490
+ "version": "4.14.4",
2491
+ "resolved": "https://registry.npmjs.org/jose/-/jose-4.14.4.tgz",
2492
+ "integrity": "sha512-j8GhLiKmUAh+dsFXlX1aJCbt5KMibuKb+d7j1JaOJG6s2UjX1PQlW+OKB/sD4a/5ZYF4RcmYmLSndOoU3Lt/3g==",
2493
+ "funding": {
2494
+ "url": "https://github.com/sponsors/panva"
2495
+ }
2496
+ },
2497
  "node_modules/js-sdsl": {
2498
  "version": "4.3.0",
2499
  "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz",
 
2624
  "version": "6.0.0",
2625
  "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
2626
  "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
 
2627
  "dependencies": {
2628
  "yallist": "^4.0.0"
2629
  },
 
2924
  "node": ">=0.10"
2925
  }
2926
  },
2927
+ "node_modules/oidc-token-hash": {
2928
+ "version": "5.0.3",
2929
+ "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz",
2930
+ "integrity": "sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==",
2931
+ "engines": {
2932
+ "node": "^10.13.0 || >=12.0.0"
2933
+ }
2934
+ },
2935
  "node_modules/once": {
2936
  "version": "1.4.0",
2937
  "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
 
2955
  "url": "https://github.com/sponsors/sindresorhus"
2956
  }
2957
  },
2958
+ "node_modules/openid-client": {
2959
+ "version": "5.4.2",
2960
+ "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.4.2.tgz",
2961
+ "integrity": "sha512-lIhsdPvJ2RneBm3nGBBhQchpe3Uka//xf7WPHTIglery8gnckvW7Bd9IaQzekzXJvWthCMyi/xVEyGW0RFPytw==",
2962
+ "dependencies": {
2963
+ "jose": "^4.14.1",
2964
+ "lru-cache": "^6.0.0",
2965
+ "object-hash": "^2.2.0",
2966
+ "oidc-token-hash": "^5.0.3"
2967
+ },
2968
+ "funding": {
2969
+ "url": "https://github.com/sponsors/panva"
2970
+ }
2971
+ },
2972
+ "node_modules/openid-client/node_modules/object-hash": {
2973
+ "version": "2.2.0",
2974
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
2975
+ "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
2976
+ "engines": {
2977
+ "node": ">= 6"
2978
+ }
2979
+ },
2980
  "node_modules/optionator": {
2981
  "version": "0.9.1",
2982
  "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
 
4496
  "node_modules/yallist": {
4497
  "version": "4.0.0",
4498
  "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
4499
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
 
4500
  },
4501
  "node_modules/yaml": {
4502
  "version": "1.10.2",
package.json CHANGED
@@ -36,8 +36,8 @@
36
  },
37
  "type": "module",
38
  "dependencies": {
39
- "@huggingface/inference": "^2.2.0",
40
  "@huggingface/hub": "^0.5.1",
 
41
  "autoprefixer": "^10.4.14",
42
  "date-fns": "^2.29.3",
43
  "dotenv": "^16.0.3",
@@ -45,6 +45,7 @@
45
  "marked": "^4.3.0",
46
  "mongodb": "^5.3.0",
47
  "nanoid": "^4.0.2",
 
48
  "parquetjs": "^0.11.2",
49
  "postcss": "^8.4.21",
50
  "tailwind-scrollbar": "^3.0.0",
 
36
  },
37
  "type": "module",
38
  "dependencies": {
 
39
  "@huggingface/hub": "^0.5.1",
40
+ "@huggingface/inference": "^2.2.0",
41
  "autoprefixer": "^10.4.14",
42
  "date-fns": "^2.29.3",
43
  "dotenv": "^16.0.3",
 
45
  "marked": "^4.3.0",
46
  "mongodb": "^5.3.0",
47
  "nanoid": "^4.0.2",
48
+ "openid-client": "^5.4.2",
49
  "parquetjs": "^0.11.2",
50
  "postcss": "^8.4.21",
51
  "tailwind-scrollbar": "^3.0.0",
src/app.d.ts CHANGED
@@ -1,7 +1,7 @@
1
  /// <reference types="@sveltejs/kit" />
2
  /// <reference types="unplugin-icons/types/svelte" />
3
 
4
- import type { ObjectId } from "mongodb";
5
 
6
  // See https://kit.svelte.dev/docs/types#app
7
  // for information about these interfaces
@@ -10,7 +10,7 @@ declare global {
10
  // interface Error {}
11
  interface Locals {
12
  sessionId: string;
13
- userId?: ObjectId;
14
  }
15
  // interface PageData {}
16
  // interface Platform {}
 
1
  /// <reference types="@sveltejs/kit" />
2
  /// <reference types="unplugin-icons/types/svelte" />
3
 
4
+ import type { User } from "$lib/types/User";
5
 
6
  // See https://kit.svelte.dev/docs/types#app
7
  // for information about these interfaces
 
10
  // interface Error {}
11
  interface Locals {
12
  sessionId: string;
13
+ user?: User;
14
  }
15
  // interface PageData {}
16
  // interface Platform {}
src/hooks.server.ts CHANGED
@@ -1,14 +1,13 @@
1
- import { dev } from "$app/environment";
2
  import { COOKIE_NAME } from "$env/static/private";
3
  import type { Handle } from "@sveltejs/kit";
4
  import {
5
  PUBLIC_GOOGLE_ANALYTICS_ID,
6
  PUBLIC_DEPRECATED_GOOGLE_ANALYTICS_ID,
7
  } from "$env/static/public";
8
- import { addYears } from "date-fns";
9
  import { collections } from "$lib/server/database";
10
  import { base } from "$app/paths";
11
- import { requiresUser } from "$lib/server/auth";
 
12
 
13
  export const handle: Handle = async ({ event, resolve }) => {
14
  const token = event.cookies.get(COOKIE_NAME);
@@ -18,10 +17,11 @@ export const handle: Handle = async ({ event, resolve }) => {
18
  const user = await collections.users.findOne({ sessionId: event.locals.sessionId });
19
 
20
  if (user) {
21
- event.locals.userId = user._id;
22
  }
23
 
24
  if (
 
25
  !event.url.pathname.startsWith(`${base}/admin`) &&
26
  !["GET", "OPTIONS", "HEAD"].includes(event.request.method)
27
  ) {
@@ -31,9 +31,7 @@ export const handle: Handle = async ({ event, resolve }) => {
31
 
32
  if (!user && requiresUser) {
33
  return new Response(
34
- sendJson
35
- ? JSON.stringify({ error: "You need to be logged in first" })
36
- : "You need to be logged in first",
37
  {
38
  status: 401,
39
  headers: {
@@ -43,7 +41,9 @@ export const handle: Handle = async ({ event, resolve }) => {
43
  );
44
  }
45
 
46
- if (!event.url.pathname.startsWith(`${base}/settings`)) {
 
 
47
  const hasAcceptedEthicsModal = await collections.settings.countDocuments({
48
  sessionId: event.locals.sessionId,
49
  ethicsModalAcceptedAt: { $exists: true },
@@ -65,15 +65,7 @@ export const handle: Handle = async ({ event, resolve }) => {
65
  }
66
  }
67
 
68
- // Refresh cookie expiration date
69
- event.cookies.set(COOKIE_NAME, event.locals.sessionId, {
70
- path: "/",
71
- // So that it works inside the space's iframe
72
- sameSite: dev ? "lax" : "none",
73
- secure: !dev,
74
- httpOnly: true,
75
- expires: addYears(new Date(), 1),
76
- });
77
 
78
  let replaced = false;
79
 
 
 
1
  import { COOKIE_NAME } from "$env/static/private";
2
  import type { Handle } from "@sveltejs/kit";
3
  import {
4
  PUBLIC_GOOGLE_ANALYTICS_ID,
5
  PUBLIC_DEPRECATED_GOOGLE_ANALYTICS_ID,
6
  } from "$env/static/public";
 
7
  import { collections } from "$lib/server/database";
8
  import { base } from "$app/paths";
9
+ import { refreshSessionCookie, requiresUser } from "$lib/server/auth";
10
+ import { ERROR_MESSAGES } from "$lib/stores/errors";
11
 
12
  export const handle: Handle = async ({ event, resolve }) => {
13
  const token = event.cookies.get(COOKIE_NAME);
 
17
  const user = await collections.users.findOne({ sessionId: event.locals.sessionId });
18
 
19
  if (user) {
20
+ event.locals.user = user;
21
  }
22
 
23
  if (
24
+ !event.url.pathname.startsWith(`${base}/login`) &&
25
  !event.url.pathname.startsWith(`${base}/admin`) &&
26
  !["GET", "OPTIONS", "HEAD"].includes(event.request.method)
27
  ) {
 
31
 
32
  if (!user && requiresUser) {
33
  return new Response(
34
+ sendJson ? JSON.stringify({ error: ERROR_MESSAGES.authOnly }) : ERROR_MESSAGES.authOnly,
 
 
35
  {
36
  status: 401,
37
  headers: {
 
41
  );
42
  }
43
 
44
+ // if login is not required and the call is not from /settings, we check if the user has accepted the ethics modal first.
45
+ // If login is required, `ethicsModalAcceptedAt` is already true at this point, so do not pass this condition. This saves a DB call.
46
+ if (!requiresUser && !event.url.pathname.startsWith(`${base}/settings`)) {
47
  const hasAcceptedEthicsModal = await collections.settings.countDocuments({
48
  sessionId: event.locals.sessionId,
49
  ethicsModalAcceptedAt: { $exists: true },
 
65
  }
66
  }
67
 
68
+ refreshSessionCookie(event.cookies, event.locals.sessionId);
 
 
 
 
 
 
 
 
69
 
70
  let replaced = false;
71
 
src/lib/components/{EthicsModal.svelte → LoginModal.svelte} RENAMED
@@ -1,8 +1,10 @@
1
  <script lang="ts">
2
  import { enhance } from "$app/forms";
3
  import { base } from "$app/paths";
 
4
  import { PUBLIC_VERSION } from "$env/static/public";
5
  import Logo from "$lib/components/icons/Logo.svelte";
 
6
  import Modal from "$lib/components/Modal.svelte";
7
  import type { LayoutData } from "../../routes/$types";
8
 
@@ -31,17 +33,31 @@
31
  <p class="px-2 text-sm text-gray-500">
32
  Your conversations will be shared with model authors unless you disable it from your settings.
33
  </p>
34
- <form action="{base}/settings" use:enhance method="POST">
35
- <input type="hidden" name="ethicsModalAccepted" value={true} />
36
- {#each Object.entries(settings) as [key, val]}
37
- <input type="hidden" name={key} value={val} />
38
- {/each}
39
- <button
40
- type="submit"
41
- class="mt-2 rounded-full bg-black px-5 py-2 text-lg font-semibold text-gray-100 transition-colors hover:bg-yellow-500"
42
- >
43
- Start chatting
44
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  </form>
46
  </div>
47
  </Modal>
 
1
  <script lang="ts">
2
  import { enhance } from "$app/forms";
3
  import { base } from "$app/paths";
4
+ import { page } from "$app/stores";
5
  import { PUBLIC_VERSION } from "$env/static/public";
6
  import Logo from "$lib/components/icons/Logo.svelte";
7
+ import LogoHuggingFaceBorderless from "$lib/components/icons/LogoHuggingFaceBorderless.svelte";
8
  import Modal from "$lib/components/Modal.svelte";
9
  import type { LayoutData } from "../../routes/$types";
10
 
 
33
  <p class="px-2 text-sm text-gray-500">
34
  Your conversations will be shared with model authors unless you disable it from your settings.
35
  </p>
36
+ <form
37
+ action="{base}/{$page.data.requiresLogin ? 'login' : 'settings'}"
38
+ use:enhance
39
+ method="POST"
40
+ >
41
+ {#if $page.data.requiresLogin}
42
+ <button
43
+ type="submit"
44
+ class="mt-2 flex items-center whitespace-nowrap rounded-full bg-black px-5 py-2 text-lg font-semibold text-gray-100 transition-colors hover:bg-yellow-500"
45
+ >
46
+ Sign in with <LogoHuggingFaceBorderless classNames="text-xl mr-1 ml-1.5" /> Hugging Face
47
+ </button>
48
+ <p class="mt-2 px-2 text-sm text-gray-500">to start chatting right away</p>
49
+ {:else}
50
+ <input type="hidden" name="ethicsModalAccepted" value={true} />
51
+ {#each Object.entries(settings) as [key, val]}
52
+ <input type="hidden" name={key} value={val} />
53
+ {/each}
54
+ <button
55
+ type="submit"
56
+ class="mt-2 rounded-full bg-black px-5 py-2 text-lg font-semibold text-gray-100 transition-colors hover:bg-yellow-500"
57
+ >
58
+ Start chatting
59
+ </button>
60
+ {/if}
61
  </form>
62
  </div>
63
  </Modal>
src/lib/components/NavMenu.svelte CHANGED
@@ -10,12 +10,14 @@
10
  const dispatch = createEventDispatcher<{
11
  shareConversation: { id: string; title: string };
12
  clickSettings: void;
 
13
  }>();
14
 
15
  export let conversations: Array<{
16
  id: string;
17
  title: string;
18
  }> = [];
 
19
  </script>
20
 
21
  <div class="sticky top-0 flex flex-none items-center justify-between px-3 py-3.5 max-sm:pt-0">
@@ -40,17 +42,34 @@
40
  <div
41
  class="mt-0.5 flex flex-col gap-1 rounded-r-xl bg-gradient-to-l from-gray-50 p-3 text-sm dark:from-gray-800/30"
42
  >
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  <button
44
  on:click={switchTheme}
45
  type="button"
46
- class="group flex h-9 flex-none items-center gap-1.5 rounded-lg pl-3 pr-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
47
  >
48
  Theme
49
  </button>
50
  <button
51
  on:click={() => dispatch("clickSettings")}
52
  type="button"
53
- class="group flex h-9 flex-none items-center gap-1.5 rounded-lg pl-3 pr-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
54
  >
55
  Settings
56
  </button>
@@ -58,13 +77,13 @@
58
  href="https://huggingface.co/spaces/huggingchat/chat-ui/discussions"
59
  target="_blank"
60
  rel="noreferrer"
61
- class="group flex h-9 flex-none items-center gap-1.5 rounded-lg pl-3 pr-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
62
  >
63
  Feedback
64
  </a>
65
  <a
66
  href="{base}/privacy"
67
- class="group flex h-9 flex-none items-center gap-1.5 rounded-lg pl-3 pr-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
68
  >
69
  About & Privacy
70
  </a>
 
10
  const dispatch = createEventDispatcher<{
11
  shareConversation: { id: string; title: string };
12
  clickSettings: void;
13
+ clickLogout: void;
14
  }>();
15
 
16
  export let conversations: Array<{
17
  id: string;
18
  title: string;
19
  }> = [];
20
+ export let user: { username: string } | undefined;
21
  </script>
22
 
23
  <div class="sticky top-0 flex flex-none items-center justify-between px-3 py-3.5 max-sm:pt-0">
 
42
  <div
43
  class="mt-0.5 flex flex-col gap-1 rounded-r-xl bg-gradient-to-l from-gray-50 p-3 text-sm dark:from-gray-800/30"
44
  >
45
+ {#if user?.username}
46
+ <form
47
+ action="{base}/logout"
48
+ method="post"
49
+ class="group flex items-center gap-1.5 rounded-lg pl-3 pr-2 hover:bg-gray-100 dark:hover:bg-gray-700"
50
+ >
51
+ <span class="flex h-9 flex-none items-center gap-1.5 pr-2 text-gray-500 dark:text-gray-400"
52
+ >{user?.username}</span
53
+ >
54
+ <button
55
+ type="submit"
56
+ class="ml-auto h-6 flex-none items-center gap-1.5 rounded-md border bg-white px-2 text-gray-700 shadow-sm group-hover:flex hover:shadow-none dark:border-gray-600 dark:bg-gray-600 dark:text-gray-400 dark:hover:text-gray-300 md:hidden"
57
+ >
58
+ Sign Out
59
+ </button>
60
+ </form>
61
+ {/if}
62
  <button
63
  on:click={switchTheme}
64
  type="button"
65
+ class="flex h-9 flex-none items-center gap-1.5 rounded-lg pl-3 pr-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
66
  >
67
  Theme
68
  </button>
69
  <button
70
  on:click={() => dispatch("clickSettings")}
71
  type="button"
72
+ class="flex h-9 flex-none items-center gap-1.5 rounded-lg pl-3 pr-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
73
  >
74
  Settings
75
  </button>
 
77
  href="https://huggingface.co/spaces/huggingchat/chat-ui/discussions"
78
  target="_blank"
79
  rel="noreferrer"
80
+ class="flex h-9 flex-none items-center gap-1.5 rounded-lg pl-3 pr-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
81
  >
82
  Feedback
83
  </a>
84
  <a
85
  href="{base}/privacy"
86
+ class="flex h-9 flex-none items-center gap-1.5 rounded-lg pl-3 pr-2 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
87
  >
88
  About & Privacy
89
  </a>
src/lib/components/icons/LogoHuggingFaceBorderless.svelte ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ export let classNames = "";
3
+ </script>
4
+
5
+ <svg
6
+ class={classNames}
7
+ xmlns="http://www.w3.org/2000/svg"
8
+ width="1em"
9
+ height="1em"
10
+ fill="none"
11
+ viewBox="0 0 95 88"
12
+ >
13
+ <path fill="#FFD21E" d="M47.21 76.5a34.75 34.75 0 1 0 0-69.5 34.75 34.75 0 0 0 0 69.5Z" />
14
+ <path
15
+ fill="#FF9D0B"
16
+ d="M81.96 41.75a34.75 34.75 0 1 0-69.5 0 34.75 34.75 0 0 0 69.5 0Zm-73.5 0a38.75 38.75 0 1 1 77.5 0 38.75 38.75 0 0 1-77.5 0Z"
17
+ />
18
+ <path
19
+ fill="#3A3B45"
20
+ d="M58.5 32.3c1.28.44 1.78 3.06 3.07 2.38a5 5 0 1 0-6.76-2.07c.61 1.15 2.55-.72 3.7-.32ZM34.95 32.3c-1.28.44-1.79 3.06-3.07 2.38a5 5 0 1 1 6.76-2.07c-.61 1.15-2.56-.72-3.7-.32ZM46.96 56.29c9.83 0 13-8.76 13-13.26 0-2.34-1.57-1.6-4.09-.36-2.33 1.15-5.46 2.74-8.9 2.74-7.19 0-13-6.88-13-2.38s3.16 13.26 13 13.26Z"
21
+ />
22
+ <mask id="a" width="27" height="16" x="33" y="41" maskUnits="userSpaceOnUse">
23
+ <path
24
+ fill="#fff"
25
+ d="M46.96 56.29c9.83 0 13-8.76 13-13.26 0-2.34-1.57-1.6-4.09-.36-2.33 1.15-5.46 2.74-8.9 2.74-7.19 0-13-6.88-13-2.38s3.16 13.26 13 13.26Z"
26
+ />
27
+ </mask>
28
+ <g mask="url(#a)">
29
+ <path
30
+ fill="#F94040"
31
+ d="M47.21 66.5a8.67 8.67 0 0 0 2.65-16.94c-.84-.26-1.73 2.6-2.65 2.6-.86 0-1.7-2.88-2.48-2.65a8.68 8.68 0 0 0 2.48 16.99Z"
32
+ />
33
+ </g>
34
+ <path
35
+ fill="#FF9D0B"
36
+ d="M70.71 37a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5ZM24.21 37a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5ZM17.52 48c-1.62 0-3.06.66-4.07 1.87a5.97 5.97 0 0 0-1.33 3.76 7.1 7.1 0 0 0-1.94-.3c-1.55 0-2.95.59-3.94 1.66a5.8 5.8 0 0 0-.8 7 5.3 5.3 0 0 0-1.79 2.82c-.24.9-.48 2.8.8 4.74a5.22 5.22 0 0 0-.37 5.02c1.02 2.32 3.57 4.14 8.52 6.1 3.07 1.22 5.89 2 5.91 2.01a44.33 44.33 0 0 0 10.93 1.6c5.86 0 10.05-1.8 12.46-5.34 3.88-5.69 3.33-10.9-1.7-15.92-2.77-2.78-4.62-6.87-5-7.77-.78-2.66-2.84-5.62-6.25-5.62a5.7 5.7 0 0 0-4.6 2.46c-1-1.26-1.98-2.25-2.86-2.82A7.4 7.4 0 0 0 17.52 48Zm0 4c.51 0 1.14.22 1.82.65 2.14 1.36 6.25 8.43 7.76 11.18.5.92 1.37 1.31 2.14 1.31 1.55 0 2.75-1.53.15-3.48-3.92-2.93-2.55-7.72-.68-8.01.08-.02.17-.02.24-.02 1.7 0 2.45 2.93 2.45 2.93s2.2 5.52 5.98 9.3c3.77 3.77 3.97 6.8 1.22 10.83-1.88 2.75-5.47 3.58-9.16 3.58-3.81 0-7.73-.9-9.92-1.46-.11-.03-13.45-3.8-11.76-7 .28-.54.75-.76 1.34-.76 2.38 0 6.7 3.54 8.57 3.54.41 0 .7-.17.83-.6.79-2.85-12.06-4.05-10.98-8.17.2-.73.71-1.02 1.44-1.02 3.14 0 10.2 5.53 11.68 5.53.11 0 .2-.03.24-.1.74-1.2.33-2.04-4.9-5.2-5.21-3.16-8.88-5.06-6.8-7.33.24-.26.58-.38 1-.38 3.17 0 10.66 6.82 10.66 6.82s2.02 2.1 3.25 2.1c.28 0 .52-.1.68-.38.86-1.46-8.06-8.22-8.56-11.01-.34-1.9.24-2.85 1.31-2.85Z"
37
+ />
38
+ <path
39
+ fill="#FFD21E"
40
+ d="M38.6 76.69c2.75-4.04 2.55-7.07-1.22-10.84-3.78-3.77-5.98-9.3-5.98-9.3s-.82-3.2-2.69-2.9c-1.87.3-3.24 5.08.68 8.01 3.91 2.93-.78 4.92-2.29 2.17-1.5-2.75-5.62-9.82-7.76-11.18-2.13-1.35-3.63-.6-3.13 2.2.5 2.79 9.43 9.55 8.56 11-.87 1.47-3.93-1.71-3.93-1.71s-9.57-8.71-11.66-6.44c-2.08 2.27 1.59 4.17 6.8 7.33 5.23 3.16 5.64 4 4.9 5.2-.75 1.2-12.28-8.53-13.36-4.4-1.08 4.11 11.77 5.3 10.98 8.15-.8 2.85-9.06-5.38-10.74-2.18-1.7 3.21 11.65 6.98 11.76 7.01 4.3 1.12 15.25 3.49 19.08-2.12Z"
41
+ />
42
+ <path
43
+ fill="#FF9D0B"
44
+ d="M77.4 48c1.62 0 3.07.66 4.07 1.87a5.97 5.97 0 0 1 1.33 3.76 7.1 7.1 0 0 1 1.95-.3c1.55 0 2.95.59 3.94 1.66a5.8 5.8 0 0 1 .8 7 5.3 5.3 0 0 1 1.78 2.82c.24.9.48 2.8-.8 4.74a5.22 5.22 0 0 1 .37 5.02c-1.02 2.32-3.57 4.14-8.51 6.1-3.08 1.22-5.9 2-5.92 2.01a44.33 44.33 0 0 1-10.93 1.6c-5.86 0-10.05-1.8-12.46-5.34-3.88-5.69-3.33-10.9 1.7-15.92 2.78-2.78 4.63-6.87 5.01-7.77.78-2.66 2.83-5.62 6.24-5.62a5.7 5.7 0 0 1 4.6 2.46c1-1.26 1.98-2.25 2.87-2.82A7.4 7.4 0 0 1 77.4 48Zm0 4c-.51 0-1.13.22-1.82.65-2.13 1.36-6.25 8.43-7.76 11.18a2.43 2.43 0 0 1-2.14 1.31c-1.54 0-2.75-1.53-.14-3.48 3.91-2.93 2.54-7.72.67-8.01a1.54 1.54 0 0 0-.24-.02c-1.7 0-2.45 2.93-2.45 2.93s-2.2 5.52-5.97 9.3c-3.78 3.77-3.98 6.8-1.22 10.83 1.87 2.75 5.47 3.58 9.15 3.58 3.82 0 7.73-.9 9.93-1.46.1-.03 13.45-3.8 11.76-7-.29-.54-.75-.76-1.34-.76-2.38 0-6.71 3.54-8.57 3.54-.42 0-.71-.17-.83-.6-.8-2.85 12.05-4.05 10.97-8.17-.19-.73-.7-1.02-1.44-1.02-3.14 0-10.2 5.53-11.68 5.53-.1 0-.19-.03-.23-.1-.74-1.2-.34-2.04 4.88-5.2 5.23-3.16 8.9-5.06 6.8-7.33-.23-.26-.57-.38-.98-.38-3.18 0-10.67 6.82-10.67 6.82s-2.02 2.1-3.24 2.1a.74.74 0 0 1-.68-.38c-.87-1.46 8.05-8.22 8.55-11.01.34-1.9-.24-2.85-1.31-2.85Z"
45
+ />
46
+ <path
47
+ fill="#FFD21E"
48
+ d="M56.33 76.69c-2.75-4.04-2.56-7.07 1.22-10.84 3.77-3.77 5.97-9.3 5.97-9.3s.82-3.2 2.7-2.9c1.86.3 3.23 5.08-.68 8.01-3.92 2.93.78 4.92 2.28 2.17 1.51-2.75 5.63-9.82 7.76-11.18 2.13-1.35 3.64-.6 3.13 2.2-.5 2.79-9.42 9.55-8.55 11 .86 1.47 3.92-1.71 3.92-1.71s9.58-8.71 11.66-6.44c2.08 2.27-1.58 4.17-6.8 7.33-5.23 3.16-5.63 4-4.9 5.2.75 1.2 12.28-8.53 13.36-4.4 1.08 4.11-11.76 5.3-10.97 8.15.8 2.85 9.05-5.38 10.74-2.18 1.69 3.21-11.65 6.98-11.76 7.01-4.31 1.12-15.26 3.49-19.08-2.12Z"
49
+ />
50
+ </svg>
src/lib/server/auth.ts CHANGED
@@ -1,9 +1,111 @@
1
- import { HF_CLIENT_ID, HF_CLIENT_SECRET } from "$env/static/private";
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
- export const requiresUser = !!HF_CLIENT_ID && !!HF_CLIENT_SECRET;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  export const authCondition = (locals: App.Locals) => {
6
- return locals.userId
7
- ? { userId: locals.userId }
8
  : { sessionId: locals.sessionId, userId: { $exists: false } };
9
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Issuer, BaseClient, type UserinfoResponse, TokenSet } from "openid-client";
2
+ import { addDays, addYears } from "date-fns";
3
+ import {
4
+ COOKIE_NAME,
5
+ OPENID_CLIENT_ID,
6
+ OPENID_CLIENT_SECRET,
7
+ OPENID_PROVIDER_URL,
8
+ } from "$env/static/private";
9
+ import { PUBLIC_ORIGIN } from "$env/static/public";
10
+ import { sha256 } from "$lib/utils/sha256";
11
+ import { z } from "zod";
12
+ import { base } from "$app/paths";
13
+ import { dev } from "$app/environment";
14
+ import type { Cookies } from "@sveltejs/kit";
15
 
16
+ export interface OIDCSettings {
17
+ redirectURI: string;
18
+ }
19
+
20
+ export interface OIDCUserInfo {
21
+ token: TokenSet;
22
+ userData: UserinfoResponse;
23
+ }
24
+
25
+ export const requiresUser = !!OPENID_CLIENT_ID && !!OPENID_CLIENT_SECRET;
26
+
27
+ export function refreshSessionCookie(cookies: Cookies, sessionId: string) {
28
+ cookies.set(COOKIE_NAME, sessionId, {
29
+ path: "/",
30
+ // So that it works inside the space's iframe
31
+ sameSite: dev ? "lax" : "none",
32
+ secure: !dev,
33
+ httpOnly: true,
34
+ expires: addYears(new Date(), 1),
35
+ });
36
+ }
37
+
38
+ export const getRedirectURI = (url: URL) => `${PUBLIC_ORIGIN || url.origin}${base}/login/callback`;
39
+
40
+ export const OIDC_SCOPES = "openid profile";
41
 
42
  export const authCondition = (locals: App.Locals) => {
43
+ return locals.user
44
+ ? { userId: locals.user._id }
45
  : { sessionId: locals.sessionId, userId: { $exists: false } };
46
  };
47
+
48
+ /**
49
+ * Generates a CSRF token using the user sessionId. Note that we don't need a secret because sessionId is enough.
50
+ */
51
+ export async function generateCsrfToken(sessionId: string): Promise<string> {
52
+ const data = { expiration: addDays(new Date(), 1).getTime() };
53
+
54
+ return Buffer.from(
55
+ JSON.stringify({
56
+ data,
57
+ signature: await sha256(JSON.stringify(data) + "##" + sessionId),
58
+ })
59
+ ).toString("base64");
60
+ }
61
+
62
+ async function getOIDCClient(settings: OIDCSettings): Promise<BaseClient> {
63
+ const issuer = await Issuer.discover(OPENID_PROVIDER_URL);
64
+ return new issuer.Client({
65
+ client_id: OPENID_CLIENT_ID,
66
+ client_secret: OPENID_CLIENT_SECRET,
67
+ redirect_uris: [settings.redirectURI],
68
+ response_types: ["code"],
69
+ });
70
+ }
71
+
72
+ export async function getOIDCAuthorizationUrl(
73
+ settings: OIDCSettings,
74
+ params: { sessionId: string }
75
+ ): Promise<string> {
76
+ const client = await getOIDCClient(settings);
77
+ const csrfToken = await generateCsrfToken(params.sessionId);
78
+ const url = client.authorizationUrl({
79
+ scope: OIDC_SCOPES,
80
+ state: csrfToken,
81
+ });
82
+
83
+ return url;
84
+ }
85
+
86
+ export async function getOIDCUserData(settings: OIDCSettings, code: string): Promise<OIDCUserInfo> {
87
+ const client = await getOIDCClient(settings);
88
+ const token = await client.callback(settings.redirectURI, { code });
89
+ const userData = await client.userinfo(token);
90
+
91
+ return { token, userData };
92
+ }
93
+
94
+ export async function validateCsrfToken(token: string, sessionId: string) {
95
+ try {
96
+ const { data, signature } = z
97
+ .object({
98
+ data: z.object({
99
+ expiration: z.number().int(),
100
+ }),
101
+ signature: z.string().length(64),
102
+ })
103
+ .parse(JSON.parse(token));
104
+ const reconstructSign = await sha256(JSON.stringify(data) + "##" + sessionId);
105
+
106
+ return data.expiration > Date.now() && signature === reconstructSign;
107
+ } catch (e) {
108
+ console.error(e);
109
+ return false;
110
+ }
111
+ }
src/lib/stores/errors.ts CHANGED
@@ -2,6 +2,7 @@ import { writable } from "svelte/store";
2
 
3
  export const ERROR_MESSAGES = {
4
  default: "Oops, something went wrong.",
 
5
  };
6
 
7
  export const error = writable<string | null>(null);
 
2
 
3
  export const ERROR_MESSAGES = {
4
  default: "Oops, something went wrong.",
5
+ authOnly: "You have to be logged in.",
6
  };
7
 
8
  export const error = writable<string | null>(null);
src/lib/types/Settings.ts CHANGED
@@ -1,3 +1,4 @@
 
1
  import type { Timestamps } from "./Timestamps";
2
  import type { User } from "./User";
3
 
@@ -14,3 +15,9 @@ export interface Settings extends Timestamps {
14
  ethicsModalAcceptedAt: Date | null;
15
  activeModel: string;
16
  }
 
 
 
 
 
 
 
1
+ import { defaultModel } from "$lib/server/models";
2
  import type { Timestamps } from "./Timestamps";
3
  import type { User } from "./User";
4
 
 
15
  ethicsModalAcceptedAt: Date | null;
16
  activeModel: string;
17
  }
18
+
19
+ // TODO: move this to a constant file along with other constants
20
+ export const DEFAULT_SETTINGS = {
21
+ shareConversationsWithModelAuthors: true,
22
+ activeModel: defaultModel.id,
23
+ };
src/lib/types/User.ts CHANGED
@@ -10,5 +10,5 @@ export interface User extends Timestamps {
10
  hfUserId: string;
11
 
12
  // Session identifier, stored in the cookie
13
- sessionId?: string;
14
  }
 
10
  hfUserId: string;
11
 
12
  // Session identifier, stored in the cookie
13
+ sessionId: string;
14
  }
src/lib/utils/sha256.ts CHANGED
@@ -1,3 +1,5 @@
 
 
1
  export async function sha256(input: string): Promise<string> {
2
  const utf8 = new TextEncoder().encode(input);
3
  const hashBuffer = await crypto.subtle.digest("SHA-256", utf8);
 
1
+ import * as crypto from "crypto";
2
+
3
  export async function sha256(input: string): Promise<string> {
4
  const utf8 = new TextEncoder().encode(input);
5
  const hashBuffer = await crypto.subtle.digest("SHA-256", utf8);
src/routes/+layout.server.ts CHANGED
@@ -4,7 +4,8 @@ import { collections } from "$lib/server/database";
4
  import type { Conversation } from "$lib/types/Conversation";
5
  import { UrlDependency } from "$lib/types/UrlDependency";
6
  import { defaultModel, models, oldModels, validateModel } from "$lib/server/models";
7
- import { authCondition } from "$lib/server/auth";
 
8
 
9
  export const load: LayoutServerLoad = async ({ locals, depends, url }) => {
10
  const { conversations } = collections;
@@ -54,9 +55,11 @@ export const load: LayoutServerLoad = async ({ locals, depends, url }) => {
54
  }))
55
  .toArray(),
56
  settings: {
57
- shareConversationsWithModelAuthors: settings?.shareConversationsWithModelAuthors ?? true,
 
 
58
  ethicsModalAcceptedAt: settings?.ethicsModalAcceptedAt ?? null,
59
- activeModel: settings?.activeModel ?? defaultModel.id,
60
  },
61
  models: models.map((model) => ({
62
  id: model.id,
@@ -69,5 +72,7 @@ export const load: LayoutServerLoad = async ({ locals, depends, url }) => {
69
  parameters: model.parameters,
70
  })),
71
  oldModels,
 
 
72
  };
73
  };
 
4
  import type { Conversation } from "$lib/types/Conversation";
5
  import { UrlDependency } from "$lib/types/UrlDependency";
6
  import { defaultModel, models, oldModels, validateModel } from "$lib/server/models";
7
+ import { authCondition, requiresUser } from "$lib/server/auth";
8
+ import { DEFAULT_SETTINGS } from "$lib/types/Settings";
9
 
10
  export const load: LayoutServerLoad = async ({ locals, depends, url }) => {
11
  const { conversations } = collections;
 
55
  }))
56
  .toArray(),
57
  settings: {
58
+ shareConversationsWithModelAuthors:
59
+ settings?.shareConversationsWithModelAuthors ??
60
+ DEFAULT_SETTINGS.shareConversationsWithModelAuthors,
61
  ethicsModalAcceptedAt: settings?.ethicsModalAcceptedAt ?? null,
62
+ activeModel: settings?.activeModel ?? DEFAULT_SETTINGS.activeModel,
63
  },
64
  models: models.map((model) => ({
65
  id: model.id,
 
72
  parameters: model.parameters,
73
  })),
74
  oldModels,
75
+ user: locals.user && { username: locals.user.username, avatarUrl: locals.user.avatarUrl },
76
+ requiresLogin: requiresUser,
77
  };
78
  };
src/routes/+layout.svelte CHANGED
@@ -13,8 +13,8 @@
13
  import MobileNav from "$lib/components/MobileNav.svelte";
14
  import NavMenu from "$lib/components/NavMenu.svelte";
15
  import Toast from "$lib/components/Toast.svelte";
16
- import EthicsModal from "$lib/components/EthicsModal.svelte";
17
  import SettingsModal from "$lib/components/SettingsModal.svelte";
 
18
 
19
  export let data;
20
 
@@ -113,6 +113,7 @@
113
  >
114
  <NavMenu
115
  conversations={data.conversations}
 
116
  on:shareConversation={(ev) => shareConversation(ev.detail.id, ev.detail.title)}
117
  on:deleteConversation={(ev) => deleteConversation(ev.detail)}
118
  on:clickSettings={() => (isSettingsOpen = true)}
@@ -122,6 +123,7 @@
122
  <nav class="grid max-h-screen grid-cols-1 grid-rows-[auto,1fr,auto] max-md:hidden">
123
  <NavMenu
124
  conversations={data.conversations}
 
125
  on:shareConversation={(ev) => shareConversation(ev.detail.id, ev.detail.title)}
126
  on:deleteConversation={(ev) => deleteConversation(ev.detail)}
127
  on:clickSettings={() => (isSettingsOpen = true)}
@@ -134,8 +136,8 @@
134
  {#if isSettingsOpen}
135
  <SettingsModal on:close={() => (isSettingsOpen = false)} settings={data.settings} />
136
  {/if}
137
- {#if !data.settings.ethicsModalAcceptedAt}
138
- <EthicsModal settings={data.settings} />
139
  {/if}
140
  <slot />
141
  </div>
 
13
  import MobileNav from "$lib/components/MobileNav.svelte";
14
  import NavMenu from "$lib/components/NavMenu.svelte";
15
  import Toast from "$lib/components/Toast.svelte";
 
16
  import SettingsModal from "$lib/components/SettingsModal.svelte";
17
+ import LoginModal from "$lib/components/LoginModal.svelte";
18
 
19
  export let data;
20
 
 
113
  >
114
  <NavMenu
115
  conversations={data.conversations}
116
+ user={data.user}
117
  on:shareConversation={(ev) => shareConversation(ev.detail.id, ev.detail.title)}
118
  on:deleteConversation={(ev) => deleteConversation(ev.detail)}
119
  on:clickSettings={() => (isSettingsOpen = true)}
 
123
  <nav class="grid max-h-screen grid-cols-1 grid-rows-[auto,1fr,auto] max-md:hidden">
124
  <NavMenu
125
  conversations={data.conversations}
126
+ user={data.user}
127
  on:shareConversation={(ev) => shareConversation(ev.detail.id, ev.detail.title)}
128
  on:deleteConversation={(ev) => deleteConversation(ev.detail)}
129
  on:clickSettings={() => (isSettingsOpen = true)}
 
136
  {#if isSettingsOpen}
137
  <SettingsModal on:close={() => (isSettingsOpen = false)} settings={data.settings} />
138
  {/if}
139
+ {#if data.requiresLogin ? !data.user : !data.settings.ethicsModalAcceptedAt}
140
+ <LoginModal settings={data.settings} />
141
  {/if}
142
  <slot />
143
  </div>
src/routes/conversation/+server.ts CHANGED
@@ -44,7 +44,7 @@ export const POST: RequestHandler = async ({ locals, request }) => {
44
  model: values.model,
45
  createdAt: new Date(),
46
  updatedAt: new Date(),
47
- ...(locals.userId ? { userId: locals.userId } : { sessionId: locals.sessionId }),
48
  ...(values.fromShare ? { meta: { fromShareId: values.fromShare } } : {}),
49
  });
50
 
 
44
  model: values.model,
45
  createdAt: new Date(),
46
  updatedAt: new Date(),
47
+ ...(locals.user ? { userId: locals.user._id } : { sessionId: locals.sessionId }),
48
  ...(values.fromShare ? { meta: { fromShareId: values.fromShare } } : {}),
49
  });
50
 
src/routes/login/+page.server.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { redirect } from "@sveltejs/kit";
2
+ import { getOIDCAuthorizationUrl, getRedirectURI } from "$lib/server/auth";
3
+
4
+ export const actions = {
5
+ default: async function ({ url, locals }) {
6
+ // TODO: Handle errors if provider is not responding
7
+ const authorizationUrl = await getOIDCAuthorizationUrl(
8
+ { redirectURI: getRedirectURI(url) },
9
+ { sessionId: locals.sessionId }
10
+ );
11
+
12
+ throw redirect(303, authorizationUrl);
13
+ },
14
+ };
src/routes/login/callback/+server.ts ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { redirect, error } from "@sveltejs/kit";
2
+ import {
3
+ authCondition,
4
+ getOIDCUserData,
5
+ getRedirectURI,
6
+ refreshSessionCookie,
7
+ validateCsrfToken,
8
+ } from "$lib/server/auth";
9
+ import { z } from "zod";
10
+ import { collections } from "$lib/server/database";
11
+ import { ObjectId } from "mongodb";
12
+ import { base } from "$app/paths";
13
+ import { DEFAULT_SETTINGS } from "$lib/types/Settings";
14
+
15
+ export async function GET({ url, locals, cookies }) {
16
+ const { error: errorName } = z
17
+ .object({
18
+ error: z.string().optional(),
19
+ })
20
+ .parse(Object.fromEntries(url.searchParams.entries()));
21
+
22
+ if (errorName) {
23
+ // TODO: Display denied error on the UI
24
+ throw redirect(302, base || "/");
25
+ }
26
+
27
+ const { code, state } = z
28
+ .object({
29
+ code: z.string(),
30
+ state: z.string(),
31
+ })
32
+ .parse(Object.fromEntries(url.searchParams.entries()));
33
+
34
+ const csrfToken = Buffer.from(state, "base64").toString("utf-8");
35
+
36
+ const isValidToken = await validateCsrfToken(csrfToken, locals.sessionId);
37
+
38
+ if (!isValidToken) {
39
+ throw error(403, "Invalid or expired CSRF token");
40
+ }
41
+
42
+ const { userData } = await getOIDCUserData({ redirectURI: getRedirectURI(url) }, code);
43
+
44
+ const {
45
+ preferred_username: username,
46
+ name,
47
+ picture: avatarUrl,
48
+ sub: hfUserId,
49
+ } = z
50
+ .object({
51
+ preferred_username: z.string(),
52
+ name: z.string(),
53
+ picture: z.string(),
54
+ sub: z.string(),
55
+ })
56
+ .parse(userData);
57
+
58
+ const existingUser = await collections.users.findOne({ hfUserId });
59
+ let userId = existingUser?._id;
60
+
61
+ if (existingUser) {
62
+ // update existing user if any
63
+ await collections.users.updateOne(
64
+ { _id: existingUser._id },
65
+ { $set: { username, name, avatarUrl } }
66
+ );
67
+ // refresh session cookie
68
+ refreshSessionCookie(cookies, existingUser.sessionId);
69
+ } else {
70
+ // user doesn't exist yet, create a new one
71
+ const { insertedId } = await collections.users.insertOne({
72
+ _id: new ObjectId(),
73
+ createdAt: new Date(),
74
+ updatedAt: new Date(),
75
+ username,
76
+ name,
77
+ avatarUrl,
78
+ hfUserId,
79
+ sessionId: locals.sessionId,
80
+ });
81
+
82
+ userId = insertedId;
83
+
84
+ // update pre-existing settings
85
+ const { matchedCount } = await collections.settings.updateOne(authCondition(locals), {
86
+ $set: { userId, updatedAt: new Date() },
87
+ $unset: { sessionId: "" },
88
+ });
89
+
90
+ if (!matchedCount) {
91
+ // update settings if existing or create new default ones
92
+ await collections.settings.insertOne({
93
+ userId,
94
+ ethicsModalAcceptedAt: new Date(),
95
+ updatedAt: new Date(),
96
+ createdAt: new Date(),
97
+ ...DEFAULT_SETTINGS,
98
+ });
99
+ }
100
+ }
101
+
102
+ // migrate pre-existing conversations
103
+ await collections.conversations.updateMany(authCondition(locals), {
104
+ $set: { userId },
105
+ $unset: { sessionId: "" },
106
+ });
107
+
108
+ throw redirect(302, base || "/");
109
+ }
src/routes/logout/+page.server.ts ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { dev } from "$app/environment";
2
+ import { base } from "$app/paths";
3
+ import { COOKIE_NAME } from "$env/static/private";
4
+ import { redirect } from "@sveltejs/kit";
5
+
6
+ export const actions = {
7
+ default: async function ({ cookies }) {
8
+ cookies.delete(COOKIE_NAME, {
9
+ path: "/",
10
+ // So that it works inside the space's iframe
11
+ sameSite: dev ? "lax" : "none",
12
+ secure: !dev,
13
+ httpOnly: true,
14
+ });
15
+ throw redirect(303, base || "/");
16
+ },
17
+ };
src/routes/settings/+page.server.ts CHANGED
@@ -2,8 +2,9 @@ import { base } from "$app/paths";
2
  import { collections } from "$lib/server/database";
3
  import { redirect } from "@sveltejs/kit";
4
  import { z } from "zod";
5
- import { defaultModel, models, validateModel } from "$lib/server/models";
6
  import { authCondition } from "$lib/server/auth";
 
7
 
8
  export const actions = {
9
  default: async function ({ request, locals }) {
@@ -11,14 +12,16 @@ export const actions = {
11
 
12
  const { ethicsModalAccepted, ...settings } = z
13
  .object({
14
- shareConversationsWithModelAuthors: z.boolean({ coerce: true }).default(true),
 
 
15
  ethicsModalAccepted: z.boolean({ coerce: true }).optional(),
16
  activeModel: validateModel(models),
17
  })
18
  .parse({
19
  shareConversationsWithModelAuthors: formData.get("shareConversationsWithModelAuthors"),
20
  ethicsModalAccepted: formData.get("ethicsModalAccepted"),
21
- activeModel: formData.get("activeModel") ?? defaultModel.id,
22
  });
23
 
24
  await collections.settings.updateOne(
 
2
  import { collections } from "$lib/server/database";
3
  import { redirect } from "@sveltejs/kit";
4
  import { z } from "zod";
5
+ import { models, validateModel } from "$lib/server/models";
6
  import { authCondition } from "$lib/server/auth";
7
+ import { DEFAULT_SETTINGS } from "$lib/types/Settings";
8
 
9
  export const actions = {
10
  default: async function ({ request, locals }) {
 
12
 
13
  const { ethicsModalAccepted, ...settings } = z
14
  .object({
15
+ shareConversationsWithModelAuthors: z
16
+ .boolean({ coerce: true })
17
+ .default(DEFAULT_SETTINGS.shareConversationsWithModelAuthors),
18
  ethicsModalAccepted: z.boolean({ coerce: true }).optional(),
19
  activeModel: validateModel(models),
20
  })
21
  .parse({
22
  shareConversationsWithModelAuthors: formData.get("shareConversationsWithModelAuthors"),
23
  ethicsModalAccepted: formData.get("ethicsModalAccepted"),
24
+ activeModel: formData.get("activeModel") ?? DEFAULT_SETTINGS.activeModel,
25
  });
26
 
27
  await collections.settings.updateOne(