diff --git a/backend/apps/web/models/auths.py b/backend/apps/web/models/auths.py index 06986503..a97312ff 100644 --- a/backend/apps/web/models/auths.py +++ b/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 diff --git a/backend/apps/web/models/users.py b/backend/apps/web/models/users.py index a01e595e..7d1e182d 100644 --- a/backend/apps/web/models/users.py +++ b/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()), } ) diff --git a/backend/apps/web/routers/auths.py b/backend/apps/web/routers/auths.py index 293cb55b..89d8c1c8 100644 --- a/backend/apps/web/routers/auths.py +++ b/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: diff --git a/src/lib/apis/auths/index.ts b/src/lib/apis/auths/index.ts index 548a9418..efeeff33 100644 --- a/src/lib/apis/auths/index.ts +++ b/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) => { diff --git a/src/lib/components/chat/Settings/Account.svelte b/src/lib/components/chat/Settings/Account.svelte index e0239fb0..7982bff2 100644 --- a/src/lib/components/chat/Settings/Account.svelte +++ b/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 @@ }} /> -
{$i18n.t('Profile')}
- -
+ + +
+
+
+ +
+
+ +
+
{$i18n.t('Profile Image')}
+ +
+ + + + +
+
+
+
{$i18n.t('Name')}
+ +
+ +
+
+

diff --git a/src/lib/i18n/locales/en-US/translation.json b/src/lib/i18n/locales/en-US/translation.json index 8c8e5dd2..e11ef013 100644 --- a/src/lib/i18n/locales/en-US/translation.json +++ b/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": "", diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 9ee8b6fc..e9a4e229 100644 --- a/src/lib/utils/index.ts +++ b/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'); diff --git a/src/routes/auth/+page.svelte b/src/routes/auth/+page.svelte index 2dc2a92b..c7eab0db 100644 --- a/src/routes/auth/+page.svelte +++ b/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); };