diff --git a/api/src/main/java/run/halo/app/core/extension/User.java b/api/src/main/java/run/halo/app/core/extension/User.java index fd5d661acc..65ab52bece 100644 --- a/api/src/main/java/run/halo/app/core/extension/User.java +++ b/api/src/main/java/run/halo/app/core/extension/User.java @@ -34,6 +34,11 @@ public class User extends AbstractExtension { public static final String ROLE_NAMES_ANNO = "rbac.authorization.halo.run/role-names"; + public static final String LAST_AVATAR_ATTACHMENT_NAME_ANNO = + "halo.run/last-avatar-attachment-name"; + + public static final String AVATAR_ATTACHMENT_NAME_ANNO = "halo.run/avatar-attachment-name"; + public static final String HIDDEN_USER_LABEL = "halo.run/hidden-user"; @Schema(required = true) diff --git a/api/src/main/java/run/halo/app/infra/SystemSetting.java b/api/src/main/java/run/halo/app/infra/SystemSetting.java index 6b2165baf8..27f8488207 100644 --- a/api/src/main/java/run/halo/app/infra/SystemSetting.java +++ b/api/src/main/java/run/halo/app/infra/SystemSetting.java @@ -68,6 +68,7 @@ public static class User { public static final String GROUP = "user"; Boolean allowRegistration; String defaultRole; + String avatarPolicy; } @Data diff --git a/application/src/main/java/run/halo/app/core/extension/endpoint/UserEndpoint.java b/application/src/main/java/run/halo/app/core/extension/endpoint/UserEndpoint.java index 45b94a49ad..23a131ee6c 100644 --- a/application/src/main/java/run/halo/app/core/extension/endpoint/UserEndpoint.java +++ b/application/src/main/java/run/halo/app/core/extension/endpoint/UserEndpoint.java @@ -5,13 +5,17 @@ import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; import static org.apache.commons.lang3.StringUtils.defaultIfBlank; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; +import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; +import static org.springdoc.core.fn.builders.schema.Builder.schemaBuilder; +import static org.springframework.web.reactive.function.server.RequestPredicates.contentType; import static run.halo.app.extension.ListResult.generateGenericClass; import static run.halo.app.extension.router.QueryParamBuildUtil.buildParametersFromType; import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToPredicate; import com.fasterxml.jackson.core.type.TypeReference; +import com.google.common.io.Files; import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -25,22 +29,32 @@ import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Function; import java.util.function.Predicate; import java.util.regex.Matcher; import java.util.regex.Pattern; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; +import org.springdoc.core.fn.builders.requestbody.Builder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; +import org.springframework.core.io.buffer.DataBuffer; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.data.domain.Sort; import org.springframework.http.MediaType; +import org.springframework.http.codec.multipart.FilePart; +import org.springframework.http.codec.multipart.Part; import org.springframework.lang.NonNull; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; import org.springframework.stereotype.Component; import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.util.MultiValueMap; +import org.springframework.util.unit.DataSize; +import org.springframework.web.reactive.function.BodyExtractors; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; @@ -51,6 +65,8 @@ import reactor.util.retry.Retry; import run.halo.app.core.extension.Role; import run.halo.app.core.extension.User; +import run.halo.app.core.extension.attachment.Attachment; +import run.halo.app.core.extension.service.AttachmentService; import run.halo.app.core.extension.service.RoleService; import run.halo.app.core.extension.service.UserService; import run.halo.app.extension.Comparators; @@ -59,6 +75,8 @@ import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.router.IListRequest; +import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; import run.halo.app.infra.utils.JsonUtils; @Component @@ -66,9 +84,14 @@ public class UserEndpoint implements CustomEndpoint { private static final String SELF_USER = "-"; + private static final String USER_AVATAR_GROUP_NAME = "user-avatar-group"; + private static final String DEFAULT_USER_AVATAR_ATTACHMENT_POLICY_NAME = "default-policy"; + private static final DataSize MAX_AVATAR_FILE_SIZE = DataSize.ofMegabytes(2L); private final ReactiveExtensionClient client; private final UserService userService; private final RoleService roleService; + private final AttachmentService attachmentService; + private final SystemConfigurableEnvironmentFetcher environmentFetcher; @Override public RouterFunction endpoint() { @@ -145,9 +168,137 @@ public RouterFunction endpoint() { .implementation(generateGenericClass(ListedUser.class))); buildParametersFromType(builder, ListRequest.class); }) + .POST("users/{name}/avatar", contentType(MediaType.MULTIPART_FORM_DATA), + this::uploadUserAvatar, + builder -> builder + .operationId("UploadUserAvatar") + .description("upload user avatar") + .tag(tag) + .parameter(parameterBuilder() + .in(ParameterIn.PATH) + .name("name") + .description("User name") + .required(true) + ) + .requestBody(Builder.requestBodyBuilder() + .required(true) + .content(contentBuilder() + .mediaType(MediaType.MULTIPART_FORM_DATA_VALUE) + .schema(schemaBuilder().implementation(IAvatarUploadRequest.class)) + )) + .response(responseBuilder().implementation(User.class)) + ) + .DELETE("users/{name}/avatar", this::deleteUserAvatar, builder -> builder + .tag(tag) + .operationId("DeleteUserAvatar") + .description("delete user avatar") + .parameter(parameterBuilder() + .in(ParameterIn.PATH) + .name("name") + .description("User name") + .required(true) + ) + .response(responseBuilder().implementation(User.class)) + .build()) .build(); } + private Mono deleteUserAvatar(ServerRequest request) { + final var nameInPath = request.pathVariable("name"); + return getUserOrSelf(nameInPath) + .flatMap(user -> { + MetadataUtil.nullSafeAnnotations(user) + .remove(User.AVATAR_ATTACHMENT_NAME_ANNO); + return client.update(user); + }) + .flatMap(user -> ServerResponse.ok().bodyValue(user)); + } + + private Mono getUserOrSelf(String name) { + if (!SELF_USER.equals(name)) { + return client.get(User.class, name); + } + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .map(Authentication::getName) + .flatMap(currentUserName -> client.get(User.class, currentUserName)); + } + + private Mono uploadUserAvatar(ServerRequest request) { + final var username = request.pathVariable("name"); + return request.body(BodyExtractors.toMultipartData()) + .map(AvatarUploadRequest::new) + .flatMap(this::uploadAvatar) + .flatMap(attachment -> getUserOrSelf(username) + .flatMap(user -> { + MetadataUtil.nullSafeAnnotations(user) + .put(User.AVATAR_ATTACHMENT_NAME_ANNO, + attachment.getMetadata().getName()); + return client.update(user); + }) + .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) + .filter(OptimisticLockingFailureException.class::isInstance)) + ) + .flatMap(user -> ServerResponse.ok().bodyValue(user)); + } + + public interface IAvatarUploadRequest { + @Schema(requiredMode = REQUIRED, description = "Avatar file") + FilePart getFile(); + } + + public record AvatarUploadRequest(MultiValueMap formData) { + public FilePart getFile() { + Part file = formData.getFirst("file"); + if (file == null) { + throw new ServerWebInputException("No file part found in the request"); + } + + if (!(file instanceof FilePart filePart)) { + throw new ServerWebInputException("Invalid part of file"); + } + + if (!filePart.filename().endsWith(".png")) { + throw new ServerWebInputException("Only support avatar in PNG format"); + } + return filePart; + } + } + + private Mono uploadAvatar(AvatarUploadRequest uploadRequest) { + return environmentFetcher.fetch(SystemSetting.User.GROUP, SystemSetting.User.class) + .switchIfEmpty( + Mono.error(new IllegalStateException("User setting is not configured")) + ) + .flatMap(userSetting -> Mono.defer( + () -> { + String avatarPolicy = userSetting.getAvatarPolicy(); + if (StringUtils.isBlank(avatarPolicy)) { + avatarPolicy = DEFAULT_USER_AVATAR_ATTACHMENT_POLICY_NAME; + } + FilePart filePart = uploadRequest.getFile(); + var ext = Files.getFileExtension(filePart.filename()); + return attachmentService.upload(avatarPolicy, + USER_AVATAR_GROUP_NAME, + UUID.randomUUID() + "." + ext, + maxSizeCheck(filePart.content()), + filePart.headers().getContentType() + ); + }) + ); + } + + private Flux maxSizeCheck(Flux content) { + var lenRef = new AtomicInteger(0); + return content.doOnNext(dataBuffer -> { + int len = lenRef.accumulateAndGet(dataBuffer.readableByteCount(), Integer::sum); + if (len > MAX_AVATAR_FILE_SIZE.toBytes()) { + throw new ServerWebInputException("The avatar file needs to be smaller than " + + MAX_AVATAR_FILE_SIZE.toMegabytes() + " MB."); + } + }); + } + private Mono createUser(ServerRequest request) { return request.bodyToMono(CreateUserRequest.class) .doOnNext(createUserRequest -> { @@ -234,10 +385,18 @@ private Mono updateProfile(ServerRequest request) { .switchIfEmpty( Mono.error(() -> new ServerWebInputException("Username didn't match."))) .map(user -> { - currentUser.getMetadata().setAnnotations(user.getMetadata().getAnnotations()); + Map oldAnnotations = + MetadataUtil.nullSafeAnnotations(currentUser); + Map newAnnotations = user.getMetadata().getAnnotations(); + if (!CollectionUtils.isEmpty(newAnnotations)) { + newAnnotations.put(User.LAST_AVATAR_ATTACHMENT_NAME_ANNO, + oldAnnotations.get(User.LAST_AVATAR_ATTACHMENT_NAME_ANNO)); + newAnnotations.put(User.AVATAR_ATTACHMENT_NAME_ANNO, + oldAnnotations.get(User.AVATAR_ATTACHMENT_NAME_ANNO)); + currentUser.getMetadata().setAnnotations(newAnnotations); + } var spec = currentUser.getSpec(); var newSpec = user.getSpec(); - spec.setAvatar(newSpec.getAvatar()); spec.setBio(newSpec.getBio()); spec.setDisplayName(newSpec.getDisplayName()); spec.setTwoFactorAuthEnabled(newSpec.getTwoFactorAuthEnabled()); diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/UserReconciler.java b/application/src/main/java/run/halo/app/core/extension/reconciler/UserReconciler.java index 222078bd10..ab89f6e74d 100644 --- a/application/src/main/java/run/halo/app/core/extension/reconciler/UserReconciler.java +++ b/application/src/main/java/run/halo/app/core/extension/reconciler/UserReconciler.java @@ -3,6 +3,7 @@ import static run.halo.app.core.extension.User.GROUP; import static run.halo.app.core.extension.User.KIND; +import java.net.URI; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -16,6 +17,8 @@ import run.halo.app.core.extension.RoleBinding; import run.halo.app.core.extension.User; import run.halo.app.core.extension.UserConnection; +import run.halo.app.core.extension.attachment.Attachment; +import run.halo.app.core.extension.service.AttachmentService; import run.halo.app.core.extension.service.RoleService; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.GroupKind; @@ -37,6 +40,7 @@ public class UserReconciler implements Reconciler { private final ExtensionClient client; private final ExternalUrlSupplier externalUrlSupplier; private final RoleService roleService; + private final AttachmentService attachmentService; private final RetryTemplate retryTemplate = RetryTemplate.builder() .maxAttempts(20) .fixedBackoff(300) @@ -54,10 +58,48 @@ public Result reconcile(Request request) { addFinalizerIfNecessary(user); ensureRoleNamesAnno(request.name()); updatePermalink(request.name()); + handleAvatar(request.name()); }); return new Result(false, null); } + private void handleAvatar(String name) { + client.fetch(User.class, name).ifPresent(user -> { + Map annotations = MetadataUtil.nullSafeAnnotations(user); + + String avatarAttachmentName = annotations.get(User.AVATAR_ATTACHMENT_NAME_ANNO); + String oldAvatarAttachmentName = annotations.get(User.LAST_AVATAR_ATTACHMENT_NAME_ANNO); + + if (StringUtils.isNotBlank(oldAvatarAttachmentName) + && !StringUtils.equals(oldAvatarAttachmentName, avatarAttachmentName)) { + client.fetch(Attachment.class, oldAvatarAttachmentName) + .ifPresent(client::delete); + annotations.remove(User.LAST_AVATAR_ATTACHMENT_NAME_ANNO); + oldAvatarAttachmentName = null; + } + + if (StringUtils.isBlank(oldAvatarAttachmentName) + && StringUtils.isNotBlank(avatarAttachmentName)) { + oldAvatarAttachmentName = avatarAttachmentName; + annotations.put(User.LAST_AVATAR_ATTACHMENT_NAME_ANNO, oldAvatarAttachmentName); + } + + if (StringUtils.isNotBlank(avatarAttachmentName)) { + client.fetch(Attachment.class, avatarAttachmentName) + .ifPresent(attachment -> { + URI avatarUri = attachmentService.getPermalink(attachment).block(); + if (avatarUri == null) { + throw new IllegalStateException("User avatar attachment not found."); + } + user.getSpec().setAvatar(avatarUri.toString()); + }); + } else { + user.getSpec().setAvatar(null); + } + client.update(user); + }); + } + private void ensureRoleNamesAnno(String name) { client.fetch(User.class, name).ifPresent(user -> { Map annotations = MetadataUtil.nullSafeAnnotations(user); diff --git a/application/src/main/resources/extensions/attachment-local-policy.yaml b/application/src/main/resources/extensions/attachment-local-policy.yaml index d9d6232f8a..89979fde16 100644 --- a/application/src/main/resources/extensions/attachment-local-policy.yaml +++ b/application/src/main/resources/extensions/attachment-local-policy.yaml @@ -35,3 +35,14 @@ spec: name: location label: 存储位置 help: ~/.halo2/attachments/upload 下的子目录 +--- +apiVersion: storage.halo.run/v1alpha1 +kind: Group +metadata: + name: user-avatar-group + labels: + halo.run/hidden: "true" + finalizers: + - system-protection +spec: + displayName: UserAvatar \ No newline at end of file diff --git a/application/src/main/resources/extensions/role-template-authenticated.yaml b/application/src/main/resources/extensions/role-template-authenticated.yaml index a8f6f15f9e..e487f2b975 100644 --- a/application/src/main/resources/extensions/role-template-authenticated.yaml +++ b/application/src/main/resources/extensions/role-template-authenticated.yaml @@ -35,6 +35,10 @@ rules: resources: [ "users" ] resourceNames: [ "-" ] verbs: [ "get", "update" ] + - apiGroups: [ "api.console.halo.run" ] + resources: [ "users/avatar" ] + resourceNames: [ "-" ] + verbs: [ "create", "delete" ] --- apiVersion: v1alpha1 kind: "Role" diff --git a/application/src/main/resources/extensions/role-template-user.yaml b/application/src/main/resources/extensions/role-template-user.yaml index 0d2267bea7..ebd69d14a0 100644 --- a/application/src/main/resources/extensions/role-template-user.yaml +++ b/application/src/main/resources/extensions/role-template-user.yaml @@ -16,7 +16,7 @@ rules: resources: [ "users" ] verbs: [ "create", "patch", "update", "delete", "deletecollection" ] - apiGroups: [ "api.console.halo.run" ] - resources: [ "users", "users/permissions", "users/password" ] + resources: [ "users", "users/permissions", "users/password", "users/avatar" ] verbs: [ "*" ] --- apiVersion: v1alpha1 diff --git a/application/src/main/resources/extensions/system-setting.yaml b/application/src/main/resources/extensions/system-setting.yaml index f287d1274b..f4cd28bb19 100644 --- a/application/src/main/resources/extensions/system-setting.yaml +++ b/application/src/main/resources/extensions/system-setting.yaml @@ -78,6 +78,10 @@ spec: - $formkit: roleSelect name: defaultRole label: "默认角色" + - $formkit: attachmentPolicySelect + name: avatarPolicy + label: "头像存储位置" + value: "default-policy" - group: comment label: 评论设置 formSchema: diff --git a/application/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java b/application/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java index 04c751f1f2..6cbcb718c6 100644 --- a/application/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java +++ b/application/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java @@ -7,6 +7,7 @@ import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -17,6 +18,7 @@ import java.time.Instant; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -30,19 +32,25 @@ import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; +import org.springframework.http.client.MultipartBodyBuilder; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.reactive.function.BodyInserters; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Role; import run.halo.app.core.extension.RoleBinding; import run.halo.app.core.extension.User; +import run.halo.app.core.extension.attachment.Attachment; +import run.halo.app.core.extension.service.AttachmentService; import run.halo.app.core.extension.service.RoleService; import run.halo.app.core.extension.service.UserService; import run.halo.app.extension.ListResult; import run.halo.app.extension.Metadata; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.exception.ExtensionNotFoundException; +import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; +import run.halo.app.infra.SystemSetting; import run.halo.app.infra.utils.JsonUtils; @SpringBootTest @@ -55,6 +63,12 @@ class UserEndpointTest { @Mock RoleService roleService; + @Mock + AttachmentService attachmentService; + + @Mock + SystemConfigurableEnvironmentFetcher environmentFetcher; + @Mock ReactiveExtensionClient client; @@ -512,4 +526,104 @@ void createWhenNameDuplicate() { .exchange() .expectStatus().isOk(); } + + @Nested + class AvatarUploadTest { + @Test + void respondWithErrorIfTypeNotPNG() { + + var multipartBodyBuilder = new MultipartBodyBuilder(); + multipartBodyBuilder.part("file", "fake-file") + .contentType(MediaType.IMAGE_JPEG) + .filename("fake-filename.jpg"); + + SystemSetting.User user = mock(SystemSetting.User.class); + when(environmentFetcher.fetch(SystemSetting.User.GROUP, SystemSetting.User.class)) + .thenReturn(Mono.just(user)); + + webClient + .post() + .uri("/users/-/avatar") + .contentType(MediaType.MULTIPART_FORM_DATA) + .body(BodyInserters.fromMultipartData(multipartBodyBuilder.build())) + .exchange() + .expectStatus() + .is4xxClientError(); + } + + @Test + void shouldUploadSuccessfully() { + var currentUser = createUser("fake-user"); + + Attachment attachment = new Attachment(); + Metadata metadata = new Metadata(); + metadata.setName("fake-attachment"); + attachment.setMetadata(metadata); + + var multipartBodyBuilder = new MultipartBodyBuilder(); + multipartBodyBuilder.part("file", "fake-file") + .contentType(MediaType.IMAGE_PNG) + .filename("fake-filename.png"); + + SystemSetting.User user = mock(SystemSetting.User.class); + when(environmentFetcher.fetch(SystemSetting.User.GROUP, SystemSetting.User.class)) + .thenReturn(Mono.just(user)); + when(client.get(User.class, "fake-user")).thenReturn(Mono.just(currentUser)); + when(attachmentService.upload(anyString(), anyString(), anyString(), + any(), any(MediaType.IMAGE_PNG.getClass()))).thenReturn(Mono.just(attachment)); + + when(client.update(currentUser)).thenReturn(Mono.just(currentUser)); + + webClient.post() + .uri("/users/-/avatar") + .contentType(MediaType.MULTIPART_FORM_DATA) + .body(BodyInserters.fromMultipartData(multipartBodyBuilder.build())) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .json(""" + { + "spec":{ + "displayName":"Faker", + "avatar":"fake-avatar.png", + "email":"hi@halo.run", + "password":"fake-password", + "bio":"Fake bio" + }, + "status":null, + "apiVersion":"v1alpha1", + "kind":"User", + "metadata":{ + "name":"fake-user", + "annotations":{ + "halo.run/avatar-attachment-name": + "fake-attachment" + } + } + } + """); + + verify(client).get(User.class, "fake-user"); + verify(client).update(currentUser); + } + + User createUser(String name) { + var spec = new User.UserSpec(); + spec.setEmail("hi@halo.run"); + spec.setBio("Fake bio"); + spec.setDisplayName("Faker"); + spec.setAvatar("fake-avatar.png"); + spec.setPassword("fake-password"); + + var metadata = new Metadata(); + metadata.setName(name); + metadata.setAnnotations(new HashMap<>()); + + var user = new User(); + user.setSpec(spec); + user.setMetadata(metadata); + return user; + } + } } diff --git a/application/src/test/java/run/halo/app/core/extension/reconciler/UserReconcilerTest.java b/application/src/test/java/run/halo/app/core/extension/reconciler/UserReconcilerTest.java index 19bf539f02..6f1aac113a 100644 --- a/application/src/test/java/run/halo/app/core/extension/reconciler/UserReconcilerTest.java +++ b/application/src/test/java/run/halo/app/core/extension/reconciler/UserReconcilerTest.java @@ -64,10 +64,10 @@ void permalinkForFakeUser() throws URISyntaxException { when(client.fetch(eq(User.class), eq("fake-user"))) .thenReturn(Optional.of(user("fake-user"))); userReconciler.reconcile(new Reconciler.Request("fake-user")); - verify(client, times(2)).update(any(User.class)); + verify(client, times(3)).update(any(User.class)); ArgumentCaptor captor = ArgumentCaptor.forClass(User.class); - verify(client, times(2)).update(captor.capture()); + verify(client, times(3)).update(captor.capture()); assertThat(captor.getValue().getStatus().getPermalink()) .isEqualTo("http://localhost:8090/authors/fake-user"); } @@ -77,7 +77,7 @@ void permalinkForAnonymousUser() { when(client.fetch(eq(User.class), eq(AnonymousUserConst.PRINCIPAL))) .thenReturn(Optional.of(user(AnonymousUserConst.PRINCIPAL))); userReconciler.reconcile(new Reconciler.Request(AnonymousUserConst.PRINCIPAL)); - verify(client, times(1)).update(any(User.class)); + verify(client, times(2)).update(any(User.class)); } @Test @@ -102,7 +102,7 @@ void ensureRoleNamesAnno() { userReconciler.reconcile(new Reconciler.Request("fake-user")); ArgumentCaptor captor = ArgumentCaptor.forClass(User.class); - verify(client, times(2)).update(captor.capture()); + verify(client, times(3)).update(captor.capture()); User user = captor.getAllValues().get(1); assertThat(user.getMetadata().getAnnotations().get(User.ROLE_NAMES_ANNO)) .isEqualTo("[\"fake-role\"]"); @@ -113,6 +113,7 @@ User user(String name) { user.setMetadata(new Metadata()); user.getMetadata().setName(name); user.getMetadata().setFinalizers(Set.of("user-protection")); + user.setSpec(new User.UserSpec()); return user; } } \ No newline at end of file diff --git a/console/package.json b/console/package.json index a85e003cb7..fe26d9c56f 100644 --- a/console/package.json +++ b/console/package.json @@ -74,6 +74,7 @@ "axios": "^0.27.2", "codemirror": "^6.0.1", "colorjs.io": "^0.4.3", + "cropperjs": "^1.5.13", "dayjs": "^1.11.7", "emoji-mart": "^5.3.3", "fastq": "^1.15.0", diff --git a/console/packages/api-client/src/api/api-console-halo-run-v1alpha1-user-api.ts b/console/packages/api-client/src/api/api-console-halo-run-v1alpha1-user-api.ts index 269fae8b5a..130ec8032c 100644 --- a/console/packages/api-client/src/api/api-console-halo-run-v1alpha1-user-api.ts +++ b/console/packages/api-client/src/api/api-console-halo-run-v1alpha1-user-api.ts @@ -185,6 +185,60 @@ export const ApiConsoleHaloRunV1alpha1UserApiAxiosParamCreator = function ( options: localVarRequestOptions, }; }, + /** + * delete user avatar + * @param {string} name User name + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteUserAvatar: async ( + name: string, + options: AxiosRequestConfig = {} + ): Promise => { + // verify required parameter 'name' is not null or undefined + assertParamExists("deleteUserAvatar", "name", name); + const localVarPath = + `/apis/api.console.halo.run/v1alpha1/users/{name}/avatar`.replace( + `{${"name"}}`, + encodeURIComponent(String(name)) + ); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "DELETE", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication BasicAuth required + // http basic authentication required + setBasicAuthToObject(localVarRequestOptions, configuration); + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration); + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Get current user detail * @param {*} [options] Override http request option. @@ -544,6 +598,74 @@ export const ApiConsoleHaloRunV1alpha1UserApiAxiosParamCreator = function ( configuration ); + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * upload user avatar + * @param {string} name User name + * @param {File} file + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + uploadUserAvatar: async ( + name: string, + file: File, + options: AxiosRequestConfig = {} + ): Promise => { + // verify required parameter 'name' is not null or undefined + assertParamExists("uploadUserAvatar", "name", name); + // verify required parameter 'file' is not null or undefined + assertParamExists("uploadUserAvatar", "file", file); + const localVarPath = + `/apis/api.console.halo.run/v1alpha1/users/{name}/avatar`.replace( + `{${"name"}}`, + encodeURIComponent(String(name)) + ); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "POST", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + const localVarFormParams = new ((configuration && + configuration.formDataCtor) || + FormData)(); + + // authentication BasicAuth required + // http basic authentication required + setBasicAuthToObject(localVarRequestOptions, configuration); + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration); + + if (file !== undefined) { + localVarFormParams.append("file", file as any); + } + + localVarHeaderParameter["Content-Type"] = "multipart/form-data"; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + localVarRequestOptions.data = localVarFormParams; + return { url: toPathString(localVarUrlObj), options: localVarRequestOptions, @@ -611,6 +733,27 @@ export const ApiConsoleHaloRunV1alpha1UserApiFp = function ( configuration ); }, + /** + * delete user avatar + * @param {string} name User name + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async deleteUserAvatar( + name: string, + options?: AxiosRequestConfig + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.deleteUserAvatar(name, options); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, /** * Get current user detail * @param {*} [options] Override http request option. @@ -767,6 +910,29 @@ export const ApiConsoleHaloRunV1alpha1UserApiFp = function ( configuration ); }, + /** + * upload user avatar + * @param {string} name User name + * @param {File} file + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async uploadUserAvatar( + name: string, + file: File, + options?: AxiosRequestConfig + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.uploadUserAvatar(name, file, options); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, }; }; @@ -813,6 +979,20 @@ export const ApiConsoleHaloRunV1alpha1UserApiFactory = function ( .createUser(requestParameters.createUserRequest, options) .then((request) => request(axios, basePath)); }, + /** + * delete user avatar + * @param {ApiConsoleHaloRunV1alpha1UserApiDeleteUserAvatarRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deleteUserAvatar( + requestParameters: ApiConsoleHaloRunV1alpha1UserApiDeleteUserAvatarRequest, + options?: AxiosRequestConfig + ): AxiosPromise { + return localVarFp + .deleteUserAvatar(requestParameters.name, options) + .then((request) => request(axios, basePath)); + }, /** * Get current user detail * @param {*} [options] Override http request option. @@ -908,6 +1088,24 @@ export const ApiConsoleHaloRunV1alpha1UserApiFactory = function ( .updateCurrentUser(requestParameters.user, options) .then((request) => request(axios, basePath)); }, + /** + * upload user avatar + * @param {ApiConsoleHaloRunV1alpha1UserApiUploadUserAvatarRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + uploadUserAvatar( + requestParameters: ApiConsoleHaloRunV1alpha1UserApiUploadUserAvatarRequest, + options?: AxiosRequestConfig + ): AxiosPromise { + return localVarFp + .uploadUserAvatar( + requestParameters.name, + requestParameters.file, + options + ) + .then((request) => request(axios, basePath)); + }, }; }; @@ -946,6 +1144,20 @@ export interface ApiConsoleHaloRunV1alpha1UserApiCreateUserRequest { readonly createUserRequest: CreateUserRequest; } +/** + * Request parameters for deleteUserAvatar operation in ApiConsoleHaloRunV1alpha1UserApi. + * @export + * @interface ApiConsoleHaloRunV1alpha1UserApiDeleteUserAvatarRequest + */ +export interface ApiConsoleHaloRunV1alpha1UserApiDeleteUserAvatarRequest { + /** + * User name + * @type {string} + * @memberof ApiConsoleHaloRunV1alpha1UserApiDeleteUserAvatar + */ + readonly name: string; +} + /** * Request parameters for getPermissions operation in ApiConsoleHaloRunV1alpha1UserApi. * @export @@ -1065,6 +1277,27 @@ export interface ApiConsoleHaloRunV1alpha1UserApiUpdateCurrentUserRequest { readonly user: User; } +/** + * Request parameters for uploadUserAvatar operation in ApiConsoleHaloRunV1alpha1UserApi. + * @export + * @interface ApiConsoleHaloRunV1alpha1UserApiUploadUserAvatarRequest + */ +export interface ApiConsoleHaloRunV1alpha1UserApiUploadUserAvatarRequest { + /** + * User name + * @type {string} + * @memberof ApiConsoleHaloRunV1alpha1UserApiUploadUserAvatar + */ + readonly name: string; + + /** + * + * @type {File} + * @memberof ApiConsoleHaloRunV1alpha1UserApiUploadUserAvatar + */ + readonly file: File; +} + /** * ApiConsoleHaloRunV1alpha1UserApi - object-oriented interface * @export @@ -1108,6 +1341,22 @@ export class ApiConsoleHaloRunV1alpha1UserApi extends BaseAPI { .then((request) => request(this.axios, this.basePath)); } + /** + * delete user avatar + * @param {ApiConsoleHaloRunV1alpha1UserApiDeleteUserAvatarRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ApiConsoleHaloRunV1alpha1UserApi + */ + public deleteUserAvatar( + requestParameters: ApiConsoleHaloRunV1alpha1UserApiDeleteUserAvatarRequest, + options?: AxiosRequestConfig + ) { + return ApiConsoleHaloRunV1alpha1UserApiFp(this.configuration) + .deleteUserAvatar(requestParameters.name, options) + .then((request) => request(this.axios, this.basePath)); + } + /** * Get current user detail * @param {*} [options] Override http request option. @@ -1212,4 +1461,20 @@ export class ApiConsoleHaloRunV1alpha1UserApi extends BaseAPI { .updateCurrentUser(requestParameters.user, options) .then((request) => request(this.axios, this.basePath)); } + + /** + * upload user avatar + * @param {ApiConsoleHaloRunV1alpha1UserApiUploadUserAvatarRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ApiConsoleHaloRunV1alpha1UserApi + */ + public uploadUserAvatar( + requestParameters: ApiConsoleHaloRunV1alpha1UserApiUploadUserAvatarRequest, + options?: AxiosRequestConfig + ) { + return ApiConsoleHaloRunV1alpha1UserApiFp(this.configuration) + .uploadUserAvatar(requestParameters.name, requestParameters.file, options) + .then((request) => request(this.axios, this.basePath)); + } } diff --git a/console/packages/components/src/icons/icons.ts b/console/packages/components/src/icons/icons.ts index 3fd463b735..cc2f4c0e33 100644 --- a/console/packages/components/src/icons/icons.ts +++ b/console/packages/components/src/icons/icons.ts @@ -59,6 +59,12 @@ import IconArrowDownCircleLine from "~icons/ri/arrow-down-circle-line"; import IconTerminalBoxLine from "~icons/ri/terminal-box-line"; import IconClipboardLine from "~icons/ri/clipboard-line"; import IconLockPasswordLine from "~icons/ri/lock-password-line"; +import IconRiPencilFill from "~icons/ri/pencil-fill"; +import IconZoomInLine from "~icons/ri/zoom-in-line"; +import IconZoomOutLine from "~icons/ri/zoom-out-line"; +import IconArrowLeftRightLine from "~icons/ri/arrow-left-right-line"; +import IconArrowUpDownLine from "~icons/ri/arrow-up-down-line"; +import IconRiUpload2Fill from "~icons/ri/upload-2-fill"; export { IconDashboard, @@ -122,4 +128,10 @@ export { IconTerminalBoxLine, IconClipboardLine, IconLockPasswordLine, + IconRiPencilFill, + IconZoomInLine, + IconZoomOutLine, + IconArrowLeftRightLine, + IconArrowUpDownLine, + IconRiUpload2Fill, }; diff --git a/console/pnpm-lock.yaml b/console/pnpm-lock.yaml index fa9151d74e..e973855d33 100644 --- a/console/pnpm-lock.yaml +++ b/console/pnpm-lock.yaml @@ -128,6 +128,9 @@ importers: colorjs.io: specifier: ^0.4.3 version: 0.4.3 + cropperjs: + specifier: ^1.5.13 + version: 1.5.13 dayjs: specifier: ^1.11.7 version: 1.11.7 @@ -5550,6 +5553,10 @@ packages: /crelt@1.0.5: resolution: {integrity: sha512-+BO9wPPi+DWTDcNYhr/W90myha8ptzftZT+LwcmUbbok0rcP/fequmFYCw8NMoH7pkAZQzU78b3kYrlua5a9eA==} + /cropperjs@1.5.13: + resolution: {integrity: sha512-by7jKAo73y5/Do0K6sxdTKHgndY0NMjG2bEdgeJxycbcmHuCiMXqw8sxy5C5Y5WTOTcDGmbT7Sr5CgKOXR06OA==} + dev: false + /cross-spawn@5.1.0: resolution: {integrity: sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==} dependencies: diff --git a/console/src/constants/annotations.ts b/console/src/constants/annotations.ts index 03abddca01..c3c16a56f9 100644 --- a/console/src/constants/annotations.ts +++ b/console/src/constants/annotations.ts @@ -9,6 +9,8 @@ export enum rbacAnnotations { ROLE_NAMES = "rbac.authorization.halo.run/role-names", DISPLAY_NAME = "rbac.authorization.halo.run/display-name", DEPENDENCIES = "rbac.authorization.halo.run/dependencies", + AVATAR_ATTACHMENT_NAME = "halo.run/avatar-attachment-name", + LAST_AVATAR_ATTACHMENT_NAME = "halo.run/last-avatar-attachment-name", } // content diff --git a/console/src/locales/en.yaml b/console/src/locales/en.yaml index 7160163827..5522d39b03 100644 --- a/console/src/locales/en.yaml +++ b/console/src/locales/en.yaml @@ -874,6 +874,14 @@ core: bio: Bio creation_time: Creation time identity_authentication: Identity authentication + avatar: + title: Avatar + toast_upload_failed: Failed to upload avatar + toast_remove_failed: Failed to delete avatar + cropper_modal: + title: Crop Avatar + remove: + title: Are you sure you want to delete the avatar? role: title: Roles common: diff --git a/console/src/locales/zh-CN.yaml b/console/src/locales/zh-CN.yaml index a0e6d6235b..43d4b07d8b 100644 --- a/console/src/locales/zh-CN.yaml +++ b/console/src/locales/zh-CN.yaml @@ -874,6 +874,14 @@ core: bio: 描述 creation_time: 注册时间 identity_authentication: 登录方式 + avatar: + title: 头像 + toast_upload_failed: 上传头像失败 + toast_remove_failed: 删除头像失败 + cropper_modal: + title: 裁剪头像 + remove: + title: 确定要删除头像吗? role: title: 角色 common: diff --git a/console/src/locales/zh-TW.yaml b/console/src/locales/zh-TW.yaml index ba21806f77..2a20f657fd 100644 --- a/console/src/locales/zh-TW.yaml +++ b/console/src/locales/zh-TW.yaml @@ -874,6 +874,14 @@ core: bio: 描述 creation_time: 註冊時間 identity_authentication: 登入方式 + avatar: + title: 頭像 + toast_upload_failed: 上傳頭像失敗 + toast_remove_failed: 刪除頭像失敗 + cropper_modal: + title: 裁剪頭像 + remove: + title: 確定要刪除頭像嗎? role: title: 角色 common: diff --git a/console/src/modules/system/users/components/UserAvatarCropper.vue b/console/src/modules/system/users/components/UserAvatarCropper.vue new file mode 100644 index 0000000000..3d473a8d61 --- /dev/null +++ b/console/src/modules/system/users/components/UserAvatarCropper.vue @@ -0,0 +1,259 @@ + + diff --git a/console/src/modules/system/users/components/UserCreationModal.vue b/console/src/modules/system/users/components/UserCreationModal.vue index b4f6d7c2d4..1884c45786 100644 --- a/console/src/modules/system/users/components/UserCreationModal.vue +++ b/console/src/modules/system/users/components/UserCreationModal.vue @@ -149,13 +149,6 @@ const handleCreateUser = async () => { name="phone" validation="length:0,20" > - { name="phone" validation="length:0,20" > - import("../components/UserAvatarCropper.vue") +); + +interface IUserAvatarCropperType + extends Ref> { + getCropperFile(): Promise; +} + +const { open, reset, onChange } = useFileDialog({ + accept: ".jpg, .jpeg, .png", + multiple: false, +}); const { currentUserHasPermission } = usePermission(); const userStore = useUserStore(); const { t } = useI18n(); @@ -50,6 +72,7 @@ const { params } = useRoute(); const { data: user, + isFetching, isLoading, refetch, } = useQuery({ @@ -65,6 +88,13 @@ const { return data; } }, + refetchInterval: (data) => { + const annotations = data?.user.metadata.annotations; + return annotations?.[rbacAnnotations.AVATAR_ATTACHMENT_NAME] !== + annotations?.[rbacAnnotations.LAST_AVATAR_ATTACHMENT_NAME] + ? 1000 + : false; + }, }); const isCurrentUser = computed(() => { @@ -104,6 +134,74 @@ const handleTabChange = (id: string) => { router.push({ name: tab.routeName }); } }; + +const userAvatarCropper = ref(); +const showAvatarEditor = ref(false); +const visibleCropperModal = ref(false); +const originalFile = ref() as Ref; +onChange((files) => { + if (!files) { + return; + } + if (files.length > 0) { + originalFile.value = files[0]; + visibleCropperModal.value = true; + } +}); + +const uploadSaving = ref(false); +const handleUploadAvatar = () => { + userAvatarCropper.value?.getCropperFile().then((file) => { + uploadSaving.value = true; + apiClient.user + .uploadUserAvatar({ + name: params.name as string, + file: file, + }) + .then(() => { + refetch(); + handleCloseCropperModal(); + }) + .catch(() => { + Toast.error(t("core.user.detail.avatar.toast_upload_failed")); + }) + .finally(() => { + uploadSaving.value = false; + }); + }); +}; + +const handleRemoveCurrentAvatar = () => { + Dialog.warning({ + title: t("core.user.detail.avatar.remove.title"), + description: t("core.common.dialog.descriptions.cannot_be_recovered"), + confirmType: "danger", + confirmText: t("core.common.buttons.confirm"), + cancelText: t("core.common.buttons.cancel"), + onConfirm: async () => { + apiClient.user + .deleteUserAvatar({ + name: params.name as string, + }) + .then(() => { + refetch(); + }) + .catch(() => { + Toast.error(t("core.user.detail.avatar.toast_remove_failed")); + }); + }, + }); +}; + +const handleCloseCropperModal = () => { + visibleCropperModal.value = false; + reset(); +}; + +const changeUploadAvatar = () => { + reset(); + open(); +};