From 3a63a14c4d71eefc3a6a1e26eca13a68539b4493 Mon Sep 17 00:00:00 2001 From: Nikolai Papin Date: Sat, 2 Aug 2025 23:37:16 +0300 Subject: [PATCH 1/2] refactor: implemented privacy checks in the GetProfileByUsername method; refactor: reworked sql request for privacy-checking profile getter --- backend/internal/database/query.sql.go | 82 ++++++++++++++------------ backend/internal/services/profile.go | 20 +++++-- sqlc/query.sql | 48 ++++++++------- 3 files changed, 85 insertions(+), 65 deletions(-) diff --git a/backend/internal/database/query.sql.go b/backend/internal/database/query.sql.go index a85cf12..5d8c06a 100644 --- a/backend/internal/database/query.sql.go +++ b/backend/internal/database/query.sql.go @@ -345,59 +345,63 @@ func (q *Queries) GetProfileByUsername(ctx context.Context, username string) (Pr return i, err } -const getProfileByUsernameRestricted = `-- name: GetProfileByUsernameRestricted :one +const getProfileByUsernameWithPrivacy = `-- name: GetProfileByUsernameWithPrivacy :one SELECT - users.username, - profiles.name, - CASE - WHEN profile_settings.hide_birthday OR profile_settings.hide_profile_details THEN NULL - ELSE profiles.birthday - END AS birthday, - CASE - WHEN profile_settings.hide_profile_details THEN NULL - ELSE profiles.bio - END AS bio, - CASE - WHEN profile_settings.hide_profile_details THEN NULL - ELSE profiles.avatar_url - END AS avatar_url, - profiles.color, - profiles.color_grad, - profile_settings.hide_profile_details -FROM profiles -JOIN users ON users.id = profiles.user_id -JOIN profile_settings ON profiles.id = profile_settings.profile_id -WHERE users.username = $1 AND ($2 IS FALSE OR profile_settings.hide_for_unauthenticated IS FALSE) + u.username, + p.name, + p.bio, + p.avatar_url, + CASE WHEN ps.hide_birthday THEN NULL ELSE p.birthday END AS birthday, + p.color, + p.color_grad, + NOT ($1::text = '' AND ps.hide_for_unauthenticated) AS access_allowed +FROM + users AS u +JOIN profiles AS p ON u.id = p.user_id +JOIN profile_settings AS ps ON p.id = ps.profile_id +WHERE + u.username = $2::text + AND ( + $2::text = $1::text + OR + u.deleted IS FALSE + AND u.verified IS TRUE + AND NOT EXISTS ( + SELECT 1 + FROM banned_users + WHERE user_id = u.id + ) + ) ` -type GetProfileByUsernameRestrictedParams struct { - Username string - Column2 *bool +type GetProfileByUsernameWithPrivacyParams struct { + Requester string + SearchedUsername string } -type GetProfileByUsernameRestrictedRow struct { - Username string - Name string - Birthday pgtype.Timestamp - Bio *string - AvatarUrl *string - Color string - ColorGrad string - HideProfileDetails bool +type GetProfileByUsernameWithPrivacyRow struct { + Username string + Name string + Bio string + AvatarUrl string + Birthday pgtype.Timestamp + Color string + ColorGrad string + AccessAllowed *bool } -func (q *Queries) GetProfileByUsernameRestricted(ctx context.Context, arg GetProfileByUsernameRestrictedParams) (GetProfileByUsernameRestrictedRow, error) { - row := q.db.QueryRow(ctx, getProfileByUsernameRestricted, arg.Username, arg.Column2) - var i GetProfileByUsernameRestrictedRow +func (q *Queries) GetProfileByUsernameWithPrivacy(ctx context.Context, arg GetProfileByUsernameWithPrivacyParams) (GetProfileByUsernameWithPrivacyRow, error) { + row := q.db.QueryRow(ctx, getProfileByUsernameWithPrivacy, arg.Requester, arg.SearchedUsername) + var i GetProfileByUsernameWithPrivacyRow err := row.Scan( &i.Username, &i.Name, - &i.Birthday, &i.Bio, &i.AvatarUrl, + &i.Birthday, &i.Color, &i.ColorGrad, - &i.HideProfileDetails, + &i.AccessAllowed, ) return i, err } diff --git a/backend/internal/services/profile.go b/backend/internal/services/profile.go index c6698ab..c074915 100644 --- a/backend/internal/services/profile.go +++ b/backend/internal/services/profile.go @@ -70,7 +70,6 @@ func (p *profileServiceImpl) GetMyProfile(cinfo dto.ClientInfo) (*dto.ProfileDto return profileDto, nil } -// TODO: Profile privacy settings checks func (p *profileServiceImpl) GetProfileByUsername(cinfo dto.ClientInfo, username string) (*dto.ProfileDto, error) { helper, db, err := database.NewDbHelperTransaction(p.dbctx); if err != nil { p.log.Error( @@ -80,7 +79,10 @@ func (p *profileServiceImpl) GetProfileByUsername(cinfo dto.ClientInfo, username } defer helper.Rollback() - profile, err := db.TXQueries.GetProfileByUsername(db.CTX, username); if err != nil { + profileRow, err := db.TXQueries.GetProfileByUsernameWithPrivacy(db.CTX, database.GetProfileByUsernameWithPrivacyParams{ + Requester: cinfo.Username, + SearchedUsername: username, + }); if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errs.ErrNotFound } @@ -92,8 +94,18 @@ func (p *profileServiceImpl) GetProfileByUsername(cinfo dto.ClientInfo, username return nil, errs.ErrServerError } - profileDto := &dto.ProfileDto{} - mapspecial.MapProfileDto(profile, profileDto) + if !*profileRow.AccessAllowed { + return nil, errs.ErrForbidden + } + + profileDto := &dto.ProfileDto{ + Name: profileRow.Name, + Bio: profileRow.Bio, + AvatarUrl: &profileRow.AvatarUrl, + Birthday: profileRow.Birthday.Time.UnixMilli(), + Color: profileRow.Color, + ColorGrad: profileRow.ColorGrad, + } return profileDto, nil } diff --git a/sqlc/query.sql b/sqlc/query.sql index 4aefdf9..55c6b75 100644 --- a/sqlc/query.sql +++ b/sqlc/query.sql @@ -296,29 +296,33 @@ SELECT profiles.* FROM profiles JOIN users ON users.id = profiles.user_id WHERE users.username = $1; -;-- name: GetProfileByUsernameRestricted :one +;-- name: GetProfileByUsernameWithPrivacy :one SELECT - users.username, - profiles.name, - CASE - WHEN profile_settings.hide_birthday OR profile_settings.hide_profile_details THEN NULL - ELSE profiles.birthday - END AS birthday, - CASE - WHEN profile_settings.hide_profile_details THEN NULL - ELSE profiles.bio - END AS bio, - CASE - WHEN profile_settings.hide_profile_details THEN NULL - ELSE profiles.avatar_url - END AS avatar_url, - profiles.color, - profiles.color_grad, - profile_settings.hide_profile_details -FROM profiles -JOIN users ON users.id = profiles.user_id -JOIN profile_settings ON profiles.id = profile_settings.profile_id -WHERE users.username = $1 AND ($2 IS FALSE OR profile_settings.hide_for_unauthenticated IS FALSE); + u.username, + p.name, + p.bio, + p.avatar_url, + CASE WHEN ps.hide_birthday THEN NULL ELSE p.birthday END AS birthday, + p.color, + p.color_grad, + NOT (@requester::text = '' AND ps.hide_for_unauthenticated) AS access_allowed +FROM + users AS u +JOIN profiles AS p ON u.id = p.user_id +JOIN profile_settings AS ps ON p.id = ps.profile_id +WHERE + u.username = @searched_username::text + AND ( + @searched_username::text = @requester::text + OR + u.deleted IS FALSE + AND u.verified IS TRUE + AND NOT EXISTS ( + SELECT 1 + FROM banned_users + WHERE user_id = u.id + ) + ); ;-- name: GetProfilesRestricted :many SELECT -- 2.49.1 From b24ffcf3f8a00aff1340ae938a946967efa372f0 Mon Sep 17 00:00:00 2001 From: Nikolai Papin Date: Sun, 3 Aug 2025 00:22:01 +0300 Subject: [PATCH 2/2] feat: birthday validation integrated into profile --- backend/internal/dto/profile.go | 2 +- backend/internal/models/auth.go | 2 +- backend/internal/services/auth.go | 7 +++++++ backend/internal/services/profile.go | 1 - backend/internal/validation/custom.go | 17 +++++++++++++++++ 5 files changed, 26 insertions(+), 3 deletions(-) diff --git a/backend/internal/dto/profile.go b/backend/internal/dto/profile.go index bba4da5..46d5a26 100644 --- a/backend/internal/dto/profile.go +++ b/backend/internal/dto/profile.go @@ -30,7 +30,7 @@ type NewProfileDto struct { Name string `json:"name" binding:"required" validate:"name"` Bio string `json:"bio" validate:"bio"` AvatarUploadID string `json:"avatar_upload_id" validate:"omitempty,upload_id=avatar"` - Birthday int64 `json:"birthday"` + Birthday int64 `json:"birthday" validate:"birthday_unix_milli"` Color string `json:"color" validate:"color_hex"` ColorGrad string `json:"color_grad" validate:"color_hex"` } diff --git a/backend/internal/models/auth.go b/backend/internal/models/auth.go index 365f644..ec467e5 100644 --- a/backend/internal/models/auth.go +++ b/backend/internal/models/auth.go @@ -32,7 +32,7 @@ type RegistrationCompleteRequest struct { Username string `json:"username" binding:"required" validate:"username"` VerificationCode string `json:"verification_code" binding:"required" validate:"verification_code=reg"` Name string `json:"name" binding:"required" validate:"name"` - Birthday *string `json:"birthday"` + Birthday int64 `json:"birthday" validate:"birthday_unix_milli"` } type RegistrationCompleteResponse struct { diff --git a/backend/internal/services/auth.go b/backend/internal/services/auth.go index 04c884f..489faaa 100644 --- a/backend/internal/services/auth.go +++ b/backend/internal/services/auth.go @@ -35,6 +35,7 @@ import ( "github.com/google/uuid" "github.com/jackc/pgerrcode" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" "go.uber.org/zap" ) @@ -437,9 +438,15 @@ func (a *authServiceImpl) RegistrationComplete(cinfo dto.ClientInfo, request mod return nil, errs.ErrServerError } + birthdayTimestamp := pgtype.Timestamp { + Time: time.UnixMilli(request.Birthday), + Valid: true, + } + profile, err = db.TXQueries.CreateProfile(db.CTX, database.CreateProfileParams{ UserID: user.ID, Name: request.Name, + Birthday: birthdayTimestamp, }) if err != nil { diff --git a/backend/internal/services/profile.go b/backend/internal/services/profile.go index c074915..39b3c8b 100644 --- a/backend/internal/services/profile.go +++ b/backend/internal/services/profile.go @@ -126,7 +126,6 @@ func (p *profileServiceImpl) GetProfileSettings(cinfo dto.ClientInfo) (*dto.Prof return profileSettingsDto, nil } -// XXX: no validation for timestamps' allowed ranges func (p *profileServiceImpl) UpdateProfile(cinfo dto.ClientInfo, newProfile dto.NewProfileDto) (bool, error) { helper, db, err := database.NewDbHelperTransaction(p.dbctx); if err != nil { p.log.Error( diff --git a/backend/internal/validation/custom.go b/backend/internal/validation/custom.go index 06ec89c..a230b19 100644 --- a/backend/internal/validation/custom.go +++ b/backend/internal/validation/custom.go @@ -21,6 +21,7 @@ import ( "easywish/config" "fmt" "regexp" + "time" "github.com/go-playground/validator/v10" ) @@ -55,6 +56,22 @@ func GetCustomHandlers() []CustomValidatorHandler { return regexp.MustCompile(`^.{1,512}$`).MatchString(username) }}, + { + FieldName: "birthday_unix_milli", + Function: func(fl validator.FieldLevel) bool { + + timestamp := fl.Field().Int() + date := time.UnixMilli(timestamp) + currentDate := time.Now() + + age := currentDate.Year() - date.Year() + if currentDate.YearDay() < date.YearDay() { + age-- + } + + return age >= 0 && age <= 122 + }}, + { FieldName: "color_hex", Function: func(fl validator.FieldLevel) bool { -- 2.49.1