diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 705a8f4..760b57d 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -748,7 +748,7 @@ const docTemplate = `{ ], "properties": { "birthday": { - "type": "string" + "type": "integer" }, "name": { "type": "string" diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 7fed634..32e0e63 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -744,7 +744,7 @@ ], "properties": { "birthday": { - "type": "string" + "type": "integer" }, "name": { "type": "string" diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 9410109..86e2d09 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -157,7 +157,7 @@ definitions: models.RegistrationCompleteRequest: properties: birthday: - type: string + type: integer name: type: string username: diff --git a/backend/internal/controllers/profile.go b/backend/internal/controllers/profile.go index dd5ed2c..e417f92 100644 --- a/backend/internal/controllers/profile.go +++ b/backend/internal/controllers/profile.go @@ -120,8 +120,12 @@ func (ctrl *ProfileController) getProfileByUsername(c *gin.Context) { response, err := ctrl.ps.GetProfileByUsername(cinfo, username); if err != nil { if errors.Is(err, errs.ErrNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "Profile not found"}) + } else if errors.Is(err, errs.ErrUnauthorized) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Profile avaiable to authrorized users only"}) } else if errors.Is(err, errs.ErrForbidden) { c.JSON(http.StatusForbidden, gin.H{"error": "Access restricted by profile's privacy settings"}) + } else if errors.Is(err, errs.ErrGone) { + c.JSON(http.StatusGone, gin.H{"error": "Profile no longer available"}) } else { c.Status(http.StatusInternalServerError) } diff --git a/backend/internal/database/query.sql.go b/backend/internal/database/query.sql.go index aa4a174..8049fb7 100644 --- a/backend/internal/database/query.sql.go +++ b/backend/internal/database/query.sql.go @@ -43,7 +43,6 @@ type CheckProfileAccessRow struct { CaptchaRequired bool } -// XXX: recheck, was tired func (q *Queries) CheckProfileAccess(ctx context.Context, arg CheckProfileAccessParams) (CheckProfileAccessRow, error) { row := q.db.QueryRow(ctx, checkProfileAccess, arg.ID, arg.Requester) var i CheckProfileAccessRow @@ -99,6 +98,46 @@ func (q *Queries) CheckUserRegistrationAvailability(ctx context.Context, arg Che return i, err } +const checkWishAccessByGuid = `-- name: CheckWishAccessByGuid :one +SELECT EXISTS ( + SELECT 1 + FROM wishes w + JOIN wish_lists wl ON w.wish_list_id = wl.id + JOIN profiles p ON wl.profile_id = p.id + JOIN profile_settings ps ON ps.profile_id = p.id + JOIN users u ON p.user_id = u.id + LEFT JOIN banned_users bu ON u.id = bu.user_id + AND bu.pardoned = FALSE + AND (bu.expires_at IS NULL OR bu.expires_at > NOW()) + WHERE w.guid = ($1::text)::uuid + AND ps.hide_profile_details = FALSE + AND ( + $2::text != '' + OR ps.hide_for_unauthenticated IS FALSE + ) + AND ( + w.fulfilled = FALSE + OR ps.hide_fulfilled IS FALSE + ) + AND w.deleted = FALSE + AND wl.deleted = FALSE + AND u.deleted = FALSE + AND bu.id IS NULL -- Ensures owner is not banned +) +` + +type CheckWishAccessByGuidParams struct { + Guid string + Requester string +} + +func (q *Queries) CheckWishAccessByGuid(ctx context.Context, arg CheckWishAccessByGuidParams) (bool, error) { + row := q.db.QueryRow(ctx, checkWishAccessByGuid, arg.Guid, arg.Requester) + var exists bool + err := row.Scan(&exists) + return exists, err +} + const createBannedUser = `-- name: CreateBannedUser :one INSERT INTO banned_users(user_id, expires_at, reason, banned_by) VALUES ( $1, $2, $3, $4) RETURNING id, user_id, date, reason, expires_at, banned_by, pardoned, pardoned_by @@ -520,7 +559,6 @@ type GetProfileByUsernameWithPrivacyRow struct { ColorGrad string } -// FIXME: tweak backend code to handle privacy correctly func (q *Queries) GetProfileByUsernameWithPrivacy(ctx context.Context, searchedUsername string) (GetProfileByUsernameWithPrivacyRow, error) { row := q.db.QueryRow(ctx, getProfileByUsernameWithPrivacy, searchedUsername) var i GetProfileByUsernameWithPrivacyRow @@ -1016,77 +1054,6 @@ func (q *Queries) GetWishByGuid(ctx context.Context, guid string) (Wish, error) return i, err } -const getWishByGuidWithPrivacy = `-- name: GetWishByGuidWithPrivacy :one -SELECT - w.id, w.guid, w.wish_list_id, w.wish_list_guid, w.name, w.description, w.picture_url, w.stars, w.creation_date, w.fulfilled, w.fulfilled_date, w.deleted, - CASE - WHEN - ( - $1::text = u.username OR - NOT ps.hide_profile_details AND - NOT - ( - ps.hide_for_unauthenticated AND - $1::text = '' - ) AND - NOT wl.hidden - ) - THEN TRUE - ELSE FALSE - END AS access_allowed -FROM wishes w -JOIN wish_lists wl ON w.wish_list_id = wl.id -JOIN profiles p ON wl.profile_id = p.id -JOIN profile_settings ps ON ps.profile_id = p.id -JOIN users u ON p.user_id = u.id -WHERE - w.guid = ($2::text)::uuid AND - w.deleted IS FALSE -` - -type GetWishByGuidWithPrivacyParams struct { - Requester string - Guid string -} - -type GetWishByGuidWithPrivacyRow struct { - ID int64 - Guid pgtype.UUID - WishListID int64 - WishListGuid pgtype.UUID - Name string - Description string - PictureUrl string - Stars int16 - CreationDate pgtype.Timestamp - Fulfilled bool - FulfilledDate pgtype.Timestamp - Deleted bool - AccessAllowed bool -} - -// XXX: Obsolete, use the according access check query instead -func (q *Queries) GetWishByGuidWithPrivacy(ctx context.Context, arg GetWishByGuidWithPrivacyParams) (GetWishByGuidWithPrivacyRow, error) { - row := q.db.QueryRow(ctx, getWishByGuidWithPrivacy, arg.Requester, arg.Guid) - var i GetWishByGuidWithPrivacyRow - err := row.Scan( - &i.ID, - &i.Guid, - &i.WishListID, - &i.WishListGuid, - &i.Name, - &i.Description, - &i.PictureUrl, - &i.Stars, - &i.CreationDate, - &i.Fulfilled, - &i.FulfilledDate, - &i.Deleted, - &i.AccessAllowed, - ) - return i, err -} - const getWishlistByGuid = `-- name: GetWishlistByGuid :one SELECT id, guid, profile_id, hidden, name, icon_name, color, color_grad, deleted FROM wish_lists wl WHERE wl.guid = ($1::text)::uuid @@ -1240,7 +1207,6 @@ type GetWishlistsByUsernameWithPrivacyRow struct { AccessAllowed bool } -// XXX: Obsolete, use the according access check query instead func (q *Queries) GetWishlistsByUsernameWithPrivacy(ctx context.Context, arg GetWishlistsByUsernameWithPrivacyParams) ([]GetWishlistsByUsernameWithPrivacyRow, error) { rows, err := q.db.Query(ctx, getWishlistsByUsernameWithPrivacy, arg.Requester, arg.Username) if err != nil { diff --git a/backend/internal/errors/general.go b/backend/internal/errors/general.go index 2a58602..d55e5a6 100644 --- a/backend/internal/errors/general.go +++ b/backend/internal/errors/general.go @@ -27,4 +27,5 @@ var ( ErrForbidden = errors.New("Access is denied") ErrTooManyRequests = errors.New("Too many requests") ErrNotFound = errors.New("Resource not found") + ErrGone = errors.New("Resource no longer available") ) diff --git a/backend/internal/services/profile.go b/backend/internal/services/profile.go index 39b3c8b..21ea2fe 100644 --- a/backend/internal/services/profile.go +++ b/backend/internal/services/profile.go @@ -79,10 +79,7 @@ func (p *profileServiceImpl) GetProfileByUsername(cinfo dto.ClientInfo, username } defer helper.Rollback() - profileRow, err := db.TXQueries.GetProfileByUsernameWithPrivacy(db.CTX, database.GetProfileByUsernameWithPrivacyParams{ - Requester: cinfo.Username, - SearchedUsername: username, - }); if err != nil { + profileRow, err := db.TXQueries.GetProfileByUsername(db.CTX, username); if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errs.ErrNotFound } @@ -94,9 +91,33 @@ func (p *profileServiceImpl) GetProfileByUsername(cinfo dto.ClientInfo, username return nil, errs.ErrServerError } - if !*profileRow.AccessAllowed { + accessChecks, err := db.TXlessQueries.CheckProfileAccess(db.CTX, database.CheckProfileAccessParams{ + Requester: cinfo.Username, + ID: profileRow.ID, + }); if err != nil { + p.log.Error( + "Failed to check access for given profile", + zap.String("profile_owner_username", username), + zap.String("requester", cinfo.Username), + zap.Error(err)) + return nil, errs.ErrServerError + } + + if accessChecks.AuthRequired { + return nil, errs.ErrUnauthorized + } + if accessChecks.Hidden { return nil, errs.ErrForbidden } + if accessChecks.UserBanned { + return nil, errs.ErrGone + } + if accessChecks.UserUnavailable { + return nil, errs.ErrGone + } + if accessChecks.CaptchaRequired { + p.log.Warn("Captcha check is not implemented") + } profileDto := &dto.ProfileDto{ Name: profileRow.Name, diff --git a/sqlc/query.sql b/sqlc/query.sql index 8b5001c..e8300d3 100644 --- a/sqlc/query.sql +++ b/sqlc/query.sql @@ -281,7 +281,6 @@ JOIN users ON users.id = profiles.user_id WHERE users.username = $1; ;-- name: CheckProfileAccess :one --- XXX: recheck, was tired SELECT CASE WHEN u.deleted OR NOT u.verified THEN TRUE ELSE FALSE END AS user_unavailable, CASE WHEN EXISTS ( @@ -300,7 +299,6 @@ JOIN users u ON p.user_id = u.id WHERE p.id = $1; ;-- name: GetProfileByUsernameWithPrivacy :one --- FIXME: tweak backend code to handle privacy correctly SELECT u.username, p.name, @@ -402,7 +400,6 @@ JOIN users u ON u.id = p.user_id WHERE u.username = @username::text; -- name: GetWishlistsByUsernameWithPrivacy :many --- XXX: Obsolete, create according access check query instead SELECT wl.*, CASE @@ -496,32 +493,31 @@ FROM updated; SELECT * FROM wishes w WHERE w.guid = (@guid::text)::uuid; -;-- name: GetWishByGuidWithPrivacy :one --- XXX: Obsolete, create according access check query instead -SELECT - w.*, - CASE - WHEN - ( - @requester::text = u.username OR - NOT ps.hide_profile_details AND - NOT - ( - ps.hide_for_unauthenticated AND - @requester::text = '' - ) AND - NOT wl.hidden - ) - THEN TRUE - ELSE FALSE - END AS access_allowed -FROM wishes w -JOIN wish_lists wl ON w.wish_list_id = wl.id -JOIN profiles p ON wl.profile_id = p.id -JOIN profile_settings ps ON ps.profile_id = p.id -JOIN users u ON p.user_id = u.id -WHERE - w.guid = (@guid::text)::uuid AND - w.deleted IS FALSE; +;-- name: CheckWishAccessByGuid :one +SELECT EXISTS ( + SELECT 1 + FROM wishes w + JOIN wish_lists wl ON w.wish_list_id = wl.id + JOIN profiles p ON wl.profile_id = p.id + JOIN profile_settings ps ON ps.profile_id = p.id + JOIN users u ON p.user_id = u.id + LEFT JOIN banned_users bu ON u.id = bu.user_id + AND bu.pardoned = FALSE + AND (bu.expires_at IS NULL OR bu.expires_at > NOW()) + WHERE w.guid = (@guid::text)::uuid + AND ps.hide_profile_details = FALSE + AND ( + @requester::text != '' + OR ps.hide_for_unauthenticated IS FALSE + ) + AND ( + w.fulfilled = FALSE + OR ps.hide_fulfilled IS FALSE + ) + AND w.deleted = FALSE + AND wl.deleted = FALSE + AND u.deleted = FALSE + AND bu.id IS NULL -- Ensures owner is not banned +); --: }}} diff --git a/sqlc/schema.sql b/sqlc/schema.sql index 7dc87f8..ed04b80 100644 --- a/sqlc/schema.sql +++ b/sqlc/schema.sql @@ -121,63 +121,3 @@ CREATE TABLE IF NOT EXISTS "wishes" ( fulfilled_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted BOOLEAN NOT NULL DEFAULT FALSE ); - -CREATE OR REPLACE FUNCTION get_profile(requester_user_id BIGINT, target_profile_id BIGINT) -RETURNS JSONB AS $$ -DECLARE - profile_record profiles%ROWTYPE; - settings_record profile_settings%ROWTYPE; - is_owner BOOLEAN; - is_banned BOOLEAN; - is_deleted BOOLEAN; -BEGIN - -- Check if target user exists and is not deleted/banned - SELECT p.*, u.deleted INTO profile_record - FROM profiles p - JOIN users u ON p.user_id = u.id - WHERE p.id = target_profile_id; - - IF NOT FOUND THEN - RETURN NULL; -- Or raise an exception for "not found" - END IF; - - is_deleted := profile_record.deleted; -- From users table - IF is_deleted THEN - RETURN NULL; - END IF; - - -- Check if requester is banned (simplified; expand as needed) - SELECT EXISTS(SELECT 1 FROM banned_users WHERE user_id = requester_user_id AND pardoned = FALSE AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)) INTO is_banned; - IF is_banned THEN - RAISE EXCEPTION 'Requester is banned'; - END IF; - - -- Determine ownership - is_owner := (profile_record.user_id = requester_user_id); - - -- Fetch settings - SELECT * INTO settings_record FROM profile_settings WHERE profile_id = target_profile_id; - - -- Apply privacy: Hide for unauthenticated or based on settings - IF requester_user_id IS NULL AND settings_record.hide_for_unauthenticated THEN -- NULL requester means unauth - RETURN NULL; - END IF; - - IF NOT is_owner AND settings_record.hide_profile_details THEN - RETURN NULL; -- Or return minimal public data - END IF; - - -- Sanitize fields based on settings - IF NOT is_owner AND settings_record.hide_birthday THEN - profile_record.birthday := NULL; - END IF; - -- Add more field-level masking here (e.g., bio, avatar_url) - - -- Return as JSONB for easy app consumption - RETURN row_to_json(profile_record)::JSONB; -EXCEPTION - WHEN OTHERS THEN - RAISE NOTICE 'Access denied: %', SQLERRM; - RETURN NULL; -END; -$$ LANGUAGE plpgsql SECURITY DEFINER;