forked from open-webui/open-webui
Merge pull request #1386 from dannyl1u/feat/profile-image-initials
feat: default profile image with user initials
This commit is contained in:
commit
02d3fb427b
8 changed files with 248 additions and 27 deletions
|
@ -86,6 +86,7 @@ class SignupForm(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
email: str
|
email: str
|
||||||
password: str
|
password: str
|
||||||
|
profile_image_url: Optional[str] = "/user.png"
|
||||||
|
|
||||||
|
|
||||||
class AuthsTable:
|
class AuthsTable:
|
||||||
|
@ -94,7 +95,12 @@ class AuthsTable:
|
||||||
self.db.create_tables([Auth])
|
self.db.create_tables([Auth])
|
||||||
|
|
||||||
def insert_new_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]:
|
) -> Optional[UserModel]:
|
||||||
log.info("insert_new_auth")
|
log.info("insert_new_auth")
|
||||||
|
|
||||||
|
@ -105,7 +111,7 @@ class AuthsTable:
|
||||||
)
|
)
|
||||||
result = Auth.create(**auth.model_dump())
|
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:
|
if result and user:
|
||||||
return user
|
return user
|
||||||
|
|
|
@ -31,7 +31,7 @@ class UserModel(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
email: str
|
email: str
|
||||||
role: str = "pending"
|
role: str = "pending"
|
||||||
profile_image_url: str = "/user.png"
|
profile_image_url: str
|
||||||
timestamp: int # timestamp in epoch
|
timestamp: int # timestamp in epoch
|
||||||
api_key: Optional[str] = None
|
api_key: Optional[str] = None
|
||||||
|
|
||||||
|
@ -59,7 +59,12 @@ class UsersTable:
|
||||||
self.db.create_tables([User])
|
self.db.create_tables([User])
|
||||||
|
|
||||||
def insert_new_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]:
|
) -> Optional[UserModel]:
|
||||||
user = UserModel(
|
user = UserModel(
|
||||||
**{
|
**{
|
||||||
|
@ -67,7 +72,7 @@ class UsersTable:
|
||||||
"name": name,
|
"name": name,
|
||||||
"email": email,
|
"email": email,
|
||||||
"role": role,
|
"role": role,
|
||||||
"profile_image_url": "/user.png",
|
"profile_image_url": profile_image_url,
|
||||||
"timestamp": int(time.time()),
|
"timestamp": int(time.time()),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
@ -163,7 +163,11 @@ async def signup(request: Request, form_data: SignupForm):
|
||||||
)
|
)
|
||||||
hashed = get_password_hash(form_data.password)
|
hashed = get_password_hash(form_data.password)
|
||||||
user = Auths.insert_new_auth(
|
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:
|
if user:
|
||||||
|
|
|
@ -58,7 +58,12 @@ export const userSignIn = async (email: string, password: string) => {
|
||||||
return res;
|
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;
|
let error = null;
|
||||||
|
|
||||||
const res = await fetch(`${WEBUI_API_BASE_URL}/auths/signup`, {
|
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({
|
body: JSON.stringify({
|
||||||
name: name,
|
name: name,
|
||||||
email: email,
|
email: email,
|
||||||
password: password
|
password: password,
|
||||||
|
profile_image_url: profile_image_url
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
import UpdatePassword from './Account/UpdatePassword.svelte';
|
import UpdatePassword from './Account/UpdatePassword.svelte';
|
||||||
import { getGravatarUrl } from '$lib/apis/utils';
|
import { getGravatarUrl } from '$lib/apis/utils';
|
||||||
|
import { generateInitialsImage, canvasPixelTest } from '$lib/utils';
|
||||||
import { copyToClipboard } from '$lib/utils';
|
import { copyToClipboard } from '$lib/utils';
|
||||||
import Plus from '$lib/components/icons/Plus.svelte';
|
import Plus from '$lib/components/icons/Plus.svelte';
|
||||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||||
|
@ -28,6 +29,12 @@
|
||||||
let profileImageInputElement: HTMLInputElement;
|
let profileImageInputElement: HTMLInputElement;
|
||||||
|
|
||||||
const submitHandler = async () => {
|
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(
|
const updatedUser = await updateUserProfile(localStorage.token, name, profileImageUrl).catch(
|
||||||
(error) => {
|
(error) => {
|
||||||
toast.error(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="flex flex-col">
|
||||||
<div class="self-center">
|
<div class="self-center">
|
||||||
|
<div class=" mb-2.5 text-sm font-medium">{$i18n.t('Profile')}</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="relative rounded-full dark:bg-gray-700"
|
class="relative rounded-full dark:bg-gray-700"
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -138,9 +145,9 @@
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={profileImageUrl !== '' ? profileImageUrl : '/user.png'}
|
src={profileImageUrl !== '' ? profileImageUrl : generateInitialsImage(name)}
|
||||||
alt="profile"
|
alt="profile"
|
||||||
class=" rounded-full w-16 h-16 object-cover"
|
class=" rounded-full size-20 object-cover"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
@ -161,32 +168,144 @@
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="flex-1">
|
<div class="flex-1 flex flex-col self-center">
|
||||||
<div class="flex flex-col w-full">
|
<div class="flex flex-col w-full">
|
||||||
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Name')}</div>
|
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Name')}</div>
|
||||||
|
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<input
|
<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"
|
type="text"
|
||||||
bind:value={name}
|
bind:value={name}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
</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" />
|
<hr class=" dark:border-gray-700 my-4" />
|
||||||
<UpdatePassword />
|
<UpdatePassword />
|
||||||
|
|
||||||
|
|
|
@ -150,6 +150,7 @@
|
||||||
"Failed to read clipboard contents": "",
|
"Failed to read clipboard contents": "",
|
||||||
"File Mode": "",
|
"File Mode": "",
|
||||||
"File not found.": "",
|
"File not found.": "",
|
||||||
|
"Fingerprint spoofing detected: Unable to use initials as avatar. Defaulting to default profile image.": "",
|
||||||
"Focus chat input": "",
|
"Focus chat input": "",
|
||||||
"Format your variables using square brackets like this:": "",
|
"Format your variables using square brackets like this:": "",
|
||||||
"From (Base Model)": "",
|
"From (Base Model)": "",
|
||||||
|
@ -340,6 +341,7 @@
|
||||||
"URL Mode": "",
|
"URL Mode": "",
|
||||||
"Use '#' in the prompt input to load and select your documents.": "",
|
"Use '#' in the prompt input to load and select your documents.": "",
|
||||||
"Use Gravatar": "",
|
"Use Gravatar": "",
|
||||||
|
"Use Initials": "",
|
||||||
"user": "",
|
"user": "",
|
||||||
"User Permissions": "",
|
"User Permissions": "",
|
||||||
"Users": "",
|
"Users": "",
|
||||||
|
|
|
@ -111,6 +111,82 @@ export const getGravatarURL = (email) => {
|
||||||
return `https://www.gravatar.com/avatar/${hash}`;
|
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) => {
|
export const copyToClipboard = (text) => {
|
||||||
if (!navigator.clipboard) {
|
if (!navigator.clipboard) {
|
||||||
const textArea = document.createElement('textarea');
|
const textArea = document.createElement('textarea');
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
import { WEBUI_NAME, config, user } from '$lib/stores';
|
import { WEBUI_NAME, config, user } from '$lib/stores';
|
||||||
import { onMount, getContext } from 'svelte';
|
import { onMount, getContext } from 'svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { generateInitialsImage, canvasPixelTest } from '$lib/utils';
|
||||||
|
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
||||||
|
@ -36,10 +37,12 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
const signUpHandler = async () => {
|
const signUpHandler = async () => {
|
||||||
const sessionUser = await userSignUp(name, email, password).catch((error) => {
|
const sessionUser = await userSignUp(name, email, password, generateInitialsImage(name)).catch(
|
||||||
toast.error(error);
|
(error) => {
|
||||||
return null;
|
toast.error(error);
|
||||||
});
|
return null;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
await setSessionUser(sessionUser);
|
await setSessionUser(sessionUser);
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in a new issue