Compare commits

3 Commits

Author SHA1 Message Date
3bcd8af100 Merge pull request 'Backend: finishing the first milestone' (#6) from feat-profile_service into main
Reviewed-on: #6
2025-08-03 00:25:04 +03:00
b24ffcf3f8 feat: birthday validation integrated into profile 2025-08-03 00:22:01 +03:00
3a63a14c4d refactor: implemented privacy checks in the GetProfileByUsername method;
refactor: reworked sql request for privacy-checking profile getter
2025-08-02 23:37:16 +03:00
7 changed files with 111 additions and 68 deletions

View File

@@ -345,59 +345,63 @@ func (q *Queries) GetProfileByUsername(ctx context.Context, username string) (Pr
return i, err return i, err
} }
const getProfileByUsernameRestricted = `-- name: GetProfileByUsernameRestricted :one const getProfileByUsernameWithPrivacy = `-- name: GetProfileByUsernameWithPrivacy :one
SELECT SELECT
users.username, u.username,
profiles.name, p.name,
CASE p.bio,
WHEN profile_settings.hide_birthday OR profile_settings.hide_profile_details THEN NULL p.avatar_url,
ELSE profiles.birthday CASE WHEN ps.hide_birthday THEN NULL ELSE p.birthday END AS birthday,
END AS birthday, p.color,
CASE p.color_grad,
WHEN profile_settings.hide_profile_details THEN NULL NOT ($1::text = '' AND ps.hide_for_unauthenticated) AS access_allowed
ELSE profiles.bio FROM
END AS bio, users AS u
CASE JOIN profiles AS p ON u.id = p.user_id
WHEN profile_settings.hide_profile_details THEN NULL JOIN profile_settings AS ps ON p.id = ps.profile_id
ELSE profiles.avatar_url WHERE
END AS avatar_url, u.username = $2::text
profiles.color, AND (
profiles.color_grad, $2::text = $1::text
profile_settings.hide_profile_details OR
FROM profiles u.deleted IS FALSE
JOIN users ON users.id = profiles.user_id AND u.verified IS TRUE
JOIN profile_settings ON profiles.id = profile_settings.profile_id AND NOT EXISTS (
WHERE users.username = $1 AND ($2 IS FALSE OR profile_settings.hide_for_unauthenticated IS FALSE) SELECT 1
FROM banned_users
WHERE user_id = u.id
)
)
` `
type GetProfileByUsernameRestrictedParams struct { type GetProfileByUsernameWithPrivacyParams struct {
Username string Requester string
Column2 *bool SearchedUsername string
} }
type GetProfileByUsernameRestrictedRow struct { type GetProfileByUsernameWithPrivacyRow struct {
Username string Username string
Name string Name string
Birthday pgtype.Timestamp Bio string
Bio *string AvatarUrl string
AvatarUrl *string Birthday pgtype.Timestamp
Color string Color string
ColorGrad string ColorGrad string
HideProfileDetails bool AccessAllowed *bool
} }
func (q *Queries) GetProfileByUsernameRestricted(ctx context.Context, arg GetProfileByUsernameRestrictedParams) (GetProfileByUsernameRestrictedRow, error) { func (q *Queries) GetProfileByUsernameWithPrivacy(ctx context.Context, arg GetProfileByUsernameWithPrivacyParams) (GetProfileByUsernameWithPrivacyRow, error) {
row := q.db.QueryRow(ctx, getProfileByUsernameRestricted, arg.Username, arg.Column2) row := q.db.QueryRow(ctx, getProfileByUsernameWithPrivacy, arg.Requester, arg.SearchedUsername)
var i GetProfileByUsernameRestrictedRow var i GetProfileByUsernameWithPrivacyRow
err := row.Scan( err := row.Scan(
&i.Username, &i.Username,
&i.Name, &i.Name,
&i.Birthday,
&i.Bio, &i.Bio,
&i.AvatarUrl, &i.AvatarUrl,
&i.Birthday,
&i.Color, &i.Color,
&i.ColorGrad, &i.ColorGrad,
&i.HideProfileDetails, &i.AccessAllowed,
) )
return i, err return i, err
} }

View File

@@ -30,7 +30,7 @@ type NewProfileDto struct {
Name string `json:"name" binding:"required" validate:"name"` Name string `json:"name" binding:"required" validate:"name"`
Bio string `json:"bio" validate:"bio"` Bio string `json:"bio" validate:"bio"`
AvatarUploadID string `json:"avatar_upload_id" validate:"omitempty,upload_id=avatar"` 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"` Color string `json:"color" validate:"color_hex"`
ColorGrad string `json:"color_grad" validate:"color_hex"` ColorGrad string `json:"color_grad" validate:"color_hex"`
} }

View File

@@ -32,7 +32,7 @@ type RegistrationCompleteRequest struct {
Username string `json:"username" binding:"required" validate:"username"` Username string `json:"username" binding:"required" validate:"username"`
VerificationCode string `json:"verification_code" binding:"required" validate:"verification_code=reg"` VerificationCode string `json:"verification_code" binding:"required" validate:"verification_code=reg"`
Name string `json:"name" binding:"required" validate:"name"` Name string `json:"name" binding:"required" validate:"name"`
Birthday *string `json:"birthday"` Birthday int64 `json:"birthday" validate:"birthday_unix_milli"`
} }
type RegistrationCompleteResponse struct { type RegistrationCompleteResponse struct {

View File

@@ -35,6 +35,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgerrcode" "github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -437,9 +438,15 @@ func (a *authServiceImpl) RegistrationComplete(cinfo dto.ClientInfo, request mod
return nil, errs.ErrServerError return nil, errs.ErrServerError
} }
birthdayTimestamp := pgtype.Timestamp {
Time: time.UnixMilli(request.Birthday),
Valid: true,
}
profile, err = db.TXQueries.CreateProfile(db.CTX, database.CreateProfileParams{ profile, err = db.TXQueries.CreateProfile(db.CTX, database.CreateProfileParams{
UserID: user.ID, UserID: user.ID,
Name: request.Name, Name: request.Name,
Birthday: birthdayTimestamp,
}) })
if err != nil { if err != nil {

View File

@@ -70,7 +70,6 @@ func (p *profileServiceImpl) GetMyProfile(cinfo dto.ClientInfo) (*dto.ProfileDto
return profileDto, nil return profileDto, nil
} }
// TODO: Profile privacy settings checks
func (p *profileServiceImpl) GetProfileByUsername(cinfo dto.ClientInfo, username string) (*dto.ProfileDto, error) { func (p *profileServiceImpl) GetProfileByUsername(cinfo dto.ClientInfo, username string) (*dto.ProfileDto, error) {
helper, db, err := database.NewDbHelperTransaction(p.dbctx); if err != nil { helper, db, err := database.NewDbHelperTransaction(p.dbctx); if err != nil {
p.log.Error( p.log.Error(
@@ -80,7 +79,10 @@ func (p *profileServiceImpl) GetProfileByUsername(cinfo dto.ClientInfo, username
} }
defer helper.Rollback() 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) { if errors.Is(err, pgx.ErrNoRows) {
return nil, errs.ErrNotFound return nil, errs.ErrNotFound
} }
@@ -92,8 +94,18 @@ func (p *profileServiceImpl) GetProfileByUsername(cinfo dto.ClientInfo, username
return nil, errs.ErrServerError return nil, errs.ErrServerError
} }
profileDto := &dto.ProfileDto{} if !*profileRow.AccessAllowed {
mapspecial.MapProfileDto(profile, profileDto) 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 return profileDto, nil
} }
@@ -114,7 +126,6 @@ func (p *profileServiceImpl) GetProfileSettings(cinfo dto.ClientInfo) (*dto.Prof
return profileSettingsDto, nil return profileSettingsDto, nil
} }
// XXX: no validation for timestamps' allowed ranges
func (p *profileServiceImpl) UpdateProfile(cinfo dto.ClientInfo, newProfile dto.NewProfileDto) (bool, error) { func (p *profileServiceImpl) UpdateProfile(cinfo dto.ClientInfo, newProfile dto.NewProfileDto) (bool, error) {
helper, db, err := database.NewDbHelperTransaction(p.dbctx); if err != nil { helper, db, err := database.NewDbHelperTransaction(p.dbctx); if err != nil {
p.log.Error( p.log.Error(

View File

@@ -21,6 +21,7 @@ import (
"easywish/config" "easywish/config"
"fmt" "fmt"
"regexp" "regexp"
"time"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
) )
@@ -55,6 +56,22 @@ func GetCustomHandlers() []CustomValidatorHandler {
return regexp.MustCompile(`^.{1,512}$`).MatchString(username) 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", FieldName: "color_hex",
Function: func(fl validator.FieldLevel) bool { Function: func(fl validator.FieldLevel) bool {

View File

@@ -296,29 +296,33 @@ SELECT profiles.* FROM profiles
JOIN users ON users.id = profiles.user_id JOIN users ON users.id = profiles.user_id
WHERE users.username = $1; WHERE users.username = $1;
;-- name: GetProfileByUsernameRestricted :one ;-- name: GetProfileByUsernameWithPrivacy :one
SELECT SELECT
users.username, u.username,
profiles.name, p.name,
CASE p.bio,
WHEN profile_settings.hide_birthday OR profile_settings.hide_profile_details THEN NULL p.avatar_url,
ELSE profiles.birthday CASE WHEN ps.hide_birthday THEN NULL ELSE p.birthday END AS birthday,
END AS birthday, p.color,
CASE p.color_grad,
WHEN profile_settings.hide_profile_details THEN NULL NOT (@requester::text = '' AND ps.hide_for_unauthenticated) AS access_allowed
ELSE profiles.bio FROM
END AS bio, users AS u
CASE JOIN profiles AS p ON u.id = p.user_id
WHEN profile_settings.hide_profile_details THEN NULL JOIN profile_settings AS ps ON p.id = ps.profile_id
ELSE profiles.avatar_url WHERE
END AS avatar_url, u.username = @searched_username::text
profiles.color, AND (
profiles.color_grad, @searched_username::text = @requester::text
profile_settings.hide_profile_details OR
FROM profiles u.deleted IS FALSE
JOIN users ON users.id = profiles.user_id AND u.verified IS TRUE
JOIN profile_settings ON profiles.id = profile_settings.profile_id AND NOT EXISTS (
WHERE users.username = $1 AND ($2 IS FALSE OR profile_settings.hide_for_unauthenticated IS FALSE); SELECT 1
FROM banned_users
WHERE user_id = u.id
)
);
;-- name: GetProfilesRestricted :many ;-- name: GetProfilesRestricted :many
SELECT SELECT