From dd2960a7422c55497f3de5f57006da158ec9c8b3 Mon Sep 17 00:00:00 2001 From: Nikolai Papin Date: Sat, 16 Aug 2025 22:21:41 +0300 Subject: [PATCH] refactor: began refactoring access control in sql --- backend/internal/database/query.sql.go | 333 ++++++++++++++++++++++--- sqlc/query.sql | 104 ++++++-- sqlc/schema.sql | 60 +++++ 3 files changed, 443 insertions(+), 54 deletions(-) diff --git a/backend/internal/database/query.sql.go b/backend/internal/database/query.sql.go index f5dabea..aa4a174 100644 --- a/backend/internal/database/query.sql.go +++ b/backend/internal/database/query.sql.go @@ -11,6 +11,52 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const checkProfileAccess = `-- name: CheckProfileAccess :one +SELECT + CASE WHEN u.deleted OR NOT u.verified THEN TRUE ELSE FALSE END AS user_unavailable, + CASE WHEN EXISTS ( + SELECT 1 + FROM banned_users + WHERE user_id = u.id AND + pardoned IS FALSE AND + (expires_at IS NULL OR expires_at < CURRENT_TIMESTAMP) + ) THEN TRUE ELSE FALSE END AS user_banned, + CASE WHEN ps.hide_profile_details THEN TRUE ELSE FALSE END AS hidden, + CASE WHEN ps.hide_for_unauthenticated AND $2::text = '' THEN TRUE ELSE FALSE END AS auth_required, + CASE WHEN ps.captcha THEN TRUE ELSE FALSE END AS captcha_required +FROM profiles p +JOIN profile_settings ps ON ps.profile_id = p.id +JOIN users u ON p.user_id = u.id +WHERE p.id = $1 +` + +type CheckProfileAccessParams struct { + ID int64 + Requester string +} + +type CheckProfileAccessRow struct { + UserUnavailable bool + UserBanned bool + Hidden bool + AuthRequired bool + 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 + err := row.Scan( + &i.UserUnavailable, + &i.UserBanned, + &i.Hidden, + &i.AuthRequired, + &i.CaptchaRequired, + ) + return i, err +} + const checkUserRegistrationAvailability = `-- name: CheckUserRegistrationAvailability :one SELECT COUNT(CASE WHEN users.username = $1::text THEN 1 END) > 0 AS username_busy, @@ -253,6 +299,60 @@ func (q *Queries) CreateUser(ctx context.Context, username string) (User, error) return i, err } +const createWish = `-- name: CreateWish :one +INSERT INTO wishes( + wish_list_id, + wish_list_guid, + name, + description, + picture_url, + stars) +VALUES + ( + (SELECT id FROM wish_lists wl WHERE wl.guid = ($1::text)::uuid), + ($1::text)::uuid, + $2::text, + $3::text, + $4::text, + $5::smallint + ) +RETURNING id, guid, wish_list_id, wish_list_guid, name, description, picture_url, stars, creation_date, fulfilled, fulfilled_date, deleted +` + +type CreateWishParams struct { + WishListGuid string + Name string + Description string + PictureUrl string + Stars int16 +} + +func (q *Queries) CreateWish(ctx context.Context, arg CreateWishParams) (Wish, error) { + row := q.db.QueryRow(ctx, createWish, + arg.WishListGuid, + arg.Name, + arg.Description, + arg.PictureUrl, + arg.Stars, + ) + var i Wish + 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, + ) + return i, err +} + const createWishList = `-- name: CreateWishList :one INSERT INTO wish_lists(profile_id, hidden, name, icon_name, color, color_grad) VALUES ( @@ -401,47 +501,28 @@ SELECT 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 + p.color_grad 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 AND - pardoned IS FALSE AND - (expires_at IS NULL OR expires_at < CURRENT_TIMESTAMP) - ) - ) + u.username = $1::text ` -type GetProfileByUsernameWithPrivacyParams struct { - Requester string - SearchedUsername string -} - type GetProfileByUsernameWithPrivacyRow struct { - Username string - Name string - Bio string - AvatarUrl string - Birthday pgtype.Timestamp - Color string - ColorGrad string - AccessAllowed *bool + Username string + Name string + Bio string + AvatarUrl string + Birthday pgtype.Timestamp + Color string + ColorGrad string } -func (q *Queries) GetProfileByUsernameWithPrivacy(ctx context.Context, arg GetProfileByUsernameWithPrivacyParams) (GetProfileByUsernameWithPrivacyRow, error) { - row := q.db.QueryRow(ctx, getProfileByUsernameWithPrivacy, arg.Requester, arg.SearchedUsername) +// 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 err := row.Scan( &i.Username, @@ -451,7 +532,6 @@ func (q *Queries) GetProfileByUsernameWithPrivacy(ctx context.Context, arg GetPr &i.Birthday, &i.Color, &i.ColorGrad, - &i.AccessAllowed, ) return i, err } @@ -911,6 +991,102 @@ func (q *Queries) GetValidUserSessions(ctx context.Context, userID int64) ([]Ses return items, nil } +const getWishByGuid = `-- name: GetWishByGuid :one +SELECT id, guid, wish_list_id, wish_list_guid, name, description, picture_url, stars, creation_date, fulfilled, fulfilled_date, deleted FROM wishes w +WHERE w.guid = ($1::text)::uuid +` + +func (q *Queries) GetWishByGuid(ctx context.Context, guid string) (Wish, error) { + row := q.db.QueryRow(ctx, getWishByGuid, guid) + var i Wish + 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, + ) + 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 @@ -933,11 +1109,93 @@ func (q *Queries) GetWishlistByGuid(ctx context.Context, guid string) (WishList, return i, err } +const getWishlistsByUsername = `-- name: GetWishlistsByUsername :many +SELECT wl.id, guid, profile_id, hidden, wl.name, icon_name, wl.color, wl.color_grad, wl.deleted, p.id, user_id, p.name, bio, avatar_url, birthday, p.color, p.color_grad, u.id, username, verified, registration_date, role, u.deleted FROM wish_lists wl +JOIN profiles p ON p.id = wl.profile_id +JOIN users u ON u.id = p.user_id +WHERE u.username = $1::text +` + +type GetWishlistsByUsernameRow struct { + ID int64 + Guid pgtype.UUID + ProfileID int64 + Hidden bool + Name string + IconName string + Color string + ColorGrad string + Deleted bool + ID_2 int64 + UserID int64 + Name_2 string + Bio string + AvatarUrl string + Birthday pgtype.Timestamp + Color_2 string + ColorGrad_2 string + ID_3 int64 + Username string + Verified bool + RegistrationDate pgtype.Timestamp + Role int32 + Deleted_2 *bool +} + +func (q *Queries) GetWishlistsByUsername(ctx context.Context, username string) ([]GetWishlistsByUsernameRow, error) { + rows, err := q.db.Query(ctx, getWishlistsByUsername, username) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetWishlistsByUsernameRow + for rows.Next() { + var i GetWishlistsByUsernameRow + if err := rows.Scan( + &i.ID, + &i.Guid, + &i.ProfileID, + &i.Hidden, + &i.Name, + &i.IconName, + &i.Color, + &i.ColorGrad, + &i.Deleted, + &i.ID_2, + &i.UserID, + &i.Name_2, + &i.Bio, + &i.AvatarUrl, + &i.Birthday, + &i.Color_2, + &i.ColorGrad_2, + &i.ID_3, + &i.Username, + &i.Verified, + &i.RegistrationDate, + &i.Role, + &i.Deleted_2, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getWishlistsByUsernameWithPrivacy = `-- name: GetWishlistsByUsernameWithPrivacy :many SELECT wl.id, wl.guid, wl.profile_id, wl.hidden, wl.name, wl.icon_name, wl.color, wl.color_grad, wl.deleted, CASE - WHEN (ps.hide_profile_details OR ps.hide_for_unauthenticated) THEN FALSE + WHEN ( + ps.hide_profile_details OR ( + ps.hide_for_unauthenticated AND + $1::text = '' + ) + ) THEN FALSE ELSE TRUE END AS access_allowed FROM @@ -950,9 +1208,9 @@ JOIN users AS u ON p.user_id = u.id WHERE wl.deleted IS FALSE AND - u.username = $1::text AND + u.username = $2::text AND ( - u.username = $2::text OR + u.username = $1::text OR (u.verified IS TRUE AND NOT EXISTS ( SELECT 1 @@ -965,8 +1223,8 @@ WHERE ` type GetWishlistsByUsernameWithPrivacyParams struct { - Username string Requester string + Username string } type GetWishlistsByUsernameWithPrivacyRow struct { @@ -982,8 +1240,9 @@ 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.Username, arg.Requester) + rows, err := q.db.Query(ctx, getWishlistsByUsernameWithPrivacy, arg.Requester, arg.Username) if err != nil { return nil, err } diff --git a/sqlc/query.sql b/sqlc/query.sql index 88e3a25..8b5001c 100644 --- a/sqlc/query.sql +++ b/sqlc/query.sql @@ -280,7 +280,27 @@ SELECT profiles.* FROM profiles 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 ( + SELECT 1 + FROM banned_users + WHERE user_id = u.id AND + pardoned IS FALSE AND + (expires_at IS NULL OR expires_at < CURRENT_TIMESTAMP) + ) THEN TRUE ELSE FALSE END AS user_banned, + CASE WHEN ps.hide_profile_details THEN TRUE ELSE FALSE END AS hidden, + CASE WHEN ps.hide_for_unauthenticated AND @requester::text = '' THEN TRUE ELSE FALSE END AS auth_required, + CASE WHEN ps.captcha THEN TRUE ELSE FALSE END AS captcha_required +FROM profiles p +JOIN profile_settings ps ON ps.profile_id = p.id +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, @@ -288,27 +308,13 @@ SELECT 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 + p.color_grad 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 AND - pardoned IS FALSE AND - (expires_at IS NULL OR expires_at < CURRENT_TIMESTAMP) - ) - ); + u.username = @searched_username::text; ;-- name: GetProfilesRestricted :many SELECT @@ -389,11 +395,23 @@ WHERE wl.guid = (@guid::text)::uuid; SELECT * FROM wish_lists wl WHERE wl.guid = (@guid::text)::uuid; +;-- name: GetWishlistsByUsername :many +SELECT * FROM wish_lists wl +JOIN profiles p ON p.id = wl.profile_id +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 - WHEN (ps.hide_profile_details OR ps.hide_for_unauthenticated) THEN FALSE + WHEN ( + ps.hide_profile_details OR ( + ps.hide_for_unauthenticated AND + @requester::text = '' + ) + ) THEN FALSE ELSE TRUE END AS access_allowed FROM @@ -423,6 +441,25 @@ WHERE --: Wish Object {{{ +;-- name: CreateWish :one +INSERT INTO wishes( + wish_list_id, + wish_list_guid, + name, + description, + picture_url, + stars) +VALUES + ( + (SELECT id FROM wish_lists wl WHERE wl.guid = (@wish_list_guid::text)::uuid), + (@wish_list_guid::text)::uuid, + @name::text, + @description::text, + @picture_url::text, + @stars::smallint + ) +RETURNING *; + ;-- name: UpdateWishByGuid :exec UPDATE wishes w SET @@ -454,4 +491,37 @@ WITH updated AS ( SELECT COUNT(*) > 0 AS target_found FROM updated; + +;-- name: GetWishByGuid :one +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; + --: }}} diff --git a/sqlc/schema.sql b/sqlc/schema.sql index ed04b80..7dc87f8 100644 --- a/sqlc/schema.sql +++ b/sqlc/schema.sql @@ -121,3 +121,63 @@ 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;