Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for user avatar upload #4253

Merged
merged 38 commits into from Jul 24, 2023
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
fc433c2
add upload avatar
LIlGG Jul 14, 2023
65babe7
Merge branch 'main' of https://github.com/halo-dev/halo into refactor…
LIlGG Jul 17, 2023
2c245f3
refactor: add support for user avatar upload
LIlGG Jul 17, 2023
83b479c
optimized avatar cropper
LIlGG Jul 17, 2023
28397e1
refactor: add support for user avatar upload
LIlGG Jul 18, 2023
f1c7f73
change avatar upload edit icon
LIlGG Jul 18, 2023
433aae8
Merge branch 'main' of https://github.com/halo-dev/halo into refactor…
LIlGG Jul 19, 2023
9a2d926
refactor:add support for user avatar upload
LIlGG Jul 20, 2023
ff55410
Merge branch 'main' of https://github.com/halo-dev/halo into refactor…
LIlGG Jul 20, 2023
2140744
refactor:add support for user avatar upload
LIlGG Jul 20, 2023
164dd79
Merge branch 'main' of https://github.com/halo-dev/halo into refactor…
LIlGG Jul 20, 2023
d74f128
refactor:add support for user avatar upload
LIlGG Jul 20, 2023
55cb1c0
refactor:add support for user avatar upload
LIlGG Jul 20, 2023
ce97be9
Merge branch 'main' into refactor/avatar
ruibaby Jul 20, 2023
3276603
add upload avatar
LIlGG Jul 14, 2023
48e2e1a
refactor: add support for user avatar upload
LIlGG Jul 17, 2023
d09dffc
optimized avatar cropper
LIlGG Jul 17, 2023
135720a
refactor: add support for user avatar upload
LIlGG Jul 18, 2023
e78e179
change avatar upload edit icon
LIlGG Jul 18, 2023
df55a25
refactor:add support for user avatar upload
LIlGG Jul 20, 2023
19aa9cb
refactor:add support for user avatar upload
LIlGG Jul 20, 2023
4cbafeb
refactor:add support for user avatar upload
LIlGG Jul 20, 2023
5e01fe9
refactor:add support for user avatar upload
LIlGG Jul 20, 2023
4f569e4
refactor:add support for user avatar upload
LIlGG Jul 20, 2023
fc7149c
Resolve merge
LIlGG Jul 20, 2023
517612f
Merge branch 'main' into refactor/avatar
JohnNiang Jul 21, 2023
a4b444a
Merge branch 'main' into refactor/avatar
guqing Jul 21, 2023
73a0522
refactor:add support for user avatar upload
LIlGG Jul 21, 2023
10a0c42
refactor:add support for user avatar upload
LIlGG Jul 21, 2023
a8787ce
Merge branch 'main' into refactor/avatar
guqing Jul 21, 2023
3e3abd0
refactor:add support for user avatar upload
LIlGG Jul 21, 2023
46faaf0
Merge remote-tracking branch 'upstream/main' into refactor/avatar
LIlGG Jul 21, 2023
e776943
Merge branch 'refactor/avatar' of https://github.com/LIlGG/halo into …
LIlGG Jul 21, 2023
40a2ea5
refactor:add support for user avatar upload
LIlGG Jul 23, 2023
245a073
refactor:add support for user avatar upload
LIlGG Jul 23, 2023
3cb6060
refactor:add support for user avatar upload
LIlGG Jul 24, 2023
6a2f955
Merge branch 'main' into refactor/avatar
LIlGG Jul 24, 2023
a1ea92b
Merge branch 'main' into refactor/avatar
ruibaby Jul 24, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions api/src/main/java/run/halo/app/core/extension/User.java
Expand Up @@ -34,6 +34,12 @@ 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 =
"rbac.authorization.halo.run/last-avatar-attachment-name";
LIlGG marked this conversation as resolved.
Show resolved Hide resolved

public static final String AVATAR_ATTACHMENT_NAME_ANNO =
"rbac.authorization.halo.run/avatar-attachment-name";
LIlGG marked this conversation as resolved.
Show resolved Hide resolved

public static final String HIDDEN_USER_LABEL = "halo.run/hidden-user";

@Schema(required = true)
Expand Down
2 changes: 2 additions & 0 deletions api/src/main/java/run/halo/app/infra/SystemSetting.java
Expand Up @@ -68,6 +68,8 @@ public static class User {
public static final String GROUP = "user";
Boolean allowRegistration;
String defaultRole;
String avatarPolicy;
Integer avatarMaxSize;
}

@Data
Expand Down
Expand Up @@ -5,8 +5,11 @@
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;
Expand All @@ -31,16 +34,22 @@
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.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.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;
Expand All @@ -51,6 +60,7 @@
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.service.AttachmentService;
import run.halo.app.core.extension.service.RoleService;
import run.halo.app.core.extension.service.UserService;
import run.halo.app.extension.Comparators;
Expand All @@ -59,16 +69,22 @@
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
@RequiredArgsConstructor
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 final ReactiveExtensionClient client;
private final UserService userService;
private final RoleService roleService;
private final AttachmentService attachmentService;
private final SystemConfigurableEnvironmentFetcher environmentFetcher;

@Override
public RouterFunction<ServerResponse> endpoint() {
Expand Down Expand Up @@ -145,9 +161,120 @@ public RouterFunction<ServerResponse> endpoint() {
.implementation(generateGenericClass(ListedUser.class)));
buildParametersFromType(builder, ListRequest.class);
})
.POST("users/-/avatar", contentType(MediaType.MULTIPART_FORM_DATA),
this::uploadUserAvatar,
builder -> builder
.operationId("UploadCurrentUserAvatar")
.tag(tag)
.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/-/avatar", this::deleteUserAvatar, builder -> builder
.tag(tag)
.operationId("DeleteCurrentUserAvatar")
.description("delete current user avatar")
.response(responseBuilder().implementation(User.class))
.build())
.build();
}

private Mono<ServerResponse> deleteUserAvatar(ServerRequest request) {
return getCurrentUser()
.flatMap(user -> {
MetadataUtil.nullSafeAnnotations(user)
.remove(User.AVATAR_ATTACHMENT_NAME_ANNO);
return client.update(user);
})
.flatMap(user -> ServerResponse.ok().bodyValue(user));
}

private Mono<ServerResponse> uploadUserAvatar(ServerRequest request) {
return request.body(BodyExtractors.toMultipartData())
.map(AvatarUploadRequest::new)
.flatMap(uploadRequest -> environmentFetcher.fetch(SystemSetting.User.GROUP,
SystemSetting.User.class)
.switchIfEmpty(
Mono.error(new IllegalStateException("User setting is not configured"))
)
.flatMap(userSetting -> {
FilePart filePart = uploadRequest.getFile();
Integer avatarMaxSize = userSetting.getAvatarMaxSize();
if (Objects.isNull(avatarMaxSize)) {
avatarMaxSize = 1;
}
final Integer finalAvatarMaxSize = avatarMaxSize;
return filePart.content()
.reduce(0L, (totalSize, dataBuffer) -> {
long byteCount = dataBuffer.readableByteCount();
if (totalSize + byteCount > finalAvatarMaxSize * 1024 * 1024) {
throw new ServerWebInputException(
"The avatar file needs to be smaller than " + finalAvatarMaxSize
+ " MB.");
}
return totalSize + byteCount;
})
.flatMap(item -> {
String avatarPolicy = userSetting.getAvatarPolicy();
if (StringUtils.isBlank(avatarPolicy)) {
avatarPolicy = DEFAULT_USER_AVATAR_ATTACHMENT_POLICY_NAME;
}
return attachmentService.upload(avatarPolicy,
USER_AVATAR_GROUP_NAME,
filePart.filename(),
filePart.content(),
filePart.headers().getContentType()
);
});
})
)
.flatMap(attachment -> Mono.defer(() -> getCurrentUser()
.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<String, Part> 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<User> getCurrentUser() {
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.map(Authentication::getName)
.flatMap(currentUserName -> client.get(User.class, currentUserName));
}

private Mono<ServerResponse> createUser(ServerRequest request) {
return request.bodyToMono(CreateUserRequest.class)
.doOnNext(createUserRequest -> {
Expand Down Expand Up @@ -234,10 +361,18 @@ private Mono<ServerResponse> updateProfile(ServerRequest request) {
.switchIfEmpty(
Mono.error(() -> new ServerWebInputException("Username didn't match.")))
.map(user -> {
currentUser.getMetadata().setAnnotations(user.getMetadata().getAnnotations());
Map<String, String> oldAnnotations =
MetadataUtil.nullSafeAnnotations(currentUser);
Map<String, String> 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());
Expand Down
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -37,6 +40,7 @@ public class UserReconciler implements Reconciler<Request> {
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)
Expand All @@ -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<String, String> 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<String, String> annotations = MetadataUtil.nullSafeAnnotations(user);
Expand Down
Expand Up @@ -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
Expand Up @@ -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"
Expand Down
11 changes: 11 additions & 0 deletions application/src/main/resources/extensions/system-setting.yaml
Expand Up @@ -78,6 +78,17 @@ spec:
- $formkit: roleSelect
name: defaultRole
label: "默认角色"
- $formkit: attachmentPolicySelect
name: avatarPolicy
label: "头像附件存储策略"
value: "default-policy"
- $formkit: number
LIlGG marked this conversation as resolved.
Show resolved Hide resolved
label: "允许上传的头像大小(MB)"
name: avatarMaxSize
value: 2
min: 1
max: 100
validation: required | max:100 | min:1
- group: comment
label: 评论设置
formSchema:
Expand Down