49 Commits

Author SHA1 Message Date
a5f89c2b32 Merge branch 'ml2' into fix-auth_service 2025-08-04 21:22:22 +03:00
ffcbff5294 Merge pull request 'feat: implement wish list and wish features including creation, retrieval, and updates;' (#7) from feat-sql into ml2
Reviewed-on: #7
2025-08-04 21:19:00 +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
52 changed files with 4424 additions and 1053 deletions

View File

@@ -15,17 +15,17 @@
// 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/>.
// @title Easywish client API // @title Easywish client API
// @version 1.0 // @version 1.0
// @description Easy and feature-rich wishlist. // @description Easy and feature-rich wishlist.
// @license.name GPL-3.0 // @license.name GPL-3.0
// @BasePath /api/ // @BasePath /api/
// @Schemes http // @Schemes http
// @securityDefinitions.apikey JWT // @securityDefinitions.apikey JWT
// @in header // @in header
// @name Authorization // @name Authorization
package main package main
@@ -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

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

View File

@@ -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": {
@@ -252,26 +270,6 @@ const docTemplate = `{
} }
}, },
"/profile": { "/profile": {
"patch": {
"security": [
{
"JWT": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Profile"
],
"summary": "Update profile",
"responses": {}
}
},
"/profile/me": {
"get": { "get": {
"security": [ "security": [
{ {
@@ -287,30 +285,17 @@ const docTemplate = `{
"tags": [ "tags": [
"Profile" "Profile"
], ],
"summary": "Get own profile when authorized", "summary": "Get your profile",
"responses": {} "responses": {
} "200": {
}, "description": " ",
"/profile/privacy": { "schema": {
"get": { "$ref": "#/definitions/dto.ProfileDto"
"security": [ }
{
"JWT": []
} }
], }
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Profile"
],
"summary": "Get profile privacy settings",
"responses": {}
}, },
"patch": { "put": {
"security": [ "security": [
{ {
"JWT": [] "JWT": []
@@ -325,8 +310,89 @@ const docTemplate = `{
"tags": [ "tags": [
"Profile" "Profile"
], ],
"summary": "Update profile privacy settings", "summary": "Update your profile",
"responses": {} "parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.NewProfileDto"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"type": "boolean"
}
}
}
}
},
"/profile/settings": {
"get": {
"security": [
{
"JWT": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Profile"
],
"summary": "Get your profile settings",
"responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/dto.ProfileSettingsDto"
}
}
}
},
"put": {
"security": [
{
"JWT": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Profile"
],
"summary": "Update your profile's settings",
"parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.ProfileSettingsDto"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"type": "boolean"
}
}
}
} }
}, },
"/profile/{username}": { "/profile/{username}": {
@@ -345,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": {
@@ -375,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"
} }
} }
} }
@@ -383,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": {
@@ -445,7 +661,7 @@ const docTemplate = `{
"email": { "email": {
"type": "string" "type": "string"
}, },
"log_out_accounts": { "log_out_sessions": {
"type": "boolean" "type": "boolean"
}, },
"password": { "password": {
@@ -467,6 +683,20 @@ const docTemplate = `{
} }
} }
}, },
"models.PresignedUploadResponse": {
"type": "object",
"properties": {
"fields": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"url": {
"type": "string"
}
}
},
"models.RefreshRequest": { "models.RefreshRequest": {
"type": "object", "type": "object",
"required": [ "required": [
@@ -474,7 +704,8 @@ const docTemplate = `{
], ],
"properties": { "properties": {
"refresh_token": { "refresh_token": {
"type": "string" "type": "string",
"maxLength": 2000
} }
} }
}, },

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": {
@@ -248,26 +266,6 @@
} }
}, },
"/profile": { "/profile": {
"patch": {
"security": [
{
"JWT": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Profile"
],
"summary": "Update profile",
"responses": {}
}
},
"/profile/me": {
"get": { "get": {
"security": [ "security": [
{ {
@@ -283,30 +281,17 @@
"tags": [ "tags": [
"Profile" "Profile"
], ],
"summary": "Get own profile when authorized", "summary": "Get your profile",
"responses": {} "responses": {
} "200": {
}, "description": " ",
"/profile/privacy": { "schema": {
"get": { "$ref": "#/definitions/dto.ProfileDto"
"security": [ }
{
"JWT": []
} }
], }
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Profile"
],
"summary": "Get profile privacy settings",
"responses": {}
}, },
"patch": { "put": {
"security": [ "security": [
{ {
"JWT": [] "JWT": []
@@ -321,8 +306,89 @@
"tags": [ "tags": [
"Profile" "Profile"
], ],
"summary": "Update profile privacy settings", "summary": "Update your profile",
"responses": {} "parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.NewProfileDto"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"type": "boolean"
}
}
}
}
},
"/profile/settings": {
"get": {
"security": [
{
"JWT": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Profile"
],
"summary": "Get your profile settings",
"responses": {
"200": {
"description": " ",
"schema": {
"$ref": "#/definitions/dto.ProfileSettingsDto"
}
}
}
},
"put": {
"security": [
{
"JWT": []
}
],
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Profile"
],
"summary": "Update your profile's settings",
"parameters": [
{
"description": " ",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/dto.ProfileSettingsDto"
}
}
],
"responses": {
"200": {
"description": " ",
"schema": {
"type": "boolean"
}
}
}
} }
}, },
"/profile/{username}": { "/profile/{username}": {
@@ -341,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": {
@@ -371,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"
} }
} }
} }
@@ -379,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": {
@@ -441,7 +657,7 @@
"email": { "email": {
"type": "string" "type": "string"
}, },
"log_out_accounts": { "log_out_sessions": {
"type": "boolean" "type": "boolean"
}, },
"password": { "password": {
@@ -463,6 +679,20 @@
} }
} }
}, },
"models.PresignedUploadResponse": {
"type": "object",
"properties": {
"fields": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"url": {
"type": "string"
}
}
},
"models.RefreshRequest": { "models.RefreshRequest": {
"type": "object", "type": "object",
"required": [ "required": [
@@ -470,7 +700,8 @@
], ],
"properties": { "properties": {
"refresh_token": { "refresh_token": {
"type": "string" "type": "string",
"maxLength": 2000
} }
} }
}, },

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
@@ -38,7 +99,7 @@ definitions:
properties: properties:
email: email:
type: string type: string
log_out_accounts: log_out_sessions:
type: boolean type: boolean
password: password:
type: string type: string
@@ -56,9 +117,19 @@ definitions:
refresh_token: refresh_token:
type: string type: string
type: object type: object
models.PresignedUploadResponse:
properties:
fields:
additionalProperties:
type: string
type: object
url:
type: string
type: object
models.RefreshRequest: models.RefreshRequest:
properties: properties:
refresh_token: refresh_token:
maxLength: 2000
type: string type: string
required: required:
- refresh_token - refresh_token
@@ -113,18 +184,29 @@ 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:
@@ -262,15 +344,41 @@ paths:
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}:
@@ -278,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:
@@ -337,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,42 @@ 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/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,35 @@ 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/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 +152,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 +175,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 +186,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 +203,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 +214,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 +229,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,114 +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) log *zap.Logger
Login(c *gin.Context)
Refresh(c *gin.Context)
PasswordResetBegin(c *gin.Context)
PasswordResetComplete(c *gin.Context)
Router
} }
type authControllerImpl struct { func NewAuthController(log *zap.Logger, auth services.AuthService) Controller {
authService services.AuthService ctrl := &AuthController{auth: auth, log: log}
log *zap.Logger
} return &controllerImpl{
Path: "/auth",
func NewAuthController(_log *zap.Logger, as services.AuthService) AuthController { Middleware: []gin.HandlerFunc{},
return &authControllerImpl{log: _log, authService: as} Methods: []ControllerMethod{
} {
HttpMethod: POST,
// @Summary Acquire tokens via login credentials (and 2FA code if needed) Path: "/registrationBegin",
// @Tags Auth Authorization: enums.GuestRole,
// @Accept json Middleware: []gin.HandlerFunc{},
// @Produce json Function: ctrl.registrationBeginHandler,
// @Param request body models.LoginRequest true " " },
// @Success 200 {object} models.LoginResponse " " {
// @Failure 403 "Invalid login credentials" HttpMethod: POST,
// @Router /auth/login [post] Path: "/registrationComplete",
func (a *authControllerImpl) Login(c *gin.Context) { Authorization: enums.GuestRole,
request, ok := utils.GetRequest[models.LoginRequest](c) Middleware: []gin.HandlerFunc{},
if !ok { Function: ctrl.registrationCompleteHandler,
c.Status(http.StatusBadRequest) },
return {
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) // @Summary Register an account
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body models.RegistrationBeginRequest true " "
// @Success 200 "Account is created and awaiting verification"
// @Failure 409 "Username or email is already taken"
// @Failure 429 "Too many recent registration attempts for this email"
// @Router /auth/registrationBegin [post]
func (ctrl *AuthController) registrationBeginHandler(c *gin.Context) {
request, err := GetRequest[models.RegistrationBeginRequest](c)
if err != nil { if err != nil {
if errors.Is(err, errs.ErrForbidden) {
c.Status(http.StatusForbidden)
} else {
c.Status(http.StatusInternalServerError)
}
return return
} }
c.JSON(http.StatusOK, response) _, err = ctrl.auth.RegistrationBegin(request.Body)
return
}
// @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 (a *authControllerImpl) PasswordResetBegin(c *gin.Context) {
c.Status(http.StatusNotImplemented)
}
// @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 (a *authControllerImpl) PasswordResetComplete(c *gin.Context) {
c.Status(http.StatusNotImplemented)
}
// @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 (a *authControllerImpl) Refresh(c *gin.Context) {
c.Status(http.StatusNotImplemented)
}
// @Summary Register an account
// @Tags Auth
// @Accept json
// @Produce json
// @Param request body models.RegistrationBeginRequest true " "
// @Success 200 "Account is created and awaiting verification"
// @Failure 409 "Username or email is already taken"
// @Failure 429 "Too many recent registration attempts for this email"
// @Router /auth/registrationBegin [post]
func (a *authControllerImpl) RegistrationBegin(c *gin.Context) {
request, ok := utils.GetRequest[models.RegistrationBeginRequest](c)
if !ok {
c.Status(http.StatusBadRequest)
return
}
_, err := a.authService.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)
} }
@@ -146,26 +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 " " // @Param request body models.RegistrationCompleteRequest true " "
// @Success 200 {object} models.RegistrationCompleteResponse " " // @Success 200 {object} models.RegistrationCompleteResponse " "
// @Failure 403 "Invalid email or verification code" // @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)
@@ -176,15 +150,150 @@ func (a *authControllerImpl) RegistrationComplete(c *gin.Context) {
} }
return return
} }
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.PasswordResetBeginRequest](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

@@ -1,94 +1,197 @@
// Copyright (c) 2025 Nikolai Papin // Copyright (c) 2025 Nikolai Papin
// //
// This file is part of Easywish // This file is part of Easywish
// //
// This program is free software: you can redistribute it and/or modify // This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by // it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or // the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version. // (at your option) any later version.
// //
// This program is distributed in the hope that it will be useful, // This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of // but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details. // the GNU General Public License for more details.
// //
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
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,
},
},
}
}
// @Summary Get your profile
// @Tags Profile
// @Accept json
// @Produce json
// @Security JWT
// @Success 200 {object} dto.ProfileDto " "
// @Router /profile [get]
func (ctrl *ProfileController) getMyProfile(c *gin.Context) {
cinfo := GetClientInfo(c)
response, err := ctrl.ps.GetMyProfile(cinfo); if err != nil {
c.Status(http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, response)
} }
func NewProfileController() ProfileController { // @Summary Get profile by username
return &profileControllerImpl{} // @Tags Profile
// @Accept json
// @Produce json
// @Security JWT
// @Param username path string true " "
// @Success 200 {object} dto.ProfileDto " "
// @Failure 404 "Profile not found"
// @Failure 403 "Restricted profile"
// @Router /profile/{username} [get]
func (ctrl *ProfileController) getProfileByUsername(c *gin.Context) {
cinfo := GetClientInfo(c)
username := c.Param("username"); if username == "" {
c.Status(http.StatusBadRequest)
return
}
response, err := ctrl.ps.GetProfileByUsername(cinfo, username); if err != nil {
if errors.Is(err, errs.ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "Profile not found"})
} else if errors.Is(err, errs.ErrForbidden) {
c.JSON(http.StatusForbidden, gin.H{"error": "Access restricted by profile's privacy settings"})
} else {
c.Status(http.StatusInternalServerError)
}
return
}
c.JSON(http.StatusOK, response)
} }
// @Summary Get someone's profile details // @Summary Get your profile settings
// @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.ProfileSettingsDto " "
// @Router /profile/{username} [get] // @Router /profile/settings [get]
func (p *profileControllerImpl) GetProfile(c *gin.Context) { func (ctrl *ProfileController) getProfileSettings(c *gin.Context) {
c.Status(http.StatusNotImplemented) cinfo := GetClientInfo(c)
response, err := ctrl.ps.GetProfileSettings(cinfo); if err != nil {
c.Status(http.StatusInternalServerError)
return
}
c.JSON(http.StatusOK, response)
} }
// @Summary Get own profile when authorized // @Summary Update your profile
// @Tags Profile // @Tags Profile
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Security JWT // @Security JWT
// @Router /profile/me [get] // @Param request body dto.NewProfileDto true " "
func (p *profileControllerImpl) GetOwnProfile(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 Update profile // @Summary Update your profile's settings
// @Tags Profile // @Tags Profile
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Security JWT // @Security JWT
// @Router /profile [patch] // @Param request body dto.ProfileSettingsDto true " "
func (p *profileControllerImpl) UpdateProfile(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 Get 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 [get]
func (p *profileControllerImpl) GetPrivacySettings(c *gin.Context) {
c.Status(http.StatusNotImplemented)
}
// @Summary Update profile privacy settings c.JSON(http.StatusOK, response)
// @Tags Profile
// @Accept json
// @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) {
} }

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

@@ -1,56 +1,60 @@
// Copyright (c) 2025 Nikolai Papin // Copyright (c) 2025 Nikolai Papin
// //
// This file is part of Easywish // This file is part of Easywish
// //
// This program is free software: you can redistribute it and/or modify // This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by // it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or // the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version. // (at your option) any later version.
// //
// This program is distributed in the hope that it will be useful, // This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of // but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details. // the GNU General Public License for more details.
// //
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
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{} // @Summary Get health status
// @Description Used internally for checking service health
func NewServiceController() ServiceController { // @Tags Service
return &serviceControllerImpl{} // @Accept json
} // @Produce json
// @Success 200 {object} models.HealthStatusResponse "Says whether it's healthy or not"
// HealthCheck implements ServiceController. // @Router /service/health [get]
// @Summary Get health status func (ctrl *ServiceController) healthHandler(c *gin.Context) {
// @Description Used internally for checking service health c.JSON(http.StatusOK, models.HealthStatusResponse{Healthy: true,})
// @Tags Service
// @Accept json
// @Produce json
// @Success 200 {object} HealthStatus "Says whether it's healthy or not"
// @Router /service/health [get]
func (s *serviceControllerImpl) HealthCheck(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"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

@@ -1,30 +1,74 @@
// Copyright (c) 2025 Nikolai Papin // Copyright (c) 2025 Nikolai Papin
// //
// This file is part of Easywish // This file is part of Easywish
// //
// This program is free software: you can redistribute it and/or modify // This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by // it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or // the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version. // (at your option) any later version.
// //
// This program is distributed in the hope that it will be useful, // This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of // but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details. // the GNU General Public License for more details.
// //
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
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

@@ -1,17 +1,17 @@
// Copyright (c) 2025 Nikolai Papin // Copyright (c) 2025 Nikolai Papin
// //
// This file is part of Easywish // This file is part of Easywish
// //
// This program is free software: you can redistribute it and/or modify // This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by // it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or // the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version. // (at your option) any later version.
// //
// This program is distributed in the hope that it will be useful, // This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of // but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details. // the GNU General Public License for more details.
// //
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
@@ -26,18 +26,20 @@ 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 {
CTX context.Context CTX context.Context
Queries Queries Queries Queries
} }
type dbHelperTransactionImpl struct { type dbHelperTransactionImpl struct {
CTX context.Context CTX context.Context
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 {
@@ -64,10 +66,10 @@ func NewDbHelperTransaction(dbContext DbContext) (DbHelperTransaction, *dbHelper
txQueries := queries.WithTx(tx) txQueries := queries.WithTx(tx)
obj := &dbHelperTransactionImpl{ obj := &dbHelperTransactionImpl{
CTX: ctx, CTX: ctx,
TXlessQueries: *queries, TXlessQueries: *queries,
TX: tx, TX: tx,
TXQueries: *txQueries, TXQueries: *txQueries,
} }
return obj, obj, nil return obj, obj, nil
@@ -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 nil
return errCommit
} }
// 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,41 +43,69 @@ 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 {
ID int64 ID int64
UserID int64 UserID int64
Guid pgtype.UUID Guid pgtype.UUID
Name *string Name *string
Platform *string Platform *string
LatestIp *string LatestIp *string
LoginTime pgtype.Timestamp LoginTime pgtype.Timestamp
LastSeenDate pgtype.Timestamp LastRefreshExpTime pgtype.Timestamp
Terminated *bool LastSeenDate pgtype.Timestamp
Terminated *bool
} }
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
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

@@ -1,34 +1,32 @@
// Copyright (c) 2025 Nikolai Papin // Copyright (c) 2025 Nikolai Papin
// //
// This file is part of Easywish // This file is part of Easywish
// //
// This program is free software: you can redistribute it and/or modify // This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by // it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or // the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version. // (at your option) any later version.
// //
// This program is distributed in the hope that it will be useful, // This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of // but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details. // the GNU General Public License for more details.
// //
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
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

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

@@ -1,29 +1,24 @@
// Copyright (c) 2025 Nikolai Papin // Copyright (c) 2025 Nikolai Papin
// //
// This file is part of Easywish // This file is part of Easywish
// //
// This program is free software: you can redistribute it and/or modify // This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by // it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or // the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version. // (at your option) any later version.
// //
// This program is distributed in the hope that it will be useful, // This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of // but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details. // the GNU General Public License for more details.
// //
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
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,6 @@ 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")
) )

View File

@@ -1,26 +1,27 @@
// Copyright (c) 2025 Nikolai Papin // Copyright (c) 2025 Nikolai Papin
// //
// This file is part of Easywish // This file is part of Easywish
// //
// This program is free software: you can redistribute it and/or modify // This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by // it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or // the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version. // (at your option) any later version.
// //
// This program is distributed in the hope that it will be useful, // This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of // but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details. // the GNU General Public License for more details.
// //
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
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

@@ -1,35 +1,37 @@
// Copyright (c) 2025 Nikolai Papin // Copyright (c) 2025 Nikolai Papin
// //
// This file is part of Easywish // This file is part of Easywish
// //
// This program is free software: you can redistribute it and/or modify // This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by // it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or // the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version. // (at your option) any later version.
// //
// This program is distributed in the hope that it will be useful, // This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of // but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details. // the GNU General Public License for more details.
// //
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
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 func(c *gin.Context) {
return nil, nil, err if !limiter.Allow() {
c.AbortWithStatus(http.StatusTooManyRequests)
return
}
c.Next()
} }
return conn, ctx, nil
} }

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

@@ -32,7 +32,7 @@ type RegistrationCompleteRequest struct {
Username string `json:"username" binding:"required" validate:"username"` Username string `json:"username" binding:"required" validate:"username"`
VerificationCode string `json:"verification_code" binding:"required" validate:"verification_code=reg"` VerificationCode string `json:"verification_code" binding:"required" validate:"verification_code=reg"`
Name string `json:"name" binding:"required" validate:"name"` Name string `json:"name" binding:"required" validate:"name"`
Birthday *string `json:"birthday"` Birthday int64 `json:"birthday" validate:"birthday_unix_milli"`
} }
type RegistrationCompleteResponse struct { type RegistrationCompleteResponse struct {
@@ -49,9 +49,8 @@ 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 {
@@ -66,9 +65,19 @@ type PasswordResetCompleteRequest struct {
Email string `json:"email" binding:"required,email"` Email string `json:"email" binding:"required,email"`
VerificationCode string `json:"verification_code" binding:"required" validate:"verification_code=reset"` VerificationCode string `json:"verification_code" binding:"required" validate:"verification_code=reset"`
NewPassword string `json:"password" binding:"required" validate:"password"` NewPassword string `json:"password" binding:"required" validate:"password"`
LogOutAccounts bool `json:"log_out_accounts"` LogOutSessions bool `json:"log_out_sessions"`
} }
type PasswordResetCompleteResponse struct { type PasswordResetCompleteResponse struct {
Tokens 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,
},
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,224 @@
// 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.GetProfileByUsernameWithPrivacy(db.CTX, database.GetProfileByUsernameWithPrivacyParams{
Requester: cinfo.Username,
SearchedUsername: 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
}
if !*profileRow.AccessAllowed {
return nil, errs.ErrForbidden
}
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 NewUploadService(_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,9 @@ import (
var Module = fx.Module("services", var Module = fx.Module("services",
fx.Provide( fx.Provide(
NewUploadService,
NewSmtpService, NewSmtpService,
NewAuthService, NewAuthService,
NewProfileService,
), ),
) )

View File

@@ -1,35 +1,29 @@
// Copyright (c) 2025 Nikolai Papin // Copyright (c) 2025 Nikolai Papin
// //
// This file is part of Easywish // This file is part of Easywish
// //
// This program is free software: you can redistribute it and/or modify // This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by // it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or // the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version. // (at your option) any later version.
// //
// This program is distributed in the hope that it will be useful, // This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of // but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details. // the GNU General Public License for more details.
// //
// You should have received a copy of the GNU General Public License // You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
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 false
return &request, true
} }

View File

@@ -25,22 +25,24 @@ 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(),
} }
accessToken, err = jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString([]byte(cfg.JwtSecret)) accessToken, err = jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString([]byte(cfg.JwtSecret))
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(),
} }
refreshToken, err = jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString([]byte(cfg.JwtSecret)) refreshToken, err = jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString([]byte(cfg.JwtSecret))

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,45 @@
// Copyright (c) 2025 Nikolai Papin
//
// This file is part of Easywish
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
// the GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package utils
import (
"easywish/config"
"fmt"
"net/url"
)
// TODO: Move this method to s3 service
func LocalizeS3Url(originalURL string) (*url.URL, error) {
cfg := config.GetConfig()
newDomain := fmt.Sprintf("%s:%d", cfg.Hostname, cfg.Port)
parsedURL, err := url.Parse(originalURL)
if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
}
newURL := url.URL{
Scheme: parsedURL.Scheme,
Host: newDomain,
Path: "/s3" + parsedURL.Path,
RawQuery: parsedURL.RawQuery,
}
return &newURL, nil
}

View File

@@ -21,6 +21,7 @@ import (
"easywish/config" "easywish/config"
"fmt" "fmt"
"regexp" "regexp"
"time"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
) )
@@ -48,6 +49,36 @@ func GetCustomHandlers() []CustomValidatorHandler {
return regexp.MustCompile(`^.{1,75}$`).MatchString(username) return regexp.MustCompile(`^.{1,75}$`).MatchString(username)
}}, }},
{
FieldName: "bio",
Function: func(fl validator.FieldLevel) bool {
username := fl.Field().String()
return regexp.MustCompile(`^.{1,512}$`).MatchString(username)
}},
{
FieldName: "birthday_unix_milli",
Function: func(fl validator.FieldLevel) bool {
timestamp := fl.Field().Int()
date := time.UnixMilli(timestamp)
currentDate := time.Now()
age := currentDate.Year() - date.Year()
if currentDate.YearDay() < date.YearDay() {
age--
}
return age >= 0 && age <= 122
}},
{
FieldName: "color_hex",
Function: func(fl validator.FieldLevel) bool {
username := fl.Field().String()
return regexp.MustCompile(`^#[0-9a-f]{6,6}$`).MatchString(username)
}},
{ {
FieldName: "password", FieldName: "password",
Function: func(fl validator.FieldLevel) bool { Function: func(fl validator.FieldLevel) bool {
@@ -96,6 +127,20 @@ func GetCustomHandlers() []CustomValidatorHandler {
panic(fmt.Sprintf("'%s' is not a valid verification code type", codeType)) panic(fmt.Sprintf("'%s' is not a valid verification code type", codeType))
}}, }},
{
FieldName: "upload_id",
Function: func(fl validator.FieldLevel) bool {
uploadType := fl.Param()
uploadID := fl.Field().String()
pattern := fmt.Sprintf(
"^%s-([{(]?([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})[})]?)$",
uploadType,
)
return regexp.MustCompile(pattern).MatchString(uploadID)
}},
} }

View File

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

View File

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

View File

@@ -32,7 +32,9 @@ WHERE id = $1;
;-- name: UpdateUserByUsername :exec ;-- name: UpdateUserByUsername :exec
UPDATE users UPDATE users
SET verified = $2, deleted = $3 SET
verified = COALESCE($2, verified),
deleted = COALESCE($3, deleted)
WHERE username = $1; WHERE username = $1;
;-- name: DeleteUser :exec ;-- name: DeleteUser :exec
@@ -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,35 @@ 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: GetProfileByUsernameWithPrivacy :one
SELECT SELECT
users.username, u.username,
profiles.name, p.name,
CASE p.bio,
WHEN profile_settings.hide_birthday OR profile_settings.hide_profile_details THEN NULL p.avatar_url,
ELSE profiles.birthday CASE WHEN ps.hide_birthday THEN NULL ELSE p.birthday END AS birthday,
END AS birthday, p.color,
CASE p.color_grad,
WHEN profile_settings.hide_profile_details THEN NULL NOT (@requester::text = '' AND ps.hide_for_unauthenticated) AS access_allowed
ELSE profiles.bio FROM
END AS bio, users AS u
CASE JOIN profiles AS p ON u.id = p.user_id
WHEN profile_settings.hide_profile_details THEN NULL JOIN profile_settings AS ps ON p.id = ps.profile_id
ELSE profiles.avatar_url WHERE
END AS avatar_url, u.username = @searched_username::text
profiles.color, AND (
profiles.color_grad, @searched_username::text = @requester::text
profile_settings.hide_profile_details OR
FROM profiles u.deleted IS FALSE
JOIN users ON users.id = profiles.user_id AND u.verified IS TRUE
JOIN profile_settings ON profiles.id = profile_settings.profile_id AND NOT EXISTS (
WHERE users.username = $1 AND ($2 IS FALSE OR profile_settings.hide_for_unauthenticated IS FALSE); SELECT 1
FROM banned_users
WHERE user_id = u.id AND
pardoned IS FALSE AND
(expires_at IS NULL OR expires_at < CURRENT_TIMESTAMP)
)
);
;-- name: GetProfilesRestricted :many ;-- name: GetProfilesRestricted :many
SELECT SELECT
@@ -265,17 +336,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 +357,82 @@ 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::boolean
)
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: GetWishlistsByUsernameWithPrivacy :many
SELECT
wl.*,
CASE
WHEN (ps.hide_profile_details OR ps.hide_for_unauthenticated) 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: 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;
--: }}}

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,47 @@ 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),
color VARCHAR(7),
color_grad VARCHAR(7),
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,
name VARCHAR(32) 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
);