From 462f99f3b1cc0bd1ce997a256a7fdb5e3008f98f Mon Sep 17 00:00:00 2001 From: IrusHunter Date: Thu, 28 May 2026 11:18:01 +0300 Subject: [PATCH 01/19] feat: add UserAnonymityConfig model --- .dockerignore | 6 + cmd/wire_gen.go | 34 +- docker-compose.yaml | 7 +- docs/db.drawio | 762 ++++++++++++++++++ internal/controller/user_controller.go | 40 + internal/entity/fake_username_entity.go | 18 + .../entity/user_anonymity_config_entity.go | 15 + internal/migrations/init_data.go | 2 + internal/repo/provider.go | 2 + .../user_anonymity_config_repo.go | 65 ++ internal/router/answer_api_router.go | 2 + .../schema/user_anonymity_config_schema.go | 22 + internal/service/content/user_service.go | 7 + internal/service/provider.go | 2 + .../user_anonymity_config_service.go | 65 ++ 15 files changed, 1022 insertions(+), 27 deletions(-) create mode 100644 .dockerignore create mode 100644 docs/db.drawio create mode 100644 internal/entity/fake_username_entity.go create mode 100644 internal/entity/user_anonymity_config_entity.go create mode 100644 internal/repo/user_anonymity_config/user_anonymity_config_repo.go create mode 100644 internal/schema/user_anonymity_config_schema.go create mode 100644 internal/service/user_anonymity_config/user_anonymity_config_service.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..c3373c53c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +**/node_modules +.pnpm-store +dist +build +.git \ No newline at end of file diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 9fe134ed6..03fd92384 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -1,28 +1,8 @@ -//go:build !wireinject -// +build !wireinject - -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - // Code generated by Wire. DO NOT EDIT. -//go:generate go run github.com/google/wire/cmd/wire +//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:build !wireinject +// +build !wireinject package answercmd @@ -68,6 +48,7 @@ import ( "github.com/apache/answer/internal/repo/tag_common" "github.com/apache/answer/internal/repo/unique" "github.com/apache/answer/internal/repo/user" + "github.com/apache/answer/internal/repo/user_anonymity_config" "github.com/apache/answer/internal/repo/user_external_login" "github.com/apache/answer/internal/repo/user_notification_config" "github.com/apache/answer/internal/router" @@ -116,6 +97,7 @@ import ( tag_common2 "github.com/apache/answer/internal/service/tag_common" "github.com/apache/answer/internal/service/uploader" "github.com/apache/answer/internal/service/user_admin" + user_anonymity_config2 "github.com/apache/answer/internal/service/user_anonymity_config" "github.com/apache/answer/internal/service/user_common" user_external_login2 "github.com/apache/answer/internal/service/user_external_login" user_notification_config2 "github.com/apache/answer/internal/service/user_notification_config" @@ -169,6 +151,8 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, userNotificationConfigRepo := user_notification_config.NewUserNotificationConfigRepo(dataData) userNotificationConfigService := user_notification_config2.NewUserNotificationConfigService(userRepo, userNotificationConfigRepo) userExternalLoginService := user_external_login2.NewUserExternalLoginService(userRepo, userCommon, userExternalLoginRepo, emailService, siteInfoCommonService, userActiveActivityRepo, userNotificationConfigService) + userAnonymityConfigRepo := user_anonymity_config.NewUserAnonymityConfigRepo(dataData) + userAnonymityConfigService := user_anonymity_config2.NewUserAnonymityConfigService(userAnonymityConfigRepo) questionRepo := question.NewQuestionRepo(dataData, uniqueIDRepo) answerRepo := answer.NewAnswerRepo(dataData, uniqueIDRepo, userRankRepo, activityRepo) voteRepo := activity_common.NewVoteRepo(dataData, activityRepo) @@ -189,10 +173,10 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, eventqueueService := eventqueue.NewService() fileRecordRepo := file_record.NewFileRecordRepo(dataData) fileRecordService := file_record2.NewFileRecordService(fileRecordRepo, revisionRepo, serviceConf, siteInfoCommonService, userCommon) - userService := content.NewUserService(userRepo, userActiveActivityRepo, activityRepo, emailService, authService, siteInfoCommonService, userRoleRelService, userCommon, userExternalLoginService, userNotificationConfigRepo, userNotificationConfigService, questionCommon, eventqueueService, fileRecordService) + userService := content.NewUserService(userRepo, userActiveActivityRepo, activityRepo, emailService, authService, siteInfoCommonService, userRoleRelService, userCommon, userExternalLoginService, userNotificationConfigRepo, userNotificationConfigService, userAnonymityConfigService, questionCommon, eventqueueService, fileRecordService) captchaRepo := captcha.NewCaptchaRepo(dataData) captchaService := action.NewCaptchaService(captchaRepo) - userController := controller.NewUserController(authService, userService, captchaService, emailService, siteInfoCommonService, userNotificationConfigService) + userController := controller.NewUserController(authService, userService, captchaService, emailService, siteInfoCommonService, userNotificationConfigService, userAnonymityConfigService) commentRepo := comment.NewCommentRepo(dataData, uniqueIDRepo) commentCommonRepo := comment.NewCommentCommonRepo(dataData, uniqueIDRepo) objService := object_info.NewObjService(answerRepo, questionRepo, commentCommonRepo, tagCommonRepo, tagCommonService) diff --git a/docker-compose.yaml b/docker-compose.yaml index 58e8ea036..564b45cf0 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -18,12 +18,15 @@ version: "3" services: answer: - image: apache/answer + build: ./ ports: - '9080:80' - restart: on-failure volumes: - answer-data:/data + # env_file: + # - ./.env + # environment: + # - DOCKER_ENV=true volumes: answer-data: diff --git a/docs/db.drawio b/docs/db.drawio new file mode 100644 index 000000000..56faf8f20 --- /dev/null +++ b/docs/db.drawio @@ -0,0 +1,762 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/internal/controller/user_controller.go b/internal/controller/user_controller.go index 77c806e07..40dbeaec3 100644 --- a/internal/controller/user_controller.go +++ b/internal/controller/user_controller.go @@ -35,6 +35,7 @@ import ( "github.com/apache/answer/internal/service/content" "github.com/apache/answer/internal/service/export" "github.com/apache/answer/internal/service/siteinfo_common" + "github.com/apache/answer/internal/service/user_anonymity_config" "github.com/apache/answer/internal/service/user_notification_config" "github.com/apache/answer/pkg/checker" "github.com/gin-gonic/gin" @@ -50,6 +51,7 @@ type UserController struct { emailService *export.EmailService siteInfoCommonService siteinfo_common.SiteInfoCommonService userNotificationConfigService *user_notification_config.UserNotificationConfigService + userAnonymityConfigService *user_anonymity_config.UserAnonymityConfigService } // NewUserController new controller @@ -60,6 +62,7 @@ func NewUserController( emailService *export.EmailService, siteInfoCommonService siteinfo_common.SiteInfoCommonService, userNotificationConfigService *user_notification_config.UserNotificationConfigService, + userAnonymityConfigService *user_anonymity_config.UserAnonymityConfigService, ) *UserController { return &UserController{ authService: authService, @@ -68,6 +71,7 @@ func NewUserController( emailService: emailService, siteInfoCommonService: siteInfoCommonService, userNotificationConfigService: userNotificationConfigService, + userAnonymityConfigService: userAnonymityConfigService, } } @@ -547,6 +551,42 @@ func (uc *UserController) UpdateUserNotificationConfig(ctx *gin.Context) { handler.HandleResponse(ctx, err, nil) } +// GetUserAnonymityConfig get user's anonymity config +// @Summary get user's anonymity config +// @Description get user's anonymity config +// @Tags User +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Success 200 {object} handler.RespBody{data=schema.GetUserAnonymityConfigResp} +// @Router /answer/api/v1/user/anonymity/config [post] +func (uc *UserController) GetUserAnonymityConfig(ctx *gin.Context) { + userID := middleware.GetLoginUserIDFromContext(ctx) + resp, err := uc.userAnonymityConfigService.GetUserAnonymityConfig(ctx, userID) + handler.HandleResponse(ctx, err, resp) +} + +// UpdateUserAnonymityConfig update user's anonymity config +// @Summary update user's anonymity config +// @Description update user's anonymity config +// @Tags User +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param data body schema.UpdateUserAnonymityConfigReq true "UpdateUserAnonymityConfigReq" +// @Success 200 {object} handler.RespBody{} +// @Router /answer/api/v1/user/anonymity/config [put] +func (uc *UserController) UpdateUserAnonymityConfig(ctx *gin.Context) { + req := &schema.UpdateUserAnonymityConfigReq{} + if handler.BindAndCheck(ctx, req) { + return + } + + req.UserID = middleware.GetLoginUserIDFromContext(ctx) + err := uc.userAnonymityConfigService.UpdateUserAnonymityConfig(ctx, req) + handler.HandleResponse(ctx, err, nil) +} + // UserChangeEmailSendCode send email to the user email then change their email // @Summary send email to the user email then change their email // @Description send email to the user email then change their email diff --git a/internal/entity/fake_username_entity.go b/internal/entity/fake_username_entity.go new file mode 100644 index 000000000..02fb0940b --- /dev/null +++ b/internal/entity/fake_username_entity.go @@ -0,0 +1,18 @@ +package entity + +import "time" + +// FakeUsername fake username +type FakeUsername struct { + ID string `xorm:"pk autoincr BIGINT(20) id"` + CreatedAt time.Time `xorm:"created TIMESTAMP created_at"` + UpdatedAt time.Time `xorm:"updated TIMESTAMP updated_at"` + UserID string `xorm:"not null default 0 BIGINT(20) user_id"` + QuestionID string `xorm:"not null default 0 BIGINT(20) question_id"` + FakeName string `xorm:"not null VARCHAR(50) fake_name"` +} + +// TableName fake username table name +func (FakeUsername) TableName() string { + return "fake_username" +} diff --git a/internal/entity/user_anonymity_config_entity.go b/internal/entity/user_anonymity_config_entity.go new file mode 100644 index 000000000..09a3b2606 --- /dev/null +++ b/internal/entity/user_anonymity_config_entity.go @@ -0,0 +1,15 @@ +package entity + +import "time" + +type UserAnonymityConfig struct { + ID string `xorm:"not null pk autoincr BIGINT(20) id"` + CreatedAt time.Time `xorm:"created TIMESTAMP created_at"` + UpdatedAt time.Time `xorm:"updated TIMESTAMP updated_at"` + UserID string `xorm:"not null default 0 BIGINT(20) INDEX UNIQUE user_id"` + Enabled bool `xorm:"not null default false BOOL enabled"` +} + +func (UserAnonymityConfig) TableName() string { + return "user_anonymity_config" +} diff --git a/internal/migrations/init_data.go b/internal/migrations/init_data.go index 5af41bbfc..97bf61572 100644 --- a/internal/migrations/init_data.go +++ b/internal/migrations/init_data.go @@ -79,6 +79,8 @@ var ( &entity.APIKey{}, &entity.AIConversation{}, &entity.AIConversationRecord{}, + &entity.FakeUsername{}, + &entity.UserAnonymityConfig{}, } roles = []*entity.Role{ diff --git a/internal/repo/provider.go b/internal/repo/provider.go index 510a94aaa..d95daca03 100644 --- a/internal/repo/provider.go +++ b/internal/repo/provider.go @@ -53,6 +53,7 @@ import ( "github.com/apache/answer/internal/repo/tag_common" "github.com/apache/answer/internal/repo/unique" "github.com/apache/answer/internal/repo/user" + "github.com/apache/answer/internal/repo/user_anonymity_config" "github.com/apache/answer/internal/repo/user_external_login" "github.com/apache/answer/internal/repo/user_notification_config" "github.com/google/wire" @@ -103,6 +104,7 @@ var ProviderSetRepo = wire.NewSet( user_external_login.NewUserExternalLoginRepo, plugin_config.NewPluginConfigRepo, user_notification_config.NewUserNotificationConfigRepo, + user_anonymity_config.NewUserAnonymityConfigRepo, limit.NewRateLimitRepo, plugin_config.NewPluginUserConfigRepo, review.NewReviewRepo, diff --git a/internal/repo/user_anonymity_config/user_anonymity_config_repo.go b/internal/repo/user_anonymity_config/user_anonymity_config_repo.go new file mode 100644 index 000000000..c78dee918 --- /dev/null +++ b/internal/repo/user_anonymity_config/user_anonymity_config_repo.go @@ -0,0 +1,65 @@ +package user_anonymity_config + +import ( + "context" + + "github.com/apache/answer/internal/base/data" + "github.com/apache/answer/internal/base/reason" + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/internal/service/user_anonymity_config" + "github.com/segmentfault/pacman/errors" +) + +type userAnonymityConfigRepo struct { + data *data.Data +} + +func NewUserAnonymityConfigRepo(data *data.Data) user_anonymity_config.UserAnonymityConfigRepo { + return &userAnonymityConfigRepo{ + data: data, + } +} + +func (ur *userAnonymityConfigRepo) Add(ctx context.Context, userIDs []string, enabled bool) (err error) { + var configs []*entity.UserAnonymityConfig + for _, userID := range userIDs { + configs = append(configs, &entity.UserAnonymityConfig{ + UserID: userID, + Enabled: enabled, + }) + } + _, err = ur.data.DB.Context(ctx).Insert(configs) + if err != nil { + return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return nil +} + +func (ur *userAnonymityConfigRepo) Save(ctx context.Context, uc *entity.UserAnonymityConfig) (err error) { + old := &entity.UserAnonymityConfig{UserID: uc.UserID} + exist, err := ur.data.DB.Context(ctx).Get(old) + if err != nil { + return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + if exist { + old.Enabled = uc.Enabled + _, err = ur.data.DB.Context(ctx).ID(old.ID).UseBool("enabled").Cols("enabled").Update(old) + } else { + _, err = ur.data.DB.Context(ctx).Insert(uc) + } + if err != nil { + return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return nil +} + +// GetByUserID get anonymity config by user id +func (ur *userAnonymityConfigRepo) GetByUserID(ctx context.Context, userID string) ( + uc *entity.UserAnonymityConfig, exist bool, err error) { + uc = &entity.UserAnonymityConfig{UserID: userID} + exist, err = ur.data.DB.Context(ctx).Get(uc) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} diff --git a/internal/router/answer_api_router.go b/internal/router/answer_api_router.go index 84b8b4e1c..08069e20d 100644 --- a/internal/router/answer_api_router.go +++ b/internal/router/answer_api_router.go @@ -290,6 +290,8 @@ func (a *AnswerAPIRouter) RegisterAnswerAPIRouter(r *gin.RouterGroup) { r.PUT("/user/interface", a.userController.UserUpdateInterface) r.GET("/user/notification/config", a.userController.GetUserNotificationConfig) r.PUT("/user/notification/config", a.userController.UpdateUserNotificationConfig) + r.GET("user/anonymity/config", a.userController.GetUserAnonymityConfig) + r.PUT("user/anonymity/config", a.userController.UpdateUserAnonymityConfig) r.GET("/user/info/search", a.userController.SearchUserListByName) // vote diff --git a/internal/schema/user_anonymity_config_schema.go b/internal/schema/user_anonymity_config_schema.go new file mode 100644 index 000000000..23fad67d0 --- /dev/null +++ b/internal/schema/user_anonymity_config_schema.go @@ -0,0 +1,22 @@ +package schema + +import "github.com/apache/answer/internal/entity" + +type UserAnonymityConfig struct { + Enabled bool `validate:"required" json:"enabled"` +} + +func NewUserAnonymityConfig(uc entity.UserAnonymityConfig) UserAnonymityConfig { + return UserAnonymityConfig{ + Enabled: uc.Enabled, + } +} + +type UpdateUserAnonymityConfigReq struct { + UserAnonymityConfig + UserID string `json:"-"` +} + +type GetUserAnonymityConfigResp struct { + UserAnonymityConfig +} diff --git a/internal/service/content/user_service.go b/internal/service/content/user_service.go index 711d6caa0..b545058db 100644 --- a/internal/service/content/user_service.go +++ b/internal/service/content/user_service.go @@ -26,6 +26,7 @@ import ( "time" "github.com/apache/answer/internal/service/eventqueue" + "github.com/apache/answer/internal/service/user_anonymity_config" "github.com/apache/answer/pkg/token" "github.com/apache/answer/internal/base/constant" @@ -67,6 +68,7 @@ type UserService struct { userExternalLoginService *user_external_login.UserExternalLoginService userNotificationConfigRepo user_notification_config.UserNotificationConfigRepo userNotificationConfigService *user_notification_config.UserNotificationConfigService + userAnonymityConfigService *user_anonymity_config.UserAnonymityConfigService questionService *questioncommon.QuestionCommon eventQueueService eventqueue.Service fileRecordService *file_record.FileRecordService @@ -83,6 +85,7 @@ func NewUserService(userRepo usercommon.UserRepo, userExternalLoginService *user_external_login.UserExternalLoginService, userNotificationConfigRepo user_notification_config.UserNotificationConfigRepo, userNotificationConfigService *user_notification_config.UserNotificationConfigService, + userAnonymityConfigService *user_anonymity_config.UserAnonymityConfigService, questionService *questioncommon.QuestionCommon, eventQueueService eventqueue.Service, fileRecordService *file_record.FileRecordService, @@ -99,6 +102,7 @@ func NewUserService(userRepo usercommon.UserRepo, userExternalLoginService: userExternalLoginService, userNotificationConfigRepo: userNotificationConfigRepo, userNotificationConfigService: userNotificationConfigService, + userAnonymityConfigService: userAnonymityConfigService, questionService: questionService, eventQueueService: eventQueueService, fileRecordService: fileRecordService, @@ -478,6 +482,9 @@ func (us *UserService) UserRegisterByEmail(ctx context.Context, registerUserInfo if err := us.userNotificationConfigService.SetDefaultUserNotificationConfig(ctx, []string{userInfo.ID}); err != nil { log.Errorf("set default user notification config failed, err: %v", err) } + if err := us.userAnonymityConfigService.SetDefaultUserAnonymityConfig(ctx, []string{userInfo.ID}); err != nil { + log.Errorf("set default user anonymity config failed, err: %v", err) + } // send email data := &schema.EmailCodeContent{ diff --git a/internal/service/provider.go b/internal/service/provider.go index 3e43b0ae0..47b964da0 100644 --- a/internal/service/provider.go +++ b/internal/service/provider.go @@ -64,6 +64,7 @@ import ( tagcommon "github.com/apache/answer/internal/service/tag_common" "github.com/apache/answer/internal/service/uploader" "github.com/apache/answer/internal/service/user_admin" + "github.com/apache/answer/internal/service/user_anonymity_config" usercommon "github.com/apache/answer/internal/service/user_common" "github.com/apache/answer/internal/service/user_external_login" "github.com/apache/answer/internal/service/user_notification_config" @@ -121,6 +122,7 @@ var ProviderSetService = wire.NewSet( activityqueue.NewService, user_notification_config.NewUserNotificationConfigService, notification.NewExternalNotificationService, + user_anonymity_config.NewUserAnonymityConfigService, noticequeue.NewExternalService, review.NewReviewService, meta.NewMetaService, diff --git a/internal/service/user_anonymity_config/user_anonymity_config_service.go b/internal/service/user_anonymity_config/user_anonymity_config_service.go new file mode 100644 index 000000000..7a6bf9e6f --- /dev/null +++ b/internal/service/user_anonymity_config/user_anonymity_config_service.go @@ -0,0 +1,65 @@ +package user_anonymity_config + +import ( + "context" + + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/internal/schema" +) + +type UserAnonymityConfigRepo interface { + Add(ctx context.Context, userIDs []string, enabled bool) (err error) + Save(ctx context.Context, uc *entity.UserAnonymityConfig) (err error) + GetByUserID(ctx context.Context, userID string) (uc *entity.UserAnonymityConfig, exists bool, err error) +} + +type UserAnonymityConfigService struct { + userAnonymityConfigRepo UserAnonymityConfigRepo +} + +func NewUserAnonymityConfigService(userAnonymityConfigRepo UserAnonymityConfigRepo) *UserAnonymityConfigService { + return &UserAnonymityConfigService{ + userAnonymityConfigRepo: userAnonymityConfigRepo, + } +} + +func (us *UserAnonymityConfigService) GetUserAnonymityConfig(ctx context.Context, userID string) ( + resp *schema.GetUserAnonymityConfigResp, err error, +) { + anonymityConfig, exists, err := us.userAnonymityConfigRepo.GetByUserID(ctx, userID) + if err != nil { + return nil, err + } + if !exists { + anonymityConfig = &entity.UserAnonymityConfig{} + } + resp = &schema.GetUserAnonymityConfigResp{} + resp.UserAnonymityConfig = schema.NewUserAnonymityConfig(*anonymityConfig) + return resp, nil +} + +func (us *UserAnonymityConfigService) UpdateUserAnonymityConfig( + ctx context.Context, req *schema.UpdateUserAnonymityConfigReq) (err error) { + // req.Format() + + err = us.userAnonymityConfigRepo.Save(ctx, us.convertToEntity(ctx, req.UserID, req.Enabled)) + if err != nil { + return err + } + return nil +} + +func (us *UserAnonymityConfigService) SetDefaultUserAnonymityConfig( + ctx context.Context, userIDs []string, +) (err error) { + return us.userAnonymityConfigRepo.Add(ctx, userIDs, false) +} + +func (us *UserAnonymityConfigService) convertToEntity( + _ context.Context, userID string, enabled bool, +) *entity.UserAnonymityConfig { + return &entity.UserAnonymityConfig{ + UserID: userID, + Enabled: enabled, + } +} From 7dea2b420275c70e8582b1648d3b286356824045 Mon Sep 17 00:00:00 2001 From: IrusHunter Date: Thu, 28 May 2026 17:31:00 +0300 Subject: [PATCH 02/19] add fake username initializers --- .vscode/settings.json | 14 +++- cmd/wire_gen.go | 13 +++- .../repo/fake_username/fake_username_repo.go | 45 +++++++++++ internal/repo/provider.go | 2 + internal/service/comment/comment_service.go | 8 ++ internal/service/content/answer_service.go | 9 +++ internal/service/content/question_service.go | 9 +++ .../fake_username/fake_username_generator.go | 37 ++++++++++ .../fake_username/fake_username_service.go | 74 +++++++++++++++++++ internal/service/provider.go | 3 + internal/service/question_common/question.go | 5 ++ 11 files changed, 214 insertions(+), 5 deletions(-) create mode 100644 internal/repo/fake_username/fake_username_repo.go create mode 100644 internal/service/fake_username/fake_username_generator.go create mode 100644 internal/service/fake_username/fake_username_service.go diff --git a/.vscode/settings.json b/.vscode/settings.json index b7d39e6e5..11e58713d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,6 +4,18 @@ ], "explorer.autoReveal": "focusNoScroll", "cSpell.words": [ - "grecaptcha" + "activityqueue", + "answercommon", + "collectioncommon", + "errorlist", + "grecaptcha", + "metacommon", + "noticequeue", + "questioncommon", + "siteinfo", + "tagcommon", + "tagerr", + "taglist", + "usercommon" ] } diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 03fd92384..b695a9796 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -30,6 +30,7 @@ import ( "github.com/apache/answer/internal/repo/comment" "github.com/apache/answer/internal/repo/config" "github.com/apache/answer/internal/repo/export" + fake_username2 "github.com/apache/answer/internal/repo/fake_username" "github.com/apache/answer/internal/repo/file_record" "github.com/apache/answer/internal/repo/limit" "github.com/apache/answer/internal/repo/meta" @@ -70,6 +71,7 @@ import ( "github.com/apache/answer/internal/service/dashboard" "github.com/apache/answer/internal/service/eventqueue" export2 "github.com/apache/answer/internal/service/export" + "github.com/apache/answer/internal/service/fake_username" "github.com/apache/answer/internal/service/feature_toggle" file_record2 "github.com/apache/answer/internal/service/file_record" "github.com/apache/answer/internal/service/follow" @@ -169,7 +171,10 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, answerCommon := answercommon.NewAnswerCommon(answerRepo) metaRepo := meta.NewMetaRepo(dataData) metaCommonService := metacommon.NewMetaCommonService(metaRepo) - questionCommon := questioncommon.NewQuestionCommon(questionRepo, answerRepo, voteRepo, followRepo, tagCommonService, userCommon, collectionCommon, answerCommon, metaCommonService, configService, service, revisionRepo, siteInfoCommonService, dataData) + fakeUsernameGenerator := fake_username.NewFakeUsernameGenerator() + fakeUsernameRepo := fake_username2.NewFakeUsernameRepo(dataData) + fakeUsernameService := fake_username.NewFakeUsernameService(fakeUsernameGenerator, fakeUsernameRepo, userRepo, userAnonymityConfigRepo) + questionCommon := questioncommon.NewQuestionCommon(questionRepo, answerRepo, voteRepo, followRepo, tagCommonService, userCommon, collectionCommon, answerCommon, metaCommonService, configService, service, revisionRepo, siteInfoCommonService, fakeUsernameService, dataData) eventqueueService := eventqueue.NewService() fileRecordRepo := file_record.NewFileRecordRepo(dataData) fileRecordService := file_record2.NewFileRecordService(fileRecordRepo, revisionRepo, serviceConf, siteInfoCommonService, userCommon) @@ -184,7 +189,7 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, externalService := noticequeue.NewExternalService() reviewRepo := review.NewReviewRepo(dataData) reviewService := review2.NewReviewService(reviewRepo, objService, userCommon, userRepo, questionRepo, answerRepo, userRoleRelService, externalService, tagCommonService, questionCommon, noticequeueService, siteInfoCommonService, commentCommonRepo) - commentService := comment2.NewCommentService(commentRepo, commentCommonRepo, userCommon, objService, voteRepo, emailService, userRepo, noticequeueService, externalService, service, eventqueueService, reviewService) + commentService := comment2.NewCommentService(commentRepo, commentCommonRepo, userCommon, objService, voteRepo, emailService, userRepo, noticequeueService, externalService, service, eventqueueService, reviewService, fakeUsernameService) rolePowerRelRepo := role.NewRolePowerRelRepo(dataData) rolePowerRelService := role2.NewRolePowerRelService(rolePowerRelRepo, userRoleRelService) rankService := rank2.NewRankService(userCommon, userRankRepo, objService, userRoleRelService, rolePowerRelService, configService) @@ -196,8 +201,8 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, answerActivityRepo := activity.NewAnswerActivityRepo(dataData, activityRepo, userRankRepo, noticequeueService) answerActivityService := activity2.NewAnswerActivityService(answerActivityRepo, configService) externalNotificationService := notification.NewExternalNotificationService(dataData, userNotificationConfigRepo, followRepo, emailService, userRepo, externalService, userExternalLoginRepo, siteInfoCommonService) - questionService := content.NewQuestionService(activityRepo, questionRepo, answerRepo, tagCommonService, tagService, questionCommon, userCommon, userRepo, userRoleRelService, revisionService, metaCommonService, collectionCommon, answerActivityService, emailService, noticequeueService, externalService, service, siteInfoCommonService, externalNotificationService, reviewService, configService, eventqueueService, reviewRepo) - answerService := content.NewAnswerService(answerRepo, questionRepo, questionCommon, userCommon, collectionCommon, userRepo, revisionService, answerActivityService, answerCommon, voteRepo, emailService, userRoleRelService, noticequeueService, externalService, service, reviewService, eventqueueService) + questionService := content.NewQuestionService(activityRepo, questionRepo, answerRepo, tagCommonService, tagService, questionCommon, userCommon, userRepo, userRoleRelService, revisionService, metaCommonService, collectionCommon, answerActivityService, emailService, noticequeueService, externalService, service, siteInfoCommonService, externalNotificationService, reviewService, configService, eventqueueService, reviewRepo, fakeUsernameService) + answerService := content.NewAnswerService(answerRepo, questionRepo, questionCommon, userCommon, collectionCommon, userRepo, revisionService, answerActivityService, answerCommon, voteRepo, emailService, userRoleRelService, noticequeueService, externalService, service, reviewService, eventqueueService, fakeUsernameService) reportHandle := report_handle.NewReportHandle(questionService, answerService, commentService) reportService := report2.NewReportService(reportRepo, objService, userCommon, answerRepo, questionRepo, commentCommonRepo, reportHandle, configService, eventqueueService) reportController := controller.NewReportController(reportService, rankService, captchaService) diff --git a/internal/repo/fake_username/fake_username_repo.go b/internal/repo/fake_username/fake_username_repo.go new file mode 100644 index 000000000..4cc0c9efc --- /dev/null +++ b/internal/repo/fake_username/fake_username_repo.go @@ -0,0 +1,45 @@ +package fake_username + +import ( + "context" + + "github.com/apache/answer/internal/base/data" + "github.com/apache/answer/internal/base/reason" + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/internal/service/fake_username" + "github.com/segmentfault/pacman/errors" +) + +type fakeUsernameRepo struct { + data *data.Data +} + +func NewFakeUsernameRepo(data *data.Data) fake_username.FakeUsernameRepo { + return &fakeUsernameRepo{ + data: data, + } +} + +func (ur *fakeUsernameRepo) Add(ctx context.Context, userID, questionID string, fakeName string) (err error) { + fakeUsername := &entity.FakeUsername{ + UserID: userID, + QuestionID: questionID, + FakeName: fakeName, + } + _, err = ur.data.DB.Context(ctx).Insert(fakeUsername) + if err != nil { + return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return nil +} + +func (ur *fakeUsernameRepo) GetByUserIDAndQuestionID(ctx context.Context, userID, questionID string) ( + fu *entity.FakeUsername, exist bool, err error, +) { + fu = &entity.FakeUsername{UserID: userID, QuestionID: questionID} + exist, err = ur.data.DB.Context(ctx).Get(fu) + if err != nil { + err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + return +} diff --git a/internal/repo/provider.go b/internal/repo/provider.go index d95daca03..1043ba65f 100644 --- a/internal/repo/provider.go +++ b/internal/repo/provider.go @@ -35,6 +35,7 @@ import ( "github.com/apache/answer/internal/repo/comment" "github.com/apache/answer/internal/repo/config" "github.com/apache/answer/internal/repo/export" + "github.com/apache/answer/internal/repo/fake_username" "github.com/apache/answer/internal/repo/file_record" "github.com/apache/answer/internal/repo/limit" "github.com/apache/answer/internal/repo/meta" @@ -105,6 +106,7 @@ var ProviderSetRepo = wire.NewSet( plugin_config.NewPluginConfigRepo, user_notification_config.NewUserNotificationConfigRepo, user_anonymity_config.NewUserAnonymityConfigRepo, + fake_username.NewFakeUsernameRepo, limit.NewRateLimitRepo, plugin_config.NewPluginUserConfigRepo, review.NewReviewRepo, diff --git a/internal/service/comment/comment_service.go b/internal/service/comment/comment_service.go index 30ff43c6b..b1eb004b8 100644 --- a/internal/service/comment/comment_service.go +++ b/internal/service/comment/comment_service.go @@ -23,6 +23,7 @@ import ( "context" "github.com/apache/answer/internal/service/eventqueue" + "github.com/apache/answer/internal/service/fake_username" "github.com/apache/answer/internal/service/review" "time" @@ -93,6 +94,7 @@ type CommentService struct { activityQueueService activityqueue.Service eventQueueService eventqueue.Service reviewService *review.ReviewService + fakeUsernameService *fake_username.FakeUsernameService } // NewCommentService new comment service @@ -109,6 +111,7 @@ func NewCommentService( activityQueueService activityqueue.Service, eventQueueService eventqueue.Service, reviewService *review.ReviewService, + fakeUsernameService *fake_username.FakeUsernameService, ) *CommentService { return &CommentService{ commentRepo: commentRepo, @@ -123,6 +126,7 @@ func NewCommentService( activityQueueService: activityQueueService, eventQueueService: eventQueueService, reviewService: reviewService, + fakeUsernameService: fakeUsernameService, } } @@ -167,6 +171,10 @@ func (cs *CommentService) AddComment(ctx context.Context, req *schema.AddComment return nil, err } + if err := cs.fakeUsernameService.AddFakeUsernameIfNeeded(ctx, req.UserID, objInfo.QuestionID); err != nil { + return nil, err + } + comment.Status = cs.reviewService.AddCommentReview(ctx, comment, req.IP, req.UserAgent) if err := cs.commentRepo.UpdateCommentStatus(ctx, comment.ID, comment.Status); err != nil { return nil, err diff --git a/internal/service/content/answer_service.go b/internal/service/content/answer_service.go index 2ad875177..b41b66efc 100644 --- a/internal/service/content/answer_service.go +++ b/internal/service/content/answer_service.go @@ -25,6 +25,7 @@ import ( "time" "github.com/apache/answer/internal/service/eventqueue" + "github.com/apache/answer/internal/service/fake_username" "github.com/apache/answer/internal/base/constant" "github.com/apache/answer/internal/base/reason" @@ -70,6 +71,7 @@ type AnswerService struct { activityQueueService activityqueue.Service reviewService *review.ReviewService eventQueueService eventqueue.Service + fakeUsernameService *fake_username.FakeUsernameService } func NewAnswerService( @@ -90,6 +92,7 @@ func NewAnswerService( activityQueueService activityqueue.Service, reviewService *review.ReviewService, eventQueueService eventqueue.Service, + fakeUsernameService *fake_username.FakeUsernameService, ) *AnswerService { return &AnswerService{ answerRepo: answerRepo, @@ -109,6 +112,7 @@ func NewAnswerService( activityQueueService: activityQueueService, reviewService: reviewService, eventQueueService: eventQueueService, + fakeUsernameService: fakeUsernameService, } } @@ -267,6 +271,11 @@ func (as *AnswerService) Insert(ctx context.Context, req *schema.AnswerAddReq) ( if err = as.answerRepo.AddAnswer(ctx, insertData); err != nil { return "", err } + + if err := as.fakeUsernameService.AddFakeUsernameIfNeeded(ctx, req.UserID, req.QuestionID); err != nil { + return "", err + } + insertData.Status = as.reviewService.AddAnswerReview(ctx, insertData, req.IP, req.UserAgent) if err := as.answerRepo.UpdateAnswerStatus(ctx, insertData.ID, insertData.Status); err != nil { return "", err diff --git a/internal/service/content/question_service.go b/internal/service/content/question_service.go index bc3ac0bb6..a6d94e164 100644 --- a/internal/service/content/question_service.go +++ b/internal/service/content/question_service.go @@ -26,6 +26,7 @@ import ( "time" "github.com/apache/answer/internal/service/eventqueue" + "github.com/apache/answer/internal/service/fake_username" "github.com/apache/answer/plugin" "github.com/apache/answer/internal/base/constant" @@ -93,6 +94,7 @@ type QuestionService struct { configService *config.ConfigService eventQueueService eventqueue.Service reviewRepo review.ReviewRepo + fakeUsernameService *fake_username.FakeUsernameService } func NewQuestionService( @@ -119,6 +121,7 @@ func NewQuestionService( configService *config.ConfigService, eventQueueService eventqueue.Service, reviewRepo review.ReviewRepo, + fakeUsernameService *fake_username.FakeUsernameService, ) *QuestionService { return &QuestionService{ activityRepo: activityRepo, @@ -144,6 +147,7 @@ func NewQuestionService( configService: configService, eventQueueService: eventQueueService, reviewRepo: reviewRepo, + fakeUsernameService: fakeUsernameService, } } @@ -386,6 +390,11 @@ func (qs *QuestionService) AddQuestion(ctx context.Context, req *schema.Question if err != nil { return } + + if err := qs.fakeUsernameService.AddFakeUsernameIfNeeded(ctx, req.UserID, question.ID); err != nil { + return nil, err + } + question.Status = qs.reviewService.AddQuestionReview(ctx, question, req.Tags, req.IP, req.UserAgent) if err := qs.questionRepo.UpdateQuestionStatus(ctx, question.ID, question.Status); err != nil { return nil, err diff --git a/internal/service/fake_username/fake_username_generator.go b/internal/service/fake_username/fake_username_generator.go new file mode 100644 index 000000000..78e687b61 --- /dev/null +++ b/internal/service/fake_username/fake_username_generator.go @@ -0,0 +1,37 @@ +package fake_username + +import ( + "fmt" + "math/rand" +) + +type FakeUsernameGenerator struct{} + +func NewFakeUsernameGenerator() *FakeUsernameGenerator { + return &FakeUsernameGenerator{} +} + +func (fg *FakeUsernameGenerator) GenerateFakeName() string { + firstParts := []string{ + "Cool", + "Dark", + "Fast", + "Silent", + "Crazy", + } + + secondParts := []string{ + "Tiger", + "Wolf", + "Ninja", + "Dragon", + "Hawk", + } + + number := rand.Intn(10000) + + first := firstParts[rand.Intn(len(firstParts))] + second := secondParts[rand.Intn(len(secondParts))] + + return fmt.Sprintf("%s%s%d", first, second, number) +} diff --git a/internal/service/fake_username/fake_username_service.go b/internal/service/fake_username/fake_username_service.go new file mode 100644 index 000000000..252aa8d20 --- /dev/null +++ b/internal/service/fake_username/fake_username_service.go @@ -0,0 +1,74 @@ +package fake_username + +import ( + "context" + + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/internal/service/user_anonymity_config" + usercommon "github.com/apache/answer/internal/service/user_common" + "github.com/segmentfault/pacman/log" +) + +type FakeUsernameRepo interface { + Add(ctx context.Context, userID, questionID string, fakeName string) (err error) + GetByUserIDAndQuestionID(ctx context.Context, userID, questionID string) (fu *entity.FakeUsername, exist bool, err error) +} + +type FakeUsernameService struct { + fakeUsernameGenerator *FakeUsernameGenerator + fakeUsernameRepo FakeUsernameRepo + userRepo usercommon.UserRepo + userAnonymityConfigRepo user_anonymity_config.UserAnonymityConfigRepo +} + +func NewFakeUsernameService( + fakeUsernameGenerator *FakeUsernameGenerator, + fakeUsernameRepo FakeUsernameRepo, + userRepo usercommon.UserRepo, + userAnonymityConfigRepo user_anonymity_config.UserAnonymityConfigRepo, +) *FakeUsernameService { + return &FakeUsernameService{ + fakeUsernameGenerator: fakeUsernameGenerator, + fakeUsernameRepo: fakeUsernameRepo, + userRepo: userRepo, + userAnonymityConfigRepo: userAnonymityConfigRepo, + } +} + +// func (fs *FakeUsernameService) AddFakeUsernameFor(ctx context.Context, userID, questionID string) (err error) { +// return fs.fakeUsernameRepo.Add(ctx, userID, questionID, fs.fakeUsernameGenerator.GenerateFakeName()) +// } + +func (fs *FakeUsernameService) GetNameByUserIDAndQuestionID( + ctx context.Context, userID, questionID string, +) (name string, err error) { + fakeUsername, exists, err := fs.fakeUsernameRepo.GetByUserIDAndQuestionID(ctx, userID, questionID) + if err != nil { + log.Errorf("failed to get fake username record: %w", err) + } + if exists { + name = fakeUsername.FakeName + return + } + + userInfo, exists, err := fs.userRepo.GetByUserID(ctx, userID) + if err != nil || !exists { + return "", err + } + name = userInfo.DisplayName + + return +} + +func (fs *FakeUsernameService) AddFakeUsernameIfNeeded(ctx context.Context, userID, questionID string) (err error) { + userAnonymityConfig, _, err := fs.userAnonymityConfigRepo.GetByUserID(ctx, userID) + if err != nil { + log.Errorf("failed to get user anonymity config: %w", err) + } + + if userAnonymityConfig.Enabled { + return fs.fakeUsernameRepo.Add(ctx, userID, questionID, fs.fakeUsernameGenerator.GenerateFakeName()) + } + + return +} diff --git a/internal/service/provider.go b/internal/service/provider.go index 47b964da0..5cc3e5acf 100644 --- a/internal/service/provider.go +++ b/internal/service/provider.go @@ -38,6 +38,7 @@ import ( "github.com/apache/answer/internal/service/dashboard" "github.com/apache/answer/internal/service/eventqueue" "github.com/apache/answer/internal/service/export" + "github.com/apache/answer/internal/service/fake_username" "github.com/apache/answer/internal/service/feature_toggle" "github.com/apache/answer/internal/service/file_record" "github.com/apache/answer/internal/service/follow" @@ -123,6 +124,8 @@ var ProviderSetService = wire.NewSet( user_notification_config.NewUserNotificationConfigService, notification.NewExternalNotificationService, user_anonymity_config.NewUserAnonymityConfigService, + fake_username.NewFakeUsernameService, + fake_username.NewFakeUsernameGenerator, noticequeue.NewExternalService, review.NewReviewService, meta.NewMetaService, diff --git a/internal/service/question_common/question.go b/internal/service/question_common/question.go index 3a7306342..4d05d9105 100644 --- a/internal/service/question_common/question.go +++ b/internal/service/question_common/question.go @@ -27,6 +27,7 @@ import ( "strings" "time" + "github.com/apache/answer/internal/service/fake_username" "github.com/apache/answer/internal/service/siteinfo_common" "github.com/apache/answer/internal/base/constant" @@ -106,6 +107,7 @@ type QuestionCommon struct { activityQueueService activityqueue.Service revisionRepo revision.RevisionRepo siteInfoService siteinfo_common.SiteInfoCommonService + fakeUsernameService *fake_username.FakeUsernameService data *data.Data } @@ -122,6 +124,7 @@ func NewQuestionCommon(questionRepo QuestionRepo, activityQueueService activityqueue.Service, revisionRepo revision.RevisionRepo, siteInfoService siteinfo_common.SiteInfoCommonService, + fakeUsernameService *fake_username.FakeUsernameService, data *data.Data, ) *QuestionCommon { return &QuestionCommon{ @@ -138,6 +141,7 @@ func NewQuestionCommon(questionRepo QuestionRepo, activityQueueService: activityQueueService, revisionRepo: revisionRepo, siteInfoService: siteInfoService, + fakeUsernameService: fakeUsernameService, data: data, } } @@ -464,6 +468,7 @@ func (qs *QuestionCommon) FormatQuestionsPage( return formattedQuestions, nil } +// TODO WITH BATCH!!! func (qs *QuestionCommon) FormatQuestions(ctx context.Context, questionList []*entity.Question, loginUserID string) ([]*schema.QuestionInfoResp, error) { list := make([]*schema.QuestionInfoResp, 0) objectIds := make([]string, 0) From de5760901b228980ef40f3defd561e0cca1f1eee Mon Sep 17 00:00:00 2001 From: liza-hamai Date: Fri, 29 May 2026 14:44:41 +0300 Subject: [PATCH 03/19] feat: add anonymity settings and configuration interface --- i18n/en_US.yaml | 16 ++- .../pages/Users/Settings/Anonymity/index.tsx | 93 ++++++++++++++++ .../Users/Settings/components/Nav/index.tsx | 101 +++++++++++------- ui/src/router/routes.ts | 4 + ui/src/services/client/settings.ts | 15 +++ 5 files changed, 191 insertions(+), 38 deletions(-) create mode 100644 ui/src/pages/Users/Settings/Anonymity/index.tsx diff --git a/i18n/en_US.yaml b/i18n/en_US.yaml index 9a0d198b3..1f9737f88 100644 --- a/i18n/en_US.yaml +++ b/i18n/en_US.yaml @@ -1323,6 +1323,7 @@ ui: notification: Notifications account: Account interface: Interface + anonymity: Anonymity profile: heading: Profile btn_name: Save @@ -1365,6 +1366,19 @@ ui: all_new_question_for_following_tags: label: All new questions for following tags description: Get notified of new questions for following tags. + anonymity: + heading: Pseudo-Anonymity + intro: >- + When enabled, your questions and answers will be published without + displaying your real username. An alias (pseudonym) will be used instead, + your user ID will be hidden in API responses, and links to your author + profile will be disabled. + turn_on: Turn on + enabled: + label: Enable pseudo-anonymity + description: >- + Publish questions and answers without displaying your real user data. + Uses an alias instead of your username. account: heading: Account change_email_btn: Change email @@ -2482,5 +2496,3 @@ ui: copy: Copy to clipboard copied: Copied external_content_warning: External images/media are not displayed. - - diff --git a/ui/src/pages/Users/Settings/Anonymity/index.tsx b/ui/src/pages/Users/Settings/Anonymity/index.tsx new file mode 100644 index 000000000..11e6d7e34 --- /dev/null +++ b/ui/src/pages/Users/Settings/Anonymity/index.tsx @@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useState, FormEvent, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { FormDataType } from '@/common/interface'; +import { useToast } from '@/hooks'; +import { useGetAnonymityConfig, putAnonymityConfig } from '@/services'; +import { SchemaForm, JSONSchema, UISchema, initFormData } from '@/components'; + +const Index = () => { + const toast = useToast(); + const { t } = useTranslation('translation', { + keyPrefix: 'settings.anonymity', + }); + const { data: configData } = useGetAnonymityConfig(); + + const schema: JSONSchema = { + title: t('heading'), + properties: { + enabled: { + type: 'boolean', + title: t('enabled.label'), + description: t('enabled.description'), + default: configData?.enabled ?? false, + }, + }, + }; + + const uiSchema: UISchema = { + enabled: { + 'ui:widget': 'switch', + 'ui:options': { + label: t('turn_on'), + }, + }, + }; + + const [formData, setFormData] = useState(initFormData(schema)); + + useEffect(() => { + setFormData(initFormData(schema)); + }, [configData]); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + event.stopPropagation(); + + putAnonymityConfig({ enabled: formData.enabled.value }).then(() => { + toast.onShow({ + msg: t('update', { keyPrefix: 'toast' }), + variant: 'success', + }); + }); + }; + + const handleChange = (ud) => { + setFormData(ud); + }; + + return ( + <> +

{t('heading')}

+

{t('intro')}

+ + + ); +}; + +export default React.memo(Index); diff --git a/ui/src/pages/Users/Settings/components/Nav/index.tsx b/ui/src/pages/Users/Settings/components/Nav/index.tsx index 6e2b0c076..11e6d7e34 100644 --- a/ui/src/pages/Users/Settings/components/Nav/index.tsx +++ b/ui/src/pages/Users/Settings/components/Nav/index.tsx @@ -17,47 +17,76 @@ * under the License. */ -import React, { FC } from 'react'; -import { Nav } from 'react-bootstrap'; +import React, { useState, FormEvent, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { NavLink, useMatch } from 'react-router-dom'; -import { useGetUserPluginList } from '@/services'; +import type { FormDataType } from '@/common/interface'; +import { useToast } from '@/hooks'; +import { useGetAnonymityConfig, putAnonymityConfig } from '@/services'; +import { SchemaForm, JSONSchema, UISchema, initFormData } from '@/components'; -const Index: FC = () => { - const { t } = useTranslation('translation', { keyPrefix: 'settings.nav' }); - const settingMatch = useMatch('/users/settings/:setting'); - const { data } = useGetUserPluginList(); +const Index = () => { + const toast = useToast(); + const { t } = useTranslation('translation', { + keyPrefix: 'settings.anonymity', + }); + const { data: configData } = useGetAnonymityConfig(); + + const schema: JSONSchema = { + title: t('heading'), + properties: { + enabled: { + type: 'boolean', + title: t('enabled.label'), + description: t('enabled.description'), + default: configData?.enabled ?? false, + }, + }, + }; + + const uiSchema: UISchema = { + enabled: { + 'ui:widget': 'switch', + 'ui:options': { + label: t('turn_on'), + }, + }, + }; + + const [formData, setFormData] = useState(initFormData(schema)); + + useEffect(() => { + setFormData(initFormData(schema)); + }, [configData]); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + event.stopPropagation(); + + putAnonymityConfig({ enabled: formData.enabled.value }).then(() => { + toast.onShow({ + msg: t('update', { keyPrefix: 'toast' }), + variant: 'success', + }); + }); + }; + + const handleChange = (ud) => { + setFormData(ud); + }; return ( - + <> +

{t('heading')}

+

{t('intro')}

+ + ); }; diff --git a/ui/src/router/routes.ts b/ui/src/router/routes.ts index 8423fb7a3..d5e69f1cd 100644 --- a/ui/src/router/routes.ts +++ b/ui/src/router/routes.ts @@ -202,6 +202,10 @@ const routes: RouteNode[] = [ path: 'interface', page: 'pages/Users/Settings/Interface', }, + { + path: 'anonymity', + page: 'pages/Users/Settings/Anonymity', + }, { path: ':slug_name', page: 'pages/Users/Settings/Plugins', diff --git a/ui/src/services/client/settings.ts b/ui/src/services/client/settings.ts index 0111c2da5..3743a8d61 100644 --- a/ui/src/services/client/settings.ts +++ b/ui/src/services/client/settings.ts @@ -75,3 +75,18 @@ export const useGetUserPluginConfig = (params) => { export const updateUserPluginConfig = (params) => { return request.put('/answer/api/v1/user/plugin/config', params); }; + +export interface AnonymityConfig { + enabled: boolean; +} + +export const useGetAnonymityConfig = () => { + return useSWR( + '/answer/api/v1/user/anonymity/config', + request.instance.get, + ); +}; + +export const putAnonymityConfig = (data: AnonymityConfig) => { + return request.put('/answer/api/v1/user/anonymity/config', data); +}; From bcd6ed818185cd54887bfd58625629b97669ab95 Mon Sep 17 00:00:00 2001 From: IrusHunter Date: Fri, 29 May 2026 15:37:54 +0300 Subject: [PATCH 04/19] feat: enhance user and question deletion processes with permanent removal of anonymity configurations --- internal/repo/question/question_repo.go | 6 +++++ internal/repo/user/user_backyard_repo.go | 23 +++++++++++++++++-- .../schema/user_anonymity_config_schema.go | 2 +- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/internal/repo/question/question_repo.go b/internal/repo/question/question_repo.go index 3449efb8b..dc5c93bc2 100644 --- a/internal/repo/question/question_repo.go +++ b/internal/repo/question/question_repo.go @@ -185,6 +185,12 @@ func (qr *questionRepo) DeletePermanentlyQuestions(ctx context.Context) (err err return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() } + // delete all fake usernames permanently + _, err = qr.data.DB.Context(ctx).In("question_id", ids).Delete(&entity.FakeUsername{}) + if err != nil { + return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + _, err = qr.data.DB.Context(ctx).Where("status = ?", entity.QuestionStatusDeleted).Delete(&entity.Question{}) if err != nil { return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() diff --git a/internal/repo/user/user_backyard_repo.go b/internal/repo/user/user_backyard_repo.go index c93845e97..69611dc95 100644 --- a/internal/repo/user/user_backyard_repo.go +++ b/internal/repo/user/user_backyard_repo.go @@ -182,11 +182,30 @@ func (ur *userAdminRepo) GetUserPage(ctx context.Context, page, pageSize int, us // DeletePermanentlyUsers delete permanently users func (ur *userAdminRepo) DeletePermanentlyUsers(ctx context.Context) (err error) { + ids := make([]string, 0) + err = ur.data.DB.Context(ctx).Select("id").Table(new(entity.User).TableName()). + Where("status = ?", entity.UserStatusDeleted).Find(&ids) + if err != nil { + return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + + if len(ids) == 0 { + return nil + } + + // delete all user anonymity configs permanently + _, err = ur.data.DB.Context(ctx).In("user_id", ids).Delete(&entity.UserAnonymityConfig{}) + if err != nil { + return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + + // delete users permanently _, err = ur.data.DB.Context(ctx).Where("status = ?", entity.UserStatusDeleted).Delete(&entity.User{}) if err != nil { - err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + return errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() } - return + + return nil } // GetExpiredSuspendedUsers gets all suspended users whose suspension has expired diff --git a/internal/schema/user_anonymity_config_schema.go b/internal/schema/user_anonymity_config_schema.go index 23fad67d0..431467fc4 100644 --- a/internal/schema/user_anonymity_config_schema.go +++ b/internal/schema/user_anonymity_config_schema.go @@ -3,7 +3,7 @@ package schema import "github.com/apache/answer/internal/entity" type UserAnonymityConfig struct { - Enabled bool `validate:"required" json:"enabled"` + Enabled bool `json:"enabled"` } func NewUserAnonymityConfig(uc entity.UserAnonymityConfig) UserAnonymityConfig { From 122130f43fd44c8d49f2410017efe53c7a2b2af6 Mon Sep 17 00:00:00 2001 From: liza-hamai Date: Fri, 29 May 2026 20:38:24 +0300 Subject: [PATCH 05/19] feat: refactor settings navigation to enhance user experience and integrate dynamic plugin links --- .../Users/Settings/components/Nav/index.tsx | 104 +++++++----------- 1 file changed, 39 insertions(+), 65 deletions(-) diff --git a/ui/src/pages/Users/Settings/components/Nav/index.tsx b/ui/src/pages/Users/Settings/components/Nav/index.tsx index 11e6d7e34..6ab2d48fb 100644 --- a/ui/src/pages/Users/Settings/components/Nav/index.tsx +++ b/ui/src/pages/Users/Settings/components/Nav/index.tsx @@ -17,76 +17,50 @@ * under the License. */ -import React, { useState, FormEvent, useEffect } from 'react'; +import React, { FC } from 'react'; +import { Nav } from 'react-bootstrap'; import { useTranslation } from 'react-i18next'; +import { NavLink, useMatch } from 'react-router-dom'; -import type { FormDataType } from '@/common/interface'; -import { useToast } from '@/hooks'; -import { useGetAnonymityConfig, putAnonymityConfig } from '@/services'; -import { SchemaForm, JSONSchema, UISchema, initFormData } from '@/components'; +import { useGetUserPluginList } from '@/services'; -const Index = () => { - const toast = useToast(); - const { t } = useTranslation('translation', { - keyPrefix: 'settings.anonymity', - }); - const { data: configData } = useGetAnonymityConfig(); - - const schema: JSONSchema = { - title: t('heading'), - properties: { - enabled: { - type: 'boolean', - title: t('enabled.label'), - description: t('enabled.description'), - default: configData?.enabled ?? false, - }, - }, - }; - - const uiSchema: UISchema = { - enabled: { - 'ui:widget': 'switch', - 'ui:options': { - label: t('turn_on'), - }, - }, - }; - - const [formData, setFormData] = useState(initFormData(schema)); - - useEffect(() => { - setFormData(initFormData(schema)); - }, [configData]); - - const handleSubmit = (event: FormEvent) => { - event.preventDefault(); - event.stopPropagation(); - - putAnonymityConfig({ enabled: formData.enabled.value }).then(() => { - toast.onShow({ - msg: t('update', { keyPrefix: 'toast' }), - variant: 'success', - }); - }); - }; - - const handleChange = (ud) => { - setFormData(ud); - }; +const Index: FC = () => { + const { t } = useTranslation('translation', { keyPrefix: 'settings.nav' }); + const settingMatch = useMatch('/users/settings/:setting'); + const { data } = useGetUserPluginList(); return ( - <> -

{t('heading')}

-

{t('intro')}

- - + ); }; From d897e4560d3ff457a7c824429e768604ec92bd6b Mon Sep 17 00:00:00 2001 From: liza-hamai Date: Fri, 29 May 2026 22:22:42 +0300 Subject: [PATCH 06/19] feat: update user display logic to ensure usernames are shown only when available and not deleted --- ui/src/components/BaseUserCard/index.tsx | 2 +- ui/src/components/Comment/components/ActionBar/index.tsx | 4 ++-- ui/src/components/Comment/index.tsx | 2 +- ui/src/components/UserCard/index.tsx | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ui/src/components/BaseUserCard/index.tsx b/ui/src/components/BaseUserCard/index.tsx index f171bc2ca..45f2c10f1 100644 --- a/ui/src/components/BaseUserCard/index.tsx +++ b/ui/src/components/BaseUserCard/index.tsx @@ -46,7 +46,7 @@ const Index: FC = ({ }) => { return (
- {data?.status !== 'deleted' ? ( + {data?.status !== 'deleted' && data?.username ? ( { diff --git a/ui/src/components/Comment/components/ActionBar/index.tsx b/ui/src/components/Comment/components/ActionBar/index.tsx index 692b0d208..4d7a71189 100644 --- a/ui/src/components/Comment/components/ActionBar/index.tsx +++ b/ui/src/components/Comment/components/ActionBar/index.tsx @@ -43,7 +43,7 @@ const ActionBar = ({ return (
- {userStatus !== 'deleted' ? ( + {userStatus !== 'deleted' && username ? ( ) : ( - {nickName} + {nickName} )} • diff --git a/ui/src/components/Comment/index.tsx b/ui/src/components/Comment/index.tsx index af2ddbd4f..f6d0f4700 100644 --- a/ui/src/components/Comment/index.tsx +++ b/ui/src/components/Comment/index.tsx @@ -418,7 +418,7 @@ const Comment: FC = ({ objectId, mode, commentId, children }) => { ) : (
{item.reply_user_display_name && - (item.reply_user_status !== 'deleted' ? ( + (item.reply_user_status !== 'deleted' && item.reply_username ? ( diff --git a/ui/src/components/UserCard/index.tsx b/ui/src/components/UserCard/index.tsx index cc7e883c6..075533734 100644 --- a/ui/src/components/UserCard/index.tsx +++ b/ui/src/components/UserCard/index.tsx @@ -48,7 +48,7 @@ const Index: FC = ({ }) => { return (
- {data?.status !== 'deleted' ? ( + {data?.status !== 'deleted' && data?.username ? ( = ({ )}
- {data?.status !== 'deleted' ? ( + {data?.status !== 'deleted' && data?.username ? ( Date: Sat, 30 May 2026 18:25:15 +0300 Subject: [PATCH 07/19] feat: add anonymized responses --- cmd/wire_gen.go | 9 +-- .../repo/fake_username/fake_username_repo.go | 13 ++++ internal/service/comment/comment_service.go | 47 +++++++++++++++ internal/service/content/answer_service.go | 36 +++++++++++ .../fake_username/anonymity_service.go | 54 +++++++++++++++++ .../fake_username/fake_username_service.go | 26 +------- internal/service/provider.go | 1 + internal/service/question_common/question.go | 59 +++++++++++++++++++ 8 files changed, 216 insertions(+), 29 deletions(-) create mode 100644 internal/service/fake_username/anonymity_service.go diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index b695a9796..8183d7d07 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -173,8 +173,9 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, metaCommonService := metacommon.NewMetaCommonService(metaRepo) fakeUsernameGenerator := fake_username.NewFakeUsernameGenerator() fakeUsernameRepo := fake_username2.NewFakeUsernameRepo(dataData) - fakeUsernameService := fake_username.NewFakeUsernameService(fakeUsernameGenerator, fakeUsernameRepo, userRepo, userAnonymityConfigRepo) - questionCommon := questioncommon.NewQuestionCommon(questionRepo, answerRepo, voteRepo, followRepo, tagCommonService, userCommon, collectionCommon, answerCommon, metaCommonService, configService, service, revisionRepo, siteInfoCommonService, fakeUsernameService, dataData) + fakeUsernameService := fake_username.NewFakeUsernameService(fakeUsernameGenerator, fakeUsernameRepo, userAnonymityConfigRepo) + anonymityService := fake_username.NewAnonymityService(fakeUsernameRepo) + questionCommon := questioncommon.NewQuestionCommon(questionRepo, answerRepo, voteRepo, followRepo, tagCommonService, userCommon, collectionCommon, answerCommon, metaCommonService, configService, service, revisionRepo, siteInfoCommonService, fakeUsernameService, anonymityService, dataData) eventqueueService := eventqueue.NewService() fileRecordRepo := file_record.NewFileRecordRepo(dataData) fileRecordService := file_record2.NewFileRecordService(fileRecordRepo, revisionRepo, serviceConf, siteInfoCommonService, userCommon) @@ -189,7 +190,7 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, externalService := noticequeue.NewExternalService() reviewRepo := review.NewReviewRepo(dataData) reviewService := review2.NewReviewService(reviewRepo, objService, userCommon, userRepo, questionRepo, answerRepo, userRoleRelService, externalService, tagCommonService, questionCommon, noticequeueService, siteInfoCommonService, commentCommonRepo) - commentService := comment2.NewCommentService(commentRepo, commentCommonRepo, userCommon, objService, voteRepo, emailService, userRepo, noticequeueService, externalService, service, eventqueueService, reviewService, fakeUsernameService) + commentService := comment2.NewCommentService(commentRepo, commentCommonRepo, userCommon, objService, voteRepo, emailService, userRepo, noticequeueService, externalService, service, eventqueueService, reviewService, fakeUsernameService, anonymityService) rolePowerRelRepo := role.NewRolePowerRelRepo(dataData) rolePowerRelService := role2.NewRolePowerRelService(rolePowerRelRepo, userRoleRelService) rankService := rank2.NewRankService(userCommon, userRankRepo, objService, userRoleRelService, rolePowerRelService, configService) @@ -202,7 +203,7 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, answerActivityService := activity2.NewAnswerActivityService(answerActivityRepo, configService) externalNotificationService := notification.NewExternalNotificationService(dataData, userNotificationConfigRepo, followRepo, emailService, userRepo, externalService, userExternalLoginRepo, siteInfoCommonService) questionService := content.NewQuestionService(activityRepo, questionRepo, answerRepo, tagCommonService, tagService, questionCommon, userCommon, userRepo, userRoleRelService, revisionService, metaCommonService, collectionCommon, answerActivityService, emailService, noticequeueService, externalService, service, siteInfoCommonService, externalNotificationService, reviewService, configService, eventqueueService, reviewRepo, fakeUsernameService) - answerService := content.NewAnswerService(answerRepo, questionRepo, questionCommon, userCommon, collectionCommon, userRepo, revisionService, answerActivityService, answerCommon, voteRepo, emailService, userRoleRelService, noticequeueService, externalService, service, reviewService, eventqueueService, fakeUsernameService) + answerService := content.NewAnswerService(answerRepo, questionRepo, questionCommon, userCommon, collectionCommon, userRepo, revisionService, answerActivityService, answerCommon, voteRepo, emailService, userRoleRelService, noticequeueService, externalService, service, reviewService, eventqueueService, fakeUsernameService, anonymityService) reportHandle := report_handle.NewReportHandle(questionService, answerService, commentService) reportService := report2.NewReportService(reportRepo, objService, userCommon, answerRepo, questionRepo, commentCommonRepo, reportHandle, configService, eventqueueService) reportController := controller.NewReportController(reportService, rankService, captchaService) diff --git a/internal/repo/fake_username/fake_username_repo.go b/internal/repo/fake_username/fake_username_repo.go index 4cc0c9efc..57b3aef3b 100644 --- a/internal/repo/fake_username/fake_username_repo.go +++ b/internal/repo/fake_username/fake_username_repo.go @@ -43,3 +43,16 @@ func (ur *fakeUsernameRepo) GetByUserIDAndQuestionID(ctx context.Context, userID } return } + +func (fr *fakeUsernameRepo) BatchGetByUserIDs( + ctx context.Context, userIDs []string, questionID string, +) ([]*entity.FakeUsername, error) { + list := make([]*entity.FakeUsername, 0) + + err := fr.data.DB.Context(ctx).In("user_id", userIDs).And("question_id = ?", questionID).Find(&list) + if err != nil { + return nil, errors.InternalServer(reason.DatabaseError).WithError(err).WithStack() + } + + return list, nil +} diff --git a/internal/service/comment/comment_service.go b/internal/service/comment/comment_service.go index b1eb004b8..ff8729bba 100644 --- a/internal/service/comment/comment_service.go +++ b/internal/service/comment/comment_service.go @@ -95,6 +95,7 @@ type CommentService struct { eventQueueService eventqueue.Service reviewService *review.ReviewService fakeUsernameService *fake_username.FakeUsernameService + anonymityService *fake_username.AnonymityService } // NewCommentService new comment service @@ -112,6 +113,7 @@ func NewCommentService( eventQueueService eventqueue.Service, reviewService *review.ReviewService, fakeUsernameService *fake_username.FakeUsernameService, + anonymityService *fake_username.AnonymityService, ) *CommentService { return &CommentService{ commentRepo: commentRepo, @@ -127,6 +129,7 @@ func NewCommentService( eventQueueService: eventQueueService, reviewService: reviewService, fakeUsernameService: fakeUsernameService, + anonymityService: anonymityService, } } @@ -344,6 +347,17 @@ func (cs *CommentService) GetComment(ctx context.Context, req *schema.GetComment return nil, err } if exist { + anonymizedUsers, err := cs.anonymityService.AnonymizeUserData( + ctx, []string{commentUser.ID}, comment.QuestionID, req.UserID) + if err != nil { + log.Errorf("failed to anonymize user: %w", err) + } + + if au, ok := anonymizedUsers[commentUser.ID]; ok { + commentUser = au + } + + resp.UserID = commentUser.ID resp.Username = commentUser.Username resp.UserDisplayName = commentUser.DisplayName resp.UserAvatar = commentUser.Avatar @@ -358,6 +372,17 @@ func (cs *CommentService) GetComment(ctx context.Context, req *schema.GetComment return nil, err } if exist { + anonymizedUsers, err := cs.anonymityService.AnonymizeUserData( + ctx, []string{replyUser.ID}, comment.QuestionID, req.UserID) + if err != nil { + log.Errorf("failed to anonymize user: %w", err) + } + + if au, ok := anonymizedUsers[replyUser.ID]; ok { + replyUser = au + } + + resp.ReplyUserID = replyUser.ID resp.ReplyUsername = replyUser.Username resp.ReplyUserDisplayName = replyUser.DisplayName resp.ReplyUserStatus = replyUser.Status @@ -440,6 +465,17 @@ func (cs *CommentService) convertCommentEntity2Resp(ctx context.Context, req *sc return nil, err } if exist { + anonymizedUsers, err := cs.anonymityService.AnonymizeUserData( + ctx, []string{commentUser.ID}, comment.QuestionID, req.UserID) + if err != nil { + log.Errorf("failed to anonymize user: %w", err) + } + + if au, ok := anonymizedUsers[commentUser.ID]; ok { + commentUser = au + } + + commentResp.UserID = commentUser.ID commentResp.Username = commentUser.Username commentResp.UserDisplayName = commentUser.DisplayName commentResp.UserAvatar = commentUser.Avatar @@ -454,6 +490,17 @@ func (cs *CommentService) convertCommentEntity2Resp(ctx context.Context, req *sc return nil, err } if exist { + anonymizedUsers, err := cs.anonymityService.AnonymizeUserData( + ctx, []string{replyUser.ID}, comment.QuestionID, req.UserID) + if err != nil { + log.Errorf("failed to anonymize user: %w", err) + } + + if au, ok := anonymizedUsers[replyUser.ID]; ok { + replyUser = au + } + + commentResp.ReplyUserID = replyUser.ID commentResp.ReplyUsername = replyUser.Username commentResp.ReplyUserDisplayName = replyUser.DisplayName commentResp.ReplyUserStatus = replyUser.Status diff --git a/internal/service/content/answer_service.go b/internal/service/content/answer_service.go index b41b66efc..a58497b9b 100644 --- a/internal/service/content/answer_service.go +++ b/internal/service/content/answer_service.go @@ -72,6 +72,7 @@ type AnswerService struct { reviewService *review.ReviewService eventQueueService eventqueue.Service fakeUsernameService *fake_username.FakeUsernameService + anonymityService *fake_username.AnonymityService } func NewAnswerService( @@ -93,6 +94,7 @@ func NewAnswerService( reviewService *review.ReviewService, eventQueueService eventqueue.Service, fakeUsernameService *fake_username.FakeUsernameService, + anonymityService *fake_username.AnonymityService, ) *AnswerService { return &AnswerService{ answerRepo: answerRepo, @@ -113,6 +115,7 @@ func NewAnswerService( reviewService: reviewService, eventQueueService: eventQueueService, fakeUsernameService: fakeUsernameService, + anonymityService: anonymityService, } } @@ -551,6 +554,24 @@ func (as *AnswerService) Get(ctx context.Context, answerID, loginUserID string) info.UpdateUserInfo = userInfoMap[answerInfo.LastEditUserID] } + userIDs := []string{ + answerInfo.UserID, + answerInfo.LastEditUserID, + } + + anonymizedUsers, err := as.anonymityService.AnonymizeUserData(ctx, userIDs, info.QuestionID, loginUserID) + if err != nil { + return nil, nil, has, err + } + + if au, ok := anonymizedUsers[answerInfo.UserID]; ok { + info.UserInfo = au + } + + if au, ok := anonymizedUsers[answerInfo.LastEditUserID]; ok { + info.UpdateUserInfo = au + } + if loginUserID == "" { return info, questionInfo, has, nil } @@ -667,6 +688,21 @@ func (as *AnswerService) SearchFormatInfo(ctx context.Context, answers []*entity for _, item := range list { item.UserInfo = userInfoMap[item.UserID] item.UpdateUserInfo = userInfoMap[item.UpdateUserID] + + anonymizedUsers, err := as.anonymityService.AnonymizeUserData( + ctx, []string{item.UserID, item.UpdateUserID}, item.QuestionID, "", + ) + if err != nil { + return nil, err + } + + if au, ok := anonymizedUsers[item.UserID]; ok { + item.UserInfo = au + } + + if au, ok := anonymizedUsers[item.UpdateUserID]; ok { + item.UpdateUserInfo = au + } } if len(req.UserID) == 0 { return list, nil diff --git a/internal/service/fake_username/anonymity_service.go b/internal/service/fake_username/anonymity_service.go new file mode 100644 index 000000000..3ad4c482d --- /dev/null +++ b/internal/service/fake_username/anonymity_service.go @@ -0,0 +1,54 @@ +package fake_username + +import ( + "context" + + "github.com/apache/answer/internal/schema" + "github.com/apache/answer/pkg/checker" + "github.com/segmentfault/pacman/log" +) + +type AnonymityService struct { + fakeUsernameRepo FakeUsernameRepo +} + +func NewAnonymityService(fakeUsernameRepo FakeUsernameRepo) *AnonymityService { + return &AnonymityService{ + fakeUsernameRepo: fakeUsernameRepo, + } +} + +func (s *AnonymityService) AnonymizeUserData( + ctx context.Context, userIDs []string, questionID, forUserID string, +) (anonymizeInfo map[string]*schema.UserBasicInfo, err error) { + anonymizeInfo = map[string]*schema.UserBasicInfo{} + + userIDs = checker.FilterEmptyString(userIDs) + if len(userIDs) == 0 { + return + } + + filteredUserIDs := make([]string, 0, len(userIDs)) + if forUserID != "" { + for _, id := range userIDs { + if id == forUserID { + continue + } + filteredUserIDs = append(filteredUserIDs, id) + } + } else { + filteredUserIDs = userIDs + } + + fakeUsernames, err := s.fakeUsernameRepo.BatchGetByUserIDs(ctx, filteredUserIDs, questionID) + if err != nil { + return + } + + for _, item := range fakeUsernames { + anonymizeInfo[item.UserID] = &schema.UserBasicInfo{DisplayName: item.FakeName} + } + log.Errorf("fake usernames for %s: %d", questionID, len(fakeUsernames)) + + return +} diff --git a/internal/service/fake_username/fake_username_service.go b/internal/service/fake_username/fake_username_service.go index 252aa8d20..2e47aa696 100644 --- a/internal/service/fake_username/fake_username_service.go +++ b/internal/service/fake_username/fake_username_service.go @@ -5,32 +5,29 @@ import ( "github.com/apache/answer/internal/entity" "github.com/apache/answer/internal/service/user_anonymity_config" - usercommon "github.com/apache/answer/internal/service/user_common" "github.com/segmentfault/pacman/log" ) type FakeUsernameRepo interface { Add(ctx context.Context, userID, questionID string, fakeName string) (err error) GetByUserIDAndQuestionID(ctx context.Context, userID, questionID string) (fu *entity.FakeUsername, exist bool, err error) + BatchGetByUserIDs(ctx context.Context, userIDs []string, questionID string) ([]*entity.FakeUsername, error) } type FakeUsernameService struct { fakeUsernameGenerator *FakeUsernameGenerator fakeUsernameRepo FakeUsernameRepo - userRepo usercommon.UserRepo userAnonymityConfigRepo user_anonymity_config.UserAnonymityConfigRepo } func NewFakeUsernameService( fakeUsernameGenerator *FakeUsernameGenerator, fakeUsernameRepo FakeUsernameRepo, - userRepo usercommon.UserRepo, userAnonymityConfigRepo user_anonymity_config.UserAnonymityConfigRepo, ) *FakeUsernameService { return &FakeUsernameService{ fakeUsernameGenerator: fakeUsernameGenerator, fakeUsernameRepo: fakeUsernameRepo, - userRepo: userRepo, userAnonymityConfigRepo: userAnonymityConfigRepo, } } @@ -39,27 +36,6 @@ func NewFakeUsernameService( // return fs.fakeUsernameRepo.Add(ctx, userID, questionID, fs.fakeUsernameGenerator.GenerateFakeName()) // } -func (fs *FakeUsernameService) GetNameByUserIDAndQuestionID( - ctx context.Context, userID, questionID string, -) (name string, err error) { - fakeUsername, exists, err := fs.fakeUsernameRepo.GetByUserIDAndQuestionID(ctx, userID, questionID) - if err != nil { - log.Errorf("failed to get fake username record: %w", err) - } - if exists { - name = fakeUsername.FakeName - return - } - - userInfo, exists, err := fs.userRepo.GetByUserID(ctx, userID) - if err != nil || !exists { - return "", err - } - name = userInfo.DisplayName - - return -} - func (fs *FakeUsernameService) AddFakeUsernameIfNeeded(ctx context.Context, userID, questionID string) (err error) { userAnonymityConfig, _, err := fs.userAnonymityConfigRepo.GetByUserID(ctx, userID) if err != nil { diff --git a/internal/service/provider.go b/internal/service/provider.go index 5cc3e5acf..73ee167b7 100644 --- a/internal/service/provider.go +++ b/internal/service/provider.go @@ -126,6 +126,7 @@ var ProviderSetService = wire.NewSet( user_anonymity_config.NewUserAnonymityConfigService, fake_username.NewFakeUsernameService, fake_username.NewFakeUsernameGenerator, + fake_username.NewAnonymityService, noticequeue.NewExternalService, review.NewReviewService, meta.NewMetaService, diff --git a/internal/service/question_common/question.go b/internal/service/question_common/question.go index 4d05d9105..7a1d4b459 100644 --- a/internal/service/question_common/question.go +++ b/internal/service/question_common/question.go @@ -108,6 +108,7 @@ type QuestionCommon struct { revisionRepo revision.RevisionRepo siteInfoService siteinfo_common.SiteInfoCommonService fakeUsernameService *fake_username.FakeUsernameService + anonymityService *fake_username.AnonymityService data *data.Data } @@ -125,6 +126,7 @@ func NewQuestionCommon(questionRepo QuestionRepo, revisionRepo revision.RevisionRepo, siteInfoService siteinfo_common.SiteInfoCommonService, fakeUsernameService *fake_username.FakeUsernameService, + anonymityService *fake_username.AnonymityService, data *data.Data, ) *QuestionCommon { return &QuestionCommon{ @@ -142,6 +144,7 @@ func NewQuestionCommon(questionRepo QuestionRepo, revisionRepo: revisionRepo, siteInfoService: siteInfoService, fakeUsernameService: fakeUsernameService, + anonymityService: anonymityService, data: data, } } @@ -339,6 +342,30 @@ func (qs *QuestionCommon) Info(ctx context.Context, questionID string, loginUser resp.UserInfo = userInfoMap[questionInfo.UserID] resp.UpdateUserInfo = userInfoMap[questionInfo.LastEditUserID] resp.LastAnsweredUserInfo = userInfoMap[resp.LastAnsweredUserID] + + userIDs := []string{ + questionInfo.UserID, + questionInfo.LastEditUserID, + resp.LastAnsweredUserID, + } + + anonymizedUsers, err := qs.anonymityService.AnonymizeUserData(ctx, userIDs, resp.ID, loginUserID) + if err != nil { + return nil, err + } + + if au, ok := anonymizedUsers[questionInfo.UserID]; ok { + resp.UserInfo = au + } + + if au, ok := anonymizedUsers[questionInfo.LastEditUserID]; ok { + resp.UpdateUserInfo = au + } + + if au, ok := anonymizedUsers[resp.LastAnsweredUserID]; ok { + resp.LastAnsweredUserInfo = au + } + if len(loginUserID) == 0 { return resp, nil } @@ -457,6 +484,15 @@ func (qs *QuestionCommon) FormatQuestionsPage( userInfo, ok := userInfoMap[item.Operator.ID] if ok { if userInfo != nil { + anonymizedUsers, err := qs.anonymityService.AnonymizeUserData(ctx, []string{userInfo.ID}, item.ID, loginUserID) + if err != nil { + log.Errorf("failed to anonymize user: %w", err) + } + + if au, ok := anonymizedUsers[userInfo.ID]; ok { + userInfo = au + } + item.Operator.ID = userInfo.ID item.Operator.DisplayName = userInfo.DisplayName item.Operator.Username = userInfo.Username item.Operator.Rank = userInfo.Rank @@ -495,6 +531,29 @@ func (qs *QuestionCommon) FormatQuestions(ctx context.Context, questionList []*e item.UserInfo = userInfoMap[item.UserID] item.UpdateUserInfo = userInfoMap[item.LastEditUserID] item.LastAnsweredUserInfo = userInfoMap[item.LastAnsweredUserID] + + userIDs := []string{ + item.UserID, + item.LastEditUserID, + item.LastAnsweredUserID, + } + + anonymizedUsers, err := qs.anonymityService.AnonymizeUserData(ctx, userIDs, item.ID, loginUserID) + if err != nil { + return nil, err + } + + if au, ok := anonymizedUsers[item.UserID]; ok { + item.UserInfo = au + } + + if au, ok := anonymizedUsers[item.LastEditUserID]; ok { + item.UpdateUserInfo = au + } + + if au, ok := anonymizedUsers[item.LastAnsweredUserID]; ok { + item.LastAnsweredUserInfo = au + } } if loginUserID == "" { return list, nil From 4d8848d1e7e19862969aa61156d8509fcc440853 Mon Sep 17 00:00:00 2001 From: liza-hamai Date: Sat, 30 May 2026 19:57:23 +0300 Subject: [PATCH 08/19] fix: improve formatting and readability in ActionBar and Comment components --- ui/src/components/Comment/components/ActionBar/index.tsx | 4 +++- ui/src/components/Comment/index.tsx | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/ui/src/components/Comment/components/ActionBar/index.tsx b/ui/src/components/Comment/components/ActionBar/index.tsx index 4d7a71189..53cef61d7 100644 --- a/ui/src/components/Comment/components/ActionBar/index.tsx +++ b/ui/src/components/Comment/components/ActionBar/index.tsx @@ -51,7 +51,9 @@ const ActionBar = ({ {nickName} ) : ( - {nickName} + + {nickName} + )} • diff --git a/ui/src/components/Comment/index.tsx b/ui/src/components/Comment/index.tsx index f6d0f4700..4097b1336 100644 --- a/ui/src/components/Comment/index.tsx +++ b/ui/src/components/Comment/index.tsx @@ -418,7 +418,8 @@ const Comment: FC = ({ objectId, mode, commentId, children }) => { ) : (
{item.reply_user_display_name && - (item.reply_user_status !== 'deleted' && item.reply_username ? ( + (item.reply_user_status !== 'deleted' && + item.reply_username ? ( From 818a0cd23ae7485ab53e207adba1e3d5b39a211c Mon Sep 17 00:00:00 2001 From: IrusHunter Date: Sat, 30 May 2026 19:57:45 +0300 Subject: [PATCH 09/19] fix: remove debug line --- internal/service/fake_username/anonymity_service.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/internal/service/fake_username/anonymity_service.go b/internal/service/fake_username/anonymity_service.go index 3ad4c482d..e8a089ae9 100644 --- a/internal/service/fake_username/anonymity_service.go +++ b/internal/service/fake_username/anonymity_service.go @@ -5,7 +5,6 @@ import ( "github.com/apache/answer/internal/schema" "github.com/apache/answer/pkg/checker" - "github.com/segmentfault/pacman/log" ) type AnonymityService struct { @@ -48,7 +47,6 @@ func (s *AnonymityService) AnonymizeUserData( for _, item := range fakeUsernames { anonymizeInfo[item.UserID] = &schema.UserBasicInfo{DisplayName: item.FakeName} } - log.Errorf("fake usernames for %s: %d", questionID, len(fakeUsernames)) return } From 3a0ae52f41ab97ef686f7d05c47c571c9c385711 Mon Sep 17 00:00:00 2001 From: liza-hamai Date: Sat, 30 May 2026 22:46:19 +0300 Subject: [PATCH 10/19] feat: add private level functionality to questions --- internal/entity/question_entity.go | 5 ++ internal/migrations/migrations.go | 1 + internal/migrations/v32.go | 77 ++++++++++++++++++ internal/schema/question_schema.go | 3 + internal/service/content/question_service.go | 4 + internal/service/question_common/question.go | 85 ++++++-------------- 6 files changed, 116 insertions(+), 59 deletions(-) create mode 100644 internal/migrations/v32.go diff --git a/internal/entity/question_entity.go b/internal/entity/question_entity.go index 9e5dcd112..c2103585c 100644 --- a/internal/entity/question_entity.go +++ b/internal/entity/question_entity.go @@ -32,6 +32,10 @@ const ( QuestionPin = 2 QuestionShow = 1 QuestionHide = 2 + + QuestionPrivateLevelPublic = "public" + QuestionPrivateLevelAuthenticated = "authenticated" + QuestionPrivateLevelPrivate = "private" ) var AdminQuestionSearchStatus = map[string]int{ @@ -74,6 +78,7 @@ type Question struct { PostUpdateTime time.Time `xorm:"post_update_time TIMESTAMP"` RevisionID string `xorm:"not null default 0 BIGINT(20) revision_id"` LinkedCount int `xorm:"not null default 0 INT(11) linked_count"` + PrivateLevel string `xorm:"not null default 'public' VARCHAR(20) private_level"` } // TableName question table name diff --git a/internal/migrations/migrations.go b/internal/migrations/migrations.go index 33453ef19..5e27cb04c 100644 --- a/internal/migrations/migrations.go +++ b/internal/migrations/migrations.go @@ -107,6 +107,7 @@ var migrations = []Migration{ NewMigration("v1.7.2", "expand avatar column length", expandAvatarColumnLength, false), NewMigration("v1.8.0", "change admin menu", updateAdminMenuSettings, true), NewMigration("v1.8.1", "ai feat", aiFeat, true), + NewMigration("v1.8.2", "add question private level", addQuestionPrivateLevel, false), } func GetMigrations() []Migration { diff --git a/internal/migrations/v32.go b/internal/migrations/v32.go new file mode 100644 index 000000000..a5f95d31b --- /dev/null +++ b/internal/migrations/v32.go @@ -0,0 +1,77 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package migrations + +import ( + "context" + "fmt" + + "github.com/segmentfault/pacman/log" + "xorm.io/xorm" +) + +func addQuestionPrivateLevel(ctx context.Context, x *xorm.Engine) error { + type QuestionPrivateLevel struct { + PrivateLevel string `xorm:"not null default 'public' VARCHAR(20) private_level"` + } + + if err := x.Context(ctx).Sync(new(QuestionPrivateLevel)); err != nil { + log.Errorf("sync QuestionPrivateLevel failed: %s", err) + } + + switch x.Dialect().URI().DBType { + case "mysql", "MYSQL": + _, err := x.Context(ctx).Exec( + "ALTER TABLE `question` ADD COLUMN IF NOT EXISTS `private_level` VARCHAR(20) NOT NULL DEFAULT 'public'", + ) + if err != nil { + return fmt.Errorf("add private_level column to question (mysql): %w", err) + } + case "postgres", "POSTGRES": + _, err := x.Context(ctx).Exec( + `ALTER TABLE "question" ADD COLUMN IF NOT EXISTS "private_level" VARCHAR(20) NOT NULL DEFAULT 'public'`, + ) + if err != nil { + return fmt.Errorf("add private_level column to question (postgres): %w", err) + } + default: + // sqlite3 - check if column exists first + rows, err := x.Context(ctx).QueryString("PRAGMA table_info(question)") + if err != nil { + return fmt.Errorf("check question columns (sqlite): %w", err) + } + exists := false + for _, row := range rows { + if row["name"] == "private_level" { + exists = true + break + } + } + if !exists { + _, err = x.Context(ctx).Exec( + "ALTER TABLE `question` ADD COLUMN `private_level` VARCHAR(20) NOT NULL DEFAULT 'public'", + ) + if err != nil { + return fmt.Errorf("add private_level column to question (sqlite): %w", err) + } + } + } + return nil +} diff --git a/internal/schema/question_schema.go b/internal/schema/question_schema.go index 133208286..1fc356e63 100644 --- a/internal/schema/question_schema.go +++ b/internal/schema/question_schema.go @@ -84,6 +84,8 @@ type QuestionAdd struct { HTML string `json:"-"` // tags Tags []*TagItem `validate:"dive" json:"tags"` + // private level: public, authenticated, private + PrivateLevel string `validate:"required,oneof=public authenticated private" json:"private_level"` // user id UserID string `json:"-"` QuestionPermission @@ -267,6 +269,7 @@ type QuestionInfoResp struct { // MemberActions MemberActions []*PermissionMemberAction `json:"member_actions"` ExtendsActions []*PermissionMemberAction `json:"extends_actions"` + PrivateLevel string `json:"private_level"` } // UpdateQuestionResp update question resp diff --git a/internal/service/content/question_service.go b/internal/service/content/question_service.go index a6d94e164..155c9a153 100644 --- a/internal/service/content/question_service.go +++ b/internal/service/content/question_service.go @@ -385,6 +385,10 @@ func (qs *QuestionService) AddQuestion(ctx context.Context, req *schema.Question question.PostUpdateTime = now question.Pin = entity.QuestionUnPin question.Show = entity.QuestionShow + question.PrivateLevel = req.PrivateLevel + if question.PrivateLevel == "" { + question.PrivateLevel = entity.QuestionPrivateLevelPublic + } // question.UpdatedAt = nil err = qs.questionRepo.AddQuestion(ctx, question) if err != nil { diff --git a/internal/service/question_common/question.go b/internal/service/question_common/question.go index 7a1d4b459..3400ebf62 100644 --- a/internal/service/question_common/question.go +++ b/internal/service/question_common/question.go @@ -108,7 +108,6 @@ type QuestionCommon struct { revisionRepo revision.RevisionRepo siteInfoService siteinfo_common.SiteInfoCommonService fakeUsernameService *fake_username.FakeUsernameService - anonymityService *fake_username.AnonymityService data *data.Data } @@ -126,7 +125,6 @@ func NewQuestionCommon(questionRepo QuestionRepo, revisionRepo revision.RevisionRepo, siteInfoService siteinfo_common.SiteInfoCommonService, fakeUsernameService *fake_username.FakeUsernameService, - anonymityService *fake_username.AnonymityService, data *data.Data, ) *QuestionCommon { return &QuestionCommon{ @@ -144,7 +142,6 @@ func NewQuestionCommon(questionRepo QuestionRepo, revisionRepo: revisionRepo, siteInfoService: siteInfoService, fakeUsernameService: fakeUsernameService, - anonymityService: anonymityService, data: data, } } @@ -268,6 +265,18 @@ func (qs *QuestionCommon) Info(ctx context.Context, questionID string, loginUser return resp, errors.NotFound(reason.QuestionNotFound) } resp = qs.ShowFormat(ctx, questionInfo) + // Access control based on private_level + if resp.PrivateLevel == entity.QuestionPrivateLevelPrivate { + // only the author can view private questions + if loginUserID != questionInfo.UserID { + return resp, errors.Forbidden(reason.QuestionNotFound) + } + } else if resp.PrivateLevel == entity.QuestionPrivateLevelAuthenticated { + // only logged-in users can view authenticated questions + if loginUserID == "" { + return resp, errors.Forbidden(reason.QuestionNotFound) + } + } if resp.Status == entity.QuestionStatusClosed { metaInfo, err := qs.metaCommonService.GetMetaByObjectIdAndKey(ctx, questionInfo.ID, entity.QuestionCloseReasonKey) if err != nil { @@ -342,30 +351,6 @@ func (qs *QuestionCommon) Info(ctx context.Context, questionID string, loginUser resp.UserInfo = userInfoMap[questionInfo.UserID] resp.UpdateUserInfo = userInfoMap[questionInfo.LastEditUserID] resp.LastAnsweredUserInfo = userInfoMap[resp.LastAnsweredUserID] - - userIDs := []string{ - questionInfo.UserID, - questionInfo.LastEditUserID, - resp.LastAnsweredUserID, - } - - anonymizedUsers, err := qs.anonymityService.AnonymizeUserData(ctx, userIDs, resp.ID, loginUserID) - if err != nil { - return nil, err - } - - if au, ok := anonymizedUsers[questionInfo.UserID]; ok { - resp.UserInfo = au - } - - if au, ok := anonymizedUsers[questionInfo.LastEditUserID]; ok { - resp.UpdateUserInfo = au - } - - if au, ok := anonymizedUsers[resp.LastAnsweredUserID]; ok { - resp.LastAnsweredUserInfo = au - } - if len(loginUserID) == 0 { return resp, nil } @@ -484,15 +469,6 @@ func (qs *QuestionCommon) FormatQuestionsPage( userInfo, ok := userInfoMap[item.Operator.ID] if ok { if userInfo != nil { - anonymizedUsers, err := qs.anonymityService.AnonymizeUserData(ctx, []string{userInfo.ID}, item.ID, loginUserID) - if err != nil { - log.Errorf("failed to anonymize user: %w", err) - } - - if au, ok := anonymizedUsers[userInfo.ID]; ok { - userInfo = au - } - item.Operator.ID = userInfo.ID item.Operator.DisplayName = userInfo.DisplayName item.Operator.Username = userInfo.Username item.Operator.Rank = userInfo.Rank @@ -512,6 +488,16 @@ func (qs *QuestionCommon) FormatQuestions(ctx context.Context, questionList []*e for _, questionInfo := range questionList { item := qs.ShowFormat(ctx, questionInfo) + // filter by private_level + if item.PrivateLevel == entity.QuestionPrivateLevelPrivate { + if loginUserID != questionInfo.UserID { + continue + } + } else if item.PrivateLevel == entity.QuestionPrivateLevelAuthenticated { + if loginUserID == "" { + continue + } + } list = append(list, item) objectIds = append(objectIds, item.ID) userIds = append(userIds, item.UserID, item.LastEditUserID, item.LastAnsweredUserID) @@ -531,29 +517,6 @@ func (qs *QuestionCommon) FormatQuestions(ctx context.Context, questionList []*e item.UserInfo = userInfoMap[item.UserID] item.UpdateUserInfo = userInfoMap[item.LastEditUserID] item.LastAnsweredUserInfo = userInfoMap[item.LastAnsweredUserID] - - userIDs := []string{ - item.UserID, - item.LastEditUserID, - item.LastAnsweredUserID, - } - - anonymizedUsers, err := qs.anonymityService.AnonymizeUserData(ctx, userIDs, item.ID, loginUserID) - if err != nil { - return nil, err - } - - if au, ok := anonymizedUsers[item.UserID]; ok { - item.UserInfo = au - } - - if au, ok := anonymizedUsers[item.LastEditUserID]; ok { - item.UpdateUserInfo = au - } - - if au, ok := anonymizedUsers[item.LastAnsweredUserID]; ok { - item.LastAnsweredUserInfo = au - } } if loginUserID == "" { return list, nil @@ -734,6 +697,10 @@ func (qs *QuestionCommon) ShowFormat(ctx context.Context, data *entity.Question) info.Status = data.Status info.Pin = data.Pin info.Show = data.Show + info.PrivateLevel = data.PrivateLevel + if info.PrivateLevel == "" { + info.PrivateLevel = entity.QuestionPrivateLevelPublic + } info.UserID = data.UserID info.LastEditUserID = data.LastEditUserID if data.LastAnswerID != "0" { From ba8742e9a9c70ed8f0c77bb8eb034efcb062509f Mon Sep 17 00:00:00 2001 From: liza-hamai Date: Sat, 30 May 2026 23:57:46 +0300 Subject: [PATCH 11/19] feat: add private level option to question visibility --- node_modules/.package-lock.json | 6 +++++ package-lock.json | 6 +++++ ui/src/common/interface.ts | 1 + ui/src/pages/Questions/Ask/index.tsx | 35 ++++++++++++++++++++++++++++ 4 files changed, 48 insertions(+) create mode 100644 node_modules/.package-lock.json create mode 100644 package-lock.json diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json new file mode 100644 index 000000000..9b4a763c9 --- /dev/null +++ b/node_modules/.package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "answer-fork", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..9b4a763c9 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "answer-fork", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/ui/src/common/interface.ts b/ui/src/common/interface.ts index 308726e80..c16535b11 100644 --- a/ui/src/common/interface.ts +++ b/ui/src/common/interface.ts @@ -83,6 +83,7 @@ export interface QuestionParams extends ImgCodeReq { url_title?: string; content: string; tags: Tag[]; + private_level: 'public' | 'authenticated' | 'private'; } export interface QuestionWithAnswer extends QuestionParams { diff --git a/ui/src/pages/Questions/Ask/index.tsx b/ui/src/pages/Questions/Ask/index.tsx index ab680c495..1ea5a62da 100644 --- a/ui/src/pages/Questions/Ask/index.tsx +++ b/ui/src/pages/Questions/Ask/index.tsx @@ -98,6 +98,7 @@ const Ask = () => { const [blockState, setBlockState] = useState(false); const [focusType, setForceType] = useState(''); const [hasDraft, setHasDraft] = useState(false); + const [privateLevel, setPrivateLevel] = useState<'public' | 'authenticated' | 'private'>('public'); const resetForm = () => { setFormData(initFormData); setCheckState(false); @@ -388,6 +389,7 @@ const Ask = () => { title: formData.title.value, content: formData.content.value, tags: formData.tags.value, + private_level: privateLevel, }; if (isEdit) { @@ -518,6 +520,39 @@ const Ask = () => { errMsg={formData.tags.errorMsg} /> + {!isEdit && ( + + {t('form.fields.visibility.label', 'Visibility')} +
+ {[ + { value: 'public', label: t('form.fields.visibility.public', 'Public'), desc: t('form.fields.visibility.public_desc', 'Everyone can see this question') }, + { value: 'authenticated', label: t('form.fields.visibility.authenticated', 'Registered users'), desc: t('form.fields.visibility.authenticated_desc', 'Only logged-in users can see this question') }, + { value: 'private', label: t('form.fields.visibility.private', 'Only me'), desc: t('form.fields.visibility.private_desc', 'Only you can see this question') }, + ].map((opt) => ( +
setPrivateLevel(opt.value as typeof privateLevel)} + className={`border rounded p-3 cursor-pointer flex-fill ${privateLevel === opt.value ? 'border-primary bg-primary bg-opacity-10' : 'border-secondary'}`} + style={{ cursor: 'pointer', minWidth: '140px' }}> +
+ setPrivateLevel(opt.value as typeof privateLevel)} + label="" + style={{ margin: 0 }} + /> + {opt.label} +
+
{opt.desc}
+
+ ))} +
+
+ )} {!isEdit && ( <> Date: Sun, 31 May 2026 14:12:23 +0300 Subject: [PATCH 12/19] feat: enhance question visibility and anonymization features --- internal/repo/question/question_repo.go | 11 +++- internal/schema/mcp_schema.go | 10 ++- internal/service/content/answer_service.go | 2 +- .../content/question_hottest_service.go | 2 +- internal/service/content/question_service.go | 2 +- internal/service/question_common/question.go | 62 ++++++++++++++++++- ui/src/components/Editor/index.scss | 5 +- ui/src/pages/Questions/Ask/index.tsx | 50 ++++++++++++--- .../builtin/HostingConnector/info.yaml | 1 - .../ThirdPartyConnector/i18n/zh_CN.yaml | 1 - .../builtin/ThirdPartyConnector/info.yaml | 1 - 11 files changed, 126 insertions(+), 21 deletions(-) diff --git a/internal/repo/question/question_repo.go b/internal/repo/question/question_repo.go index dc5c93bc2..06352444f 100644 --- a/internal/repo/question/question_repo.go +++ b/internal/repo/question/question_repo.go @@ -399,7 +399,7 @@ func (qr *questionRepo) SitemapQuestions(ctx context.Context, page, pageSize int // GetQuestionPage query question page func (qr *questionRepo) GetQuestionPage(ctx context.Context, page, pageSize int, - tagIDs []string, userID, orderCond string, inDays int, showHidden, showPending bool) ( + tagIDs []string, userID, orderCond, loginUserID string, inDays int, showHidden, showPending bool) ( questionList []*entity.Question, total int64, err error) { questionList = make([]*entity.Question, 0) session := qr.data.DB.Context(ctx) @@ -412,6 +412,15 @@ func (qr *questionRepo) GetQuestionPage(ctx context.Context, page, pageSize int, } session.Select("question.*") session.In("question.status", status) + + if loginUserID == "" { + session.And("question.private_level = ?", entity.QuestionPrivateLevelPublic) + } else { + session.And("(question.private_level != ? OR question.user_id = ?)", + entity.QuestionPrivateLevelPrivate, loginUserID, + ) + } + if len(tagIDs) > 0 { session.Join("LEFT", "tag_rel", "question.id = tag_rel.object_id") session.In("tag_rel.tag_id", tagIDs) diff --git a/internal/schema/mcp_schema.go b/internal/schema/mcp_schema.go index bead21c9d..3c96e1317 100644 --- a/internal/schema/mcp_schema.go +++ b/internal/schema/mcp_schema.go @@ -180,14 +180,18 @@ func (cond *MCPSearchCond) ToQueryString() string { queryBuilder.WriteString(cond.Keyword) } if len(cond.Username) > 0 { - queryBuilder.WriteString(" user:" + cond.Username) + queryBuilder.WriteString(" user:") + queryBuilder.WriteString(cond.Username) } if cond.Score > 0 { - queryBuilder.WriteString(" score:" + converter.IntToString(int64(cond.Score))) + queryBuilder.WriteString(" score:") + queryBuilder.WriteString(converter.IntToString(int64(cond.Score))) } if len(cond.Tags) > 0 { for _, tag := range cond.Tags { - queryBuilder.WriteString(" [" + tag + "]") + queryBuilder.WriteString(" [") + queryBuilder.WriteString(tag) + queryBuilder.WriteString("]") } } return strings.TrimSpace(queryBuilder.String()) diff --git a/internal/service/content/answer_service.go b/internal/service/content/answer_service.go index a58497b9b..51be42881 100644 --- a/internal/service/content/answer_service.go +++ b/internal/service/content/answer_service.go @@ -690,7 +690,7 @@ func (as *AnswerService) SearchFormatInfo(ctx context.Context, answers []*entity item.UpdateUserInfo = userInfoMap[item.UpdateUserID] anonymizedUsers, err := as.anonymityService.AnonymizeUserData( - ctx, []string{item.UserID, item.UpdateUserID}, item.QuestionID, "", + ctx, []string{item.UserID, item.UpdateUserID}, item.QuestionID, req.UserID, ) if err != nil { return nil, err diff --git a/internal/service/content/question_hottest_service.go b/internal/service/content/question_hottest_service.go index b73e7a30d..201a0d0f6 100644 --- a/internal/service/content/question_hottest_service.go +++ b/internal/service/content/question_hottest_service.go @@ -40,7 +40,7 @@ func (q *QuestionService) RefreshHottestCron(ctx context.Context) { ctx, page, pageSize, []string{}, - "", "newest", + "", "newest", "", schema.HotInDays, false, false) if err != nil { diff --git a/internal/service/content/question_service.go b/internal/service/content/question_service.go index 155c9a153..4cc13aae6 100644 --- a/internal/service/content/question_service.go +++ b/internal/service/content/question_service.go @@ -1518,7 +1518,7 @@ func (qs *QuestionService) GetQuestionPage(ctx context.Context, req *schema.Ques } questionList, total, err := qs.questionRepo.GetQuestionPage(ctx, req.Page, req.PageSize, - tagIDs, req.UserIDBeSearched, req.OrderCond, req.InDays, showHidden, req.ShowPending) + tagIDs, req.UserIDBeSearched, req.OrderCond, req.LoginUserID, req.InDays, showHidden, req.ShowPending) if err != nil { return nil, 0, err } diff --git a/internal/service/question_common/question.go b/internal/service/question_common/question.go index 3400ebf62..e9295ecc2 100644 --- a/internal/service/question_common/question.go +++ b/internal/service/question_common/question.go @@ -60,7 +60,8 @@ type QuestionRepo interface { UpdateQuestion(ctx context.Context, question *entity.Question, Cols []string) (err error) GetQuestion(ctx context.Context, id string) (question *entity.Question, exist bool, err error) GetQuestionList(ctx context.Context, question *entity.Question) (questions []*entity.Question, err error) - GetQuestionPage(ctx context.Context, page, pageSize int, tagIDs []string, userID, orderCond string, inDays int, showHidden, showPending bool) ( + GetQuestionPage(ctx context.Context, page, pageSize int, tagIDs []string, userID, orderCond, loginUserID string, + inDays int, showHidden, showPending bool) ( questionList []*entity.Question, total int64, err error) GetRecommendQuestionPageByTags(ctx context.Context, userID string, tagIDs, followedQuestionIDs []string, page, pageSize int) (questionList []*entity.Question, total int64, err error) UpdateQuestionStatus(ctx context.Context, questionID string, status int) (err error) @@ -108,6 +109,7 @@ type QuestionCommon struct { revisionRepo revision.RevisionRepo siteInfoService siteinfo_common.SiteInfoCommonService fakeUsernameService *fake_username.FakeUsernameService + anonymityService *fake_username.AnonymityService data *data.Data } @@ -125,6 +127,7 @@ func NewQuestionCommon(questionRepo QuestionRepo, revisionRepo revision.RevisionRepo, siteInfoService siteinfo_common.SiteInfoCommonService, fakeUsernameService *fake_username.FakeUsernameService, + anonymityService *fake_username.AnonymityService, data *data.Data, ) *QuestionCommon { return &QuestionCommon{ @@ -142,6 +145,7 @@ func NewQuestionCommon(questionRepo QuestionRepo, revisionRepo: revisionRepo, siteInfoService: siteInfoService, fakeUsernameService: fakeUsernameService, + anonymityService: anonymityService, data: data, } } @@ -351,6 +355,30 @@ func (qs *QuestionCommon) Info(ctx context.Context, questionID string, loginUser resp.UserInfo = userInfoMap[questionInfo.UserID] resp.UpdateUserInfo = userInfoMap[questionInfo.LastEditUserID] resp.LastAnsweredUserInfo = userInfoMap[resp.LastAnsweredUserID] + + userIDs := []string{ + questionInfo.UserID, + questionInfo.LastEditUserID, + resp.LastAnsweredUserID, + } + + anonymizedUsers, err := qs.anonymityService.AnonymizeUserData(ctx, userIDs, resp.ID, loginUserID) + if err != nil { + return nil, err + } + + if au, ok := anonymizedUsers[questionInfo.UserID]; ok { + resp.UserInfo = au + } + + if au, ok := anonymizedUsers[questionInfo.LastEditUserID]; ok { + resp.UpdateUserInfo = au + } + + if au, ok := anonymizedUsers[resp.LastAnsweredUserID]; ok { + resp.LastAnsweredUserInfo = au + } + if len(loginUserID) == 0 { return resp, nil } @@ -469,6 +497,15 @@ func (qs *QuestionCommon) FormatQuestionsPage( userInfo, ok := userInfoMap[item.Operator.ID] if ok { if userInfo != nil { + anonymizedUsers, err := qs.anonymityService.AnonymizeUserData(ctx, []string{userInfo.ID}, item.ID, loginUserID) + if err != nil { + log.Errorf("failed to anonymize user: %w", err) + } + + if au, ok := anonymizedUsers[userInfo.ID]; ok { + userInfo = au + } + item.Operator.ID = userInfo.ID item.Operator.DisplayName = userInfo.DisplayName item.Operator.Username = userInfo.Username item.Operator.Rank = userInfo.Rank @@ -517,6 +554,29 @@ func (qs *QuestionCommon) FormatQuestions(ctx context.Context, questionList []*e item.UserInfo = userInfoMap[item.UserID] item.UpdateUserInfo = userInfoMap[item.LastEditUserID] item.LastAnsweredUserInfo = userInfoMap[item.LastAnsweredUserID] + + userIDs := []string{ + item.UserID, + item.LastEditUserID, + item.LastAnsweredUserID, + } + + anonymizedUsers, err := qs.anonymityService.AnonymizeUserData(ctx, userIDs, item.ID, loginUserID) + if err != nil { + return nil, err + } + + if au, ok := anonymizedUsers[item.UserID]; ok { + item.UserInfo = au + } + + if au, ok := anonymizedUsers[item.LastEditUserID]; ok { + item.UpdateUserInfo = au + } + + if au, ok := anonymizedUsers[item.LastAnsweredUserID]; ok { + item.LastAnsweredUserInfo = au + } } if loginUserID == "" { return list, nil diff --git a/ui/src/components/Editor/index.scss b/ui/src/components/Editor/index.scss index 60a549aaf..ee4b062bf 100644 --- a/ui/src/components/Editor/index.scss +++ b/ui/src/components/Editor/index.scss @@ -152,8 +152,9 @@ .CodeMirror { height: auto; - font-family: SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', - 'Courier New', monospace !important; + font-family: + SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', + monospace !important; font-size: 14px; pre.CodeMirror-line, pre.CodeMirror-line-like { diff --git a/ui/src/pages/Questions/Ask/index.tsx b/ui/src/pages/Questions/Ask/index.tsx index 1ea5a62da..36dae6042 100644 --- a/ui/src/pages/Questions/Ask/index.tsx +++ b/ui/src/pages/Questions/Ask/index.tsx @@ -98,7 +98,9 @@ const Ask = () => { const [blockState, setBlockState] = useState(false); const [focusType, setForceType] = useState(''); const [hasDraft, setHasDraft] = useState(false); - const [privateLevel, setPrivateLevel] = useState<'public' | 'authenticated' | 'private'>('public'); + const [privateLevel, setPrivateLevel] = useState< + 'public' | 'authenticated' | 'private' + >('public'); const resetForm = () => { setFormData(initFormData); setCheckState(false); @@ -522,16 +524,44 @@ const Ask = () => { {!isEdit && ( - {t('form.fields.visibility.label', 'Visibility')} + + {t('form.fields.visibility.label', 'Visibility')} +
{[ - { value: 'public', label: t('form.fields.visibility.public', 'Public'), desc: t('form.fields.visibility.public_desc', 'Everyone can see this question') }, - { value: 'authenticated', label: t('form.fields.visibility.authenticated', 'Registered users'), desc: t('form.fields.visibility.authenticated_desc', 'Only logged-in users can see this question') }, - { value: 'private', label: t('form.fields.visibility.private', 'Only me'), desc: t('form.fields.visibility.private_desc', 'Only you can see this question') }, + { + value: 'public', + label: t('form.fields.visibility.public', 'Public'), + desc: t( + 'form.fields.visibility.public_desc', + 'Everyone can see this question', + ), + }, + { + value: 'authenticated', + label: t( + 'form.fields.visibility.authenticated', + 'Registered users', + ), + desc: t( + 'form.fields.visibility.authenticated_desc', + 'Only logged-in users can see this question', + ), + }, + { + value: 'private', + label: t('form.fields.visibility.private', 'Only me'), + desc: t( + 'form.fields.visibility.private_desc', + 'Only you can see this question', + ), + }, ].map((opt) => (
setPrivateLevel(opt.value as typeof privateLevel)} + onClick={() => + setPrivateLevel(opt.value as typeof privateLevel) + } className={`border rounded p-3 cursor-pointer flex-fill ${privateLevel === opt.value ? 'border-primary bg-primary bg-opacity-10' : 'border-secondary'}`} style={{ cursor: 'pointer', minWidth: '140px' }}>
@@ -541,13 +571,17 @@ const Ask = () => { name="private_level" value={opt.value} checked={privateLevel === opt.value} - onChange={() => setPrivateLevel(opt.value as typeof privateLevel)} + onChange={() => + setPrivateLevel(opt.value as typeof privateLevel) + } label="" style={{ margin: 0 }} /> {opt.label}
-
{opt.desc}
+
+ {opt.desc} +
))}
diff --git a/ui/src/plugins/builtin/HostingConnector/info.yaml b/ui/src/plugins/builtin/HostingConnector/info.yaml index f6699088b..417f3a2b6 100644 --- a/ui/src/plugins/builtin/HostingConnector/info.yaml +++ b/ui/src/plugins/builtin/HostingConnector/info.yaml @@ -19,4 +19,3 @@ slug_name: hosting_connector type: connector version: 0.0.1 author: Answer - diff --git a/ui/src/plugins/builtin/ThirdPartyConnector/i18n/zh_CN.yaml b/ui/src/plugins/builtin/ThirdPartyConnector/i18n/zh_CN.yaml index 7a6953884..c8be34388 100644 --- a/ui/src/plugins/builtin/ThirdPartyConnector/i18n/zh_CN.yaml +++ b/ui/src/plugins/builtin/ThirdPartyConnector/i18n/zh_CN.yaml @@ -20,4 +20,3 @@ plugin: ui: connect: 连接到 {{ auth_name }} remove: 解绑 {{ auth_name }} - diff --git a/ui/src/plugins/builtin/ThirdPartyConnector/info.yaml b/ui/src/plugins/builtin/ThirdPartyConnector/info.yaml index 37a42b4ff..7cae8692f 100644 --- a/ui/src/plugins/builtin/ThirdPartyConnector/info.yaml +++ b/ui/src/plugins/builtin/ThirdPartyConnector/info.yaml @@ -20,4 +20,3 @@ type: connector version: 0.0.1 link: https://github.com/apache/answer-plugins/tree/main/connector-basic author: Answer - From 56ec1e58ea7b94afe8913c4817fe6df1827b1991 Mon Sep 17 00:00:00 2001 From: IrusHunter Date: Tue, 2 Jun 2026 11:59:04 +0300 Subject: [PATCH 13/19] feat: integrate anonymity service for user data anonymization in notifications --- cmd/wire_gen.go | 2 +- internal/service/content/answer_service.go | 8 ++++++++ internal/service/notification_common/notification.go | 12 ++++++++++++ internal/service/question_common/question.go | 5 +++-- 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 8183d7d07..3bd310a91 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -240,7 +240,7 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, siteInfoService := siteinfo.NewSiteInfoService(siteInfoRepo, siteInfoCommonService, emailService, tagCommonService, configService, questionCommon, fileRecordService) siteInfoController := controller_admin.NewSiteInfoController(siteInfoService) controllerSiteInfoController := controller.NewSiteInfoController(siteInfoCommonService) - notificationCommon := notificationcommon.NewNotificationCommon(dataData, notificationRepo, userCommon, activityRepo, followRepo, objService, noticequeueService, userExternalLoginRepo, siteInfoCommonService) + notificationCommon := notificationcommon.NewNotificationCommon(dataData, notificationRepo, userCommon, activityRepo, followRepo, objService, noticequeueService, userExternalLoginRepo, siteInfoCommonService, anonymityService) badgeRepo := badge.NewBadgeRepo(dataData, uniqueIDRepo) notificationService := notification.NewNotificationService(dataData, notificationRepo, notificationCommon, revisionService, userRepo, reportRepo, reviewService, badgeRepo) notificationController := controller.NewNotificationController(notificationService, rankService) diff --git a/internal/service/content/answer_service.go b/internal/service/content/answer_service.go index 51be42881..0a2d1c524 100644 --- a/internal/service/content/answer_service.go +++ b/internal/service/content/answer_service.go @@ -785,7 +785,15 @@ func (as *AnswerService) notificationAnswerTheQuestion(ctx context.Context, AnswerSummary: answerSummary, UnsubscribeCode: token.GenerateToken(), } + answerUser, _, _ := as.userCommon.GetUserBasicInfoByID(ctx, answerUserID) + fakeUsername, err := as.anonymityService.AnonymizeUserData(ctx, []string{answerUserID}, questionID, "") + if err != nil { + log.Errorf("failed to get fake username: %w", err) + } else if fu, ok := fakeUsername[answerUserID]; ok { + answerUser = fu + } + if answerUser != nil { rawData.AnswerUserDisplayName = answerUser.DisplayName } diff --git a/internal/service/notification_common/notification.go b/internal/service/notification_common/notification.go index aa3f4106c..b155882b6 100644 --- a/internal/service/notification_common/notification.go +++ b/internal/service/notification_common/notification.go @@ -25,6 +25,7 @@ import ( "time" "github.com/apache/answer/internal/base/translator" + "github.com/apache/answer/internal/service/fake_username" "github.com/apache/answer/internal/service/siteinfo_common" "github.com/apache/answer/internal/service/user_external_login" "github.com/apache/answer/pkg/display" @@ -69,6 +70,7 @@ type NotificationCommon struct { notificationQueueService noticequeue.Service userExternalLoginRepo user_external_login.UserExternalLoginRepo siteInfoService siteinfo_common.SiteInfoCommonService + anonymityService *fake_username.AnonymityService } func NewNotificationCommon( @@ -81,6 +83,7 @@ func NewNotificationCommon( notificationQueueService noticequeue.Service, userExternalLoginRepo user_external_login.UserExternalLoginRepo, siteInfoService siteinfo_common.SiteInfoCommonService, + anonymityService *fake_username.AnonymityService, ) *NotificationCommon { notification := &NotificationCommon{ data: data, @@ -92,6 +95,7 @@ func NewNotificationCommon( notificationQueueService: notificationQueueService, userExternalLoginRepo: userExternalLoginRepo, siteInfoService: siteInfoService, + anonymityService: anonymityService, } notificationQueueService.RegisterHandler(notification.AddNotification) return notification @@ -189,6 +193,14 @@ func (ns *NotificationCommon) AddNotification(ctx context.Context, msg *schema.N if !exist { return fmt.Errorf("user not exist: %s", req.TriggerUserID) } + + fakeUsername, err := ns.anonymityService.AnonymizeUserData(ctx, []string{req.TriggerUserID}, questionID, req.ReceiverUserID) + if err != nil { + log.Errorf("failed to get fake username: %w", err) + } else if fu, ok := fakeUsername[req.TriggerUserID]; ok { + userBasicInfo = fu + } + req.UserInfo = userBasicInfo content, _ := json.Marshal(req) _, ok := constant.NotificationMsgTypeMapping[req.NotificationAction] diff --git a/internal/service/question_common/question.go b/internal/service/question_common/question.go index e9295ecc2..26c26dcef 100644 --- a/internal/service/question_common/question.go +++ b/internal/service/question_common/question.go @@ -270,12 +270,13 @@ func (qs *QuestionCommon) Info(ctx context.Context, questionID string, loginUser } resp = qs.ShowFormat(ctx, questionInfo) // Access control based on private_level - if resp.PrivateLevel == entity.QuestionPrivateLevelPrivate { + switch resp.PrivateLevel { + case entity.QuestionPrivateLevelPrivate: // only the author can view private questions if loginUserID != questionInfo.UserID { return resp, errors.Forbidden(reason.QuestionNotFound) } - } else if resp.PrivateLevel == entity.QuestionPrivateLevelAuthenticated { + case entity.QuestionPrivateLevelAuthenticated: // only logged-in users can view authenticated questions if loginUserID == "" { return resp, errors.Forbidden(reason.QuestionNotFound) From 1a760107028eb8571b84ddf8bb54b5f23bd948e2 Mon Sep 17 00:00:00 2001 From: IrusHunter Date: Tue, 2 Jun 2026 12:08:38 +0300 Subject: [PATCH 14/19] add report --- report.md | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 report.md diff --git a/report.md b/report.md new file mode 100644 index 000000000..6909013d0 --- /dev/null +++ b/report.md @@ -0,0 +1,106 @@ +# Access Control and Pseudonymous Content Support + +## Project Overview + +**Project:** Knowledge Sharing Platform +**Feature:** Pseudonymous Users & Content Visibility Control + +## Objective + +This fork extends the platform with mechanisms for user pseudonymity and content visibility control. The goal is to provide users with greater privacy when participating in discussions while ensuring that access to content can be restricted according to predefined visibility levels. + +## Implemented Features + +### 1. User Pseudonymity + +Implemented a configurable anonymity system that allows users to participate without exposing their real identity. + +#### Configuration Management + +- Added anonymity configuration for users. +- Users can enable or disable anonymity mode at any time. +- Automatic creation of anonymity configuration during user registration. +- Automatic removal of anonymity configuration when a user account is permanently deleted. + +#### Alias Generation + +- Implemented fake name (alias) generation for anonymous users. +- Automatically creates alias records when anonymity mode is enabled and a user creates: + - a question + - an answer + - a comment + +#### Content Representation + +- Replaced real user information with anonymous aliases when retrieving: + - questions + - answers + - comments + +- Replaced user avatars with anonymous representations. +- Administrators retain access to the original user information. + +#### Access Restrictions + +- Disabled navigation to anonymous user profiles. +- Implemented anonymization of notifications. +- Added support for viewing generated aliases by the alias owner + +#### Cleanup Logic + +- Removes all alias records associated with a question when that question is permanently deleted. + +### 2. Content Visibility Control + +Implemented configurable visibility levels for questions. + +#### Visibility Levels + +Added a visibility field represented by an enumeration: + +- `public` – available to all users. +- `authenticated` – available only to authenticated users. +- `private` – available only to the author. + +#### Access Enforcement + +Implemented visibility validation across the application: + +- Restricted access to questions according to their visibility level. +- Added permission checks before accessing question details. +- Prevented unauthorized navigation to restricted questions. +- Ensured question listings only contain content available to the current user. + +## Backend Changes + +### Data Model Updates + +- Added anonymity configuration entity. +- Added alias storage. +- Added question visibility field and corresponding enum. + +### Business Logic + +- Alias generation and lifecycle management. +- Content anonymization during response mapping. +- Permission-based visibility filtering. +- Notification anonymization. + +### Cleanup Operations + +- Remove anonymity configuration on user deletion. +- Remove question aliases on question deletion. + +## Result + +The platform now supports: + +- User-controlled anonymity. +- Automatic pseudonymous identity generation. +- Anonymous participation in discussions. +- Visibility-based access control for questions. +- Secure content filtering according to user permissions. +- Administrator access to original user information when required. +- Proper cleanup of anonymity-related data during entity removal. + +These changes improve both privacy and access management while remaining fully integrated with the existing platform architecture. From 900bb6feae7deb9d036859ad08cd401522f44d5e Mon Sep 17 00:00:00 2001 From: liza-hamai Date: Tue, 2 Jun 2026 23:14:25 +0300 Subject: [PATCH 15/19] feat: integrate anonymity service into notification handling --- cmd/wire_gen.go | 2 +- .../notification/notification_service.go | 23 +++++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 3bd310a91..700aeeed4 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -242,7 +242,7 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, controllerSiteInfoController := controller.NewSiteInfoController(siteInfoCommonService) notificationCommon := notificationcommon.NewNotificationCommon(dataData, notificationRepo, userCommon, activityRepo, followRepo, objService, noticequeueService, userExternalLoginRepo, siteInfoCommonService, anonymityService) badgeRepo := badge.NewBadgeRepo(dataData, uniqueIDRepo) - notificationService := notification.NewNotificationService(dataData, notificationRepo, notificationCommon, revisionService, userRepo, reportRepo, reviewService, badgeRepo) + notificationService := notification.NewNotificationService(dataData, notificationRepo, notificationCommon, revisionService, userRepo, reportRepo, reviewService, badgeRepo, anonymityService) notificationController := controller.NewNotificationController(notificationService, rankService) dashboardService := dashboard.NewDashboardService(questionRepo, answerRepo, commentCommonRepo, voteRepo, userRepo, reportRepo, configService, siteInfoCommonService, serviceConf, reviewService, revisionRepo, dataData) dashboardController := controller.NewDashboardController(dashboardService) diff --git a/internal/service/notification/notification_service.go b/internal/service/notification/notification_service.go index 6a69cbaef..d9eed7dea 100644 --- a/internal/service/notification/notification_service.go +++ b/internal/service/notification/notification_service.go @@ -32,6 +32,7 @@ import ( "github.com/apache/answer/internal/entity" "github.com/apache/answer/internal/schema" "github.com/apache/answer/internal/service/badge" + "github.com/apache/answer/internal/service/fake_username" notficationcommon "github.com/apache/answer/internal/service/notification_common" "github.com/apache/answer/internal/service/report_common" "github.com/apache/answer/internal/service/review" @@ -53,6 +54,7 @@ type NotificationService struct { reviewService *review.ReviewService userRepo usercommon.UserRepo badgeRepo badge.BadgeRepo + anonymityService *fake_username.AnonymityService } func NewNotificationService( @@ -64,6 +66,7 @@ func NewNotificationService( reportRepo report_common.ReportRepo, reviewService *review.ReviewService, badgeRepo badge.BadgeRepo, + anonymityService *fake_username.AnonymityService, ) *NotificationService { return &NotificationService{ data: data, @@ -74,6 +77,7 @@ func NewNotificationService( reportRepo: reportRepo, reviewService: reviewService, badgeRepo: badgeRepo, + anonymityService: anonymityService, } } @@ -226,11 +230,11 @@ func (ns *NotificationService) GetNotificationPage(ctx context.Context, searchCo if err != nil { return nil, err } - resp = ns.formatNotificationPage(ctx, notifications) + resp = ns.formatNotificationPage(ctx, notifications, searchCond.UserID) return pager.NewPageModel(total, resp), nil } -func (ns *NotificationService) formatNotificationPage(ctx context.Context, notifications []*entity.Notification) ( +func (ns *NotificationService) formatNotificationPage(ctx context.Context, notifications []*entity.Notification, receiverUserID string) ( resp []*schema.NotificationContent) { lang := handler.GetLangByCtx(ctx) enableShortID := handler.GetEnableShortID(ctx) @@ -309,6 +313,21 @@ func (ns *NotificationService) formatNotificationPage(ctx context.Context, notif DisplayName: "user" + converter.DeleteUserDisplay(userInfo.ID), Status: constant.UserDeleted, } + continue + } + + // Apply anonymization: if the trigger user has enabled anonymity on this question, + // replace their user info with the fake username (same logic as questions/answers/comments). + questionID := item.ObjectInfo.ObjectMap["question"] + if questionID != "" && item.UserInfo.ID != "" { + anonymizedUsers, err := ns.anonymityService.AnonymizeUserData( + ctx, []string{item.UserInfo.ID}, uid.DeShortID(questionID), receiverUserID, + ) + if err != nil { + log.Errorf("failed to anonymize user data for notification: %v", err) + } else if au, exists := anonymizedUsers[item.UserInfo.ID]; exists { + item.UserInfo = au + } } } return resp From 7fbfeed6ffd8ea2464e9c17a167e5e356528f111 Mon Sep 17 00:00:00 2001 From: liza-hamai Date: Wed, 3 Jun 2026 10:00:25 +0300 Subject: [PATCH 16/19] feat: enhance user info display in notifications --- ui/src/pages/Users/Notifications/components/Inbox/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/src/pages/Users/Notifications/components/Inbox/index.tsx b/ui/src/pages/Users/Notifications/components/Inbox/index.tsx index 64a2c34ba..0530f3dbb 100644 --- a/ui/src/pages/Users/Notifications/components/Inbox/index.tsx +++ b/ui/src/pages/Users/Notifications/components/Inbox/index.tsx @@ -61,7 +61,9 @@ const Inbox = ({ data, handleReadNotification }) => { !item.is_read && 'warning', )}>
- {item.user_info && item.user_info.status !== 'deleted' ? ( + {item.user_info && + item.user_info.status !== 'deleted' && + item.user_info.username ? ( {item.user_info.display_name}{' '} From cd0aec477bcb27fad586d0b12aa19e119af96565 Mon Sep 17 00:00:00 2001 From: IrusHunter Date: Wed, 3 Jun 2026 11:00:23 +0300 Subject: [PATCH 17/19] fix: remove local work files --- .dockerignore | 6 - docker-compose.yaml | 7 +- docs/db.drawio | 762 -------------------------------------------- 3 files changed, 2 insertions(+), 773 deletions(-) delete mode 100644 .dockerignore delete mode 100644 docs/db.drawio diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index c3373c53c..000000000 --- a/.dockerignore +++ /dev/null @@ -1,6 +0,0 @@ -node_modules -**/node_modules -.pnpm-store -dist -build -.git \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index 564b45cf0..58e8ea036 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -18,15 +18,12 @@ version: "3" services: answer: - build: ./ + image: apache/answer ports: - '9080:80' + restart: on-failure volumes: - answer-data:/data - # env_file: - # - ./.env - # environment: - # - DOCKER_ENV=true volumes: answer-data: diff --git a/docs/db.drawio b/docs/db.drawio deleted file mode 100644 index 56faf8f20..000000000 --- a/docs/db.drawio +++ /dev/null @@ -1,762 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file From f415de5a3486bb8183796e9d963845c865d483fa Mon Sep 17 00:00:00 2001 From: liza-hamai Date: Wed, 3 Jun 2026 11:04:33 +0300 Subject: [PATCH 18/19] chore: add Apache License header to anonymity service files --- .../fake_username/anonymity_service.go | 19 +++++++++++++++++++ .../fake_username/fake_username_generator.go | 19 +++++++++++++++++++ .../fake_username/fake_username_service.go | 19 +++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/internal/service/fake_username/anonymity_service.go b/internal/service/fake_username/anonymity_service.go index e8a089ae9..b2d6937ea 100644 --- a/internal/service/fake_username/anonymity_service.go +++ b/internal/service/fake_username/anonymity_service.go @@ -1,3 +1,22 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package fake_username import ( diff --git a/internal/service/fake_username/fake_username_generator.go b/internal/service/fake_username/fake_username_generator.go index 78e687b61..e6a9997f5 100644 --- a/internal/service/fake_username/fake_username_generator.go +++ b/internal/service/fake_username/fake_username_generator.go @@ -1,3 +1,22 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package fake_username import ( diff --git a/internal/service/fake_username/fake_username_service.go b/internal/service/fake_username/fake_username_service.go index 2e47aa696..723854055 100644 --- a/internal/service/fake_username/fake_username_service.go +++ b/internal/service/fake_username/fake_username_service.go @@ -1,3 +1,22 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package fake_username import ( From 5e8b7270a65e3b5b1e92b4462a64ddccea4c0936 Mon Sep 17 00:00:00 2001 From: IrusHunter Date: Wed, 3 Jun 2026 11:05:27 +0300 Subject: [PATCH 19/19] chore: add Apache License header to multiple entity and service files --- internal/entity/fake_username_entity.go | 19 +++++++++++++++++++ .../entity/user_anonymity_config_entity.go | 19 +++++++++++++++++++ .../repo/fake_username/fake_username_repo.go | 19 +++++++++++++++++++ .../user_anonymity_config_repo.go | 19 +++++++++++++++++++ .../user_anonymity_config_service.go | 19 +++++++++++++++++++ 5 files changed, 95 insertions(+) diff --git a/internal/entity/fake_username_entity.go b/internal/entity/fake_username_entity.go index 02fb0940b..f23178fa8 100644 --- a/internal/entity/fake_username_entity.go +++ b/internal/entity/fake_username_entity.go @@ -1,3 +1,22 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package entity import "time" diff --git a/internal/entity/user_anonymity_config_entity.go b/internal/entity/user_anonymity_config_entity.go index 09a3b2606..9058c68e0 100644 --- a/internal/entity/user_anonymity_config_entity.go +++ b/internal/entity/user_anonymity_config_entity.go @@ -1,3 +1,22 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package entity import "time" diff --git a/internal/repo/fake_username/fake_username_repo.go b/internal/repo/fake_username/fake_username_repo.go index 57b3aef3b..a68fc44ea 100644 --- a/internal/repo/fake_username/fake_username_repo.go +++ b/internal/repo/fake_username/fake_username_repo.go @@ -1,3 +1,22 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package fake_username import ( diff --git a/internal/repo/user_anonymity_config/user_anonymity_config_repo.go b/internal/repo/user_anonymity_config/user_anonymity_config_repo.go index c78dee918..b2415a1a8 100644 --- a/internal/repo/user_anonymity_config/user_anonymity_config_repo.go +++ b/internal/repo/user_anonymity_config/user_anonymity_config_repo.go @@ -1,3 +1,22 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package user_anonymity_config import ( diff --git a/internal/service/user_anonymity_config/user_anonymity_config_service.go b/internal/service/user_anonymity_config/user_anonymity_config_service.go index 7a6bf9e6f..f10a479e8 100644 --- a/internal/service/user_anonymity_config/user_anonymity_config_service.go +++ b/internal/service/user_anonymity_config/user_anonymity_config_service.go @@ -1,3 +1,22 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + package user_anonymity_config import (