refactor: sql queries related to privacy-accounting;

chore: regenerated swagger;
feat: utilizing new 410 error when user is banned/unavailable/deleted
This commit is contained in:
2025-08-23 19:17:05 +03:00
parent dd2960a742
commit 3198612e16
9 changed files with 100 additions and 172 deletions

View File

@@ -748,7 +748,7 @@ const docTemplate = `{
], ],
"properties": { "properties": {
"birthday": { "birthday": {
"type": "string" "type": "integer"
}, },
"name": { "name": {
"type": "string" "type": "string"

View File

@@ -744,7 +744,7 @@
], ],
"properties": { "properties": {
"birthday": { "birthday": {
"type": "string" "type": "integer"
}, },
"name": { "name": {
"type": "string" "type": "string"

View File

@@ -157,7 +157,7 @@ definitions:
models.RegistrationCompleteRequest: models.RegistrationCompleteRequest:
properties: properties:
birthday: birthday:
type: string type: integer
name: name:
type: string type: string
username: username:

View File

@@ -120,8 +120,12 @@ func (ctrl *ProfileController) getProfileByUsername(c *gin.Context) {
response, err := ctrl.ps.GetProfileByUsername(cinfo, username); if err != nil { response, err := ctrl.ps.GetProfileByUsername(cinfo, username); if err != nil {
if errors.Is(err, errs.ErrNotFound) { if errors.Is(err, errs.ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "Profile not found"}) 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) { } else if errors.Is(err, errs.ErrForbidden) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access restricted by profile's privacy settings"}) 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 { } else {
c.Status(http.StatusInternalServerError) c.Status(http.StatusInternalServerError)
} }

View File

@@ -43,7 +43,6 @@ type CheckProfileAccessRow struct {
CaptchaRequired bool CaptchaRequired bool
} }
// XXX: recheck, was tired
func (q *Queries) CheckProfileAccess(ctx context.Context, arg CheckProfileAccessParams) (CheckProfileAccessRow, error) { func (q *Queries) CheckProfileAccess(ctx context.Context, arg CheckProfileAccessParams) (CheckProfileAccessRow, error) {
row := q.db.QueryRow(ctx, checkProfileAccess, arg.ID, arg.Requester) row := q.db.QueryRow(ctx, checkProfileAccess, arg.ID, arg.Requester)
var i CheckProfileAccessRow var i CheckProfileAccessRow
@@ -99,6 +98,46 @@ func (q *Queries) CheckUserRegistrationAvailability(ctx context.Context, arg Che
return i, err 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 const createBannedUser = `-- name: CreateBannedUser :one
INSERT INTO banned_users(user_id, expires_at, reason, banned_by) 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 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 ColorGrad string
} }
// FIXME: tweak backend code to handle privacy correctly
func (q *Queries) GetProfileByUsernameWithPrivacy(ctx context.Context, searchedUsername string) (GetProfileByUsernameWithPrivacyRow, error) { func (q *Queries) GetProfileByUsernameWithPrivacy(ctx context.Context, searchedUsername string) (GetProfileByUsernameWithPrivacyRow, error) {
row := q.db.QueryRow(ctx, getProfileByUsernameWithPrivacy, searchedUsername) row := q.db.QueryRow(ctx, getProfileByUsernameWithPrivacy, searchedUsername)
var i GetProfileByUsernameWithPrivacyRow var i GetProfileByUsernameWithPrivacyRow
@@ -1016,77 +1054,6 @@ func (q *Queries) GetWishByGuid(ctx context.Context, guid string) (Wish, error)
return i, err 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 const getWishlistByGuid = `-- name: GetWishlistByGuid :one
SELECT id, guid, profile_id, hidden, name, icon_name, color, color_grad, deleted FROM wish_lists wl SELECT id, guid, profile_id, hidden, name, icon_name, color, color_grad, deleted FROM wish_lists wl
WHERE wl.guid = ($1::text)::uuid WHERE wl.guid = ($1::text)::uuid
@@ -1240,7 +1207,6 @@ type GetWishlistsByUsernameWithPrivacyRow struct {
AccessAllowed bool AccessAllowed bool
} }
// XXX: Obsolete, use the according access check query instead
func (q *Queries) GetWishlistsByUsernameWithPrivacy(ctx context.Context, arg GetWishlistsByUsernameWithPrivacyParams) ([]GetWishlistsByUsernameWithPrivacyRow, error) { func (q *Queries) GetWishlistsByUsernameWithPrivacy(ctx context.Context, arg GetWishlistsByUsernameWithPrivacyParams) ([]GetWishlistsByUsernameWithPrivacyRow, error) {
rows, err := q.db.Query(ctx, getWishlistsByUsernameWithPrivacy, arg.Requester, arg.Username) rows, err := q.db.Query(ctx, getWishlistsByUsernameWithPrivacy, arg.Requester, arg.Username)
if err != nil { if err != nil {

View File

@@ -27,4 +27,5 @@ var (
ErrForbidden = errors.New("Access is denied") ErrForbidden = errors.New("Access is denied")
ErrTooManyRequests = errors.New("Too many requests") ErrTooManyRequests = errors.New("Too many requests")
ErrNotFound = errors.New("Resource not found") ErrNotFound = errors.New("Resource not found")
ErrGone = errors.New("Resource no longer available")
) )

View File

@@ -79,10 +79,7 @@ func (p *profileServiceImpl) GetProfileByUsername(cinfo dto.ClientInfo, username
} }
defer helper.Rollback() defer helper.Rollback()
profileRow, err := db.TXQueries.GetProfileByUsernameWithPrivacy(db.CTX, database.GetProfileByUsernameWithPrivacyParams{ profileRow, err := db.TXQueries.GetProfileByUsername(db.CTX, username); if err != nil {
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
} }
@@ -94,9 +91,33 @@ func (p *profileServiceImpl) GetProfileByUsername(cinfo dto.ClientInfo, username
return nil, errs.ErrServerError 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 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{ profileDto := &dto.ProfileDto{
Name: profileRow.Name, Name: profileRow.Name,

View File

@@ -281,7 +281,6 @@ JOIN users ON users.id = profiles.user_id
WHERE users.username = $1; WHERE users.username = $1;
;-- name: CheckProfileAccess :one ;-- name: CheckProfileAccess :one
-- XXX: recheck, was tired
SELECT SELECT
CASE WHEN u.deleted OR NOT u.verified THEN TRUE ELSE FALSE END AS user_unavailable, CASE WHEN u.deleted OR NOT u.verified THEN TRUE ELSE FALSE END AS user_unavailable,
CASE WHEN EXISTS ( CASE WHEN EXISTS (
@@ -300,7 +299,6 @@ JOIN users u ON p.user_id = u.id
WHERE p.id = $1; WHERE p.id = $1;
;-- name: GetProfileByUsernameWithPrivacy :one ;-- name: GetProfileByUsernameWithPrivacy :one
-- FIXME: tweak backend code to handle privacy correctly
SELECT SELECT
u.username, u.username,
p.name, p.name,
@@ -402,7 +400,6 @@ JOIN users u ON u.id = p.user_id
WHERE u.username = @username::text; WHERE u.username = @username::text;
-- name: GetWishlistsByUsernameWithPrivacy :many -- name: GetWishlistsByUsernameWithPrivacy :many
-- XXX: Obsolete, create according access check query instead
SELECT SELECT
wl.*, wl.*,
CASE CASE
@@ -496,32 +493,31 @@ FROM updated;
SELECT * FROM wishes w SELECT * FROM wishes w
WHERE w.guid = (@guid::text)::uuid; WHERE w.guid = (@guid::text)::uuid;
;-- name: GetWishByGuidWithPrivacy :one ;-- name: CheckWishAccessByGuid :one
-- XXX: Obsolete, create according access check query instead SELECT EXISTS (
SELECT SELECT 1
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 FROM wishes w
JOIN wish_lists wl ON w.wish_list_id = wl.id JOIN wish_lists wl ON w.wish_list_id = wl.id
JOIN profiles p ON wl.profile_id = p.id JOIN profiles p ON wl.profile_id = p.id
JOIN profile_settings ps ON ps.profile_id = p.id JOIN profile_settings ps ON ps.profile_id = p.id
JOIN users u ON p.user_id = u.id JOIN users u ON p.user_id = u.id
WHERE LEFT JOIN banned_users bu ON u.id = bu.user_id
w.guid = (@guid::text)::uuid AND AND bu.pardoned = FALSE
w.deleted IS 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
);
--: }}} --: }}}

View File

@@ -121,63 +121,3 @@ CREATE TABLE IF NOT EXISTS "wishes" (
fulfilled_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, fulfilled_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT FALSE 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;