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 9fe134ed6..700aeeed4 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 @@ -50,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" @@ -68,6 +49,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" @@ -89,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" @@ -116,6 +99,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 +153,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) @@ -185,14 +171,18 @@ 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, 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) - 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) @@ -200,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) + 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) @@ -212,8 +202,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, 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) @@ -250,9 +240,9 @@ 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) + 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/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/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..f23178fa8 --- /dev/null +++ b/internal/entity/fake_username_entity.go @@ -0,0 +1,37 @@ +/* + * 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" + +// 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/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/entity/user_anonymity_config_entity.go b/internal/entity/user_anonymity_config_entity.go new file mode 100644 index 000000000..9058c68e0 --- /dev/null +++ b/internal/entity/user_anonymity_config_entity.go @@ -0,0 +1,34 @@ +/* + * 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" + +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/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/repo/fake_username/fake_username_repo.go b/internal/repo/fake_username/fake_username_repo.go new file mode 100644 index 000000000..a68fc44ea --- /dev/null +++ b/internal/repo/fake_username/fake_username_repo.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 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 +} + +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/repo/provider.go b/internal/repo/provider.go index 510a94aaa..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" @@ -53,6 +54,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 +105,8 @@ var ProviderSetRepo = wire.NewSet( user_external_login.NewUserExternalLoginRepo, 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/repo/question/question_repo.go b/internal/repo/question/question_repo.go index 3449efb8b..06352444f 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() @@ -393,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) @@ -406,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/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/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..b2415a1a8 --- /dev/null +++ b/internal/repo/user_anonymity_config/user_anonymity_config_repo.go @@ -0,0 +1,84 @@ +/* + * 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 ( + "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/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/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/schema/user_anonymity_config_schema.go b/internal/schema/user_anonymity_config_schema.go new file mode 100644 index 000000000..431467fc4 --- /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 `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/comment/comment_service.go b/internal/service/comment/comment_service.go index 30ff43c6b..ff8729bba 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,8 @@ type CommentService struct { activityQueueService activityqueue.Service eventQueueService eventqueue.Service reviewService *review.ReviewService + fakeUsernameService *fake_username.FakeUsernameService + anonymityService *fake_username.AnonymityService } // NewCommentService new comment service @@ -109,6 +112,8 @@ func NewCommentService( activityQueueService activityqueue.Service, eventQueueService eventqueue.Service, reviewService *review.ReviewService, + fakeUsernameService *fake_username.FakeUsernameService, + anonymityService *fake_username.AnonymityService, ) *CommentService { return &CommentService{ commentRepo: commentRepo, @@ -123,6 +128,8 @@ func NewCommentService( activityQueueService: activityQueueService, eventQueueService: eventQueueService, reviewService: reviewService, + fakeUsernameService: fakeUsernameService, + anonymityService: anonymityService, } } @@ -167,6 +174,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 @@ -336,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 @@ -350,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 @@ -432,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 @@ -446,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 2ad875177..0a2d1c524 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,8 @@ type AnswerService struct { activityQueueService activityqueue.Service reviewService *review.ReviewService eventQueueService eventqueue.Service + fakeUsernameService *fake_username.FakeUsernameService + anonymityService *fake_username.AnonymityService } func NewAnswerService( @@ -90,6 +93,8 @@ func NewAnswerService( activityQueueService activityqueue.Service, reviewService *review.ReviewService, eventQueueService eventqueue.Service, + fakeUsernameService *fake_username.FakeUsernameService, + anonymityService *fake_username.AnonymityService, ) *AnswerService { return &AnswerService{ answerRepo: answerRepo, @@ -109,6 +114,8 @@ func NewAnswerService( activityQueueService: activityQueueService, reviewService: reviewService, eventQueueService: eventQueueService, + fakeUsernameService: fakeUsernameService, + anonymityService: anonymityService, } } @@ -267,6 +274,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 @@ -542,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 } @@ -658,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, req.UserID, + ) + 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 @@ -740,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/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 bc3ac0bb6..4cc13aae6 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, } } @@ -381,11 +385,20 @@ 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 { 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 @@ -1505,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/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/fake_username/anonymity_service.go b/internal/service/fake_username/anonymity_service.go new file mode 100644 index 000000000..b2d6937ea --- /dev/null +++ b/internal/service/fake_username/anonymity_service.go @@ -0,0 +1,71 @@ +/* + * 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 ( + "context" + + "github.com/apache/answer/internal/schema" + "github.com/apache/answer/pkg/checker" +) + +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} + } + + return +} 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..e6a9997f5 --- /dev/null +++ b/internal/service/fake_username/fake_username_generator.go @@ -0,0 +1,56 @@ +/* + * 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 ( + "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..723854055 --- /dev/null +++ b/internal/service/fake_username/fake_username_service.go @@ -0,0 +1,69 @@ +/* + * 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 ( + "context" + + "github.com/apache/answer/internal/entity" + "github.com/apache/answer/internal/service/user_anonymity_config" + "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 + userAnonymityConfigRepo user_anonymity_config.UserAnonymityConfigRepo +} + +func NewFakeUsernameService( + fakeUsernameGenerator *FakeUsernameGenerator, + fakeUsernameRepo FakeUsernameRepo, + userAnonymityConfigRepo user_anonymity_config.UserAnonymityConfigRepo, +) *FakeUsernameService { + return &FakeUsernameService{ + fakeUsernameGenerator: fakeUsernameGenerator, + fakeUsernameRepo: fakeUsernameRepo, + 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) 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/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 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/provider.go b/internal/service/provider.go index 3e43b0ae0..73ee167b7 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" @@ -64,6 +65,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 +123,10 @@ var ProviderSetService = wire.NewSet( activityqueue.NewService, user_notification_config.NewUserNotificationConfigService, notification.NewExternalNotificationService, + 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 3a7306342..26c26dcef 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" @@ -59,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) @@ -106,6 +108,8 @@ type QuestionCommon struct { activityQueueService activityqueue.Service revisionRepo revision.RevisionRepo siteInfoService siteinfo_common.SiteInfoCommonService + fakeUsernameService *fake_username.FakeUsernameService + anonymityService *fake_username.AnonymityService data *data.Data } @@ -122,6 +126,8 @@ func NewQuestionCommon(questionRepo QuestionRepo, activityQueueService activityqueue.Service, revisionRepo revision.RevisionRepo, siteInfoService siteinfo_common.SiteInfoCommonService, + fakeUsernameService *fake_username.FakeUsernameService, + anonymityService *fake_username.AnonymityService, data *data.Data, ) *QuestionCommon { return &QuestionCommon{ @@ -138,6 +144,8 @@ func NewQuestionCommon(questionRepo QuestionRepo, activityQueueService: activityQueueService, revisionRepo: revisionRepo, siteInfoService: siteInfoService, + fakeUsernameService: fakeUsernameService, + anonymityService: anonymityService, data: data, } } @@ -261,6 +269,19 @@ 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 + switch resp.PrivateLevel { + case entity.QuestionPrivateLevelPrivate: + // only the author can view private questions + if loginUserID != questionInfo.UserID { + return resp, errors.Forbidden(reason.QuestionNotFound) + } + case 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 { @@ -335,6 +356,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 } @@ -453,6 +498,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 @@ -464,6 +518,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) @@ -471,6 +526,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) @@ -490,6 +555,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 @@ -670,6 +758,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" { 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..f10a479e8 --- /dev/null +++ b/internal/service/user_anonymity_config/user_anonymity_config_service.go @@ -0,0 +1,84 @@ +/* + * 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 ( + "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, + } +} 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/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. 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/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..53cef61d7 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..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_user_status !== 'deleted' && + item.reply_username ? ( 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/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 ? ( { 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 +391,7 @@ const Ask = () => { title: formData.title.value, content: formData.content.value, tags: formData.tags.value, + private_level: privateLevel, }; if (isEdit) { @@ -518,6 +522,71 @@ 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 && ( <> { !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}{' '} 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..6ab2d48fb 100644 --- a/ui/src/pages/Users/Settings/components/Nav/index.tsx +++ b/ui/src/pages/Users/Settings/components/Nav/index.tsx @@ -47,6 +47,9 @@ const Index: FC = () => { {t('interface')} + + {t('anonymity')} + {data?.map((item) => { return ( { 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); +};