ソースを参照

Merge pull request #1386 from dannyl1u/feat/profile-image-initials

feat: default profile image with user initials
Timothy Jaeryang Baek 1 年間 前
コミット
02d3fb427b

+ 8 - 2
backend/apps/web/models/auths.py

@@ -86,6 +86,7 @@ class SignupForm(BaseModel):
     name: str
     email: str
     password: str
+    profile_image_url: Optional[str] = "/user.png"
 
 
 class AuthsTable:
@@ -94,7 +95,12 @@ class AuthsTable:
         self.db.create_tables([Auth])
 
     def insert_new_auth(
-        self, email: str, password: str, name: str, role: str = "pending"
+        self,
+        email: str,
+        password: str,
+        name: str,
+        profile_image_url: str = "/user.png",
+        role: str = "pending",
     ) -> Optional[UserModel]:
         log.info("insert_new_auth")
 
@@ -105,7 +111,7 @@ class AuthsTable:
         )
         result = Auth.create(**auth.model_dump())
 
-        user = Users.insert_new_user(id, name, email, role)
+        user = Users.insert_new_user(id, name, email, profile_image_url, role)
 
         if result and user:
             return user

+ 8 - 3
backend/apps/web/models/users.py

@@ -31,7 +31,7 @@ class UserModel(BaseModel):
     name: str
     email: str
     role: str = "pending"
-    profile_image_url: str = "/user.png"
+    profile_image_url: str
     timestamp: int  # timestamp in epoch
     api_key: Optional[str] = None
 
@@ -59,7 +59,12 @@ class UsersTable:
         self.db.create_tables([User])
 
     def insert_new_user(
-        self, id: str, name: str, email: str, role: str = "pending"
+        self,
+        id: str,
+        name: str,
+        email: str,
+        profile_image_url: str = "/user.png",
+        role: str = "pending",
     ) -> Optional[UserModel]:
         user = UserModel(
             **{
@@ -67,7 +72,7 @@ class UsersTable:
                 "name": name,
                 "email": email,
                 "role": role,
-                "profile_image_url": "/user.png",
+                "profile_image_url": profile_image_url,
                 "timestamp": int(time.time()),
             }
         )

+ 5 - 1
backend/apps/web/routers/auths.py

@@ -163,7 +163,11 @@ async def signup(request: Request, form_data: SignupForm):
         )
         hashed = get_password_hash(form_data.password)
         user = Auths.insert_new_auth(
-            form_data.email.lower(), hashed, form_data.name, role
+            form_data.email.lower(),
+            hashed,
+            form_data.name,
+            form_data.profile_image_url,
+            role,
         )
 
         if user:

+ 8 - 2
src/lib/apis/auths/index.ts

@@ -58,7 +58,12 @@ export const userSignIn = async (email: string, password: string) => {
 	return res;
 };
 
-export const userSignUp = async (name: string, email: string, password: string) => {
+export const userSignUp = async (
+	name: string,
+	email: string,
+	password: string,
+	profile_image_url: string
+) => {
 	let error = null;
 
 	const res = await fetch(`${WEBUI_API_BASE_URL}/auths/signup`, {
@@ -69,7 +74,8 @@ export const userSignUp = async (name: string, email: string, password: string)
 		body: JSON.stringify({
 			name: name,
 			email: email,
-			password: password
+			password: password,
+			profile_image_url: profile_image_url
 		})
 	})
 		.then(async (res) => {

+ 134 - 15
src/lib/components/chat/Settings/Account.svelte

@@ -7,6 +7,7 @@
 
 	import UpdatePassword from './Account/UpdatePassword.svelte';
 	import { getGravatarUrl } from '$lib/apis/utils';
+	import { generateInitialsImage, canvasPixelTest } from '$lib/utils';
 	import { copyToClipboard } from '$lib/utils';
 	import Plus from '$lib/components/icons/Plus.svelte';
 	import Tooltip from '$lib/components/common/Tooltip.svelte';
@@ -28,6 +29,12 @@
 	let profileImageInputElement: HTMLInputElement;
 
 	const submitHandler = async () => {
+		if (name !== $user.name) {
+			if (profileImageUrl === generateInitialsImage($user.name) || profileImageUrl === '') {
+				profileImageUrl = generateInitialsImage(name);
+			}
+		}
+
 		const updatedUser = await updateUserProfile(localStorage.token, name, profileImageUrl).catch(
 			(error) => {
 				toast.error(error);
@@ -125,11 +132,11 @@
 			}}
 		/>
 
-		<div class=" mb-2.5 text-sm font-medium">{$i18n.t('Profile')}</div>
-
-		<div class="flex space-x-5">
+		<!-- <div class="flex space-x-5">
 			<div class="flex flex-col">
 				<div class="self-center">
+					<div class=" mb-2.5 text-sm font-medium">{$i18n.t('Profile')}</div>
+
 					<button
 						class="relative rounded-full dark:bg-gray-700"
 						type="button"
@@ -138,9 +145,9 @@
 						}}
 					>
 						<img
-							src={profileImageUrl !== '' ? profileImageUrl : '/user.png'}
+							src={profileImageUrl !== '' ? profileImageUrl : generateInitialsImage(name)}
 							alt="profile"
-							class=" rounded-full w-16 h-16 object-cover"
+							class=" rounded-full size-20 object-cover"
 						/>
 
 						<div
@@ -161,32 +168,144 @@
 						</div>
 					</button>
 				</div>
-				<button
-					class=" text-xs text-gray-600"
-					on:click={async () => {
-						const url = await getGravatarUrl($user.email);
-
-						profileImageUrl = url;
-					}}>{$i18n.t('Use Gravatar')}</button
-				>
 			</div>
 
-			<div class="flex-1">
+			<div class="flex-1 flex flex-col self-center">
 				<div class="flex flex-col w-full">
 					<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Name')}</div>
 
 					<div class="flex-1">
 						<input
-							class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
+							class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
 							type="text"
 							bind:value={name}
 							required
 						/>
 					</div>
+
+					<div class="mt-2">
+						<button
+							class=" text-xs text-center text-gray-800 dark:text-gray-400 rounded-full px-4 py-0.5 bg-gray-100 dark:bg-gray-850"
+							on:click={async () => {
+								const url = await getGravatarUrl($user.email);
+
+								profileImageUrl = url;
+							}}>{$i18n.t('Use Gravatar')}</button
+						>
+
+						<button
+							class=" text-xs text-center text-gray-800 dark:text-gray-400 rounded-full px-4 py-0.5 bg-gray-100 dark:bg-gray-850"
+							on:click={async () => {
+								if (canvasPixelTest()) {
+									profileImageUrl = generateInitialsImage(name);
+								} else {
+									toast.info(
+										$i18n.t(
+											'Fingerprint spoofing detected: Unable to use initials as avatar. Defaulting to default profile image.'
+										),
+										{
+											duration: 1000 * 10
+										}
+									);
+								}
+							}}>{$i18n.t('Use Initials')}</button
+						>
+					</div>
+				</div>
+			</div>
+		</div> -->
+
+		<div class="flex space-x-5">
+			<div class="flex flex-col">
+				<div class="self-center mt-2">
+					<button
+						class="relative rounded-full dark:bg-gray-700"
+						type="button"
+						on:click={() => {
+							profileImageInputElement.click();
+						}}
+					>
+						<img
+							src={profileImageUrl !== '' ? profileImageUrl : generateInitialsImage(name)}
+							alt="profile"
+							class=" rounded-full size-16 object-cover"
+						/>
+
+						<div
+							class="absolute flex justify-center rounded-full bottom-0 left-0 right-0 top-0 h-full w-full overflow-hidden bg-gray-700 bg-fixed opacity-0 transition duration-300 ease-in-out hover:opacity-50"
+						>
+							<div class="my-auto text-gray-100">
+								<svg
+									xmlns="http://www.w3.org/2000/svg"
+									viewBox="0 0 20 20"
+									fill="currentColor"
+									class="w-5 h-5"
+								>
+									<path
+										d="m2.695 14.762-1.262 3.155a.5.5 0 0 0 .65.65l3.155-1.262a4 4 0 0 0 1.343-.886L17.5 5.501a2.121 2.121 0 0 0-3-3L3.58 13.419a4 4 0 0 0-.885 1.343Z"
+									/>
+								</svg>
+							</div>
+						</div>
+					</button>
+				</div>
+			</div>
+
+			<div class="flex-1 flex flex-col self-center">
+				<div class="  font-medium mb-1.5">{$i18n.t('Profile Image')}</div>
+
+				<div>
+					<button
+						class=" text-xs text-center text-gray-800 dark:text-gray-400 rounded-lg px-4 py-1 bg-gray-100 dark:bg-gray-850"
+						on:click={async () => {
+							if (canvasPixelTest()) {
+								profileImageUrl = generateInitialsImage(name);
+							} else {
+								toast.info(
+									$i18n.t(
+										'Fingerprint spoofing detected: Unable to use initials as avatar. Defaulting to default profile image.'
+									),
+									{
+										duration: 1000 * 10
+									}
+								);
+							}
+						}}>{$i18n.t('Use Initials')}</button
+					>
+
+					<button
+						class=" text-xs text-center text-gray-800 dark:text-gray-400 rounded-lg px-4 py-1 bg-gray-100 dark:bg-gray-850"
+						on:click={async () => {
+							const url = await getGravatarUrl($user.email);
+
+							profileImageUrl = url;
+						}}>{$i18n.t('Use Gravatar')}</button
+					>
+
+					<button
+						class=" text-xs text-center text-gray-800 dark:text-gray-400 rounded-lg px-4 py-1 bg-gray-100 dark:bg-gray-850"
+						on:click={async () => {
+							profileImageUrl = '/user.png';
+						}}>{$i18n.t('Remove')}</button
+					>
 				</div>
 			</div>
 		</div>
 
+		<div class="flex-1">
+			<div class="flex flex-col w-full">
+				<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Name')}</div>
+
+				<div class="flex-1">
+					<input
+						class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
+						type="text"
+						bind:value={name}
+						required
+					/>
+				</div>
+			</div>
+		</div>
 		<hr class=" dark:border-gray-700 my-4" />
 		<UpdatePassword />
 

+ 2 - 0
src/lib/i18n/locales/en-US/translation.json

@@ -150,6 +150,7 @@
 	"Failed to read clipboard contents": "",
 	"File Mode": "",
 	"File not found.": "",
+	"Fingerprint spoofing detected: Unable to use initials as avatar. Defaulting to default profile image.": "",
 	"Focus chat input": "",
 	"Format your variables using square brackets like this:": "",
 	"From (Base Model)": "",
@@ -340,6 +341,7 @@
 	"URL Mode": "",
 	"Use '#' in the prompt input to load and select your documents.": "",
 	"Use Gravatar": "",
+	"Use Initials": "",
 	"user": "",
 	"User Permissions": "",
 	"Users": "",

+ 76 - 0
src/lib/utils/index.ts

@@ -111,6 +111,82 @@ export const getGravatarURL = (email) => {
 	return `https://www.gravatar.com/avatar/${hash}`;
 };
 
+export const canvasPixelTest = () => {
+	// Test a 1x1 pixel to potentially identify browser/plugin fingerprint blocking or spoofing
+	// Inspiration: https://github.com/kkapsner/CanvasBlocker/blob/master/test/detectionTest.js
+	const canvas = document.createElement('canvas');
+	const ctx = canvas.getContext('2d');
+	canvas.height = 1;
+	canvas.width = 1;
+	const imageData = new ImageData(canvas.width, canvas.height);
+	const pixelValues = imageData.data;
+
+	// Generate RGB test data
+	for (let i = 0; i < imageData.data.length; i += 1) {
+		if (i % 4 !== 3) {
+			pixelValues[i] = Math.floor(256 * Math.random());
+		} else {
+			pixelValues[i] = 255;
+		}
+	}
+
+	ctx.putImageData(imageData, 0, 0);
+	const p = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
+
+	// Read RGB data and fail if unmatched
+	for (let i = 0; i < p.length; i += 1) {
+		if (p[i] !== pixelValues[i]) {
+			console.log(
+				'canvasPixelTest: Wrong canvas pixel RGB value detected:',
+				p[i],
+				'at:',
+				i,
+				'expected:',
+				pixelValues[i]
+			);
+			console.log('canvasPixelTest: Canvas blocking or spoofing is likely');
+			return false;
+		}
+	}
+
+	return true;
+};
+
+export const generateInitialsImage = (name) => {
+	const canvas = document.createElement('canvas');
+	const ctx = canvas.getContext('2d');
+	canvas.width = 100;
+	canvas.height = 100;
+
+	if (!canvasPixelTest()) {
+		console.log(
+			'generateInitialsImage: failed pixel test, fingerprint evasion is likely. Using default image.'
+		);
+		return '/user.png';
+	}
+
+	ctx.fillStyle = '#F39C12';
+	ctx.fillRect(0, 0, canvas.width, canvas.height);
+
+	ctx.fillStyle = '#FFFFFF';
+	ctx.font = '40px Helvetica';
+	ctx.textAlign = 'center';
+	ctx.textBaseline = 'middle';
+
+	const sanitizedName = name.trim();
+	const initials =
+		sanitizedName.length > 0
+			? sanitizedName[0] +
+			  (sanitizedName.split(' ').length > 1
+					? sanitizedName[sanitizedName.lastIndexOf(' ') + 1]
+					: '')
+			: '';
+
+	ctx.fillText(initials.toUpperCase(), canvas.width / 2, canvas.height / 2);
+
+	return canvas.toDataURL();
+};
+
 export const copyToClipboard = (text) => {
 	if (!navigator.clipboard) {
 		const textArea = document.createElement('textarea');

+ 7 - 4
src/routes/auth/+page.svelte

@@ -6,6 +6,7 @@
 	import { WEBUI_NAME, config, user } from '$lib/stores';
 	import { onMount, getContext } from 'svelte';
 	import { toast } from 'svelte-sonner';
+	import { generateInitialsImage, canvasPixelTest } from '$lib/utils';
 
 	const i18n = getContext('i18n');
 
@@ -36,10 +37,12 @@
 	};
 
 	const signUpHandler = async () => {
-		const sessionUser = await userSignUp(name, email, password).catch((error) => {
-			toast.error(error);
-			return null;
-		});
+		const sessionUser = await userSignUp(name, email, password, generateInitialsImage(name)).catch(
+			(error) => {
+				toast.error(error);
+				return null;
+			}
+		);
 
 		await setSessionUser(sessionUser);
 	};