// 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 . package services import ( "easywish/internal/database" "easywish/internal/dto" errs "easywish/internal/errors" "easywish/internal/utils" mapspecial "easywish/internal/utils/mapSpecial" "errors" "time" "github.com/go-redis/redis/v8" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/minio/minio-go/v7" "github.com/rafiulgits/go-automapper" "go.uber.org/zap" ) type ProfileService interface { GetProfileByUsername(cinfo dto.ClientInfo, username string) (*dto.ProfileDto, error) GetMyProfile(cinfo dto.ClientInfo) (*dto.ProfileDto, error) UpdateProfile(cinfo dto.ClientInfo, newProfile dto.NewProfileDto) (bool, error) GetProfileSettings(cinfo dto.ClientInfo) (*dto.ProfileSettingsDto, error) UpdateProfileSettings(cinfo dto.ClientInfo, newProfileSettings dto.ProfileSettingsDto) (bool, error) } type profileServiceImpl struct { log *zap.Logger dbctx database.DbContext redis *redis.Client minio *minio.Client s3 S3Service } func NewProfileService(_log *zap.Logger, _dbctx database.DbContext, _redis *redis.Client, _minio *minio.Client, _s3 S3Service) ProfileService { return &profileServiceImpl{log: _log, dbctx: _dbctx, redis: _redis, minio: _minio, s3: _s3} } func (p *profileServiceImpl) GetMyProfile(cinfo dto.ClientInfo) (*dto.ProfileDto, error) { db := database.NewDbHelper(p.dbctx); profile, err := db.Queries.GetProfileByUsername(db.CTX, cinfo.Username); if err != nil { p.log.Error( "Failed to find user profile by username", zap.Error(err)) return nil, errs.ErrServerError } profileDto := &dto.ProfileDto{} mapspecial.MapProfileDto(profile, profileDto) return profileDto, nil } 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( "Failed to start transaction", zap.Error(err)) return nil, errs.ErrServerError } defer helper.Rollback() profileRow, err := db.TXQueries.GetProfileByUsername(db.CTX, username); if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errs.ErrNotFound } p.log.Error( "Failed to get user profile by username", zap.String("username", username), zap.Error(err)) return nil, errs.ErrServerError } 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 } func (p *profileServiceImpl) GetProfileSettings(cinfo dto.ClientInfo) (*dto.ProfileSettingsDto, error) { db := database.NewDbHelper(p.dbctx); profileSettings, err := db.Queries.GetProfileSettingsByUsername(db.CTX, cinfo.Username); if err != nil { p.log.Error( "Failed to find user profile settings by username", zap.Error(err)) return nil, errs.ErrServerError } profileSettingsDto := &dto.ProfileSettingsDto{} automapper.Map(profileSettings, profileSettingsDto) return profileSettingsDto, nil } 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( "Failed to open transaction", zap.Error(err)) return false, errs.ErrServerError } defer helper.Rollback() birthdayTimestamp := pgtype.Timestamp { Time: time.UnixMilli(newProfile.Birthday), Valid: true, } var avatarUrl *string if newProfile.AvatarUploadID != "" { key, err := p.s3.SaveUpload(newProfile.AvatarUploadID, "avatars"); if err != nil { if errors.Is(err, errs.ErrFileNotFound) { return false, err } p.log.Error("Failed to save avatar", zap.String("upload_id", newProfile.AvatarUploadID), zap.Error(err)) return false, errs.ErrServerError } urlObj := p.s3.GetLocalizedFileUrl(*key, "avatars") avatarUrl = utils.NewPointer(urlObj.String()) } err = db.TXlessQueries.UpdateProfileByUsername(db.CTX, database.UpdateProfileByUsernameParams{ Username: cinfo.Username, Name: newProfile.Name, Bio: newProfile.Bio, Birthday: birthdayTimestamp, AvatarUrl: avatarUrl, Color: newProfile.Color, ColorGrad: newProfile.ColorGrad, }); if err != nil { p.log.Error( "Failed to update user profile", zap.String("username", cinfo.Username), zap.Error(err)) return false, errs.ErrServerError } err = helper.Commit(); if err != nil { p.log.Error( "Failed to commit transaction", zap.Error(err)) return false, errs.ErrServerError } return true, nil } func (p *profileServiceImpl) UpdateProfileSettings(cinfo dto.ClientInfo, newProfileSettings dto.ProfileSettingsDto) (bool, error) { helper, db, err := database.NewDbHelperTransaction(p.dbctx); if err != nil { p.log.Error( "Failed to open transaction", zap.Error(err)) return false, err } defer helper.Rollback() // I wanted an automapper here but I'm feeling lazy, it's not used anywhere else regardless. // Also this was initially meant to be a PATCH request but I realized that the fields in the // DTO model are not pointers. Too late, guess this is a PUT request now. Who the hell cares // about a couple extra bytes you have to send every now and then anyways. err = db.TXlessQueries.UpdateProfileSettingsByUsername(db.CTX, database.UpdateProfileSettingsByUsernameParams{ Username: cinfo.Username, HideFulfilled: newProfileSettings.HideFulfilled, HideProfileDetails: newProfileSettings.HideProfileDetails, HideForUnauthenticated: newProfileSettings.HideForUnauthenticated, HideBirthday: newProfileSettings.HideBirthday, Captcha: newProfileSettings.Captcha, FollowersOnlyInteraction: newProfileSettings.FollowersOnlyInteraction, }); if err != nil { p.log.Error( "Failed to update user profile settings", zap.String("username", cinfo.Username), zap.Error(err)) return false, errs.ErrServerError } err = helper.Commit(); if err != nil { p.log.Error( "Failed to commit transaction", zap.Error(err)) return false, errs.ErrServerError } return true, nil }