63 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
6f7d8bf244 refactor: updated profiles 2025-07-18 21:08:18 +03:00
6588190e8b refactor: renamed claims file;
chore: removed more unused stuff
2025-07-18 00:10:52 +03:00
8f04566b5a chore: regenerated swagger;
chore: removed deprecated and unused stuff
2025-07-17 23:15:14 +03:00
f2274f6c58 Merge pull request 'refactor-controllers' (#5) from refactor-controllers into main
Reviewed-on: #5
2025-07-17 22:39:19 +03:00
feb0524d39 Merge pull request 'refactor: declaring controller methods externally because the big idiot swaggo does not want to work unless the comments are attached to a gin handler func;' (#4) from fix-swaggo into refactor-controllers
Reviewed-on: #4
2025-07-17 22:38:34 +03:00
f2753e1495 refactor: declaring controller methods externally because the big idiot swaggo does not want to work unless the comments are attached to a gin handler func;
fix: swagger docs work now;
chore: remove incomplete account and profile controllers;
fix: correct client info type in request middleware
2025-07-17 22:37:07 +03:00
d6e2d02bff refactor: transitioned auth controller to use the new controller structure;
feat: setup DI for controllers;
refactor: marked old utils and routes package parts as deprecated
2025-07-17 21:42:47 +03:00
f9d7439def fix: Setup interface mismatch;
refactor: GetRequest now panics on missing client_info since it is only supposed to be used on handlers behind AuthMiddleware
2025-07-17 17:53:07 +03:00
7298ab662f experiment: prototyping new ASP.NET-like controllers;
feat: ControllerMethod struct for storing data about an individual API endpoint;
feat: controllerImpl struct for setting up a controller;
feat: GetRequest method for parsing and validating a request with automatic abortion on binding/validation errors
2025-07-17 17:20:48 +03:00
ec56f64420 fix: wrong role 'guest' instead of 'user' defaulting in schema 2025-07-17 04:35:34 +03:00
249bbe4a98 feat: add user role support to database and queries;
fix: add max length validation for refresh token in RefreshRequest;
refactor: use named constants for cache durations in AuthService;
refactor: select all user columns in GetValidUserByLoginCredentials query;
2025-07-17 04:31:25 +03:00
b986d45d82 fix: handle large terminated sessions caching with pagination to prevent RAM overflow;
feat: add paginated query for terminated sessions GUIDs with limit and offset;
refactor: batch processing terminated sessions in Redis with pipeline;
chore: log batch caching progress for terminated sessions;
fix: set TTL for session termination cache keys (8 hours);
refactor: update SQL query for terminated sessions to use pagination;
fix: correct loop structure in auth service initialization
2025-07-17 04:09:15 +03:00
827928178e feat: add change password endpoint using old password;
feat: implement change password service method with validation;
fix: correct ErrorIsOneOf function logic to return true on match;
refactor: rename 'log_out_accounts' to 'log_out_sessions' for clarity;
refactor: update session termination to return GUIDs and cache in Redis;
fix: ensure RollbackOnError only rolls back uncommitted transactions;
fix: handle transaction commit errors properly in dbHelper;
refactor: add helper methods for session termination and registration;
refactor: pass client info to login and registration complete methods;
fix: improve token validation error handling in refresh endpoint;
refactor: update auth middleware to set session info correctly;
chore: remove unused ClientInfo DTO;
fix: correct password reset complete to use session termination helper;
refactor: adjust database queries for session management;
chore: update SQL schema and queries for sessions;
docs: update swagger docs with new endpoint and model changes
2025-07-17 03:44:22 +03:00
8b558eaf5f feat: fully implemented Refresh method;
fix: Improve error handling in Refresh method for token validation;
fix: Update Refresh route to use correct request model;
fix: Correct request model for password reset complete route;
fix: Redis pipeline error handling in AuthService constructor;
fix: Refresh method wanted access token;
refactor: Enhance error handling for unexpected token validation errors;
refactor: Simplify claims extraction in ValidateToken method;
fix: Ensure session termination state is correctly dereferenced;
refactor: Return structured session info in ValidateToken method;
feat: New util method to check if an error is one of multiple given ones;
2025-07-15 23:32:25 +03:00
e465da6854 refactor: Simplify AuthMiddleware;
refactor: Move token validation logic to AuthService;
refactor: Remove Redis cache checks from middleware;
fix: Improve error handling for token validation;
refactor: Update Refresh method to use new validation logic;
chore: Clean up unused imports and comments
2025-07-15 22:37:41 +03:00
a582b75c82 feat: new ValidateToken method for AuthService, based on code from the monolithic implementation of auth middleware;
feat: add detailed authentication error types;
2025-07-15 21:59:05 +03:00
b3a405016e refactor: introduce DTOs for claims, session, and request handling
feat: add token validation service method
refactor: update middleware to use structured DTOs
feat: implement session info propagation through context
refactor: replace ad-hoc structs with DTOs in middleware
chore: organize auth-related data structures
2025-07-15 20:54:12 +03:00
ee6cff4104 feat: add registration attempt rate limiting with Redis
feat: prevent email enumeration by caching registration state
fix: correct Redis key formatting for session termination cache
refactor: improve registration flow with Redis cooldown checks
chore: add Redis caching for registration in-progress state
2025-07-15 02:55:26 +03:00
d8ea9f79c6 feat: add session expiration tracking and validation
feat: implement Redis caching for terminated sessions
feat: add new session GUID queries for validation
refactor: extend Session model with last_refresh_exp_time
refactor: update token generation to include role and session
refactor: modify auth middleware to validate session status
refactor: replace GetUserSessions with GetValidUserSessions
chore: add uuid/v5 dependency
fix: update router to pass dependencies to auth middleware
chore: update SQL schema and queries for new expiration field
2025-07-14 20:44:30 +03:00
24cb8ecb6e feat: implemented controller methods for passwordresetcomplete, refresh in auth controller 2025-07-13 20:58:36 +03:00
95294686b7 feat: PasswordResetBegin of auth controller;
fix: sql query updateLoginInformationByUsername used in-database hashing;
refactor: renamed LogOutAccounts into LogOutSessions in models/auth;
refactor: added error checks on opening transactions for all auth service methods;
refactor: added error checks on commiting transactions likewise;
refactor: simplified PasswordResetBegin logic;
feat: implemented PasswordResetComplete method of auth service;
2025-07-13 19:10:34 +03:00
65ea47dbb6 feat: new RollbackOnError method added for transactional db helper and integrated into auth service 2025-07-13 15:57:34 +03:00
a3bebd89be chore: todo comments;
fix: txless creation of confirmation code in passwordresetbegin
2025-07-13 02:18:46 +03:00
a2dd8993a6 feat: auth service logic for purging expired unverified accounts upon registration, new sql queries for this purpose 2025-07-13 01:57:19 +03:00
8fa57eddb1 feat: implemented PasswordResetBegin method in auth service with cooldown for each email being stored in redis 2025-07-12 19:32:53 +03:00
b91ff2c802 refactor: added redisclient connection error check 2025-07-12 17:04:09 +03:00
541847221b chore: tidy swagger comments;
feat: password reset models;
feat: verification code validator
2025-07-11 17:44:48 +03:00
c988a16783 refactor: removed error logs from smtp service since they are redundant 2025-07-10 12:21:23 +03:00
f59b647b27 feat: development compose file;
fix: smtp service
2025-07-10 01:51:48 +03:00
58 changed files with 5752 additions and 1085 deletions

View File

@@ -45,7 +45,8 @@ import (
"easywish/internal/controllers" "easywish/internal/controllers"
"easywish/internal/database" "easywish/internal/database"
"easywish/internal/logger" "easywish/internal/logger"
"easywish/internal/routes" minioclient "easywish/internal/minioClient"
redisclient "easywish/internal/redisClient"
"easywish/internal/services" "easywish/internal/services"
"easywish/internal/validation" "easywish/internal/validation"
@@ -59,10 +60,14 @@ func main() {
panic(err) panic(err)
} }
cfg := config.GetConfig()
fx.New( fx.New(
fx.Provide( fx.Provide(
logger.NewLogger, logger.NewLogger,
logger.NewSyncLogger, logger.NewSyncLogger,
redisclient.NewRedisClient,
minioclient.NewMinioClient,
gin.Default, gin.Default,
), ),
database.Module, database.Module,
@@ -70,7 +75,6 @@ func main() {
validation.Module, validation.Module,
controllers.Module, controllers.Module,
routes.Module,
fx.Invoke(func(lc fx.Lifecycle, router *gin.Engine, syncLogger *logger.SyncLogger) { fx.Invoke(func(lc fx.Lifecycle, router *gin.Engine, syncLogger *logger.SyncLogger) {
@@ -80,7 +84,7 @@ func main() {
// Gin // Gin
server := &http.Server{ server := &http.Server{
Addr: fmt.Sprintf(":%s", strconv.Itoa(int(config.GetConfig().Port))), Addr: fmt.Sprintf(":%s", strconv.Itoa(int(cfg.Port))),
Handler: router, Handler: router,
} }

View File

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

@@ -18,8 +18,8 @@ const docTemplate = `{
"host": "{{.Host}}", "host": "{{.Host}}",
"basePath": "{{.BasePath}}", "basePath": "{{.BasePath}}",
"paths": { "paths": {
"/account/changePassword": { "/auth/changePassword": {
"put": { "post": {
"security": [ "security": [
{ {
"JWT": [] "JWT": []
@@ -32,10 +32,28 @@ const docTemplate = `{
"application/json" "application/json"
], ],
"tags": [ "tags": [
"Account" "Auth"
], ],
"summary": "Change account password", "summary": "Set new password using the old password",
"responses": {} "parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.ChangePasswordRequest"
}
}
],
"responses": {
"200": {
"description": "Password successfully changed"
},
"403": {
"description": "Invalid old password"
}
}
} }
}, },
"/auth/login": { "/auth/login": {
@@ -52,7 +70,7 @@ const docTemplate = `{
"summary": "Acquire tokens via login credentials (and 2FA code if needed)", "summary": "Acquire tokens via login credentials (and 2FA code if needed)",
"parameters": [ "parameters": [
{ {
"description": "desc", "description": " ",
"name": "request", "name": "request",
"in": "body", "in": "body",
"required": true, "required": true,
@@ -63,10 +81,13 @@ const docTemplate = `{
], ],
"responses": { "responses": {
"200": { "200": {
"description": "desc", "description": " ",
"schema": { "schema": {
"$ref": "#/definitions/models.LoginResponse" "$ref": "#/definitions/models.LoginResponse"
} }
},
"403": {
"description": "Invalid login credentials"
} }
} }
} }
@@ -83,7 +104,25 @@ const docTemplate = `{
"Auth" "Auth"
], ],
"summary": "Request password reset email", "summary": "Request password reset email",
"responses": {} "parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.PasswordResetBeginRequest"
}
}
],
"responses": {
"200": {
"description": "Reset code sent to the email if it is attached to an account"
},
"429": {
"description": "Too many recent requests for this email"
}
}
} }
}, },
"/auth/passwordResetComplete": { "/auth/passwordResetComplete": {
@@ -97,8 +136,29 @@ const docTemplate = `{
"tags": [ "tags": [
"Auth" "Auth"
], ],
"summary": "Complete password reset with email code and provide 2FA code or backup code if needed", "summary": "Complete password reset via email code",
"responses": {} "parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.PasswordResetCompleteRequest"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/models.PasswordResetCompleteResponse"
}
},
"403": {
"description": "Wrong verification code or username"
}
}
} }
}, },
"/auth/refresh": { "/auth/refresh": {
@@ -113,7 +173,28 @@ const docTemplate = `{
"Auth" "Auth"
], ],
"summary": "Receive new tokens via refresh token", "summary": "Receive new tokens via refresh token",
"responses": {} "parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.RefreshRequest"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/models.RefreshResponse"
}
},
"401": {
"description": "Invalid refresh token"
}
}
} }
}, },
"/auth/registrationBegin": { "/auth/registrationBegin": {
@@ -130,7 +211,7 @@ const docTemplate = `{
"summary": "Register an account", "summary": "Register an account",
"parameters": [ "parameters": [
{ {
"description": "desc", "description": " ",
"name": "request", "name": "request",
"in": "body", "in": "body",
"required": true, "required": true,
@@ -145,6 +226,9 @@ const docTemplate = `{
}, },
"409": { "409": {
"description": "Username or email is already taken" "description": "Username or email is already taken"
},
"429": {
"description": "Too many recent registration attempts for this email"
} }
} }
} }
@@ -163,7 +247,7 @@ const docTemplate = `{
"summary": "Confirm with code, finish creating the account", "summary": "Confirm with code, finish creating the account",
"parameters": [ "parameters": [
{ {
"description": "desc", "description": " ",
"name": "request", "name": "request",
"in": "body", "in": "body",
"required": true, "required": true,
@@ -174,35 +258,18 @@ const docTemplate = `{
], ],
"responses": { "responses": {
"200": { "200": {
"description": "desc", "description": " ",
"schema": { "schema": {
"$ref": "#/definitions/models.RegistrationCompleteResponse" "$ref": "#/definitions/models.RegistrationCompleteResponse"
} }
},
"403": {
"description": "Invalid email or verification code"
} }
} }
} }
}, },
"/profile": { "/profile": {
"patch": {
"security": [
{
"JWT": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Profile"
],
"summary": "Update profile",
"responses": {}
}
},
"/profile/me": {
"get": { "get": {
"security": [ "security": [
{ {
@@ -218,11 +285,54 @@ const docTemplate = `{
"tags": [ "tags": [
"Profile" "Profile"
], ],
"summary": "Get own profile when authorized", "summary": "Get your profile",
"responses": {} "responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/dto.ProfileDto"
}
}
} }
}, },
"/profile/privacy": { "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": { "get": {
"security": [ "security": [
{ {
@@ -238,10 +348,17 @@ const docTemplate = `{
"tags": [ "tags": [
"Profile" "Profile"
], ],
"summary": "Get profile privacy settings", "summary": "Get your profile settings",
"responses": {} "responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/dto.ProfileSettingsDto"
}
}
}
}, },
"patch": { "put": {
"security": [ "security": [
{ {
"JWT": [] "JWT": []
@@ -256,8 +373,26 @@ const docTemplate = `{
"tags": [ "tags": [
"Profile" "Profile"
], ],
"summary": "Update profile privacy settings", "summary": "Update your profile's settings",
"responses": {} "parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.ProfileSettingsDto"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"type": "boolean"
}
}
}
} }
}, },
"/profile/{username}": { "/profile/{username}": {
@@ -276,17 +411,30 @@ const docTemplate = `{
"tags": [ "tags": [
"Profile" "Profile"
], ],
"summary": "Get someone's profile details", "summary": "Get profile by username",
"parameters": [ "parameters": [
{ {
"type": "string", "type": "string",
"description": "Username", "description": " ",
"name": "username", "name": "username",
"in": "path", "in": "path",
"required": true "required": true
} }
], ],
"responses": {} "responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/dto.ProfileDto"
}
},
"403": {
"description": "Restricted profile"
},
"404": {
"description": "Profile not found"
}
}
} }
}, },
"/service/health": { "/service/health": {
@@ -306,7 +454,51 @@ const docTemplate = `{
"200": { "200": {
"description": "Says whether it's healthy or not", "description": "Says whether it's healthy or not",
"schema": { "schema": {
"$ref": "#/definitions/controllers.HealthStatus" "$ref": "#/definitions/models.HealthStatusResponse"
}
}
}
}
},
"/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"
} }
} }
} }
@@ -314,7 +506,100 @@ const docTemplate = `{
} }
}, },
"definitions": { "definitions": {
"controllers.HealthStatus": { "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": {
"type": "object",
"required": [
"old_password",
"password"
],
"properties": {
"old_password": {
"type": "string"
},
"password": {
"type": "string"
},
"totp": {
"type": "string"
}
}
},
"models.HealthStatusResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
"healthy": { "healthy": {
@@ -354,9 +639,91 @@ const docTemplate = `{
} }
} }
}, },
"models.PasswordResetBeginRequest": {
"type": "object",
"required": [
"email"
],
"properties": {
"email": {
"type": "string"
}
}
},
"models.PasswordResetCompleteRequest": {
"type": "object",
"required": [
"email",
"password",
"verification_code"
],
"properties": {
"email": {
"type": "string"
},
"log_out_sessions": {
"type": "boolean"
},
"password": {
"type": "string"
},
"verification_code": {
"type": "string"
}
}
},
"models.PasswordResetCompleteResponse": {
"type": "object",
"properties": {
"access_token": {
"type": "string"
},
"refresh_token": {
"type": "string"
}
}
},
"models.PresignedUploadResponse": {
"type": "object",
"properties": {
"fields": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"url": {
"type": "string"
}
}
},
"models.RefreshRequest": {
"type": "object",
"required": [
"refresh_token"
],
"properties": {
"refresh_token": {
"type": "string",
"maxLength": 2000
}
}
},
"models.RefreshResponse": {
"type": "object",
"properties": {
"access_token": {
"type": "string"
},
"refresh_token": {
"type": "string"
}
}
},
"models.RegistrationBeginRequest": { "models.RegistrationBeginRequest": {
"type": "object", "type": "object",
"required": [ "required": [
"email",
"password", "password",
"username" "username"
], ],
@@ -381,7 +748,7 @@ const docTemplate = `{
], ],
"properties": { "properties": {
"birthday": { "birthday": {
"type": "string" "type": "integer"
}, },
"name": { "name": {
"type": "string" "type": "string"

View File

@@ -14,8 +14,8 @@
}, },
"basePath": "/api/", "basePath": "/api/",
"paths": { "paths": {
"/account/changePassword": { "/auth/changePassword": {
"put": { "post": {
"security": [ "security": [
{ {
"JWT": [] "JWT": []
@@ -28,10 +28,28 @@
"application/json" "application/json"
], ],
"tags": [ "tags": [
"Account" "Auth"
], ],
"summary": "Change account password", "summary": "Set new password using the old password",
"responses": {} "parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.ChangePasswordRequest"
}
}
],
"responses": {
"200": {
"description": "Password successfully changed"
},
"403": {
"description": "Invalid old password"
}
}
} }
}, },
"/auth/login": { "/auth/login": {
@@ -48,7 +66,7 @@
"summary": "Acquire tokens via login credentials (and 2FA code if needed)", "summary": "Acquire tokens via login credentials (and 2FA code if needed)",
"parameters": [ "parameters": [
{ {
"description": "desc", "description": " ",
"name": "request", "name": "request",
"in": "body", "in": "body",
"required": true, "required": true,
@@ -59,10 +77,13 @@
], ],
"responses": { "responses": {
"200": { "200": {
"description": "desc", "description": " ",
"schema": { "schema": {
"$ref": "#/definitions/models.LoginResponse" "$ref": "#/definitions/models.LoginResponse"
} }
},
"403": {
"description": "Invalid login credentials"
} }
} }
} }
@@ -79,7 +100,25 @@
"Auth" "Auth"
], ],
"summary": "Request password reset email", "summary": "Request password reset email",
"responses": {} "parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.PasswordResetBeginRequest"
}
}
],
"responses": {
"200": {
"description": "Reset code sent to the email if it is attached to an account"
},
"429": {
"description": "Too many recent requests for this email"
}
}
} }
}, },
"/auth/passwordResetComplete": { "/auth/passwordResetComplete": {
@@ -93,8 +132,29 @@
"tags": [ "tags": [
"Auth" "Auth"
], ],
"summary": "Complete password reset with email code and provide 2FA code or backup code if needed", "summary": "Complete password reset via email code",
"responses": {} "parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.PasswordResetCompleteRequest"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/models.PasswordResetCompleteResponse"
}
},
"403": {
"description": "Wrong verification code or username"
}
}
} }
}, },
"/auth/refresh": { "/auth/refresh": {
@@ -109,7 +169,28 @@
"Auth" "Auth"
], ],
"summary": "Receive new tokens via refresh token", "summary": "Receive new tokens via refresh token",
"responses": {} "parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.RefreshRequest"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/models.RefreshResponse"
}
},
"401": {
"description": "Invalid refresh token"
}
}
} }
}, },
"/auth/registrationBegin": { "/auth/registrationBegin": {
@@ -126,7 +207,7 @@
"summary": "Register an account", "summary": "Register an account",
"parameters": [ "parameters": [
{ {
"description": "desc", "description": " ",
"name": "request", "name": "request",
"in": "body", "in": "body",
"required": true, "required": true,
@@ -141,6 +222,9 @@
}, },
"409": { "409": {
"description": "Username or email is already taken" "description": "Username or email is already taken"
},
"429": {
"description": "Too many recent registration attempts for this email"
} }
} }
} }
@@ -159,7 +243,7 @@
"summary": "Confirm with code, finish creating the account", "summary": "Confirm with code, finish creating the account",
"parameters": [ "parameters": [
{ {
"description": "desc", "description": " ",
"name": "request", "name": "request",
"in": "body", "in": "body",
"required": true, "required": true,
@@ -170,35 +254,18 @@
], ],
"responses": { "responses": {
"200": { "200": {
"description": "desc", "description": " ",
"schema": { "schema": {
"$ref": "#/definitions/models.RegistrationCompleteResponse" "$ref": "#/definitions/models.RegistrationCompleteResponse"
} }
},
"403": {
"description": "Invalid email or verification code"
} }
} }
} }
}, },
"/profile": { "/profile": {
"patch": {
"security": [
{
"JWT": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Profile"
],
"summary": "Update profile",
"responses": {}
}
},
"/profile/me": {
"get": { "get": {
"security": [ "security": [
{ {
@@ -214,11 +281,54 @@
"tags": [ "tags": [
"Profile" "Profile"
], ],
"summary": "Get own profile when authorized", "summary": "Get your profile",
"responses": {} "responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/dto.ProfileDto"
}
}
} }
}, },
"/profile/privacy": { "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": { "get": {
"security": [ "security": [
{ {
@@ -234,10 +344,17 @@
"tags": [ "tags": [
"Profile" "Profile"
], ],
"summary": "Get profile privacy settings", "summary": "Get your profile settings",
"responses": {} "responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/dto.ProfileSettingsDto"
}
}
}
}, },
"patch": { "put": {
"security": [ "security": [
{ {
"JWT": [] "JWT": []
@@ -252,8 +369,26 @@
"tags": [ "tags": [
"Profile" "Profile"
], ],
"summary": "Update profile privacy settings", "summary": "Update your profile's settings",
"responses": {} "parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.ProfileSettingsDto"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"type": "boolean"
}
}
}
} }
}, },
"/profile/{username}": { "/profile/{username}": {
@@ -272,17 +407,30 @@
"tags": [ "tags": [
"Profile" "Profile"
], ],
"summary": "Get someone's profile details", "summary": "Get profile by username",
"parameters": [ "parameters": [
{ {
"type": "string", "type": "string",
"description": "Username", "description": " ",
"name": "username", "name": "username",
"in": "path", "in": "path",
"required": true "required": true
} }
], ],
"responses": {} "responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/dto.ProfileDto"
}
},
"403": {
"description": "Restricted profile"
},
"404": {
"description": "Profile not found"
}
}
} }
}, },
"/service/health": { "/service/health": {
@@ -302,7 +450,51 @@
"200": { "200": {
"description": "Says whether it's healthy or not", "description": "Says whether it's healthy or not",
"schema": { "schema": {
"$ref": "#/definitions/controllers.HealthStatus" "$ref": "#/definitions/models.HealthStatusResponse"
}
}
}
}
},
"/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"
} }
} }
} }
@@ -310,7 +502,100 @@
} }
}, },
"definitions": { "definitions": {
"controllers.HealthStatus": { "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": {
"type": "object",
"required": [
"old_password",
"password"
],
"properties": {
"old_password": {
"type": "string"
},
"password": {
"type": "string"
},
"totp": {
"type": "string"
}
}
},
"models.HealthStatusResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
"healthy": { "healthy": {
@@ -350,9 +635,91 @@
} }
} }
}, },
"models.PasswordResetBeginRequest": {
"type": "object",
"required": [
"email"
],
"properties": {
"email": {
"type": "string"
}
}
},
"models.PasswordResetCompleteRequest": {
"type": "object",
"required": [
"email",
"password",
"verification_code"
],
"properties": {
"email": {
"type": "string"
},
"log_out_sessions": {
"type": "boolean"
},
"password": {
"type": "string"
},
"verification_code": {
"type": "string"
}
}
},
"models.PasswordResetCompleteResponse": {
"type": "object",
"properties": {
"access_token": {
"type": "string"
},
"refresh_token": {
"type": "string"
}
}
},
"models.PresignedUploadResponse": {
"type": "object",
"properties": {
"fields": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"url": {
"type": "string"
}
}
},
"models.RefreshRequest": {
"type": "object",
"required": [
"refresh_token"
],
"properties": {
"refresh_token": {
"type": "string",
"maxLength": 2000
}
}
},
"models.RefreshResponse": {
"type": "object",
"properties": {
"access_token": {
"type": "string"
},
"refresh_token": {
"type": "string"
}
}
},
"models.RegistrationBeginRequest": { "models.RegistrationBeginRequest": {
"type": "object", "type": "object",
"required": [ "required": [
"email",
"password", "password",
"username" "username"
], ],
@@ -377,7 +744,7 @@
], ],
"properties": { "properties": {
"birthday": { "birthday": {
"type": "string" "type": "integer"
}, },
"name": { "name": {
"type": "string" "type": "string"

View File

@@ -1,6 +1,67 @@
basePath: /api/ basePath: /api/
definitions: definitions:
controllers.HealthStatus: 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:
properties:
old_password:
type: string
password:
type: string
totp:
type: string
required:
- old_password
- password
type: object
models.HealthStatusResponse:
properties: properties:
healthy: healthy:
type: boolean type: boolean
@@ -27,6 +88,59 @@ definitions:
refresh_token: refresh_token:
type: string type: string
type: object type: object
models.PasswordResetBeginRequest:
properties:
email:
type: string
required:
- email
type: object
models.PasswordResetCompleteRequest:
properties:
email:
type: string
log_out_sessions:
type: boolean
password:
type: string
verification_code:
type: string
required:
- email
- password
- verification_code
type: object
models.PasswordResetCompleteResponse:
properties:
access_token:
type: string
refresh_token:
type: string
type: object
models.PresignedUploadResponse:
properties:
fields:
additionalProperties:
type: string
type: object
url:
type: string
type: object
models.RefreshRequest:
properties:
refresh_token:
maxLength: 2000
type: string
required:
- refresh_token
type: object
models.RefreshResponse:
properties:
access_token:
type: string
refresh_token:
type: string
type: object
models.RegistrationBeginRequest: models.RegistrationBeginRequest:
properties: properties:
email: email:
@@ -36,13 +150,14 @@ definitions:
username: username:
type: string type: string
required: required:
- email
- password - password
- username - username
type: object type: object
models.RegistrationCompleteRequest: models.RegistrationCompleteRequest:
properties: properties:
birthday: birthday:
type: string type: integer
name: name:
type: string type: string
username: username:
@@ -69,24 +184,35 @@ info:
title: Easywish client API title: Easywish client API
version: "1.0" version: "1.0"
paths: paths:
/account/changePassword: /auth/changePassword:
put: post:
consumes: consumes:
- application/json - application/json
parameters:
- description: ' '
in: body
name: request
required: true
schema:
$ref: '#/definitions/models.ChangePasswordRequest'
produces: produces:
- application/json - application/json
responses: {} responses:
"200":
description: Password successfully changed
"403":
description: Invalid old password
security: security:
- JWT: [] - JWT: []
summary: Change account password summary: Set new password using the old password
tags: tags:
- Account - Auth
/auth/login: /auth/login:
post: post:
consumes: consumes:
- application/json - application/json
parameters: parameters:
- description: desc - description: ' '
in: body in: body
name: request name: request
required: true required: true
@@ -96,9 +222,11 @@ paths:
- application/json - application/json
responses: responses:
"200": "200":
description: desc description: ' '
schema: schema:
$ref: '#/definitions/models.LoginResponse' $ref: '#/definitions/models.LoginResponse'
"403":
description: Invalid login credentials
summary: Acquire tokens via login credentials (and 2FA code if needed) summary: Acquire tokens via login credentials (and 2FA code if needed)
tags: tags:
- Auth - Auth
@@ -106,9 +234,20 @@ paths:
post: post:
consumes: consumes:
- application/json - application/json
parameters:
- description: ' '
in: body
name: request
required: true
schema:
$ref: '#/definitions/models.PasswordResetBeginRequest'
produces: produces:
- application/json - application/json
responses: {} responses:
"200":
description: Reset code sent to the email if it is attached to an account
"429":
description: Too many recent requests for this email
summary: Request password reset email summary: Request password reset email
tags: tags:
- Auth - Auth
@@ -116,20 +255,45 @@ paths:
post: post:
consumes: consumes:
- application/json - application/json
parameters:
- description: ' '
in: body
name: request
required: true
schema:
$ref: '#/definitions/models.PasswordResetCompleteRequest'
produces: produces:
- application/json - application/json
responses: {} responses:
summary: Complete password reset with email code and provide 2FA code or backup "200":
code if needed description: ' '
schema:
$ref: '#/definitions/models.PasswordResetCompleteResponse'
"403":
description: Wrong verification code or username
summary: Complete password reset via email code
tags: tags:
- Auth - Auth
/auth/refresh: /auth/refresh:
post: post:
consumes: consumes:
- application/json - application/json
parameters:
- description: ' '
in: body
name: request
required: true
schema:
$ref: '#/definitions/models.RefreshRequest'
produces: produces:
- application/json - application/json
responses: {} responses:
"200":
description: ' '
schema:
$ref: '#/definitions/models.RefreshResponse'
"401":
description: Invalid refresh token
summary: Receive new tokens via refresh token summary: Receive new tokens via refresh token
tags: tags:
- Auth - Auth
@@ -138,7 +302,7 @@ paths:
consumes: consumes:
- application/json - application/json
parameters: parameters:
- description: desc - description: ' '
in: body in: body
name: request name: request
required: true required: true
@@ -151,6 +315,8 @@ paths:
description: Account is created and awaiting verification description: Account is created and awaiting verification
"409": "409":
description: Username or email is already taken description: Username or email is already taken
"429":
description: Too many recent registration attempts for this email
summary: Register an account summary: Register an account
tags: tags:
- Auth - Auth
@@ -159,7 +325,7 @@ paths:
consumes: consumes:
- application/json - application/json
parameters: parameters:
- description: desc - description: ' '
in: body in: body
name: request name: request
required: true required: true
@@ -169,22 +335,50 @@ paths:
- application/json - application/json
responses: responses:
"200": "200":
description: desc description: ' '
schema: schema:
$ref: '#/definitions/models.RegistrationCompleteResponse' $ref: '#/definitions/models.RegistrationCompleteResponse'
"403":
description: Invalid email or verification code
summary: Confirm with code, finish creating the account summary: Confirm with code, finish creating the account
tags: tags:
- Auth - Auth
/profile: /profile:
patch: get:
consumes: consumes:
- application/json - application/json
produces: produces:
- application/json - application/json
responses: {} responses:
"200":
description: ' '
schema:
$ref: '#/definitions/dto.ProfileDto'
security: security:
- JWT: [] - JWT: []
summary: Update profile 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: tags:
- Profile - Profile
/profile/{username}: /profile/{username}:
@@ -192,52 +386,63 @@ paths:
consumes: consumes:
- application/json - application/json
parameters: parameters:
- description: Username - description: ' '
in: path in: path
name: username name: username
required: true required: true
type: string type: string
produces: produces:
- application/json - application/json
responses: {} responses:
"200":
description: ' '
schema:
$ref: '#/definitions/dto.ProfileDto'
"403":
description: Restricted profile
"404":
description: Profile not found
security: security:
- JWT: [] - JWT: []
summary: Get someone's profile details summary: Get profile by username
tags: tags:
- Profile - Profile
/profile/me: /profile/settings:
get: get:
consumes: consumes:
- application/json - application/json
produces: produces:
- application/json - application/json
responses: {} responses:
"200":
description: ' '
schema:
$ref: '#/definitions/dto.ProfileSettingsDto'
security: security:
- JWT: [] - JWT: []
summary: Get own profile when authorized summary: Get your profile settings
tags: tags:
- Profile - Profile
/profile/privacy: put:
get:
consumes: consumes:
- application/json - application/json
parameters:
- description: ' '
in: body
name: request
required: true
schema:
$ref: '#/definitions/dto.ProfileSettingsDto'
produces: produces:
- application/json - application/json
responses: {} responses:
"200":
description: ' '
schema:
type: boolean
security: security:
- JWT: [] - JWT: []
summary: Get profile privacy settings summary: Update your profile's settings
tags:
- Profile
patch:
consumes:
- application/json
produces:
- application/json
responses: {}
security:
- JWT: []
summary: Update profile privacy settings
tags: tags:
- Profile - Profile
/service/health: /service/health:
@@ -251,10 +456,38 @@ paths:
"200": "200":
description: Says whether it's healthy or not description: Says whether it's healthy or not
schema: schema:
$ref: '#/definitions/controllers.HealthStatus' $ref: '#/definitions/models.HealthStatusResponse'
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,16 +16,21 @@ 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 (
github.com/KyleBanks/depth v1.2.1 // indirect github.com/KyleBanks/depth v1.2.1 // indirect
github.com/bytedance/sonic v1.13.3 // indirect github.com/bytedance/sonic v1.13.3 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect github.com/bytedance/sonic/loader v0.2.4 // 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/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
@@ -31,35 +39,43 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // 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/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

@@ -5,12 +5,18 @@ github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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/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=
@@ -23,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=
@@ -39,6 +47,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
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=
@@ -48,6 +58,8 @@ github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0= github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0=
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -62,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=
@@ -76,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=
@@ -117,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=
@@ -138,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=
@@ -147,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=
@@ -160,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=
@@ -169,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=
@@ -180,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

@@ -19,7 +19,6 @@ package controllers
import ( import (
errs "easywish/internal/errors" errs "easywish/internal/errors"
"easywish/internal/middleware"
"easywish/internal/models" "easywish/internal/models"
"easywish/internal/services" "easywish/internal/services"
"easywish/internal/utils" "easywish/internal/utils"
@@ -31,102 +30,92 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
type AuthController interface { type AuthController struct {
RegistrationBegin(c *gin.Context) auth services.AuthService
RegistrationComplete(c *gin.Context)
Login(c *gin.Context)
Refresh(c *gin.Context)
PasswordResetBegin(c *gin.Context)
PasswordResetComplete(c *gin.Context)
Router
}
type authControllerImpl struct {
authService services.AuthService
log *zap.Logger log *zap.Logger
} }
func NewAuthController(_log *zap.Logger, as services.AuthService) AuthController { func NewAuthController(log *zap.Logger, auth services.AuthService) Controller {
return &authControllerImpl{log: _log, authService: as} ctrl := &AuthController{auth: auth, log: log}
}
// @Summary Acquire tokens via login credentials (and 2FA code if needed) return &controllerImpl{
// @Tags Auth Path: "/auth",
// @Accept json Middleware: []gin.HandlerFunc{},
// @Produce json Methods: []ControllerMethod{
// @Param request body models.LoginRequest true "desc" {
// @Success 200 {object} models.LoginResponse "desc" HttpMethod: POST,
// @Router /auth/login [post] Path: "/registrationBegin",
func (a *authControllerImpl) Login(c *gin.Context) { Authorization: enums.GuestRole,
request, ok := utils.GetRequest[models.LoginRequest](c) Middleware: []gin.HandlerFunc{},
if !ok { Function: ctrl.registrationBeginHandler,
c.Status(http.StatusBadRequest) },
return {
HttpMethod: POST,
Path: "/registrationComplete",
Authorization: enums.GuestRole,
Middleware: []gin.HandlerFunc{},
Function: ctrl.registrationCompleteHandler,
},
{
HttpMethod: POST,
Path: "/login",
Authorization: enums.GuestRole,
Middleware: []gin.HandlerFunc{},
Function: ctrl.loginHandler,
},
{
HttpMethod: POST,
Path: "/refresh",
Authorization: enums.GuestRole,
Middleware: []gin.HandlerFunc{},
Function: ctrl.refreshHandler,
},
{
HttpMethod: POST,
Path: "/passwordResetBegin",
Authorization: enums.GuestRole,
Middleware: []gin.HandlerFunc{},
Function: ctrl.passwordResetBeginHandler,
},
{
HttpMethod: POST,
Path: "/passwordResetComplete",
Authorization: enums.GuestRole,
Middleware: []gin.HandlerFunc{},
Function: ctrl.passwordResetCompleteHandler,
},
{
HttpMethod: POST,
Path: "/changePassword",
Authorization: enums.UserRole,
Middleware: []gin.HandlerFunc{},
Function: ctrl.changePasswordHandler,
},
},
} }
response, err := a.authService.Login(request.Body)
if err != nil {
if errors.Is(err, errs.ErrForbidden) {
c.Status(http.StatusForbidden)
} else {
c.Status(http.StatusInternalServerError)
}
return
}
c.JSON(http.StatusOK, response)
return
}
// @Summary Request password reset email
// @Tags Auth
// @Accept json
// @Produce json
// @Router /auth/passwordResetBegin [post]
func (a *authControllerImpl) PasswordResetBegin(c *gin.Context) {
c.Status(http.StatusNotImplemented)
}
// @Summary Complete password reset with email code and provide 2FA code or backup code if needed
// @Tags Auth
// @Accept json
// @Produce json
// @Router /auth/passwordResetComplete [post]
func (a *authControllerImpl) PasswordResetComplete(c *gin.Context) {
c.Status(http.StatusNotImplemented)
}
// @Summary Receive new tokens via refresh token
// @Tags Auth
// @Accept json
// @Produce json
// @Router /auth/refresh [post]
func (a *authControllerImpl) Refresh(c *gin.Context) {
c.Status(http.StatusNotImplemented)
} }
// @Summary Register an account // @Summary Register an account
// @Tags Auth // @Tags Auth
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param request body models.RegistrationBeginRequest true "desc" // @Param request body models.RegistrationBeginRequest true " "
// @Success 200 "Account is created and awaiting verification" // @Success 200 "Account is created and awaiting verification"
// @Success 409 "Username or email is already taken" // @Failure 409 "Username or email is already taken"
// @Failure 429 "Too many recent registration attempts for this email"
// @Router /auth/registrationBegin [post] // @Router /auth/registrationBegin [post]
func (a *authControllerImpl) RegistrationBegin(c *gin.Context) { func (ctrl *AuthController) registrationBeginHandler(c *gin.Context) {
request, err := GetRequest[models.RegistrationBeginRequest](c)
request, ok := utils.GetRequest[models.RegistrationBeginRequest](c) if err != nil {
if !ok {
c.Status(http.StatusBadRequest)
return return
} }
_, err := a.authService.RegistrationBegin(request.Body) _, err = ctrl.auth.RegistrationBegin(request.Body)
if err != nil { if err != nil {
if errors.Is(err, errs.ErrUsernameTaken) || errors.Is(err, errs.ErrEmailTaken) { if errors.Is(err, errs.ErrUsernameTaken) || errors.Is(err, errs.ErrEmailTaken) {
c.Status(http.StatusConflict) c.Status(http.StatusConflict)
} else if errors.Is(err, errs.ErrTooManyRequests) {
c.Status(http.StatusTooManyRequests)
} else { } else {
c.Status(http.StatusInternalServerError) c.Status(http.StatusInternalServerError)
} }
@@ -134,25 +123,23 @@ func (a *authControllerImpl) RegistrationBegin(c *gin.Context) {
} }
c.Status(http.StatusOK) c.Status(http.StatusOK)
return
} }
// @Summary Confirm with code, finish creating the account // @Summary Confirm with code, finish creating the account
// @Tags Auth // @Tags Auth
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param request body models.RegistrationCompleteRequest true "desc" // @Param request body models.RegistrationCompleteRequest true " "
// @Success 200 {object} models.RegistrationCompleteResponse "desc" // @Success 200 {object} models.RegistrationCompleteResponse " "
// @Failure 403 "Invalid email or verification code"
// @Router /auth/registrationComplete [post] // @Router /auth/registrationComplete [post]
func (a *authControllerImpl) RegistrationComplete(c *gin.Context) { func (ctrl *AuthController) registrationCompleteHandler(c *gin.Context) {
request, ok := utils.GetRequest[models.RegistrationCompleteRequest](c) request, err := GetRequest[models.RegistrationCompleteRequest](c)
if !ok { if err != nil {
c.Status(http.StatusBadRequest)
return return
} }
response, err := a.authService.RegistrationComplete(request.Body) response, err := ctrl.auth.RegistrationComplete(request.User, request.Body)
if err != nil { if err != nil {
if errors.Is(err, errs.ErrForbidden) { if errors.Is(err, errs.ErrForbidden) {
c.Status(http.StatusForbidden) c.Status(http.StatusForbidden)
@@ -167,11 +154,146 @@ func (a *authControllerImpl) RegistrationComplete(c *gin.Context) {
c.JSON(http.StatusOK, response) c.JSON(http.StatusOK, response)
} }
func (a *authControllerImpl) RegisterRoutes(group *gin.RouterGroup) { // @Summary Acquire tokens via login credentials (and 2FA code if needed)
group.POST("/registrationBegin", middleware.RequestMiddleware[models.RegistrationBeginRequest](enums.GuestRole), a.RegistrationBegin) // @Tags Auth
group.POST("/registrationComplete", middleware.RequestMiddleware[models.RegistrationCompleteRequest](enums.GuestRole), a.RegistrationComplete) // @Accept json
group.POST("/login", middleware.RequestMiddleware[models.LoginRequest](enums.GuestRole), a.Login) // @Produce json
group.POST("/refresh", middleware.RequestMiddleware[models.RegistrationBeginRequest](enums.UserRole), a.Refresh) // @Param request body models.LoginRequest true " "
group.POST("/passwordResetBegin", middleware.RequestMiddleware[models.RegistrationBeginRequest](enums.GuestRole), a.PasswordResetBegin) // @Success 200 {object} models.LoginResponse " "
group.POST("/passwordResetComplete", middleware.RequestMiddleware[models.RegistrationBeginRequest](enums.GuestRole), a.PasswordResetComplete) // @Failure 403 "Invalid login credentials"
// @Router /auth/login [post]
func (ctrl *AuthController) loginHandler(c *gin.Context) {
request, err := GetRequest[models.LoginRequest](c)
if err != nil {
return
}
response, err := ctrl.auth.Login(request.User, request.Body)
if err != nil {
if errors.Is(err, errs.ErrForbidden) {
c.Status(http.StatusForbidden)
} else {
c.Status(http.StatusInternalServerError)
}
return
}
c.JSON(http.StatusOK, response)
}
// @Summary Receive new tokens via refresh token
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body models.RefreshRequest true " "
// @Router /auth/refresh [post]
// @Success 200 {object} models.RefreshResponse " "
// @Failure 401 "Invalid refresh token"
func (ctrl *AuthController) refreshHandler(c *gin.Context) {
request, err := GetRequest[models.RefreshRequest](c)
if err != nil {
return
}
response, err := ctrl.auth.Refresh(request.Body)
if err != nil {
if utils.ErrorIsOneOf(
err,
errs.ErrTokenExpired,
errs.ErrTokenInvalid,
errs.ErrInvalidToken,
errs.ErrWrongTokenType,
errs.ErrSessionNotFound,
errs.ErrSessionTerminated,
) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusOK, response)
}
// @Summary Request password reset email
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body models.PasswordResetBeginRequest true " "
// @Router /auth/passwordResetBegin [post]
// @Success 200 "Reset code sent to the email if it is attached to an account"
// @Failure 429 "Too many recent requests for this email"
func (ctrl *AuthController) passwordResetBeginHandler(c *gin.Context) {
request, err := GetRequest[models.PasswordResetBeginRequest](c)
if err != nil {
return
}
_, err = ctrl.auth.PasswordResetBegin(request.Body)
if err != nil {
if errors.Is(err, errs.ErrTooManyRequests) {
c.Status(http.StatusTooManyRequests)
} else {
c.Status(http.StatusInternalServerError)
}
return
}
c.Status(http.StatusOK)
}
// @Summary Complete password reset via email code
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body models.PasswordResetCompleteRequest true " "
// @Router /auth/passwordResetComplete [post]
// @Success 200 {object} models.PasswordResetCompleteResponse " "
// @Success 403 "Wrong verification code or username"
func (ctrl *AuthController) passwordResetCompleteHandler(c *gin.Context) {
request, err := GetRequest[models.PasswordResetCompleteRequest](c)
if err != nil {
return
}
response, err := ctrl.auth.PasswordResetComplete(request.Body)
if err != nil {
if errors.Is(err, errs.ErrForbidden) {
c.Status(http.StatusForbidden)
} else {
c.Status(http.StatusInternalServerError)
}
return
}
c.JSON(http.StatusOK, response)
}
// @Summary Set new password using the old password
// @Tags Auth
// @Accept json
// @Produce json
// @Security JWT
// @Param request body models.ChangePasswordRequest true " "
// @Success 200 "Password successfully changed"
// @Failure 403 "Invalid old password"
// @Router /auth/changePassword [post]
func (ctrl *AuthController) changePasswordHandler(c *gin.Context) {
request, err := GetRequest[models.ChangePasswordRequest](c)
if err != nil {
return
}
_, err = ctrl.auth.ChangePassword(request.Body, request.User)
if err != nil {
if errors.Is(err, errs.ErrForbidden) {
c.Status(http.StatusForbidden)
} else {
c.Status(http.StatusInternalServerError)
}
return
}
c.Status(http.StatusOK)
} }

View File

@@ -0,0 +1,154 @@
// 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"
"easywish/internal/services"
"easywish/internal/utils/enums"
"easywish/internal/validation"
"fmt"
"net/http"
"os"
"path/filepath"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
var (
GET = "GET"
POST = "POST"
PUT = "PUT"
PATCH = "PATCH"
DELETE = "DELETE"
)
type ControllerMethod struct {
HttpMethod string
Path string
Authorization enums.Role
Middleware []gin.HandlerFunc
Function func (c *gin.Context)
}
type controllerImpl struct {
Path string
Middleware []gin.HandlerFunc
Methods []ControllerMethod
}
type Controller interface {
Setup(group *gin.RouterGroup, log *zap.Logger, auth services.AuthService)
}
func (ctrl *controllerImpl) Setup(group *gin.RouterGroup, log *zap.Logger, auth services.AuthService) {
ctrlGroup := group.Group(ctrl.Path)
ctrlGroup.Use(ctrl.Middleware...)
for _, method := range ctrl.Methods {
ctrlGroup.Handle(
method.HttpMethod,
method.Path,
append(
method.Middleware,
gin.HandlerFunc(func(c *gin.Context) {
clientInfo, _ := c.Get("client_info")
if clientInfo.(dto.ClientInfo).Role < method.Authorization {
c.AbortWithStatusJSON(
http.StatusForbidden,
gin.H{"error": "Insufficient authorization for this method"})
return
}
}),
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) {
var body ModelT
if err := c.ShouldBindJSON(&body); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return nil, err
}
// TODO: Think hard on a singleton for better performance
validate := validation.NewValidator()
if err := validate.Struct(body); err != nil {
c.AbortWithStatusJSON(
http.StatusBadRequest,
gin.H{"error": err.Error()})
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 {
c.AbortWithStatusJSON(
http.StatusInternalServerError,
gin.H{"error": "Client info was not found"})
panic("No client_info found in gin context. Does the handler use AuthMiddleware?")
}
cinfo := cinfoFromCtx.(dto.ClientInfo)
return cinfo
}

View File

@@ -18,77 +18,184 @@
package controllers package controllers
import ( import (
"easywish/internal/dto"
errs "easywish/internal/errors"
"easywish/internal/services"
"easywish/internal/utils/enums"
"errors"
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap"
) )
type ProfileController interface { type ProfileController struct {
GetProfile(c *gin.Context) log *zap.Logger
GetOwnProfile(c *gin.Context) ps services.ProfileService
UpdateProfile(c *gin.Context)
GetPrivacySettings(c *gin.Context)
UpdatePrivacySettings(c *gin.Context)
Router
} }
type profileControllerImpl struct { 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,
},
},
}
} }
func NewProfileController() ProfileController { // @Summary Get your profile
return &profileControllerImpl{}
}
// @Summary Get someone's profile details
// @Tags Profile // @Tags Profile
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param username path string true "Username"
// @Security JWT // @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] // @Router /profile/{username} [get]
func (p *profileControllerImpl) GetProfile(c *gin.Context) { func (ctrl *ProfileController) getProfileByUsername(c *gin.Context) {
c.Status(http.StatusNotImplemented) 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 own profile when authorized // @Summary Get your profile settings
// @Tags Profile // @Tags Profile
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Security JWT // @Security JWT
// @Router /profile/me [get] // @Success 200 {object} dto.ProfileSettingsDto " "
func (p *profileControllerImpl) GetOwnProfile(c *gin.Context) { // @Router /profile/settings [get]
c.Status(http.StatusNotImplemented) 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 profile // @Summary Update your profile
// @Tags Profile // @Tags Profile
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Security JWT // @Security JWT
// @Router /profile [patch] // @Param request body dto.NewProfileDto true " "
func (p *profileControllerImpl) UpdateProfile(c *gin.Context) { // @Success 200 {object} bool " "
c.Status(http.StatusNotImplemented) // @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 Get profile privacy settings // @Summary Update your profile's settings
// @Tags Profile // @Tags Profile
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Security JWT // @Security JWT
// @Router /profile/privacy [get] // @Param request body dto.ProfileSettingsDto true " "
func (p *profileControllerImpl) GetPrivacySettings(c *gin.Context) { // @Success 200 {object} bool " "
c.Status(http.StatusNotImplemented) // @Router /profile/settings [put]
} func (ctrl *ProfileController) updateProfileSettings(c *gin.Context) {
request, err := GetRequest[dto.ProfileSettingsDto](c); if err != nil {
return
}
// @Summary Update profile privacy settings response, err := ctrl.ps.UpdateProfileSettings(request.User, request.Body); if err != nil || !response {
// @Tags Profile c.Status(http.StatusInternalServerError)
// @Accept json return
// @Produce json }
// @Security JWT
// @Router /profile/privacy [patch]
func (p *profileControllerImpl) UpdatePrivacySettings(c *gin.Context) {
c.Status(http.StatusNotImplemented)
}
func (p *profileControllerImpl) RegisterRoutes(group *gin.RouterGroup) { 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

@@ -18,39 +18,43 @@
package controllers package controllers
import ( import (
"easywish/internal/models"
"easywish/internal/utils/enums"
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
type ServiceController interface { type ServiceController struct {}
HealthCheck(c *gin.Context)
Router func NewServiceController() Controller {
ctrl := &ServiceController{}
return &controllerImpl{
Path: "/service",
Middleware: []gin.HandlerFunc{},
Methods: []ControllerMethod{
{
HttpMethod: GET,
Path: "/health",
Authorization: enums.GuestRole,
Middleware: []gin.HandlerFunc{},
Function: ctrl.healthHandler,
},
},
}
} }
type serviceControllerImpl struct{}
func NewServiceController() ServiceController {
return &serviceControllerImpl{}
}
// HealthCheck implements ServiceController.
// @Summary Get health status // @Summary Get health status
// @Description Used internally for checking service health // @Description Used internally for checking service health
// @Tags Service // @Tags Service
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Success 200 {object} HealthStatus "Says whether it's healthy or not" // @Success 200 {object} models.HealthStatusResponse "Says whether it's healthy or not"
// @Router /service/health [get] // @Router /service/health [get]
func (s *serviceControllerImpl) HealthCheck(c *gin.Context) { func (ctrl *ServiceController) healthHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"healthy": true}) c.JSON(http.StatusOK, models.HealthStatusResponse{Healthy: true,})
}
// RegisterRoutes implements ServiceController.
func (s *serviceControllerImpl) RegisterRoutes(group *gin.RouterGroup) {
group.GET("/health", s.HealthCheck)
}
type HealthStatus struct {
Healthy bool `json:"healthy"`
} }

View File

@@ -18,13 +18,57 @@
package controllers package controllers
import ( import (
"easywish/internal/dto"
"easywish/internal/middleware"
"easywish/internal/services"
"net/http"
"github.com/gin-gonic/gin"
"go.uber.org/fx" "go.uber.org/fx"
"go.uber.org/zap"
) )
type SetupControllersParams struct {
fx.In
Controllers []Controller `group:"controllers"`
Log *zap.Logger
Auth services.AuthService
Group *gin.Engine
}
func setupControllers(p SetupControllersParams) {
apiGroup := p.Group.Group("/api")
apiGroup.Use(middleware.AuthMiddleware(p.Log, p.Auth))
apiGroup.Use(gin.HandlerFunc(func(c *gin.Context) {
ip := c.ClientIP()
userAgent := c.Request.UserAgent()
sessionInfoFromCtx, ok := c.Get("session_info"); if !ok {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid or missing session data"})
return
}
sessionInfo := sessionInfoFromCtx.(dto.SessionInfo)
c.Set("client_info", dto.ClientInfo{
SessionInfo: sessionInfo,
IP: ip,
UserAgent: userAgent,
})
c.Next()
}))
for _, ctrl := range p.Controllers {
ctrl.Setup(apiGroup, p.Log, p.Auth)
}
}
var Module = fx.Module("controllers", var Module = fx.Module("controllers",
fx.Provide( fx.Provide(
NewServiceController, fx.Annotate(NewServiceController, fx.ResultTags(`group:"controllers"`)),
NewAuthController, fx.Annotate(NewAuthController, fx.ResultTags(`group:"controllers"`)),
NewProfileController, fx.Annotate(NewProfileController, fx.ResultTags(`group:"controllers"`)),
fx.Annotate(NewS3Controller, fx.ResultTags(`group:"controllers"`)),
), ),
fx.Invoke(setupControllers),
) )

View File

@@ -26,6 +26,7 @@ import (
type DbHelperTransaction interface { type DbHelperTransaction interface {
Commit() error Commit() error
Rollback() error Rollback() error
RollbackOnError(err error) error
} }
type DbHelper struct { type DbHelper struct {
@@ -38,6 +39,7 @@ type dbHelperTransactionImpl struct {
TXlessQueries Queries TXlessQueries Queries
TX pgx.Tx TX pgx.Tx
TXQueries Queries TXQueries Queries
isCommited bool
} }
func NewDbHelper(dbContext DbContext) DbHelper { func NewDbHelper(dbContext DbContext) DbHelper {
@@ -78,23 +80,25 @@ func (d *dbHelperTransactionImpl) Commit() error {
errCommit := d.TX.Commit(d.CTX) errCommit := d.TX.Commit(d.CTX)
if errCommit != nil { if errCommit != nil {
errRollback := d.TX.Rollback(d.CTX) d.isCommited = true
if errRollback != nil {
return errRollback
} }
return errCommit return errCommit
}
return nil
} }
// Rollback implements DbHelperTransaction. // Rollback implements DbHelperTransaction.
func (d *dbHelperTransactionImpl) Rollback() error { func (d *dbHelperTransactionImpl) Rollback() error {
err := d.TX.Rollback(d.CTX) if d.isCommited {
return nil
}
if err != nil { return d.TX.Rollback(d.CTX);
return err }
// RollbackOnError implements DbHelperTransaction.
func (d *dbHelperTransactionImpl) RollbackOnError(err error) error {
if d.isCommited || err == nil {
return d.Rollback()
} }
return nil return nil
} }

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 {
@@ -43,23 +43,23 @@ type Profile struct {
ID int64 ID int64
UserID int64 UserID int64
Name string Name string
Bio *string Bio string
AvatarUrl *string AvatarUrl string
Birthday pgtype.Timestamp Birthday pgtype.Timestamp
Color *string Color string
ColorGrad *string ColorGrad string
} }
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 {
@@ -70,6 +70,7 @@ type Session struct {
Platform *string Platform *string
LatestIp *string LatestIp *string
LoginTime pgtype.Timestamp LoginTime pgtype.Timestamp
LastRefreshExpTime pgtype.Timestamp
LastSeenDate pgtype.Timestamp LastSeenDate pgtype.Timestamp
Terminated *bool Terminated *bool
} }
@@ -77,7 +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
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
}

File diff suppressed because it is too large Load Diff

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 dto
type ClientInfo struct {
SessionInfo
IP string
UserAgent string
}

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,23 @@
// 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 Request[T any] struct {
User ClientInfo
Body T
}

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 dto
import "easywish/internal/utils/enums"
type SessionInfo struct {
Username string
Session string
Role enums.Role
}

View File

@@ -15,20 +15,18 @@
// 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/>.
package controllers package dto
import ( import (
"net/http" "easywish/internal/utils/enums"
"github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5"
) )
// @Summary Change account password type UserClaims struct {
// @Tags Account Username string `json:"username"`
// @Accept json Role enums.Role `json:"role"`
// @Produce json Type enums.JwtTokenType `json:"type"`
// @Security JWT Session string `json:"session"`
// @Router /account/changePassword [put] jwt.RegisteredClaims
func ChangePassword(c *gin.Context) {
c.Status(http.StatusNotImplemented)
} }

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

@@ -29,4 +29,10 @@ var (
ErrInvalidCredentials = errors.New("Invalid username, password or TOTP code") ErrInvalidCredentials = errors.New("Invalid username, password or TOTP code")
ErrInvalidToken = errors.New("Token is invalid or expired") ErrInvalidToken = errors.New("Token is invalid or expired")
ErrServerError = errors.New("Internal server error") ErrServerError = errors.New("Internal server error")
ErrTokenExpired = errors.New("Token is expired")
ErrTokenInvalid = ErrInvalidToken
ErrWrongTokenType = errors.New("Invalid token type")
ErrSessionNotFound = errors.New("Could not find session in database")
ErrSessionTerminated = errors.New("Session is terminated")
) )

View File

@@ -15,15 +15,10 @@
// 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/>.
package routes package errors
import ( import "errors"
"go.uber.org/fx"
)
var Module = fx.Module("routes", var (
fx.Provide( ErrClientInfoNotProvided = errors.New("No client info provded")
NewRouteGroups,
),
fx.Invoke(NewRouter),
) )

View File

@@ -25,4 +25,7 @@ var (
ErrNotImplemented = errors.New("Feature is not implemented") ErrNotImplemented = errors.New("Feature is not implemented")
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")
ErrNotFound = errors.New("Resource not found")
ErrGone = errors.New("Resource no longer available")
) )

View File

@@ -15,12 +15,13 @@
// 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/>.
package controllers
package errors
import ( import (
"github.com/gin-gonic/gin" "errors"
) )
type Router interface { var (
RegisterRoutes(group *gin.RouterGroup) 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

@@ -18,64 +18,53 @@
package middleware package middleware
import ( import (
"easywish/config" "easywish/internal/dto"
"easywish/internal/services"
"easywish/internal/utils/enums" "easywish/internal/utils/enums"
"errors" "errors"
"fmt"
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5" "go.uber.org/zap"
errs "easywish/internal/errors"
) )
type Claims struct { func AuthMiddleware(log *zap.Logger, auth services.AuthService) gin.HandlerFunc {
Username string `json:"username"`
Role enums.Role `json:"role"`
jwt.RegisteredClaims
}
// TODO: validate token type
// TODO: validate session guid
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
cfg := config.GetConfig()
authHeader := c.GetHeader("Authorization") authHeader := c.GetHeader("Authorization")
if authHeader == "" { if authHeader == "" {
c.Set("username", nil)
c.Set("role", enums.GuestRole) c.Set("session_info", dto.SessionInfo{
Username: "",
Session: "",
Role: enums.GuestRole},
)
c.Next() c.Next()
return return
} }
tokenString := authHeader tokenString := authHeader
if sessionInfo, err := auth.ValidateToken(tokenString, enums.JwtAccessTokenType); err != nil {
token, err := jwt.ParseWithClaims( if errors.Is(err, errs.ErrTokenExpired) {
tokenString, c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Token is expired"})
&Claims{}, } else if errors.Is(err, errs.ErrTokenInvalid) {
func(token *jwt.Token) (any, error) { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Token is invalid"})
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { } else if errors.Is(err, errs.ErrWrongTokenType) {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid token type"})
} } else if errors.Is(err, errs.ErrSessionNotFound) {
return []byte(cfg.JwtSecret), nil c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Could not find session in database"})
}, } else if errors.Is(err, errs.ErrSessionTerminated) {
) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Session is terminated"})
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Token expired"})
} else { } else {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"})
} }
return return
} else {
c.Set("session_info", *sessionInfo)
c.Next()
} }
if claims, ok := token.Claims.(*Claims); ok && token.Valid { return
c.Set("username", claims.Username)
c.Set("role", claims.Role)
c.Next()
} else {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid claims"})
}
} }
} }

View File

@@ -15,21 +15,23 @@
// 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/>.
package utils package middleware
import ( import (
"context" "net/http"
"easywish/config"
"github.com/jackc/pgx/v5" "github.com/gin-gonic/gin"
"golang.org/x/time/rate"
) )
func GetDbConn() (*pgx.Conn, context.Context, error) { func RateLimitMiddleware(r rate.Limit, b int) gin.HandlerFunc {
ctx := context.Background() limiter := rate.NewLimiter(r, b)
conn, err := pgx.Connect(ctx, config.GetConfig().DatabaseUrl)
if err != nil {
return nil, nil, err
}
return conn, ctx, nil return func(c *gin.Context) {
if !limiter.Allow() {
c.AbortWithStatus(http.StatusTooManyRequests)
return
}
c.Next()
}
} }

View File

@@ -1,110 +0,0 @@
// 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 (
"easywish/internal/utils/enums"
"easywish/internal/validation"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
)
type UserInfo struct {
Username string
Role enums.Role
}
type Request[T any] struct {
User UserInfo
Body T
}
const requestKey = "request"
func UserInfoFromContext(c *gin.Context) (*UserInfo, bool) {
var username any
var role any
var ok bool
username, ok = c.Get("username") ; if !ok {
return &UserInfo{Username: "", Role: enums.GuestRole}, true
}
role, ok = c.Get("role"); if !ok {
return nil, false
}
if username == nil {
return &UserInfo{Username: "", Role: enums.GuestRole}, true
}
if role == nil {
return nil, false
}
return &UserInfo{Username: username.(string), Role: role.(enums.Role)}, true
}
func RequestFromContext[T any](c *gin.Context) Request[T] {
return c.Value(requestKey).(Request[T])
}
func RequestMiddleware[T any](role enums.Role) gin.HandlerFunc {
return gin.HandlerFunc(func(c *gin.Context) {
userInfo, ok := UserInfoFromContext(c)
if !ok {
c.Status(http.StatusUnauthorized)
return
}
if userInfo.Role < role {
c.Status(http.StatusForbidden)
return
}
var body T
if err := c.ShouldBindJSON(&body); err != nil {
c.String(http.StatusBadRequest, err.Error())
return
}
validate := validation.NewValidator()
if err := validate.Struct(body); err != nil {
errorList := err.(validator.ValidationErrors)
c.String(http.StatusBadRequest, fmt.Sprintf("Validation error: %s", errorList))
return
}
request := Request[T]{
User: *userInfo,
Body: body,
}
c.Set(requestKey, request)
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

@@ -30,9 +30,9 @@ type RegistrationBeginRequest struct {
type RegistrationCompleteRequest struct { 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"` 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 {
@@ -49,11 +49,35 @@ type LoginResponse struct {
Tokens Tokens
} }
// TODO: length check
type RefreshRequest struct { type RefreshRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"` RefreshToken string `json:"refresh_token" binding:"required,max=2000"`
} }
type RefreshResponse struct { type RefreshResponse struct {
Tokens Tokens
} }
type PasswordResetBeginRequest struct {
Email string `json:"email" binding:"required,email"`
}
type PasswordResetCompleteRequest struct {
Email string `json:"email" binding:"required,email"`
VerificationCode string `json:"verification_code" binding:"required" validate:"verification_code=reset"`
NewPassword string `json:"password" binding:"required" validate:"password"`
LogOutSessions bool `json:"log_out_sessions"`
}
type PasswordResetCompleteResponse struct {
Tokens
}
type ChangePasswordRequest struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"password" binding:"required" validate:"password"`
TOTP string `json:"totp"`
}
type ChangePasswordResponse struct {
Tokens
}

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

@@ -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 models
type HealthStatusResponse struct {
Healthy bool `json:"healthy"`
}

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 redisclient
import (
"context"
"easywish/config"
"github.com/go-redis/redis/v8"
)
func NewRedisClient() *redis.Client {
cfg := config.GetConfig()
options, err := redis.ParseURL(cfg.RedisUrl)
if err != nil {
panic("Failed to parse redis URL: " + err.Error())
}
client := redis.NewClient(options)
ctx := context.Background()
if err := client.Ping(ctx).Err(); err != nil {
panic("Redis connection failed: " + err.Error())
}
return client
}

View File

@@ -1,63 +0,0 @@
// 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 routes
import (
"easywish/internal/controllers"
"easywish/internal/middleware"
"github.com/gin-gonic/gin"
)
func NewRouter(engine *gin.Engine, groups []RouteGroup) *gin.Engine {
apiGroup := engine.Group("/api")
apiGroup.Use(middleware.AuthMiddleware())
for _, group := range groups {
subgroup := apiGroup.Group(group.BasePath)
subgroup.Use(group.Middleware...)
group.Router.RegisterRoutes(subgroup)
}
return engine
}
type RouteGroup struct {
BasePath string
Middleware []gin.HandlerFunc
Router controllers.Router
}
func NewRouteGroups(
authController controllers.AuthController,
serviceController controllers.ServiceController,
profileController controllers.ProfileController,
) []RouteGroup {
return []RouteGroup{
{
BasePath: "/auth",
Router: authController,
},
{
BasePath: "/service",
Router: serviceController,
},
{
BasePath: "/profile",
Router: profileController,
},
}
}

View File

@@ -18,60 +18,248 @@
package services package services
import ( import (
"context"
"easywish/config" "easywish/config"
"easywish/internal/database" "easywish/internal/database"
"easywish/internal/dto"
errs "easywish/internal/errors" errs "easywish/internal/errors"
"easywish/internal/models" "easywish/internal/models"
"easywish/internal/utils" "easywish/internal/utils"
"easywish/internal/utils/enums" "easywish/internal/utils/enums"
"errors" "errors"
"fmt" "fmt"
"time"
"github.com/go-redis/redis/v8"
"github.com/golang-jwt/jwt/v5"
"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"
) )
var (
AuthTerminatedSessionCacheDuration = time.Duration(8 * time.Hour)
AuthRegistrationCooldownCacheDuration = time.Duration(10 * time.Minute)
)
type AuthService interface { type AuthService interface {
RegistrationBegin(request models.RegistrationBeginRequest) (bool, error) RegistrationBegin(request models.RegistrationBeginRequest) (bool, error)
RegistrationComplete(request models.RegistrationCompleteRequest) (*models.RegistrationCompleteResponse, error) RegistrationComplete(cinfo dto.ClientInfo, request models.RegistrationCompleteRequest) (*models.RegistrationCompleteResponse, error)
Login(request models.LoginRequest) (*models.LoginResponse, error) Login(cinfo dto.ClientInfo, request models.LoginRequest) (*models.LoginResponse, error)
Refresh(request models.RefreshRequest) (*models.RefreshResponse, error) Refresh(request models.RefreshRequest) (*models.RefreshResponse, error)
PasswordResetBegin(request models.PasswordResetBeginRequest) (bool, error)
PasswordResetComplete(request models.PasswordResetCompleteRequest) (*models.PasswordResetCompleteResponse, error)
ChangePassword(request models.ChangePasswordRequest, cinfo dto.ClientInfo) (bool, error)
ValidateToken(token string, tokenType enums.JwtTokenType) (*dto.SessionInfo, error)
} }
type authServiceImpl struct { type authServiceImpl struct {
log *zap.Logger log *zap.Logger
smtp SmtpService
dbctx database.DbContext dbctx database.DbContext
redis *redis.Client
smtp SmtpService
} }
func NewAuthService(_log *zap.Logger, _dbctx database.DbContext, _smtp SmtpService) AuthService { func NewAuthService(_log *zap.Logger, _dbctx database.DbContext, _redis *redis.Client, _smtp SmtpService) AuthService {
return &authServiceImpl{log: _log, dbctx: _dbctx, smtp: _smtp} authService := &authServiceImpl{log: _log, dbctx: _dbctx, redis: _redis, smtp: _smtp}
ctx := context.TODO()
db := database.NewDbHelper(_dbctx)
// Batch processing parameters
batchSize := 1000
offset := 0
totalCached := 0
for {
guids, err := db.Queries.GetUnexpiredTerminatedSessionsGuidsPaginated(
db.CTX,
database.GetUnexpiredTerminatedSessionsGuidsPaginatedParams{
BatchSize: int32(batchSize),
Offset: int64(offset),
},
)
if err != nil {
panic("Failed to load terminated sessions' GUIDs: " + err.Error())
}
// Break loop when no more records
if len(guids) == 0 {
break
}
// Process batch in Redis pipeline
pipe := _redis.Pipeline()
for _, guid := range guids {
key := fmt.Sprintf("session::%s::is_terminated", guid)
pipe.Set(ctx, key, true, AuthTerminatedSessionCacheDuration)
}
if _, err := pipe.Exec(ctx); err != nil {
panic("Failed to cache terminated sessions: " + err.Error())
}
totalCached += len(guids)
offset += len(guids)
_log.Info(
"Cached batch of terminated sessions",
zap.Int("batch_size", len(guids)),
zap.Int("total_cached", totalCached))
}
_log.Info("Finished caching terminated sessions",
zap.Int("total_sessions", totalCached),
)
return authService
}
func (a *authServiceImpl) terminateAllSessionsForUser(ctx context.Context, username string, queries *database.Queries) error {
sessionGuids, err := queries.TerminateAllSessionsForUserByUsername(ctx, username); if err != nil {
a.log.Error(
"Failed to terminate older sessions for user trying to log in",
zap.String("username", username),
zap.Error(err))
return err
}
pipe := a.redis.Pipeline()
for _, guid := range sessionGuids {
pipe.Set(
ctx,
fmt.Sprintf("session::%s::is_terminated", guid),
true,
AuthTerminatedSessionCacheDuration,
)
}
if _, err := pipe.Exec(ctx); err != nil {
a.log.Error(
"Failed to cache terminated sessions",
zap.Error(err))
return err
}
return nil
}
func (a *authServiceImpl) registerSession(ctx context.Context, userID int64, cinfo dto.ClientInfo, queries *database.Queries) (*database.Session, error) {
session, err := queries.CreateSession(ctx, database.CreateSessionParams{
UserID: userID,
Name: utils.NewPointer(cinfo.UserAgent),
Platform: utils.NewPointer(cinfo.UserAgent),
LatestIp: utils.NewPointer(cinfo.IP),
}); if err != nil {
a.log.Error(
"Failed to add session to database",
zap.Error(err))
return nil, err
}
a.log.Info(
"Registered a new user session",
zap.String("username", cinfo.Username),
zap.String("session", cinfo.Session))
return &session, nil
} }
func (a *authServiceImpl) RegistrationBegin(request models.RegistrationBeginRequest) (bool, error) { func (a *authServiceImpl) RegistrationBegin(request models.RegistrationBeginRequest) (bool, error) {
var occupationStatus database.CheckUserRegistrationAvailabilityRow
var user database.User var user database.User
var generatedCode string var generatedCode string
var generatedCodeHash string var generatedCodeHash string
var passwordHash string var passwordHash string
helper, db, _ := database.NewDbHelperTransaction(a.dbctx)
defer helper.Rollback()
var err error var err error
if user, err = db.TXQueries.CreateUser(db.CTX, request.Username); err != nil { helper, db, err := database.NewDbHelperTransaction(a.dbctx)
if err != nil {
if errs.MatchPgError(err, pgerrcode.UniqueViolation) { a.log.Error(
a.log.Warn( "Failed to open a transaction",
"Attempted registration for a taken username",
zap.String("username", request.Username),
zap.Error(err)) zap.Error(err))
return false, errs.ErrServerError
}
defer helper.RollbackOnError(err)
return false, errs.ErrUsernameTaken isInProgress, err := a.redis.Get(
context.TODO(),
fmt.Sprintf("email::%s::registration_in_progress",
request.Email),
).Bool(); if err != nil {
if err != redis.Nil {
a.log.Error(
"Failed to look up cached registration_in_progress state of email as part of registration procedure",
zap.String("email", request.Email),
zap.Error(err))
return false, errs.ErrServerError
}
isInProgress = false
} else if isInProgress {
a.log.Warn(
"Attempted to begin registration on email that is in progress of registration or on cooldown",
zap.String("email", request.Email))
return false, errs.ErrTooManyRequests
} }
if occupationStatus, err = db.TXQueries.CheckUserRegistrationAvailability(db.CTX, database.CheckUserRegistrationAvailabilityParams{
Email: request.Email,
Username: request.Username,
}); err != nil {
a.log.Error(
"Failed to check credentials availability for registration",
zap.String("username", request.Username),
zap.String("email", request.Email),
zap.Error(err))
return false, errs.ErrServerError
}
if occupationStatus.UsernameBusy {
a.log.Warn(
"Attempted registration for a taken username",
zap.String("email", request.Email),
zap.String("username", request.Username))
return false, errs.ErrUsernameTaken
} else if occupationStatus.EmailBusy {
// Falsely confirm in order to avoid disclosing registered email addresses
if err := a.redis.Set(
context.TODO(),
fmt.Sprintf("email::%s::registration_in_progress", request.Email),
true,
AuthRegistrationCooldownCacheDuration,
).Err(); err != nil {
a.log.Error(
"Failed to falsely set cache registration_in_progress state for email as a measure to prevent email enumeration",
zap.String("email", request.Email),
zap.Error(err))
return false, errs.ErrServerError
}
a.log.Warn(
"Attempted registration for a taken email",
zap.String("email", request.Email),
zap.String("username", request.Username))
return true, nil
} else {
if _, err := db.TXQueries.DeleteUnverifiedAccountsHavingUsernameOrEmail(db.CTX, database.DeleteUnverifiedAccountsHavingUsernameOrEmailParams{
Username: request.Username,
Email: request.Email,
}); err != nil {
a.log.Error(
"Failed to purge unverified accounts as part of registration",
zap.String("email", request.Email),
zap.String("username", request.Username),
zap.Error(err))
return false, errs.ErrServerError
}
}
if user, err = db.TXQueries.CreateUser(db.CTX, request.Username); err != nil {
a.log.Error("Failed to add user to database", zap.Error(err)) a.log.Error("Failed to add user to database", zap.Error(err))
return false, errs.ErrServerError return false, errs.ErrServerError
} }
@@ -138,27 +326,58 @@ func (a *authServiceImpl) RegistrationBegin(request models.RegistrationBeginRequ
} else { } else {
a.log.Debug( a.log.Debug(
"Declated registration code for a new user. Enable SMTP in the config to disable this message", "Declared registration code for a new user. Enable SMTP in the config to disable this message",
zap.String("username", user.Username), zap.String("username", user.Username),
zap.String("code", generatedCode)) zap.String("code", generatedCode))
} }
helper.Commit() if err := a.redis.Set(
context.TODO(),
fmt.Sprintf("email::%s::registration_in_progress", request.Email),
true,
AuthTerminatedSessionCacheDuration,
).Err(); err != nil {
a.log.Error(
"Failed to cache registration_in_progress state for email",
zap.String("email", request.Email),
zap.Error(err))
return false, errs.ErrServerError
}
if err = helper.Commit(); err != nil {
a.log.Error(
"Failed to commit transaction",
zap.Error(err))
redisErr := a.redis.Del(context.TODO(), fmt.Sprintf("email::%s::registration_in_progress", request.Email))
if redisErr != nil {
a.log.Error(
"Failed to delete cooldown redis key while rolling back RegistrationBegin",
zap.Error(redisErr.Err()))
}
return false, errs.ErrServerError
}
return true, nil return true, nil
} }
func (a *authServiceImpl) RegistrationComplete(request models.RegistrationCompleteRequest) (*models.RegistrationCompleteResponse, error) { func (a *authServiceImpl) RegistrationComplete(cinfo dto.ClientInfo, request models.RegistrationCompleteRequest) (*models.RegistrationCompleteResponse, error) {
var user database.User var user database.User
var profile database.Profile var profile database.Profile
var session database.Session
var confirmationCode database.ConfirmationCode var confirmationCode database.ConfirmationCode
var accessToken, refreshToken string var accessToken, refreshToken string
var err error var err error
helper, db, _ := database.NewDbHelperTransaction(a.dbctx) helper, db, err := database.NewDbHelperTransaction(a.dbctx)
defer helper.Rollback() if err != nil {
a.log.Error(
"Failed to open a transaction",
zap.Error(err))
return nil, errs.ErrServerError
}
defer helper.RollbackOnError(err)
user, err = db.TXQueries.GetUserByUsername(db.CTX, request.Username) user, err = db.TXQueries.GetUserByUsername(db.CTX, request.Username)
@@ -203,7 +422,7 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple
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 {
@@ -217,7 +436,7 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple
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 {
@@ -227,15 +446,20 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple
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 {
a.log.Error("Failed to create profile for user", a.log.Error("Failed to create profile for user",
zap.String("username", user.Username), zap.String("username", user.Username),
) )
return nil, errs.ErrServerError return nil, errs.ErrServerError
} }
@@ -249,23 +473,12 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple
return nil, errs.ErrServerError return nil, errs.ErrServerError
} }
// TODO: session info session, err := a.registerSession(context.TODO(), user.ID, cinfo, &db.TXQueries); if err != nil {
session, err = db.TXQueries.CreateSession(db.CTX, database.CreateSessionParams{ a.log.Error("", zap.Error(err))
UserID: user.ID,
Name: utils.NewPointer("First device"),
Platform: utils.NewPointer("Unknown"),
LatestIp: utils.NewPointer("Unknown"),
})
if err != nil {
a.log.Error(
"Failed to create a new session during registration, rolling back registration",
zap.String("username", user.Username),
zap.Error(err))
return nil, errs.ErrServerError return nil, errs.ErrServerError
} }
accessToken, refreshToken, err = utils.GenerateTokens(user.Username, session.Guid.String()) accessToken, refreshToken, err = utils.GenerateTokens(user.Username, session.Guid.String(), enums.Role(user.Role))
if err != nil { if err != nil {
a.log.Error( a.log.Error(
@@ -275,7 +488,12 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple
return nil, errs.ErrServerError return nil, errs.ErrServerError
} }
helper.Commit() if err = helper.Commit(); err != nil {
a.log.Error(
"Failed to commit transaction",
zap.Error(err))
return nil, errs.ErrServerError
}
a.log.Info( a.log.Info(
"User verified registration", "User verified registration",
@@ -289,25 +507,23 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple
return &response, nil return &response, nil
} }
// TODO: totp func (a *authServiceImpl) Login(cinfo dto.ClientInfo, request models.LoginRequest) (*models.LoginResponse, error) {
func (a *authServiceImpl) Login(request models.LoginRequest) (*models.LoginResponse, error) {
var userRow database.GetValidUserByLoginCredentialsRow var userRow database.GetValidUserByLoginCredentialsRow
var session database.Session
helper, db, _ := database.NewDbHelperTransaction(a.dbctx)
defer helper.Rollback()
var err error var err error
helper, db, err := database.NewDbHelperTransaction(a.dbctx)
if err != nil {
a.log.Error(
"Failed to open a transaction",
zap.Error(err))
return nil, errs.ErrServerError
}
defer helper.RollbackOnError(err)
userRow, err = db.TXQueries.GetValidUserByLoginCredentials(db.CTX, database.GetValidUserByLoginCredentialsParams{ userRow, err = db.TXQueries.GetValidUserByLoginCredentials(db.CTX, database.GetValidUserByLoginCredentialsParams{
Username: request.Username, Username: request.Username,
Password: request.Password, Password: request.Password,
}) }); if err != nil {
if err != nil {
a.log.Warn(
"Failed login attempt",
zap.Error(err))
var returnedError error var returnedError error
@@ -318,36 +534,30 @@ func (a *authServiceImpl) Login(request models.LoginRequest) (*models.LoginRespo
returnedError = errs.ErrServerError returnedError = errs.ErrServerError
} }
a.log.Warn(
"Failed login attempt",
zap.Error(err))
return nil, returnedError return nil, returnedError
} }
// Until release 4, only 1 session at a time is supported // Until release 4, only 1 session at a time is supported
if err = db.TXQueries.TerminateAllSessionsForUserByUsername(db.CTX, request.Username); err != nil { err = a.terminateAllSessionsForUser(context.TODO(), request.Username, &db.TXQueries); if err != nil {
a.log.Error( a.log.Error(
"Failed to terminate older sessions for user trying to log in", "Failed to terminate user's sessions during login",
zap.String("username", request.Username),
zap.Error(err)) zap.Error(err))
return nil, errs.ErrServerError return nil, errs.ErrServerError
} }
session, err = db.TXQueries.CreateSession(db.CTX, database.CreateSessionParams{ session, err := a.registerSession(context.TODO(), userRow.ID, cinfo, &db.TXQueries); if err != nil {
// TODO: use actual values for session metadata a.log.Error("", zap.Error(err))
UserID: userRow.ID,
Name: utils.NewPointer("New device"),
Platform: utils.NewPointer("Unknown"),
LatestIp: utils.NewPointer("Unknown"),
})
if err != nil {
a.log.Error(
"Failed to create session for a new login",
zap.String("username", userRow.Username),
zap.Error(err))
return nil, errs.ErrServerError return nil, errs.ErrServerError
} }
accessToken, refreshToken, err := utils.GenerateTokens(userRow.Username, session.Guid.String()) accessToken, refreshToken, err := utils.GenerateTokens(
if err != nil { userRow.Username,
session.Guid.String(),
enums.Role(userRow.Role),
); if err != nil {
a.log.Error( a.log.Error(
"Failed to generate tokens for a new login", "Failed to generate tokens for a new login",
zap.String("username", userRow.Username), zap.String("username", userRow.Username),
@@ -355,7 +565,12 @@ func (a *authServiceImpl) Login(request models.LoginRequest) (*models.LoginRespo
return nil, errs.ErrServerError return nil, errs.ErrServerError
} }
helper.Commit() if err = helper.Commit(); err != nil {
a.log.Error(
"Failed to commit transaction",
zap.Error(err))
return nil, errs.ErrServerError
}
response := models.LoginResponse{Tokens: models.Tokens{ response := models.LoginResponse{Tokens: models.Tokens{
AccessToken: accessToken, AccessToken: accessToken,
@@ -366,5 +581,441 @@ func (a *authServiceImpl) Login(request models.LoginRequest) (*models.LoginRespo
} }
func (a *authServiceImpl) Refresh(request models.RefreshRequest) (*models.RefreshResponse, error) { func (a *authServiceImpl) Refresh(request models.RefreshRequest) (*models.RefreshResponse, error) {
return nil, errs.ErrNotImplemented
sessionInfo, err := a.ValidateToken(request.RefreshToken, enums.JwtRefreshTokenType)
if err != nil {
if utils.ErrorIsOneOf(
err,
errs.ErrInvalidToken,
errs.ErrTokenExpired,
errs.ErrWrongTokenType,
errs.ErrSessionNotFound,
errs.ErrSessionTerminated,
) {
return nil, err
} else {
a.log.Error(
"Encountered an unexpected error while validating token",
zap.Error(err))
return nil, errs.ErrServerError
}
}
accessToken, refreshToken, err := utils.GenerateTokens(
sessionInfo.Username,
sessionInfo.Session,
sessionInfo.Role,
)
if err != nil {
a.log.Error(
"Failed to generate tokens for user during refresh",
zap.String("username", sessionInfo.Username),
zap.String("session", sessionInfo.Session),
zap.Error(err))
return nil, errs.ErrServerError
}
response := models.RefreshResponse{
Tokens: models.Tokens{
AccessToken: accessToken,
RefreshToken: refreshToken,
},
}
return &response, nil
}
func (a *authServiceImpl) ValidateToken(jwtToken string, tokenType enums.JwtTokenType) (*dto.SessionInfo, error) {
var err error
token, err := jwt.ParseWithClaims(
jwtToken,
&dto.UserClaims{},
func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(config.GetConfig().JwtSecret), nil
},
)
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, errs.ErrTokenExpired
}
return nil, errs.ErrInvalidToken
}
claims, ok := token.Claims.(*dto.UserClaims)
if ok && token.Valid {
if claims.Type != tokenType {
return nil, errs.ErrWrongTokenType
}
ctx := context.TODO()
isTerminated, redisErr := a.redis.Get(ctx, fmt.Sprintf("session::%s::is_terminated", claims.Session)).Bool()
if redisErr != nil && redisErr != redis.Nil {
a.log.Error(
"Failed to lookup cache to check whether session is not terminated",
zap.Error(redisErr))
return nil, redisErr
}
// Cache if nil
if redisErr == redis.Nil {
db := database.NewDbHelper(a.dbctx)
session, err := db.Queries.GetSessionByGuid(db.CTX, claims.Session)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
a.log.Warn(
"Session does not exist or was deleted",
zap.String("session", claims.Session))
return nil, errs.ErrSessionNotFound
}
a.log.Error(
"Failed to lookup session in database",
zap.String("session", claims.Session),
zap.Error(err))
return nil, err
}
if err := a.redis.Set(
ctx,
fmt.Sprintf("session::%s::is_terminated", claims.Session),
*session.Terminated,
AuthTerminatedSessionCacheDuration,
).Err(); err != nil {
a.log.Error(
"Failed to cache session's is_terminated state",
zap.String("session", claims.Session),
zap.Error(err))
// c.AbortWithStatus(http.StatusInternalServerError)
return nil, err
}
isTerminated = *session.Terminated
}
if isTerminated {
return nil, errs.ErrSessionTerminated
}
}
sessionInfo := dto.SessionInfo{
Username: claims.Username,
Session: claims.Session,
Role: claims.Role,
}
return &sessionInfo, nil
}
func (a *authServiceImpl) PasswordResetBegin(request models.PasswordResetBeginRequest) (bool, error) {
var user database.User
var generatedCode, hashedCode string
var err error
helper, db, err := database.NewDbHelperTransaction(a.dbctx)
if err != nil {
a.log.Error(
"Failed to open a transaction",
zap.Error(err))
return false, errs.ErrServerError
}
defer helper.RollbackOnError(err)
ctx := context.TODO()
cooldownTimeUnix, redisErr := a.redis.Get(ctx, fmt.Sprintf("email::%s::reset_cooldown", request.Email)).Int64()
if redisErr != nil && redisErr != redis.Nil {
a.log.Error(
"Failed to get reset_cooldown state for user",
zap.String("email", request.Email),
zap.Error(redisErr))
return false, errs.ErrServerError
}
if time.Now().Unix() < cooldownTimeUnix {
a.log.Warn(
"Attempted to request a new password reset code for email on active reset cooldown",
zap.String("email", request.Email))
return false, errs.ErrTooManyRequests
}
if user, err = db.TXQueries.GetUserByEmail(db.CTX, request.Email); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// Enable cooldown for the email despite that account does not exist
err := a.redis.Set(
ctx,
fmt.Sprintf("email::%s::reset_cooldown", request.Email),
time.Now().Add(10*time.Minute),
time.Duration(10*time.Minute),
).Err()
if err != nil {
a.log.Error(
"Failed to set reset cooldown for email",
zap.Error(err))
return false, err
}
a.log.Warn(
"Requested password reset email for unexistent user",
zap.String("email", request.Email))
return true, nil
}
a.log.Error(
"Failed to retrieve user from database",
zap.String("email", request.Email),
zap.Error(err))
return false, errs.ErrServerError
}
generatedCode = uuid.New().String()
if hashedCode, err = utils.HashPassword(generatedCode); err != nil {
a.log.Error(
"Failed to hash password reset code for user",
zap.String("username", user.Username),
zap.Error(err))
return false, errs.ErrServerError
}
if _, err = db.TXQueries.CreateConfirmationCode(db.CTX, database.CreateConfirmationCodeParams{
UserID: user.ID,
CodeType: int32(enums.PasswordResetCodeType),
CodeHash: hashedCode,
}); err != nil {
a.log.Error(
"Failed to save user password reset code to the database",
zap.String("username", user.Username),
zap.Error(err))
}
err = a.redis.Set(
ctx,
fmt.Sprintf("email::%s::reset_cooldown", request.Email),
time.Now().Add(10*time.Minute),
time.Duration(10*time.Minute),
).Err()
if err != nil {
a.log.Error(
"Failed to set reset cooldown for email. Cancelling password reset",
zap.Error(err))
return false, err
}
if err = helper.Commit(); err != nil {
a.log.Error(
"Failed to commit transaction",
zap.Error(err))
redisErr := a.redis.Del(context.TODO(), fmt.Sprintf("email::%s::reset_cooldown", request.Email))
if redisErr != nil {
a.log.Error(
"Failed to delete cooldown redis key while rolling back PasswordResetBegin",
zap.Error(redisErr.Err()))
}
return false, errs.ErrServerError
}
return true, nil
}
func (a *authServiceImpl) PasswordResetComplete(request models.PasswordResetCompleteRequest) (*models.PasswordResetCompleteResponse, error) {
var resetCode database.ConfirmationCode
var user database.User
var session database.Session
var hashedPassword, accessToken, refreshToken string
var err error
helper, db, err := database.NewDbHelperTransaction(a.dbctx)
if err != nil {
a.log.Error(
"Failed to open a transaction",
zap.Error(err))
return nil, errs.ErrServerError
}
if user, err = db.TXQueries.GetUserByEmail(db.CTX, request.Email); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
a.log.Warn(
"Attempted to complete password reset for unregistered email",
zap.String("email", request.Email),
zap.Error(err))
return nil, errs.ErrForbidden
}
a.log.Error(
"Failed to look up user of email while trying to complete password reset",
zap.String("email", request.Email),
zap.Error(err))
return nil, errs.ErrServerError
}
if resetCode, err = db.TXQueries.GetValidConfirmationCodeByCode(db.CTX, database.GetValidConfirmationCodeByCodeParams{
UserID: user.ID,
CodeType: int32(enums.PasswordResetCodeType),
Code: request.VerificationCode,
}); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
a.log.Warn(
"Attempted to reset password for user using incorrect confirmation code",
zap.String("email", request.Email),
zap.String("username", user.Username),
zap.String("provided_code", request.VerificationCode),
zap.Error(err))
return nil, errs.ErrForbidden
}
}
if err = db.TXQueries.UpdateConfirmationCode(db.CTX, database.UpdateConfirmationCodeParams{
ID: resetCode.ID,
Used: true,
}); err != nil {
a.log.Error(
"Failed to invalidate password reset code upon use",
zap.String("username", user.Username),
zap.String("email", request.Email),
zap.Int64("code_id", resetCode.ID),
zap.Error(err))
return nil, errs.ErrServerError
}
if hashedPassword, err = utils.HashPassword(request.NewPassword); err != nil {
a.log.Error(
"Failed to hash new password as part of user password reset",
zap.String("email", request.Email),
zap.String("username", user.Username),
zap.Error(err))
return nil, errs.ErrServerError
}
if err = db.TXQueries.UpdateLoginInformationByUsername(db.CTX, database.UpdateLoginInformationByUsernameParams{
Username: user.Username,
PasswordHash: hashedPassword,
}); err != nil {
a.log.Error(
"Failed to save new password to database as part of user password reset",
zap.String("username", user.Username),
zap.String("email", request.Email),
zap.Error(err))
}
if request.LogOutSessions {
err = a.terminateAllSessionsForUser(context.TODO(), user.Username, &db.TXQueries); if err != nil {
a.log.Error(
"Failed to terminate user's sessions during login",
zap.Error(err))
return nil, errs.ErrServerError
}
}
// FIXME: grab client info
session, err = db.TXQueries.CreateSession(db.CTX, database.CreateSessionParams{
UserID: user.ID,
Name: utils.NewPointer("First device"),
Platform: utils.NewPointer("Unknown"),
LatestIp: utils.NewPointer("Unknown"),
}); if err != nil {
a.log.Error(
"Failed to create new session for user as part of user password reset",
zap.String("email", request.Email),
zap.String("username", user.Username),
zap.Error(err))
}
if accessToken, refreshToken, err = utils.GenerateTokens(
user.Username,
session.Guid.String(),
enums.UserRole,
); err != nil {
a.log.Error(
"Failed to generate tokens as part of user password reset",
zap.String("email", request.Email),
zap.String("username", user.Username),
zap.Error(err))
return nil, errs.ErrServerError
}
response := models.PasswordResetCompleteResponse{
Tokens: models.Tokens{
AccessToken: accessToken,
RefreshToken: refreshToken,
},
}
if err = helper.Commit(); err != nil {
a.log.Error(
"Failed to commit transaction",
zap.Error(err))
return nil, errs.ErrServerError
}
return &response, nil
}
// XXX: Mechanism for loging out existing sessions currently does not exist
func (a *authServiceImpl) ChangePassword(request models.ChangePasswordRequest, uinfo dto.ClientInfo) (bool, error) {
var err error
helper, db, err := database.NewDbHelperTransaction(a.dbctx); if err != nil {
a.log.Error(
"Failed to open a transaction",
zap.Error(err))
return false, errs.ErrServerError
}
defer helper.RollbackOnError(err)
linfo, err := db.TXQueries.GetLoginInformationByUsername(db.CTX, uinfo.Username); if err != nil {
a.log.Error(
"Failed to get user login information",
zap.Error(err))
return false, errs.ErrServerError
}
if !utils.CheckPasswordHash(request.OldPassword, linfo.PasswordHash) {
a.log.Warn(
"Provided invalid old password while changing password",
zap.String("username", uinfo.Username))
return false, errs.ErrForbidden
}
newPasswordHash, err := utils.HashPassword(request.NewPassword); if err != nil {
a.log.Error(
"Failed to hash new password while changing password",
zap.String("username", uinfo.Username),
zap.Error(err))
return false, errs.ErrServerError
}
err = db.TXQueries.UpdateLoginInformationByUsername(db.CTX, database.UpdateLoginInformationByUsernameParams{
Username: uinfo.Username,
PasswordHash: newPasswordHash,
}); if err != nil {
a.log.Error(
"Failed to save new password into database",
zap.String("username", uinfo.Username),
zap.Error(err))
return false, errs.ErrServerError
}
if err := helper.Commit(); err != nil {
a.log.Error(
"Failed to commit transaction",
zap.Error(err))
return false, errs.ErrServerError
}
return true, 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

@@ -26,7 +26,6 @@ import (
"strings" "strings"
"time" "time"
"go.uber.org/zap"
errs "easywish/internal/errors" errs "easywish/internal/errors"
) )
@@ -35,23 +34,20 @@ type SmtpService interface {
} }
type smtpServiceImpl struct { type smtpServiceImpl struct {
log *zap.Logger
} }
func NewSmtpService(_log *zap.Logger) SmtpService { func NewSmtpService() SmtpService {
return &smtpServiceImpl{log: _log} return &smtpServiceImpl{}
} }
func (s *smtpServiceImpl) SendEmail(to string, subject, body string) error { func (s *smtpServiceImpl) SendEmail(to string, subject, body string) error {
cfg := config.GetConfig() cfg := config.GetConfig()
if !cfg.SmtpEnabled { if !cfg.SmtpEnabled {
s.log.Error("Attempted to send an email with SMTP disabled in the config")
return errs.ErrSmtpDisabled return errs.ErrSmtpDisabled
} }
if cfg.SmtpServer == "" || cfg.SmtpPort == 0 || cfg.SmtpFrom == "" { if cfg.SmtpServer == "" || cfg.SmtpPort == 0 || cfg.SmtpFrom == "" {
s.log.Error("SMTP service settings or the SMTP From paramater are not set")
return errs.ErrSmtpMissingConfiguration return errs.ErrSmtpMissingConfiguration
} }
@@ -72,7 +68,7 @@ func (s *smtpServiceImpl) SendEmail(to string, subject, body string) error {
sb.WriteString("\r\n" + body) sb.WriteString("\r\n" + body)
message := []byte(sb.String()) message := []byte(sb.String())
hostPort := fmt.Sprintf("%s:%d", cfg.SmtpServer, cfg.SmtpPort) hostPort := net.JoinHostPort(cfg.SmtpServer, fmt.Sprintf("%d", cfg.SmtpPort))
var conn net.Conn var conn net.Conn
var err error var err error
@@ -84,14 +80,12 @@ func (s *smtpServiceImpl) SendEmail(to string, subject, body string) error {
conn, err = net.DialTimeout("tcp", hostPort, timeout) conn, err = net.DialTimeout("tcp", hostPort, timeout)
} }
if err != nil { if err != nil {
s.log.Error("SMTP connection failure", zap.Error(err))
return err return err
} }
defer conn.Close() defer conn.Close()
client, err := smtp.NewClient(conn, cfg.SmtpServer) client, err := smtp.NewClient(conn, cfg.SmtpServer)
if err != nil { if err != nil {
s.log.Error("SMTP client creation failed", zap.Error(err))
return err return err
} }
defer client.Close() defer client.Close()
@@ -107,19 +101,16 @@ func (s *smtpServiceImpl) SendEmail(to string, subject, body string) error {
if cfg.SmtpUser != "" && cfg.SmtpPassword != "" { if cfg.SmtpUser != "" && cfg.SmtpPassword != "" {
auth := smtp.PlainAuth("", cfg.SmtpUser, cfg.SmtpPassword, cfg.SmtpServer) auth := smtp.PlainAuth("", cfg.SmtpUser, cfg.SmtpPassword, cfg.SmtpServer)
if err = client.Auth(auth); err != nil { if err = client.Auth(auth); err != nil {
s.log.Error("SMTP authentication failure", zap.Error(err))
return err return err
} }
} }
if err = client.Mail(cfg.SmtpFrom); err != nil { if err = client.Mail(cfg.SmtpFrom); err != nil {
s.log.Error("SMTP sender set failed", zap.Error(err))
return err return err
} }
for _, recipient := range to { for _, recipient := range toSlice {
if err = client.Rcpt(string(recipient)); err != nil { if err = client.Rcpt(recipient); err != nil {
s.log.Error("SMTP recipient set failed", zap.Error(err))
return err return err
} }
} }
@@ -127,15 +118,12 @@ func (s *smtpServiceImpl) SendEmail(to string, subject, body string) error {
// Send email body // Send email body
w, err := client.Data() w, err := client.Data()
if err != nil { if err != nil {
s.log.Error("SMTP data command failed", zap.Error(err))
return err return err
} }
if _, err = w.Write(message); err != nil { if _, err = w.Write(message); err != nil {
s.log.Error("SMTP message write failed", zap.Error(err))
return err return err
} }
if err = w.Close(); err != nil { if err = w.Close(); err != nil {
s.log.Error("SMTP message close failed", zap.Error(err))
return err return err
} }

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

@@ -17,19 +17,13 @@
package utils package utils
import ( import "errors"
"easywish/internal/middleware"
"github.com/gin-gonic/gin" func ErrorIsOneOf(err error, ignoreErrors ...error) bool {
) for _, ignore := range ignoreErrors {
if errors.Is(err, ignore) {
func GetRequest[T any](c *gin.Context) (*middleware.Request[T], bool) { return true
req, ok := c.Get("request")
request := req.(middleware.Request[T])
if !ok {
return nil, false
} }
}
return &request, true return false
} }

View File

@@ -25,12 +25,13 @@ import (
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
) )
func GenerateTokens(username string, sessionGuid string) (accessToken, refreshToken string, err error) { func GenerateTokens(username string, sessionGuid string, role enums.Role) (accessToken, refreshToken string, err error) {
cfg := config.GetConfig() cfg := config.GetConfig()
accessClaims := jwt.MapClaims{ accessClaims := jwt.MapClaims{
"username": username, "username": username,
"guid": sessionGuid, "role": role,
"session": sessionGuid,
"type": enums.JwtAccessTokenType, "type": enums.JwtAccessTokenType,
"exp": time.Now().Add(time.Minute * time.Duration(cfg.JwtExpAccess)).Unix(), "exp": time.Now().Add(time.Minute * time.Duration(cfg.JwtExpAccess)).Unix(),
} }
@@ -38,7 +39,8 @@ func GenerateTokens(username string, sessionGuid string) (accessToken, refreshTo
refreshClaims := jwt.MapClaims{ refreshClaims := jwt.MapClaims{
"username": username, "username": username,
"guid": sessionGuid, "role": role,
"session": sessionGuid,
"type": enums.JwtRefreshTokenType, "type": enums.JwtRefreshTokenType,
"exp": time.Now().Add(time.Hour * time.Duration(cfg.JwtExpRefresh)).Unix(), "exp": time.Now().Add(time.Hour * time.Duration(cfg.JwtExpRefresh)).Unix(),
} }

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

@@ -19,7 +19,9 @@ package validation
import ( import (
"easywish/config" "easywish/config"
"fmt"
"regexp" "regexp"
"time"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
) )
@@ -40,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 {
@@ -47,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 {
@@ -77,6 +116,39 @@ func GetCustomHandlers() []CustomValidatorHandler {
return true return true
}}, }},
{
FieldName: "verification_code",
Function: func(fl validator.FieldLevel) bool {
codeType := fl.Param()
code := fl.Field().String()
if codeType == "reg" {
return regexp.MustCompile(`[\d]{6,6}`).MatchString(code)
}
if codeType == "reset" {
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(code)
}
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)
}},
} }
return handlers return handlers

131
dev-compose.yml Normal file
View File

@@ -0,0 +1,131 @@
services:
backend:
build: ./backend
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_healthy
healthcheck:
test: ["CMD", "/usr/bin/curl", "-f", "http://localhost:8080/api/service/health"]
interval: 5s
timeout: 10s
retries: 3
environment:
ENVIRONMENT: "development"
HOSTNAME: ${HOSTNAME}
PORT: ${PORT}
POSTGRES_URL: ${POSTGRES_URL}
REDIS_URL: ${REDIS_URL}
MINIO_URL: ${MINIO_URL}
MINIO_HOST: ${MINIO_HOST}
MINIO_PORT: ${MINIO_PORT}
JWT_ALGORITHM: ${JWT_ALGORITHM}
JWT_SECRET: ${JWT_SECRET}
JWT_ISSUER: ${JWT_ISSUER}
JWT_AUDIENCE: ${JWT_AUDIENCE}
JWT_EXP_ACCESS: ${JWT_EXP_ACCESS}
JWT_EXP_REFRESH: ${JWT_EXP_REFRESH}
SMTP_ENABLED: ${SMTP_ENABLED}
SMTP_SERVER: ${SMTP_SERVER}
SMTP_PORT: ${SMTP_PORT}
SMTP_USER: ${SMTP_USER}
SMTP_PASSWORD: ${SMTP_PASSWORD}
SMTP_FROM: ${SMTP_FROM}
SMTP_USE_TLS: ${SMTP_USE_TLS}
SMTP_USE_SSL: ${SMTP_USE_SSL}
SMTP_TIMEOUT: ${SMTP_TIMEOUT}
PASSWORD_MIN_LENGTH: ${PASSWORD_MIN_LENGTH}
PASSWORD_MAX_LENGTH: ${PASSWORD_MAX_LENGTH}
PASSWORD_CHECK_NUMBER: ${PASSWORD_CHECK_NUMBER}
PASSWORD_CHECK_CHARAC: ${PASSWORD_CHECK_CHARAC}
PASSWORD_CHECK_CASES: ${PASSWORD_CHECK_CASES}
PASSWORD_CHECK_SYMBOL: ${PASSWORD_CHECK_SYMBOL}
PASSWORD_CHECK_LEAKED: ${PASSWORD_CHECK_LEAKED}
ports:
- "8080:8080"
networks:
- easywish-network
frontend:
build: ./frontend
restart: unless-stopped
depends_on:
backend:
condition: service_healthy
networks:
- easywish-network
minio:
image: minio/minio
command: server /data --console-address ":9001" --ftp="address=:8021" --ftp="passive-port-range=30000-40000"
healthcheck:
test: [ "CMD", "curl", "-I", "localhost:9000/minio/health/live" ]
interval: 5s
timeout: 10s
retries: 3
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
ports:
- "9000:9000"
- "9001:9001"
- "8021:8021"
networks:
- easywish-network
volumes:
- minio_data:/data
postgres:
image: postgres:latest
healthcheck:
test: [ "CMD", "pg_isready", "-q", "-d", "${POSTGRES_DB}", "-U", "${POSTGRES_USER}" ]
interval: 5s
timeout: 10s
retries: 3
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
ports:
- "5432:5432"
networks:
- easywish-network
volumes:
- postgres_data:/var/lib/postgresql/data
- ./sqlc/schema.sql:/docker-entrypoint-initdb.d/init.sql
redis:
image: eqalpha/keydb
healthcheck:
test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ]
interval: 5s
timeout: 10s
retries: 3
command: ["keydb-server", "--requirepass", "${REDIS_PASSWORD}"]
environment:
REDIS_PASSWORD: ${REDIS_PASSWORD}
ports:
- "6379:6379"
networks:
- easywish-network
test-mailserver:
image: rnwood/smtp4dev
hostname: easywish.weirdcatland
ports:
- "25:25"
- "5000:80"
networks:
- easywish-network
volumes:
postgres_data:
minio_data:
networks:
easywish-network:
driver: bridge

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
@@ -58,20 +60,64 @@ WHERE linfo.email = @email::text;
;-- name: GetValidUserByLoginCredentials :one ;-- name: GetValidUserByLoginCredentials :one
SELECT SELECT
users.id, users.*,
users.username,
linfo.password_hash, linfo.password_hash,
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
SELECT
COUNT(CASE WHEN users.username = @username::text THEN 1 END) > 0 AS username_busy,
COUNT(CASE WHEN linfo.email = @email::text THEN 1 END) > 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
NOT EXISTS (
SELECT 1
FROM confirmation_codes AS codes
WHERE codes.user_id = users.id
AND codes.code_type = 0
AND codes.deleted IS FALSE
AND codes.expires_at > CURRENT_TIMESTAMP
)
);
;-- name: DeleteUnverifiedAccountsHavingUsernameOrEmail :one
WITH deleted_rows AS (
DELETE FROM users
WHERE
(username = @username::text OR
EXISTS (
SELECT 1
FROM login_informations AS linfo
WHERE linfo.user_id = users.id
AND linfo.email = @email::text
))
AND verified IS FALSE
RETURNING *
)
SELECT COUNT(*) AS deleted_count FROM deleted_rows;
--: }}} --: }}}
--: Banned User Object {{{ --: Banned User Object {{{
@@ -111,13 +157,7 @@ VALUES ( $1, $2, @password_hash::text ) RETURNING *;
UPDATE login_informations UPDATE login_informations
SET SET
email = COALESCE($2, email), email = COALESCE($2, email),
password_hash = COALESCE( password_hash = COALESCE(@password_hash::text, password_hash),
CASE
WHEN @password::text IS NOT NULL
THEN crypt(@password::text, gen_salt('bf'))
END,
password_hash
),
totp_encrypted = COALESCE($4, totp_encrypted), totp_encrypted = COALESCE($4, totp_encrypted),
email_2fa_enabled = COALESCE($5, email_2fa_enabled), email_2fa_enabled = COALESCE($5, email_2fa_enabled),
password_change_date = COALESCE($6, password_change_date) password_change_date = COALESCE($6, password_change_date)
@@ -146,6 +186,15 @@ WHERE
used IS FALSE AND used IS FALSE AND
code_hash = crypt(@code::text, code_hash); code_hash = crypt(@code::text, code_hash);
;-- name: GetValidConfirmationCodesByUsername :many
SELECT * FROM confirmation_codes
JOIN users on users.id = confirmation_codes.user_id
WHERE
users.username = @username::text AND
code_type = @code_type::integer AND
expires_at > CURRENT_TIMESTAMP AND
used IS FALSE;
;-- name: UpdateConfirmationCode :exec ;-- name: UpdateConfirmationCode :exec
UPDATE confirmation_codes UPDATE confirmation_codes
SET SET
@@ -172,19 +221,35 @@ SET
platform = COALESCE($3, platform), platform = COALESCE($3, platform),
latest_ip = COALESCE($4, latest_ip), latest_ip = COALESCE($4, latest_ip),
login_time = COALESCE($5, login_time), login_time = COALESCE($5, login_time),
last_seen_date = COALESCE($6, last_seen_date), last_refresh_exp_time = COALESCE($6, last_refresh_exp_time),
terminated = COALESCE($7, terminated) last_seen_date = COALESCE($7, last_seen_date),
terminated = COALESCE($8, terminated)
WHERE id = $1; WHERE id = $1;
;-- name: GetUserSessions :many ;-- name: GetSessionByGuid :one
SELECT * FROM sessions SELECT * FROM sessions
WHERE user_id = $1 AND terminated IS FALSE; WHERE guid = (@guid::text)::uuid;
;-- name: TerminateAllSessionsForUserByUsername :exec ;-- name: GetValidUserSessions :many
SELECT * FROM sessions
WHERE
user_id = $1 AND terminated IS FALSE AND
last_refresh_exp_time > CURRENT_TIMESTAMP;
-- name: GetUnexpiredTerminatedSessionsGuidsPaginated :many
SELECT guid FROM sessions
WHERE
terminated IS TRUE AND
last_refresh_exp_time > CURRENT_TIMESTAMP
LIMIT @batch_size::integer
OFFSET $2;
;-- name: TerminateAllSessionsForUserByUsername :many
UPDATE sessions UPDATE sessions
SET terminated = TRUE SET terminated = TRUE
FROM users FROM users
WHERE sessions.user_id = users.id AND users.username = @username::text; WHERE sessions.user_id = users.id AND users.username = @username::text
RETURNING sessions.guid;
;-- name: PruneTerminatedSessions :exec ;-- name: PruneTerminatedSessions :exec
DELETE FROM sessions DELETE FROM sessions
@@ -204,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;
@@ -215,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
@@ -265,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
@@ -284,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,8 +22,9 @@ 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
deleted BOOLEAN DEFAULT FALSE deleted BOOLEAN DEFAULT FALSE
); );
@@ -34,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)
); );
@@ -54,18 +55,19 @@ 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(100), name VARCHAR(175),
platform VARCHAR(32), platform VARCHAR(175),
latest_ip VARCHAR(16), latest_ip VARCHAR(16),
login_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, login_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_refresh_exp_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + INTERVAL '10080 seconds',
last_seen_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, last_seen_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
terminated BOOLEAN DEFAULT FALSE terminated BOOLEAN DEFAULT FALSE
); );
@@ -74,21 +76,48 @@ CREATE TABLE IF NOT EXISTS "profiles" (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
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), bio VARCHAR(512) NOT NULL DEFAULT '',
avatar_url VARCHAR(255), avatar_url VARCHAR(512) NOT NULL DEFAULT '',
birthday TIMESTAMP, birthday TIMESTAMP,
color VARCHAR(7), color VARCHAR(7) NOT NULL DEFAULT '#254333',
color_grad VARCHAR(7) color_grad VARCHAR(7) NOT NULL DEFAULT '#691E4D'
); );
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
);