18 Commits

Author SHA1 Message Date
4b40a05e2d refactor: adjustments to variable namings 2025-09-11 14:26:40 +03:00
f81e4eaa47 feat: NewWishListService constructor implemented 2025-09-01 18:47:30 +03:00
14bad8e7ef feat: Implemented create wish method for wish list service 2025-09-01 18:46:08 +03:00
3198612e16 refactor: sql queries related to privacy-accounting;
chore: regenerated swagger;
feat: utilizing new 410 error when user is banned/unavailable/deleted
2025-08-23 19:17:05 +03:00
dd2960a742 refactor: began refactoring access control in sql 2025-08-16 22:21:41 +03:00
c7a440e38f feat: database method to move wish to another list 2025-08-15 14:34:28 +03:00
d12162fc3b feat: mapper function for wish dto;
refactor: made guid foreign key for wish object for more ease of use
2025-08-15 13:53:02 +03:00
711b1ad5d1 feat: mapper function for wishlist dto;
refactor: made database fields for wishlist object not null
2025-08-13 21:22:18 +03:00
af69c4fe07 feat: initialized wishlist service 2025-08-12 23:53:19 +03:00
5eb90b18d5 feat: sorting and filtering enums 2025-08-10 23:54:16 +03:00
bd90fb339f feat: mapstructure tags for wishlist service dtos 2025-08-07 14:37:11 +03:00
e4c879db36 feat: dtos for wishList service;
feat: validator for guid;
feat: created interface for wishlist service
2025-08-06 17:36:31 +03:00
6cb64d5f03 Merge branch 'feat-sql' into ml2 2025-08-04 21:26:40 +03:00
d7d18f1284 fix: corrected redis logic to prevent temporary lock-outs on failed database transactions;
fix: ChangePassword transaction isolation;
chore: highlighted issues
2025-08-04 21:17:06 +03:00
b1125d3f6a feat: implement wish list and wish features including creation, retrieval, and updates;
fix: modify ban logic to respect expiration timestamps and pardon flags;
refactor: change boolean fields to non-nullable in models and use COALESCE for optional updates in SQL
2025-08-04 20:26:51 +03:00
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
23 changed files with 1311 additions and 128 deletions

View File

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

View File

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

View File

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

View File

@@ -52,6 +52,7 @@ require (
github.com/minio/crc64nvme v1.0.2 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/minio/minio-go/v7 v7.0.95 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect

View File

@@ -99,6 +99,8 @@ github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYEvYU=
github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=

View File

@@ -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)
}

View File

@@ -15,7 +15,7 @@ type BannedUser struct {
Reason *string
ExpiresAt pgtype.Timestamp
BannedBy *string
Pardoned *bool
Pardoned bool
PardonedBy *string
}
@@ -25,8 +25,8 @@ type ConfirmationCode struct {
CodeType int32
CodeHash string
ExpiresAt pgtype.Timestamp
Used *bool
Deleted *bool
Used bool
Deleted bool
}
type LoginInformation struct {
@@ -78,8 +78,35 @@ type Session struct {
type User struct {
ID int64
Username string
Verified *bool
Verified bool
RegistrationDate pgtype.Timestamp
Role int32
Deleted *bool
}
type Wish 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
}
type WishList struct {
ID int64
Guid pgtype.UUID
ProfileID int64
Hidden bool
Name string
IconName string
Color string
ColorGrad string
Deleted bool
}

View File

@@ -11,6 +11,51 @@ 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
}
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,
@@ -53,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
@@ -253,6 +338,108 @@ 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 (
(SELECT p.id FROM profiles AS p
JOIN users AS u ON u.id = p.user_id
WHERE u.username = $1::text),
$2::boolean,
$3::text,
$4::text,
$5::text,
$6::text
)
RETURNING id, guid, profile_id, hidden, name, icon_name, color, color_grad, deleted
`
type CreateWishListParams struct {
Username string
Hidden bool
Name string
IconName string
Color string
ColorGrad string
}
func (q *Queries) CreateWishList(ctx context.Context, arg CreateWishListParams) (WishList, error) {
row := q.db.QueryRow(ctx, createWishList,
arg.Username,
arg.Hidden,
arg.Name,
arg.IconName,
arg.Color,
arg.ColorGrad,
)
var i WishList
err := row.Scan(
&i.ID,
&i.Guid,
&i.ProfileID,
&i.Hidden,
&i.Name,
&i.IconName,
&i.Color,
&i.ColorGrad,
&i.Deleted,
)
return i, err
}
const deleteUnverifiedAccountsHavingUsernameOrEmail = `-- name: DeleteUnverifiedAccountsHavingUsernameOrEmail :one
WITH deleted_rows AS (
DELETE FROM users
@@ -345,59 +532,44 @@ 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
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 = $1::text
`
type GetProfileByUsernameRestrictedParams struct {
Username string
Column2 *bool
}
type GetProfileByUsernameRestrictedRow struct {
type GetProfileByUsernameWithPrivacyRow struct {
Username string
Name string
Bio string
AvatarUrl string
Birthday pgtype.Timestamp
Bio *string
AvatarUrl *string
Color string
ColorGrad string
HideProfileDetails 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, searchedUsername string) (GetProfileByUsernameWithPrivacyRow, error) {
row := q.db.QueryRow(ctx, getProfileByUsernameWithPrivacy, 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,
)
return i, err
}
@@ -722,11 +894,11 @@ type GetValidConfirmationCodesByUsernameRow struct {
CodeType int32
CodeHash string
ExpiresAt pgtype.Timestamp
Used *bool
Deleted *bool
Used bool
Deleted bool
ID_2 int64
Username string
Verified *bool
Verified bool
RegistrationDate pgtype.Timestamp
Role int32
Deleted_2 *bool
@@ -773,12 +945,17 @@ SELECT
linfo.totp_encrypted
FROM users
JOIN login_informations AS linfo ON users.id = linfo.user_id
LEFT JOIN banned_users AS banned ON users.id = banned.user_id
WHERE
users.username = $1 AND
users.verified IS TRUE AND -- Verified
users.deleted IS FALSE AND -- Not deleted
banned.user_id IS NULL AND -- Not banned
NOT EXISTS (
SELECT 1
FROM banned_users
WHERE user_id = users.id AND
pardoned IS FALSE AND
(expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)
) AND -- Not banned
linfo.password_hash = crypt($2::text, linfo.password_hash)
`
@@ -790,7 +967,7 @@ type GetValidUserByLoginCredentialsParams struct {
type GetValidUserByLoginCredentialsRow struct {
ID int64
Username string
Verified *bool
Verified bool
RegistrationDate pgtype.Timestamp
Role int32
Deleted *bool
@@ -852,6 +1029,265 @@ 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 getWishListOwnerByGuid = `-- name: GetWishListOwnerByGuid :one
SELECT u.id, u.username, u.verified, u.registration_date, u.role, u.deleted
FROM wish_lists wl
JOIN profiles p ON wl.profile_id = p.id
JOIN users u ON p.user_id = u.id
WHERE wl.guid = ($1::text)::uuid
`
func (q *Queries) GetWishListOwnerByGuid(ctx context.Context, guid string) (User, error) {
row := q.db.QueryRow(ctx, getWishListOwnerByGuid, guid)
var i User
err := row.Scan(
&i.ID,
&i.Username,
&i.Verified,
&i.RegistrationDate,
&i.Role,
&i.Deleted,
)
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
`
func (q *Queries) GetWishlistByGuid(ctx context.Context, guid string) (WishList, error) {
row := q.db.QueryRow(ctx, getWishlistByGuid, guid)
var i WishList
err := row.Scan(
&i.ID,
&i.Guid,
&i.ProfileID,
&i.Hidden,
&i.Name,
&i.IconName,
&i.Color,
&i.ColorGrad,
&i.Deleted,
)
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 AND
$1::text = ''
)
) THEN FALSE
ELSE TRUE
END AS access_allowed
FROM
wish_lists wl
JOIN
profiles AS p ON wl.profile_id = p.id
JOIN
profile_settings AS ps ON ps.profile_id = p.id
JOIN
users AS u ON p.user_id = u.id
WHERE
wl.deleted IS FALSE AND
u.username = $2::text AND
(
u.username = $1::text OR
(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)
))
)
`
type GetWishlistsByUsernameWithPrivacyParams struct {
Requester string
Username string
}
type GetWishlistsByUsernameWithPrivacyRow struct {
ID int64
Guid pgtype.UUID
ProfileID int64
Hidden bool
Name string
IconName string
Color string
ColorGrad string
Deleted bool
AccessAllowed bool
}
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 {
return nil, err
}
defer rows.Close()
var items []GetWishlistsByUsernameWithPrivacyRow
for rows.Next() {
var i GetWishlistsByUsernameWithPrivacyRow
if err := rows.Scan(
&i.ID,
&i.Guid,
&i.ProfileID,
&i.Hidden,
&i.Name,
&i.IconName,
&i.Color,
&i.ColorGrad,
&i.Deleted,
&i.AccessAllowed,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const moveWishToWishListWithGuid = `-- name: MoveWishToWishListWithGuid :one
WITH updated AS (
UPDATE wishes w
SET
wish_list_id = wl.id,
wish_list_guid = ($1::text)::uuid
FROM wish_lists wl
WHERE
wl.guid = ($1::text)::uuid AND
wl.profile_id = ( -- Make sure the wish is not moved to another profile
SELECT profile_id
FROM wish_lists
WHERE wish_lists.id = w.wish_list_id
)
RETURNING w.id
)
SELECT
COUNT(*) > 0 AS target_found
FROM updated
`
func (q *Queries) MoveWishToWishListWithGuid(ctx context.Context, wishListGuid string) (bool, error) {
row := q.db.QueryRow(ctx, moveWishToWishListWithGuid, wishListGuid)
var target_found bool
err := row.Scan(&target_found)
return target_found, err
}
const pruneExpiredConfirmationCodes = `-- name: PruneExpiredConfirmationCodes :exec
DELETE FROM confirmation_codes
WHERE expires_at < CURRENT_TIMESTAMP
@@ -916,7 +1352,7 @@ type UpdateBannedUserParams struct {
Reason *string
ExpiresAt pgtype.Timestamp
BannedBy *string
Pardoned *bool
Pardoned bool
PardonedBy *string
}
@@ -942,8 +1378,8 @@ WHERE id = $1
type UpdateConfirmationCodeParams struct {
ID int64
Used *bool
Deleted *bool
Used bool
Deleted bool
}
func (q *Queries) UpdateConfirmationCode(ctx context.Context, arg UpdateConfirmationCodeParams) error {
@@ -1108,7 +1544,7 @@ WHERE id = $1
type UpdateUserParams struct {
ID int64
Verified *bool
Verified bool
Deleted *bool
}
@@ -1119,13 +1555,15 @@ func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) error {
const updateUserByUsername = `-- name: UpdateUserByUsername :exec
UPDATE users
SET verified = $2, deleted = $3
SET
verified = COALESCE($2, verified),
deleted = COALESCE($3, deleted)
WHERE username = $1
`
type UpdateUserByUsernameParams struct {
Username string
Verified *bool
Verified bool
Deleted *bool
}
@@ -1133,3 +1571,76 @@ func (q *Queries) UpdateUserByUsername(ctx context.Context, arg UpdateUserByUser
_, err := q.db.Exec(ctx, updateUserByUsername, arg.Username, arg.Verified, arg.Deleted)
return err
}
const updateWishByGuid = `-- name: UpdateWishByGuid :exec
UPDATE wishes w
SET
name = COALESCE($1::text, w.name),
description = COALESCE($2::text, w.description),
picture_url = COALESCE($3::text, w.picture_url),
stars = COALESCE($4::smallint, w.stars),
fulfilled = COALESCE($5::boolean, w.fulfilled),
fulfilled_date = COALESCE($6::timestamp, w.fulfilled_date),
deleted = COALESCE($7::boolean, w.deleted)
WHERE w.guid = ($8::text)::uuid
`
type UpdateWishByGuidParams struct {
Name string
Description string
PictureUrl string
Stars int16
Fulfilled bool
FulfilledDate pgtype.Timestamp
Deleted bool
Guid string
}
func (q *Queries) UpdateWishByGuid(ctx context.Context, arg UpdateWishByGuidParams) error {
_, err := q.db.Exec(ctx, updateWishByGuid,
arg.Name,
arg.Description,
arg.PictureUrl,
arg.Stars,
arg.Fulfilled,
arg.FulfilledDate,
arg.Deleted,
arg.Guid,
)
return err
}
const updateWishListByGuid = `-- name: UpdateWishListByGuid :exec
UPDATE wish_lists wl
SET
hidden = COALESCE($1::boolean, wl.hidden),
name = COALESCE($2::text, wl.name),
icon_name = COALESCE($3::text, wl.icon_name),
color = COALESCE($4::text, wl.color),
color_grad = COALESCE($5::text, wl.color_grad),
deleted = COALESCE($6::boolean, wl.deleted)
WHERE wl.guid = ($7::text)::uuid
`
type UpdateWishListByGuidParams struct {
Hidden bool
Name string
IconName string
Color string
ColorGrad string
Deleted bool
Guid string
}
func (q *Queries) UpdateWishListByGuid(ctx context.Context, arg UpdateWishListByGuidParams) error {
_, err := q.db.Exec(ctx, updateWishListByGuid,
arg.Hidden,
arg.Name,
arg.IconName,
arg.Color,
arg.ColorGrad,
arg.Deleted,
arg.Guid,
)
return err
}

View File

@@ -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"`
}

View File

@@ -0,0 +1,55 @@
// Copyright (c) 2025 Nikolai Papin
//
// This file is part of Easywish
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package dto
type WishListDto struct {
Guid string `json:"guid" mapstructure:"guid"`
Name string `json:"name" mapstructure:"name"`
Hidden bool `json:"hidden" mapstructure:"hidden"`
IconName string `json:"icon_name" mapstructure:"icon_name"`
Color string `json:"color" mapstructure:"color"`
ColorGrad string `json:"color_grad" mapstructure:"color_grad"`
}
type NewWishListDto struct {
Name string `json:"name" mapstructure:"name" binding:"required" validate:"max=32"`
Hidden bool `json:"hidden" mapstructure:"hidden"`
IconName string `json:"icon_name" mapstructure:"icon_name" validate:"omitempty,max=64"`
Color string `json:"color" mapstructure:"color" validate:"omitempty,color_hex"`
ColorGrad string `json:"color_grad" mapstructure:"color_grad" validate:"omitempty,color_hex"`
}
type WishDto struct {
Guid string `json:"guid" mapstructure:"guid"`
WishListGuid string `json:"wish_list_guid" mapstructure:"wish_list_guid"`
Name string `json:"name" mapstructure:"name"`
Description string `json:"description" mapstructure:"description"`
PictureUrl string `json:"picture_url" mapstructure:"picture_url"`
Stars int `json:"stars" mapstructure:"stars"`
CreationDate int64 `json:"creation_date" mapstructure:"creation_date"`
Fulfilled bool `json:"fulfilled" mapstructure:"fulfilled"`
FulfilledDate int64 `json:"fulfilled_date" mapstructure:"fulfilled_date"`
}
type NewWishDto struct {
WishListGuid string `json:"wish_list_guid" mapstructure:"wish_list_guid" binding:"required" validate:"guid"`
Name string `json:"name" mapstructure:"name" binding:"required" validate:"max=64"`
Description string `json:"description" mapstructure:"description" validate:"omitempty,max=1000"`
PictureUploadID string `json:"picture_upload_id" mapstructure:"picture_upload_id" validate:"omitempty,upload_id=image"`
Stars int `json:"stars" mapstructure:"stars" validate:"min=1,max=5"`
}

View File

@@ -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")
)

View File

@@ -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 {

View File

@@ -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"
)
@@ -347,6 +348,14 @@ func (a *authServiceImpl) RegistrationBegin(request models.RegistrationBeginRequ
a.log.Error(
"Failed to commit transaction",
zap.Error(err))
redisErr := a.redis.Del(context.TODO(), fmt.Sprintf("email::%s::registration_in_progress", request.Email))
if redisErr != nil {
a.log.Error(
"Failed to delete cooldown redis key while rolling back RegistrationBegin",
zap.Error(redisErr.Err()))
}
return false, errs.ErrServerError
}
@@ -413,7 +422,7 @@ func (a *authServiceImpl) RegistrationComplete(cinfo dto.ClientInfo, request mod
err = db.TXQueries.UpdateConfirmationCode(db.CTX, database.UpdateConfirmationCodeParams{
ID: confirmationCode.ID,
Used: utils.NewPointer(true),
Used: true,
})
if err != nil {
@@ -427,7 +436,7 @@ func (a *authServiceImpl) RegistrationComplete(cinfo dto.ClientInfo, request mod
err = db.TXQueries.UpdateUser(db.CTX, database.UpdateUserParams{
ID: user.ID,
Verified: utils.NewPointer(true),
Verified: true,
})
if err != nil {
@@ -437,9 +446,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 {
@@ -801,6 +816,14 @@ func (a *authServiceImpl) PasswordResetBegin(request models.PasswordResetBeginRe
a.log.Error(
"Failed to commit transaction",
zap.Error(err))
redisErr := a.redis.Del(context.TODO(), fmt.Sprintf("email::%s::reset_cooldown", request.Email))
if redisErr != nil {
a.log.Error(
"Failed to delete cooldown redis key while rolling back PasswordResetBegin",
zap.Error(redisErr.Err()))
}
return false, errs.ErrServerError
}
@@ -857,7 +880,7 @@ func (a *authServiceImpl) PasswordResetComplete(request models.PasswordResetComp
if err = db.TXQueries.UpdateConfirmationCode(db.CTX, database.UpdateConfirmationCodeParams{
ID: resetCode.ID,
Used: utils.NewPointer(true),
Used: true,
}); err != nil {
a.log.Error(
"Failed to invalidate password reset code upon use",
@@ -897,6 +920,7 @@ func (a *authServiceImpl) PasswordResetComplete(request models.PasswordResetComp
}
}
// FIXME: grab client info
session, err = db.TXQueries.CreateSession(db.CTX, database.CreateSessionParams{
UserID: user.ID,
Name: utils.NewPointer("First device"),
@@ -940,6 +964,7 @@ func (a *authServiceImpl) PasswordResetComplete(request models.PasswordResetComp
return &response, nil
}
// XXX: Mechanism for loging out existing sessions currently does not exist
func (a *authServiceImpl) ChangePassword(request models.ChangePasswordRequest, uinfo dto.ClientInfo) (bool, error) {
var err error
@@ -974,7 +999,7 @@ func (a *authServiceImpl) ChangePassword(request models.ChangePasswordRequest, u
return false, errs.ErrServerError
}
err = db.TXlessQueries.UpdateLoginInformationByUsername(db.CTX, database.UpdateLoginInformationByUsernameParams{
err = db.TXQueries.UpdateLoginInformationByUsername(db.CTX, database.UpdateLoginInformationByUsernameParams{
Username: uinfo.Username,
PasswordHash: newPasswordHash,
}); if err != nil {

View File

@@ -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,7 @@ 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.GetProfileByUsername(db.CTX, username); if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, errs.ErrNotFound
}
@@ -92,8 +91,42 @@ func (p *profileServiceImpl) GetProfileByUsername(cinfo dto.ClientInfo, username
return nil, errs.ErrServerError
}
profileDto := &dto.ProfileDto{}
mapspecial.MapProfileDto(profile, profileDto)
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,
Bio: profileRow.Bio,
AvatarUrl: &profileRow.AvatarUrl,
Birthday: profileRow.Birthday.Time.UnixMilli(),
Color: profileRow.Color,
ColorGrad: profileRow.ColorGrad,
}
return profileDto, nil
}
@@ -114,7 +147,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(

View File

@@ -48,7 +48,7 @@ type s3ServiceImpl struct {
imagePolicy minio.PostPolicy
}
func NewUploadService(_minio *minio.Client, _log *zap.Logger) S3Service {
func NewS3Service(_minio *minio.Client, _log *zap.Logger) S3Service {
service := s3ServiceImpl{
minio: _minio,
log: _log,

View File

@@ -23,9 +23,10 @@ import (
var Module = fx.Module("services",
fx.Provide(
NewUploadService,
NewS3Service,
NewSmtpService,
NewAuthService,
NewProfileService,
NewWishListService,
),
)

View File

@@ -0,0 +1,228 @@
// Copyright (c) 2025 Nikolai Papin
//
// This file is part of Easywish
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package services
import (
"easywish/internal/database"
"easywish/internal/dto"
errs "easywish/internal/errors"
"easywish/internal/utils"
"easywish/internal/utils/enums"
mapspecial "easywish/internal/utils/mapSpecial"
"errors"
"github.com/go-redis/redis/v8"
"github.com/jackc/pgx/v5"
"go.uber.org/zap"
)
type wishListServiceImpl struct {
log *zap.Logger
dbctx database.DbContext
redis *redis.Client
s3 S3Service
}
type WishListService interface {
CreateWishList(cinfo dto.ClientInfo, object dto.NewWishListDto) (*dto.WishListDto, error)
UpdateWishListByGuid(cinfo dto.ClientInfo, guid string, object dto.NewWishDto) (bool, error)
DeleteWishListByGuid(cinfo dto.ClientInfo, guid string) (bool, error)
GetWishListByGuid(cinfo dto.ClientInfo, guid string) (*dto.WishListDto, error)
GetUserWishListsPaginated(cinfo dto.ClientInfo, amount int, page int, sorting enums.Sorting, sortOrder enums.SortOrder) (*[]dto.WishListDto, error)
CreateWish(cinfo dto.ClientInfo, object dto.NewWishDto) (*dto.WishDto, error)
UpdateWish(cinfo dto.ClientInfo, guid string, object dto.NewWishDto) (bool, error)
MoveWishToWishList(cinfo dto.ClientInfo, wishGuid string, wishListGuid string) (bool, error)
GetWishByGuid(cinfo dto.ClientInfo, guid string) (*dto.WishDto, error)
GetWishesByWishListGuidPaginated(cinfo dto.ClientInfo, guid string, amount int, page int, sorting enums.Sorting, sortOrder enums.SortOrder) (*[]dto.WishDto, error)
}
func NewWishListService(_log *zap.Logger, _dbctx database.DbContext, _redis *redis.Client, _s3 S3Service) WishListService {
return wishListServiceImpl{
log: _log,
dbctx: _dbctx,
redis: _redis,
s3: _s3,
}
}
func (w wishListServiceImpl) CreateWish(cinfo dto.ClientInfo, object dto.NewWishDto) (*dto.WishDto, error) {
helper, db, err := database.NewDbHelperTransaction(w.dbctx); if err != nil {
w.log.Error(
"Failed to open transaction",
zap.Error(err))
return nil, errs.ErrServerError
}
defer helper.Rollback()
// Check if wish list exists
wishList, err := db.TXQueries.GetWishlistByGuid(db.CTX, object.WishListGuid); if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
w.log.Warn(
"Attempted to create a wish for a wish list that does not exist",
zap.String("username", cinfo.Username),
zap.String("wish_list_guid", object.WishListGuid),
zap.Error(err))
return nil, errs.ErrNotFound
}
w.log.Error(
"Failed to get wishlist for the new wish",
zap.String("username", cinfo.Username),
zap.String("wish_list_guid", object.WishListGuid),
zap.Error(err))
return nil, errs.ErrServerError
}
wishListOwnerUser, err := db.TXQueries.GetWishListOwnerByGuid(db.CTX, wishList.Guid.String()); if err != nil {
w.log.Error(
"Failed to get wish list owner",
zap.String("username", cinfo.Username),
zap.String("wish_list_guid", wishList.Guid.String()),
zap.Error(err))
return nil, errs.ErrServerError
}
if wishListOwnerUser.Username != cinfo.Username {
w.log.Warn(
"Attempt to create wish in a wish list the user does not own",
zap.String("owner_username", wishListOwnerUser.Username),
zap.String("username", cinfo.Username),
zap.String("wish_list_guid", object.WishListGuid))
// As usual, we will pretend that it does not exist
return nil, errs.ErrNotFound
}
var avatarUrl *string
if object.PictureUploadID != "" {
key, err := w.s3.SaveUpload(object.PictureUploadID, "images"); if err != nil {
if errors.Is(err, errs.ErrFileNotFound) {
return nil, err
}
w.log.Error(
"Failed to save image",
zap.String("upload_id", object.PictureUploadID),
zap.String("username", cinfo.Username),
zap.Error(err))
return nil, errs.ErrServerError
}
urlObj := w.s3.GetLocalizedFileUrl(*key, "images")
avatarUrl = utils.NewPointer(urlObj.String())
} else {
avatarUrl = utils.NewPointer("")
}
newWish, err := db.TXQueries.CreateWish(db.CTX, database.CreateWishParams{
WishListGuid: object.WishListGuid,
Name: object.Name,
Description: object.Description,
PictureUrl: *avatarUrl,
Stars: int16(object.Stars),
}); if err != nil {
w.log.Error(
"Failed to create a new wish",
zap.String("username", cinfo.Username),
zap.String("wish_list_guid", object.WishListGuid),
zap.Error(err))
return nil, errs.ErrServerError
}
err = helper.Commit(); if err != nil {
w.log.Error(
"Failed to commit transaction",
zap.Error(err))
return nil, errs.ErrServerError
}
wishDto := &dto.WishDto{}
mapspecial.MapWishDto(newWish, wishDto)
return wishDto, nil
}
func (w wishListServiceImpl) CreateWishList(cinfo dto.ClientInfo, object dto.NewWishListDto) (*dto.WishListDto, error) {
helper, db, err := database.NewDbHelperTransaction(w.dbctx); if err != nil {
w.log.Error(
"Failed to open transaction",
zap.Error(err))
return nil, errs.ErrServerError
}
defer helper.Rollback()
createdWishList, err := db.TXQueries.CreateWishList(db.CTX, database.CreateWishListParams{
Username: cinfo.Username,
Hidden: object.Hidden,
Name: object.Name,
IconName: object.IconName,
Color: object.Color,
ColorGrad: object.ColorGrad,
}); if err != nil {
w.log.Error(
"Failed to create wish list",
zap.String("username", cinfo.Username),
zap.Error(err))
return nil, errs.ErrServerError
}
err = helper.Commit(); if err != nil {
w.log.Error(
"Failed to commit transaction",
zap.Error(err))
return nil, errs.ErrServerError
}
wishListDto := &dto.WishListDto{}
mapspecial.MapWishListDto(createdWishList, wishListDto)
return wishListDto, nil
}
func (w wishListServiceImpl) DeleteWishListByGuid(cinfo dto.ClientInfo, guid string) (bool, error) {
panic("unimplemented")
}
func (w wishListServiceImpl) GetUserWishListsPaginated(cinfo dto.ClientInfo, amount int, page int, sorting enums.Sorting, sortOrder enums.SortOrder) (*[]dto.WishListDto, error) {
panic("unimplemented")
}
func (w wishListServiceImpl) GetWishByGuid(cinfo dto.ClientInfo, guid string) (*dto.WishDto, error) {
panic("unimplemented")
}
func (w wishListServiceImpl) GetWishListByGuid(cinfo dto.ClientInfo, guid string) (*dto.WishListDto, error) {
panic("unimplemented")
}
func (w wishListServiceImpl) GetWishesByWishListGuidPaginated(cinfo dto.ClientInfo, guid string, amount int, page int, sorting enums.Sorting, sortOrder enums.SortOrder) (*[]dto.WishDto, error) {
panic("unimplemented")
}
func (w wishListServiceImpl) MoveWishToWishList(cinfo dto.ClientInfo, wishGuid string, wishListGuid string) (bool, error) {
panic("unimplemented")
}
func (w wishListServiceImpl) UpdateWish(cinfo dto.ClientInfo, guid string, object dto.NewWishDto) (bool, error) {
panic("unimplemented")
}
func (w wishListServiceImpl) UpdateWishListByGuid(cinfo dto.ClientInfo, guid string, object dto.NewWishDto) (bool, error) {
panic("unimplemented")
}

View File

@@ -35,3 +35,15 @@ const (
JwtAccessTokenType JwtTokenType = iota
JwtRefreshTokenType
)
type Sorting int32
const (
ByDate Sorting = iota
ByScore
)
type SortOrder int32
const (
Descending = iota
Ascending
)

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2025 Nikolai Papin
//
// This file is part of Easywish
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package mapspecial
import (
"easywish/internal/database"
"easywish/internal/dto"
"github.com/rafiulgits/go-automapper"
)
func MapWishDto(dbModel database.Wish, dtoModel *dto.WishDto) {
if dtoModel == nil {
dtoModel = &dto.WishDto{}
}
automapper.Map(&dbModel, dtoModel, func(src *database.Wish, dst *dto.WishDto) {
dst.Guid = src.Guid.String()
dst.WishListGuid = src.WishListGuid.String()
dst.CreationDate = src.CreationDate.Time.UnixMilli()
dst.FulfilledDate = src.FulfilledDate.Time.UnixMilli()
})
}

View File

@@ -0,0 +1,35 @@
// Copyright (c) 2025 Nikolai Papin
//
// This file is part of Easywish
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package mapspecial
import (
"easywish/internal/database"
"easywish/internal/dto"
"github.com/rafiulgits/go-automapper"
)
func MapWishListDto(dbModel database.WishList, dtoModel *dto.WishListDto) {
if dtoModel == nil {
dtoModel = &dto.WishListDto{}
}
automapper.Map(&dbModel, dtoModel, func(src *database.WishList, dst *dto.WishListDto) {
dst.Guid = src.Guid.String()
})
}

View File

@@ -21,6 +21,7 @@ import (
"easywish/config"
"fmt"
"regexp"
"time"
"github.com/go-playground/validator/v10"
)
@@ -41,6 +42,13 @@ func GetCustomHandlers() []CustomValidatorHandler {
return regexp.MustCompile(`^[a-zA-Z0-9_]{3,20}$`).MatchString(username)
}},
{
FieldName: "guid",
Function: func(fl validator.FieldLevel) bool {
guid := fl.Field().String()
return regexp.MustCompile(`^([{(]?([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})[})]?)$`).MatchString(guid)
}},
{
FieldName: "name",
Function: func(fl validator.FieldLevel) bool {
@@ -55,6 +63,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 {

View File

@@ -32,7 +32,9 @@ WHERE id = $1;
;-- name: UpdateUserByUsername :exec
UPDATE users
SET verified = $2, deleted = $3
SET
verified = COALESCE($2, verified),
deleted = COALESCE($3, deleted)
WHERE username = $1;
;-- name: DeleteUser :exec
@@ -56,29 +58,6 @@ SELECT users.* FROM users
JOIN login_informations linfo ON linfo.user_id = users.id
WHERE linfo.email = @email::text;
;-- name: CheckUserRegistrationAvailability :one
-- SELECT
-- COUNT(users.username = @username::text) > 0 AS username_busy,
-- COUNT(linfo.email = @email::text) > 0 AS email_busy
-- FROM users
-- JOIN login_informations AS linfo on linfo.user_id = users.id
-- WHERE
-- (
-- users.username = @username::text OR
-- linfo.email = @email::text
-- )
-- AND
-- (
-- users.verified IS TRUE OR
-- COUNT(
-- SELECT confirmation_codes as codes
-- JOIN users on users.id = codes.user_id
-- WHERE codes.code_type = 0 AND
-- codes.deleted IS FALSE AND
-- codes.expires_at < CURRENT_TIMESTAMP
-- ) = 0;
-- )
;-- name: GetValidUserByLoginCredentials :one
SELECT
users.*,
@@ -86,12 +65,17 @@ SELECT
linfo.totp_encrypted
FROM users
JOIN login_informations AS linfo ON users.id = linfo.user_id
LEFT JOIN banned_users AS banned ON users.id = banned.user_id
WHERE
users.username = $1 AND
users.verified IS TRUE AND -- Verified
users.deleted IS FALSE AND -- Not deleted
banned.user_id IS NULL AND -- Not banned
NOT EXISTS (
SELECT 1
FROM banned_users
WHERE user_id = users.id AND
pardoned IS FALSE AND
(expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)
) AND -- Not banned
linfo.password_hash = crypt(@password::text, linfo.password_hash); -- Password hash matches
;-- name: CheckUserRegistrationAvailability :one
@@ -296,29 +280,39 @@ SELECT profiles.* FROM profiles
JOIN users ON users.id = profiles.user_id
WHERE users.username = $1;
;-- name: GetProfileByUsernameRestricted :one
;-- name: CheckProfileAccess :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);
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
SELECT
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
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;
;-- name: GetProfilesRestricted :many
SELECT
@@ -367,3 +361,170 @@ JOIN users ON users.id = profiles.user_id
WHERE users.username = $1;
--: }}}
--: Wish List Object {{{
;-- name: CreateWishList :one
INSERT INTO wish_lists(profile_id, hidden, name, icon_name, color, color_grad)
VALUES (
(SELECT p.id FROM profiles AS p
JOIN users AS u ON u.id = p.user_id
WHERE u.username = @username::text),
@hidden::boolean,
@name::text,
@icon_name::text,
@color::text,
@color_grad::text
)
RETURNING *;
;-- name: UpdateWishListByGuid :exec
UPDATE wish_lists wl
SET
hidden = COALESCE(@hidden::boolean, wl.hidden),
name = COALESCE(@name::text, wl.name),
icon_name = COALESCE(@icon_name::text, wl.icon_name),
color = COALESCE(@color::text, wl.color),
color_grad = COALESCE(@color_grad::text, wl.color_grad),
deleted = COALESCE(@deleted::boolean, wl.deleted)
WHERE wl.guid = (@guid::text)::uuid;
;-- name: GetWishlistByGuid :one
SELECT * FROM wish_lists wl
WHERE wl.guid = (@guid::text)::uuid;
;-- name: GetWishListOwnerByGuid :one
SELECT u.*
FROM wish_lists wl
JOIN profiles p ON wl.profile_id = p.id
JOIN users u ON p.user_id = u.id
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
SELECT
wl.*,
CASE
WHEN (
ps.hide_profile_details OR (
ps.hide_for_unauthenticated AND
@requester::text = ''
)
) THEN FALSE
ELSE TRUE
END AS access_allowed
FROM
wish_lists wl
JOIN
profiles AS p ON wl.profile_id = p.id
JOIN
profile_settings AS ps ON ps.profile_id = p.id
JOIN
users AS u ON p.user_id = u.id
WHERE
wl.deleted IS FALSE AND
u.username = @username::text AND
(
u.username = @requester::text OR
(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)
))
);
--: }}}
--: 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
name = COALESCE(@name::text, w.name),
description = COALESCE(@description::text, w.description),
picture_url = COALESCE(@picture_url::text, w.picture_url),
stars = COALESCE(@stars::smallint, w.stars),
fulfilled = COALESCE(@fulfilled::boolean, w.fulfilled),
fulfilled_date = COALESCE(@fulfilled_date::timestamp, w.fulfilled_date),
deleted = COALESCE(@deleted::boolean, w.deleted)
WHERE w.guid = (@guid::text)::uuid;
;-- name: MoveWishToWishListWithGuid :one
WITH updated AS (
UPDATE wishes w
SET
wish_list_id = wl.id,
wish_list_guid = (@wish_list_guid::text)::uuid
FROM wish_lists wl
WHERE
wl.guid = (@wish_list_guid::text)::uuid AND
wl.profile_id = ( -- Make sure the wish is not moved to another profile
SELECT profile_id
FROM wish_lists
WHERE wish_lists.id = w.wish_list_id
)
RETURNING w.id
)
SELECT
COUNT(*) > 0 AS target_found
FROM updated;
;-- name: GetWishByGuid :one
SELECT * FROM wishes w
WHERE w.guid = (@guid::text)::uuid;
;-- 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
);
--: }}}

View File

@@ -22,7 +22,7 @@ CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE IF NOT EXISTS "users" (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(20) UNIQUE NOT NULL,
verified BOOLEAN DEFAULT FALSE,
verified BOOLEAN NOT NULL DEFAULT FALSE,
registration_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
role INTEGER NOT NULL DEFAULT 1, -- enum user
deleted BOOLEAN DEFAULT FALSE
@@ -35,7 +35,7 @@ CREATE TABLE IF NOT EXISTS "banned_users" (
reason VARCHAR(512),
expires_at TIMESTAMP,
banned_by VARCHAR(20) DEFAULT 'system',
pardoned BOOLEAN DEFAULT FALSE,
pardoned BOOLEAN NOT NULL DEFAULT FALSE,
pardoned_by VARCHAR(20)
);
@@ -55,14 +55,14 @@ CREATE TABLE IF NOT EXISTS "confirmation_codes" (
code_type INTEGER NOT NULL CHECK (code_type IN (0, 1)),
code_hash VARCHAR(512) NOT NULL,
expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + INTERVAL '10 minutes',
used BOOLEAN DEFAULT FALSE,
deleted BOOLEAN DEFAULT FALSE
used BOOLEAN NOT NULL DEFAULT FALSE,
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS "sessions" (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
guid UUID NOT NULL DEFAULT gen_random_uuid(),
guid UUID UNIQUE NOT NULL DEFAULT gen_random_uuid(),
name VARCHAR(175),
platform VARCHAR(175),
latest_ip VARCHAR(16),
@@ -77,7 +77,7 @@ CREATE TABLE IF NOT EXISTS "profiles" (
user_id BIGINT UNIQUE NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(75) NOT NULL,
bio VARCHAR(512) NOT NULL DEFAULT '',
avatar_url VARCHAR(255) NOT NULL DEFAULT '',
avatar_url VARCHAR(512) NOT NULL DEFAULT '',
birthday TIMESTAMP,
color VARCHAR(7) NOT NULL DEFAULT '#254333',
color_grad VARCHAR(7) NOT NULL DEFAULT '#691E4D'
@@ -93,4 +93,31 @@ CREATE TABLE IF NOT EXISTS "profile_settings" (
hide_dates BOOLEAN NOT NULL DEFAULT FALSE,
captcha BOOLEAN NOT NULL DEFAULT FALSE,
followers_only_interaction BOOLEAN NOT NULL DEFAULT FALSE
)
);
CREATE TABLE IF NOT EXISTS "wish_lists" (
id BIGSERIAL PRIMARY KEY,
guid UUID UNIQUE NOT NULL DEFAULT gen_random_uuid(),
profile_id BIGINT UNIQUE NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
hidden BOOLEAN NOT NULL DEFAULT FALSE,
name VARCHAR(32) NOT NULL DEFAULT 'Wishes',
icon_name VARCHAR(64) NOT NULL DEFAULT '',
color VARCHAR(7) NOT NULL DEFAULT '',
color_grad VARCHAR(7) NOT NULL DEFAULT '',
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS "wishes" (
id BIGSERIAL PRIMARY KEY,
guid UUID UNIQUE NOT NULL DEFAULT gen_random_uuid(),
wish_list_id BIGINT UNIQUE NOT NULL REFERENCES wish_lists(id) ON DELETE CASCADE,
wish_list_guid UUID NOT NULL REFERENCES wish_lists(guid) ON DELETE CASCADE,
name VARCHAR(64) NOT NULL DEFAULT 'New wish',
description VARCHAR(1000) NOT NULL DEFAULT '',
picture_url VARCHAR(512) NOT NULL DEFAULT '',
stars SMALLINT NOT NULL DEFAULT 3 CHECK (stars BETWEEN 1 AND 5),
creation_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
fulfilled BOOLEAN NOT NULL DEFAULT FALSE,
fulfilled_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT FALSE
);