Enable avatar upload for users

This commit is contained in:
grimsi
2024-09-14 17:22:43 +02:00
parent d2f720a6ed
commit 3d77a6b871
20 changed files with 1861 additions and 2327 deletions
+4 -3
View File
@@ -4,7 +4,7 @@ import {Avatar, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger} from "@ne
import {useNavigate} from "react-router-dom";
export default function ProfileMenu() {
const {state, logout} = useAuth();
const auth = useAuth();
const navigate = useNavigate();
const profileMenuItems = [
@@ -17,7 +17,7 @@ export default function ProfileMenu() {
label: "Administration",
icon: <GearFine/>,
onClick: () => navigate("/administration/libraries"),
showIf: state.user?.roles?.some(a => a?.includes("ADMIN"))
showIf: auth.state.user?.roles?.some(a => a?.includes("ADMIN"))
},
{
label: "Help",
@@ -27,7 +27,7 @@ export default function ProfileMenu() {
{
label: "Sign Out",
icon: <SignOut/>,
onClick: () => logout(),
onClick: () => auth.logout(),
color: "primary"
},
];
@@ -36,6 +36,7 @@ export default function ProfileMenu() {
<Dropdown placement="bottom-end">
<DropdownTrigger>
<Avatar showFallback
src={`/images/avatar?username=${auth.state.user?.username}`}
radius="full"
as="button"
className="transition-transform size-8"
@@ -1,7 +1,7 @@
import Section from "Frontend/components/general/Section";
import Input from "Frontend/components/general/Input";
import {Form, Formik} from "formik";
import {Button} from "@nextui-org/react";
import {Avatar, Button} from "@nextui-org/react";
import {Check, Info} from "@phosphor-icons/react";
import React, {useEffect, useState} from "react";
import {useAuth} from "Frontend/util/auth";
@@ -10,6 +10,7 @@ import UserUpdateDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserU
import {UserEndpoint} from "Frontend/generated/endpoints";
import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
import {toast} from "sonner";
import FileUpload from "Frontend/components/general/FileUpload";
export default function ProfileManagement() {
const [configSaved, setConfigSaved] = useState(false);
@@ -90,13 +91,18 @@ export default function ProfileManagement() {
</div>
<div className="flex flex-row flex-1 justify-between gap-8">
<div className="flex flex-col basis-1/4 items-center">
<Section title="Avatar"></Section>
<Avatar showFallback
src={`/images/avatar?username=${auth.state.user?.username}`}
className="size-40 m-4"></Avatar>
<FileUpload upload="/avatar/upload" clear="/avatar/delete" accept="image/*"/>
</div>
<div className="flex flex-col flex-grow">
<Section title="Personal information"/>
<Input name="username" label="Username" type="text" autocomplete="username"/>
<Input name="email" label="Email" type="email" autocomplete="email"/>
</div>
<div className="flex flex-col flex-1">
<Section title="Security"/>
<Input name="newPassword" label="New Password" type="password"
autocomplete="new-password"/>
@@ -27,7 +27,7 @@ function UserManagementLayout({getConfig, formik}: any) {
</div>
<Section title="Users"/>
<div className="grid grid-flow-col grid-cols-4 gap-4">
<div className="grid grid-cols-300px gap-4">
{users.map((user) => <UserCard user={user} key={user.username}/>)}
</div>
@@ -0,0 +1,74 @@
import {toast} from "sonner";
import {getCsrfToken} from "Frontend/util/auth";
import {Button, Input, Tooltip} from "@nextui-org/react";
import {useState} from "react";
import {Trash} from "@phosphor-icons/react";
export default function FileUpload({upload, clear, accept}: { upload: string, clear: string, accept: string }) {
const [avatar, setAvatar] = useState<any>();
function onFileSelected(event: any) {
setAvatar(event.target.files[0]);
}
async function uploadAvatar() {
const formData = new FormData();
formData.append("file", avatar);
try {
const response = await fetch(upload, {
headers: {
"X-CSRF-Token": getCsrfToken()
},
method: "POST",
credentials: "same-origin",
body: formData
});
const result = await response.text();
if (response.ok) {
toast.success("Avatar updated");
} else {
toast.error("Error uploading avatar", {description: result});
}
} catch (error: any) {
toast.error("Error uploading avatar", {description: error.message})
}
}
async function removeAvatar() {
try {
const response = await fetch(clear, {
headers: {
"X-CSRF-Token": getCsrfToken()
},
method: "POST",
credentials: "same-origin"
});
const result = await response.text();
if (response.ok) {
toast.success("Avatar removed");
} else {
toast.error("Error removing avatar", {description: result});
}
} catch (error: any) {
toast.error("Error removing avatar", {description: error.message})
}
}
return (
<div className="flex flex-col gap-2">
<div className="flex flex-row gap-2">
<Input type="file" accept={accept} onChange={onFileSelected}/>
<Button onClick={uploadAvatar} color="success">Upload</Button>
<Tooltip content="Remove your current avatar">
<Button onClick={removeAvatar} isIconOnly color="danger"><Trash/></Button>
</Tooltip>
</div>
</div>
);
};
@@ -3,7 +3,7 @@ import {Divider} from "@nextui-org/react";
export default function Section({title}: { title: string }) {
return (
<>
<h2 className={"text-xl font-bold mt-8"}>{title}</h2>
<h2 className={"text-xl font-bold mt-8 mb-1"}>{title}</h2>
<Divider className="mb-4"/>
</>
);
@@ -4,11 +4,15 @@ import {roleToColor, roleToRoleName} from "Frontend/util/utils";
export function UserCard({user}: { user: UserInfoDto }) {
return (
<Card className="flex flex-row flex-grow items-center gap-4 p-2">
<Avatar classNames={{
base: "gradient-primary size-20",
icon: "text-background/80"
}}></Avatar>
<Card className="flex flex-row items-center gap-4 p-2">
<Avatar showFallback
name={user.username?.charAt(0)}
src={`/images/avatar?username=${user?.username}`}
classNames={{
base: "gradient-primary size-20",
icon: "text-background/80",
name: "text-background/80 text-5xl -mt-1",
}}></Avatar>
<div className="flex flex-col gap-1">
<p className="font-semibold">{user.username}</p>
<p className="text-sm">{user.email}</p>
+7 -1
View File
@@ -7,4 +7,10 @@ const auth = configureAuth(UserEndpoint.getUserInfo);
// Export auth provider and useAuth hook, which are automatically
// typed to the result of `UserInfoService.getUserInfo`
export const useAuth = auth.useAuth;
export const AuthProvider = auth.AuthProvider;
export const AuthProvider = auth.AuthProvider;
export function getCsrfToken() {
const token = document.querySelector('meta[name="_csrf"]')?.getAttribute('content');
return token || '';
}
@@ -30,6 +30,7 @@ class SecurityConfig(
http.authorizeHttpRequests { auth: AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry ->
auth.requestMatchers("/setup").permitAll()
.requestMatchers("/public/**").permitAll()
.requestMatchers("/images/**").permitAll()
}
http.sessionManagement { sessionManagement ->
@@ -45,7 +46,6 @@ class SecurityConfig(
@Throws(Exception::class)
public override fun configure(web: WebSecurity) {
super.configure(web)
web.ignoring().requestMatchers("/images/**")
if ("dev" in environment.activeProfiles) {
web.ignoring().requestMatchers("/h2-console/**")
@@ -11,6 +11,7 @@ import jakarta.annotation.security.RolesAllowed
import org.springframework.security.core.Authentication
import org.springframework.security.core.context.SecurityContextHolder
@Endpoint
class UserEndpoint(
private val userService: UserService
@@ -22,7 +23,6 @@ class UserEndpoint(
return userService.getUserInfo(auth.name)
}
@PermitAll
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
fun getAllUsers(): List<UserInfoDto> {
return userService.getAllUsers()
@@ -3,8 +3,10 @@ package de.grimsi.gameyfin.users
import de.grimsi.gameyfin.meta.Roles
import de.grimsi.gameyfin.users.dto.UserInfoDto
import de.grimsi.gameyfin.users.dto.UserUpdateDto
import de.grimsi.gameyfin.users.entities.Avatar
import de.grimsi.gameyfin.users.entities.Role
import de.grimsi.gameyfin.users.entities.User
import de.grimsi.gameyfin.users.persistence.AvatarContentStore
import de.grimsi.gameyfin.users.persistence.UserRepository
import jakarta.transaction.Transactional
import org.springframework.security.core.GrantedAuthority
@@ -14,6 +16,8 @@ import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import org.springframework.web.multipart.MultipartFile
import java.io.InputStream
@Service
@@ -22,7 +26,8 @@ class UserService(
private val userRepository: UserRepository,
private val passwordEncoder: PasswordEncoder,
private val roleService: RoleService,
private val sessionService: SessionService
private val sessionService: SessionService,
private val avatarStore: AvatarContentStore
) : UserDetailsService {
override fun loadUserByUsername(username: String): UserDetails {
@@ -50,6 +55,35 @@ class UserService(
return toUserInfo(user)
}
fun getAvatar(username: String): Avatar? {
val user = userByUsername(username)
return user.avatar
}
fun getAvatarFile(avatar: Avatar): InputStream {
return avatarStore.getContent(avatar)
}
fun setAvatar(username: String, file: MultipartFile) {
val user = userByUsername(username)
if (user.avatar == null) {
user.avatar = Avatar(mimeType = file.contentType)
}
avatarStore.setContent(user.avatar, file.inputStream)
userRepository.save(user)
}
fun deleteAvatar(username: String) {
val user = userByUsername(username)
avatarStore.unsetContent(user.avatar)
user.avatar = null
userRepository.save(user)
}
fun registerUser(user: User, role: Roles): User {
return registerUser(user, listOf(role))
}
@@ -0,0 +1,55 @@
package de.grimsi.gameyfin.users.avatar
import de.grimsi.gameyfin.users.UserService
import jakarta.annotation.security.PermitAll
import jakarta.servlet.http.HttpServletResponse
import org.springframework.core.io.InputStreamResource
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.core.Authentication
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
@RestController
class AvatarController(
private val userService: UserService
) {
@PostMapping("/avatar/upload")
fun uploadAvatar(@RequestParam("file") file: MultipartFile) {
val auth: Authentication = SecurityContextHolder.getContext().authentication
userService.setAvatar(auth.name, file)
}
@PostMapping("/avatar/delete")
fun deleteAvatar() {
val auth: Authentication = SecurityContextHolder.getContext().authentication
userService.deleteAvatar(auth.name)
}
@PermitAll
@GetMapping("/images/avatar")
fun getAvatar(
@RequestParam("username") username: String,
response: HttpServletResponse
): ResponseEntity<InputStreamResource>? {
val avatar = userService.getAvatar(username) ?: return ResponseEntity.notFound().build()
val file = avatar.let { userService.getAvatarFile(it) }
val inputStreamResource = InputStreamResource(file)
val headers = HttpHeaders()
headers.contentLength = avatar.contentLength!!
headers.contentType = MediaType.parseMediaType(avatar.mimeType!!)
return ResponseEntity.ok()
.headers(headers)
.body(inputStreamResource)
}
}
@@ -8,16 +8,16 @@ import org.springframework.content.commons.annotations.MimeType
@Embeddable
class Avatar {
class Avatar(
@ContentId
@Nullable
var contentId: String? = null
var contentId: String? = null,
@ContentLength
@Nullable
var contentLength: Long? = null
var contentLength: Long? = null,
@MimeType
@Nullable
var mimeType: String? = null
}
)
+3 -1
View File
@@ -20,9 +20,9 @@ spring:
devtools.restart.additional-exclude: dev/hilla/openapi.json
jpa:
# defer-datasource-initialization: true
database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: update
open-in-view: false
mustache:
check-template-location: false
sql.init.mode: always
@@ -32,6 +32,8 @@ spring:
db-name: gameyfin_db
url: jdbc:h2:file:./db/${spring.datasource.db-name}
driverClassName: org.h2.Driver
content:
fs.filesystem-root: ./data/
application:
name: Gameyfin