34 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
5ed75c350a feat: remove authentication requirement for avatar and image upload endpoints;
fix: remove 500 error responses from upload endpoints;
fix: return validation error strings instead of error lists;
fix: handle invalid avatar upload IDs with 400 Bad Request response;
fix: add missing S3Controller to controller initialization;
fix: change avatar_upload_id to string type and update validation rules;
chore: add license header to smtp.go;
refactor: replace manual proxy implementation with httputil.ReverseProxy;
fix: inject S3Service dependency into ProfileService;
fix: set color and color_grad fields during profile update;
fix: correct DTO mapping for profile and settings;
fix: check object existence before copying in SaveUpload;
fix: adjust profile DTO mapping function for proper pointer handling
2025-08-02 03:47:56 +03:00
669349e020 chore: remove direct avatar upload endpoint (POST /profile/avatar);
feat: add endpoints for presigned upload URLs (GET /upload/avatar, GET /upload/image);
refactor: replace ProfileDto with NewProfileDto in update profile endpoint;
feat: implement S3 integration for avatar management;
fix: update database queries to handle new avatar upload flow;
chore: add new dependencies for S3 handling (golang.org/x/time);
refactor: rename UploadService to S3Service;
refactor: change return type for func LocalizeS3Url(originalURL string) (*url.URL, error);
feat: add custom validator for upload_id
2025-08-01 04:34:06 +03:00
8dba0f79aa feat: UploadService controller with ratelimit middleware 2025-07-31 18:10:45 +03:00
08b3942d35 feat: UploadService for acquiring temporary file upload urls for different objects (currently avatars and general images) 2025-07-31 17:46:47 +03:00
0a38267cb0 feat: configuration parameters for minio host, port, timeout;
refactor: renamed buckets;
fix: corrected Host header changing behavior in minio gin endpoint;
feat: function to convert local minio url to /s3/ path url with the backend host
2025-07-30 14:26:27 +03:00
ed044590a0 feat: setup direct access to minio endpoint to images and avatars buckets through /s3/ path 2025-07-29 20:57:36 +03:00
e15ee90a62 feat: Automatic creation of buckets and setting expiration rules 2025-07-28 01:41:01 +03:00
fbe73e2a68 feat: bucket precreation for minioclient 2025-07-27 16:14:12 +03:00
2809e1bd37 feat: enable minio ftp 2025-07-26 22:23:43 +03:00
f08cb639a5 feat: added minio dependency 2025-07-23 21:11:49 +03:00
d14f90d628 feat: complete profile update and settings management
refactor: change profile update endpoints to PUT
refactor: changed profile settings update query to use username
chore: update SQL queries for profile operations
2025-07-23 17:46:59 +03:00
f38af13dc1 feat: GetProfileByUsername implemented 2025-07-21 23:16:09 +03:00
705b420b9e feat: implemented own profile getter;
experiment: using custom automapper function to map profile to profileDto;
refactor: adjusted ProfileService to use pointer return types with models
2025-07-20 21:39:21 +03:00
df54829a67 fix: change avatar upload response to JSON object with URL;
feat: add UrlDto for standardized URL responses;
refactor: update avatar upload endpoint to return UrlDto;
docs: regenerate Swagger;
chore: add comments for untested profile controller methods
2025-07-19 23:23:56 +03:00
f65439fb50 feat: fully implement profile controller;
feat: implement file upload handling in controller with size and type validation;
feat: add custom validation rules for bio and color hex fields;
refactor: enhance request handling with dedicated client info extraction;
chore: update profile DTOs with validation tags;
docs: profile controller swagger
2025-07-19 22:57:44 +03:00
fc0c73aa5b feat: added go-automapper for mapping dtos;
feat: implemented mapspecial package for mapping dtos that are not possible to automap by default;
initialized profile service;
added dtos for profile and profileSettings
2025-07-19 11:44:15 +03:00
41 changed files with 3501 additions and 201 deletions

View File

@@ -45,6 +45,7 @@ import (
"easywish/internal/controllers" "easywish/internal/controllers"
"easywish/internal/database" "easywish/internal/database"
"easywish/internal/logger" "easywish/internal/logger"
minioclient "easywish/internal/minioClient"
redisclient "easywish/internal/redisClient" redisclient "easywish/internal/redisClient"
"easywish/internal/services" "easywish/internal/services"
"easywish/internal/validation" "easywish/internal/validation"
@@ -66,6 +67,7 @@ func main() {
logger.NewLogger, logger.NewLogger,
logger.NewSyncLogger, logger.NewSyncLogger,
redisclient.NewRedisClient, redisclient.NewRedisClient,
minioclient.NewMinioClient,
gin.Default, gin.Default,
), ),
database.Module, database.Module,

View File

@@ -1,17 +1,17 @@
// Copyright (c) 2025 Nikolai Papin // Copyright (c) 2025 Nikolai Papin
// //
// This file is part of Easywish // This file is part of Easywish
// //
// This program is free software: you can redistribute it and/or modify // 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 // it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or // the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version. // (at your option) any later version.
// //
// This program is distributed in the hope that it will be useful, // This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of // but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details. // the GNU General Public License for more details.
// //
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
@@ -20,6 +20,7 @@ package config
import ( import (
"fmt" "fmt"
"strings" "strings"
"time"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
@@ -31,6 +32,9 @@ type Config struct {
DatabaseUrl string `mapstructure:"POSTGRES_URL"` DatabaseUrl string `mapstructure:"POSTGRES_URL"`
RedisUrl string `mapstructure:"REDIS_URL"` RedisUrl string `mapstructure:"REDIS_URL"`
MinioUrl string `mapstructure:"MINIO_URL"` MinioUrl string `mapstructure:"MINIO_URL"`
MinioHost string `mapstructure:"MINIO_HOST"`
MinioPort uint16 `mapstructure:"MINIO_PORT"`
MinioTimeout int64 `mapstructure:"MINIO_TIMEOUT"`
JwtAlgorithm string `mapstructure:"JWT_ALGORITHM"` JwtAlgorithm string `mapstructure:"JWT_ALGORITHM"`
JwtSecret string `mapstructure:"JWT_SECRET"` JwtSecret string `mapstructure:"JWT_SECRET"`
@@ -65,6 +69,8 @@ func Load() (*Config, error) {
viper.SetDefault("HOSTNAME", "localhost") viper.SetDefault("HOSTNAME", "localhost")
viper.SetDefault("PORT", "8080") viper.SetDefault("PORT", "8080")
viper.SetDefault("MINIO_TIMEOUT", 90 * time.Second)
viper.SetDefault("JWT_ALGORITHM", "HS256") viper.SetDefault("JWT_ALGORITHM", "HS256")
viper.SetDefault("JWT_SECRET", "default_jwt_secret_please_change") viper.SetDefault("JWT_SECRET", "default_jwt_secret_please_change")
viper.SetDefault("JWT_EXP_ACCESS", 5) viper.SetDefault("JWT_EXP_ACCESS", 5)
@@ -97,9 +103,13 @@ func Load() (*Config, error) {
viper.BindEnv("HOSTNAME") viper.BindEnv("HOSTNAME")
viper.BindEnv("PORT") viper.BindEnv("PORT")
viper.BindEnv("MINIO_TIMEOUT")
viper.BindEnv("POSTGRES_URL") viper.BindEnv("POSTGRES_URL")
viper.BindEnv("REDIS_URL") viper.BindEnv("REDIS_URL")
viper.BindEnv("MINIO_URL") viper.BindEnv("MINIO_URL")
viper.BindEnv("MINIO_HOST")
viper.BindEnv("MINIO_PORT")
viper.BindEnv("JWT_ALGORITHM") viper.BindEnv("JWT_ALGORITHM")
viper.BindEnv("JWT_SECRET") viper.BindEnv("JWT_SECRET")
@@ -132,6 +142,8 @@ func Load() (*Config, error) {
"POSTGRES_URL", "POSTGRES_URL",
"REDIS_URL", "REDIS_URL",
"MINIO_URL", "MINIO_URL",
"MINIO_HOST",
"MINIO_PORT",
} }
var missing []string var missing []string
for _, key := range required { for _, key := range required {

View File

@@ -269,6 +269,174 @@ const docTemplate = `{
} }
} }
}, },
"/profile": {
"get": {
"security": [
{
"JWT": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Profile"
],
"summary": "Get your profile",
"responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/dto.ProfileDto"
}
}
}
},
"put": {
"security": [
{
"JWT": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Profile"
],
"summary": "Update your profile",
"parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.NewProfileDto"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"type": "boolean"
}
}
}
}
},
"/profile/settings": {
"get": {
"security": [
{
"JWT": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Profile"
],
"summary": "Get your profile settings",
"responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/dto.ProfileSettingsDto"
}
}
}
},
"put": {
"security": [
{
"JWT": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Profile"
],
"summary": "Update your profile's settings",
"parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.ProfileSettingsDto"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"type": "boolean"
}
}
}
}
},
"/profile/{username}": {
"get": {
"security": [
{
"JWT": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Profile"
],
"summary": "Get profile by username",
"parameters": [
{
"type": "string",
"description": " ",
"name": "username",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/dto.ProfileDto"
}
},
"403": {
"description": "Restricted profile"
},
"404": {
"description": "Profile not found"
}
}
}
},
"/service/health": { "/service/health": {
"get": { "get": {
"description": "Used internally for checking service health", "description": "Used internally for checking service health",
@@ -291,9 +459,128 @@ const docTemplate = `{
} }
} }
} }
},
"/upload/avatar": {
"get": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Upload"
],
"summary": "Get presigned URL for avatar upload",
"responses": {
"200": {
"description": "Presigned URL and form data",
"schema": {
"$ref": "#/definitions/models.PresignedUploadResponse"
}
}
}
}
},
"/upload/image": {
"get": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Upload"
],
"summary": "Get presigned URL for image upload",
"responses": {
"200": {
"description": "Presigned URL and form data",
"schema": {
"$ref": "#/definitions/models.PresignedUploadResponse"
}
}
}
}
} }
}, },
"definitions": { "definitions": {
"dto.NewProfileDto": {
"type": "object",
"required": [
"name"
],
"properties": {
"avatar_upload_id": {
"type": "string"
},
"bio": {
"type": "string"
},
"birthday": {
"type": "integer"
},
"color": {
"type": "string"
},
"color_grad": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"dto.ProfileDto": {
"type": "object",
"properties": {
"avatar_url": {
"type": "string"
},
"bio": {
"type": "string"
},
"birthday": {
"type": "integer"
},
"color": {
"type": "string"
},
"color_grad": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"dto.ProfileSettingsDto": {
"type": "object",
"properties": {
"captcha": {
"type": "boolean"
},
"followers_only_interaction": {
"type": "boolean"
},
"hide_birthday": {
"type": "boolean"
},
"hide_dates": {
"type": "boolean"
},
"hide_for_unauthenticated": {
"type": "boolean"
},
"hide_fulfilled": {
"type": "boolean"
},
"hide_profile_details": {
"type": "boolean"
}
}
},
"models.ChangePasswordRequest": { "models.ChangePasswordRequest": {
"type": "object", "type": "object",
"required": [ "required": [
@@ -396,6 +683,20 @@ const docTemplate = `{
} }
} }
}, },
"models.PresignedUploadResponse": {
"type": "object",
"properties": {
"fields": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"url": {
"type": "string"
}
}
},
"models.RefreshRequest": { "models.RefreshRequest": {
"type": "object", "type": "object",
"required": [ "required": [
@@ -447,7 +748,7 @@ const docTemplate = `{
], ],
"properties": { "properties": {
"birthday": { "birthday": {
"type": "string" "type": "integer"
}, },
"name": { "name": {
"type": "string" "type": "string"

View File

@@ -265,6 +265,174 @@
} }
} }
}, },
"/profile": {
"get": {
"security": [
{
"JWT": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Profile"
],
"summary": "Get your profile",
"responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/dto.ProfileDto"
}
}
}
},
"put": {
"security": [
{
"JWT": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Profile"
],
"summary": "Update your profile",
"parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.NewProfileDto"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"type": "boolean"
}
}
}
}
},
"/profile/settings": {
"get": {
"security": [
{
"JWT": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Profile"
],
"summary": "Get your profile settings",
"responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/dto.ProfileSettingsDto"
}
}
}
},
"put": {
"security": [
{
"JWT": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Profile"
],
"summary": "Update your profile's settings",
"parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.ProfileSettingsDto"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"type": "boolean"
}
}
}
}
},
"/profile/{username}": {
"get": {
"security": [
{
"JWT": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Profile"
],
"summary": "Get profile by username",
"parameters": [
{
"type": "string",
"description": " ",
"name": "username",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/dto.ProfileDto"
}
},
"403": {
"description": "Restricted profile"
},
"404": {
"description": "Profile not found"
}
}
}
},
"/service/health": { "/service/health": {
"get": { "get": {
"description": "Used internally for checking service health", "description": "Used internally for checking service health",
@@ -287,9 +455,128 @@
} }
} }
} }
},
"/upload/avatar": {
"get": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Upload"
],
"summary": "Get presigned URL for avatar upload",
"responses": {
"200": {
"description": "Presigned URL and form data",
"schema": {
"$ref": "#/definitions/models.PresignedUploadResponse"
}
}
}
}
},
"/upload/image": {
"get": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Upload"
],
"summary": "Get presigned URL for image upload",
"responses": {
"200": {
"description": "Presigned URL and form data",
"schema": {
"$ref": "#/definitions/models.PresignedUploadResponse"
}
}
}
}
} }
}, },
"definitions": { "definitions": {
"dto.NewProfileDto": {
"type": "object",
"required": [
"name"
],
"properties": {
"avatar_upload_id": {
"type": "string"
},
"bio": {
"type": "string"
},
"birthday": {
"type": "integer"
},
"color": {
"type": "string"
},
"color_grad": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"dto.ProfileDto": {
"type": "object",
"properties": {
"avatar_url": {
"type": "string"
},
"bio": {
"type": "string"
},
"birthday": {
"type": "integer"
},
"color": {
"type": "string"
},
"color_grad": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"dto.ProfileSettingsDto": {
"type": "object",
"properties": {
"captcha": {
"type": "boolean"
},
"followers_only_interaction": {
"type": "boolean"
},
"hide_birthday": {
"type": "boolean"
},
"hide_dates": {
"type": "boolean"
},
"hide_for_unauthenticated": {
"type": "boolean"
},
"hide_fulfilled": {
"type": "boolean"
},
"hide_profile_details": {
"type": "boolean"
}
}
},
"models.ChangePasswordRequest": { "models.ChangePasswordRequest": {
"type": "object", "type": "object",
"required": [ "required": [
@@ -392,6 +679,20 @@
} }
} }
}, },
"models.PresignedUploadResponse": {
"type": "object",
"properties": {
"fields": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"url": {
"type": "string"
}
}
},
"models.RefreshRequest": { "models.RefreshRequest": {
"type": "object", "type": "object",
"required": [ "required": [
@@ -443,7 +744,7 @@
], ],
"properties": { "properties": {
"birthday": { "birthday": {
"type": "string" "type": "integer"
}, },
"name": { "name": {
"type": "string" "type": "string"

View File

@@ -1,5 +1,54 @@
basePath: /api/ basePath: /api/
definitions: definitions:
dto.NewProfileDto:
properties:
avatar_upload_id:
type: string
bio:
type: string
birthday:
type: integer
color:
type: string
color_grad:
type: string
name:
type: string
required:
- name
type: object
dto.ProfileDto:
properties:
avatar_url:
type: string
bio:
type: string
birthday:
type: integer
color:
type: string
color_grad:
type: string
name:
type: string
type: object
dto.ProfileSettingsDto:
properties:
captcha:
type: boolean
followers_only_interaction:
type: boolean
hide_birthday:
type: boolean
hide_dates:
type: boolean
hide_for_unauthenticated:
type: boolean
hide_fulfilled:
type: boolean
hide_profile_details:
type: boolean
type: object
models.ChangePasswordRequest: models.ChangePasswordRequest:
properties: properties:
old_password: old_password:
@@ -68,6 +117,15 @@ definitions:
refresh_token: refresh_token:
type: string type: string
type: object type: object
models.PresignedUploadResponse:
properties:
fields:
additionalProperties:
type: string
type: object
url:
type: string
type: object
models.RefreshRequest: models.RefreshRequest:
properties: properties:
refresh_token: refresh_token:
@@ -99,7 +157,7 @@ definitions:
models.RegistrationCompleteRequest: models.RegistrationCompleteRequest:
properties: properties:
birthday: birthday:
type: string type: integer
name: name:
type: string type: string
username: username:
@@ -285,6 +343,108 @@ paths:
summary: Confirm with code, finish creating the account summary: Confirm with code, finish creating the account
tags: tags:
- Auth - Auth
/profile:
get:
consumes:
- application/json
produces:
- application/json
responses:
"200":
description: ' '
schema:
$ref: '#/definitions/dto.ProfileDto'
security:
- JWT: []
summary: Get your profile
tags:
- Profile
put:
consumes:
- application/json
parameters:
- description: ' '
in: body
name: request
required: true
schema:
$ref: '#/definitions/dto.NewProfileDto'
produces:
- application/json
responses:
"200":
description: ' '
schema:
type: boolean
security:
- JWT: []
summary: Update your profile
tags:
- Profile
/profile/{username}:
get:
consumes:
- application/json
parameters:
- description: ' '
in: path
name: username
required: true
type: string
produces:
- application/json
responses:
"200":
description: ' '
schema:
$ref: '#/definitions/dto.ProfileDto'
"403":
description: Restricted profile
"404":
description: Profile not found
security:
- JWT: []
summary: Get profile by username
tags:
- Profile
/profile/settings:
get:
consumes:
- application/json
produces:
- application/json
responses:
"200":
description: ' '
schema:
$ref: '#/definitions/dto.ProfileSettingsDto'
security:
- JWT: []
summary: Get your profile settings
tags:
- Profile
put:
consumes:
- application/json
parameters:
- description: ' '
in: body
name: request
required: true
schema:
$ref: '#/definitions/dto.ProfileSettingsDto'
produces:
- application/json
responses:
"200":
description: ' '
schema:
type: boolean
security:
- JWT: []
summary: Update your profile's settings
tags:
- Profile
/service/health: /service/health:
get: get:
consumes: consumes:
@@ -300,6 +460,34 @@ paths:
summary: Get health status summary: Get health status
tags: tags:
- Service - Service
/upload/avatar:
get:
consumes:
- application/json
produces:
- application/json
responses:
"200":
description: Presigned URL and form data
schema:
$ref: '#/definitions/models.PresignedUploadResponse'
summary: Get presigned URL for avatar upload
tags:
- Upload
/upload/image:
get:
consumes:
- application/json
produces:
- application/json
responses:
"200":
description: Presigned URL and form data
schema:
$ref: '#/definitions/models.PresignedUploadResponse'
summary: Get presigned URL for image upload
tags:
- Upload
schemes: schemes:
- http - http
securityDefinitions: securityDefinitions:

View File

@@ -5,7 +5,10 @@ go 1.24.3
require ( require (
github.com/gin-gonic/gin v1.10.1 github.com/gin-gonic/gin v1.10.1
github.com/go-playground/validator/v10 v10.27.0 github.com/go-playground/validator/v10 v10.27.0
github.com/go-redis/redis/v8 v8.11.5
github.com/golang-jwt/jwt/v5 v5.2.2 github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438
github.com/jackc/pgx/v5 v5.7.5 github.com/jackc/pgx/v5 v5.7.5
github.com/spf13/viper v1.20.1 github.com/spf13/viper v1.20.1
github.com/swaggo/files v1.0.1 github.com/swaggo/files v1.0.1
@@ -13,6 +16,7 @@ require (
github.com/swaggo/swag v1.16.4 github.com/swaggo/swag v1.16.4
go.uber.org/fx v1.24.0 go.uber.org/fx v1.24.0
go.uber.org/zap v1.27.0 go.uber.org/zap v1.27.0
golang.org/x/crypto v0.40.0
) )
require ( require (
@@ -22,49 +26,56 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect github.com/cloudwego/base64x v0.1.5 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-openapi/jsonpointer v0.21.1 // indirect github.com/go-openapi/jsonpointer v0.21.1 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/spec v0.21.0 // indirect github.com/go-openapi/spec v0.21.0 // indirect
github.com/go-openapi/swag v0.23.1 // indirect github.com/go-openapi/swag v0.23.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-redis/redis/v8 v8.11.5 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/gofrs/uuid/v5 v5.3.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/rafiulgits/go-automapper v0.1.4 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/sagikazarmark/locafero v0.9.0 // indirect github.com/sagikazarmark/locafero v0.9.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.14.0 // indirect github.com/spf13/afero v1.14.0 // indirect
github.com/spf13/cast v1.9.2 // indirect github.com/spf13/cast v1.9.2 // indirect
github.com/spf13/pflag v1.0.6 // indirect github.com/spf13/pflag v1.0.6 // indirect
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect
github.com/tinylib/msgp v1.3.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/dig v1.19.0 // indirect go.uber.org/dig v1.19.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.18.0 // indirect golang.org/x/arch v0.18.0 // indirect
golang.org/x/crypto v0.39.0 // indirect golang.org/x/net v0.42.0 // indirect
golang.org/x/net v0.41.0 // indirect golang.org/x/sync v0.16.0 // indirect
golang.org/x/sync v0.15.0 // indirect golang.org/x/sys v0.34.0 // indirect
golang.org/x/sys v0.33.0 // indirect golang.org/x/text v0.27.0 // indirect
golang.org/x/text v0.26.0 // indirect golang.org/x/time v0.12.0 // indirect
golang.org/x/tools v0.34.0 // indirect golang.org/x/tools v0.34.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect

View File

@@ -15,6 +15,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
@@ -27,6 +29,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic=
github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
@@ -49,8 +53,6 @@ github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIx
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gofrs/uuid/v5 v5.3.2 h1:2jfO8j3XgSwlz/wHqemAEugfnTlikAYHhnqQ8Xh4fE0=
github.com/gofrs/uuid/v5 v5.3.2/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -72,9 +74,14 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -86,17 +93,37 @@ github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/minio/crc64nvme v1.0.2 h1:6uO1UxGAD+kwqWWp7mBFsi5gAse66C4NXO8cmcVculg=
github.com/minio/crc64nvme v1.0.2/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
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-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rafiulgits/go-automapper v0.1.4 h1:JiuPl3kjixpngxoDHLXKUfWYIPNLYO7EIGN+m6X0zFk=
github.com/rafiulgits/go-automapper v0.1.4/go.mod h1:5R1UXVz04qYUVBQMSOJfC6472yAZIT2wWIl/zx4aNvo=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k= github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk= github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
@@ -127,6 +154,8 @@ github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+z
github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo= github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo=
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A= github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg= github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
@@ -148,6 +177,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
@@ -157,10 +188,14 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -170,6 +205,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -179,6 +216,10 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -190,6 +231,10 @@ google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -22,10 +22,13 @@ import (
"easywish/internal/services" "easywish/internal/services"
"easywish/internal/utils/enums" "easywish/internal/utils/enums"
"easywish/internal/validation" "easywish/internal/validation"
"fmt"
"net/http" "net/http"
"os"
"path/filepath"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10" "github.com/google/uuid"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -75,12 +78,45 @@ func (ctrl *controllerImpl) Setup(group *gin.RouterGroup, log *zap.Logger, auth
} }
}), }),
method.Function)..., method.Function)...,
)} )
}
}
func GetFile(c *gin.Context, name string, maxSize int64, allowedTypes map[string]bool) (*string, error) {
file, err := c.FormFile(name); if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("File '%s': not provided", name)})
return nil, err
}
if file.Size > int64(maxSize) {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("File '%s': file too large", name)})
return nil, fmt.Errorf("File too large")
}
fileType := file.Header.Get("Content-Type")
if len(allowedTypes) > 0 && !allowedTypes[fileType] {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("File '%s': forbidden file type: %s", name, fileType)})
return nil, fmt.Errorf("Wrong file type")
}
folderPath := "/tmp/uploads"
if _, err := os.Stat(folderPath); os.IsNotExist(err) {
os.MkdirAll(folderPath, 0700)
}
filePath := fmt.Sprintf("%s/%s-%s", folderPath, uuid.New().String(), filepath.Base(file.Filename))
if err := c.SaveUploadedFile(file, filePath); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Error saving file"})
return nil, err
}
return &filePath, nil
} }
func GetRequest[ModelT any](c *gin.Context) (*dto.Request[ModelT], error) { func GetRequest[ModelT any](c *gin.Context) (*dto.Request[ModelT], error) {
var body ModelT var body ModelT
if err := c.ShouldBindJSON(&body); err != nil { if err := c.ShouldBindJSON(&body); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return nil, err return nil, err
@@ -90,13 +126,22 @@ func GetRequest[ModelT any](c *gin.Context) (*dto.Request[ModelT], error) {
validate := validation.NewValidator() validate := validation.NewValidator()
if err := validate.Struct(body); err != nil { if err := validate.Struct(body); err != nil {
errorList := err.(validator.ValidationErrors)
c.AbortWithStatusJSON( c.AbortWithStatusJSON(
http.StatusBadRequest, http.StatusBadRequest,
gin.H{"error": errorList}) gin.H{"error": err.Error()})
return nil, err return nil, err
} }
cinfo := GetClientInfo(c)
return &dto.Request[ModelT]{
Body: body,
User: cinfo,
}, nil
}
func GetClientInfo(c * gin.Context) (dto.ClientInfo) {
cinfoFromCtx, ok := c.Get("client_info"); if !ok { cinfoFromCtx, ok := c.Get("client_info"); if !ok {
c.AbortWithStatusJSON( c.AbortWithStatusJSON(
http.StatusInternalServerError, http.StatusInternalServerError,
@@ -105,8 +150,5 @@ func GetRequest[ModelT any](c *gin.Context) (*dto.Request[ModelT], error) {
} }
cinfo := cinfoFromCtx.(dto.ClientInfo) cinfo := cinfoFromCtx.(dto.ClientInfo)
return &dto.Request[ModelT]{ return cinfo
Body: body,
User: cinfo,
}, nil
} }

View File

@@ -0,0 +1,201 @@
// 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 controllers
import (
"easywish/internal/dto"
errs "easywish/internal/errors"
"easywish/internal/services"
"easywish/internal/utils/enums"
"errors"
"net/http"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type ProfileController struct {
log *zap.Logger
ps services.ProfileService
}
func NewProfileController(_log *zap.Logger, _ps services.ProfileService) Controller {
ctrl := ProfileController{log: _log, ps: _ps}
return &controllerImpl{
Path: "/profile",
Middleware: []gin.HandlerFunc{},
Methods: []ControllerMethod{
{
HttpMethod: GET,
Path: "",
Authorization: enums.UserRole,
Middleware: []gin.HandlerFunc{},
Function: ctrl.getMyProfile,
},
{
HttpMethod: GET,
Path: "/:username",
Authorization: enums.GuestRole,
Middleware: []gin.HandlerFunc{},
Function: ctrl.getProfileByUsername,
},
{
HttpMethod: GET,
Path: "/settings",
Authorization: enums.UserRole,
Middleware: []gin.HandlerFunc{},
Function: ctrl.getProfileSettings,
},
{
HttpMethod: PUT,
Path: "",
Authorization: enums.UserRole,
Middleware: []gin.HandlerFunc{},
Function: ctrl.updateProfile,
},
{
HttpMethod: PUT,
Path: "/settings",
Authorization: enums.UserRole,
Middleware: []gin.HandlerFunc{},
Function: ctrl.updateProfileSettings,
},
},
}
}
// @Summary Get your profile
// @Tags Profile
// @Accept json
// @Produce json
// @Security JWT
// @Success 200 {object} dto.ProfileDto " "
// @Router /profile [get]
func (ctrl *ProfileController) getMyProfile(c *gin.Context) {
cinfo := GetClientInfo(c)
response, err := ctrl.ps.GetMyProfile(cinfo); if err != nil {
c.Status(http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, response)
}
// @Summary Get profile by username
// @Tags Profile
// @Accept json
// @Produce json
// @Security JWT
// @Param username path string true " "
// @Success 200 {object} dto.ProfileDto " "
// @Failure 404 "Profile not found"
// @Failure 403 "Restricted profile"
// @Router /profile/{username} [get]
func (ctrl *ProfileController) getProfileByUsername(c *gin.Context) {
cinfo := GetClientInfo(c)
username := c.Param("username"); if username == "" {
c.Status(http.StatusBadRequest)
return
}
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)
}
return
}
c.JSON(http.StatusOK, response)
}
// @Summary Get your profile settings
// @Tags Profile
// @Accept json
// @Produce json
// @Security JWT
// @Success 200 {object} dto.ProfileSettingsDto " "
// @Router /profile/settings [get]
func (ctrl *ProfileController) getProfileSettings(c *gin.Context) {
cinfo := GetClientInfo(c)
response, err := ctrl.ps.GetProfileSettings(cinfo); if err != nil {
c.Status(http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, response)
}
// @Summary Update your profile
// @Tags Profile
// @Accept json
// @Produce json
// @Security JWT
// @Param request body dto.NewProfileDto true " "
// @Success 200 {object} bool " "
// @Router /profile [put]
func (ctrl *ProfileController) updateProfile(c *gin.Context) {
request, err := GetRequest[dto.NewProfileDto](c); if err != nil {
return
}
response, err := ctrl.ps.UpdateProfile(request.User, request.Body); if err != nil {
if errors.Is(err, errs.ErrFileNotFound) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid upload ID. Make sure file was uploaded and is not expired."})
}
c.Status(http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, response)
}
// @Summary Update your profile's settings
// @Tags Profile
// @Accept json
// @Produce json
// @Security JWT
// @Param request body dto.ProfileSettingsDto true " "
// @Success 200 {object} bool " "
// @Router /profile/settings [put]
func (ctrl *ProfileController) updateProfileSettings(c *gin.Context) {
request, err := GetRequest[dto.ProfileSettingsDto](c); if err != nil {
return
}
response, err := ctrl.ps.UpdateProfileSettings(request.User, request.Body); if err != nil || !response {
c.Status(http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, response)
}

View File

@@ -0,0 +1,102 @@
// 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 controllers
import (
"easywish/internal/middleware"
"easywish/internal/models"
"easywish/internal/services"
"easywish/internal/utils/enums"
"net/http"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"golang.org/x/time/rate"
)
type S3Controller struct {
log *zap.Logger
s3 services.S3Service
}
func NewS3Controller(_log *zap.Logger, _us services.S3Service) Controller {
ctrl := S3Controller{log: _log, s3: _us}
return &controllerImpl{
Path: "/upload",
Middleware: []gin.HandlerFunc{
middleware.RateLimitMiddleware(rate.Every(2*time.Second), 1),
},
Methods: []ControllerMethod{
{
HttpMethod: GET,
Path: "/avatar",
Authorization: enums.GuestRole,
Middleware: []gin.HandlerFunc{},
Function: ctrl.getAvatarUploadUrl,
},
{
HttpMethod: GET,
Path: "/image",
Authorization: enums.GuestRole,
Middleware: []gin.HandlerFunc{},
Function: ctrl.getImageUploadUrl,
},
},
}
}
// @Summary Get presigned URL for avatar upload
// @Tags Upload
// @Accept json
// @Produce json
// @Success 200 {object} models.PresignedUploadResponse "Presigned URL and form data"
// @Router /upload/avatar [get]
func (ctrl *S3Controller) getAvatarUploadUrl(c *gin.Context) {
url, formData, err := ctrl.s3.CreateAvatarUrl()
if err != nil {
ctrl.log.Error("Failed to generate avatar upload URL", zap.Error(err))
c.Status(http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, models.PresignedUploadResponse{
Url: *url,
Fields: *formData,
})
}
// @Summary Get presigned URL for image upload
// @Tags Upload
// @Accept json
// @Produce json
// @Success 200 {object} models.PresignedUploadResponse "Presigned URL and form data"
// @Router /upload/image [get]
func (ctrl *S3Controller) getImageUploadUrl(c *gin.Context) {
url, formData, err := ctrl.s3.CreateImageUrl()
if err != nil {
c.Status(http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, models.PresignedUploadResponse{
Url: *url,
Fields: *formData,
})
}

View File

@@ -65,8 +65,10 @@ func setupControllers(p SetupControllersParams) {
var Module = fx.Module("controllers", var Module = fx.Module("controllers",
fx.Provide( fx.Provide(
fx.Annotate(NewAuthController, fx.ResultTags(`group:"controllers"`)),
fx.Annotate(NewServiceController, fx.ResultTags(`group:"controllers"`)), fx.Annotate(NewServiceController, fx.ResultTags(`group:"controllers"`)),
fx.Annotate(NewAuthController, fx.ResultTags(`group:"controllers"`)),
fx.Annotate(NewProfileController, fx.ResultTags(`group:"controllers"`)),
fx.Annotate(NewS3Controller, fx.ResultTags(`group:"controllers"`)),
), ),
fx.Invoke(setupControllers), fx.Invoke(setupControllers),
) )

View File

@@ -15,7 +15,7 @@ type BannedUser struct {
Reason *string Reason *string
ExpiresAt pgtype.Timestamp ExpiresAt pgtype.Timestamp
BannedBy *string BannedBy *string
Pardoned *bool Pardoned bool
PardonedBy *string PardonedBy *string
} }
@@ -25,8 +25,8 @@ type ConfirmationCode struct {
CodeType int32 CodeType int32
CodeHash string CodeHash string
ExpiresAt pgtype.Timestamp ExpiresAt pgtype.Timestamp
Used *bool Used bool
Deleted *bool Deleted bool
} }
type LoginInformation struct { type LoginInformation struct {
@@ -53,13 +53,13 @@ type Profile struct {
type ProfileSetting struct { type ProfileSetting struct {
ID int64 ID int64
ProfileID int64 ProfileID int64
HideFulfilled *bool HideFulfilled bool
HideProfileDetails *bool HideProfileDetails bool
HideForUnauthenticated *bool HideForUnauthenticated bool
HideBirthday *bool HideBirthday bool
HideDates *bool HideDates bool
Captcha *bool Captcha bool
FollowersOnlyInteraction *bool FollowersOnlyInteraction bool
} }
type Session struct { type Session struct {
@@ -78,8 +78,35 @@ type Session struct {
type User struct { type User struct {
ID int64 ID int64
Username string Username string
Verified *bool Verified bool
RegistrationDate pgtype.Timestamp RegistrationDate pgtype.Timestamp
Role int32 Role int32
Deleted *bool 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" "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 const checkUserRegistrationAvailability = `-- name: CheckUserRegistrationAvailability :one
SELECT SELECT
COUNT(CASE WHEN users.username = $1::text THEN 1 END) > 0 AS username_busy, 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 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
@@ -253,6 +338,108 @@ func (q *Queries) CreateUser(ctx context.Context, username string) (User, error)
return i, err 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 const deleteUnverifiedAccountsHavingUsernameOrEmail = `-- name: DeleteUnverifiedAccountsHavingUsernameOrEmail :one
WITH deleted_rows AS ( WITH deleted_rows AS (
DELETE FROM users DELETE FROM users
@@ -345,59 +532,44 @@ func (q *Queries) GetProfileByUsername(ctx context.Context, username string) (Pr
return i, err return i, err
} }
const getProfileByUsernameRestricted = `-- name: GetProfileByUsernameRestricted :one const getProfileByUsernameWithPrivacy = `-- name: GetProfileByUsernameWithPrivacy :one
SELECT SELECT
users.username, u.username,
profiles.name, p.name,
CASE p.bio,
WHEN profile_settings.hide_birthday OR profile_settings.hide_profile_details THEN NULL p.avatar_url,
ELSE profiles.birthday CASE WHEN ps.hide_birthday THEN NULL ELSE p.birthday END AS birthday,
END AS birthday, p.color,
CASE p.color_grad
WHEN profile_settings.hide_profile_details THEN NULL FROM
ELSE profiles.bio users AS u
END AS bio, JOIN profiles AS p ON u.id = p.user_id
CASE JOIN profile_settings AS ps ON p.id = ps.profile_id
WHEN profile_settings.hide_profile_details THEN NULL WHERE
ELSE profiles.avatar_url u.username = $1::text
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)
` `
type GetProfileByUsernameRestrictedParams struct { type GetProfileByUsernameWithPrivacyRow struct {
Username string Username string
Column2 *bool Name string
Bio string
AvatarUrl string
Birthday pgtype.Timestamp
Color string
ColorGrad string
} }
type GetProfileByUsernameRestrictedRow struct { func (q *Queries) GetProfileByUsernameWithPrivacy(ctx context.Context, searchedUsername string) (GetProfileByUsernameWithPrivacyRow, error) {
Username string row := q.db.QueryRow(ctx, getProfileByUsernameWithPrivacy, searchedUsername)
Name string var i GetProfileByUsernameWithPrivacyRow
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
err := row.Scan( err := row.Scan(
&i.Username, &i.Username,
&i.Name, &i.Name,
&i.Birthday,
&i.Bio, &i.Bio,
&i.AvatarUrl, &i.AvatarUrl,
&i.Birthday,
&i.Color, &i.Color,
&i.ColorGrad, &i.ColorGrad,
&i.HideProfileDetails,
) )
return i, err return i, err
} }
@@ -456,7 +628,7 @@ type GetProfilesRestrictedRow struct {
AvatarUrl *string AvatarUrl *string
Color string Color string
ColorGrad string ColorGrad string
HideProfileDetails *bool HideProfileDetails bool
} }
func (q *Queries) GetProfilesRestricted(ctx context.Context, arg GetProfilesRestrictedParams) ([]GetProfilesRestrictedRow, error) { func (q *Queries) GetProfilesRestricted(ctx context.Context, arg GetProfilesRestrictedParams) ([]GetProfilesRestrictedRow, error) {
@@ -722,11 +894,11 @@ type GetValidConfirmationCodesByUsernameRow struct {
CodeType int32 CodeType int32
CodeHash string CodeHash string
ExpiresAt pgtype.Timestamp ExpiresAt pgtype.Timestamp
Used *bool Used bool
Deleted *bool Deleted bool
ID_2 int64 ID_2 int64
Username string Username string
Verified *bool Verified bool
RegistrationDate pgtype.Timestamp RegistrationDate pgtype.Timestamp
Role int32 Role int32
Deleted_2 *bool Deleted_2 *bool
@@ -773,12 +945,17 @@ SELECT
linfo.totp_encrypted linfo.totp_encrypted
FROM users FROM users
JOIN login_informations AS linfo ON users.id = linfo.user_id JOIN login_informations AS linfo ON users.id = linfo.user_id
LEFT JOIN banned_users AS banned ON users.id = banned.user_id
WHERE WHERE
users.username = $1 AND users.username = $1 AND
users.verified IS TRUE AND -- Verified users.verified IS TRUE AND -- Verified
users.deleted IS FALSE AND -- Not deleted 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) linfo.password_hash = crypt($2::text, linfo.password_hash)
` `
@@ -790,7 +967,7 @@ type GetValidUserByLoginCredentialsParams struct {
type GetValidUserByLoginCredentialsRow struct { type GetValidUserByLoginCredentialsRow struct {
ID int64 ID int64
Username string Username string
Verified *bool Verified bool
RegistrationDate pgtype.Timestamp RegistrationDate pgtype.Timestamp
Role int32 Role int32
Deleted *bool Deleted *bool
@@ -852,6 +1029,265 @@ func (q *Queries) GetValidUserSessions(ctx context.Context, userID int64) ([]Ses
return items, nil 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 const pruneExpiredConfirmationCodes = `-- name: PruneExpiredConfirmationCodes :exec
DELETE FROM confirmation_codes DELETE FROM confirmation_codes
WHERE expires_at < CURRENT_TIMESTAMP WHERE expires_at < CURRENT_TIMESTAMP
@@ -916,7 +1352,7 @@ type UpdateBannedUserParams struct {
Reason *string Reason *string
ExpiresAt pgtype.Timestamp ExpiresAt pgtype.Timestamp
BannedBy *string BannedBy *string
Pardoned *bool Pardoned bool
PardonedBy *string PardonedBy *string
} }
@@ -942,8 +1378,8 @@ WHERE id = $1
type UpdateConfirmationCodeParams struct { type UpdateConfirmationCodeParams struct {
ID int64 ID int64
Used *bool Used bool
Deleted *bool Deleted bool
} }
func (q *Queries) UpdateConfirmationCode(ctx context.Context, arg UpdateConfirmationCodeParams) error { func (q *Queries) UpdateConfirmationCode(ctx context.Context, arg UpdateConfirmationCodeParams) error {
@@ -990,9 +1426,9 @@ SET
name = COALESCE($2, name), name = COALESCE($2, name),
bio = COALESCE($3, bio), bio = COALESCE($3, bio),
birthday = COALESCE($4, birthday), birthday = COALESCE($4, birthday),
avatar_url = COALESCE($5, avatar_url), avatar_url = COALESCE($7, avatar_url),
color = COALESCE($6, color), color = COALESCE($5, color),
color_grad = COALESCE($7, color_grad) color_grad = COALESCE($6, color_grad)
FROM users FROM users
WHERE username = $1 WHERE username = $1
` `
@@ -1002,9 +1438,9 @@ type UpdateProfileByUsernameParams struct {
Name string Name string
Bio string Bio string
Birthday pgtype.Timestamp Birthday pgtype.Timestamp
AvatarUrl string
Color string Color string
ColorGrad string ColorGrad string
AvatarUrl *string
} }
func (q *Queries) UpdateProfileByUsername(ctx context.Context, arg UpdateProfileByUsernameParams) error { func (q *Queries) UpdateProfileByUsername(ctx context.Context, arg UpdateProfileByUsernameParams) error {
@@ -1013,40 +1449,42 @@ func (q *Queries) UpdateProfileByUsername(ctx context.Context, arg UpdateProfile
arg.Name, arg.Name,
arg.Bio, arg.Bio,
arg.Birthday, arg.Birthday,
arg.AvatarUrl,
arg.Color, arg.Color,
arg.ColorGrad, arg.ColorGrad,
arg.AvatarUrl,
) )
return err return err
} }
const updateProfileSettings = `-- name: UpdateProfileSettings :exec const updateProfileSettingsByUsername = `-- name: UpdateProfileSettingsByUsername :exec
UPDATE profile_settings UPDATE profile_settings ps
SET SET
hide_fulfilled = COALESCE($2, hide_fulfilled), hide_fulfilled = COALESCE($2, ps.hide_fulfilled),
hide_profile_details = COALESCE($3, hide_profile_details), hide_profile_details = COALESCE($3, ps.hide_profile_details),
hide_for_unauthenticated = COALESCE($4, hide_for_unauthenticated), hide_for_unauthenticated = COALESCE($4, ps.hide_for_unauthenticated),
hide_birthday = COALESCE($5, hide_birthday), hide_birthday = COALESCE($5, ps.hide_birthday),
hide_dates = COALESCE($6, hide_dates), hide_dates = COALESCE($6, ps.hide_dates),
captcha = COALESCE($7, captcha), captcha = COALESCE($7, ps.captcha),
followers_only_interaction = COALESCE($8, followers_only_interaction) followers_only_interaction = COALESCE($8, ps.followers_only_interaction)
WHERE id = $1 FROM profiles p
JOIN users u ON p.user_id = u.id
WHERE ps.profile_id = p.id AND u.username = $1
` `
type UpdateProfileSettingsParams struct { type UpdateProfileSettingsByUsernameParams struct {
ID int64 Username string
HideFulfilled *bool HideFulfilled bool
HideProfileDetails *bool HideProfileDetails bool
HideForUnauthenticated *bool HideForUnauthenticated bool
HideBirthday *bool HideBirthday bool
HideDates *bool HideDates bool
Captcha *bool Captcha bool
FollowersOnlyInteraction *bool FollowersOnlyInteraction bool
} }
func (q *Queries) UpdateProfileSettings(ctx context.Context, arg UpdateProfileSettingsParams) error { func (q *Queries) UpdateProfileSettingsByUsername(ctx context.Context, arg UpdateProfileSettingsByUsernameParams) error {
_, err := q.db.Exec(ctx, updateProfileSettings, _, err := q.db.Exec(ctx, updateProfileSettingsByUsername,
arg.ID, arg.Username,
arg.HideFulfilled, arg.HideFulfilled,
arg.HideProfileDetails, arg.HideProfileDetails,
arg.HideForUnauthenticated, arg.HideForUnauthenticated,
@@ -1106,7 +1544,7 @@ WHERE id = $1
type UpdateUserParams struct { type UpdateUserParams struct {
ID int64 ID int64
Verified *bool Verified bool
Deleted *bool Deleted *bool
} }
@@ -1117,13 +1555,15 @@ func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) error {
const updateUserByUsername = `-- name: UpdateUserByUsername :exec const updateUserByUsername = `-- name: UpdateUserByUsername :exec
UPDATE users UPDATE users
SET verified = $2, deleted = $3 SET
verified = COALESCE($2, verified),
deleted = COALESCE($3, deleted)
WHERE username = $1 WHERE username = $1
` `
type UpdateUserByUsernameParams struct { type UpdateUserByUsernameParams struct {
Username string Username string
Verified *bool Verified bool
Deleted *bool Deleted *bool
} }
@@ -1131,3 +1571,76 @@ func (q *Queries) UpdateUserByUsername(ctx context.Context, arg UpdateUserByUser
_, err := q.db.Exec(ctx, updateUserByUsername, arg.Username, arg.Verified, arg.Deleted) _, err := q.db.Exec(ctx, updateUserByUsername, arg.Username, arg.Verified, arg.Deleted)
return err 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

@@ -0,0 +1,22 @@
// 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 UrlDto struct {
Url string `json:"url" binding:"required"`
}

View File

@@ -0,0 +1,46 @@
// 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 ProfileDto struct {
Name string `json:"name"`
Bio string `json:"bio"`
AvatarUrl *string `json:"avatar_url"`
Birthday int64 `json:"birthday"`
Color string `json:"color"`
ColorGrad string `json:"color_grad"`
}
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" validate:"birthday_unix_milli"`
Color string `json:"color" validate:"color_hex"`
ColorGrad string `json:"color_grad" validate:"color_hex"`
}
type ProfileSettingsDto struct {
HideFulfilled bool `json:"hide_fulfilled"`
HideProfileDetails bool `json:"hide_profile_details"`
HideForUnauthenticated bool `json:"hide_for_unauthenticated"`
HideBirthday bool `json:"hide_birthday"`
HideDates bool `json:"hide_dates"`
Captcha bool `json:"captcha"`
FollowersOnlyInteraction bool `json:"followers_only_interaction"`
}

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

@@ -26,4 +26,6 @@ var (
ErrBadRequest = errors.New("Bad request") ErrBadRequest = errors.New("Bad request")
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")
ErrGone = errors.New("Resource no longer available")
) )

View File

@@ -0,0 +1,27 @@
// 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 errors
import (
"errors"
)
var (
ErrFileNotFound = errors.New("File with this key does not exist")
)

View File

@@ -1,3 +1,20 @@
// 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 errors package errors
import ( import (

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 middleware
import (
"net/http"
"github.com/gin-gonic/gin"
"golang.org/x/time/rate"
)
func RateLimitMiddleware(r rate.Limit, b int) gin.HandlerFunc {
limiter := rate.NewLimiter(r, b)
return func(c *gin.Context) {
if !limiter.Allow() {
c.AbortWithStatus(http.StatusTooManyRequests)
return
}
c.Next()
}
}

View File

@@ -0,0 +1,87 @@
// 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 minioclient
import (
"context"
"fmt"
"slices"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/lifecycle"
)
var Buckets map[string]string
func setupBuckets(client *minio.Client) {
Buckets = map[string]string{
"avatars": "ew-avatars",
"images": "ew-images",
"uploads": "ew-uploads",
}
hiddenBuckets := []string{
"uploads",
}
// NOTICE: it has a formatting value in there for the bucket name!!
// I'm kind of ashamed for doing this, but the library did not have
// an API for configuring a policy, so we're left with JSON I guess
readOnlyPolicyTemplate := `{"Version": "2012-10-17","Statement": [{"Action": ["s3:GetObject"],"Effect": "Allow","Principal": {"AWS": ["*"]},"Resource": ["arn:aws:s3:::%s/*"],"Sid": ""}]}`
ctx := context.Background()
var newBuckets []string
for key, value := range Buckets {
bucketExists, err := client.BucketExists(ctx, value); if err != nil {
panic("Failure to check if bucket '" + value + "' exists: " + err.Error())
}
if !bucketExists {
err := client.MakeBucket(ctx, value, minio.MakeBucketOptions{}); if err != nil {
panic("Failure to create bucket '" + value + "': " + err.Error())
}
newBuckets = append(newBuckets, key)
if !slices.Contains(hiddenBuckets, key) {
client.SetBucketPolicy(ctx, value, fmt.Sprintf(readOnlyPolicyTemplate, value))
}
}
}
if slices.Contains(newBuckets, "uploads") {
uploadsCfg := lifecycle.NewConfiguration()
uploadsCfg.Rules = []lifecycle.Rule{
{
ID: "expire-uploads",
Status: "Enabled",
Expiration: lifecycle.Expiration{Days: 1},
},
}
err := client.SetBucketLifecycle(ctx, Buckets["uploads"], uploadsCfg); if err != nil {
errRm := client.RemoveBucket(ctx, Buckets["uploads"])
if errRm != nil {
panic("Failed to set lifecycle configuration for the uploads bucket: '" + err.Error() + "' and then failed to delete it: " + errRm.Error())
}
panic("Failed to set lifecycle configuration for the uploads bucket: " + err.Error())
}
}
}

View File

@@ -0,0 +1,69 @@
// 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 minioclient
import (
"easywish/config"
"fmt"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
)
func setupGinEndpoint(router *gin.Engine) {
cfg := config.GetConfig()
minioHost := fmt.Sprintf("%s:%d", cfg.MinioHost, cfg.MinioPort)
proxy := &httputil.ReverseProxy{
Director: func(req *http.Request) {
path := strings.TrimPrefix(req.URL.Path, "/s3")
targetURL := &url.URL{
Scheme: "http",
Host: minioHost,
Path: path,
RawQuery: req.URL.RawQuery,
}
req.URL = targetURL
req.Host = minioHost
req.Header.Set("Host", minioHost)
req.Header.Del("X-Forwarded-For")
req.Header.Del("X-Forwarded-Host")
req.Header.Del("X-Forwarded-Proto")
},
Transport: &http.Transport{
ResponseHeaderTimeout: time.Duration(cfg.MinioTimeout),
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"error": "Failed to forward request"}`))
fmt.Printf("Proxy error: %v\n", err)
},
}
s3group := router.Group("/s3")
s3group.Any("/*path", func(c *gin.Context) {
proxy.ServeHTTP(c.Writer, c.Request)
})
}

View File

@@ -0,0 +1,61 @@
// 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 minioclient
import (
"easywish/config"
"net/url"
"time"
"github.com/gin-gonic/gin"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
func NewMinioClient(router *gin.Engine) *minio.Client {
cfg := config.GetConfig()
if cfg.MinioUrl == "" {
panic("Failed Minio URL not set in config")
}
minioURL, err := url.Parse(cfg.MinioUrl); if err != nil {
panic("Failed to parse Minio URL: " + err.Error())
}
user := minioURL.User.Username()
password, ok := minioURL.User.Password(); if !ok {
panic("Failed to parse Minio secret key from the URL")
}
client, err := minio.New(minioURL.Host, &minio.Options{
Creds: credentials.NewStaticV4(user, password, ""),
Secure: minioURL.Scheme == "https",
}); if err != nil {
panic("Failed to initiate minio client: " + err.Error())
}
_, err = client.HealthCheck(time.Second*5); if err != nil {
panic("Failed to communicate with minio: " + err.Error())
}
setupBuckets(client)
setupGinEndpoint(router)
return client
}

View File

@@ -32,7 +32,7 @@ type RegistrationCompleteRequest struct {
Username string `json:"username" binding:"required" validate:"username"` Username string `json:"username" binding:"required" validate:"username"`
VerificationCode string `json:"verification_code" binding:"required" validate:"verification_code=reg"` VerificationCode string `json:"verification_code" binding:"required" validate:"verification_code=reg"`
Name string `json:"name" binding:"required" validate:"name"` Name string `json:"name" binding:"required" validate:"name"`
Birthday *string `json:"birthday"` Birthday int64 `json:"birthday" validate:"birthday_unix_milli"`
} }
type RegistrationCompleteResponse struct { type RegistrationCompleteResponse struct {

View File

@@ -0,0 +1,18 @@
// 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 models

View File

@@ -0,0 +1,24 @@
// 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 models
type PresignedUploadResponse struct {
Url string `json:"url"`
Fields map[string]string `json:"fields"`
}

View File

@@ -35,6 +35,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgerrcode" "github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -347,6 +348,14 @@ func (a *authServiceImpl) RegistrationBegin(request models.RegistrationBeginRequ
a.log.Error( a.log.Error(
"Failed to commit transaction", "Failed to commit transaction",
zap.Error(err)) 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 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{ err = db.TXQueries.UpdateConfirmationCode(db.CTX, database.UpdateConfirmationCodeParams{
ID: confirmationCode.ID, ID: confirmationCode.ID,
Used: utils.NewPointer(true), Used: true,
}) })
if err != nil { if err != nil {
@@ -427,7 +436,7 @@ func (a *authServiceImpl) RegistrationComplete(cinfo dto.ClientInfo, request mod
err = db.TXQueries.UpdateUser(db.CTX, database.UpdateUserParams{ err = db.TXQueries.UpdateUser(db.CTX, database.UpdateUserParams{
ID: user.ID, ID: user.ID,
Verified: utils.NewPointer(true), Verified: true,
}) })
if err != nil { if err != nil {
@@ -437,9 +446,15 @@ func (a *authServiceImpl) RegistrationComplete(cinfo dto.ClientInfo, request mod
return nil, errs.ErrServerError return nil, errs.ErrServerError
} }
birthdayTimestamp := pgtype.Timestamp {
Time: time.UnixMilli(request.Birthday),
Valid: true,
}
profile, err = db.TXQueries.CreateProfile(db.CTX, database.CreateProfileParams{ profile, err = db.TXQueries.CreateProfile(db.CTX, database.CreateProfileParams{
UserID: user.ID, UserID: user.ID,
Name: request.Name, Name: request.Name,
Birthday: birthdayTimestamp,
}) })
if err != nil { if err != nil {
@@ -801,6 +816,14 @@ func (a *authServiceImpl) PasswordResetBegin(request models.PasswordResetBeginRe
a.log.Error( a.log.Error(
"Failed to commit transaction", "Failed to commit transaction",
zap.Error(err)) 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 return false, errs.ErrServerError
} }
@@ -857,7 +880,7 @@ func (a *authServiceImpl) PasswordResetComplete(request models.PasswordResetComp
if err = db.TXQueries.UpdateConfirmationCode(db.CTX, database.UpdateConfirmationCodeParams{ if err = db.TXQueries.UpdateConfirmationCode(db.CTX, database.UpdateConfirmationCodeParams{
ID: resetCode.ID, ID: resetCode.ID,
Used: utils.NewPointer(true), Used: true,
}); err != nil { }); err != nil {
a.log.Error( a.log.Error(
"Failed to invalidate password reset code upon use", "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{ session, err = db.TXQueries.CreateSession(db.CTX, database.CreateSessionParams{
UserID: user.ID, UserID: user.ID,
Name: utils.NewPointer("First device"), Name: utils.NewPointer("First device"),
@@ -940,6 +964,7 @@ func (a *authServiceImpl) PasswordResetComplete(request models.PasswordResetComp
return &response, nil 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) { func (a *authServiceImpl) ChangePassword(request models.ChangePasswordRequest, uinfo dto.ClientInfo) (bool, error) {
var err error var err error
@@ -974,7 +999,7 @@ func (a *authServiceImpl) ChangePassword(request models.ChangePasswordRequest, u
return false, errs.ErrServerError return false, errs.ErrServerError
} }
err = db.TXlessQueries.UpdateLoginInformationByUsername(db.CTX, database.UpdateLoginInformationByUsernameParams{ err = db.TXQueries.UpdateLoginInformationByUsername(db.CTX, database.UpdateLoginInformationByUsernameParams{
Username: uinfo.Username, Username: uinfo.Username,
PasswordHash: newPasswordHash, PasswordHash: newPasswordHash,
}); if err != nil { }); if err != nil {

View File

@@ -0,0 +1,245 @@
// 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"
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
}

View File

@@ -0,0 +1,176 @@
// 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 (
"context"
"easywish/config"
minioclient "easywish/internal/minioClient"
errs "easywish/internal/errors"
"easywish/internal/utils"
"fmt"
"net/url"
"time"
"github.com/google/uuid"
"github.com/minio/minio-go/v7"
"go.uber.org/zap"
)
type S3Service interface {
CreateAvatarUrl() (*string, *map[string]string, error)
CreateImageUrl() (*string, *map[string]string, error)
SaveUpload(uploadID string, bucket string) (*string, error)
GetLocalizedFileUrl(key string, bucket string) url.URL
}
type s3ServiceImpl struct {
minio *minio.Client
log *zap.Logger
avatarPolicy minio.PostPolicy
imagePolicy minio.PostPolicy
}
func NewS3Service(_minio *minio.Client, _log *zap.Logger) S3Service {
service := s3ServiceImpl{
minio: _minio,
log: _log,
}
avatarPolicy := minio.NewPostPolicy()
imagePolicy := minio.NewPostPolicy()
// At the moment the parameters match for both policies but this may
// change with introduction of new policies
for _, policy := range [...]*minio.PostPolicy{avatarPolicy, imagePolicy} {
if err := policy.SetBucket(minioclient.Buckets["uploads"]); err != nil {
panic("Failed to set bucket for policy: " + err.Error())
}
if err := policy.SetExpires(time.Now().UTC().Add(10 * time.Minute)); err != nil {
panic("Failed to set expiration time for policy: " + err.Error())
}
if err := policy.SetContentTypeStartsWith("image/"); err != nil {
panic("Failed to set allowed content types for the policy: " + err.Error())
}
if err := policy.SetContentLengthRange(1, 512*1024); err != nil {
panic("Failed to set allowed content length range for the policy: " + err.Error())
}
}
service.imagePolicy = *imagePolicy
service.avatarPolicy = *avatarPolicy
return &service
}
func (s *s3ServiceImpl) genUrl(policy minio.PostPolicy, prefix string) (*string, *map[string]string, error) {
object := prefix + uuid.New().String()
if err := policy.SetKey(object); err != nil {
s.log.Error(
"Failed to set random key for presigned url",
zap.Error(err))
return nil, nil, err
}
url, formData, err := s.minio.PresignedPostPolicy(context.Background(), &policy)
if err != nil {
s.log.Error(
"Failed to generate presigned url",
zap.String("object", object),
zap.Error(err))
return nil, nil, err
}
convertedUrl, err := utils.LocalizeS3Url(url.String())
if err != nil {
s.log.Error(
"Failed to localize object URL to user-accessible format",
zap.String("url", url.String()),
zap.Error(err))
return nil, nil, err
}
return utils.NewPointer(convertedUrl.String()), &formData, nil
}
func (s *s3ServiceImpl) CreateAvatarUrl() (*string, *map[string]string, error) {
return s.genUrl(s.avatarPolicy, "avatar-")
}
func (s *s3ServiceImpl) CreateImageUrl() (*string, *map[string]string, error) {
return s.genUrl(s.imagePolicy, "image-")
}
func (s *s3ServiceImpl) SaveUpload(uploadID string, bucketAlias string) (*string, error) {
sourceBucket := minioclient.Buckets["uploads"]
bucket := minioclient.Buckets[bucketAlias]
newObjectKey := uuid.New().String()
_, err := s.minio.StatObject(context.Background(), sourceBucket, uploadID, minio.StatObjectOptions{})
if err != nil {
if minio.ToErrorResponse(err).Code == minio.NoSuchKey {
return nil, errs.ErrFileNotFound
}
}
_, err = s.minio.CopyObject(context.Background(), minio.CopyDestOptions{
Bucket: bucket,
Object: newObjectKey,
}, minio.CopySrcOptions{
Bucket: sourceBucket,
Object: uploadID,
})
if err != nil {
s.log.Error(
"Failed to copy object to new bucket",
zap.String("sourceBucket", sourceBucket),
zap.String("uploadID", uploadID),
zap.String("destinationBucket", bucket),
zap.String("newObjectKey", newObjectKey),
zap.Error(err))
return nil, err
}
err = s.minio.RemoveObject(context.Background(), sourceBucket, uploadID, minio.RemoveObjectOptions{})
if err != nil {
s.log.Error(
"Failed to remove original object from uploads bucket",
zap.String("sourceBucket", sourceBucket),
zap.String("uploadID", uploadID),
zap.Error(err))
return nil, err
}
return &newObjectKey, nil
}
func (s *s3ServiceImpl) GetLocalizedFileUrl(key string, bucketAlias string) url.URL {
cfg := config.GetConfig()
return url.URL{
Scheme: "http",
Host: fmt.Sprintf("%s:%d", cfg.Hostname, cfg.Port),
Path: fmt.Sprintf("/s3/%s/%s", minioclient.Buckets[bucketAlias], key),
}
}

View File

@@ -23,7 +23,10 @@ import (
var Module = fx.Module("services", var Module = fx.Module("services",
fx.Provide( fx.Provide(
NewS3Service,
NewSmtpService, NewSmtpService,
NewAuthService, 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 JwtAccessTokenType JwtTokenType = iota
JwtRefreshTokenType JwtRefreshTokenType
) )
type Sorting int32
const (
ByDate Sorting = iota
ByScore
)
type SortOrder int32
const (
Descending = iota
Ascending
)

View File

@@ -0,0 +1,34 @@
// 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 MapProfileDto(dbModel database.Profile, dtoModel *dto.ProfileDto) {
if dtoModel == nil {
dtoModel = &dto.ProfileDto{}
}
automapper.Map(&dbModel, dtoModel, func(src *database.Profile, dst *dto.ProfileDto) {
dst.Birthday = src.Birthday.Time.UnixMilli()
})
}

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

@@ -0,0 +1,45 @@
// 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 utils
import (
"easywish/config"
"fmt"
"net/url"
)
// TODO: Move this method to s3 service
func LocalizeS3Url(originalURL string) (*url.URL, error) {
cfg := config.GetConfig()
newDomain := fmt.Sprintf("%s:%d", cfg.Hostname, cfg.Port)
parsedURL, err := url.Parse(originalURL)
if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
}
newURL := url.URL{
Scheme: parsedURL.Scheme,
Host: newDomain,
Path: "/s3" + parsedURL.Path,
RawQuery: parsedURL.RawQuery,
}
return &newURL, nil
}

View File

@@ -21,6 +21,7 @@ import (
"easywish/config" "easywish/config"
"fmt" "fmt"
"regexp" "regexp"
"time"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
) )
@@ -41,6 +42,13 @@ func GetCustomHandlers() []CustomValidatorHandler {
return regexp.MustCompile(`^[a-zA-Z0-9_]{3,20}$`).MatchString(username) 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", FieldName: "name",
Function: func(fl validator.FieldLevel) bool { Function: func(fl validator.FieldLevel) bool {
@@ -48,6 +56,36 @@ func GetCustomHandlers() []CustomValidatorHandler {
return regexp.MustCompile(`^.{1,75}$`).MatchString(username) return regexp.MustCompile(`^.{1,75}$`).MatchString(username)
}}, }},
{
FieldName: "bio",
Function: func(fl validator.FieldLevel) bool {
username := fl.Field().String()
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 {
username := fl.Field().String()
return regexp.MustCompile(`^#[0-9a-f]{6,6}$`).MatchString(username)
}},
{ {
FieldName: "password", FieldName: "password",
Function: func(fl validator.FieldLevel) bool { Function: func(fl validator.FieldLevel) bool {
@@ -96,6 +134,20 @@ func GetCustomHandlers() []CustomValidatorHandler {
panic(fmt.Sprintf("'%s' is not a valid verification code type", codeType)) panic(fmt.Sprintf("'%s' is not a valid verification code type", codeType))
}}, }},
{
FieldName: "upload_id",
Function: func(fl validator.FieldLevel) bool {
uploadType := fl.Param()
uploadID := fl.Field().String()
pattern := fmt.Sprintf(
"^%s-([{(]?([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})[})]?)$",
uploadType,
)
return regexp.MustCompile(pattern).MatchString(uploadID)
}},
} }

View File

@@ -22,6 +22,8 @@ services:
POSTGRES_URL: ${POSTGRES_URL} POSTGRES_URL: ${POSTGRES_URL}
REDIS_URL: ${REDIS_URL} REDIS_URL: ${REDIS_URL}
MINIO_URL: ${MINIO_URL} MINIO_URL: ${MINIO_URL}
MINIO_HOST: ${MINIO_HOST}
MINIO_PORT: ${MINIO_PORT}
JWT_ALGORITHM: ${JWT_ALGORITHM} JWT_ALGORITHM: ${JWT_ALGORITHM}
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
JWT_ISSUER: ${JWT_ISSUER} JWT_ISSUER: ${JWT_ISSUER}
@@ -60,6 +62,7 @@ services:
minio: minio:
image: minio/minio image: minio/minio
command: server /data --console-address ":9001" --ftp="address=:8021" --ftp="passive-port-range=30000-40000"
healthcheck: healthcheck:
test: [ "CMD", "curl", "-I", "localhost:9000/minio/health/live" ] test: [ "CMD", "curl", "-I", "localhost:9000/minio/health/live" ]
interval: 5s interval: 5s
@@ -70,7 +73,8 @@ services:
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
ports: ports:
- "9000:9000" - "9000:9000"
command: server /data - "9001:9001"
- "8021:8021"
networks: networks:
- easywish-network - easywish-network
volumes: volumes:

View File

@@ -22,6 +22,8 @@ services:
POSTGRES_URL: ${POSTGRES_URL} POSTGRES_URL: ${POSTGRES_URL}
REDIS_URL: ${REDIS_URL} REDIS_URL: ${REDIS_URL}
MINIO_URL: ${MINIO_URL} MINIO_URL: ${MINIO_URL}
MINIO_HOST: ${MINIO_HOST}
MINIO_PORT: ${MINIO_PORT}
JWT_ALGORITHM: ${JWT_ALGORITHM} JWT_ALGORITHM: ${JWT_ALGORITHM}
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
JWT_ISSUER: ${JWT_ISSUER} JWT_ISSUER: ${JWT_ISSUER}

View File

@@ -32,7 +32,9 @@ WHERE id = $1;
;-- name: UpdateUserByUsername :exec ;-- name: UpdateUserByUsername :exec
UPDATE users UPDATE users
SET verified = $2, deleted = $3 SET
verified = COALESCE($2, verified),
deleted = COALESCE($3, deleted)
WHERE username = $1; WHERE username = $1;
;-- name: DeleteUser :exec ;-- name: DeleteUser :exec
@@ -56,29 +58,6 @@ SELECT users.* FROM users
JOIN login_informations linfo ON linfo.user_id = users.id JOIN login_informations linfo ON linfo.user_id = users.id
WHERE linfo.email = @email::text; 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 ;-- name: GetValidUserByLoginCredentials :one
SELECT SELECT
users.*, users.*,
@@ -86,12 +65,17 @@ SELECT
linfo.totp_encrypted linfo.totp_encrypted
FROM users FROM users
JOIN login_informations AS linfo ON users.id = linfo.user_id JOIN login_informations AS linfo ON users.id = linfo.user_id
LEFT JOIN banned_users AS banned ON users.id = banned.user_id
WHERE WHERE
users.username = $1 AND users.username = $1 AND
users.verified IS TRUE AND -- Verified users.verified IS TRUE AND -- Verified
users.deleted IS FALSE AND -- Not deleted 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 linfo.password_hash = crypt(@password::text, linfo.password_hash); -- Password hash matches
;-- name: CheckUserRegistrationAvailability :one ;-- name: CheckUserRegistrationAvailability :one
@@ -285,9 +269,9 @@ SET
name = COALESCE($2, name), name = COALESCE($2, name),
bio = COALESCE($3, bio), bio = COALESCE($3, bio),
birthday = COALESCE($4, birthday), birthday = COALESCE($4, birthday),
avatar_url = COALESCE($5, avatar_url), avatar_url = COALESCE(sqlc.narg('avatar_url'), avatar_url),
color = COALESCE($6, color), color = COALESCE($5, color),
color_grad = COALESCE($7, color_grad) color_grad = COALESCE($6, color_grad)
FROM users FROM users
WHERE username = $1; WHERE username = $1;
@@ -296,29 +280,39 @@ SELECT profiles.* FROM profiles
JOIN users ON users.id = profiles.user_id JOIN users ON users.id = profiles.user_id
WHERE users.username = $1; WHERE users.username = $1;
;-- name: GetProfileByUsernameRestricted :one ;-- name: CheckProfileAccess :one
SELECT SELECT
users.username, CASE WHEN u.deleted OR NOT u.verified THEN TRUE ELSE FALSE END AS user_unavailable,
profiles.name, CASE WHEN EXISTS (
CASE SELECT 1
WHEN profile_settings.hide_birthday OR profile_settings.hide_profile_details THEN NULL FROM banned_users
ELSE profiles.birthday WHERE user_id = u.id AND
END AS birthday, pardoned IS FALSE AND
CASE (expires_at IS NULL OR expires_at < CURRENT_TIMESTAMP)
WHEN profile_settings.hide_profile_details THEN NULL ) THEN TRUE ELSE FALSE END AS user_banned,
ELSE profiles.bio CASE WHEN ps.hide_profile_details THEN TRUE ELSE FALSE END AS hidden,
END AS bio, CASE WHEN ps.hide_for_unauthenticated AND @requester::text = '' THEN TRUE ELSE FALSE END AS auth_required,
CASE CASE WHEN ps.captcha THEN TRUE ELSE FALSE END AS captcha_required
WHEN profile_settings.hide_profile_details THEN NULL FROM profiles p
ELSE profiles.avatar_url JOIN profile_settings ps ON ps.profile_id = p.id
END AS avatar_url, JOIN users u ON p.user_id = u.id
profiles.color, WHERE p.id = $1;
profiles.color_grad,
profile_settings.hide_profile_details ;-- name: GetProfileByUsernameWithPrivacy :one
FROM profiles SELECT
JOIN users ON users.id = profiles.user_id u.username,
JOIN profile_settings ON profiles.id = profile_settings.profile_id p.name,
WHERE users.username = $1 AND ($2 IS FALSE OR profile_settings.hide_for_unauthenticated IS FALSE); 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 ;-- name: GetProfilesRestricted :many
SELECT SELECT
@@ -346,17 +340,19 @@ LIMIT 20 OFFSET 20 * $1;
INSERT INTO profile_settings(profile_id) INSERT INTO profile_settings(profile_id)
VALUES ($1) RETURNING *; VALUES ($1) RETURNING *;
;-- name: UpdateProfileSettings :exec ;-- name: UpdateProfileSettingsByUsername :exec
UPDATE profile_settings UPDATE profile_settings ps
SET SET
hide_fulfilled = COALESCE($2, hide_fulfilled), hide_fulfilled = COALESCE($2, ps.hide_fulfilled),
hide_profile_details = COALESCE($3, hide_profile_details), hide_profile_details = COALESCE($3, ps.hide_profile_details),
hide_for_unauthenticated = COALESCE($4, hide_for_unauthenticated), hide_for_unauthenticated = COALESCE($4, ps.hide_for_unauthenticated),
hide_birthday = COALESCE($5, hide_birthday), hide_birthday = COALESCE($5, ps.hide_birthday),
hide_dates = COALESCE($6, hide_dates), hide_dates = COALESCE($6, ps.hide_dates),
captcha = COALESCE($7, captcha), captcha = COALESCE($7, ps.captcha),
followers_only_interaction = COALESCE($8, followers_only_interaction) followers_only_interaction = COALESCE($8, ps.followers_only_interaction)
WHERE id = $1; FROM profiles p
JOIN users u ON p.user_id = u.id
WHERE ps.profile_id = p.id AND u.username = $1;
;-- name: GetProfileSettingsByUsername :one ;-- name: GetProfileSettingsByUsername :one
SELECT profile_settings.* FROM profile_settings SELECT profile_settings.* FROM profile_settings
@@ -365,3 +361,170 @@ JOIN users ON users.id = profiles.user_id
WHERE users.username = $1; 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" ( CREATE TABLE IF NOT EXISTS "users" (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
username VARCHAR(20) UNIQUE NOT NULL, username VARCHAR(20) UNIQUE NOT NULL,
verified BOOLEAN DEFAULT FALSE, verified BOOLEAN NOT NULL DEFAULT FALSE,
registration_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, registration_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
role INTEGER NOT NULL DEFAULT 1, -- enum user role INTEGER NOT NULL DEFAULT 1, -- enum user
deleted BOOLEAN DEFAULT FALSE deleted BOOLEAN DEFAULT FALSE
@@ -35,7 +35,7 @@ CREATE TABLE IF NOT EXISTS "banned_users" (
reason VARCHAR(512), reason VARCHAR(512),
expires_at TIMESTAMP, expires_at TIMESTAMP,
banned_by VARCHAR(20) DEFAULT 'system', banned_by VARCHAR(20) DEFAULT 'system',
pardoned BOOLEAN DEFAULT FALSE, pardoned BOOLEAN NOT NULL DEFAULT FALSE,
pardoned_by VARCHAR(20) 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_type INTEGER NOT NULL CHECK (code_type IN (0, 1)),
code_hash VARCHAR(512) NOT NULL, code_hash VARCHAR(512) NOT NULL,
expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + INTERVAL '10 minutes', expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + INTERVAL '10 minutes',
used BOOLEAN DEFAULT FALSE, used BOOLEAN NOT NULL DEFAULT FALSE,
deleted BOOLEAN DEFAULT FALSE deleted BOOLEAN NOT NULL DEFAULT FALSE
); );
CREATE TABLE IF NOT EXISTS "sessions" ( CREATE TABLE IF NOT EXISTS "sessions" (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, 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), name VARCHAR(175),
platform VARCHAR(175), platform VARCHAR(175),
latest_ip VARCHAR(16), 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, user_id BIGINT UNIQUE NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(75) NOT NULL, name VARCHAR(75) NOT NULL,
bio VARCHAR(512) NOT NULL DEFAULT '', bio VARCHAR(512) NOT NULL DEFAULT '',
avatar_url VARCHAR(255) NOT NULL DEFAULT '', avatar_url VARCHAR(512) NOT NULL DEFAULT '',
birthday TIMESTAMP, birthday TIMESTAMP,
color VARCHAR(7) NOT NULL DEFAULT '#254333', color VARCHAR(7) NOT NULL DEFAULT '#254333',
color_grad VARCHAR(7) NOT NULL DEFAULT '#691E4D' color_grad VARCHAR(7) NOT NULL DEFAULT '#691E4D'
@@ -86,11 +86,38 @@ CREATE TABLE IF NOT EXISTS "profiles" (
CREATE TABLE IF NOT EXISTS "profile_settings" ( CREATE TABLE IF NOT EXISTS "profile_settings" (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
profile_id BIGINT UNIQUE NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, profile_id BIGINT UNIQUE NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
hide_fulfilled BOOLEAN DEFAULT TRUE, hide_fulfilled BOOLEAN NOT NULL DEFAULT TRUE,
hide_profile_details BOOLEAN DEFAULT FALSE, hide_profile_details BOOLEAN NOT NULL DEFAULT FALSE,
hide_for_unauthenticated BOOLEAN DEFAULT FALSE, hide_for_unauthenticated BOOLEAN NOT NULL DEFAULT FALSE,
hide_birthday BOOLEAN DEFAULT FALSE, hide_birthday BOOLEAN NOT NULL DEFAULT FALSE,
hide_dates BOOLEAN DEFAULT FALSE, hide_dates BOOLEAN NOT NULL DEFAULT FALSE,
captcha BOOLEAN DEFAULT FALSE, captcha BOOLEAN NOT NULL DEFAULT FALSE,
followers_only_interaction BOOLEAN 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
);